diff --git a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx index 7dc0c4e1..dcf3f1da 100644 --- a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx +++ b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx @@ -359,11 +359,91 @@ function turnMeta(turn: ConversationTurn): string[] { ].filter((value) => value && value !== "none"); } +/** + * Render the system prompt as a collapsed disclosure. Uses the same + * groupTranscriptParts → TranscriptPartView → TranscriptText pipeline as every + * other message so XML tag collapsing, syntax highlighting, and copy behaviour + * stay consistent. detectLanguage returns "xml" for the system prompt once the + * block-level XML heuristic in format.ts fires. + */ +function SystemMessageView(props: { + message: TranscriptMessage; + turn: ConversationTurn; + view: TranscriptViewMode; +}) { + const [open, setOpen] = useState(false); + const rawText = messageRawText(props.message); + const role = props.message.role; + const byteCount = new TextEncoder().encode(rawText).byteLength; + const renderedParts = groupTranscriptParts(props.message.parts); + const totalRenderedChildren = renderedParts.reduce( + (count, part) => count + countRenderedTranscriptChildren(part, role), + 0, + ); + let seenRenderedChildren = 0; + + return ( +
{ + if (event.currentTarget !== event.target) return; + setOpen(event.currentTarget.open); + }} + open={open} + > + +
+ + {transcriptRoleLabel(role, props.turn)} + + {open ? null : ( + + {formatBytes(byteCount)} + + )} +
+
+ {props.view === "raw" ? ( + + ) : ( +
+ {renderedParts.map((part, index) => { + const firstChildIndex = seenRenderedChildren; + seenRenderedChildren += countRenderedTranscriptChildren(part, role); + return ( + + ); + })} +
+ )} +
+ ); +} + function TranscriptMessageView(props: { message: TranscriptMessage; turn: ConversationTurn; view: TranscriptViewMode; }) { + if (transcriptRoleKind(props.message.role) === "system") { + return ( + + ); + } + const offset = formatMessageOffset(props.turn, props.message.timestamp); const renderedParts = groupTranscriptParts(props.message.parts); const rawText = messageRawText(props.message); diff --git a/packages/junior-dashboard/src/client/format.ts b/packages/junior-dashboard/src/client/format.ts index 7ef1ce5c..8064a937 100644 --- a/packages/junior-dashboard/src/client/format.ts +++ b/packages/junior-dashboard/src/client/format.ts @@ -479,6 +479,18 @@ export function detectLanguage(text: string): BundledLanguage { if (/^<[\s\S]+>$/.test(trimmed) && /<\/?[a-zA-Z][^>]*>/.test(trimmed)) { return "xml"; } + // Mixed prose + block-level XML: detect when a complete open/close element pair + // appears on its own lines. Handles system prompts and runtime context blocks + // that start with plain text but contain structured XML sections. + const blockOpen = trimmed.match( + /(?:^|\n)[ \t]*<([A-Za-z_][\w:.-]*)(?:[ \t][^<>]*)?>[ \t]*(?=\n|$)/, + ); + if (blockOpen?.[1]) { + const tag = blockOpen[1].replace(/[$()*+.?[\\^{|}]/g, "\\$&"); + if (new RegExp(`(?:^|\\n)[ \\t]*[ \\t]*(?=\\n|$)`).test(trimmed)) { + return "xml"; + } + } if (/```|^#{1,6}\s|\n[-*]\s|\n\d+\.\s|\[[^\]]+\]\([^)]+\)/m.test(trimmed)) { return "markdown"; } @@ -575,7 +587,11 @@ export function parseMarkdownBlocks( const prose = text.slice(cursor, match.index).trim(); if (prose) { const language = detectProse(prose); - blocks.push({ code: formatCodeBlock(prose, language), fenced: false, language }); + blocks.push({ + code: formatCodeBlock(prose, language), + fenced: false, + language, + }); } const language = normalizeLanguage(match[1]); blocks.push({ @@ -588,7 +604,11 @@ export function parseMarkdownBlocks( const rest = text.slice(cursor).trim(); if (rest) { const language = detectProse(rest); - blocks.push({ code: formatCodeBlock(rest, language), fenced: false, language }); + blocks.push({ + code: formatCodeBlock(rest, language), + fenced: false, + language, + }); } if (blocks.length > 0) return blocks; const language = detectProse(text); diff --git a/packages/junior-dashboard/tests/format.test.ts b/packages/junior-dashboard/tests/format.test.ts index 5050a8a1..5c86c62a 100644 --- a/packages/junior-dashboard/tests/format.test.ts +++ b/packages/junior-dashboard/tests/format.test.ts @@ -87,6 +87,39 @@ describe("parseMarkdownBlocks prose language detection", () => { expect(["xml", "html"]).toContain(block?.language); }); + it("detects mixed prose + block-level XML as xml (system prompt pattern)", () => { + const text = [ + "You are a Slack-based helper assistant.", + "", + "", + "Your Slack username is `junior`.", + "", + "", + "", + "## core identity", + "- you are junior", + "", + ].join("\n"); + const [block] = parseMarkdownBlocks(text); + expect(block?.language).toBe("xml"); + }); + + it("does not detect an unclosed block tag as xml", () => { + const text = [ + "Here is an example:", + "", + "
", + "## heading", + "- bullet", + ].join("\n"); + expect(parseMarkdownBlocks(text)[0]?.language).not.toBe("xml"); + }); + + it("keeps normal markdown without XML blocks as markdown", () => { + const text = ["Intro", "", "## heading", "- bullet"].join("\n"); + expect(parseMarkdownBlocks(text)[0]?.language).toBe("markdown"); + }); + it("detects valid JSON prose as json", () => { const [block] = parseMarkdownBlocks('{"a":1}'); expect(block?.language).toBe("json"); @@ -107,18 +140,24 @@ describe("parseMarkdownBlocks prose language detection", () => { describe("outputOnly: true mode (detectOutputLanguage — for assistant messages)", () => { it("treats XML-looking prose as markdown, never auto-detects XML", () => { - const [block] = parseMarkdownBlocks("bar", { outputOnly: true }); + const [block] = parseMarkdownBlocks("bar", { + outputOnly: true, + }); expect(block?.language).toBe("markdown"); expect(block?.fenced).toBe(false); }); it("treats HTML-looking prose as markdown", () => { - const [block] = parseMarkdownBlocks("
Hello
", { outputOnly: true }); + const [block] = parseMarkdownBlocks("
Hello
", { + outputOnly: true, + }); expect(block?.language).toBe("markdown"); }); it("treats TypeScript-looking prose as markdown", () => { - const [block] = parseMarkdownBlocks("const value = 1;", { outputOnly: true }); + const [block] = parseMarkdownBlocks("const value = 1;", { + outputOnly: true, + }); expect(block?.language).toBe("markdown"); }); @@ -135,7 +174,9 @@ describe("parseMarkdownBlocks prose language detection", () => { }); it("keeps prose blocks as markdown even when fenced XML is present", () => { - const blocks = parseMarkdownBlocks("before\n```xml\n\n```\nafter", { outputOnly: true }); + const blocks = parseMarkdownBlocks("before\n```xml\n\n```\nafter", { + outputOnly: true, + }); expect(blocks[0]?.language).toBe("markdown"); expect(blocks[0]?.fenced).toBe(false); expect(blocks[2]?.language).toBe("markdown"); @@ -143,7 +184,9 @@ describe("parseMarkdownBlocks prose language detection", () => { }); it("still detects fenced xml blocks as xml", () => { - const blocks = parseMarkdownBlocks("before\n```xml\n\n```\nafter", { outputOnly: true }); + const blocks = parseMarkdownBlocks("before\n```xml\n\n```\nafter", { + outputOnly: true, + }); expect(blocks[1]?.language).toBe("xml"); expect(blocks[1]?.fenced).toBe(true); }); @@ -153,19 +196,31 @@ describe("parseMarkdownBlocks prose language detection", () => { describe("canRenderStructuredMarkup", () => { it("returns true for xml blocks regardless of fenced status", () => { expect( - canRenderStructuredMarkup({ code: "", language: "xml", fenced: false }), - ).toBe(true); - expect( - canRenderStructuredMarkup({ code: "", language: "xml", fenced: true }), + canRenderStructuredMarkup({ + code: "", + language: "xml", + fenced: false, + }), ).toBe(true); expect( - canRenderStructuredMarkup({ code: "", language: "xml" }), + canRenderStructuredMarkup({ + code: "", + language: "xml", + fenced: true, + }), ).toBe(true); + expect(canRenderStructuredMarkup({ code: "", language: "xml" })).toBe( + true, + ); }); it("returns true for html blocks", () => { expect( - canRenderStructuredMarkup({ code: "
", language: "html", fenced: true }), + canRenderStructuredMarkup({ + code: "
", + language: "html", + fenced: true, + }), ).toBe(true); expect( canRenderStructuredMarkup({ code: "
", language: "html" }), @@ -174,7 +229,11 @@ describe("canRenderStructuredMarkup", () => { it("returns false for non-xml/html blocks", () => { expect( - canRenderStructuredMarkup({ code: "const x = 1", language: "typescript", fenced: true }), + canRenderStructuredMarkup({ + code: "const x = 1", + language: "typescript", + fenced: true, + }), ).toBe(false); }); diff --git a/packages/junior/src/reporting.ts b/packages/junior/src/reporting.ts index b92ad443..cd34acc3 100644 --- a/packages/junior/src/reporting.ts +++ b/packages/junior/src/reporting.ts @@ -25,6 +25,7 @@ import { type AgentTurnRequester, type AgentTurnSessionSummary, } from "@/chat/state/turn-session"; +import { buildSystemPrompt } from "@/chat/prompt"; import { GET as healthGET } from "@/handlers/health"; const HUNG_TURN_PROGRESS_MS = 5 * 60 * 1000; @@ -510,6 +511,14 @@ function countConversationMessages( return transcript.filter(isConversationMessage).length; } +/** Build the synthetic system-prompt message prepended to each exposed turn transcript. */ +function systemPromptMessage(): DashboardTranscriptMessage { + return { + role: "system", + parts: [{ type: "text", text: buildSystemPrompt() }], + }; +} + function turnScopedMessages(messages: PiMessage[]): PiMessage[] { for (let index = messages.length - 1; index >= 0; index -= 1) { const record = messages[index] as unknown as Record; @@ -579,7 +588,9 @@ async function readConversation( ); const transcriptMessageCount = countConversationMessages(normalizedTranscript); - const transcript = canExposeTranscript ? normalizedTranscript : []; + const transcript = canExposeTranscript + ? [systemPromptMessage(), ...normalizedTranscript] + : []; const transcriptMetadata = canExposeTranscript ? undefined : normalizedTranscript.map(redactTranscriptMessage); diff --git a/packages/junior/tests/integration/dashboard-reporting.test.ts b/packages/junior/tests/integration/dashboard-reporting.test.ts index bbdd6900..2094fd8c 100644 --- a/packages/junior/tests/integration/dashboard-reporting.test.ts +++ b/packages/junior/tests/integration/dashboard-reporting.test.ts @@ -1,6 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PiMessage } from "@/chat/pi/messages"; +vi.mock("@/chat/prompt", () => ({ + buildSystemPrompt: vi.fn(() => "[system prompt]"), + buildTurnContextPrompt: vi.fn(() => null), + JUNIOR_PERSONALITY: "", + JUNIOR_WORLD: null, +})); + +const SYSTEM_MESSAGE = { + role: "system", + parts: [{ type: "text", text: "[system prompt]" }], +}; + const ORIGINAL_ENV = { ...process.env }; describe("dashboard reporting", () => { @@ -137,6 +149,7 @@ describe("dashboard reporting", () => { transcriptMessageCount: 2, }); expect(report.turns[0]!.transcript).toEqual([ + SYSTEM_MESSAGE, { role: "user", timestamp: 3, @@ -211,6 +224,7 @@ describe("dashboard reporting", () => { transcriptAvailable: true, }); expect(report.turns[0]!.transcript).toEqual([ + SYSTEM_MESSAGE, { role: "user", timestamp: 1, @@ -277,6 +291,7 @@ describe("dashboard reporting", () => { expect(report.turns).toHaveLength(2); expect(report.turns[0]).toMatchObject({ id: "turn-one" }); expect(report.turns[0]!.transcript).toEqual([ + SYSTEM_MESSAGE, { role: "user", timestamp: 1, @@ -290,6 +305,7 @@ describe("dashboard reporting", () => { ]); expect(report.turns[1]).toMatchObject({ id: "turn-two" }); expect(report.turns[1]!.transcript).toEqual([ + SYSTEM_MESSAGE, { role: "user", timestamp: 3,