From b5023ae9354ed55e255d57efe5a2bbe5e978b1b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 12:41:20 +0200 Subject: [PATCH] feat(coach): keep looking after the round cap When the tool loop forces an answer at its round cap, the reply leads its chips with "Keep looking". Tapping it runs a normal turn, under the budget gate as ever, with two server-written context lines: the question that was left unfinished (at most 1000 characters) and what the forced turn already read, each table by the name show_result takes, so the continued turn shows those again instead of fetching them twice. An answer is continued at most once. The continued reply records the message it continues in metricSource.continuationOf and offers no further "Keep looking", even when it is forced again; a chip on a reply that is itself a continuation is refused. --- docs/api/openapi.yaml | 5 + .../__tests__/persistence-results.test.ts | 4 +- .../follow-ups/__tests__/continue.test.ts | 285 ++++++++++++++++++ src/lib/ai/coach/follow-ups/continue.ts | 222 +++++++++++++- src/lib/ai/coach/persistence.ts | 7 + src/lib/ai/coach/stream-events.ts | 7 + .../turn/__tests__/continue-turn.test.ts | 243 +++++++++++++++ src/lib/ai/coach/turn/pipeline.ts | 12 + src/lib/ai/coach/turn/provenance.ts | 11 +- src/lib/ai/coach/types.ts | 6 + 10 files changed, 796 insertions(+), 6 deletions(-) create mode 100644 src/lib/ai/coach/follow-ups/__tests__/continue.test.ts create mode 100644 src/lib/ai/coach/turn/__tests__/continue-turn.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fa365179b..a430dcac8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -50399,6 +50399,11 @@ components: description: v1.39.4 — the answer was forced at the round cap while the Coach still wanted to read. Absent otherwise. type: boolean const: true + continuationOf: + description: "v1.39.4 — this reply continues an answer that was forced at the round cap (the `continue` chip): the id of + that earlier assistant message. A continuation offers no further `continue` chip. Absent otherwise." + type: string + maxLength: 64 required: - windows - metrics diff --git a/src/lib/ai/coach/__tests__/persistence-results.test.ts b/src/lib/ai/coach/__tests__/persistence-results.test.ts index bdbc97589..ae1d2a582 100644 --- a/src/lib/ai/coach/__tests__/persistence-results.test.ts +++ b/src/lib/ai/coach/__tests__/persistence-results.test.ts @@ -138,6 +138,7 @@ const FULL: CoachProvenance = { freeText: true, }, forcedFinal: true, + continuationOf: "m0", }; function stubEchoCreate(overrideJson?: string): void { @@ -161,7 +162,7 @@ beforeEach(() => { }); describe("appendMessage — the dialog fields", () => { - it("restores steps, method, table metadata, chips, clarification and the forced marker", async () => { + it("restores steps, method, table metadata, chips, clarification, the forced marker and the continuation", async () => { stubEchoCreate(); const out = await appendMessage({ conversationId: "c1", @@ -183,6 +184,7 @@ describe("appendMessage — the dialog fields", () => { method: { entries: "nope", text: "x" }, clarification: { kind: "metric", choices: [], freeText: "yes" }, forcedFinal: "true", + continuationOf: 42, }), ); const out = await appendMessage({ diff --git a/src/lib/ai/coach/follow-ups/__tests__/continue.test.ts b/src/lib/ai/coach/follow-ups/__tests__/continue.test.ts new file mode 100644 index 000000000..05ef2b226 --- /dev/null +++ b/src/lib/ai/coach/follow-ups/__tests__/continue.test.ts @@ -0,0 +1,285 @@ +/** + * v1.39.4 — "keep looking": a chip only when the loop forced the answer, + * the continued turn told what the question was and what was already read + * (by the names `show_result` takes), and never a second continuation. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const findMany = vi.fn(); +const annotate = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { coachMessage: { findMany: (...a: unknown[]) => findMany(...a) } }, +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: (...a: unknown[]) => annotate(...a), +})); +// The stored body is plain text here; the codec is exercised elsewhere. +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: (bytes: Uint8Array) => new TextDecoder().decode(bytes), +})); + +import type { CoachFollowUp, CoachStep } from "@/lib/ai/coach/types"; +import type { PriorResultTurn } from "@/lib/ai/coach/results/refs"; + +import { + CONTINUE_QUESTION_MAX_CHARS, + buildContinueFollowUp, + continuationHint, + resolveContinuation, +} from "../continue"; + +const CONTINUE_CHIP: CoachFollowUp = { + id: "f1", + kind: "continue", + labelKey: "coach.followUp.continue", + label: "Keep looking", + reuse: false, + origin: "server", +}; + +const STEPS: CoachStep[] = [ + { + id: "s1", + tool: "get_metric_table", + labelKey: "coach.step.readWindow", + label: "x", + domain: "bp", + window: "last90days", + granularity: "week", + period: "current", + status: "done", + count: 142, + resultRef: "r1", + }, + { + id: "s2", + tool: "get_sleep", + labelKey: "coach.step.readWindow", + label: "x", + domain: "sleep", + window: "last30days", + status: "empty", + reason: "no_data", + }, + { + id: "s3", + tool: "get_labs", + labelKey: "coach.step.read", + label: "x", + domain: "labs", + status: "failed", + reason: "retrieval_failed", + }, + { + id: "s4", + tool: "get_metric_series", + labelKey: "coach.step.readWindow", + label: "x", + domain: "weight", + window: "last30days", + status: "running", + }, +]; + +const PRIOR: PriorResultTurn[] = [ + { + messageId: "m-forced", + turnIndex: 3, + results: [ + { + ref: "r1", + source: { + tool: "get_metric_table", + domain: "bp", + window: "last90days", + period: "current", + granularity: "week", + }, + shape: "timeSeries", + titleKey: "k", + title: "t", + rowCount: 13, + chartKind: null, + displayed: true, + }, + ], + }, +]; + +const bytes = (text: string) => new TextEncoder().encode(text); + +function forcedReply(extra: Record = {}) { + return { + id: "m-forced", + role: "assistant", + providerType: "openai", + encryptedContent: bytes("Partial answer."), + metricSourceJson: JSON.stringify({ + windows: [], + metrics: [], + steps: STEPS, + followUps: [CONTINUE_CHIP], + forcedFinal: true, + ...extra, + }), + }; +} + +function question( + text = "How did my blood pressure and sleep go this quarter?", +) { + return { + id: "m-question", + role: "user", + providerType: null, + encryptedContent: bytes(text), + metricSourceJson: null, + }; +} + +const TAP = { messageId: "m-forced", id: "f1" }; + +beforeEach(() => { + findMany.mockReset(); + annotate.mockReset(); +}); + +describe("buildContinueFollowUp", () => { + it("offers the chip only when the answer was forced", () => { + expect( + buildContinueFollowUp({ forcedFinal: false, locale: "en" }), + ).toBeNull(); + expect(buildContinueFollowUp({ forcedFinal: true, locale: "en" })).toEqual( + CONTINUE_CHIP, + ); + expect( + buildContinueFollowUp({ forcedFinal: true, locale: "de" })?.label, + ).not.toBe("Keep looking"); + }); + + it("offers no second continuation", () => { + expect( + buildContinueFollowUp({ + forcedFinal: true, + continuationOf: "m-forced", + locale: "en", + }), + ).toBeNull(); + }); +}); + +describe("resolveContinuation", () => { + it("names the question and what was already read, tables by their show_result name", async () => { + findMany.mockResolvedValue([forcedReply(), question()]); + const out = await resolveContinuation({ + userId: "u1", + conversationId: "c1", + followUp: TAP, + priorResults: PRIOR, + }); + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { conversationId: "c1", conversation: { userId: "u1" } }, + }), + ); + expect(out).toEqual({ + sourceMessageId: "m-forced", + contextHint: + 'CONTINUE: the person asked you to keep looking. Unfinished answer for: "How did my blood pressure and sleep go this quarter?". Already fetched: get_metric_table(bp, last90days, week) → m3.r1; get_sleep(sleep, last30days) → no readings. Use show_result for these tables, do not fetch them again. Fetch only what is still missing, then answer the question in full.', + }); + }); + + it("refuses a second continuation", async () => { + findMany.mockResolvedValue([ + forcedReply({ continuationOf: "m-earlier" }), + question(), + ]); + expect( + await resolveContinuation({ + userId: "u1", + conversationId: "c1", + followUp: TAP, + priorResults: PRIOR, + }), + ).toBeNull(); + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + action: { name: "coach.followUp.continue_refused" }, + }), + ); + }); + + it("is null for a chip that is not current or not a continue chip", async () => { + findMany.mockResolvedValue([ + { ...forcedReply(), id: "m-newer" }, + question(), + ]); + expect( + await resolveContinuation({ + userId: "u1", + conversationId: "c1", + followUp: TAP, + priorResults: PRIOR, + }), + ).toBeNull(); + + findMany.mockResolvedValue([ + forcedReply({ + followUps: [{ ...CONTINUE_CHIP, kind: "previous_period" }], + }), + question(), + ]); + expect( + await resolveContinuation({ + userId: "u1", + conversationId: "c1", + followUp: TAP, + priorResults: PRIOR, + }), + ).toBeNull(); + }); + + it("reads nothing without a chip", async () => { + expect( + await resolveContinuation({ + userId: "u1", + conversationId: "c1", + followUp: undefined, + priorResults: [], + }), + ).toBeNull(); + expect(findMany).not.toHaveBeenCalled(); + }); + + it("looks past an interrupted turn's marker", async () => { + findMany.mockResolvedValue([ + { + id: "m-cancel", + role: "assistant", + providerType: "cancelled", + encryptedContent: bytes(""), + metricSourceJson: null, + }, + forcedReply(), + question(), + ]); + const out = await resolveContinuation({ + userId: "u1", + conversationId: "c1", + followUp: TAP, + priorResults: PRIOR, + }); + expect(out?.sourceMessageId).toBe("m-forced"); + }); +}); + +describe("continuationHint", () => { + it("caps the question and keeps it on one line", () => { + const long = `line one\nline two ${"x".repeat(2000)}`; + const hint = continuationHint({ question: long, fetched: [] }); + const quoted = hint.slice(hint.indexOf('"') + 1, hint.lastIndexOf('"')); + expect([...quoted]).toHaveLength(CONTINUE_QUESTION_MAX_CHARS); + expect(quoted).not.toContain("\n"); + expect(hint).not.toContain("Already fetched"); + }); +}); diff --git a/src/lib/ai/coach/follow-ups/continue.ts b/src/lib/ai/coach/follow-ups/continue.ts index 34d90ebef..43f49bb04 100644 --- a/src/lib/ai/coach/follow-ups/continue.ts +++ b/src/lib/ai/coach/follow-ups/continue.ts @@ -2,14 +2,228 @@ * "Keep looking": when the loop forced an answer at its round cap, offer a * chip that continues the question with what was already fetched. * - * Not built yet: no chip. + * The chip leads the reply's chips. Tapping it runs a normal model turn + * (budget, capability and rate limit as ever) with two server-written + * context lines: the question that was left unfinished, and what the forced + * turn already read, each table by the name `show_result` takes, so the + * continued turn shows those again instead of fetching them a second time. + * + * An answer is continued at most once. The continued reply records the + * message it continues (`metricSource.continuationOf`) and offers no further + * "keep looking" chip, even if it is forced again; a tapped chip on a reply + * that is itself a continuation is refused. */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; import type { Locale } from "@/lib/i18n/config"; -import type { CoachFollowUp } from "@/lib/ai/coach/types"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import type { CoachFollowUp, CoachStep } from "@/lib/ai/coach/types"; +import { + coachFollowUpSchema, + coachStepSchema, +} from "@/lib/ai/coach/stream-events"; +import { COACH_FOLLOW_UP_KEYS } from "@/lib/ai/coach/dialog-keys"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { + formatPriorResultRef, + type PriorResultTurn, +} from "@/lib/ai/coach/results/refs"; -export function buildContinueFollowUp(_args: { +/** The unfinished question rides the context capped at this many characters. */ +export const CONTINUE_QUESTION_MAX_CHARS = 1000; + +/** + * The "keep looking" chip for a reply, or null: only when the loop forced + * the answer, and never on a reply that already continues another. + */ +export function buildContinueFollowUp(args: { forcedFinal: boolean; + /** Set when this reply continues an earlier forced answer. */ + continuationOf?: string; locale: Locale; }): CoachFollowUp | null { - return null; + if (!args.forcedFinal || args.continuationOf) return null; + const { t } = getServerTranslator(args.locale); + const labelKey = COACH_FOLLOW_UP_KEYS.continue; + return { + id: "f1", + kind: "continue", + labelKey, + label: t(labelKey), + reuse: false, + origin: "server", + }; +} + +/** A tapped "keep looking" chip that resolved. */ +export interface ResolvedContinuation { + /** The forced reply being continued; persisted as `continuationOf`. */ + sourceMessageId: string; + /** The context lines for the continued turn. */ + contextHint: string; +} + +/** The newest few messages of an owned conversation, newest first. */ +async function latestMessages(userId: string, conversationId: string) { + return prisma.coachMessage.findMany({ + where: { conversationId, conversation: { userId } }, + orderBy: { createdAt: "desc" }, + take: 4, + select: { + id: true, + role: true, + providerType: true, + metricSourceJson: true, + encryptedContent: true, + }, + }); +} + +interface StoredForced { + followUps: CoachFollowUp[]; + steps: CoachStep[]; + continuationOf: string | null; +} + +function storedDialog(metricSourceJson: string | null): StoredForced | null { + if (!metricSourceJson) return null; + try { + const raw = JSON.parse(metricSourceJson) as Record; + const followUps = Array.isArray(raw.followUps) + ? raw.followUps.flatMap((item) => { + const parsed = coachFollowUpSchema.safeParse(item); + return parsed.success ? [parsed.data] : []; + }) + : []; + const steps = Array.isArray(raw.steps) + ? raw.steps.flatMap((item) => { + const parsed = coachStepSchema.safeParse(item); + return parsed.success ? [parsed.data] : []; + }) + : []; + return { + followUps, + steps, + continuationOf: + typeof raw.continuationOf === "string" ? raw.continuationOf : null, + }; + } catch { + return null; + } +} + +/** One line of the question, cut at the cap on a code point. */ +function questionLine(text: string): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + const points = [...collapsed]; + return points.length <= CONTINUE_QUESTION_MAX_CHARS + ? collapsed + : `${points.slice(0, CONTINUE_QUESTION_MAX_CHARS - 1).join("")}…`; +} + +/** + * What a step read, in the tool's own terms: `get_metric_table(bp, + * last90days, week) → m3.r1`. Only enum fields and server names, so no + * record text reaches the line. + */ +function fetchedLine( + step: CoachStep, + sourceMessageId: string, + priorResults: readonly PriorResultTurn[], +): string | null { + if (step.tool === "snapshot" || !step.domain) return null; + if (step.status !== "done" && step.status !== "empty") return null; + const args = [ + step.domain, + ...(step.window ? [step.window] : []), + ...(step.granularity ? [step.granularity] : []), + ...(step.period && step.period !== "current" ? [step.period] : []), + ].join(", "); + const call = `${step.tool}(${args})`; + if (step.status === "empty") return `${call} → no readings`; + const turn = priorResults.find((p) => p.messageId === sourceMessageId); + const name = + step.resultRef && turn?.results.some((r) => r.ref === step.resultRef) + ? formatPriorResultRef(turn.turnIndex, step.resultRef) + : null; + return name ? `${call} → ${name}` : call; +} + +/** The context lines of a continued turn. Exported for the tests. */ +export function continuationHint(args: { + question: string; + fetched: string[]; +}): string { + const fetched = + args.fetched.length > 0 + ? ` Already fetched: ${args.fetched.join("; ")}. Use show_result for these tables, do not fetch them again.` + : ""; + return `CONTINUE: the person asked you to keep looking. Unfinished answer for: "${questionLine(args.question)}".${fetched} Fetch only what is still missing, then answer the question in full.`; +} + +/** + * Resolve a tapped "keep looking" chip, or null when the request carries + * none. The chip must sit on the conversation's latest reply, stored by the + * server; a reply that is itself a continuation is refused. + */ +export async function resolveContinuation(args: { + userId: string; + conversationId: string | undefined; + followUp: { messageId: string; id: string } | undefined; + /** The tables earlier replies hold, named as the turn's context names them. */ + priorResults: readonly PriorResultTurn[]; +}): Promise { + const { userId, conversationId, followUp } = args; + if (!followUp || !conversationId) return null; + let rows: Awaited>; + try { + rows = await latestMessages(userId, conversationId); + } catch { + return null; + } + const live = rows.filter((m) => m.providerType !== "cancelled"); + const latest = live[0]; + if ( + !latest || + latest.role !== "assistant" || + latest.id !== followUp.messageId + ) { + return null; + } + const stored = storedDialog(latest.metricSourceJson); + const chip = stored?.followUps.find( + (candidate) => candidate.id === followUp.id, + ); + if (!stored || chip?.kind !== "continue") return null; + if (stored.continuationOf) { + annotate({ + action: { name: "coach.followUp.continue_refused" }, + meta: { reason: "already_continued" }, + }); + return null; + } + + // The question is the person's message the forced reply answered. + const asked = live.slice(1).find((m) => m.role === "user"); + let question = ""; + if (asked) { + try { + question = decryptFromBytes(asked.encryptedContent); + } catch { + question = ""; + } + } + if (!question.trim()) return null; + + const fetched = stored.steps + .map((step) => fetchedLine(step, latest.id, args.priorResults)) + .filter((line): line is string => line !== null); + annotate({ + action: { name: "coach.followUp.continued" }, + meta: { fetched: fetched.length }, + }); + return { + sourceMessageId: latest.id, + contextHint: continuationHint({ question, fetched }), + }; } diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts index 2f6f764e8..f5a1966cd 100644 --- a/src/lib/ai/coach/persistence.ts +++ b/src/lib/ai/coach/persistence.ts @@ -325,6 +325,12 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null { const followUps = restoreFollowUps(parsed.followUps); const clarification = restoreClarification(parsed.clarification); const forcedFinal = parsed.forcedFinal === true; + const continuationOf = + typeof parsed.continuationOf === "string" && + parsed.continuationOf.length > 0 && + parsed.continuationOf.length <= 64 + ? parsed.continuationOf + : undefined; return { windows, metrics, @@ -341,6 +347,7 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null { ...(followUps ? { followUps } : {}), ...(clarification ? { clarification } : {}), ...(forcedFinal ? { forcedFinal: true as const } : {}), + ...(continuationOf ? { continuationOf } : {}), }; } catch { return null; diff --git a/src/lib/ai/coach/stream-events.ts b/src/lib/ai/coach/stream-events.ts index c30ce16d9..8de2bb4a5 100644 --- a/src/lib/ai/coach/stream-events.ts +++ b/src/lib/ai/coach/stream-events.ts @@ -429,6 +429,13 @@ export const coachProvenanceSchema = z .describe( "v1.39.4 — the answer was forced at the round cap while the Coach still wanted to read. Absent otherwise.", ), + continuationOf: z + .string() + .max(64) + .optional() + .describe( + "v1.39.4 — this reply continues an answer that was forced at the round cap (the `continue` chip): the id of that earlier assistant message. A continuation offers no further `continue` chip. Absent otherwise.", + ), }) .meta({ id: "CoachProvenance", diff --git a/src/lib/ai/coach/turn/__tests__/continue-turn.test.ts b/src/lib/ai/coach/turn/__tests__/continue-turn.test.ts new file mode 100644 index 000000000..59f23ce7a --- /dev/null +++ b/src/lib/ai/coach/turn/__tests__/continue-turn.test.ts @@ -0,0 +1,243 @@ +/** + * v1.39.4 — "keep looking" through the turn pipeline: a forced answer offers + * the chip first; tapping it runs a normal model turn (the budget gate + * applies) with the continuation lines, records what it continues, and + * offers no second "keep looking" even when it is forced again. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const m = vi.hoisted(() => ({ + findMany: vi.fn(), + annotate: vi.fn(), + resolveTurnConversation: vi.fn(), + persistUserTurn: vi.fn(), + assembleTurnContext: vi.fn(), + resolveTurnChain: vi.fn(), + reserveTurnBudget: vi.fn(), + settleReservation: vi.fn(), + runTurnModel: vi.fn(), + guardReply: vi.fn(), + surfaceCards: vi.fn(), + persistAssistantReply: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { coachMessage: { findMany: m.findMany } }, +})); +vi.mock("@/lib/logging/context", () => ({ annotate: m.annotate })); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: (bytes: Uint8Array) => new TextDecoder().decode(bytes), + encryptToBytes: vi.fn(), +})); +vi.mock("@/lib/ai/coach/persistence", () => ({ + appendMessage: vi.fn(), + createConversation: vi.fn(), +})); +vi.mock("../conversation", () => ({ + resolveTurnConversation: m.resolveTurnConversation, + persistUserTurn: m.persistUserTurn, +})); +vi.mock("../context", () => ({ assembleTurnContext: m.assembleTurnContext })); +vi.mock("../chain", () => ({ resolveTurnChain: m.resolveTurnChain })); +vi.mock("../budget", () => ({ + reserveTurnBudget: m.reserveTurnBudget, + settleReservation: m.settleReservation, +})); +vi.mock("../model", () => ({ runTurnModel: m.runTurnModel })); +vi.mock("../reply-guards", () => ({ guardReply: m.guardReply })); +vi.mock("../cards", () => ({ surfaceCards: m.surfaceCards })); +vi.mock("../persist", () => ({ + persistAssistantReply: m.persistAssistantReply, +})); + +import type { CoachProvenance, CoachStreamEvent } from "@/lib/ai/coach/types"; +import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs"; + +import { runCoachTurn } from "../pipeline"; +import type { TurnInput } from "../types"; + +const bytes = (text: string) => new TextEncoder().encode(text); + +function input(over: Partial = {}): TurnInput { + return { + userId: "u1", + locale: "en", + signal: new AbortController().signal, + conversationId: "c1", + message: "Keep looking", + scope: undefined, + guidedQuestion: undefined, + workoutId: undefined, + followUp: undefined, + clarification: undefined, + recheckCapability: async () => null, + ...over, + }; +} + +async function frames(res: Response): Promise { + const text = await res.text(); + return text + .split("\n\n") + .filter((chunk) => chunk.startsWith("data: ")) + .map((chunk) => JSON.parse(chunk.slice(6)) as CoachStreamEvent); +} + +function modelPath(forcedFinal: boolean) { + m.assembleTurnContext.mockResolvedValue({ + coachPrefs: DEFAULT_COACH_PREFS, + snapshot: { provenance: { windows: [], metrics: [] } }, + }); + m.resolveTurnChain.mockResolvedValue({ ok: true, chain: [], toolMode: true }); + m.reserveTurnBudget.mockResolvedValue({ ok: true, reservation: {} }); + m.runTurnModel.mockResolvedValue({ + ok: true, + result: { content: "x", model: "gpt" }, + workingProviderType: "openai", + toolTrace: [], + totalTokens: 12, + cachedTokens: 0, + steps: [], + results: [], + forcedFinal, + inventory: [], + }); + m.guardReply.mockResolvedValue({ + ok: true, + reply: { + replyText: "Partial.", + outboundBlocked: false, + referencedResults: [], + followUpProposals: [], + clarification: null, + keyValuesSentinel: { + keyValues: [], + malformed: false, + malformedEntries: [], + }, + groundedFigures: [], + unverifiedStripped: 0, + }, + }); + m.surfaceCards.mockResolvedValue({ suggestion: null, action: null }); + m.persistAssistantReply.mockResolvedValue({ messageId: "m-new" }); +} + +function persistedProvenance(): CoachProvenance { + return m.persistAssistantReply.mock.calls[0][0].provenance; +} + +beforeEach(() => { + for (const fn of Object.values(m)) fn.mockReset(); + m.resolveTurnConversation.mockResolvedValue({ + conversation: { + conversationId: "c1", + priorTurns: [], + priorUserMessages: [], + priorToolFigures: [], + priorSummary: null, + priorResults: [], + }, + }); +}); + +describe("a forced answer", () => { + it("offers 'keep looking' first", async () => { + modelPath(true); + const out = await frames(await runCoachTurn(input())); + const chips = out.find((f) => f.type === "followUps"); + expect(chips?.type === "followUps" && chips.followUps[0]).toMatchObject({ + id: "f1", + kind: "continue", + label: "Keep looking", + }); + expect(persistedProvenance().forcedFinal).toBe(true); + }); + + it("offers nothing to continue when the answer was not forced", async () => { + modelPath(false); + const out = await frames(await runCoachTurn(input())); + expect(out.some((f) => f.type === "followUps")).toBe(false); + }); +}); + +describe("the continued turn", () => { + beforeEach(() => { + m.findMany.mockResolvedValue([ + { + id: "m-forced", + role: "assistant", + providerType: "openai", + encryptedContent: bytes("Partial."), + metricSourceJson: JSON.stringify({ + windows: [], + metrics: [], + forcedFinal: true, + followUps: [ + { + id: "f1", + kind: "continue", + labelKey: "coach.followUp.continue", + label: "Keep looking", + reuse: false, + origin: "server", + }, + ], + }), + }, + { + id: "m-q", + role: "user", + providerType: null, + encryptedContent: bytes("Why is my pulse up?"), + metricSourceJson: null, + }, + ]); + }); + + it("runs the model under the budget gate with the continuation lines", async () => { + modelPath(false); + await frames( + await runCoachTurn( + input({ followUp: { messageId: "m-forced", id: "f1" } }), + ), + ); + expect(m.reserveTurnBudget).toHaveBeenCalledTimes(1); + const { turnHints } = m.runTurnModel.mock.calls[0][0]; + expect(turnHints).toEqual([ + expect.stringContaining('Unfinished answer for: "Why is my pulse up?"'), + ]); + expect(persistedProvenance().continuationOf).toBe("m-forced"); + }); + + it("offers no second 'keep looking' when forced again", async () => { + modelPath(true); + const out = await frames( + await runCoachTurn( + input({ followUp: { messageId: "m-forced", id: "f1" } }), + ), + ); + const chips = out.find((f) => f.type === "followUps"); + expect( + chips?.type === "followUps" && + chips.followUps.some((c) => c.kind === "continue"), + ).toBeFalsy(); + expect(persistedProvenance()).toMatchObject({ + forcedFinal: true, + continuationOf: "m-forced", + }); + }); + + it("stops at the budget gate like any turn", async () => { + modelPath(false); + m.reserveTurnBudget.mockResolvedValue({ + ok: false, + response: new Response("budget", { status: 200 }), + }); + const res = await runCoachTurn( + input({ followUp: { messageId: "m-forced", id: "f1" } }), + ); + expect(await res.text()).toBe("budget"); + expect(m.runTurnModel).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/ai/coach/turn/pipeline.ts b/src/lib/ai/coach/turn/pipeline.ts index 6720b871f..7eeedc728 100644 --- a/src/lib/ai/coach/turn/pipeline.ts +++ b/src/lib/ai/coach/turn/pipeline.ts @@ -21,6 +21,7 @@ import { persistUserTurn, resolveTurnConversation } from "./conversation"; import { handleProducerFailure } from "./errors"; import { resolveClarificationAnswer } from "@/lib/ai/coach/clarify"; import { resolveFollowUp } from "@/lib/ai/coach/follow-ups/resolve"; +import { resolveContinuation } from "@/lib/ai/coach/follow-ups/continue"; import { runTurnModel } from "./model"; import { persistAssistantReply } from "./persist"; @@ -70,8 +71,17 @@ export async function runCoachTurn(input: TurnInput): Promise { conversationId: input.conversationId, clarification: input.clarification, }); + // v1.39.4 — "keep looking": the unfinished question and what the forced + // turn already read, so the continued turn does not fetch it again. + const continuation = await resolveContinuation({ + userId, + conversationId: input.conversationId, + followUp: input.followUp, + priorResults: conversation.priorResults ?? [], + }); const turnHints = [ ...(resolvedFollowUp?.contextHint ? [resolvedFollowUp.contextHint] : []), + ...(continuation ? [continuation.contextHint] : []), ...(clarifiedLine ? [clarifiedLine] : []), ]; @@ -157,6 +167,7 @@ export async function runCoachTurn(input: TurnInput): Promise { reply, prefs: ctx.coachPrefs, locale, + continuationOf: continuation?.sourceMessageId, }); const provenance = buildTurnProvenance({ snapshotProvenance: ctx.snapshot.provenance, @@ -167,6 +178,7 @@ export async function runCoachTurn(input: TurnInput): Promise { steps: model.steps, dialog, forcedFinal: model.forcedFinal, + continuationOf: continuation?.sourceMessageId, }); const { messageId } = await persistAssistantReply({ conversationId: workingConversationId, diff --git a/src/lib/ai/coach/turn/provenance.ts b/src/lib/ai/coach/turn/provenance.ts index 437d73982..9c16ec99b 100644 --- a/src/lib/ai/coach/turn/provenance.ts +++ b/src/lib/ai/coach/turn/provenance.ts @@ -66,6 +66,8 @@ export function assembleTurnDialog(args: { reply: GuardedReply; prefs: CoachPrefs; locale: Locale; + /** v1.39.4 — the forced reply this turn continues, if it does. */ + continuationOf?: string; }): TurnDialog { const { model, reply, prefs, locale } = args; const blocked = reply.outboundBlocked; @@ -79,7 +81,11 @@ export function assembleTurnDialog(args: { const method = buildMethod({ steps: model.steps, results: metas, locale }); const continueChip = blocked ? null - : buildContinueFollowUp({ forcedFinal: model.forcedFinal, locale }); + : buildContinueFollowUp({ + forcedFinal: model.forcedFinal, + continuationOf: args.continuationOf, + locale, + }); const followUps = blocked ? [] : [ @@ -112,6 +118,8 @@ export function buildTurnProvenance(args: { steps: CoachStep[]; dialog: TurnDialog; forcedFinal: boolean; + /** v1.39.4 — the forced reply this turn continues, if it does. */ + continuationOf?: string; }): CoachProvenance { const { snapshotProvenance, reply, toolTrace, steps, dialog } = args; const surfacedSuggestion = args.suggestion; @@ -163,6 +171,7 @@ export function buildTurnProvenance(args: { ...(dialog.followUps.length > 0 ? { followUps: dialog.followUps } : {}), ...(dialog.clarification ? { clarification: dialog.clarification } : {}), ...(args.forcedFinal ? { forcedFinal: true as const } : {}), + ...(args.continuationOf ? { continuationOf: args.continuationOf } : {}), }; if (sentinel.malformed) { // Graceful degrade: log so ops can spot a provider whose diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index 9420d4b63..3bb7b8195 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -660,6 +660,12 @@ export interface CoachProvenance { * to read, so the answer was forced. Absent otherwise. */ forcedFinal?: true; + /** + * v1.39.4 — this reply continues an answer that was forced at the round + * cap: the id of that earlier assistant message. A continuation offers no + * further "keep looking" chip, so an answer is continued at most once. + */ + continuationOf?: string; } /**