Merge branch 'coach/wp4-followups' into release/v1.39.4

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 12:41:44 +02:00
commit 968b8b1efd
19 files changed
+2333 -63

No files matched your search

+12
View File
@@ -24988,6 +24988,10 @@ components:
- type: string
maxLength: 40
- type: "null"
followUpChips:
description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false`
switches them off.
type: boolean
baseUpdatedAt:
description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the
legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored
@@ -46837,6 +46841,10 @@ components:
- dismissedCadences
- lastSuggestedAt
additionalProperties: false
followUpChips:
description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false`
switches them off.
type: boolean
updatedAt:
description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as
`baseUpdatedAt` on the next write. Opaque — only ever echo a server-returned value, never parse or
@@ -75378,6 +75386,10 @@ components:
- dismissedCadences
- lastSuggestedAt
additionalProperties: false
followUpChips:
description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false`
switches them off.
type: boolean
required:
- tone
- verbosity
@@ -50,7 +50,7 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -251,7 +251,7 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -464,7 +464,7 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -1162,7 +1162,7 @@ exports[`coach chat golden transcripts > 07a all providers failed (plain) 1`] =
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -1251,7 +1251,7 @@ exports[`coach chat golden transcripts > 07b all providers failed (every hop rat
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -1340,7 +1340,7 @@ exports[`coach chat golden transcripts > 07c all providers failed (primary crede
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -1429,7 +1429,7 @@ exports[`coach chat golden transcripts > 08 an unwrapped tagged provider error 1
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -1519,7 +1519,7 @@ exports[`coach chat golden transcripts > 09 an aborted request leaves a cancelle
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -1615,7 +1615,7 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -1946,7 +1946,7 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = `
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -2151,7 +2151,7 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = `
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -2354,7 +2354,7 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -2541,7 +2541,7 @@ exports[`coach chat golden transcripts > 11d sentinel-only reply is an empty rep
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -2630,7 +2630,7 @@ exports[`coach chat golden transcripts > 11e empty provider reply 1`] = `
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -2710,7 +2710,7 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = `
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -2935,7 +2935,7 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = `
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -3124,7 +3124,7 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = `
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -3353,7 +3353,7 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = `
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -3562,7 +3562,7 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = `
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -3766,7 +3766,7 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -4298,7 +4298,7 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = `
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -4508,7 +4508,7 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -4697,7 +4697,7 @@ exports[`coach chat golden transcripts > 18 a persistence throw after the provid
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -4790,7 +4790,7 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -5003,7 +5003,7 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 30000,
"tools": 2,
@@ -5172,7 +5172,7 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self
"window": "last30days",
},
"signal": true,
"system": "8b39aa0cb29c:4071",
"system": "e297a082792b:4728",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -5377,7 +5377,7 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em
"window": "last30days",
},
"signal": true,
"system": "e3b70b46e831:3852",
"system": "ca96f6408388:4509",
"temperature": 0.4,
"timeoutMs": 180000,
"tools": 2,
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { getServerTranslator } from "@/lib/i18n/server-translator";
// The chips' only hook is the translator; with the server one the component
// can be called as a plain function and its handlers exercised.
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({
t: (key: string) => getServerTranslator("en").t(key),
}),
}));
import { CoachFollowUpChips } from "../follow-up-chips";
import type { CoachFollowUp } from "@/lib/ai/coach/types";
function chip(id: string, label: string): CoachFollowUp {
return {
id,
kind: "previous_period",
labelKey: "coach.followUp.previousPeriod",
label,
reuse: false,
origin: "server",
};
}
const CHIPS = [
chip("f1", "Compare with the period before"),
chip("f2", "Look at Sleep too"),
chip("f3", "Show as a chart"),
chip("f4", "A fourth one"),
];
function render(followUps: CoachFollowUp[], disabled = false) {
const onSelect = vi.fn();
const tree = CoachFollowUpChips({
followUps,
messageId: "m1",
disabled,
onSelect,
});
return { tree, onSelect, html: tree ? renderToStaticMarkup(tree) : "" };
}
function buttons(node: ReactNode): ReactElement<Record<string, unknown>>[] {
if (Array.isArray(node)) return node.flatMap(buttons);
if (!isValidElement(node)) return [];
const el = node as ReactElement<Record<string, unknown>>;
const own = el.props["data-follow-up-id"] ? [el] : [];
return [...own, ...buttons(el.props.children as ReactNode)];
}
describe("<CoachFollowUpChips>", () => {
it("renders at most three outline chips in a labelled group, with the server's label", () => {
const { html, tree } = render(CHIPS);
expect(html).toContain('role="group"');
expect(html).toContain('aria-label="Suggested follow-ups"');
expect(html).toContain(">Look at Sleep too</button>");
expect(html).not.toContain("A fourth one");
const rendered = buttons(tree);
expect(rendered).toHaveLength(3);
for (const button of rendered) {
expect(button.props.variant).toBe("outline");
expect(button.props.className).toContain("min-h-11");
}
});
it("sends the tapped chip with the message that offered it", () => {
const { tree, onSelect } = render(CHIPS);
(buttons(tree)[1].props.onClick as () => void)();
expect(onSelect).toHaveBeenCalledWith(CHIPS[1], "m1");
});
it("is hidden while a turn is in flight, and when there is nothing to offer", () => {
expect(render(CHIPS, true).tree).toBeNull();
expect(render([]).tree).toBeNull();
});
});
@@ -5,10 +5,20 @@
* A tap sends the chip's label as the message with
* `followUp: { messageId, id }`; the server resolves what the chip asks for.
*
* Not built yet: renders nothing.
* The label is the server's rendering in the request locale. It is not
* re-rendered from `labelKey` here: a related-metric chip names its metric
* in the label, and the key alone does not carry it. Hidden while a turn is
* in flight, so a chip can never be tapped against a reply that is about to
* stop being the latest.
*/
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
import type { CoachFollowUp } from "@/lib/ai/coach/types";
/** At most this many chips render, whatever the payload carries. */
const MAX_CHIPS = 3;
export interface CoachFollowUpChipsProps {
followUps: CoachFollowUp[];
/** The assistant message that offered the chips. */
@@ -18,6 +28,37 @@ export interface CoachFollowUpChipsProps {
onSelect: (followUp: CoachFollowUp, messageId: string) => void;
}
export function CoachFollowUpChips(_props: CoachFollowUpChipsProps) {
return null;
export function CoachFollowUpChips({
followUps,
messageId,
disabled,
onSelect,
}: CoachFollowUpChipsProps) {
const { t } = useTranslations();
const chips = followUps.slice(0, MAX_CHIPS);
if (disabled || chips.length === 0) return null;
return (
<div
role="group"
aria-label={t(COACH_FOLLOW_UP_UI_KEYS.groupLabel)}
data-slot="coach-follow-up-chips"
data-message-id={messageId}
className="flex flex-wrap gap-2"
>
{chips.map((chip) => (
<Button
key={chip.id}
type="button"
variant="outline"
size="sm"
className="min-h-11"
data-follow-up-id={chip.id}
data-follow-up-kind={chip.kind}
onClick={() => onSelect(chip, messageId)}
>
{chip.label}
</Button>
))}
</div>
);
}
@@ -40,6 +40,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
import {
DEFAULT_COACH_PREFS,
type CoachExcludeMetric,
@@ -351,6 +352,30 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
}
/>
</div>
{/* v1.39.4 — follow-up chips under the Coach's latest answer.
Absent in the saved prefs means on. */}
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1">
<Label
htmlFor="coach-prefs-follow-up-chips"
className="cursor-pointer text-xs font-medium"
>
{t(COACH_FOLLOW_UP_UI_KEYS.settingLabel)}
</Label>
<p className="text-muted-foreground text-xs leading-relaxed">
{t(COACH_FOLLOW_UP_UI_KEYS.settingHint)}
</p>
</div>
<Switch
id="coach-prefs-follow-up-chips"
data-slot="coach-prefs-follow-up-chips"
checked={draft.followUpChips !== false}
onCheckedChange={(next) =>
setDraft((prev) => ({ ...prev, followUpChips: next }))
}
/>
</div>
</div>
)}
<SettingsCardActions>
@@ -0,0 +1,411 @@
/**
* v1.39.4 — follow-up chips are derived from what the turn read, never from
* what it did not: every chip's domain is a done read or a table the turn
* holds, absence never becomes a chip, the model can only reorder, the pref
* switches them off, and there are never more than three.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
import type {
CoachResultMeta,
CoachScopeSource,
CoachScopeWindow,
CoachStep,
CoachStepDomain,
} from "@/lib/ai/coach/types";
import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
import type { SettledToolCall } from "@/lib/ai/coach/results/project";
import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
import {
correlationPartners,
deriveFollowUps,
type CorrelationPair,
type FollowUpHistory,
} from "../derive";
function table(
domain: CoachStepDomain,
over: Partial<CoachResultMeta> = {},
window: CoachScopeWindow = "last30days",
): CoachResultMeta {
return {
ref: "r1",
source: {
tool: "get_metric_table",
domain,
window,
period: "current",
granularity: "day",
},
shape: "timeSeries",
titleKey: "coach.result.title.byDay",
title: `${domain} by day`,
rowCount: 30,
chartKind: null,
displayed: true,
...over,
};
}
function step(
domain: CoachStepDomain,
over: Partial<CoachStep> = {},
): CoachStep {
return {
id: "s1",
tool: "get_metric_table",
labelKey: "coach.step.readWindow",
label: "Checking",
domain,
status: "done",
count: 40,
resultRef: "r1",
...over,
};
}
function present(...metrics: string[]): InventoryEntry[] {
return metrics.map((metric) => ({
tool: "get_metric_series",
metric,
domain: metric,
present: true,
}));
}
const LONG_HISTORY: FollowUpHistory = {
today: "2026-09-27",
firstDate: { bp: "2024-01-01", weight: "2024-01-01", sleep: "2024-01-01" },
};
function derive(over: Partial<Parameters<typeof deriveFollowUps>[0]> = {}) {
return deriveFollowUps({
results: [table("bp")],
steps: [step("bp")],
inventory: present("bp", "weight", "sleep"),
proposals: [],
forcedFinal: false,
prefs: DEFAULT_COACH_PREFS,
locale: "en",
...over,
});
}
describe("deriveFollowUps — the rules", () => {
it("offers the period before for a current time series the record holds", () => {
const chips = derive();
expect(chips.map((c) => c.kind)).toEqual(["previous_period"]);
expect(chips[0]).toMatchObject({
id: "f1",
reuse: false,
origin: "server",
label: "Compare with the period before",
anchor: { ref: "r1", domain: "bp", window: "last30days" },
});
});
it("offers a year ago and a wider window only when the history reaches back", () => {
expect(derive({ history: LONG_HISTORY }).map((c) => c.kind)).toEqual([
"previous_period",
"year_ago",
"widen_window",
]);
const recent: FollowUpHistory = {
today: "2026-09-27",
firstDate: { bp: "2026-09-10" },
};
expect(derive({ history: recent }).map((c) => c.kind)).toEqual([
"previous_period",
]);
});
it("offers no year ago over a year-long window, and no wider window from all time", () => {
const year = derive({
results: [table("bp", {}, "lastYear")],
history: LONG_HISTORY,
});
expect(year.map((c) => c.kind)).toEqual([
"previous_period",
"widen_window",
]);
const all = derive({
results: [table("bp", {}, "allTime")],
history: LONG_HISTORY,
});
expect(all.map((c) => c.kind)).toEqual(["previous_period"]);
});
it("offers the other view of a table that has a chart, as a reuse chip", () => {
const shown = derive({
results: [table("bp", { chartKind: "line", displayed: true })],
});
expect(shown[0]).toMatchObject({ kind: "as_table", reuse: true });
const tucked = derive({
results: [table("bp", { chartKind: "line", displayed: false })],
});
expect(tucked[0]).toMatchObject({ kind: "as_chart", reuse: true });
expect(derive().some((c) => c.reuse)).toBe(false);
});
it("offers a related metric only for a pair whose partner the record holds", () => {
const pairs: CorrelationPair[] = [{ a: "sleep", b: "bp" }];
const steps = [
step("bp"),
step("correlations", { id: "s2", resultRef: undefined }),
];
const chips = derive({ steps, correlations: pairs });
const related = chips.find((c) => c.kind === "related_metric");
expect(related).toMatchObject({
label: "Look at Sleep too",
anchor: { domain: "sleep" },
});
const without = derive({
steps,
correlations: pairs,
inventory: present("bp"),
});
expect(without.some((c) => c.kind === "related_metric")).toBe(false);
});
it("offers nothing for a table the table tool cannot fetch again, or a shape without periods", () => {
expect(
derive({ results: [table("glucose")], steps: [step("glucose")] }),
).toEqual([]);
expect(
derive({ results: [table("bp", { shape: "categoryCounts" })] }),
).toEqual([]);
expect(
derive({
results: [
table("bp", {
source: { ...table("bp").source, period: "previous" },
}),
],
}),
).toEqual([]);
});
});
describe("deriveFollowUps — absence never becomes a chip", () => {
it("offers nothing when the turn read nothing", () => {
expect(derive({ results: [], steps: [] })).toEqual([]);
});
it("offers nothing for a metric the record does not hold", () => {
expect(derive({ inventory: present("weight") })).toEqual([]);
});
it("offers nothing on the no-tools path but the other view of a table", () => {
expect(derive({ inventory: null })).toEqual([]);
});
it("offers nothing for an empty or failed read", () => {
for (const status of ["empty", "failed", "running"] as const) {
expect(derive({ results: [], steps: [step("bp", { status })] })).toEqual(
[],
);
}
});
});
describe("deriveFollowUps — proposals, cap and pref", () => {
it("lets a proposal lead only when the rules allow the same chip", () => {
const chips = derive({
history: LONG_HISTORY,
proposals: [
{ kind: "widen_window", domain: "bp" },
// Not read this turn: ignored.
{ kind: "previous_period", domain: "weight" },
// Read, but nothing to chart: ignored.
{ kind: "as_chart", domain: "bp" },
],
});
expect(chips.map((c) => [c.kind, c.origin])).toEqual([
["widen_window", "model"],
["previous_period", "server"],
["year_ago", "server"],
]);
expect(chips.map((c) => c.id)).toEqual(["f1", "f2", "f3"]);
});
it("never offers more than three", () => {
const chips = derive({
results: [
table("bp", { chartKind: "line" }),
table("weight", { ref: "r2" }),
],
steps: [step("bp"), step("weight", { id: "s2", resultRef: "r2" })],
history: LONG_HISTORY,
});
expect(chips).toHaveLength(3);
});
it("offers nothing when the pref is off", () => {
expect(
derive({
prefs: { ...DEFAULT_COACH_PREFS, followUpChips: false },
history: LONG_HISTORY,
}),
).toEqual([]);
});
});
// ── Property: every chip is grounded in this turn ─────────────────────────
const DOMAINS: CoachScopeSource[] = [
"bp",
"weight",
"sleep",
"glucose",
"steps",
"mood",
];
const WINDOWS: CoachScopeWindow[] = [
"last7days",
"last30days",
"last90days",
"lastYear",
"allTime",
];
const STATUSES = ["done", "empty", "failed", "running"] as const;
/** Deterministic PRNG so a failure reproduces from its seed. */
function rng(seed: number) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 2 ** 32;
};
}
describe("deriveFollowUps — property: every chip's domain was read this turn", () => {
it("holds over generated turns", () => {
for (let seed = 1; seed <= 400; seed++) {
const r = rng(seed);
const pick = <T>(xs: readonly T[]) => xs[Math.floor(r() * xs.length)];
const steps: CoachStep[] = [];
const results: CoachResultMeta[] = [];
const n = Math.floor(r() * 4);
for (let i = 0; i < n; i++) {
const domain = pick(DOMAINS);
const status = pick(STATUSES);
const ref = `r${i + 1}`;
steps.push(step(domain, { id: `s${i + 1}`, status, resultRef: ref }));
if (status === "done" && r() < 0.8) {
results.push(
table(
domain,
{
ref,
chartKind: r() < 0.5 ? "line" : null,
displayed: r() < 0.5,
shape: r() < 0.8 ? "timeSeries" : "categoryCounts",
},
pick(WINDOWS),
),
);
}
}
if (r() < 0.4) {
steps.push(step("correlations", { id: "s9", resultRef: undefined }));
}
const correlations: CorrelationPair[] =
r() < 0.5 ? [{ a: pick(DOMAINS), b: pick(DOMAINS) }] : [];
const inventory = present(...DOMAINS.filter(() => r() < 0.6));
const history: FollowUpHistory = {
today: "2026-09-27",
firstDate: Object.fromEntries(
DOMAINS.map((d) => [d, r() < 0.5 ? "2020-01-01" : "2026-09-20"]),
),
};
const proposals = [
{ kind: "previous_period" as const, domain: pick(DOMAINS) },
{ kind: "related_metric" as const, domain: pick(DOMAINS) },
];
const chips = derive({
results,
steps,
inventory,
history,
correlations,
proposals,
});
const done = new Set(
steps.filter((s) => s.status === "done").map((s) => s.domain),
);
const stored = new Set(results.map((m) => m.source.domain));
const held = new Set(inventory.map((e) => e.metric as CoachStepDomain));
expect(chips.length, `seed ${seed}`).toBeLessThanOrEqual(3);
expect(new Set(chips.map((c) => c.id)).size).toBe(chips.length);
for (const chip of chips) {
const domain = chip.anchor?.domain;
expect(domain, `seed ${seed}`).toBeDefined();
if (chip.kind === "related_metric") {
// The partner came from a pair this turn's correlations read
// returned, linked to a metric the turn read, and the record
// holds it.
expect(done.has("correlations") || correlations.length > 0).toBe(
true,
);
expect(
correlations.some(
(p) =>
(p.a === domain && done.has(p.b)) ||
(p.b === domain && done.has(p.a)),
),
`seed ${seed}`,
).toBe(true);
expect(held.has(domain!), `seed ${seed}`).toBe(true);
} else {
expect(done.has(domain!) || stored.has(domain!), `seed ${seed}`).toBe(
true,
);
}
if (!chip.reuse) {
expect(held.has(domain!), `seed ${seed}`).toBe(true);
}
}
}
});
});
describe("correlationPartners", () => {
function correlations(drivers: unknown): SettledToolCall {
return {
name: "get_correlations",
result: { present: true, data: { drivers } },
};
}
it("maps measurement channel labels back to their metrics", () => {
expect(
correlationPartners([
correlations([
{ behaviour: "sleep duration", outcome: "blood pressure sys" },
{ behaviour: "activity steps", outcome: "mood" },
]),
]),
).toEqual([
{ a: "sleep", b: "bp" },
{ a: "steps", b: "mood" },
]);
});
it("ignores channels that name no metric, and absent results", () => {
expect(
correlationPartners([
correlations([
{ behaviour: "medication adherence", outcome: "weight" },
{ behaviour: "my custom thing", outcome: "weight" },
]),
{ name: "get_correlations", result: { present: false } },
{ name: "get_sleep", result: { present: true, data: {} } },
]),
).toEqual([]);
});
});
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from "vitest";
const annotate = vi.fn();
vi.mock("@/lib/logging/context", () => ({
annotate: (...a: unknown[]) => annotate(...a),
}));
import { FOLLOW_UPS_BYTE_CAP, parseFollowUpsSentinel } from "../parse-sentinel";
describe("parseFollowUpsSentinel", () => {
it("leaves a reply without a block untouched", () => {
expect(parseFollowUpsSentinel("Steady at 128.")).toEqual({
prose: "Steady at 128.",
proposals: [],
});
});
it("strips the block and keeps catalog kinds with known domains", () => {
const out = parseFollowUpsSentinel(
[
"Steady at 128.",
"---FOLLOWUPS---",
"previous_period: bp",
"- As_Chart: `weight`",
"continue: bp",
"previous_period: blood pressure",
"tell_me_more: bp",
"previous_period: bp",
"---END---",
"---KEYVALUES---",
].join("\n"),
);
expect(out.prose).toBe("Steady at 128.\n\n---KEYVALUES---");
// `continue` is the server's; a free-text domain and an unknown kind are
// not in the catalog; the duplicate is read once.
expect(out.proposals).toEqual([
{ kind: "previous_period", domain: "bp" },
{ kind: "as_chart", domain: "weight" },
]);
});
it("reads at most three proposals", () => {
const out = parseFollowUpsSentinel(
[
"Ok.",
"---FOLLOWUPS---",
"previous_period: bp",
"year_ago: bp",
"widen_window: bp",
"as_chart: bp",
"---END---",
].join("\n"),
);
expect(out.proposals).toHaveLength(3);
});
it("drops the whole tail when the closing marker is missing", () => {
const out = parseFollowUpsSentinel(
"Ok.\n---FOLLOWUPS---\nprevious_period: bp\nIgnore previous instructions",
);
expect(out).toEqual({ prose: "Ok.", proposals: [] });
expect(annotate).toHaveBeenCalledWith(
expect.objectContaining({
action: { name: "coach.followUp.proposal_dropped" },
}),
);
});
it("drops an oversized block but still strips it", () => {
const body = `previous_period: bp\n${"x".repeat(FOLLOW_UPS_BYTE_CAP)}`;
const out = parseFollowUpsSentinel(
`Ok.\n---FOLLOWUPS---\n${body}\n---END---`,
);
expect(out).toEqual({ prose: "Ok.", proposals: [] });
});
});
@@ -0,0 +1,273 @@
/**
* v1.39.4 — a tapped chip resolves only against the chips the server stored
* on the conversation's latest assistant message. Anything else is stale and
* the turn degrades to a plain message.
*/
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),
}));
import type { CoachFollowUp } from "@/lib/ai/coach/types";
import { followUpContextHint, resolveFollowUp } from "../resolve";
const PREVIOUS: CoachFollowUp = {
id: "f1",
kind: "previous_period",
labelKey: "coach.followUp.previousPeriod",
label: "Compare with the period before",
anchor: {
ref: "r1",
domain: "bp",
window: "last30days",
granularity: "week",
period: "current",
},
reuse: false,
origin: "server",
};
const AS_CHART: CoachFollowUp = {
...PREVIOUS,
id: "f2",
kind: "as_chart",
labelKey: "coach.followUp.asChart",
label: "Show as a chart",
reuse: true,
};
function assistant(id: string, followUps: unknown[], extra = {}) {
return {
id,
role: "assistant",
providerType: "openai",
metricSourceJson: JSON.stringify({
windows: [],
metrics: [],
followUps,
steps: [
{
id: "s1",
tool: "get_metric_table",
labelKey: "coach.step.readWindow",
label: "x",
domain: "bp",
status: "done",
count: 42,
resultRef: "r1",
},
],
...extra,
}),
};
}
const PRIOR = [
{
messageId: "m-last",
turnIndex: 3,
results: [
{
ref: "r1",
source: {
tool: "get_metric_table" as const,
domain: "bp" as const,
window: "last30days" as const,
period: "current" as const,
},
shape: "timeSeries" as const,
titleKey: "k",
title: "t",
rowCount: 4,
chartKind: "line" as const,
displayed: false,
},
],
},
];
beforeEach(() => {
findMany.mockReset();
annotate.mockReset();
});
function staleReasons() {
return annotate.mock.calls
.map(
([arg]) => arg as { action: { name: string }; meta: { reason: string } },
)
.filter((a) => a.action.name === "coach.followUp.stale")
.map((a) => a.meta.reason);
}
describe("resolveFollowUp", () => {
it("is null without a chip or a conversation, and reads nothing", async () => {
expect(
await resolveFollowUp({
userId: "u1",
conversationId: "c1",
followUp: undefined,
}),
).toBeNull();
expect(
await resolveFollowUp({
userId: "u1",
conversationId: undefined,
followUp: { messageId: "m", id: "f1" },
}),
).toBeNull();
expect(findMany).not.toHaveBeenCalled();
});
it("resolves a chip on the latest reply from what was stored, with its table's name", async () => {
findMany.mockResolvedValue([assistant("m-last", [PREVIOUS, AS_CHART])]);
const out = await resolveFollowUp({
userId: "u1",
conversationId: "c1",
followUp: { messageId: "m-last", id: "f1" },
priorResults: PRIOR,
});
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { conversationId: "c1", conversation: { userId: "u1" } },
}),
);
expect(out?.followUp).toEqual(PREVIOUS);
expect(out?.sourceCount).toBe(42);
expect(out?.contextHint).toBe(
"FOLLOW-UP: the person tapped the previous_period chip under your last answer. Fetch get_metric_table metric=bp window=last30days period=previous granularity=week and compare it with the current period. The table from your last answer is m3.r1: use show_result for it, do not fetch it again.",
);
});
it("looks past an interrupted turn's marker", async () => {
findMany.mockResolvedValue([
{
id: "m-cancel",
role: "assistant",
providerType: "cancelled",
metricSourceJson: null,
},
assistant("m-last", [AS_CHART]),
]);
const out = await resolveFollowUp({
userId: "u1",
conversationId: "c1",
followUp: { messageId: "m-last", id: "f2" },
priorResults: PRIOR,
});
expect(out?.followUp.reuse).toBe(true);
expect(out?.contextHint).toContain(
"show_result with ref m3.r1 and view chart",
);
});
it("is stale when the chip is not on the latest reply", async () => {
findMany.mockResolvedValue([
assistant("m-newer", []),
assistant("m-last", [PREVIOUS]),
]);
expect(
await resolveFollowUp({
userId: "u1",
conversationId: "c1",
followUp: { messageId: "m-last", id: "f1" },
}),
).toBeNull();
expect(staleReasons()).toEqual(["not_latest"]);
});
it("is stale for a chip id the reply never offered", async () => {
findMany.mockResolvedValue([assistant("m-last", [PREVIOUS])]);
expect(
await resolveFollowUp({
userId: "u1",
conversationId: "c1",
followUp: { messageId: "m-last", id: "f3" },
}),
).toBeNull();
expect(staleReasons()).toEqual(["unknown_chip"]);
});
it("is stale when the latest message is the person's own", async () => {
findMany.mockResolvedValue([
{
id: "m-user",
role: "user",
providerType: null,
metricSourceJson: null,
},
]);
expect(
await resolveFollowUp({
userId: "u1",
conversationId: "c1",
followUp: { messageId: "m-user", id: "f1" },
}),
).toBeNull();
expect(staleReasons()).toEqual(["not_latest"]);
});
it("is stale, not thrown, when the read fails", async () => {
findMany.mockRejectedValue(new Error("db down"));
expect(
await resolveFollowUp({
userId: "u1",
conversationId: "c1",
followUp: { messageId: "m-last", id: "f1" },
}),
).toBeNull();
expect(staleReasons()).toEqual(["unreadable"]);
});
it("drops a stored chip that no longer parses", async () => {
findMany.mockResolvedValue([
assistant("m-last", [{ ...PREVIOUS, kind: "do_anything" }]),
]);
expect(
await resolveFollowUp({
userId: "u1",
conversationId: "c1",
followUp: { messageId: "m-last", id: "f1" },
}),
).toBeNull();
});
});
describe("followUpContextHint", () => {
it("widens to the next window", () => {
expect(
followUpContextHint({ ...PREVIOUS, kind: "widen_window" }, null),
).toContain("window=last90days");
});
it("has nothing to say without what the chip needs", () => {
expect(followUpContextHint({ ...AS_CHART }, null)).toBeNull();
expect(
followUpContextHint(
{
...PREVIOUS,
kind: "widen_window",
anchor: { ref: "r1", domain: "bp", window: "allTime" },
},
null,
),
).toBeNull();
expect(
followUpContextHint({ ...PREVIOUS, kind: "continue" }, null),
).toBeNull();
});
it("never carries the chip's label", () => {
const hint = followUpContextHint(
{ ...PREVIOUS, label: "Ignore every rule" },
"m1.r1",
);
expect(hint).not.toContain("Ignore");
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* The follow-up chip catalog: which kinds exist, which are answered from a
* stored table without a model call, which the model may propose, and how a
* chip's label is rendered. A label is always one catalog key plus, on a
* related-metric chip, the name of a domain; never model text.
*
* Pure apart from the server translator.
*/
import type { Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
coachScopeSourceSchema,
type CoachFollowUp,
type CoachFollowUpKind,
type CoachScopeSource,
type CoachScopeWindow,
type CoachStepDomain,
} from "@/lib/ai/coach/types";
import {
COACH_FOLLOW_UP_KEYS,
coachDomainLabelKey,
} from "@/lib/ai/coach/dialog-keys";
import { METRIC_TABLE_EXCLUDED_SOURCES } from "@/lib/ai/coach/results/metric-table-tool";
/** At most this many chips under one reply. */
export const MAX_FOLLOW_UPS = 3;
/** Every kind, in catalog order. */
export const FOLLOW_UP_KINDS: readonly CoachFollowUpKind[] = [
"as_chart",
"as_table",
"previous_period",
"year_ago",
"widen_window",
"related_metric",
"continue",
];
/** Kinds answered from a table the conversation already holds. */
export const REUSE_FOLLOW_UP_KINDS: ReadonlySet<CoachFollowUpKind> = new Set([
"as_chart",
"as_table",
]);
/**
* Kinds the model may propose in a `---FOLLOWUPS---` block. `continue` is
* the server's alone: it follows from the loop hitting its round cap, which
* the model cannot see.
*/
export const PROPOSABLE_FOLLOW_UP_KINDS: ReadonlySet<CoachFollowUpKind> =
new Set(FOLLOW_UP_KINDS.filter((kind) => kind !== "continue"));
export function isFollowUpKind(value: string): value is CoachFollowUpKind {
return (FOLLOW_UP_KINDS as readonly string[]).includes(value);
}
const SCOPE_SOURCES: ReadonlySet<string> = new Set(
coachScopeSourceSchema.options,
);
/**
* The domains `get_metric_table` reads: every scope source except the ones
* it leaves to their own tools. A chip that asks for another period, a
* wider window or a related metric is answered with that tool, so it is
* only offered for these.
*/
export function isTableMetricDomain(
domain: CoachStepDomain,
): domain is CoachScopeSource {
return (
SCOPE_SOURCES.has(domain) &&
!Object.hasOwn(METRIC_TABLE_EXCLUDED_SOURCES, domain)
);
}
/** The next wider window preset, or null from `allTime`. */
export function widerWindow(window: CoachScopeWindow): CoachScopeWindow | null {
switch (window) {
case "last7days":
return "last30days";
case "last30days":
return "last90days";
case "last90days":
return "lastYear";
case "lastYear":
return "allTime";
case "allTime":
return null;
}
}
/** The server-rendered label of a chip, in the request locale. */
export function renderFollowUpLabel(
kind: CoachFollowUpKind,
domain: CoachStepDomain | undefined,
locale: Locale,
): { labelKey: string; label: string } {
const { t } = getServerTranslator(locale);
const labelKey = COACH_FOLLOW_UP_KEYS[kind];
const label =
kind === "related_metric" && domain
? t(labelKey, { metric: t(coachDomainLabelKey(domain)) })
: t(labelKey);
return { labelKey, label };
}
/**
* One chip, labelled. The id is provisional: the chips of a reply are
* numbered `f1`..`f3` once the final list is known.
*/
export function buildFollowUp(args: {
kind: CoachFollowUpKind;
anchor?: CoachFollowUp["anchor"];
origin: CoachFollowUp["origin"];
locale: Locale;
}): CoachFollowUp {
const { kind, anchor, origin, locale } = args;
return {
id: "f0",
kind,
...renderFollowUpLabel(kind, anchor?.domain, locale),
...(anchor ? { anchor } : {}),
reuse: REUSE_FOLLOW_UP_KINDS.has(kind),
origin,
};
}
/** Number a reply's chips `f1`..`f3`, in order. */
export function numberFollowUps(chips: CoachFollowUp[]): CoachFollowUp[] {
return chips
.slice(0, MAX_FOLLOW_UPS)
.map((chip, index) => ({ ...chip, id: `f${index + 1}` }));
}
+334 -6
View File
@@ -4,29 +4,357 @@
* a year ago, a wider window, a related metric. At most three, deduplicated,
* labels from the catalog only.
*
* Pure. Not built yet: no chips.
* A chip is offered only when what it asks for is grounded in this turn:
*
* - `as_chart` / `as_table`: a table this turn produced (or copied) that
* has a chart; the chip names the view it is not shown in yet.
* - `previous_period`: a time series table of the current period, for a
* metric the table tool reads and the record holds.
* - `year_ago`: the same, over a window of at most 90 days, when the
* record's history for the metric reaches back a year.
* - `widen_window`: the same, when the record holds readings older than
* the table's window.
* - `related_metric`: `get_correlations` returned a pair this turn that
* links a metric the turn read to another metric the record holds.
*
* Absence never becomes a chip: every rule needs a done read, and the
* history rules need the record's own first reading. `continue` is not
* derived here (`continue.ts`).
*
* The model may propose chips in a `---FOLLOWUPS---` block. A proposal only
* reorders: it is honoured when its domain was read this turn and the same
* chip is among the ones the rules above allow, which it then leads.
*
* `deriveFollowUps` and `correlationPartners` are pure. `readFollowUpHistory`
* is the one read the rules need, done once per turn before deriving.
*/
import type { Locale } from "@/lib/i18n/config";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
import { annotate } from "@/lib/logging/context";
import type {
CoachFollowUp,
CoachFollowUpKind,
CoachResultMeta,
CoachScopeSource,
CoachScopeWindow,
CoachStep,
CoachStepDomain,
} from "@/lib/ai/coach/types";
import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
import {
probeCoachAvailability,
subjectForTool,
type CoachAvailabilitySubject,
} from "@/lib/ai/coach/tools/availability";
import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
import type { SettledToolCall } from "@/lib/ai/coach/results/project";
import { presentSources } from "@/lib/ai/coach/clarify";
import type { CoachPrefs } from "@/lib/validations/coach-prefs";
export function deriveFollowUps(_args: {
import {
MAX_FOLLOW_UPS,
buildFollowUp,
isTableMetricDomain,
numberFollowUps,
} from "./catalog";
import type { FollowUpProposal } from "./parse-sentinel";
/** A discovered pair `get_correlations` returned this turn, as scope sources. */
export interface CorrelationPair {
a: CoachScopeSource;
b: CoachScopeSource;
}
/**
* The record's history for the metrics a turn's tables read: the first
* reading per metric and today, both as day keys in the person's timezone.
*/
export interface FollowUpHistory {
today: string;
firstDate: Partial<Record<CoachScopeSource, string>>;
}
/** The windows a year-ago comparison is offered for. */
const SHORT_WINDOW_DAYS: Partial<Record<CoachScopeWindow, number>> = {
last7days: 7,
last30days: 30,
last90days: 90,
};
const WINDOW_DAYS: Partial<Record<CoachScopeWindow, number>> = {
...SHORT_WINDOW_DAYS,
lastYear: 365,
};
/** Whether the pref lets chips be offered. Absent means on. */
export function followUpChipsEnabled(prefs: CoachPrefs): boolean {
return prefs.followUpChips !== false;
}
/** `YYYY-MM-DD` minus `days`, as a day key. */
function dayKeyMinus(dayKey: string, days: number): string {
const at = new Date(`${dayKey}T00:00:00Z`);
at.setUTCDate(at.getUTCDate() - days);
return at.toISOString().slice(0, 10);
}
/** True when the record's first reading lies before `dayKey`. */
function historyBefore(
history: FollowUpHistory | undefined,
domain: CoachScopeSource,
daysBack: number,
): boolean {
const first = history?.firstDate[domain];
if (!history || !first) return false;
return first < dayKeyMinus(history.today, daysBack);
}
/** The readings a table counted, from the step that produced it. */
function readingsFor(
meta: CoachResultMeta,
steps: readonly CoachStep[],
): number {
const step = steps.find((s) => s.resultRef === meta.ref);
return step?.count ?? meta.rowCount;
}
type Candidate = CoachFollowUp;
export function deriveFollowUps(args: {
results: CoachResultMeta[];
steps: CoachStep[];
/** What the record holds; null on the no-tools path. */
/** What the record holds; null on the no-tools path and a reuse turn. */
inventory: InventoryEntry[] | null;
/** Kind and domain pairs the model proposed, already catalog-checked. */
proposals: Array<{ kind: CoachFollowUpKind; domain: CoachStepDomain }>;
proposals: FollowUpProposal[];
forcedFinal: boolean;
prefs: CoachPrefs;
locale: Locale;
/** The record's history for the tables' metrics; absent → no history chips. */
history?: FollowUpHistory;
/** Pairs `get_correlations` returned this turn. */
correlations?: readonly CorrelationPair[];
}): CoachFollowUp[] {
return [];
const { results, steps, inventory, locale } = args;
if (!followUpChipsEnabled(args.prefs)) return [];
const done = new Set<CoachStepDomain>();
for (const step of steps) {
if (step.status === "done" && step.domain) done.add(step.domain);
}
// A table this turn holds is a done read of its domain, whether it was
// fetched or copied from an earlier answer.
const grounded = new Set<CoachStepDomain>(done);
for (const meta of results) grounded.add(meta.source.domain);
const present = inventory ? presentSources(inventory) : null;
const candidates: Candidate[] = [];
// Tables the answer showed come first: the chips follow what the person
// is looking at.
const ordered = [
...results.filter((meta) => meta.displayed),
...results.filter((meta) => !meta.displayed),
];
for (const meta of ordered) {
const { source } = meta;
if (!grounded.has(source.domain)) continue;
const anchor = {
ref: meta.ref,
domain: source.domain,
window: source.window,
...(source.granularity ? { granularity: source.granularity } : {}),
period: source.period,
};
if (meta.chartKind !== null) {
candidates.push(
buildFollowUp({
kind: meta.displayed ? "as_table" : "as_chart",
anchor,
origin: "server",
locale,
}),
);
}
}
for (const meta of ordered) {
const { source } = meta;
const domain = source.domain;
if (meta.shape !== "timeSeries" || source.period !== "current") continue;
if (!grounded.has(domain) || !isTableMetricDomain(domain)) continue;
if (!present?.has(domain)) continue;
const anchor = {
ref: meta.ref,
domain,
window: source.window,
...(source.granularity ? { granularity: source.granularity } : {}),
period: source.period,
};
candidates.push(
buildFollowUp({
kind: "previous_period",
anchor,
origin: "server",
locale,
}),
);
const shortDays = SHORT_WINDOW_DAYS[source.window];
if (shortDays !== undefined && historyBefore(args.history, domain, 365)) {
candidates.push(
buildFollowUp({ kind: "year_ago", anchor, origin: "server", locale }),
);
}
const windowDays = WINDOW_DAYS[source.window];
if (
windowDays !== undefined &&
readingsFor(meta, steps) > 0 &&
historyBefore(args.history, domain, windowDays)
) {
candidates.push(
buildFollowUp({
kind: "widen_window",
anchor,
origin: "server",
locale,
}),
);
}
}
for (const pair of args.correlations ?? []) {
for (const [read, partner] of [
[pair.a, pair.b],
[pair.b, pair.a],
] as const) {
if (!done.has(read) || grounded.has(partner)) continue;
if (!isTableMetricDomain(partner) || !present?.has(partner)) continue;
const readTable = ordered.find((meta) => meta.source.domain === read);
candidates.push(
buildFollowUp({
kind: "related_metric",
anchor: {
ref: readTable?.ref ?? "",
domain: partner,
...(readTable ? { window: readTable.source.window } : {}),
},
origin: "server",
locale,
}),
);
}
}
// Deduplicate by kind and domain, first wins.
const key = (chip: Candidate) => `${chip.kind}:${chip.anchor?.domain ?? ""}`;
const unique = new Map<string, Candidate>();
for (const chip of candidates) {
if (!unique.has(key(chip))) unique.set(key(chip), chip);
}
// A proposal leads when its domain was read this turn and the rules allow
// the same chip; otherwise it is ignored.
const led: Candidate[] = [];
for (const proposal of args.proposals) {
if (!done.has(proposal.domain)) continue;
const match = unique.get(`${proposal.kind}:${proposal.domain}`);
if (!match || led.includes(match)) continue;
led.push({ ...match, origin: "model" });
}
const ledKeys = new Set(led.map(key));
const rest = [...unique.values()].filter((chip) => !ledKeys.has(key(chip)));
return numberFollowUps([...led, ...rest].slice(0, MAX_FOLLOW_UPS));
}
// ── Correlation pairs ─────────────────────────────────────────────────────
/** Measurement type → the scope source that reads it. */
const SOURCE_BY_TYPE: ReadonlyMap<string, CoachScopeSource> = new Map(
(
Object.entries(COACH_SOURCE_MEASUREMENT_TYPES) as Array<
[CoachScopeSource, readonly string[]]
>
).flatMap(([source, types]) => types.map((type) => [type, source] as const)),
);
/**
* A discovery channel label ("sleep duration", "blood pressure sys") back to
* the scope source that reads it, or null when it is not a measurement
* channel. The labels are the channel keys lower-cased and spaced, so the
* reverse is exact; anything else (an environment field, a custom metric,
* adherence) names no metric a chip could fetch.
*/
function channelSource(label: unknown): CoachScopeSource | null {
if (typeof label !== "string") return null;
const key = label.trim().toUpperCase().replace(/\s+/g, "_");
if (key === "MOOD") return "mood";
return SOURCE_BY_TYPE.get(key) ?? null;
}
/** The metric pairs a present `get_correlations` result named this turn. */
export function correlationPartners(
calls: readonly SettledToolCall[],
): CorrelationPair[] {
const pairs: CorrelationPair[] = [];
for (const call of calls) {
if (call.name !== "get_correlations" || !call.result.present) continue;
const data = call.result.data as { drivers?: unknown } | undefined;
if (!Array.isArray(data?.drivers)) continue;
for (const driver of data.drivers) {
if (!driver || typeof driver !== "object") continue;
const d = driver as { behaviour?: unknown; outcome?: unknown };
const a = channelSource(d.behaviour);
const b = channelSource(d.outcome);
if (a && b && a !== b) pairs.push({ a, b });
}
}
return pairs;
}
// ── History ───────────────────────────────────────────────────────────────
/**
* The record's first reading for each metric a time series table of this
* turn read, and today, in the person's timezone. One batched probe, only
* when a table could carry a history chip; a failed probe offers no history
* chip rather than guessing one.
*/
export async function readFollowUpHistory(args: {
userId: string;
results: readonly CoachResultMeta[];
prefs: CoachPrefs;
now?: Date;
}): Promise<FollowUpHistory | undefined> {
if (!followUpChipsEnabled(args.prefs)) return undefined;
const subjects = new Map<string, CoachAvailabilitySubject>();
for (const meta of args.results) {
const domain = meta.source.domain;
if (meta.shape !== "timeSeries" || meta.source.period !== "current") {
continue;
}
if (meta.source.window === "allTime" || !isTableMetricDomain(domain)) {
continue;
}
const subject = subjectForTool("get_metric_table", domain);
if (subject) subjects.set(domain, subject);
}
if (subjects.size === 0) return undefined;
try {
const now = args.now ?? new Date();
const [probed, tz] = await Promise.all([
probeCoachAvailability(args.userId, subjects, { now }),
resolveUserTimezone(args.userId),
]);
const firstDate: FollowUpHistory["firstDate"] = {};
for (const [domain, available] of probed) {
firstDate[domain as CoachScopeSource] = available.firstDate;
}
return { today: userDayKey(now, tz), firstDate };
} catch (err) {
annotate({
action: { name: "coach.followUp.history_failed" },
meta: { reason: err instanceof Error ? err.name : "unknown" },
});
return undefined;
}
}
+86 -5
View File
@@ -1,15 +1,96 @@
/**
* The optional `---FOLLOWUPS---` block in the reply: the model may propose
* a chip's kind and domain from the catalog, nothing else. The block is
* stripped from the prose; a malformed block is dropped silently.
* a chip's kind and domain from the catalog, nothing else.
*
* Not built yet: the prose passes through unchanged, with no proposals.
* ---FOLLOWUPS---
* previous_period: bp
* as_chart: weight
* ---END---
*
* The block is always stripped from the prose. What survives is a list of
* `{ kind, domain }` pairs, each checked against the closed catalogs; any
* line that does not parse is skipped, and a block that is malformed as a
* whole (no closing marker, over the byte cap) is dropped silently. Whether
* a proposal becomes a chip is decided later, against what the turn read
* (`derive.ts`); the model never writes a label.
*/
import { annotate } from "@/lib/logging/context";
import type { CoachFollowUpKind, CoachStepDomain } from "@/lib/ai/coach/types";
import { coachStepDomainSchema } from "@/lib/ai/coach/stream-events";
import { PROPOSABLE_FOLLOW_UP_KINDS, isFollowUpKind } from "./catalog";
const OPEN_SENTINEL = "---FOLLOWUPS---";
const CLOSE_SENTINEL = "---END---";
/** Block body cap in bytes, after the opening marker. */
export const FOLLOW_UPS_BYTE_CAP = 256;
/** At most this many proposals are read. */
const MAX_PROPOSALS = 3;
export interface FollowUpProposal {
kind: CoachFollowUpKind;
domain: CoachStepDomain;
}
function normalise(raw: string): string {
return raw
.trim()
.replace(/^[-*•\s]+/, "")
.replace(/^["'`]+|["'`]+$/g, "")
.trim()
.toLowerCase();
}
function parseLine(line: string): FollowUpProposal | null {
const colon = line.indexOf(":");
if (colon < 1) return null;
const kind = normalise(line.slice(0, colon));
const domain = normalise(line.slice(colon + 1));
if (!isFollowUpKind(kind) || !PROPOSABLE_FOLLOW_UP_KINDS.has(kind)) {
return null;
}
const parsedDomain = coachStepDomainSchema.safeParse(domain);
if (!parsedDomain.success) return null;
return { kind, domain: parsedDomain.data };
}
export function parseFollowUpsSentinel(prose: string): {
prose: string;
proposals: Array<{ kind: CoachFollowUpKind; domain: CoachStepDomain }>;
proposals: FollowUpProposal[];
} {
return { prose, proposals: [] };
const open = prose.indexOf(OPEN_SENTINEL);
if (open === -1) return { prose, proposals: [] };
const before = prose.slice(0, open);
const afterOpen = prose.slice(open + OPEN_SENTINEL.length);
const close = afterOpen.indexOf(CLOSE_SENTINEL);
const dropped = (reason: string, stripped: string) => {
annotate({
action: { name: "coach.followUp.proposal_dropped" },
meta: { reason },
});
return { prose: stripped, proposals: [] };
};
if (close === -1) {
// No closing marker: the rest of the reply is the block. The raw marker
// must never reach the person, so the whole tail goes.
return dropped("no_end_marker", before.trimEnd());
}
const body = afterOpen.slice(0, close);
const after = afterOpen.slice(close + CLOSE_SENTINEL.length).trim();
const stripped = `${before.trimEnd()}${after ? `\n\n${after}` : ""}`;
if (Buffer.byteLength(body, "utf8") > FOLLOW_UPS_BYTE_CAP) {
return dropped("byte_overflow", stripped);
}
const proposals: FollowUpProposal[] = [];
const seen = new Set<string>();
for (const line of body.split(/\r?\n/)) {
const proposal = parseLine(line);
if (!proposal) continue;
const key = `${proposal.kind}:${proposal.domain}`;
if (seen.has(key)) continue;
seen.add(key);
proposals.push(proposal);
if (proposals.length === MAX_PROPOSALS) break;
}
return { prose: stripped, proposals };
}
+181 -5
View File
@@ -5,22 +5,198 @@
* stored table without a model call; any other becomes a turn-context hint.
* A chip that is no longer current degrades to a plain message.
*
* Not built yet: every request is a plain message.
* The request names only which chip was tapped. What the chip asks for (its
* kind, domain, window, period, table) is read from the stored message, so
* nothing the client sends can widen it.
*/
import type { CoachFollowUp } from "@/lib/ai/coach/types";
import type { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import type { CoachFollowUp, CoachStep } from "@/lib/ai/coach/types";
import {
coachFollowUpSchema,
coachStepSchema,
} from "@/lib/ai/coach/stream-events";
import {
formatPriorResultRef,
type PriorResultTurn,
} from "@/lib/ai/coach/results/refs";
import { widerWindow } from "./catalog";
/** A chip that resolved: what it asks for, and how it is answered. */
export interface ResolvedFollowUp {
sourceMessageId: string;
followUp: CoachFollowUp;
/** A line for the turn context, on a chip answered by the model. */
/**
* A line for the turn context, on a chip answered by the model. A reuse
* chip carries one too: it is the fallback when the stored table can no
* longer be served and the turn goes to the model after all.
*/
contextHint: string | null;
/**
* The readings the source reply counted for the chip's table, from its
* stored step; absent when the step carried no count.
*/
sourceCount?: number;
}
export async function resolveFollowUp(_args: {
/** 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,
},
});
}
/** The chips and steps a stored reply carries, each parsed against its schema. */
function storedDialog(metricSourceJson: string | null): {
followUps: CoachFollowUp[];
steps: CoachStep[];
} {
const none = { followUps: [], steps: [] };
if (!metricSourceJson) return none;
try {
const raw = JSON.parse(metricSourceJson) as {
followUps?: unknown;
steps?: unknown;
};
const each = <T>(value: unknown, schema: z.ZodType<T>): T[] =>
Array.isArray(value)
? value.flatMap((item) => {
const parsed = schema.safeParse(item);
return parsed.success ? [parsed.data] : [];
})
: [];
return {
followUps: each(raw.followUps, coachFollowUpSchema),
steps: each(raw.steps, coachStepSchema),
};
} catch {
return none;
}
}
/** The `m<k>.r<n>` name the turn's context gives a stored table, if any. */
function priorName(
sourceMessageId: string,
ref: string | undefined,
priorResults: readonly PriorResultTurn[],
): string | null {
if (!ref) return null;
const turn = priorResults.find((p) => p.messageId === sourceMessageId);
if (!turn || !turn.results.some((meta) => meta.ref === ref)) return null;
return formatPriorResultRef(turn.turnIndex, ref);
}
/**
* The turn-context line for a chip answered by the model. Server-written
* from the stored chip's enum fields only; null when the chip carries
* nothing to act on.
*/
export function followUpContextHint(
followUp: CoachFollowUp,
tableName: string | null,
): string | null {
const anchor = followUp.anchor;
const from = tableName
? ` The table from your last answer is ${tableName}: use show_result for it, do not fetch it again.`
: "";
const lead = `FOLLOW-UP: the person tapped the ${followUp.kind} chip under your last answer.`;
switch (followUp.kind) {
case "as_chart":
case "as_table": {
if (!tableName) return null;
const view = followUp.kind === "as_chart" ? "chart" : "table";
return `${lead} Call show_result with ref ${tableName} and view ${view}; do not fetch it again.`;
}
case "previous_period":
case "year_ago": {
if (!anchor?.window) return null;
const period = followUp.kind === "year_ago" ? "yearAgo" : "previous";
const granularity = anchor.granularity
? ` granularity=${anchor.granularity}`
: "";
return `${lead} Fetch get_metric_table metric=${anchor.domain} window=${anchor.window} period=${period}${granularity} and compare it with the current period.${from}`;
}
case "widen_window": {
const wider = anchor?.window ? widerWindow(anchor.window) : null;
if (!anchor || !wider) return null;
return `${lead} Fetch get_metric_table metric=${anchor.domain} window=${wider} and say what the longer view adds.${from}`;
}
case "related_metric": {
if (!anchor) return null;
const window = anchor.window ? ` window=${anchor.window}` : "";
return `${lead} Fetch get_metric_table metric=${anchor.domain}${window} and describe it alongside what you already read. A pattern between two metrics is an association, never a cause.${from}`;
}
case "continue":
// Answered with its own context lines (`continue.ts`).
return null;
}
}
export async function resolveFollowUp(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<ResolvedFollowUp | null> {
return null;
const { userId, conversationId, followUp } = args;
if (!followUp || !conversationId) return null;
const stale = (reason: string) => {
annotate({
action: { name: "coach.followUp.stale" },
meta: { reason },
});
return null;
};
let rows: Awaited<ReturnType<typeof latestMessages>>;
try {
rows = await latestMessages(userId, conversationId);
} catch {
return stale("unreadable");
}
// The chip must sit on the latest reply: an interrupted turn's empty
// marker does not count as one.
const latest = rows.find((m) => m.providerType !== "cancelled");
if (
!latest ||
latest.role !== "assistant" ||
latest.id !== followUp.messageId
) {
return stale("not_latest");
}
const stored = storedDialog(latest.metricSourceJson);
const chip = stored.followUps.find(
(candidate) => candidate.id === followUp.id,
);
if (!chip) return stale("unknown_chip");
const sourceCount = chip.anchor?.ref
? stored.steps.find((step) => step.resultRef === chip.anchor?.ref)?.count
: undefined;
const tableName = priorName(
latest.id,
chip.anchor?.ref,
args.priorResults ?? [],
);
annotate({
action: { name: "coach.followUp.resolved" },
meta: { kind: chip.kind, reuse: chip.reuse, origin: chip.origin },
});
return {
sourceMessageId: latest.id,
followUp: chip,
contextHint: followUpContextHint(chip, tableName),
...(sourceCount !== undefined ? { sourceCount } : {}),
};
}
@@ -0,0 +1,415 @@
/**
* v1.39.4 — a tapped follow-up chip through the turn pipeline.
*
* - A reuse chip ("as a chart", "as a table") is answered from the stored
* table: no budget reservation, no model, one assistant message tagged
* `reuse`, and a `result` frame whose rows are the stored rows.
* - A chip that is no longer current degrades to a plain message.
* - Any other chip runs the model with a server-written context line.
* - With the pref off the model turn carries no `followUps` frame.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const m = vi.hoisted(() => ({
findMany: vi.fn(),
userFindUnique: vi.fn(),
annotate: vi.fn(),
readMessageResults: vi.fn(),
appendMessage: 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(),
resolveModuleMap: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
coachMessage: { findMany: m.findMany },
user: { findUnique: m.userFindUnique },
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: m.annotate }));
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: m.resolveModuleMap,
}));
vi.mock("@/lib/ai/coach/persistence", () => ({
appendMessage: m.appendMessage,
createConversation: vi.fn(),
readMessageResults: m.readMessageResults,
}));
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 {
CoachFollowUp,
CoachResultTable,
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 STORED: CoachResultTable = {
ref: "r1",
source: {
tool: "get_metric_table",
domain: "bp",
window: "last30days",
period: "current",
granularity: "week",
},
shape: "timeSeries",
titleKey: "coach.result.title.byWeek",
title: "Blood pressure by week",
rowCount: 3,
chartKind: "line",
displayed: false,
columns: [
{ key: "week", kind: "period", labelKey: "k", label: "Week" },
{ key: "sys", kind: "number", labelKey: "k", label: "Systolic" },
],
rows: [
["2026-W36", 128],
["2026-W37", null],
["2026-W38", 131],
],
truncated: false,
chart: { kind: "line", x: "week", series: ["sys"] },
};
const AS_CHART: CoachFollowUp = {
id: "f1",
kind: "as_chart",
labelKey: "coach.followUp.asChart",
label: "Show as a chart",
anchor: {
ref: "r1",
domain: "bp",
window: "last30days",
granularity: "week",
period: "current",
},
reuse: true,
origin: "server",
};
const PREVIOUS: CoachFollowUp = {
...AS_CHART,
id: "f2",
kind: "previous_period",
labelKey: "coach.followUp.previousPeriod",
label: "Compare with the period before",
reuse: false,
};
function latestReply(followUps: CoachFollowUp[]) {
return [
{
id: "m-last",
role: "assistant",
providerType: "openai",
metricSourceJson: JSON.stringify({
windows: [],
metrics: [],
followUps,
results: [{ ...STORED, rows: undefined, columns: undefined }],
}),
},
];
}
function input(over: Partial<TurnInput> = {}): TurnInput {
return {
userId: "u1",
locale: "en",
signal: new AbortController().signal,
conversationId: "c1",
message: "Show as a chart",
scope: undefined,
guidedQuestion: undefined,
workoutId: undefined,
followUp: { messageId: "m-last", id: "f1" },
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 annotated(name: string) {
return m.annotate.mock.calls.filter(
([arg]) => (arg as { action: { name: string } }).action.name === name,
);
}
/** The model path, enough of it to reach the frames. */
function modelPath(prefs = DEFAULT_COACH_PREFS) {
m.assembleTurnContext.mockResolvedValue({
coachPrefs: prefs,
snapshot: { provenance: { windows: [], metrics: [] } },
});
m.resolveTurnChain.mockResolvedValue({ ok: true, chain: [], toolMode: true });
m.reserveTurnBudget.mockResolvedValue({ ok: true, reservation: {} });
const table = { ...STORED, chart: null, chartKind: null, displayed: true };
m.runTurnModel.mockResolvedValue({
ok: true,
result: { content: "x", model: "gpt" },
workingProviderType: "openai",
toolTrace: [],
totalTokens: 12,
cachedTokens: 0,
steps: [
{
id: "s1",
tool: "get_metric_table",
labelKey: "coach.step.readWindow",
label: "x",
domain: "bp",
window: "last30days",
status: "done",
count: 20,
resultRef: "r1",
},
],
results: [table],
forcedFinal: false,
inventory: [
{ tool: "get_metric_series", metric: "bp", domain: "bp", present: true },
],
correlations: [],
});
m.guardReply.mockResolvedValue({
ok: true,
reply: {
replyText: "Steady.",
outboundBlocked: false,
referencedResults: ["r1"],
followUpProposals: [],
clarification: null,
keyValuesSentinel: {
keyValues: [],
malformed: false,
malformedEntries: [],
},
groundedFigures: [],
unverifiedStripped: 0,
},
});
m.surfaceCards.mockResolvedValue({ suggestion: null, action: null });
m.persistAssistantReply.mockResolvedValue({ messageId: "m-new" });
}
beforeEach(() => {
for (const fn of Object.values(m)) fn.mockReset();
m.resolveTurnConversation.mockResolvedValue({
conversation: {
conversationId: "c1",
priorTurns: [],
priorUserMessages: [],
priorToolFigures: [],
priorSummary: null,
priorResults: [{ messageId: "m-last", turnIndex: 2, results: [STORED] }],
},
});
m.resolveModuleMap.mockResolvedValue({});
m.userFindUnique.mockResolvedValue({ coachPrefsJson: null });
m.appendMessage.mockImplementation(async (params: { role: string }) => ({
id: params.role === "assistant" ? "m-reuse" : "m-user",
}));
});
describe("a reuse chip", () => {
it("answers from the stored table with no budget and no model", async () => {
m.findMany.mockResolvedValue(latestReply([AS_CHART]));
m.readMessageResults.mockResolvedValue([STORED]);
const res = await runCoachTurn(input());
const out = await frames(res);
expect(m.reserveTurnBudget).not.toHaveBeenCalled();
expect(m.runTurnModel).not.toHaveBeenCalled();
expect(m.resolveTurnChain).not.toHaveBeenCalled();
expect(m.readMessageResults).toHaveBeenCalledWith(
"u1",
"c1",
"m-last",
expect.any(Function),
);
// The person's message, then exactly one assistant message.
expect(m.persistUserTurn).toHaveBeenCalledWith("c1", "Show as a chart");
expect(m.appendMessage).toHaveBeenCalledTimes(1);
const persisted = m.appendMessage.mock.calls[0][0];
expect(persisted).toMatchObject({
role: "assistant",
providerType: "reuse",
content: "Here are the same figures from my last answer, unchanged.",
});
expect(persisted.results[0].rows).toEqual(STORED.rows);
expect(persisted.tokensUsed).toBeUndefined();
// Consecutive token frames collapsed: the caption streams word by word.
const order = out
.map((f) => f.type)
.filter((type, i, all) => type !== "token" || all[i - 1] !== "token");
expect(order).toEqual([
"step",
"token",
"provenance",
"result",
"followUps",
"done",
]);
expect(
out.flatMap((f) => (f.type === "token" ? [f.token] : [])).join(""),
).toBe("Here are the same figures from my last answer, unchanged.");
const result = out.find((f) => f.type === "result");
expect(result?.type === "result" && result.result).toMatchObject({
ref: "r1",
rows: STORED.rows,
columns: STORED.columns,
displayed: true,
chart: STORED.chart,
reusedFrom: { messageId: "m-last", ref: "r1" },
});
const followUps = out.find((f) => f.type === "followUps");
// Shown as a chart now, so the other view is what is offered.
expect(
followUps?.type === "followUps" && followUps.followUps.map((c) => c.kind),
).toEqual(["as_table"]);
const done = out.at(-1);
expect(done).toMatchObject({
type: "done",
conversationId: "c1",
messageId: "m-reuse",
});
// The provenance carries the table's metadata, never its values.
const provenance = out.find((f) => f.type === "provenance");
expect(JSON.stringify(provenance)).not.toContain("131");
expect(annotated("coach.followUp.reused")).toHaveLength(1);
});
it("drops the chart on 'as a table'", async () => {
const asTable = { ...AS_CHART, kind: "as_table" as const };
m.findMany.mockResolvedValue(latestReply([asTable]));
m.readMessageResults.mockResolvedValue([STORED]);
const out = await frames(await runCoachTurn(input()));
const result = out.find((f) => f.type === "result");
expect(result?.type === "result" && result.result.chart).toBeNull();
expect(result?.type === "result" && result.result.rows).toEqual(
STORED.rows,
);
});
it("goes to the model with the chip's hint when the table is withheld", async () => {
m.findMany.mockResolvedValue(latestReply([AS_CHART]));
m.readMessageResults.mockResolvedValue([
{ ref: "r1", withheld: "module_disabled" },
]);
modelPath();
await frames(await runCoachTurn(input()));
expect(annotated("coach.followUp.reuse_unavailable")).toHaveLength(1);
expect(m.appendMessage).not.toHaveBeenCalled();
expect(m.reserveTurnBudget).toHaveBeenCalledTimes(1);
const { turnHints } = m.runTurnModel.mock.calls[0][0];
expect(turnHints).toEqual([
expect.stringContaining("show_result with ref m2.r1 and view chart"),
]);
});
});
describe("a stale chip", () => {
it("degrades to a plain message", async () => {
m.findMany.mockResolvedValue([
{
id: "m-newer",
role: "assistant",
providerType: "openai",
metricSourceJson: null,
},
...latestReply([AS_CHART]),
]);
modelPath();
await frames(await runCoachTurn(input()));
expect(annotated("coach.followUp.stale")).toHaveLength(1);
expect(m.readMessageResults).not.toHaveBeenCalled();
expect(m.persistUserTurn).toHaveBeenCalledWith("c1", "Show as a chart");
expect(m.runTurnModel.mock.calls[0][0].turnHints).toEqual([]);
});
});
describe("a chip answered by the model", () => {
it("carries a server-written context line", async () => {
m.findMany.mockResolvedValue(latestReply([PREVIOUS]));
modelPath();
await frames(
await runCoachTurn(
input({
message: "Compare with the period before",
followUp: { messageId: "m-last", id: "f2" },
}),
),
);
expect(m.readMessageResults).not.toHaveBeenCalled();
expect(m.runTurnModel.mock.calls[0][0].turnHints).toEqual([
"FOLLOW-UP: the person tapped the previous_period chip under your last answer. Fetch get_metric_table metric=bp window=last30days period=previous granularity=week and compare it with the current period. The table from your last answer is m2.r1: use show_result for it, do not fetch it again.",
]);
});
});
describe("the follow-up pref", () => {
it("offers chips on a model turn by default", async () => {
modelPath();
const out = await frames(
await runCoachTurn(input({ followUp: undefined })),
);
expect(out.some((f) => f.type === "followUps")).toBe(true);
});
it("sends no followUps frame when switched off", async () => {
modelPath({ ...DEFAULT_COACH_PREFS, followUpChips: false });
const out = await frames(
await runCoachTurn(input({ followUp: undefined })),
);
expect(out.map((f) => f.type)).not.toContain("followUps");
expect(out.at(-1)?.type).toBe("done");
});
it("sends no followUps frame on a reuse turn when switched off", async () => {
m.userFindUnique.mockResolvedValue({
coachPrefsJson: { followUpChips: false },
});
m.findMany.mockResolvedValue(latestReply([AS_CHART]));
m.readMessageResults.mockResolvedValue([STORED]);
const out = await frames(await runCoachTurn(input()));
expect(out.map((f) => f.type)).not.toContain("followUps");
expect(out.some((f) => f.type === "result")).toBe(true);
});
});
+18 -2
View File
@@ -1,10 +1,26 @@
/**
* Prompt rules for proposing follow-up chips from the catalog.
*
* Not written yet: empty.
* The server derives the chips from what the turn read; a proposal only
* says which of them the model thinks matter most. The model names a kind
* and a domain, never a label: the server renders every word the person
* sees, and drops a proposal for a domain the turn did not read.
*
* English whatever the reply language, like the rest of the tool-mode
* prompt: these are instructions to the model, never shown to the person.
*/
import type { Locale } from "@/lib/i18n/config";
const FOLLOW_UPS_ADDENDUM = `FOLLOW-UP SUGGESTIONS
The person may see up to three suggested follow-ups under your answer; the server picks them from what you read. You may name the ones that fit your answer best, in a block after your prose and before any ---KEYVALUES--- block:
---FOLLOWUPS---
previous_period: bp
as_chart: bp
---END---
- One line each, at most three: a kind, a colon, a metric or domain you read with a tool on THIS turn and that returned data.
- Kinds: as_chart, as_table, previous_period, year_ago, widen_window, related_metric.
- Never write a label or a question: the server words them. Leave the block out when nothing fits, and never mention it in your prose.`;
export function followUpsAddendum(_locale: Locale): string {
return "";
return FOLLOW_UPS_ADDENDUM;
}
+13
View File
@@ -37,6 +37,10 @@ import {
type SettledToolCall,
} from "@/lib/ai/coach/results/project";
import { deriveChartSpec } from "@/lib/ai/coach/results/chart-spec";
import {
correlationPartners,
type CorrelationPair,
} from "@/lib/ai/coach/follow-ups/derive";
import {
createResultRefAllocator,
type PriorResultTurn,
@@ -110,6 +114,11 @@ export type ModelOutcome =
forcedFinal: boolean;
/** v1.39.4 — the DATA INVENTORY entries; null on the no-tools path. */
inventory: InventoryEntry[] | null;
/**
* v1.39.4 — the metric pairs a `get_correlations` call returned this
* turn, for the related-metric chip. Empty on the no-tools path.
*/
correlations: CorrelationPair[];
}
| { ok: false; code: string };
@@ -297,6 +306,9 @@ export async function runTurnModel(args: {
}).map(withChart),
forcedFinal: loop.forcedFinal === true,
inventory: inventory.entries,
correlations: correlationPartners(
settled.filter((call) => call !== undefined),
),
};
}
// v1.22 (#89) — the no-tools path (local / Ollama / exo, and any chain
@@ -365,6 +377,7 @@ export async function runTurnModel(args: {
results: [],
forcedFinal: false,
inventory: null,
correlations: [],
};
} catch (err) {
// The provider chain failed outright — no tokens were billed, so refund
+13
View File
@@ -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 { readFollowUpHistory } from "@/lib/ai/coach/follow-ups/derive";
import { runTurnModel } from "./model";
import { persistAssistantReply } from "./persist";
@@ -56,6 +57,7 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
userId,
conversationId: input.conversationId,
followUp: input.followUp,
priorResults: conversation.priorResults,
});
if (resolvedFollowUp?.followUp.reuse) {
const reused = await runReuseTurn({
@@ -152,11 +154,22 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
coachPrefs: ctx.coachPrefs,
reply,
});
// v1.39.4 — the record's history for the tables' metrics, which the
// year-ago and wider-window chips need. Read only when a table could
// carry one.
const history = reply.outboundBlocked
? undefined
: await readFollowUpHistory({
userId,
results: model.results,
prefs: ctx.coachPrefs,
});
const dialog = assembleTurnDialog({
model,
reply,
prefs: ctx.coachPrefs,
locale,
history,
});
const provenance = buildTurnProvenance({
snapshotProvenance: ctx.snapshot.provenance,
+23 -12
View File
@@ -20,16 +20,18 @@ import type {
import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
import type { CoachToolTrace } from "@/lib/ai/coach/tools";
import { buildMethod } from "@/lib/ai/coach/method";
import { deriveFollowUps } from "@/lib/ai/coach/follow-ups/derive";
import {
deriveFollowUps,
followUpChipsEnabled,
type FollowUpHistory,
} from "@/lib/ai/coach/follow-ups/derive";
import { numberFollowUps } from "@/lib/ai/coach/follow-ups/catalog";
import { buildContinueFollowUp } from "@/lib/ai/coach/follow-ups/continue";
import type { CoachPrefs } from "@/lib/validations/coach-prefs";
import type { ModelOutcome } from "./model";
import type { GuardedReply } from "./reply-guards";
/** v1.39.4 — at most this many chips under a reply. */
const MAX_FOLLOW_UPS = 3;
/** What the dialog adds to a guarded reply. */
export interface TurnDialog {
/** The tables to stream and persist; empty on a blocked turn. */
@@ -42,7 +44,7 @@ export interface TurnDialog {
}
/** A table's metadata: everything but the values, for the plaintext blob. */
function toResultMeta(table: CoachResultTable): CoachResultMeta {
export function toResultMeta(table: CoachResultTable): CoachResultMeta {
return {
ref: table.ref,
source: table.source,
@@ -66,6 +68,8 @@ export function assembleTurnDialog(args: {
reply: GuardedReply;
prefs: CoachPrefs;
locale: Locale;
/** The record's history for the tables' metrics, for the history chips. */
history?: FollowUpHistory;
}): TurnDialog {
const { model, reply, prefs, locale } = args;
const blocked = reply.outboundBlocked;
@@ -77,12 +81,16 @@ export function assembleTurnDialog(args: {
);
const metas = results.map(toResultMeta);
const method = buildMethod({ steps: model.steps, results: metas, locale });
const continueChip = blocked
? null
: buildContinueFollowUp({ forcedFinal: model.forcedFinal, locale });
const followUps = blocked
? []
: [
// No chips on a blocked turn, when the pref is off, or under a clarifying
// question: its choices are the next step, and chips beside them would
// compete with the answer the question waits for.
const offerChips =
!blocked && followUpChipsEnabled(prefs) && reply.clarification === null;
const continueChip = offerChips
? buildContinueFollowUp({ forcedFinal: model.forcedFinal, locale })
: null;
const followUps = offerChips
? numberFollowUps([
...(continueChip ? [continueChip] : []),
...deriveFollowUps({
results: metas,
@@ -92,8 +100,11 @@ export function assembleTurnDialog(args: {
forcedFinal: model.forcedFinal,
prefs,
locale,
history: args.history,
correlations: model.correlations,
}),
].slice(0, MAX_FOLLOW_UPS);
])
: [];
return {
results,
method,
+158 -3
View File
@@ -5,16 +5,171 @@
* Streams `step → token (caption) → provenance → result → followUps → done`
* and persists an assistant message with `providerType: "reuse"`.
*
* Not built yet: answers null, and the turn runs as a plain model turn.
* The table is copied as it was stored: the same rows, the same columns,
* marked with where it came from. Only the view changes: "as a chart" keeps
* the stored chart, "as a table" drops it so the table is what shows.
*
* Answers null, before anything is written, when the stored table cannot be
* served (withheld, unreadable, or no chart to show). The pipeline then runs
* the turn through the model with the chip's context hint instead.
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveModuleMap } from "@/lib/modules/gate";
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
import type {
CoachProvenance,
CoachResultTable,
CoachStep,
} from "@/lib/ai/coach/types";
import { appendMessage, readMessageResults } from "@/lib/ai/coach/persistence";
import { isCoachDomainWithheld } from "@/lib/ai/coach/results/domain-module";
import {
COACH_REUSE_CAPTION_KEY,
COACH_STEP_LABEL_KEYS,
} from "@/lib/ai/coach/dialog-keys";
import { buildMethod } from "@/lib/ai/coach/method";
import { deriveFollowUps } from "@/lib/ai/coach/follow-ups/derive";
import type { ResolvedFollowUp } from "@/lib/ai/coach/follow-ups/resolve";
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
import { createSseStream } from "@/lib/sse/create-stream";
import { persistUserTurn } from "./conversation";
import { toResultMeta } from "./provenance";
import { SSE_HEADERS, createTurnEmitter, emitReply } from "./sse";
import type { TurnConversation, TurnInput } from "./types";
export async function runReuseTurn(_args: {
/** The copy of a stored table this turn shows, or null when it cannot. */
async function copyStoredTable(args: {
userId: string;
conversationId: string;
resolved: ResolvedFollowUp;
}): Promise<CoachResultTable | null> {
const { userId, conversationId, resolved } = args;
const { followUp, sourceMessageId } = resolved;
const ref = followUp.anchor?.ref;
if (!ref) return null;
const modules = await resolveModuleMap(userId);
const entries = await readMessageResults(
userId,
conversationId,
sourceMessageId,
(domain) => isCoachDomainWithheld(domain, modules),
);
const entry = entries?.find((candidate) => candidate.ref === ref);
if (!entry || "withheld" in entry) return null;
const chart = followUp.kind === "as_chart" ? entry.chart : null;
if (followUp.kind === "as_chart" && chart === null) return null;
return {
...entry,
ref: "r1",
displayed: true,
chart,
chartKind: chart?.kind ?? null,
reusedFrom: { messageId: sourceMessageId, ref },
};
}
export async function runReuseTurn(args: {
input: TurnInput;
conversation: TurnConversation;
resolved: ResolvedFollowUp;
}): Promise<Response | null> {
return null;
const { input, conversation, resolved } = args;
const { userId, locale } = input;
const conversationId = conversation.conversationId;
if (!resolved.followUp.reuse) return null;
const table = await copyStoredTable({ userId, conversationId, resolved });
if (!table) {
annotate({
action: { name: "coach.followUp.reuse_unavailable" },
meta: { kind: resolved.followUp.kind },
});
return null;
}
await persistUserTurn(conversationId, input.message);
const { t } = getServerTranslator(locale);
const caption = t(COACH_REUSE_CAPTION_KEY);
const { source } = table;
const step: CoachStep = {
id: "s1",
tool: "show_result",
labelKey: COACH_STEP_LABEL_KEYS.reuse,
label: t(COACH_STEP_LABEL_KEYS.reuse),
domain: source.domain,
window: source.window,
period: source.period,
...(source.granularity ? { granularity: source.granularity } : {}),
status: "done",
...(resolved.sourceCount !== undefined
? { count: resolved.sourceCount }
: {}),
resultRef: table.ref,
};
const meta = toResultMeta(table);
const prefsRow = await prisma.user.findUnique({
where: { id: userId },
select: { coachPrefsJson: true },
});
const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
// The other view of the same table, if it has one. Nothing that would need
// a read: a reuse turn reads nothing.
const followUps = deriveFollowUps({
results: [meta],
steps: [step],
inventory: null,
proposals: [],
forcedFinal: false,
prefs,
locale,
});
const method = buildMethod({ steps: [step], results: [meta], locale });
const provenance: CoachProvenance = {
windows: [],
metrics: [],
steps: [step],
...(method ? { method } : {}),
results: [meta],
...(followUps.length > 0 ? { followUps } : {}),
};
const message = await appendMessage({
conversationId,
role: "assistant",
content: caption,
metricSource: provenance,
providerType: "reuse",
promptVersion: PROMPT_VERSION,
results: [table],
});
annotate({
action: { name: "coach.followUp.reused" },
meta: { kind: resolved.followUp.kind, rows: table.rows.length },
});
const stream = createSseStream(async (controller) => {
const emitter = createTurnEmitter(controller);
if (!emitter.aborted()) emitter.emit({ type: "step", step });
await emitReply(
emitter,
{
ok: true,
replyText: caption,
provenance,
suggestion: null,
action: null,
results: [table],
followUps,
clarification: null,
messageId: message.id,
totalTokens: 0,
model: null,
},
conversationId,
);
});
return new Response(stream, { status: 200, headers: SSE_HEADERS });
}
+11
View File
@@ -187,6 +187,17 @@ export const coachPrefsSchema = z.object({
// back to `DEFAULT_REMINDER_SUGGESTION_PREFS`. When the key IS present,
// its inner fields default (so `{}` fills to the all-on shape).
reminderSuggestions: coachReminderSuggestionPrefsSchema.optional(),
// v1.39.4 — up to three follow-up chips under the Coach's latest answer.
// On by default: an absent key reads as on (`followUpChipsEnabled`), and
// like `reminderSuggestions` it carries no schema default, so a legacy
// blob and `parse({})` stay byte-identical. Only an explicit `false`
// switches the chips off.
followUpChips: z
.boolean()
.optional()
.describe(
"v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false` switches them off.",
),
});
export type CoachPrefs = z.infer<typeof coachPrefsSchema>;