diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ede577e55..1ba6ce29e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -50407,6 +50407,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 0f440537b..262fabd70 100644 --- a/src/lib/ai/coach/turn/pipeline.ts +++ b/src/lib/ai/coach/turn/pipeline.ts @@ -22,6 +22,7 @@ import { handleProducerFailure } from "./errors"; import { resolveClarificationAnswer } from "@/lib/ai/coach/clarify"; import { resolveFollowUp } from "@/lib/ai/coach/follow-ups/resolve"; import { readFollowUpHistory } from "@/lib/ai/coach/follow-ups/derive"; +import { resolveContinuation } from "@/lib/ai/coach/follow-ups/continue"; import { runTurnModel } from "./model"; import { persistAssistantReply } from "./persist"; @@ -72,8 +73,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] : []), ]; @@ -170,6 +180,7 @@ export async function runCoachTurn(input: TurnInput): Promise { prefs: ctx.coachPrefs, locale, history, + continuationOf: continuation?.sourceMessageId, }); const provenance = buildTurnProvenance({ snapshotProvenance: ctx.snapshot.provenance, @@ -180,6 +191,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 c649cc7d8..eb68aaead 100644 --- a/src/lib/ai/coach/turn/provenance.ts +++ b/src/lib/ai/coach/turn/provenance.ts @@ -70,6 +70,8 @@ export function assembleTurnDialog(args: { locale: Locale; /** The record's history for the tables' metrics, for the history chips. */ history?: FollowUpHistory; + /** The forced reply this turn continues, if it does. */ + continuationOf?: string; }): TurnDialog { const { model, reply, prefs, locale } = args; const blocked = reply.outboundBlocked; @@ -87,7 +89,11 @@ export function assembleTurnDialog(args: { const offerChips = !blocked && followUpChipsEnabled(prefs) && reply.clarification === null; const continueChip = offerChips - ? buildContinueFollowUp({ forcedFinal: model.forcedFinal, locale }) + ? buildContinueFollowUp({ + forcedFinal: model.forcedFinal, + continuationOf: args.continuationOf, + locale, + }) : null; const followUps = offerChips ? numberFollowUps([ @@ -123,6 +129,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; @@ -174,6 +182,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; } /**