From 18c38fffe500a66cfb42db449d20cf4c20097eba Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 21:12:15 +0000 Subject: [PATCH 1/5] feat(dashboard): surface system prompt in turn transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepend a synthetic system-role message to each turn's transcript at the reporting layer so the dashboard can show the exact prompt the model received. The message is assembled from buildSystemPrompt() at read time — no storage changes needed — and is intentionally skipped from transcriptMessageCount. In the UI, system messages render collapsed by default (same UX pattern as thinking blocks) with a char count in the summary, so the transcript stays scannable without the full prompt taking over the view. This is a forward-compatible placeholder: when the transcript source moves to traces the system message will arrive as a span attribute instead of a synthetic injection. Co-Authored-By: claude-opus-4-5 Co-authored-by: David Cramer --- .../src/client/components/TranscriptTurn.tsx | 54 +++++++++++++++++++ packages/junior/src/reporting.ts | 13 ++++- .../integration/dashboard-reporting.test.ts | 16 ++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx index 7dc0c4e1..aebedb76 100644 --- a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx +++ b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx @@ -359,11 +359,65 @@ function turnMeta(turn: ConversationTurn): string[] { ].filter((value) => value && value !== "none"); } +function SystemMessageView(props: { + message: TranscriptMessage; + turn: ConversationTurn; + view: TranscriptViewMode; +}) { + const [open, setOpen] = useState(false); + const rawText = messageRawText(props.message); + + return ( + +
{ + if (event.currentTarget !== event.target) return; + setOpen(event.currentTarget.open); + }} + open={open} + > + +
+ + {transcriptRoleLabel(props.message.role, props.turn)} + + {open ? null : ( + + {rawText.length.toLocaleString()} chars + + )} +
+
+
+ {props.view === "raw" ? ( + + ) : ( + + )} +
+
+
+ ); +} + 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/src/reporting.ts b/packages/junior/src/reporting.ts index b92ad443..83c98e56 100644 --- a/packages/junior/src/reporting.ts +++ b/packages/junior/src/reporting.ts @@ -26,6 +26,7 @@ import { type AgentTurnSessionSummary, } from "@/chat/state/turn-session"; import { GET as healthGET } from "@/handlers/health"; +import { buildSystemPrompt } from "@/chat/prompt"; const HUNG_TURN_PROGRESS_MS = 5 * 60 * 1000; const SAFE_METADATA_KEY_LIMIT = 20; @@ -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, From acddc473d5389075e6b3e569ec64333bdd9527ac Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 21:14:17 +0000 Subject: [PATCH 2/5] chore: fix import order in reporting.ts Co-Authored-By: claude-opus-4-5 Co-authored-by: David Cramer --- packages/junior/src/reporting.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/junior/src/reporting.ts b/packages/junior/src/reporting.ts index 83c98e56..cd34acc3 100644 --- a/packages/junior/src/reporting.ts +++ b/packages/junior/src/reporting.ts @@ -25,8 +25,8 @@ import { type AgentTurnRequester, type AgentTurnSessionSummary, } from "@/chat/state/turn-session"; -import { GET as healthGET } from "@/handlers/health"; import { buildSystemPrompt } from "@/chat/prompt"; +import { GET as healthGET } from "@/handlers/health"; const HUNG_TURN_PROGRESS_MS = 5 * 60 * 1000; const SAFE_METADATA_KEY_LIMIT = 20; From 704dd67dd6c7a701d6135f6d2f7e80f89112ed04 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 22:45:54 +0000 Subject: [PATCH 3/5] fix(dashboard): use TranscriptPartView renderer for system message content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the plain HighlightedCode fallback with the full groupTranscriptParts → TranscriptPartView pipeline so system message content goes through TranscriptText → parseMarkdownBlocks → XML detection → StructuredMarkup. This gives the system prompt the same collapsible XML tag renderer used for user/tool messages that contain runtime context blocks. Also remove the redundant TranscriptMessageShell wrapper: transcriptMessageClass is now applied directly to the
element so the amber styling and the disclosure are a single DOM node instead of two nested containers. Co-Authored-By: claude-opus-4-5 Co-authored-by: U039RR91S --- .../src/client/components/TranscriptTurn.tsx | 76 ++++++++++++------- 1 file changed, 47 insertions(+), 29 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx index aebedb76..ab40ac7d 100644 --- a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx +++ b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx @@ -366,40 +366,58 @@ function SystemMessageView(props: { }) { const [open, setOpen] = useState(false); const rawText = messageRawText(props.message); + const role = props.message.role; + 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(props.message.role, props.turn)} +
{ + if (event.currentTarget !== event.target) return; + setOpen(event.currentTarget.open); + }} + open={open} + > + +
+ + {transcriptRoleLabel(role, props.turn)} + + {open ? null : ( + + {rawText.length.toLocaleString()} chars - {open ? null : ( - - {rawText.length.toLocaleString()} chars - - )} -
-
-
- {props.view === "raw" ? ( - - ) : ( - )}
-
- +
+ {props.view === "raw" ? ( + + ) : ( +
+ {renderedParts.map((part, index) => { + const firstChildIndex = seenRenderedChildren; + seenRenderedChildren += countRenderedTranscriptChildren(part, role); + return ( + + ); + })} +
+ )} +
); } From e33237f51e373e556c23f28505bc7beda77af7b2 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 22:53:57 +0000 Subject: [PATCH 4/5] fix(dashboard): xml collapsing and bytes for system message - Use StructuredMarkup directly with language:"xml" so the system prompt renders through the collapsible XML tree renderer instead of HighlightedCode. detectLanguage returns "markdown" for the system prompt (starts with plain text, not "<"), so bypassing it is the right call here. - Replace rawText.length / "chars" with TextEncoder byte count + formatBytes, consistent with how the rest of the dashboard shows message sizes. Co-Authored-By: claude-opus-4-5 Co-authored-by: David Cramer --- .../src/client/components/TranscriptTurn.tsx | 31 +++++-------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx index ab40ac7d..d3280180 100644 --- a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx +++ b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx @@ -1,6 +1,6 @@ import { useState, type ClipboardEventHandler, type ReactNode } from "react"; -import { HighlightedCode } from "../code"; +import { HighlightedCode, StructuredMarkup } from "../code"; import { detectLanguage, detectOutputLanguage, @@ -367,12 +367,7 @@ function SystemMessageView(props: { const [open, setOpen] = useState(false); const rawText = messageRawText(props.message); const role = props.message.role; - const renderedParts = groupTranscriptParts(props.message.parts); - const totalRenderedChildren = renderedParts.reduce( - (count, part) => count + countRenderedTranscriptChildren(part, role), - 0, - ); - let seenRenderedChildren = 0; + const byteCount = new TextEncoder().encode(rawText).byteLength; return (
{open ? null : ( - {rawText.length.toLocaleString()} chars + {formatBytes(byteCount)} )} @@ -401,21 +396,11 @@ function SystemMessageView(props: { language={detectLanguage(rawText)} /> ) : ( -
- {renderedParts.map((part, index) => { - const firstChildIndex = seenRenderedChildren; - seenRenderedChildren += countRenderedTranscriptChildren(part, role); - return ( - - ); - })} -
+ )}
); From c05050a2ed73541ed8eaccd7682e459978e18417 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 23:15:59 +0000 Subject: [PATCH 5/5] fix(dashboard): route system message through shared transcript pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detectLanguage was returning "markdown" for the system prompt because the markdown heuristic (## headings, - bullets) fired before the XML check, and the XML check required text to start with "<". The system prompt starts with plain text but contains block-level XML elements. Fix: add a block-level XML check before the markdown heuristic that requires a matched open+close tag pair on their own lines. This makes the system prompt naturally detected as "xml" without special-casing in the component layer. With the detection fixed, SystemMessageView can use the same groupTranscriptParts → TranscriptPartView → TranscriptText pipeline as every other message kind, removing the direct StructuredMarkup bypass. The StructuredMarkup import is also removed from TranscriptTurn.tsx. Tests added for the new detection behaviour: - mixed prose + block XML detected as xml (system prompt shape) - unclosed block tag not detected as xml - normal markdown without XML stays markdown Co-Authored-By: claude-opus-4-5 --- .../src/client/components/TranscriptTurn.tsx | 35 ++++++-- .../junior-dashboard/src/client/format.ts | 24 +++++- .../junior-dashboard/tests/format.test.ts | 83 ++++++++++++++++--- 3 files changed, 122 insertions(+), 20 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx index d3280180..dcf3f1da 100644 --- a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx +++ b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx @@ -1,6 +1,6 @@ import { useState, type ClipboardEventHandler, type ReactNode } from "react"; -import { HighlightedCode, StructuredMarkup } from "../code"; +import { HighlightedCode } from "../code"; import { detectLanguage, detectOutputLanguage, @@ -359,6 +359,13 @@ 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; @@ -368,6 +375,12 @@ function SystemMessageView(props: { 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 (
) : ( - +
+ {renderedParts.map((part, index) => { + const firstChildIndex = seenRenderedChildren; + seenRenderedChildren += countRenderedTranscriptChildren(part, role); + return ( + + ); + })} +
)}
); 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); });