Merge branch 'coach/wp7-continue' into release/v1.39.4

# Conflicts:
#	src/lib/ai/coach/turn/pipeline.ts
#	src/lib/ai/coach/turn/provenance.ts
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 12:43:23 +02:00
commit d63d2e6d83
10 files changed
+796 -6

No files matched your search

+5
View File
@@ -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
@@ -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({
@@ -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<string, unknown> = {}) {
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");
});
});
+218 -4
View File
@@ -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<string, unknown>;
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<ResolvedContinuation | null> {
const { userId, conversationId, followUp } = args;
if (!followUp || !conversationId) return null;
let rows: Awaited<ReturnType<typeof latestMessages>>;
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 }),
};
}
+7
View File
@@ -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;
+7
View File
@@ -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",
@@ -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> = {}): 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<CoachStreamEvent[]> {
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();
});
});
+12
View File
@@ -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<Response> {
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<Response> {
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<Response> {
steps: model.steps,
dialog,
forcedFinal: model.forcedFinal,
continuationOf: continuation?.sourceMessageId,
});
const { messageId } = await persistAssistantReply({
conversationId: workingConversationId,
+10 -1
View File
@@ -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
+6
View File
@@ -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;
}
/**