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
80 changes: 80 additions & 0 deletions packages/junior-dashboard/src/client/components/TranscriptTurn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<details
className={transcriptMessageClass(role)}
onToggle={(event) => {
if (event.currentTarget !== event.target) return;
setOpen(event.currentTarget.open);
}}
open={open}
>
<summary className="list-none cursor-pointer">
<div className={transcriptRoleClass(role)}>
<span className={transcriptRoleLabelClass(role)}>
{transcriptRoleLabel(role, props.turn)}
</span>
{open ? null : (
<span className="font-mono text-[0.78rem] text-[#888]">
{formatBytes(byteCount)}
</span>
)}
</div>
</summary>
{props.view === "raw" ? (
<HighlightedCode
code={rawText || "{}"}
language={detectLanguage(rawText)}
/>
) : (
<div className="grid min-w-0 gap-2">
{renderedParts.map((part, index) => {
const firstChildIndex = seenRenderedChildren;
seenRenderedChildren += countRenderedTranscriptChildren(part, role);
return (
<TranscriptPartView
firstChildIndex={firstChildIndex}
key={index}
lastChildIndex={totalRenderedChildren - 1}
part={part}
role={role}
/>
);
})}
</div>
)}
</details>
);
}

function TranscriptMessageView(props: {
message: TranscriptMessage;
turn: ConversationTurn;
view: TranscriptViewMode;
}) {
if (transcriptRoleKind(props.message.role) === "system") {
return (
<SystemMessageView
message={props.message}
turn={props.turn}
view={props.view}
/>
);
}

const offset = formatMessageOffset(props.turn, props.message.timestamp);
const renderedParts = groupTranscriptParts(props.message.parts);
const rawText = messageRawText(props.message);
Expand Down
24 changes: 22 additions & 2 deletions packages/junior-dashboard/src/client/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down Expand Up @@ -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({
Expand All @@ -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);
Expand Down
83 changes: 71 additions & 12 deletions packages/junior-dashboard/tests/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
"",
"<identity>",
"Your Slack username is `junior`.",
"</identity>",
"",
"<personality>",
"## core identity",
"- you are junior",
"</personality>",
].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:",
"",
"<div>",
"## 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");
Expand All @@ -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("<foo>bar</foo>", { outputOnly: true });
const [block] = parseMarkdownBlocks("<foo>bar</foo>", {
outputOnly: true,
});
expect(block?.language).toBe("markdown");
expect(block?.fenced).toBe(false);
});

it("treats HTML-looking prose as markdown", () => {
const [block] = parseMarkdownBlocks("<div>Hello</div>", { outputOnly: true });
const [block] = parseMarkdownBlocks("<div>Hello</div>", {
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");
});

Expand All @@ -135,15 +174,19 @@ describe("parseMarkdownBlocks prose language detection", () => {
});

it("keeps prose blocks as markdown even when fenced XML is present", () => {
const blocks = parseMarkdownBlocks("before\n```xml\n<foo/>\n```\nafter", { outputOnly: true });
const blocks = parseMarkdownBlocks("before\n```xml\n<foo/>\n```\nafter", {
outputOnly: true,
});
expect(blocks[0]?.language).toBe("markdown");
expect(blocks[0]?.fenced).toBe(false);
expect(blocks[2]?.language).toBe("markdown");
expect(blocks[2]?.fenced).toBe(false);
});

it("still detects fenced xml blocks as xml", () => {
const blocks = parseMarkdownBlocks("before\n```xml\n<foo/>\n```\nafter", { outputOnly: true });
const blocks = parseMarkdownBlocks("before\n```xml\n<foo/>\n```\nafter", {
outputOnly: true,
});
expect(blocks[1]?.language).toBe("xml");
expect(blocks[1]?.fenced).toBe(true);
});
Expand All @@ -153,19 +196,31 @@ describe("parseMarkdownBlocks prose language detection", () => {
describe("canRenderStructuredMarkup", () => {
it("returns true for xml blocks regardless of fenced status", () => {
expect(
canRenderStructuredMarkup({ code: "<foo/>", language: "xml", fenced: false }),
).toBe(true);
expect(
canRenderStructuredMarkup({ code: "<foo/>", language: "xml", fenced: true }),
canRenderStructuredMarkup({
code: "<foo/>",
language: "xml",
fenced: false,
}),
).toBe(true);
expect(
canRenderStructuredMarkup({ code: "<foo/>", language: "xml" }),
canRenderStructuredMarkup({
code: "<foo/>",
language: "xml",
fenced: true,
}),
).toBe(true);
expect(canRenderStructuredMarkup({ code: "<foo/>", language: "xml" })).toBe(
true,
);
});

it("returns true for html blocks", () => {
expect(
canRenderStructuredMarkup({ code: "<div/>", language: "html", fenced: true }),
canRenderStructuredMarkup({
code: "<div/>",
language: "html",
fenced: true,
}),
).toBe(true);
expect(
canRenderStructuredMarkup({ code: "<div/>", language: "html" }),
Expand All @@ -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);
});

Expand Down
13 changes: 12 additions & 1 deletion packages/junior/src/reporting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -579,7 +588,9 @@ async function readConversation(
);
const transcriptMessageCount =
countConversationMessages(normalizedTranscript);
const transcript = canExposeTranscript ? normalizedTranscript : [];
const transcript = canExposeTranscript
? [systemPromptMessage(), ...normalizedTranscript]
: [];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System prompt skews trace fallback

Medium Severity

When summary.traceId and sessionRecord.traceId are missing, traceIdFromTranscript runs on the exposed transcript that now starts with the synthetic system message. That helper returns the first trace_id-like hex match in message text, so prompt content from buildSystemPrompt() is scanned before user or assistant messages and can supply a false ID or hide the real one in the turn log.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c05050a. Configure here.

const transcriptMetadata = canExposeTranscript
? undefined
: normalizedTranscript.map(redactTranscriptMessage);
Expand Down
16 changes: 16 additions & 0 deletions packages/junior/tests/integration/dashboard-reporting.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -137,6 +149,7 @@ describe("dashboard reporting", () => {
transcriptMessageCount: 2,
});
expect(report.turns[0]!.transcript).toEqual([
SYSTEM_MESSAGE,
{
role: "user",
timestamp: 3,
Expand Down Expand Up @@ -211,6 +224,7 @@ describe("dashboard reporting", () => {
transcriptAvailable: true,
});
expect(report.turns[0]!.transcript).toEqual([
SYSTEM_MESSAGE,
{
role: "user",
timestamp: 1,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading