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)"); + }); +});