From 9359adf0c6df974b2059b597e632a14cf1942c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Thu, 22 Oct 2026 20:16:38 +0200 Subject: [PATCH] fix(coding-agent): render system messages in HTML export Render the leading system message as the system prompt and tool list, and later system messages as named section and tool updates. The header prompt/tools from agent state remain as a fallback for sessions without system entries. CLI --export now shows the prompt and tools too (#3464). closes #11261 --- .../src/core/export-html/template.css | 9 + .../src/core/export-html/template.js | 158 +++++++++++++++--- .../test/export-html-system-messages.test.ts | 83 +++++++++ 3 files changed, 225 insertions(+), 25 deletions(-) create mode 100644 packages/coding-agent/test/export-html-system-messages.test.ts diff --git a/packages/coding-agent/src/core/export-html/template.css b/packages/coding-agent/src/core/export-html/template.css index 1de5bf46b..2e2489994 100644 --- a/packages/coding-agent/src/core/export-html/template.css +++ b/packages/coding-agent/src/core/export-html/template.css @@ -687,6 +687,15 @@ display: block; } + .system-entry > .message-timestamp { + padding-left: var(--line-height); + margin-bottom: 4px; + } + + .system-entry > :last-child { + margin-bottom: 0; + } + .system-prompt.provider-prompt { border-left: 3px solid var(--warning); } diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index 737adbdb5..d6541ca77 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -313,6 +313,76 @@ return ''; } + // ============================================================ + // SYSTEM MESSAGES (pure data) + // ============================================================ + + function systemContentText(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content.filter(c => c.type === 'text' && c.text).map(c => c.text).join('\n'); + } + return ''; + } + + /** Full prompt text of a system message, mirroring getSystemMessageText() in pi-ai. */ + function getSystemMessageText(msg) { + const parts = [systemContentText(msg.content)]; + for (const value of Object.values(msg.sections || {})) { + if (value !== null) parts.push(value); + } + return parts.filter(part => part.length > 0).join('\n\n'); + } + + /** Text of a later system message, mirroring renderSystemMessageUpdate() in pi-ai. */ + function renderSystemMessageUpdate(msg) { + const parts = []; + const text = systemContentText(msg.content); + if (text.length > 0) parts.push(text); + for (const [name, value] of Object.entries(msg.sections || {})) { + parts.push(value === null + ? `Removed system prompt section "${name}".` + : `Updated system prompt section "${name}":\n\n${value}`); + } + return parts.join('\n\n'); + } + + function carriesSystemState(entry) { + return (entry.type === 'message' && entry.message.role === 'system') || + (entry.type === 'compaction' && entry.systemMessage); + } + + /** + * Whether a system entry starts the prompt on its branch. Later system entries + * patch the prompt, so they render as updates instead of a full prompt. + */ + function isLeadingSystemEntry(entry) { + let current = entry.parentId && entry.parentId !== entry.id ? byId.get(entry.parentId) : undefined; + while (current) { + if (carriesSystemState(current)) return false; + if (!current.parentId || current.parentId === current.id) break; + current = byId.get(current.parentId); + } + return true; + } + + /** Short description of what a later system message changes, e.g. "project_context, -rules, tools +bash". */ + function describeSystemUpdate(msg) { + const parts = []; + if (systemContentText(msg.content).length > 0) parts.push('instructions'); + for (const [name, value] of Object.entries(msg.sections || {})) { + parts.push(value === null ? `-${name}` : name); + } + const toolChanges = [ + ...(msg.toolsAdded || []).map(t => `+${t.name}`), + ...(msg.toolsRemoved || []).map(t => `-${t.name}`), + ]; + if (toolChanges.length > 0) parts.push(`tools ${toolChanges.join(' ')}`); + return parts.join(', '); + } + + const hasSystemEntries = entries.some(entry => entry.type === 'message' && entry.message.role === 'system'); + /** * Parse a skill block from message text. * Returns null if the text doesn't contain a skill block. @@ -337,6 +407,13 @@ case 'message': { const msg = entry.message; parts.push(msg.role); + if (msg.role === 'system') { + parts.push(isLeadingSystemEntry(entry) ? 'system prompt' : 'system update'); + parts.push(getSystemMessageText(msg)); + for (const name of Object.keys(msg.sections || {})) parts.push(name); + for (const tool of [...(msg.toolsAdded || []), ...(msg.toolsRemoved || [])]) parts.push(tool.name); + break; + } if (msg.content) parts.push(extractContent(msg.content)); if (msg.role === 'bashExecution' && msg.command) parts.push(msg.command); break; @@ -675,6 +752,13 @@ const cmd = truncate(normalize(msg.command || '')); return labelHtml + `[bash]: ${escapeHtml(cmd)}`; } + if (msg.role === 'system') { + if (isLeadingSystemEntry(entry)) { + return labelHtml + `[system prompt]`; + } + const changes = describeSystemUpdate(msg); + return labelHtml + `[system update${changes ? ': ' + escapeHtml(truncate(changes)) : ''}]`; + } return labelHtml + `[${escapeHtml(msg.role)}]`; } case 'compaction': @@ -1311,6 +1395,24 @@ } if (msg.role === 'toolResult') return ''; + + if (msg.role === 'system') { + let html = ''; + if (isLeadingSystemEntry(entry)) { + const text = getSystemMessageText(msg); + if (text) html += renderSystemPromptBlock('System Prompt', text); + html += renderToolsList('Available Tools', msg.toolsAdded); + } else { + const text = renderSystemMessageUpdate(msg); + const changes = describeSystemUpdate(msg); + if (text) html += renderSystemPromptBlock(`System Prompt Update${changes ? ': ' + changes : ''}`, text); + html += renderToolsList('Tools Added', msg.toolsAdded); + if (msg.toolsRemoved && msg.toolsRemoved.length > 0) { + html += `
Tools Removed
${msg.toolsRemoved.map(t => `
${escapeHtml(t.name)}
`).join('')}
`; + } + } + return html ? `
${tsHtml}${html}
` : ''; + } } if (entry.type === 'model_change') { @@ -1437,30 +1539,39 @@ `; - // Render system prompt (user's base prompt, applies to all providers) - if (systemPrompt) { - const lines = systemPrompt.split('\n'); - const previewLines = 10; - if (lines.length > previewLines) { - const preview = lines.slice(0, previewLines).join('\n'); - const remaining = lines.length - previewLines; - html += ``; - } else { - html += `
-
System Prompt
-
${escapeHtml(systemPrompt)}
-
`; - } + // Sessions with system entries render the prompt and tools in the transcript, where they + // occur. Older sessions have none, so fall back to the exporting agent's current state. + if (!hasSystemEntries) { + if (systemPrompt) html += renderSystemPromptBlock('System Prompt', systemPrompt); + html += renderToolsList('Available Tools', tools); } - if (tools && tools.length > 0) { - html += `
-
Available Tools
+ return html; + } + + function renderSystemPromptBlock(title, text) { + const lines = text.split('\n'); + const previewLines = 10; + if (lines.length > previewLines) { + const preview = lines.slice(0, previewLines).join('\n'); + const remaining = lines.length - previewLines; + return ``; + } + return `
+
${escapeHtml(title)}
+
${escapeHtml(text)}
+
`; + } + + function renderToolsList(title, tools) { + if (!tools || tools.length === 0) return ''; + return `
+
${escapeHtml(title)}
${tools.map(t => { const hasParams = t.parameters && typeof t.parameters === 'object' && t.parameters.properties && Object.keys(t.parameters.properties).length > 0; @@ -1485,9 +1596,6 @@ }).join('')}
`; - } - - return html; } // ============================================================ diff --git a/packages/coding-agent/test/export-html-system-messages.test.ts b/packages/coding-agent/test/export-html-system-messages.test.ts new file mode 100644 index 000000000..ee931a750 --- /dev/null +++ b/packages/coding-agent/test/export-html-system-messages.test.ts @@ -0,0 +1,83 @@ +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { getSystemMessageText, renderSystemMessageUpdate, type SystemMessage } from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; + +interface SystemHelpers { + getSystemMessageText(message: SystemMessage): string; + renderSystemMessageUpdate(message: SystemMessage): string; + isLeadingSystemEntry(entry: { id: string; parentId: string | null }): boolean; + describeSystemUpdate(message: SystemMessage): string; +} + +const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8"); + +function loadSystemHelpers(entries: unknown[]): SystemHelpers { + const start = templateJs.indexOf("function systemContentText(content)"); + const end = templateJs.indexOf("const hasSystemEntries"); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + const context: { entries: unknown[]; helpers?: SystemHelpers } = { entries }; + runInNewContext( + `const byId = new Map(entries.map(entry => [entry.id, entry])); + ${templateJs.slice(start, end)} + helpers = { getSystemMessageText, renderSystemMessageUpdate, isLeadingSystemEntry, describeSystemUpdate };`, + context, + ); + return context.helpers!; +} + +const initial: SystemMessage = { + role: "system", + content: "You are a helpful assistant.", + sections: { rules: "\nbe concise\n", project_context: "\nv1\n" }, + toolsAdded: [{ name: "read", description: "Read a file", parameters: { type: "object", properties: {} } as never }], + timestamp: 1, +}; + +const update: SystemMessage = { + role: "system", + content: [{ type: "text", text: "Extra instructions" }], + sections: { project_context: "\nv2\n", rules: null }, + toolsAdded: [ + { name: "bash", description: "Run a command", parameters: { type: "object", properties: {} } as never }, + ], + toolsRemoved: [{ name: "read" }], + timestamp: 2, +}; + +const entries = [ + { type: "message", id: "s1", parentId: null, message: initial }, + { type: "message", id: "u1", parentId: "s1", message: { role: "user", content: "hi" } }, + { type: "message", id: "s2", parentId: "u1", message: update }, +]; + +// Regression tests for #11261: system messages were stored in the export but never rendered. +describe("export HTML system messages", () => { + it("renders system message text the same way the model receives it", () => { + const helpers = loadSystemHelpers(entries); + expect(helpers.getSystemMessageText(initial)).toBe(getSystemMessageText(initial)); + expect(helpers.renderSystemMessageUpdate(update)).toBe(renderSystemMessageUpdate(update)); + }); + + it("renders the first system entry on a branch as the prompt and later ones as updates", () => { + const helpers = loadSystemHelpers(entries); + expect(helpers.isLeadingSystemEntry(entries[0])).toBe(true); + expect(helpers.isLeadingSystemEntry(entries[2])).toBe(false); + expect(helpers.describeSystemUpdate(update)).toBe("instructions, project_context, -rules, tools +bash -read"); + }); + + it("treats a compaction snapshot as earlier system state", () => { + const helpers = loadSystemHelpers([ + { type: "compaction", id: "c1", parentId: null, systemMessage: initial }, + { type: "message", id: "s2", parentId: "c1", message: update }, + ]); + expect(helpers.isLeadingSystemEntry({ id: "s2", parentId: "c1" })).toBe(false); + }); + + it("renders system entries in the transcript", () => { + expect(templateJs).toMatch(/if \(msg\.role === 'system'\) \{\s*let html = '';/); + expect(templateJs).toContain("renderSystemPromptBlock('System Prompt', text)"); + expect(templateJs).toContain("renderToolsList('Available Tools', msg.toolsAdded)"); + }); +});