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]*${tag}>[ \\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,