fix: use plain text labels instead of Markdown headers

- Changed '## Memory Candidates' to 'Memory candidates:' in compaction context
- Changed '## Pending Todos' to 'Pending todos:' in todo rendering
- Updated extractCandidateBlock() to parse plain text format (primary)
- Removed stripXmlTags() function (no longer needed)
- All 42 tests pass

Root cause: Markdown headings (##) render as purple in OpenCode UI,
same issue as XML tags and HTML comments. Plain text labels avoid
all special markup rendering.
This commit is contained in:
Ralph Chang
2026-04-26 15:13:58 +08:00
parent 32fa2bd454
commit 721544e7a8
8 changed files with 1056 additions and 116 deletions
+20 -16
View File
@@ -133,9 +133,8 @@ import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
test("parseWorkspaceMemoryCandidates rejects short text", () => {
const summary = `
<workspace_memory_candidates>
## Memory Candidates
- [decision] short text
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
@@ -143,9 +142,8 @@ test("parseWorkspaceMemoryCandidates rejects short text", () => {
test("parseWorkspaceMemoryCandidates rejects git commit hash", () => {
const summary = `
<workspace_memory_candidates>
## Memory Candidates
- [project] abc123def456 is the commit hash
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
@@ -153,9 +151,8 @@ test("parseWorkspaceMemoryCandidates rejects git commit hash", () => {
test("parseWorkspaceMemoryCandidates rejects raw error", () => {
const summary = `
<workspace_memory_candidates>
## Memory Candidates
- [feedback] TypeError: Cannot read property 'x' of undefined
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
@@ -163,9 +160,8 @@ test("parseWorkspaceMemoryCandidates rejects raw error", () => {
test("parseWorkspaceMemoryCandidates rejects stack trace", () => {
const summary = `
<workspace_memory_candidates>
## Memory Candidates
- [reference] at foo (bar.ts:10:5)
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
@@ -173,9 +169,8 @@ test("parseWorkspaceMemoryCandidates rejects stack trace", () => {
test("parseWorkspaceMemoryCandidates rejects commit prefix", () => {
const summary = `
<workspace_memory_candidates>
## Memory Candidates
- [project] fix: add new feature
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
@@ -183,9 +178,8 @@ test("parseWorkspaceMemoryCandidates rejects commit prefix", () => {
test("parseWorkspaceMemoryCandidates rejects path-heavy facts", () => {
const summary = `
<workspace_memory_candidates>
## Memory Candidates
- [project] files at /src/a.ts /src/b.ts /src/c.ts are important
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
@@ -193,9 +187,8 @@ test("parseWorkspaceMemoryCandidates rejects path-heavy facts", () => {
test("parseWorkspaceMemoryCandidates accepts valid decision", () => {
const summary = `
<workspace_memory_candidates>
## Memory Candidates
- [decision] Use pnpm instead of npm for package management
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 1);
@@ -205,11 +198,22 @@ test("parseWorkspaceMemoryCandidates accepts valid decision", () => {
test("parseWorkspaceMemoryCandidates accepts valid project info", () => {
const summary = `
<workspace_memory_candidates>
## Memory Candidates
- [project] This project uses TypeScript for all source files
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 1);
assert.equal(items[0].type, "project");
});
test("parseWorkspaceMemoryCandidates accepts plain text label format (no Markdown)", () => {
const summary = `
Memory candidates:
- [decision] Use plain text labels to avoid purple Markdown headers
- [project] This repo uses pnpm for package management
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 2);
assert.equal(items[0].type, "decision");
assert.equal(items[1].type, "project");
});
+15 -29
View File
@@ -235,13 +235,13 @@ test("experimental.session.compacting: context contains no XML tags for compacti
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 NOT contain square bracket markers
assert.equal(contextText.includes("[PRIVATE COMPACTION CONTEXT"), false,
"Context should not contain square bracket markers");
// 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");
// Verify: context should contain plain text headers
assert.equal(contextText.includes("Workspace memory") || contextText.includes("Hot session state") || contextText.includes("Pending todos"), true,
"Context should use plain text headers instead of XML tags");
} finally {
await rm(tmpDir, { recursive: true, force: true });
@@ -264,48 +264,34 @@ test("compactionContextHeader does not require XML output", async () => {
const header = output.context[0] || "";
// Should instruct HTML comment format
assert.equal(header.includes("<!-- workspace_memory_candidates"), true,
"Header should instruct HTML comment format");
// Should NOT instruct legacy XML format as primary
// Note: We check that the instruction for HTML comments comes before any XML mention
const htmlCommentIndex = header.indexOf("<!-- workspace_memory_candidates");
const xmlTagIndex = header.indexOf("<workspace_memory_candidates>\n");
if (xmlTagIndex !== -1) {
assert.equal(htmlCommentIndex < xmlTagIndex, true,
"HTML comment format should be the primary instruction, not XML");
}
// Should explicitly forbid XML
assert.equal(header.includes("DO NOT use XML tags"), true,
"Header should forbid XML tags");
// Should instruct plain text label format (not Markdown headers)
assert.equal(header.includes("Memory candidates:"), true,
"Header should use plain text 'Memory candidates:' label");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("parseWorkspaceMemoryCandidates accepts HTML comment format", async () => {
test("parseWorkspaceMemoryCandidates accepts Markdown section format", async () => {
const summary = `
## Summary
Progress made on testing.
<!-- workspace_memory_candidates
- [decision] Use HTML comments for candidates
## Memory Candidates
- [decision] Use Markdown sections for candidates
- [project] This repo uses Markdown for docs
-->
Next steps: continue development.
`;
const candidates = parseWorkspaceMemoryCandidates(summary);
assert.equal(candidates.length, 2, "Should parse HTML comment format");
assert.equal(candidates.length, 2, "Should parse Markdown section format");
assert.equal(candidates[0].type, "decision");
assert.equal(candidates[1].type, "project");
});
test("parseWorkspaceMemoryCandidates accepts Markdown section format", async () => {
test("parseWorkspaceMemoryCandidates accepts legacy Workspace Memory Candidates section", async () => {
const summary = `
## Summary
Progress made on testing.
@@ -318,7 +304,7 @@ Continue development.
`;
const candidates = parseWorkspaceMemoryCandidates(summary);
assert.equal(candidates.length, 1, "Should parse Markdown section format");
assert.equal(candidates.length, 1, "Should parse legacy section format");
assert.equal(candidates[0].type, "reference");
});
+6 -6
View File
@@ -21,7 +21,7 @@ function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "de
// Task 2: renderWorkspaceMemory tests
// ============================================
test("renderWorkspaceMemory never truncates closing XML tag", () => {
test("renderWorkspaceMemory respects budget and fits entries", () => {
const entries = Array.from({ length: 28 }, (_, i) =>
entry(`mem_${i}`, `Long durable memory entry ${i} `.repeat(20))
);
@@ -36,8 +36,8 @@ test("renderWorkspaceMemory never truncates closing XML tag", () => {
const rendered = renderWorkspaceMemory(store);
assert.ok(rendered.endsWith("</workspace_memory>"),
`Rendered memory must end with closing tag. Got: ...${rendered.slice(-50)}`);
assert.ok(!rendered.includes("<workspace_memory>"),
"Should not contain XML tags");
assert.ok(rendered.length <= 700,
`Rendered memory must not exceed maxChars. Got: ${rendered.length}`);
});
@@ -56,7 +56,7 @@ test("renderWorkspaceMemory returns empty string when maxChars too small", () =>
"When maxChars too small for even minimal envelope, return empty string");
});
test("renderWorkspaceMemory respects budget and fits entries", () => {
test("renderWorkspaceMemory respects small budget", () => {
// Create entries that would overflow a small budget
const entries = [
entry("a", "First memory entry that is reasonably long"),
@@ -74,8 +74,8 @@ test("renderWorkspaceMemory respects budget and fits entries", () => {
const rendered = renderWorkspaceMemory(store);
assert.ok(rendered.endsWith("</workspace_memory>"),
"Must end with closing tag even when truncating entries");
assert.ok(!rendered.includes("<workspace_memory>"),
"Should not contain XML tags");
assert.ok(rendered.length <= 200,
`Must respect maxChars limit. Got: ${rendered.length}`);
});