Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/services/briefing-render.ts
Original file line number Diff line number Diff line change
@@ -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");
}
7 changes: 6 additions & 1 deletion src/tools/core-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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<string, unknown>,
isError: false,
});
Expand Down
86 changes: 86 additions & 0 deletions test/unit/services/briefing-render.test.ts
Original file line number Diff line number Diff line change
@@ -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<BriefingSection> & Pick<BriefingSection, "category">): BriefingSection {
return {
id: "s1",
text: "Something happened",
type: "neutral",
...overrides,
};
}

function makeBriefing(overrides?: Partial<BriefingOutput>): 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("##");
});
});