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 += `
`;
+ }
+ }
+ 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 += `
-
-
${escapeHtml(preview)}
-
... (${remaining} more lines, click to expand)
-
${escapeHtml(systemPrompt)}
-
`;
- } else {
- html += `
-
-
${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 += `
-
+ 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 `
+
+
${escapeHtml(preview)}
+
... (${remaining} more lines, click to expand)
+
${escapeHtml(text)}
+
`;
+ }
+ return `
+
+
${escapeHtml(text)}
+
`;
+ }
+
+ function renderToolsList(title, tools) {
+ if (!tools || tools.length === 0) return '';
+ return `
`;
- }
-
- 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)");
+ });
+});