diff --git a/src/services/briefing-render.ts b/src/services/briefing-render.ts new file mode 100644 index 00000000..b567c04f --- /dev/null +++ b/src/services/briefing-render.ts @@ -0,0 +1,37 @@ +import type { BriefingOutput, BriefingSection } from "./home-types.ts"; + +// Section categories in the order the model should read them — attention +// first (matches the generator's "surface these first" guidance), then +// recent activity, then what's coming up. Mirrors the schema enum in +// `src/tools/platform/schemas/home.ts` (BriefingSection.category) — NOT the +// stale names in the inline dashboard script. +const CATEGORY_ORDER: { category: BriefingSection["category"]; label: string }[] = [ + { category: "attention", label: "Needs attention" }, + { category: "recent", label: "Recent" }, + { category: "upcoming", label: "Coming up" }, +]; + +/** + * Render a `BriefingOutput` as human-readable markdown for the `content` + * field of the `nb__briefing` tool result. + * + * The briefing body lives in `structuredContent` for the dashboard, but the + * model only ever sees `content` (the engine feeds `extractTextForModel(content)` + * back into the prompt — `structuredContent` never reaches it). A model-facing + * tool's `content` must therefore carry the human-readable summary, per the + * platform-tool contract (src/tools/platform/CLAUDE.md §2.1). Without this the + * model receives only the status note and reports an empty briefing. + */ +export function renderBriefingText(briefing: BriefingOutput): string { + const parts: string[] = [briefing.greeting]; + if (briefing.lede) parts.push(briefing.lede); + + for (const { category, label } of CATEGORY_ORDER) { + const items = briefing.sections.filter((s) => s.category === category); + if (items.length === 0) continue; + const lines = [`## ${label}`, ...items.map((s) => `- ${s.text}`)]; + parts.push(lines.join("\n")); + } + + return parts.join("\n\n"); +} diff --git a/src/tools/core-source.ts b/src/tools/core-source.ts index 297829ce..007dae26 100644 --- a/src/tools/core-source.ts +++ b/src/tools/core-source.ts @@ -23,6 +23,7 @@ import { ActivityCollector } from "../services/activity-collector.ts"; import { BriefingCache } from "../services/briefing-cache.ts"; import { collectBriefingFacets } from "../services/briefing-collector.ts"; import { BriefingGenerator } from "../services/briefing-generator.ts"; +import { renderBriefingText } from "../services/briefing-render.ts"; import type { BriefingOutput } from "../services/home-types.ts"; /** @@ -757,8 +758,12 @@ export function createCoreToolDefs(runtime: Runtime): InProcessTool[] { } const briefingCache = cache; + // `content` carries the rendered briefing (the only field the model + // sees — the engine never feeds `structuredContent` to the prompt), + // prefixed with the status note. `structuredContent` keeps the typed + // payload for the dashboard, unchanged. const ok = (briefing: BriefingOutput, note: string): ToolResult => ({ - content: textContent(note), + content: textContent(`${note}\n\n${renderBriefingText(briefing)}`), structuredContent: briefing as unknown as Record, isError: false, }); diff --git a/test/unit/services/briefing-render.test.ts b/test/unit/services/briefing-render.test.ts new file mode 100644 index 00000000..09d66b10 --- /dev/null +++ b/test/unit/services/briefing-render.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { renderBriefingText } from "../../../src/services/briefing-render.ts"; +import type { BriefingOutput, BriefingSection } from "../../../src/services/home-types.ts"; + +function section(overrides: Partial & Pick): BriefingSection { + return { + id: "s1", + text: "Something happened", + type: "neutral", + ...overrides, + }; +} + +function makeBriefing(overrides?: Partial): BriefingOutput { + return { + greeting: "Good morning, Mat", + date: "Tuesday, June 2, 2026", + lede: "Two things need your attention.", + sections: [], + state: "normal", + generated_at: "2026-06-02T16:00:00.000Z", + cached: false, + ...overrides, + }; +} + +describe("renderBriefingText", () => { + // The core regression: the briefing body must land in the rendered text, not + // only in structuredContent. The model only ever sees this string. + test("renders the section body text the model needs", () => { + const out = renderBriefingText( + makeBriefing({ + sections: [ + section({ category: "attention", type: "warning", text: "Invoice #42 is overdue" }), + section({ category: "recent", type: "positive", text: "Deploy succeeded" }), + ], + }), + ); + expect(out).toContain("Invoice #42 is overdue"); + expect(out).toContain("Deploy succeeded"); + }); + + test("includes greeting and lede", () => { + const out = renderBriefingText(makeBriefing()); + expect(out).toContain("Good morning, Mat"); + expect(out).toContain("Two things need your attention."); + }); + + // Guards the schema category names (home.ts: attention/recent/upcoming). + // A renderer keyed on the inline script's stale names (needs_attention/ + // coming_up) would silently drop every section — the failure mode this fix + // exists to prevent. + test("groups sections under labels using the schema category names", () => { + const out = renderBriefingText( + makeBriefing({ + sections: [ + section({ category: "upcoming", text: "Renewal due Friday" }), + section({ category: "attention", text: "API key expiring" }), + section({ category: "recent", text: "3 new conversations" }), + ], + }), + ); + expect(out).toContain("## Needs attention"); + expect(out).toContain("## Recent"); + expect(out).toContain("## Coming up"); + // attention surfaces before recent, which surfaces before upcoming + expect(out.indexOf("## Needs attention")).toBeLessThan(out.indexOf("## Recent")); + expect(out.indexOf("## Recent")).toBeLessThan(out.indexOf("## Coming up")); + }); + + test("omits headings for empty categories", () => { + const out = renderBriefingText( + makeBriefing({ sections: [section({ category: "recent", text: "Only recent activity" })] }), + ); + expect(out).toContain("## Recent"); + expect(out).not.toContain("## Needs attention"); + expect(out).not.toContain("## Coming up"); + }); + + test("renders a quiet briefing as greeting + lede with no headings", () => { + const out = renderBriefingText(makeBriefing({ sections: [], lede: "All clear." })); + expect(out).toContain("Good morning, Mat"); + expect(out).toContain("All clear."); + expect(out).not.toContain("##"); + }); +});