Skip to content

Commit 0eff64c

Browse files
committed
feat(agent-manager): strip claude code tag
1 parent d8a88c8 commit 0eff64c

2 files changed

Lines changed: 247 additions & 5 deletions

File tree

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* Tests for utils/ClaudeSessionParser.ts — focused on stripping
3+
* harness-injected XML tags from conversation content.
4+
*/
5+
6+
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
7+
import * as fs from 'fs';
8+
import * as os from 'os';
9+
import * as path from 'path';
10+
import { ClaudeSessionParser } from '../../utils/ClaudeSessionParser';
11+
12+
interface JsonlEntry {
13+
type: 'user' | 'assistant' | 'system';
14+
message: { content: string | Array<{ type: string; text?: string; content?: string; name?: string; input?: unknown; is_error?: boolean }> };
15+
timestamp?: string;
16+
}
17+
18+
function writeSession(entries: JsonlEntry[]): string {
19+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'parser-test-'));
20+
const filePath = path.join(dir, 'session.jsonl');
21+
fs.writeFileSync(filePath, entries.map(e => JSON.stringify(e)).join('\n'));
22+
return filePath;
23+
}
24+
25+
describe('ClaudeSessionParser.getConversation — harness tag stripping', () => {
26+
let parser: ClaudeSessionParser;
27+
const tempFiles: string[] = [];
28+
29+
beforeEach(() => {
30+
parser = new ClaudeSessionParser();
31+
});
32+
33+
afterEach(() => {
34+
for (const f of tempFiles) {
35+
try {
36+
fs.rmSync(path.dirname(f), { recursive: true, force: true });
37+
} catch { /* best effort */ }
38+
}
39+
tempFiles.length = 0;
40+
});
41+
42+
function makeSession(entries: JsonlEntry[]): string {
43+
const f = writeSession(entries);
44+
tempFiles.push(f);
45+
return f;
46+
}
47+
48+
it('strips <system-reminder> blocks from text content', () => {
49+
const file = makeSession([
50+
{
51+
type: 'assistant',
52+
message: {
53+
content: [{
54+
type: 'text',
55+
text: 'Real response here.\n<system-reminder>\nDo not mention this reminder.\n</system-reminder>\nMore response.',
56+
}],
57+
},
58+
},
59+
]);
60+
61+
const conv = parser.getConversation(file);
62+
expect(conv).toHaveLength(1);
63+
expect(conv[0].content).toBe('Real response here.\n\nMore response.');
64+
});
65+
66+
it('strips <local-command-stdout> blocks', () => {
67+
const file = makeSession([
68+
{
69+
type: 'system',
70+
message: { content: 'before <local-command-stdout>\nRunning...\nDone\n</local-command-stdout> after' },
71+
},
72+
]);
73+
74+
const conv = parser.getConversation(file);
75+
expect(conv[0].content).toBe('before after');
76+
});
77+
78+
it('strips <user-prompt-submit-hook> blocks', () => {
79+
const file = makeSession([
80+
{
81+
type: 'user',
82+
message: { content: '<user-prompt-submit-hook>hook output</user-prompt-submit-hook>\nactual question' },
83+
},
84+
]);
85+
86+
const conv = parser.getConversation(file);
87+
expect(conv[0].content).toBe('actual question');
88+
});
89+
90+
it('strips bash and command stdout/stderr blocks', () => {
91+
const file = makeSession([
92+
{
93+
type: 'assistant',
94+
message: {
95+
content: [{
96+
type: 'text',
97+
text: 'Output:\n<bash-input>ls</bash-input>\n<bash-stdout>file.txt</bash-stdout>\n<bash-stderr></bash-stderr>\n<command-stdout>x</command-stdout>\n<command-stderr>y</command-stderr>\nEnd.',
98+
}],
99+
},
100+
},
101+
]);
102+
103+
const conv = parser.getConversation(file);
104+
expect(conv[0].content).toBe('Output:\n\n\n\n\n\nEnd.'.replace(/\n{3,}/g, '\n\n'));
105+
expect(conv[0].content).not.toMatch(/<bash-/);
106+
expect(conv[0].content).not.toMatch(/<command-stdout>|<command-stderr>/);
107+
});
108+
109+
it('collapses <command-name>/<command-args> into "/name args" shorthand', () => {
110+
const file = makeSession([
111+
{
112+
type: 'user',
113+
message: {
114+
content: '<command-message>debug</command-message>\n<command-name>/debug</command-name>\n<command-args>fix the bug</command-args>',
115+
},
116+
},
117+
]);
118+
119+
const conv = parser.getConversation(file);
120+
expect(conv[0].content).toBe('/debug fix the bug');
121+
});
122+
123+
it('handles <command-name> without <command-args>', () => {
124+
const file = makeSession([
125+
{
126+
type: 'user',
127+
message: {
128+
content: '<command-message>clear</command-message>\n<command-name>/clear</command-name>',
129+
},
130+
},
131+
]);
132+
133+
const conv = parser.getConversation(file);
134+
expect(conv[0].content).toBe('/clear');
135+
});
136+
137+
it('handles multiple harness tags mixed together', () => {
138+
const file = makeSession([
139+
{
140+
type: 'user',
141+
message: {
142+
content: [{
143+
type: 'text',
144+
text: '<system-reminder>ignore me</system-reminder>\n<command-message>build</command-message>\n<command-name>/build</command-name>\n<command-args>--watch</command-args>\n<local-command-stdout>build output here</local-command-stdout>',
145+
}],
146+
},
147+
},
148+
]);
149+
150+
const conv = parser.getConversation(file);
151+
expect(conv[0].content).toBe('/build --watch');
152+
});
153+
154+
it('leaves text without harness tags unchanged', () => {
155+
const file = makeSession([
156+
{
157+
type: 'assistant',
158+
message: { content: [{ type: 'text', text: 'Hello, world!' }] },
159+
},
160+
]);
161+
162+
const conv = parser.getConversation(file);
163+
expect(conv[0].content).toBe('Hello, world!');
164+
});
165+
166+
it('drops a message that becomes empty after stripping', () => {
167+
const file = makeSession([
168+
{
169+
type: 'system',
170+
message: { content: '<system-reminder>only this</system-reminder>' },
171+
},
172+
{
173+
type: 'assistant',
174+
message: { content: [{ type: 'text', text: 'kept' }] },
175+
},
176+
]);
177+
178+
const conv = parser.getConversation(file);
179+
expect(conv).toHaveLength(1);
180+
expect(conv[0].content).toBe('kept');
181+
});
182+
183+
it('strips tags spanning multiple lines', () => {
184+
const multilineReminder = '<system-reminder>\nLine 1\nLine 2\nLine 3\n</system-reminder>';
185+
const file = makeSession([
186+
{
187+
type: 'assistant',
188+
message: { content: [{ type: 'text', text: `before\n${multilineReminder}\nafter` }] },
189+
},
190+
]);
191+
192+
const conv = parser.getConversation(file);
193+
expect(conv[0].content).toBe('before\n\nafter');
194+
});
195+
});

