fix: prevent XML tags in compaction context from causing Markdown rendering issues

- Add stripXmlTags() to convert <workspace_memory>, <hot_session_state>, <pending_todos> to Markdown headers for compaction context
- Add [PRIVATE COMPACTION CONTEXT - DO NOT OUTPUT] wrapper to prevent model from copying input context to output
- Rename renderTodos to renderTodosForCompaction for clarity
- Add test to verify compaction context contains no XML tags

This fixes the issue where compaction summary would render with purple italic text
due to --- delimiters interacting with XML-like tags in Markdown.
This commit is contained in:
Ralph Chang
2026-04-26 14:34:55 +08:00
parent 22774c5ed2
commit 92e90124de
2 changed files with 109 additions and 30 deletions
+56 -30
View File
@@ -46,38 +46,65 @@ import {
} from "./opencode.ts"; } from "./opencode.ts";
/** /**
* Generate the memory candidate instruction to include in compaction context. * Strip XML-like tags from text for Markdown-neutral rendering.
* Converts `<workspace_memory>` blocks to plain "Workspace memory:" sections.
*/ */
function memoryCandidateInstruction(): string { function stripXmlTags(text: string): string {
if (!text) return "";
// Replace XML tag pairs with Markdown headers
return text
.replace(/<workspace_memory>\n?/gi, "## Workspace Memory\n")
.replace(/<\/workspace_memory>\n?/gi, "")
.replace(/<hot_session_state>\n?/gi, "## Hot Session State\n")
.replace(/<\/hot_session_state>\n?/gi, "")
.replace(/<pending_todos>\n?/gi, "## Pending Todos\n")
.replace(/<\/pending_todos>\n?/gi, "");
}
/**
* Generate instructions for the compaction model.
* IMPORTANT: These instructions make clear what should NOT be in the final output.
*/
function compactionContextHeader(): string {
return ` return `
At the end of the compaction summary, include: [PRIVATE COMPACTION CONTEXT - DO NOT OUTPUT]
The following sections are PRIVATE INPUT for updating the compaction summary.
DO NOT copy these sections, their headings, or their contents verbatim.
Use the facts only to update the normal summary sections (Goal, Progress, etc.).
At the VERY END of your summary, you MAY include ONE output block:
<workspace_memory_candidates> <workspace_memory_candidates>
- [feedback] ... - [type] content (types: feedback, project, decision, reference)
- [project] ...
- [decision] ...
- [reference] ...
</workspace_memory_candidates> </workspace_memory_candidates>
Only include durable information useful across future sessions in this exact workspace. Only include truly durable information useful across FUTURE sessions.
Do NOT include active file lists, raw errors, temporary progress, stack traces, code signatures, API docs, git history, or facts easily rediscovered from the repository. If nothing qualifies, omit the block entirely.
For decisions, include rationale in one sentence.
If nothing qualifies, output an empty block. [END PRIVATE COMPACTION CONTEXT]
`.trim(); `.trim();
} }
/** /**
* Render todos for compaction context. * Generate the memory candidate instruction.
* This is included in compactionContextHeader() above.
*/ */
function renderTodos(todos: Array<{ content: string; status: string; priority?: string }>): string { function memoryCandidateInstruction(): string {
if (todos.length === 0) return ""; return "";
}
const lines = ["<pending_todos>"]; /**
* Render todos for compaction context (Markdown-neutral format).
*/
function renderTodosForCompaction(todos: Array<{ content: string; status: string; priority?: string }>): string {
if (todos.length === 0) return "";
const lines = ["## Pending Todos"];
for (const todo of todos) { for (const todo of todos) {
const priority = todo.priority ? ` [${todo.priority}]` : ""; const priority = todo.priority ? ` [${todo.priority}]` : "";
lines.push(`- ${todo.content}${priority}`); const status = todo.status === "completed" ? "✓" : todo.status === "in_progress" ? "→" : "○";
lines.push(`- ${status} ${todo.content}${priority}`);
} }
lines.push("</pending_todos>");
return lines.join("\n"); return lines.join("\n");
} }
@@ -300,37 +327,36 @@ export const MemoryV2Plugin: Plugin = async (input) => {
// Sub-agents don't need compaction support // Sub-agents don't need compaction support
if (await isSubAgent(sessionID)) return; if (await isSubAgent(sessionID)) return;
// Add compaction context with memory, hot state, todos, and instruction // Build private context with Markdown-neutral format
const contextParts: string[] = []; const contextParts: string[] = [];
// 1. Frozen workspace memory // 1. Frozen workspace memory (strip XML tags for compaction context)
const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID); const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID);
const workspacePrompt = renderWorkspaceMemory(workspaceMemory); const workspacePrompt = renderWorkspaceMemory(workspaceMemory);
if (workspacePrompt) { if (workspacePrompt) {
contextParts.push(workspacePrompt); contextParts.push(stripXmlTags(workspacePrompt));
} }
// 2. Hot session state // 2. Hot session state (strip XML tags for compaction context)
const sessionState = await loadSessionState(directory, sessionID); const sessionState = await loadSessionState(directory, sessionID);
const hotPrompt = renderHotSessionState(sessionState, directory); const hotPrompt = renderHotSessionState(sessionState, directory);
if (hotPrompt) { if (hotPrompt) {
contextParts.push(hotPrompt); contextParts.push(stripXmlTags(hotPrompt));
} }
// 3. Pending todos from OpenCode // 3. Pending todos from OpenCode (Markdown-neutral format)
const todos = await pendingTodos(client, sessionID); const todos = await pendingTodos(client, sessionID);
const todosPrompt = renderTodos(todos); const todosPrompt = renderTodosForCompaction(todos);
if (todosPrompt) { if (todosPrompt) {
contextParts.push(todosPrompt); contextParts.push(todosPrompt);
} }
// 4. Memory candidate instruction // Combine into single private context block
contextParts.push(memoryCandidateInstruction()); const privateContext = contextParts.length > 0
? `${compactionContextHeader()}\n\n${contextParts.join("\n\n")}`
: compactionContextHeader();
// Add to compaction context (output.context is an array) output.context.push(privateContext);
for (const part of contextParts) {
output.context.push(part);
}
}, },
// Handle session events // Handle session events
+53
View File
@@ -189,6 +189,59 @@ test("tool.execute.after: exitCode non-zero creates open error", async () => {
"exitCode non-zero should create open error"); "exitCode non-zero should create open error");
assert.equal(state.openErrors[0].category, "typecheck"); assert.equal(state.openErrors[0].category, "typecheck");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("experimental.session.compacting: context contains no XML tags for compaction", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const client = mockRootClient();
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
// Create a session state with some data
await saveSessionState(tmpDir, {
version: 1,
sessionID: "test-session-compaction",
turn: 1,
updatedAt: new Date().toISOString(),
activeFiles: [{ path: "/src/index.ts", action: "edit", count: 5, lastSeen: Date.now() }],
openErrors: [],
recentDecisions: [{ text: "Test decision", rationale: "Testing", source: "user", createdAt: Date.now() }],
});
// Call the compaction hook
const output = { context: [] as string[] };
await (plugin as Record<string, Function>)["experimental.session.compacting"](
{ sessionID: "test-session-compaction" },
output
);
// The context should be a single string (private context block)
assert.equal(output.context.length, 1, "Context should be a single block");
const contextText = output.context[0];
// Verify: context should NOT contain XML-like tags that confuse Markdown
assert.equal(contextText.includes("<workspace_memory>"), false,
"Context should not contain <workspace_memory> tag");
assert.equal(contextText.includes("</workspace_memory>"), false,
"Context should not contain </workspace_memory> tag");
assert.equal(contextText.includes("<hot_session_state>"), false,
"Context should not contain <hot_session_state> tag");
assert.equal(contextText.includes("<pending_todos>"), false,
"Context should not contain <pending_todos> tag");
// Verify: context should contain the private context header
assert.equal(contextText.includes("[PRIVATE COMPACTION CONTEXT - DO NOT OUTPUT]"), true,
"Context should contain private context header");
// Verify: context should contain Markdown headers instead
assert.equal(contextText.includes("## Workspace Memory") || contextText.includes("## Hot Session State") || contextText.includes("## Pending Todos"), true,
"Context should use Markdown headers instead of XML tags");
} finally { } finally {
await rm(tmpDir, { recursive: true, force: true }); await rm(tmpDir, { recursive: true, force: true });
} }