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
This commit is contained in:
Petr Houška 2026-10-22 20:16:38 +02:00
parent 27cc6bfc0c
commit 9359adf0c6
3 changed files with 225 additions and 25 deletions

View file

@ -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);
}

View file

@ -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 + `<span class="tree-role-tool">[bash]:</span> ${escapeHtml(cmd)}`;
}
if (msg.role === 'system') {
if (isLeadingSystemEntry(entry)) {
return labelHtml + `<span class="tree-muted">[system prompt]</span>`;
}
const changes = describeSystemUpdate(msg);
return labelHtml + `<span class="tree-muted">[system update${changes ? ': ' + escapeHtml(truncate(changes)) : ''}]</span>`;
}
return labelHtml + `<span class="tree-muted">[${escapeHtml(msg.role)}]</span>`;
}
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 += `<div class="tools-list"><div class="tools-header">Tools Removed</div><div class="tools-content">${msg.toolsRemoved.map(t => `<div class="tool-item"><span class="tool-item-name">${escapeHtml(t.name)}</span></div>`).join('')}</div></div>`;
}
}
return html ? `<div class="system-entry" id="${entryDomId}">${tsHtml}${html}</div>` : '';
}
}
if (entry.type === 'model_change') {
@ -1437,30 +1539,39 @@
</div>
</div>`;
// 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 += `<div class="system-prompt expandable" onclick="if(window.getSelection().toString())return;this.classList.toggle('expanded')">
<div class="system-prompt-header">System Prompt</div>
<div class="system-prompt-preview">${escapeHtml(preview)}</div>
<div class="system-prompt-expand-hint">... (${remaining} more lines, click to expand)</div>
<div class="system-prompt-full">${escapeHtml(systemPrompt)}</div>
</div>`;
} else {
html += `<div class="system-prompt">
<div class="system-prompt-header">System Prompt</div>
<div class="system-prompt-full" style="display: block">${escapeHtml(systemPrompt)}</div>
</div>`;
}
// 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 += `<div class="tools-list">
<div class="tools-header">Available Tools</div>
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 `<div class="system-prompt expandable" onclick="if(window.getSelection().toString())return;this.classList.toggle('expanded')">
<div class="system-prompt-header">${escapeHtml(title)}</div>
<div class="system-prompt-preview">${escapeHtml(preview)}</div>
<div class="system-prompt-expand-hint">... (${remaining} more lines, click to expand)</div>
<div class="system-prompt-full">${escapeHtml(text)}</div>
</div>`;
}
return `<div class="system-prompt">
<div class="system-prompt-header">${escapeHtml(title)}</div>
<div class="system-prompt-full" style="display: block">${escapeHtml(text)}</div>
</div>`;
}
function renderToolsList(title, tools) {
if (!tools || tools.length === 0) return '';
return `<div class="tools-list">
<div class="tools-header">${escapeHtml(title)}</div>
<div class="tools-content">
${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('')}
</div>
</div>`;
}
return html;
}
// ============================================================

View file

@ -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: "<rules>\nbe concise\n</rules>", project_context: "<project_context>\nv1\n</project_context>" },
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: "<project_context>\nv2\n</project_context>", 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)");
});
});