packages/agent-manager/src/utils/ClaudeSessionParser.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -347,9 +347,9 @@ export class ClaudeSessionParser {
347347
if (!content) return undefined;
348348

349349
if (typeof content === 'string') {
350-
const trimmed = content.trim();
351-
if (role === 'user' && isNoiseMessage(trimmed)) return undefined;
352-
return trimmed || undefined;
350+
const cleaned = stripHarnessTags(content);
351+
if (role === 'user' && isNoiseMessage(cleaned)) return undefined;
352+
return cleaned || undefined;
353353
}
354354

355355
if (!Array.isArray(content)) return undefined;
@@ -358,8 +358,10 @@ export class ClaudeSessionParser {
358358

359359
for (const block of content) {
360360
if (block.type === 'text' && block.text?.trim()) {
361-
if (role === 'user' && isNoiseMessage(block.text.trim())) continue;
362-
parts.push(block.text.trim());
361+
const cleaned = stripHarnessTags(block.text);
362+
if (!cleaned) continue;
363+
if (role === 'user' && isNoiseMessage(cleaned)) continue;
364+
parts.push(cleaned);
363365
} else if (block.type === 'tool_use' && verbose) {
364366
const inputSummary = block.input?.file_path || block.input?.pattern || block.input?.command || '';
365367
parts.push(`[Tool: ${block.name}]${inputSummary ? ' ' + inputSummary : ''}`);
@@ -374,6 +376,51 @@ export class ClaudeSessionParser {
374376
}
375377
}
376378

379+
/**
380+
* Tags whose entire block (including content) should be dropped — they are
381+
* harness-injected prompt context (system reminders, hook output, command
382+
* stdout), not meaningful conversation content.
383+
*/
384+
const HARNESS_DROP_TAGS = [
385+
'system-reminder',
386+
'local-command-stdout',
387+
'local-command-stderr',
388+
'user-prompt-submit-hook',
389+
'command-stdout',
390+
'command-stderr',
391+
'bash-input',
392+
'bash-stdout',
393+
'bash-stderr',
394+
'command-message',
395+
] as const;
396+
397+
const HARNESS_DROP_RE = new RegExp(
398+
`<(${HARNESS_DROP_TAGS.join('|')})>[\\s\\S]*?</\\1>`,
399+
'g',
400+
);
401+
402+
const COMMAND_INVOCATION_RE =
403+
/<command-name>([^<]+)<\/command-name>(?:\s*<command-args>([\s\S]*?)<\/command-args>)?/g;
404+
405+
/**
406+
* Remove harness-injected XML blocks from message text and collapse
407+
* <command-name>/<command-args> pairs into a "/name args" shorthand.
408+
*
409+
* Returns the cleaned, trimmed text. Returns an empty string if nothing
410+
* survives stripping.
411+
*/
412+
function stripHarnessTags(text: string): string {
413+
let out = text.replace(HARNESS_DROP_RE, '');
414+
415+
out = out.replace(COMMAND_INVOCATION_RE, (_match, rawName: string, rawArgs?: string) => {
416+
const name = rawName.trim();
417+
const args = rawArgs?.trim();
418+
return args ? `${name} ${args}` : name;
419+
});
420+
421+
return out.replace(/\n{3,}/g, '\n\n').trim();
422+
}
423+
377424
/** Check if a message is noise (not a meaningful user intent). */
378425
function isNoiseMessage(text: string): boolean {
379426
return (

0 commit comments

Comments
 (0)