From 1e84d7efd001f78c19ddd759c007fc01d870a927 Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:41:38 -1000 Subject: [PATCH] fix(briefing): render briefing body into content so the model sees it nb__briefing put the full BriefingOutput only in structuredContent and a short status note in content. The engine feeds extractTextForModel(content) back to the model and never reads structuredContent, so a chat caller saw only "Briefing generated." with no body. The dashboard reads structuredContent directly and was unaffected. Render the briefing (greeting, lede, sections grouped by category) into content alongside the note, per the platform-tool contract (content = human-readable summary, structuredContent = typed payload). Fixes all three branches (cache / stale / generated). Category and field names anchor on the generator's output schema (recent/upcoming/attention, type). --- src/services/briefing-render.ts | 37 ++++++++++ src/tools/core-source.ts | 7 +- test/unit/services/briefing-render.test.ts | 86 ++++++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 src/services/briefing-render.ts create mode 100644 test/unit/services/briefing-render.test.ts 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("##"); + }); +});