diff --git a/e2e/coach-activity.spec.ts b/e2e/coach-activity.spec.ts new file mode 100644 index 000000000..259a8b2f6 --- /dev/null +++ b/e2e/coach-activity.spec.ts @@ -0,0 +1,488 @@ +import AxeBuilder from "@axe-core/playwright"; +import type { Page } from "@playwright/test"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; + +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; +import { + FIXED_NOW, + done, + end, + feed, + installLiveCoachStream, + posts, + serveConversation, + tokens, + applyTheme, + type StubConversation, +} from "./setup/coach-live-stream"; + +/** + * The live trail of a Coach turn: one quiet status line at the top of the + * answer. + * + * Contracts under test, at 1440 and 390 px, light and dark: + * + * 1. While the turn runs the line says what is happening right now and + * moves with the frames: Thinking… → Fetching blood pressure… → + * Summarising 214 readings… → Writing the answer…. + * 2. A table the turn has read shows as a one-line preview before the + * first token. + * 3. Nothing opens by itself: the trail stays closed (`aria-expanded` + * false) in every state, after the answer and after a reload. + * 4. After the answer the line is one summary ("Thought it through, 1 + * lookup, 7 s"); the preview gives way to the answer's chart. + * 5. A tap opens the trail; the reasoning text of a round sits behind its + * own closed disclosure. + * 6. No horizontal scroll, a 44 px tap target on phones, axe clean. + * + * The chat stream is fed frame by frame from the spec + * (`installLiveCoachStream`), so every live state is observed rather than + * flashing by. Set COACH_SCREENSHOTS_DIR to also write screenshots of each + * state. + */ + +const CONVERSATION_ID = "coach-activity-e2e"; +const QUESTION = "How was my blood pressure this month?"; +const ANSWER = + "Your blood pressure held a steady band this month, around 126/82 mmHg on most days. Nothing stands out against your usual range."; + +const BP_VALUES: Array<[number, number]> = [ + [128, 84], + [125, 82], + [131, 85], + [122, 80], + [124, 81], + [127, 83], + [121, 79], + [126, 82], + [123, 80], + [126, 81], +]; + +const BP_TABLE = { + ref: "r1", + source: { + tool: "get_metric_table", + domain: "bp", + window: "last30days", + period: "current", + granularity: "day", + }, + shape: "timeSeries", + titleKey: "coach.result.title.byDay", + title: "Blood pressure by day", + rowCount: BP_VALUES.length, + chartKind: "line", + displayed: false, + columns: [ + { + key: "day", + kind: "period", + labelKey: "coach.result.column.day", + label: "Day", + }, + { + key: "systolic", + kind: "number", + labelKey: "coach.result.column.systolic", + label: "Systolic", + unit: "mmHg", + decimals: 0, + }, + { + key: "diastolic", + kind: "number", + labelKey: "coach.result.column.diastolic", + label: "Diastolic", + unit: "mmHg", + decimals: 0, + }, + { + key: "readings", + kind: "count", + labelKey: "coach.result.column.readings", + label: "Readings", + }, + ], + rows: BP_VALUES.map(([sys, dia], i) => [ + new Date(Date.UTC(2026, 8, 7 + i)).toISOString().slice(0, 10), + sys, + dia, + i === 0 ? 205 : 1, + ]), + truncated: false, + chart: { kind: "line", x: "day", series: ["systolic", "diastolic"] }, +}; +const BP_SHOWN = { ...BP_TABLE, displayed: true }; +const BP_META = { + ref: "r1", + source: BP_TABLE.source, + shape: BP_TABLE.shape, + titleKey: BP_TABLE.titleKey, + title: BP_TABLE.title, + rowCount: BP_TABLE.rowCount, + chartKind: "line", + displayed: true, +}; + +const STEP = { + id: "s1", + tool: "get_metric_table", + labelKey: "coach.step.readWindow", + label: "Checking: Blood pressure, last 30 days", + domain: "bp", + window: "last30days", + granularity: "day", +}; + +function entry( + id: string, + phase: string, + status: string, + label: string, + extra: Record = {}, +) { + return { + type: "activity", + activity: { + id, + phase, + status, + round: phase === "answer" ? 2 : 1, + labelKey: `insights.coach.activity.${phase}`, + label, + ...extra, + }, + }; +} + +const THINK_TITLE = "Looking at the whole month"; +const THINK_TEXT = + "The month has daily readings; a daily table answers this best."; + +const ACTIVITY_META = [ + { + id: "a1", + phase: "thinking", + status: "done", + round: 1, + labelKey: "insights.coach.activity.thinking", + label: "Thinking…", + durationMs: 2_400, + }, + { + id: "a2", + phase: "fetch", + status: "done", + round: 1, + labelKey: "insights.coach.activity.fetching", + label: "Fetching blood pressure, last 30 days…", + stepRef: "s1", + count: 214, + durationMs: 900, + }, + { + id: "a3", + phase: "digest", + status: "done", + round: 1, + labelKey: "insights.coach.activity.digestDone", + label: "214 readings from 1 area", + count: 214, + durationMs: 400, + }, + { + id: "a4", + phase: "answer", + status: "done", + round: 2, + labelKey: "insights.coach.activity.answer", + label: "Writing the answer…", + durationMs: 3_000, + }, +]; + +const METRIC_SOURCE = { + windows: ["last30days"], + metrics: ["bp"], + counts: { bp: 214 }, + steps: [{ ...STEP, status: "done", count: 214, resultRef: "r1" }], + activity: ACTIVITY_META, + results: [BP_META], + method: { + entries: [ + { + domain: "bp", + window: "last30days", + granularity: "day", + count: 214, + aggregation: "mean", + }, + ], + text: "Blood pressure, last 30 days, 214 readings, daily averages.", + }, +}; + +function conversation(): StubConversation { + return { + id: CONVERSATION_ID, + title: "Blood pressure this month", + messages: [], + results: { "coach-activity-e2e-a1": [BP_SHOWN] }, + trails: { + "coach-activity-e2e-a1": { + entries: [{ id: "a1", title: THINK_TITLE, text: THINK_TEXT }], + }, + }, + }; +} + +const VIEWPORTS = [ + { name: "1440", width: 1440, height: 900 }, + { name: "390", width: 390, height: 844 }, +] as const; + +const SHOTS = process.env.COACH_SCREENSHOTS_DIR; + +async function shot(page: Page, name: string) { + if (!SHOTS) return; + mkdirSync(SHOTS, { recursive: true }); + await page.screenshot({ path: join(SHOTS, `${name}.png`) }); +} + +async function noHorizontalScroll(page: Page) { + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); +} + +test.describe("Coach live trail", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + for (const viewport of VIEWPORTS) { + for (const theme of ["light", "dark"] as const) { + test(`${viewport.name} px, ${theme}: one quiet line that never opens by itself`, async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "sets its own viewports; one project suffices", + ); + const label = `${viewport.name}-${theme}`; + await page.setViewportSize(viewport); + await page.clock.setFixedTime(FIXED_NOW); + await serveAiBlock(page, aiBlockAvailable()); + const stub = conversation(); + await serveConversation(page, stub); + await installLiveCoachStream(page); + await applyTheme(page, theme); + + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + await page.locator('[data-slot="coach-input-textarea"]').fill(QUESTION); + await page.locator('[data-slot="coach-input-send"]').click(); + + const turn = page.locator( + '[role="log"] [data-slot="coach-bubble-assistant"]', + ); + const line = turn.locator('[data-slot="coach-turn-steps"]'); + const toggle = line.locator('[data-slot="coach-turn-steps-toggle"]'); + const current = line.locator('[data-slot="coach-turn-steps-active"]'); + + // 1. Before any frame: Thinking…, closed. + await expect(line).toHaveAttribute("data-state", "running"); + await expect(current).toHaveText("Thinking…"); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + + await feed(page, [entry("a1", "thinking", "running", "Thinking…")]); + await feed(page, [ + entry("a1", "thinking", "running", "Thinking…", { + title: THINK_TITLE, + }), + ]); + await expect(current).toHaveText(THINK_TITLE); + + await feed(page, [ + entry("a1", "thinking", "done", "Thinking…", { + title: THINK_TITLE, + text: THINK_TEXT, + durationMs: 2_400, + }), + { type: "step", step: { ...STEP, status: "running" } }, + entry( + "a2", + "fetch", + "running", + "Fetching blood pressure, last 30 days…", + { + stepRef: "s1", + }, + ), + ]); + await expect(current).toHaveText( + "Fetching blood pressure, last 30 days…", + ); + + // 2. The table it read previews before the first token. + await feed(page, [ + { type: "result", result: BP_TABLE, interim: true }, + { type: "step", step: { ...STEP, status: "done", count: 214 } }, + entry( + "a2", + "fetch", + "done", + "Fetching blood pressure, last 30 days…", + { + stepRef: "s1", + count: 214, + durationMs: 900, + }, + ), + entry("a3", "digest", "running", "Summarising 214 readings…", { + count: 214, + }), + ]); + await expect(current).toHaveText("Summarising 214 readings…"); + const preview = turn.locator( + '[data-slot="coach-interim-result"][data-ref="r1"]', + ); + await expect(preview).toBeVisible(); + await expect(preview).toContainText("Blood pressure by day"); + await expect( + turn.locator('[data-slot="coach-answer-bubble"]'), + ).toHaveCount(0); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await noHorizontalScroll(page); + await shot(page, `${label}-1-running`); + + await feed(page, [ + entry("a3", "digest", "done", "214 readings from 1 area", { + count: 214, + durationMs: 400, + }), + entry("a4", "answer", "running", "Writing the answer…"), + ...tokens(ANSWER), + ]); + await expect(current).toHaveText("Writing the answer…"); + await expect( + turn.locator('[data-slot="coach-answer-bubble"]'), + ).toContainText(ANSWER); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + + // The persisted copy lands with the refetch after `done`. + stub.messages.push( + { id: "coach-activity-e2e-u1", role: "user", content: QUESTION }, + { + id: "coach-activity-e2e-a1", + role: "assistant", + content: ANSWER, + metricSource: METRIC_SOURCE, + }, + ); + await feed(page, [ + entry("a4", "answer", "done", "Writing the answer…", { + durationMs: 3_000, + }), + { type: "provenance", metricSource: METRIC_SOURCE }, + { type: "result", result: BP_SHOWN }, + done(CONVERSATION_ID, "coach-activity-e2e-a1"), + ]); + await end(page); + + // 4. One calm summary, still closed; the preview became the chart. + const bubble = page + .locator('[data-slot="coach-bubble-assistant"]') + .filter({ hasText: ANSWER }); + const settled = bubble.locator('[data-slot="coach-turn-steps"]'); + await expect(settled).toHaveAttribute("data-state", "done"); + await expect( + settled.locator('[data-slot="coach-turn-steps-done"]'), + ).toHaveText("Thought it through, 1 lookup, 7 s"); + const settledToggle = settled.locator( + '[data-slot="coach-turn-steps-toggle"]', + ); + await expect(settledToggle).toHaveAttribute("aria-expanded", "false"); + await expect( + bubble.locator('[data-slot="coach-interim-result"]'), + ).toHaveCount(0); + await expect( + bubble.locator('[data-slot="coach-result-chart"][data-ref="r1"]'), + ).toBeVisible(); + await expect( + bubble.locator('[data-slot="coach-result-chart-plot"]'), + ).toBeVisible(); + expect((await posts(page))[0]).toMatchObject({ message: QUESTION }); + await noHorizontalScroll(page); + await shot(page, `${label}-2-settled`); + + // 6. A 44 px tap target on phones. + if (viewport.width < 640) { + const box = await settledToggle.boundingBox(); + expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); + } + + // 5. A tap opens the trail; the round's reasoning stays behind its own + // closed disclosure. + await settledToggle.click(); + await expect(settledToggle).toHaveAttribute("aria-expanded", "true"); + const panel = settled.locator('[data-slot="coach-turn-steps-panel"]'); + await expect(panel).toBeVisible(); + const rows = panel.locator('[data-slot="coach-turn-step"]'); + await expect(rows).toHaveCount(4); + await expect(rows.nth(0)).toHaveAttribute("data-phase", "thinking"); + await expect(rows.nth(1)).toHaveAttribute("data-phase", "fetch"); + await expect( + panel.locator('[data-slot="coach-method-line"]'), + ).toContainText(METRIC_SOURCE.method.text); + const detail = rows + .nth(0) + .locator('[data-slot="coach-turn-step-detail-toggle"]'); + await expect(detail).toHaveAttribute("aria-expanded", "false"); + await expect(panel).not.toContainText(THINK_TEXT); + await detail.click(); + await expect(panel).toContainText(THINK_TEXT); + await noHorizontalScroll(page); + await shot(page, `${label}-3-open`); + + const axe = await new AxeBuilder({ page }) + .include('[data-slot="coach-bubble-assistant"]') + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect( + axe.violations.map((v) => ({ + id: v.id, + nodes: v.nodes.map((n) => n.target.join(" ")), + })), + ).toEqual([]); + + // 3. After a reload the trail is closed again, and opening it reads + // the stored titles and texts. + await page.goto(`/coach?c=${CONVERSATION_ID}`, { + waitUntil: "domcontentloaded", + }); + const reloaded = page + .locator('[data-slot="coach-bubble-assistant"]') + .filter({ hasText: ANSWER }) + .locator('[data-slot="coach-turn-steps"]'); + await expect(reloaded).toHaveAttribute("data-state", "done"); + await expect( + reloaded.locator('[data-slot="coach-turn-steps-done"]'), + ).toHaveText("Thought it through, 1 lookup, 7 s"); + const reloadedToggle = reloaded.locator( + '[data-slot="coach-turn-steps-toggle"]', + ); + await expect(reloadedToggle).toHaveAttribute("aria-expanded", "false"); + await expect( + reloaded.locator('[data-slot="coach-turn-steps-panel"]'), + ).toHaveCount(0); + await reloadedToggle.click(); + await expect( + reloaded.locator('[data-slot="coach-turn-step"]').nth(0), + ).toContainText(THINK_TITLE); + }); + } + } +}); diff --git a/e2e/coach-clarification.spec.ts b/e2e/coach-clarification.spec.ts new file mode 100644 index 000000000..a703bfc66 --- /dev/null +++ b/e2e/coach-clarification.spec.ts @@ -0,0 +1,261 @@ +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; +import { + FIXED_NOW, + done, + end, + feed, + installLiveCoachStream, + posts, + serveConversation, + tokens, + applyTheme, + type StubConversation, +} from "./setup/coach-live-stream"; + +/** + * A clarifying question is a conversation, not a form. + * + * 1. The question is a normal Coach message; its choices are quiet reply + * pills under it, the assumed one first. There is no card. + * 2. A pill sends its label as the person's own message, with + * `clarification: { messageId, choiceId }`. + * 3. A typed answer works too, with `clarification: { messageId }`. + * 4. The trail above the question stays closed. + */ + +const CONVERSATION_ID = "coach-clarify-e2e"; +const QUESTION = "Is my heart rate okay?"; +const ASKS = + "Do you mean your resting heart rate or your heart rate while walking? Otherwise I'll look at your resting heart rate."; +const ANSWER = + "Your walking heart rate sat around 98 bpm over the last 30 days, close to your usual level."; + +const CLARIFICATION = { + kind: "metric", + freeText: true, + assumption: "c1", + choices: [ + { + id: "c1", + labelKey: "insights.coach.metric.resting_hr", + label: "Resting HR", + value: { metric: "resting_hr" }, + }, + { + id: "c2", + labelKey: "insights.coach.metric.walking_hr", + label: "Walking HR", + value: { metric: "walking_hr" }, + }, + ], +}; +const ASK_SOURCE = { + windows: ["last30days"], + metrics: ["resting_hr"], + activity: [ + { + id: "a1", + phase: "thinking", + status: "done", + round: 1, + labelKey: "insights.coach.activity.thinking", + label: "Thinking…", + durationMs: 1_200, + }, + { + id: "a2", + phase: "asking", + status: "done", + round: 1, + labelKey: "insights.coach.activity.asking", + label: "Asking you…", + durationMs: 300, + }, + ], + clarification: CLARIFICATION, +}; + +async function ask( + page: import("@playwright/test").Page, + stub: StubConversation, +) { + await page.locator('[data-slot="coach-input-textarea"]').fill(QUESTION); + await page.locator('[data-slot="coach-input-send"]').click(); + const live = page.locator( + '[role="log"] [data-slot="coach-turn-steps-active"]', + ); + await feed(page, [ + { + type: "activity", + activity: { + ...ASK_SOURCE.activity[0], + status: "running", + durationMs: undefined, + }, + }, + { + type: "activity", + activity: { + ...ASK_SOURCE.activity[1], + status: "running", + durationMs: undefined, + }, + }, + ]); + await expect(live).toHaveText("Asking you…"); + stub.messages.push( + { id: "coach-clarify-e2e-u1", role: "user", content: QUESTION }, + { + id: "coach-clarify-e2e-a1", + role: "assistant", + content: ASKS, + metricSource: ASK_SOURCE, + }, + ); + await feed(page, [ + ...tokens(ASKS), + { type: "activity", activity: ASK_SOURCE.activity[1] }, + { type: "provenance", metricSource: ASK_SOURCE }, + { type: "clarification", clarification: CLARIFICATION }, + done(CONVERSATION_ID, "coach-clarify-e2e-a1"), + ]); + await end(page); +} + +test.describe("Coach clarifying question", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("pills in the conversation, the assumed one first, and a tap answers", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "the flow is viewport-independent; the activity spec covers 390 px", + ); + await page.clock.setFixedTime(FIXED_NOW); + await serveAiBlock(page, aiBlockAvailable()); + const stub: StubConversation = { + id: CONVERSATION_ID, + title: "Heart rate", + messages: [], + results: {}, + trails: {}, + }; + await serveConversation(page, stub); + await installLiveCoachStream(page); + await applyTheme(page, "light"); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + + await ask(page, stub); + + const asked = page + .locator('[data-slot="coach-bubble-assistant"]') + .filter({ hasText: ASKS }); + await expect(asked).toHaveCount(1); + // 1. No card anywhere; the pills sit in the answer's own column. + await expect( + page.locator('[data-slot="coach-clarification-card"]'), + ).toHaveCount(0); + const pills = asked.locator('[data-slot="coach-suggested-replies"]'); + await expect(pills).toBeVisible(); + await expect(pills).toHaveAttribute( + "data-message-id", + "coach-clarify-e2e-a1", + ); + const choices = pills.locator("[data-choice-id]"); + await expect(choices).toHaveCount(2); + await expect(choices.nth(0)).toHaveAttribute("data-choice-id", "c1"); + // 4. The trail stays closed. + await expect( + asked.locator('[data-slot="coach-turn-steps-toggle"]'), + ).toHaveAttribute("aria-expanded", "false"); + + // 2. A tap sends the label as the person's message. + const walking = choices.nth(1); + const label = (await walking.textContent())?.trim() ?? ""; + await walking.click(); + await expect( + page + .locator('[data-slot="coach-bubble-user"]') + .filter({ hasText: label }), + ).toBeVisible(); + await expect(pills).toHaveCount(0); + const sent = await posts(page); + expect(sent[1]).toMatchObject({ + conversationId: CONVERSATION_ID, + message: label, + clarification: { messageId: "coach-clarify-e2e-a1", choiceId: "c2" }, + }); + + stub.messages.push( + { id: "coach-clarify-e2e-u2", role: "user", content: label }, + { + id: "coach-clarify-e2e-a2", + role: "assistant", + content: ANSWER, + metricSource: { windows: ["last30days"], metrics: ["walking_hr"] }, + }, + ); + await feed(page, [ + ...tokens(ANSWER), + done(CONVERSATION_ID, "coach-clarify-e2e-a2"), + ]); + await end(page); + await expect( + page + .locator('[data-slot="coach-bubble-assistant"]') + .filter({ hasText: ANSWER }), + ).toBeVisible(); + await expect( + page.locator('[data-slot="coach-suggested-replies"]'), + ).toHaveCount(0); + }); + + test("a typed answer answers the question too", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "viewport-independent; desktop run suffices", + ); + await page.clock.setFixedTime(FIXED_NOW); + await serveAiBlock(page, aiBlockAvailable()); + const stub: StubConversation = { + id: CONVERSATION_ID, + title: "Heart rate", + messages: [], + results: {}, + trails: {}, + }; + await serveConversation(page, stub); + await installLiveCoachStream(page); + await applyTheme(page, "dark"); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + + await ask(page, stub); + await expect( + page.locator('[data-slot="coach-suggested-replies"]'), + ).toBeVisible(); + + await page + .locator('[data-slot="coach-input-textarea"]') + .fill("the walking one"); + await page.locator('[data-slot="coach-input-send"]').click(); + await expect( + page + .locator('[data-slot="coach-bubble-user"]') + .filter({ hasText: "the walking one" }), + ).toBeVisible(); + const sent = await posts(page); + expect(sent[1]).toMatchObject({ + message: "the walking one", + clarification: { messageId: "coach-clarify-e2e-a1" }, + }); + expect( + (sent[1].clarification as Record).choiceId, + ).toBeUndefined(); + await end(page); + }); +}); diff --git a/e2e/coach-dialog.spec.ts b/e2e/coach-dialog.spec.ts index a5dfc1478..2f7581165 100644 --- a/e2e/coach-dialog.spec.ts +++ b/e2e/coach-dialog.spec.ts @@ -17,12 +17,13 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; * puts the table on the clipboard. * 3. A follow-up chip under the latest reply sends its label with * `followUp: { messageId, id }`. - * 4. A reply that ends in a clarifying question shows the choices card - * and no chips (as the server sends it); a choice sends its label with - * `clarification: { messageId, choiceId }`, and the card leaves once a + * 4. A reply that ends in a clarifying question offers its choices as + * reply pills under it and no chips (as the server sends it); there is + * no card. A choice sends its label with + * `clarification: { messageId, choiceId }`, and the pills leave once a * plain answer is the latest reply. - * 5. axe finds nothing on the answer, the chips and the clarification - * card, in the light and the dark theme. + * 5. axe finds nothing on the answer, the chips and the reply pills, in + * the light and the dark theme. * * The chat POST is a stubbed event stream in the documented frame order, * and the conversation detail it refetches after `done` returns the same @@ -441,7 +442,7 @@ async function expectNoAxeViolations(page: Page, label: string) { const result = await new AxeBuilder({ page }) .include('[data-slot="coach-bubble-assistant"]') .include('[data-slot="coach-follow-up-chips"]') - .include('[data-slot="coach-clarification-card"]') + .include('[data-slot="coach-suggested-replies"]') .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) .analyze(); expect( @@ -581,26 +582,31 @@ test.describe("Coach dialog", () => { }); expect(posts[1].clarification).toBeUndefined(); - // 4. The reply asks; the card offers the choices, and the chips of - // the earlier reply are gone. - const card = page.locator('[data-slot="coach-clarification-card"]'); - await expect(card).toBeVisible(); - await expect(card).toHaveAttribute( + // 4. The reply asks; pills under it offer the choices, and the chips + // of the earlier reply are gone. No card. + await expect( + page.locator('[data-slot="coach-clarification-card"]'), + ).toHaveCount(0); + const pills = secondBubble.locator( + '[data-slot="coach-suggested-replies"]', + ); + await expect(pills).toBeVisible(); + await expect(pills).toHaveAttribute( "data-message-id", TURNS[1].assistantId, ); - await expect(card.locator("[data-choice-id]")).toHaveCount(2); + await expect(pills.locator("[data-choice-id]")).toHaveCount(2); await expect(chips).toHaveCount(0); // The earlier result still renders under its own answer. await expect(figure).toBeVisible(); - // 5. axe over the answers, the result in both views and the card. + // 5. axe over the answers, the result in both views and the pills. await expectNoAxeViolations(page, `${theme} chart view with a question`); await figure.locator('[data-slot="coach-result-view-table"]').click(); await expect(table).toBeVisible(); await expectNoAxeViolations(page, `${theme} table view with a question`); - await card.locator('[data-choice-id="c2"]').click(); + await pills.locator('[data-choice-id="c2"]').click(); const thirdBubble = page .locator('[data-slot="coach-bubble-assistant"]') @@ -616,7 +622,7 @@ test.describe("Coach dialog", () => { expect(posts[2].followUp).toBeUndefined(); // A plain answer closes the question and offers no chips. - await expect(card).toHaveCount(0); + await expect(pills).toHaveCount(0); await expect(chips).toHaveCount(0); await expect( thirdBubble.locator('[data-slot="coach-turn-steps"]'), diff --git a/e2e/coach-memory.spec.ts b/e2e/coach-memory.spec.ts new file mode 100644 index 000000000..e9ed1973d --- /dev/null +++ b/e2e/coach-memory.spec.ts @@ -0,0 +1,436 @@ +import type { Page, Route } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; +import { + FIXED_NOW, + done, + end, + feed, + installLiveCoachStream, + posts, + serveConversation, + tokens, + applyTheme, + type StubConversation, +} from "./setup/coach-live-stream"; + +/** + * What the Coach keeps, in the same quiet language as everything else in + * the thread, and the thinking depth in its quick settings. + * + * 1. A fact it saved: one meta line under the answer, "Remembered: …", + * with Undo, which forgets it. + * 2. A fact it proposes (health): no line; "Yes, remember it" / "No" as + * reply pills, a tap answers with `memoryDecision`. + * 3. A plan it proposes: "Take on this plan" / "Not now", with + * `planDecision`. No plan card in the thread. + * 4. The memory list in Settings → Coach: an entry is edited in place. + * 5. The thinking depth: levels above the operator's cap are disabled and + * say so; with reasoning switched off the field is locked with one + * sentence. + */ + +const CONVERSATION_ID = "coach-memory-e2e"; + +function fulfilJson(route: Route, data: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify({ data, error: null }), + }); +} + +interface Fact { + id: string; + category: string; + text: string; + confidence: number; + source: string; + createdAt: string; +} + +async function serveFacts(page: Page, facts: Fact[]) { + const calls: Array<{ method: string; path: string; body: unknown }> = []; + await page.route( + /\/api\/insights\/coach\/facts(?:\/[^?]+)?(?:\?.*)?$/, + async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const method = request.method(); + calls.push({ + method, + path: url.pathname, + body: method === "GET" ? null : request.postDataJSON(), + }); + const id = url.pathname.split("/").pop() ?? ""; + if (method === "DELETE") { + const at = facts.findIndex((f) => f.id === id); + if (at !== -1) facts.splice(at, 1); + return fulfilJson(route, { deleted: true }); + } + if (method === "PATCH") { + const fact = facts.find((f) => f.id === id); + const next = (request.postDataJSON() as { fact: string }).fact; + if (fact) fact.text = next; + return fulfilJson(route, { fact }); + } + return fulfilJson(route, { facts }); + }, + ); + return calls; +} + +function stub(): StubConversation { + return { + id: CONVERSATION_ID, + title: "Goals", + messages: [], + results: {}, + trails: {}, + }; +} + +async function send(page: Page, text: string) { + await page.locator('[data-slot="coach-input-textarea"]').fill(text); + await page.locator('[data-slot="coach-input-send"]').click(); +} + +async function answer( + page: Page, + conversation: StubConversation, + turn: { + userId: string; + userText: string; + id: string; + text: string; + metricSource: Record; + frames: unknown[]; + providerType?: string; + }, +) { + conversation.messages.push( + { id: turn.userId, role: "user", content: turn.userText }, + { + id: turn.id, + role: "assistant", + content: turn.text, + metricSource: turn.metricSource, + providerType: turn.providerType, + }, + ); + await feed(page, [ + ...tokens(turn.text), + { type: "provenance", metricSource: turn.metricSource }, + ...turn.frames, + done(CONVERSATION_ID, turn.id), + ]); + await end(page); + await expect( + page + .locator('[data-slot="coach-bubble-assistant"]') + .filter({ hasText: turn.text }), + ).toBeVisible(); +} + +test.describe("Coach memory and plans", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "viewport-independent; desktop run suffices", + ); + await page.clock.setFixedTime(FIXED_NOW); + await applyTheme(page, "light"); + }); + + test("a saved fact is one quiet line, and Undo forgets it", async ({ + page, + }) => { + await serveAiBlock(page, aiBlockAvailable()); + const facts: Fact[] = [ + { + id: "fact-1", + category: "goal", + text: "You want to reach 75 kg by December", + confidence: 1, + source: "coach", + createdAt: "2026-10-06T09:00:00.000Z", + }, + ]; + const calls = await serveFacts(page, facts); + const conversation = stub(); + await serveConversation(page, conversation); + await installLiveCoachStream(page); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + + await send(page, "I want to reach 75 kg by December."); + await answer(page, conversation, { + userId: "coach-memory-e2e-u1", + userText: "I want to reach 75 kg by December.", + id: "coach-memory-e2e-a1", + text: "That is about 3 kg from where you are now, a steady pace over ten weeks.", + metricSource: { + windows: [], + metrics: ["weight"], + memoryNote: { proposal: false, factId: "fact-1", category: "goal" }, + }, + frames: [ + { + type: "memoryNote", + note: { + proposal: false, + factId: "fact-1", + category: "goal", + fact: "You want to reach 75 kg by December", + }, + }, + ], + }); + + const note = page.locator('[data-slot="coach-memory-note"]'); + await expect(note).toHaveText( + /Remembered: You want to reach 75 kg by December\.\s*Undo/, + ); + // No card, no accent: a meta line. + await expect(note).toHaveClass(/text-muted-foreground/); + await note.locator('[data-slot="coach-memory-note-undo"]').click(); + await expect(note).toHaveCount(0); + expect( + calls.some((c) => c.method === "DELETE" && c.path.endsWith("/fact-1")), + ).toBe(true); + }); + + test("a proposed fact and a proposed plan are answered with reply pills", async ({ + page, + }) => { + await serveAiBlock(page, aiBlockAvailable()); + await serveFacts(page, []); + const conversation = stub(); + await serveConversation(page, conversation); + await installLiveCoachStream(page); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + + // A health fact is proposed, never saved by itself. + await send(page, "I started taking ramipril in May."); + await answer(page, conversation, { + userId: "coach-memory-e2e-u1", + userText: "I started taking ramipril in May.", + id: "coach-memory-e2e-a1", + text: "Your readings since May sit a little lower. Should I remember that you take ramipril since May?", + metricSource: { + windows: [], + metrics: ["bp"], + memoryNote: { + proposal: true, + proposalId: "p1", + category: "medication", + }, + }, + frames: [ + { + type: "memoryNote", + note: { + proposal: true, + proposalId: "p1", + category: "medication", + fact: "Takes ramipril since May", + }, + }, + ], + }); + await expect(page.locator('[data-slot="coach-memory-note"]')).toHaveCount( + 0, + ); + const pills = page.locator('[data-slot="coach-suggested-replies"]'); + await expect(pills.locator("[data-reply-id]")).toHaveText([ + "Yes, remember it", + "No", + ]); + await pills.locator('[data-reply-id="memory-accept"]').click(); + await expect( + page + .locator('[data-slot="coach-bubble-user"]') + .filter({ hasText: "Yes, remember it" }), + ).toBeVisible(); + expect((await posts(page))[1]).toMatchObject({ + message: "Yes, remember it", + memoryDecision: { + messageId: "coach-memory-e2e-a1", + proposalId: "p1", + accept: true, + }, + }); + await answer(page, conversation, { + userId: "coach-memory-e2e-u2", + userText: "Yes, remember it", + id: "coach-memory-e2e-a2", + text: "Got it, I'll remember.", + providerType: "decision", + metricSource: { windows: [], metrics: [] }, + frames: [], + }); + await expect(pills).toHaveCount(0); + + // A plan is one sentence in the answer, then two pills. No card. + await send(page, "How do I stop snacking late?"); + await answer(page, conversation, { + userId: "coach-memory-e2e-u3", + userText: "How do I stop snacking late?", + id: "coach-memory-e2e-a3", + text: "When you finish dinner, then brush your teeth right away. I'll check in with you in 14 days.", + metricSource: { + windows: [], + metrics: [], + planProposal: { planId: "plan-1", metric: "WEIGHT", reviewInDays: 14 }, + }, + frames: [ + { + type: "planProposal", + proposal: { + planId: "plan-1", + metric: "WEIGHT", + reviewInDays: 14, + ifCue: "When you finish dinner", + thenAction: "brush your teeth right away", + }, + }, + ], + }); + await expect( + page.locator('[data-slot="coach-plan-proposal-card"]'), + ).toHaveCount(0); + await expect(pills.locator("[data-reply-id]")).toHaveText([ + "Take on this plan", + "Not now", + ]); + await pills.locator('[data-reply-id="plan-decline"]').click(); + expect((await posts(page))[3]).toMatchObject({ + message: "Not now", + planDecision: { + messageId: "coach-memory-e2e-a3", + planId: "plan-1", + accept: false, + }, + }); + await end(page); + }); + + test("an entry in the memory list is edited in place", async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + const calls = await serveFacts(page, [ + { + id: "fact-1", + category: "goal", + text: "Wants to reach 80 kg by autumn", + confidence: 1, + source: "user", + createdAt: "2026-10-01T09:00:00.000Z", + }, + { + id: "fact-2", + category: "preference", + text: "Prefers morning workouts", + confidence: 0.8, + source: "coach", + createdAt: "2026-10-02T09:00:00.000Z", + }, + ]); + await page.goto("/settings/coach#coach-memory", { + waitUntil: "domcontentloaded", + }); + const card = page.locator("#coach-memory"); + const rows = card.locator('[data-testid="settings-coach-memory-fact"]'); + await expect(rows).toHaveCount(2); + await expect(rows.nth(0)).toContainText("from you"); + await expect(rows.nth(1)).toContainText("from the Coach"); + + await rows + .nth(0) + .locator('[data-slot="settings-coach-memory-edit"]') + .click(); + const input = rows + .nth(0) + .locator('[data-slot="settings-coach-memory-edit-input"]'); + await input.fill("Wants to reach 78 kg by December"); + await rows + .nth(0) + .locator('[data-slot="settings-coach-memory-edit-save"]') + .click(); + await expect(rows.nth(0)).toContainText("Wants to reach 78 kg by December"); + expect( + calls.find((c) => c.method === "PATCH" && c.path.endsWith("/fact-1")) + ?.body, + ).toEqual({ fact: "Wants to reach 78 kg by December" }); + }); + + for (const scenario of [ + { + name: "capped at Medium", + block: { + level: "medium", + preference: "medium", + maxLevel: "medium", + available: true, + offIsReal: true, + source: "admin_cap", + }, + }, + { + name: "switched off by the operator", + block: { + level: "off", + preference: "medium", + maxLevel: "off", + available: false, + offIsReal: true, + source: "admin_off", + }, + }, + ]) { + test(`thinking depth in the quick settings, ${scenario.name}`, async ({ + page, + }) => { + await page.route("**/api/auth/me", async (route) => { + const response = await route.fetch(); + const body = (await response.json()) as { + data: Record | null; + }; + if (body.data) { + body.data.ai = aiBlockAvailable(); + body.data.coachReasoning = scenario.block; + } + await route.fulfill({ response, json: body }); + }); + await serveFacts(page, []); + await serveConversation(page, stub()); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + await page.locator('[data-slot="coach-settings"]').first().click(); + const select = page.locator('[data-slot="coach-reasoning-select"]'); + await expect(select).toBeVisible(); + await expect(select.locator("option")).toHaveText([ + "Off", + "Low", + "Medium", + scenario.block.available ? "High (limited by the admin)" : "High", + ]); + const field = page.locator('[data-slot="coach-reasoning-field"]'); + if (scenario.block.available) { + await expect(select).toBeEnabled(); + await expect(select.locator('option[value="high"]')).toBeDisabled(); + await expect(field).toContainText("a little more waiting"); + } else { + await expect(select).toBeDisabled(); + await expect(field).toContainText( + "The admin has turned deeper thinking off.", + ); + } + // The memory link names how much the Coach keeps. + await expect( + page.locator('[data-slot="coach-settings-memory-link"]'), + ).toHaveText("Memory (0)"); + }); + } +}); diff --git a/e2e/setup/coach-live-stream.ts b/e2e/setup/coach-live-stream.ts new file mode 100644 index 000000000..e82e97954 --- /dev/null +++ b/e2e/setup/coach-live-stream.ts @@ -0,0 +1,220 @@ +import type { Page, Request, Route } from "@playwright/test"; + +/** + * A Coach chat stream the spec drives frame by frame. + * + * `route.fulfill` hands the browser the whole event stream at once, so the + * live states of a turn (the status line moving from "Thinking…" to + * "Fetching…" to "Summarising…", an interim table before the first token) + * would flash by unobserved. This replaces `fetch` in the page for the chat + * POST only: the response body is a stream the spec feeds with + * `feed(page, frames)` and closes with `end(page)`. Every other request goes + * through untouched, so `page.route` mocks keep working. + * + * The POST bodies are recorded in the page (`posts(page)`). + */ +export async function installLiveCoachStream(page: Page): Promise { + await page.addInitScript(() => { + const w = window as unknown as { + __coachPosts: unknown[]; + __coachFeed: (frames: unknown[]) => void; + __coachEnd: () => void; + }; + const original = window.fetch.bind(window); + let controller: ReadableStreamDefaultController | null = null; + const encoder = new TextEncoder(); + w.__coachPosts = []; + w.__coachFeed = (frames) => { + for (const frame of frames) { + controller?.enqueue( + encoder.encode(`data: ${JSON.stringify(frame)}\n\n`), + ); + } + }; + w.__coachEnd = () => { + controller?.close(); + controller = null; + }; + window.fetch = async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const method = ( + init?.method ?? (input instanceof Request ? input.method : "GET") + ).toUpperCase(); + if ( + method === "POST" && + new URL(url, window.location.href).pathname === "/api/insights/chat" + ) { + w.__coachPosts.push(JSON.parse(String(init?.body ?? "{}"))); + const body = new ReadableStream({ + start(c) { + controller = c; + }, + }); + init?.signal?.addEventListener("abort", () => { + try { + controller?.error(new DOMException("aborted", "AbortError")); + } catch { + // already closed + } + }); + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return original(input, init); + }; + }); +} + +export async function feed(page: Page, frames: unknown[]): Promise { + await page.evaluate((f) => { + (window as unknown as { __coachFeed: (x: unknown[]) => void }).__coachFeed( + f, + ); + }, frames); +} + +export async function end(page: Page): Promise { + await page.evaluate(() => { + (window as unknown as { __coachEnd: () => void }).__coachEnd(); + }); +} + +export async function posts( + page: Page, +): Promise>> { + return page.evaluate( + () => + (window as unknown as { __coachPosts: Array> }) + .__coachPosts, + ); +} + +export function tokens(text: string) { + return text.split(/(?<= )/).map((token) => ({ type: "token", token })); +} + +function fulfilJson(route: Route, data: unknown) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data, error: null }), + }); +} + +export interface StoredMessage { + id: string; + role: "user" | "assistant"; + content: string; + metricSource?: Record | null; + providerType?: string | null; +} + +/** + * The conversation as the stubbed server holds it. The spec pushes the + * persisted turn before it sends `done`, so the refetch after `done` finds + * the copy that replaces the streamed reply. + */ +export interface StubConversation { + id: string; + title: string; + messages: StoredMessage[]; + /** `…/messages/{id}/results`, by message id. */ + results: Record; + /** `…/messages/{id}/trail`, by message id. */ + trails: Record; +} + +export async function serveConversation( + page: Page, + conversation: StubConversation, +): Promise { + const at = (index: number) => + new Date(Date.UTC(2026, 9, 6, 9, 0, index)).toISOString(); + const summary = () => ({ + id: conversation.id, + title: conversation.title, + createdAt: at(0), + updatedAt: at(conversation.messages.length), + messageCount: conversation.messages.length, + fenced: false, + attachments: [], + documentTitle: null, + }); + await page.route( + /\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/, + async (route: Route, request: Request) => { + const url = new URL(request.url()); + const base = `/api/insights/chat/${conversation.id}`; + const message = url.pathname.match( + /\/messages\/([^/]+)\/(results|trail)$/, + ); + if (message) { + const [, id, kind] = message; + return kind === "results" + ? fulfilJson(route, { results: conversation.results[id] ?? [] }) + : fulfilJson(route, { + trail: conversation.trails[id] ?? null, + ai: { available: true, reason: null }, + }); + } + if (url.pathname === base) { + return fulfilJson(route, { + ...summary(), + attachmentCount: 0, + summary: null, + messages: conversation.messages.map((m, index) => ({ + id: m.id, + role: m.role, + content: m.content, + createdAt: at(index + 1), + metricSource: m.metricSource ?? null, + providerType: + m.role === "assistant" ? (m.providerType ?? "mock") : null, + promptVersion: null, + tokensUsed: m.role === "assistant" ? 420 : null, + model: m.role === "assistant" ? "mock" : null, + })), + }); + } + if (url.pathname === "/api/insights/chat" && request.method() === "GET") { + return fulfilJson(route, { + conversations: conversation.messages.length > 0 ? [summary()] : [], + nextCursor: null, + }); + } + return route.fallback(); + }, + ); + await page.route("**/api/insights/coach/nudge-status*", (route) => + fulfilJson(route, { nudgedAt: null, unread: false }), + ); + await page.route("**/api/coach/about-me/questions*", (route) => + fulfilJson(route, { questions: [] }), + ); +} + +export function done(conversationId: string, messageId: string) { + return { + type: "done", + conversationId, + messageId, + usage: { totalTokens: 420 }, + }; +} + +export async function applyTheme(page: Page, theme: "light" | "dark") { + await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" }); + await page.addInitScript((value: string) => { + window.localStorage.setItem("healthlog-theme", value); + }, theme); +} + +/** Midday, so no "today / yesterday" boundary moves a label. */ +export const FIXED_NOW = new Date("2026-10-06T12:00:00+02:00"); diff --git a/messages/_pending/c.json b/messages/_pending/c.json new file mode 100644 index 000000000..7969fb78b --- /dev/null +++ b/messages/_pending/c.json @@ -0,0 +1,163 @@ +{ + "en": { + "admin": { + "assistant": { + "reasoning": { + "description": "Models think before they answer: the Coach, the daily briefing and the period summaries. More precise, uses more tokens." + } + } + }, + "insights": { + "coach": { + "rememberMessage": { + "alreadyKept": "The Coach already remembers this." + } + } + }, + "settings": { + "ai": { + "reasoning": { + "coachNote": "In the Coach, the thinking depth from the Coach settings applies." + } + } + } + }, + "de": { + "admin": { + "assistant": { + "reasoning": { + "description": "Modelle denken vor der Antwort nach: im Coach, im Tagesbriefing und in den Zusammenfassungen. Genauer, braucht mehr Tokens." + } + } + }, + "insights": { + "coach": { + "rememberMessage": { + "alreadyKept": "Das weiß der Coach schon." + } + } + }, + "settings": { + "ai": { + "reasoning": { + "coachNote": "Im Coach gilt die Denktiefe aus den Coach-Einstellungen." + } + } + } + }, + "es": { + "admin": { + "assistant": { + "reasoning": { + "description": "Los modelos piensan antes de responder: el Coach, el resumen diario y los resúmenes por periodo. Más precisos, usan más tokens." + } + } + }, + "insights": { + "coach": { + "rememberMessage": { + "alreadyKept": "El Coach ya lo recuerda." + } + } + }, + "settings": { + "ai": { + "reasoning": { + "coachNote": "En el Coach se aplica la profundidad de pensamiento de los ajustes del Coach." + } + } + } + }, + "fr": { + "admin": { + "assistant": { + "reasoning": { + "description": "Les modèles réfléchissent avant de répondre : le Coach, le briefing quotidien et les synthèses de période. Plus précis, plus de jetons." + } + } + }, + "insights": { + "coach": { + "rememberMessage": { + "alreadyKept": "Le Coach s’en souvient déjà." + } + } + }, + "settings": { + "ai": { + "reasoning": { + "coachNote": "Dans le Coach, c’est la profondeur de réflexion des réglages du Coach qui s’applique." + } + } + } + }, + "it": { + "admin": { + "assistant": { + "reasoning": { + "description": "I modelli riflettono prima di rispondere: il Coach, il briefing giornaliero e i riepiloghi di periodo. Più precisi, usano più token." + } + } + }, + "insights": { + "coach": { + "rememberMessage": { + "alreadyKept": "Il Coach lo ricorda già." + } + } + }, + "settings": { + "ai": { + "reasoning": { + "coachNote": "Nel Coach vale la profondità di ragionamento delle impostazioni del Coach." + } + } + } + }, + "pl": { + "admin": { + "assistant": { + "reasoning": { + "description": "Modele myślą przed odpowiedzią: Coach, codzienne podsumowanie i podsumowania okresów. Dokładniej, ale więcej tokenów." + } + } + }, + "insights": { + "coach": { + "rememberMessage": { + "alreadyKept": "Coach już to pamięta." + } + } + }, + "settings": { + "ai": { + "reasoning": { + "coachNote": "W Coachu obowiązuje głębokość myślenia z ustawień Coacha." + } + } + } + }, + "ko": { + "admin": { + "assistant": { + "reasoning": { + "description": "모델이 답하기 전에 생각합니다: 코치, 일일 브리핑, 기간 요약. 더 정확하지만 토큰을 더 씁니다." + } + } + }, + "insights": { + "coach": { + "rememberMessage": { + "alreadyKept": "코치가 이미 기억하고 있어요." + } + } + }, + "settings": { + "ai": { + "reasoning": { + "coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다." + } + } + } + } +} diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 34f750ae7..a429ecb1a 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -196,9 +196,26 @@ const REGISTRY: DestructiveEntry[] = [ }, { file: "components/settings/coach-memory-section.tsx", - destroys: "one remembered Coach fact, or every one of them", + destroys: "every remembered Coach fact at once", recovery: "tombstoned-no-restore", - confirm: ["ConfirmButton", "AlertDialog"], + confirm: ["AlertDialog"], + }, + { + file: "components/insights/coach-panel/use-coach-facts.ts", + destroys: "one remembered Coach fact", + triggers: [ + "components/settings/coach-memory-section.tsx", + "components/insights/coach-panel/memory-note.tsx", + ], + recovery: "tombstoned-no-restore", + confirm: ["ConfirmButton"], + unconfirmed: [ + { + control: "Undo on the memory note under an answer — memory-note.tsx", + reason: + "Undo takes back what the Coach saved a moment ago on its own, from the person's own words in the same turn. Nothing the person wrote is lost: their message stays in the thread and the fact can be kept again with one tap. Asking to confirm an undo would make reversing the Coach dearer than letting it keep something unwanted. Forgetting from the memory list, where a fact may be old and relied on, is confirmed.", + }, + ], }, { file: "hooks/use-coach-reminders.ts", @@ -210,16 +227,12 @@ const REGISTRY: DestructiveEntry[] = [ { file: "hooks/use-coach-plans.ts", destroys: "a Coach plan and the record of how it went", - triggers: [ - "app/coach/plans/page.tsx", - "components/insights/coach-panel/plan-proposal-card.tsx", - ], + triggers: ["app/coach/plans/page.tsx"], recovery: "tombstoned-no-restore", confirm: ["ConfirmButton"], unconfirmed: [ { - control: - "decline a PROPOSED plan — app/coach/plans/page.tsx and plan-proposal-card.tsx", + control: "decline a PROPOSED plan — app/coach/plans/page.tsx", reason: "Declining is the negative half of an accept/decline pair the assistant just offered; the person has invested nothing in a proposal they never adopted, and the same suggestion re-derives on a later turn. Confirming a refusal would make saying no more expensive than saying yes. The delete of a plan the person actually RAN is confirmed, and that is the one carrying history.", }, diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index bd9e68c89..64fb05022 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -190,9 +190,6 @@ const PINNED_AFFORDANCES: Record< "src/components/insights/coach-panel/history-rail.tsx": { "toast.success": 1, }, - "src/components/insights/coach-panel/plan-proposal-card.tsx": { - "text-success": 1, - }, "src/components/insights/coach-panel/reminder-suggestion-card.tsx": { "text-success": 1, }, diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx index 11872bbb9..3ee132231 100644 --- a/src/components/admin/assistant-section.tsx +++ b/src/components/admin/assistant-section.tsx @@ -14,9 +14,23 @@ import { } from "@/lib/query-keys"; import { SettingsToggle } from "./_shared"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { + DEFAULT_REASONING_MAX_EFFORT, + REASONING_ADMIN_KEYS, + REASONING_LEVEL_LABEL_KEYS, + REASONING_MAX_EFFORTS, + type ReasoningMaxEffort, +} from "@/lib/ai/reasoning/levels"; /** - * Operator-side panel for the five assistant switches. The master stops + * Operator-side panel for the five assistant switches, and (v1.41) the two + * reasoning controls: whether models may think before they answer at all, + * and the highest depth a person may choose. Background jobs follow the + * same switch; there is no separate one. + * + * The five switches: The master stops * every AI feature; four sub-switches each stop one cost or egress profile: * the Coach, the daily briefing, status notes (per-reading notes, workout * notes, reaction lines) and reading documents (the vault, lab scans, @@ -48,8 +62,16 @@ interface AssistantFlagsResponse { insightStatus: boolean; documentAi: boolean; }; + /** v1.41 — the operator's reasoning controls; absent before v1.41. */ + reasoning?: { enabled: boolean; maxEffort: ReasoningMaxEffort }; } +/** What the route accepts: any of the switches, and the reasoning pair. */ +type AssistantFlagsPatch = Partial & { + aiReasoningEnabled?: boolean; + aiReasoningMaxEffort?: ReasoningMaxEffort; +}; + function useAssistantFlags() { return useQuery({ queryKey: queryKeys.adminAssistantFlags(), @@ -66,7 +88,7 @@ function useUpdateAssistantFlags() { const { t } = useTranslations(); return useMutation({ mutationFn: async ( - patch: Partial, + patch: AssistantFlagsPatch, ): Promise => { return apiPut( "/api/admin/settings/assistant-flags", @@ -102,6 +124,7 @@ export function AssistantSection() { const raw = data?.raw; const masterOn = raw?.assistantEnabled ?? true; const disabledSubs = !masterOn || mutation.isPending; + const reasoningOn = data?.reasoning?.enabled ?? true; return ( @@ -159,6 +182,49 @@ export function AssistantSection() { disabled={disabledSubs} /> + + {/* v1.41 — reasoning: allowed at all, and how deep at most. */} +
+ + mutation.mutate({ aiReasoningEnabled: checked }) + } + disabled={disabledSubs} + /> +
+ + + mutation.mutate({ + aiReasoningMaxEffort: e.target.value as ReasoningMaxEffort, + }) + } + > + {REASONING_MAX_EFFORTS.map((effort) => ( + + ))} + +
+
); diff --git a/src/components/insights/coach-panel/__tests__/assumption-line.test.tsx b/src/components/insights/coach-panel/__tests__/assumption-line.test.tsx new file mode 100644 index 000000000..26cdb3fca --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/assumption-line.test.tsx @@ -0,0 +1,76 @@ +/** + * v1.41 — "Assumed: last 30 days. Change": one quiet line, the change only + * under the latest answer and only with alternatives. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { CoachAssumption, CoachFollowUp } from "@/lib/ai/coach/types"; + +import { CoachAssumptionLine, alternativesFor } from "../assumption-line"; + +const WINDOW: CoachAssumption = { + kind: "window", + value: { + labelKey: "coach.step.window.last30days", + label: "last 30 days", + value: { window: "last30days" }, + }, + alternatives: [], +}; +const CHANGE: CoachFollowUp = { + id: "f1", + kind: "change_assumption", + labelKey: "coach.followUp.changeAssumption", + label: "Last 90 days instead", + reuse: false, + origin: "server", + assumption: { kind: "window", value: { window: "last90days" } }, +}; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("names the assumption in one meta line", () => { + const html = render( + , + ); + expect(html).toContain("Assumed: last 30 days."); + expect(html).toMatch( + /data-slot="coach-assumption-line"[^>]*class="text-muted-foreground/, + ); + expect(html).not.toContain("coach-assumption-change"); + }); + + it("offers Change under the latest answer, closed until tapped", () => { + const html = render( + {}} + />, + ); + expect(html).toContain(">Change"); + expect(html).toContain('aria-expanded="false"'); + expect(html).not.toContain("Last 90 days instead"); + }); + + it("matches alternatives to their assumption by kind", () => { + expect(alternativesFor(WINDOW, [CHANGE])).toEqual([CHANGE]); + expect(alternativesFor({ ...WINDOW, kind: "metric" }, [CHANGE])).toEqual( + [], + ); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/clarification-card.test.tsx b/src/components/insights/coach-panel/__tests__/clarification-card.test.tsx deleted file mode 100644 index e7d28a2f3..000000000 --- a/src/components/insights/coach-panel/__tests__/clarification-card.test.tsx +++ /dev/null @@ -1,112 +0,0 @@ -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 card's only hook is the translator; the server one resolves the same -// bundles and returns the key for a missing string, like the client one. With -// it, the card can be called as a plain function and its handlers exercised -// (the node test environment has no DOM to click through). -vi.mock("@/lib/i18n/context", () => ({ - useTranslations: () => ({ - t: (key: string) => getServerTranslator("en").t(key), - }), -})); - -const focusComposer = vi.fn(); -vi.mock("../composer-focus", () => ({ - focusCoachComposer: () => focusComposer(), -})); - -import { CoachClarificationCard } from "../clarification-card"; -import type { CoachClarification } from "@/lib/ai/coach/types"; - -const METRIC: CoachClarification = { - kind: "metric", - choices: [ - { - id: "c1", - labelKey: "insights.coach.metric.pulse", - label: "Pulse (server)", - value: { metric: "pulse" }, - }, - { - id: "c2", - labelKey: "coach.not.a.key", - label: "Server label", - value: { metric: "resting_hr" }, - }, - ], - freeText: true, -}; - -function card(clarification: CoachClarification, disabled = false) { - const onChoose = vi.fn(); - const tree = CoachClarificationCard({ - clarification, - messageId: "m1", - disabled, - onChoose, - }); - return { tree, onChoose, html: renderToStaticMarkup(tree) }; -} - -function choiceButtons( - node: ReactNode, -): ReactElement>[] { - if (Array.isArray(node)) return node.flatMap(choiceButtons); - if (!isValidElement(node)) return []; - const el = node as ReactElement>; - const own = el.props["data-choice-id"] ? [el] : []; - return [...own, ...choiceButtons(el.props.children as ReactNode)]; -} - -describe("", () => { - it("is labelled, takes no focus, and leaves announcing to the live region beside it", () => { - const { html } = card(METRIC); - // The card mounts with its content, so a live region on the card itself - // is never heard; the conversation keeps one mounted for it. - expect(html).not.toContain("aria-live"); - expect(html).toContain('aria-label="The Coach has a question"'); - expect(html).not.toContain("autofocus"); - expect(html).not.toContain("autoFocus"); - }); - - it("renders the catalog label, falling back to the server label", () => { - const { html } = card(METRIC); - expect(html).toContain('aria-label="Possible answers"'); - expect(html).toContain(">Pulse"); - expect(html).toContain(">Server label"); - expect(html).toContain("You can also type your own answer."); - }); - - it("sends the tapped choice", () => { - const { tree, onChoose } = card(METRIC); - const buttons = choiceButtons(tree); - expect(buttons).toHaveLength(2); - focusComposer.mockClear(); - (buttons[1].props.onClick as () => void)(); - expect(onChoose).toHaveBeenCalledWith(METRIC.choices[1]); - // The choices disable while the answer runs: focus goes to the composer. - expect(focusComposer).toHaveBeenCalledTimes(1); - }); - - it("disables the choices while a turn is in flight", () => { - const { tree } = card(METRIC, true); - for (const button of choiceButtons(tree)) { - expect(button.props.disabled).toBe(true); - } - }); - - it("asks for a typed answer on a context question", () => { - const { html, tree } = card({ - kind: "context", - choices: [], - freeText: true, - }); - expect(choiceButtons(tree)).toHaveLength(0); - expect(html).not.toContain('role="group"'); - expect(html).toContain("Type your answer below."); - }); -}); diff --git a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx index 21626c678..2db2b6126 100644 --- a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx @@ -113,16 +113,15 @@ describe(" page deep-link (#67)", () => { expect(html).not.toContain('data-slot="coach-hero"'); }); - it("keeps the clarification live region mounted before any question arrives", () => { - // A region that mounts together with its content is not announced; the - // empty region has to be in the page first. + it("asks no question in a card above the composer", () => { + // v1.41 — a clarifying question is a normal Coach message with reply + // pills under it; there is no card and no separate live region. const html = render( , makeClient(), ); - expect(html).toMatch( - /]*data-slot="coach-clarification-live"[^>]*aria-live="polite"[^>]*class="sr-only"[^>]*><\/div>/, - ); + expect(html).not.toContain("coach-clarification-card"); + expect(html).not.toContain("coach-clarification-live"); }); it("auto-opens the most-recent thread when none is pinned", () => { diff --git a/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx b/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx index 2d4a8af6a..d24b0fca0 100644 --- a/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx @@ -8,7 +8,6 @@ import type { import { chainWithFirst, modelControlFor, - reasoningFieldFor, selectableProviders, switchDidNotTake, } from "../coach-model-picker"; @@ -120,20 +119,3 @@ describe("modelControlFor", () => { ).toEqual({ kind: "gateway", value: "qwen3:32b" }); }); }); - -describe("reasoningFieldFor", () => { - it("writes the Local and gateway entries' own reasoning fields", () => { - expect( - reasoningFieldFor("local", userProvider({ localReasoningEffort: "low" })), - ).toEqual({ field: "localReasoningEffort", value: "low" }); - expect(reasoningFieldFor("openai-compatible", userProvider())).toEqual({ - field: "compatReasoningEffort", - value: null, - }); - }); - - it("has nothing for providers without a reasoning setting", () => { - expect(reasoningFieldFor("openai", userProvider())).toBeNull(); - expect(reasoningFieldFor("codex", userProvider())).toBeNull(); - }); -}); diff --git a/src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx b/src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx new file mode 100644 index 000000000..25ec4c284 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx @@ -0,0 +1,129 @@ +/** + * v1.41 — the Coach's thinking depth: Off / Low / Medium / High, rendered + * from what the server resolved, never recomputed. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { getServerTranslator } from "@/lib/i18n/server-translator"; + +const locale = vi.hoisted(() => ({ current: "en" as "en" | "de" })); +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ + t: (key: string, params?: Record) => + getServerTranslator(locale.current).t(key, params), + }), +})); + +import { + CoachReasoningSelect, + reasoningLocked, + reasoningOptions, + reasoningStateOrDefault, + shownReasoningLevel, + type CoachReasoningState, +} from "../coach-reasoning-field"; + +const OPEN: CoachReasoningState = { + level: "medium", + preference: "medium", + maxLevel: "high", + available: true, + offIsReal: true, + source: "user", +}; +const ADMIN_OFF: CoachReasoningState = { + level: "off", + preference: "high", + maxLevel: "off", + available: false, + offIsReal: true, + source: "admin_off", +}; + +function render(state: CoachReasoningState, value = state.level) { + return renderToStaticMarkup( + {}} + />, + ); +} + +describe("the resolved block", () => { + it("reads an older server's missing block as allowed, uncapped, really off", () => { + expect(reasoningStateOrDefault(null)).toEqual(OPEN); + expect(reasoningStateOrDefault(undefined)).toEqual(OPEN); + }); + + it("is locked only when the operator switched reasoning off", () => { + expect(reasoningLocked(ADMIN_OFF)).toBe(true); + expect(reasoningLocked(OPEN)).toBe(false); + // A provider that cannot reason does not lock the person's choice. + expect( + reasoningLocked({ ...OPEN, available: false, source: "unsupported" }), + ).toBe(false); + }); +}); + +describe("", () => { + it("offers Off, Low, Medium and High with the label and hint", () => { + const html = render(OPEN); + expect(html).toContain(">Thinking depth<"); + for (const label of ["Off", "Low", "Medium", "High"]) { + expect(html).toContain(`>${label}`); + } + expect(html).toContain( + "Deeper thinking means more precise answers and a little more waiting.", + ); + expect(html).not.toMatch(/]*disabled/); + }); + + it("is locked with one sentence when the admin turned reasoning off", () => { + const html = render(ADMIN_OFF, "high"); + expect(html).toMatch(/]*disabled/); + expect(html).toContain('data-locked="true"'); + expect(html).toContain("The admin has turned deeper thinking off."); + expect(html).not.toContain("a little more waiting"); + }); + + it("shows the admin's cap: levels above it are disabled and say why", () => { + const html = render({ ...OPEN, maxLevel: "medium" }); + expect(html).toMatch( + /