Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions packages/core/src/agents/claude-code/parser.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { claudeCodeParser } from './parser.js';
import { adaptTranscript } from '../../parsers/adapt.js';
import { serializeTranscript } from '../../index.js';

/** A representative Claude Code `--print` JSONL session. */
const SESSION = [
Expand Down Expand Up @@ -77,6 +78,93 @@ const SESSION = [
}),
].join('\n');

/**
* A session with `--forward-subagent-text`: subagent lines are ordinary
* assistant/user lines whose top-level `parent_tool_use_id` names the
* spawning Agent tool_use, with `subagent_type` / `task_description`
* alongside (captured from Claude Code 2.1.220).
*/
const SUBAGENT_SESSION = [
// main thread spawns the subagent (Task's 2.1.2xx name is `Agent`)
JSON.stringify({
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'toolu_spawn',
name: 'Agent',
input: {
description: 'Check primality',
subagent_type: 'general-purpose',
prompt: 'Which of 91, 97, 100 are prime?',
},
},
],
},
}),
// the subagent's kickoff prompt, echoed as a forwarded user line
JSON.stringify({
type: 'user',
message: {
role: 'user',
content: [{ type: 'text', text: 'Which of 91, 97, 100 are prime?' }],
},
parent_tool_use_id: 'toolu_spawn',
subagent_type: 'general-purpose',
task_description: 'Check primality',
}),
// the subagent thinking (summarized) then replying
JSON.stringify({
type: 'assistant',
message: {
role: 'assistant',
content: [
{ type: 'thinking', thinking: '97 is prime; 91 and 100 are not.' },
],
},
parent_tool_use_id: 'toolu_spawn',
subagent_type: 'general-purpose',
task_description: 'Check primality',
}),
JSON.stringify({
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: '[97]' }],
},
parent_tool_use_id: 'toolu_spawn',
subagent_type: 'general-purpose',
task_description: 'Check primality',
}),
// the Agent tool_result closing the spawn, back on the main thread
JSON.stringify({
type: 'user',
message: {
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'toolu_spawn', content: '[97]' },
],
},
parent_tool_use_id: null,
}),
// main thread wraps up; the result line repeats it and must dedup
JSON.stringify({
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'The subagent says only 97 is prime.' }],
},
parent_tool_use_id: null,
}),
JSON.stringify({
type: 'result',
subtype: 'success',
result: 'The subagent says only 97 is prime.',
}),
].join('\n');

describe('claudeCodeParser', () => {
it('normalizes tool names while preserving the original, and pairs results by id', () => {
const { events, errors } = claudeCodeParser.parseTranscript(SESSION);
Expand Down Expand Up @@ -161,6 +249,46 @@ describe('claudeCodeParser', () => {
]);
});

it('tags forwarded subagent lines with the agnostic subagent ref', () => {
const { events, errors } =
claudeCodeParser.parseTranscript(SUBAGENT_SESSION);
expect(errors).toEqual([]);

// The Agent spawn (Task's 2.1.2xx name) normalizes to agent_task.
const spawn = events.find((e) => e.type === 'tool_call');
expect(spawn?.tool?.name).toBe('agent_task');
expect(spawn?.tool?.originalName).toBe('Agent');
expect(spawn?.subagent).toBeUndefined();

const tagged = events.filter((e) => e.subagent);
expect(tagged.map((e) => e.type)).toEqual([
'message', // the subagent's kickoff prompt
'thinking',
'message', // the subagent's reply
]);
for (const e of tagged) {
expect(e.subagent).toEqual({
id: 'toolu_spawn',
type: 'general-purpose',
description: 'Check primality',
});
}
expect(tagged[1].content).toBe('97 is prime; 91 and 100 are not.');
});

it('dedups the result line against the main thread even when a subagent spoke last', () => {
// A subagent reply must not register as "the last assistant message" —
// otherwise a result line repeating the main thread's final text would be
// emitted twice (or a subagent echo would swallow it).
const { events } = claudeCodeParser.parseTranscript(SUBAGENT_SESSION);
const mainAssistant = events.filter(
(e) => e.type === 'message' && e.role === 'assistant' && !e.subagent
);
expect(mainAssistant.map((e) => e.content)).toEqual([
'The subagent says only 97 is prime.',
]);
});

it('skips lines with no content and never throws on malformed lines', () => {
const { events, errors } = claudeCodeParser.parseTranscript(
'not json\n' + JSON.stringify({ type: 'system', subtype: 'init' })
Expand Down Expand Up @@ -202,6 +330,59 @@ describe('adaptTranscript', () => {
]);
});

describe('with forwarded subagent lines', () => {
const subagentAdapted = adaptTranscript(
claudeCodeParser.parseTranscript(SUBAGENT_SESSION).events
);

it('keeps the report, steps, and tool-call records on the main thread', () => {
expect(subagentAdapted.agentReport).toBe(
'The subagent says only 97 is prime.'
);
expect(subagentAdapted.steps).toBe(1);
// Only the main thread's Agent spawn — no subagent-attributed records.
expect(subagentAdapted.toolCalls.map((c) => c.endpoint)).toEqual([
'Agent',
]);
});

it('captures subagent messages and thinking as tagged transcript parts', () => {
const tagged = subagentAdapted.transcript.filter(
(p) => 'subagent' in p && p.subagent
);
expect(tagged.map((p) => p.type)).toEqual([
'message',
'thinking',
'message',
]);
expect(tagged.find((p) => p.type === 'thinking')?.content).toBe(
'97 is prime; 91 and 100 are not.'
);
});

it('serializes identically to a subagent-free transcript by default', () => {
const serialized = serializeTranscript(subagentAdapted.transcript);
expect(serialized).not.toContain('97 is prime; 91 and 100 are not.');
expect(serialized).not.toContain('[97]');
expect(serialized).toContain(
'[assistant]\nThe subagent says only 97 is prime.'
);
});

it('serializes subagent parts and thinking on request, labeled', () => {
const serialized = serializeTranscript(subagentAdapted.transcript, {
includeSubagents: true,
includeThinking: true,
});
expect(serialized).toContain(
'[subagent:general-purpose thinking]\n97 is prime; 91 and 100 are not.'
);
expect(serialized).toContain(
'[subagent:general-purpose assistant]\n[97]'
);
});
});

it('renders a scorer-facing transcript (messages + tool calls, raw args preserved)', () => {
expect(adapted.transcript).toEqual([
{ type: 'message', role: 'assistant', content: 'Let me list the files.' },
Expand Down
38 changes: 36 additions & 2 deletions packages/core/src/agents/claude-code/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,19 @@
* tool results arrive on `user` lines as `tool_result` content blocks, and the
* run ends with a top-level `result` line carrying the final text.
*
* With `--forward-subagent-text` (set by the runner), subagent text/thinking
* arrive as ordinary `assistant`/`user` lines whose top-level
* `parent_tool_use_id` names the spawning Agent/Task `tool_use` id, alongside
* `subagent_type` and `task_description`. Those events are tagged with the
* agnostic `subagent` ref so downstream consumers keep them off the main
* thread.
*
* Adapted from `@supabase/agent-evals` (packages/agent-eval/src/parsers).
*/

import type {
ParsedTranscript,
SubagentRef,
TranscriptEvent,
} from '../../transcript/types.js';
import type { AgentTranscriptParser } from '../../parsers/types.js';
Expand Down Expand Up @@ -47,8 +55,9 @@ const CLAUDE_CODE_TOOLS: AgentToolMap = {
Glob: 'glob',
Grep: 'grep',
LS: 'list_dir',
// Agent / subagent
// Agent / subagent (`Task` was renamed `Agent` in Claude Code 2.1.2xx)
Task: 'agent_task',
Agent: 'agent_task',
TodoWrite: 'agent_task',
},
};
Expand Down Expand Up @@ -164,11 +173,29 @@ function loadedSkillFromClaudeCodeCall(
return undefined;
}

/**
* Subagent attribution of a stream-json line: present when the line was
* forwarded from inside a Task/Agent subagent (`--forward-subagent-text`).
*/
function subagentRef(data: Record<string, unknown>): SubagentRef | undefined {
if (typeof data.parent_tool_use_id !== 'string') return undefined;
return {
id: data.parent_tool_use_id,
...(typeof data.subagent_type === 'string'
? { type: data.subagent_type }
: {}),
...(typeof data.task_description === 'string'
? { description: data.task_description }
: {}),
};
}

function recordToEvents(data: Record<string, unknown>): TranscriptEvent[] {
const events: TranscriptEvent[] = [];
const timestamp =
typeof data.timestamp === 'string' ? data.timestamp : undefined;
const type = data.type;
const subagent = subagentRef(data);

if (type === 'user' || data.role === 'user') {
const toolResults = getContentArray(data)?.filter(
Expand Down Expand Up @@ -272,6 +299,9 @@ function recordToEvents(data: Record<string, unknown>): TranscriptEvent[] {
});
}

if (subagent) {
for (const event of events) event.subagent = subagent;
}
return events;
}

Expand All @@ -291,8 +321,12 @@ export const claudeCodeParser: AgentTranscriptParser = {
for (const record of records) {
try {
for (const event of recordToEvents(record)) {
// Track only main-thread assistant text: the terminal `result` line
// repeats the main thread's final message, never a subagent's.
const isAssistantMessage =
event.type === 'message' && event.role === 'assistant';
event.type === 'message' &&
event.role === 'assistant' &&
!event.subagent;
// The terminal `result` line repeats the final assistant message that
// the preceding `assistant` line already emitted (stream-json carries
// both). Drop the duplicate, but still emit it when the text was never
Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/agents/claude-code/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ export const claudeCodeRunner: AgentRunner<AnthropicModel> = {
apiKeyEnvVar: 'ANTHROPIC_API_KEY',
cliPackage: '@anthropic-ai/claude-code',
// Pinned: Claude Code's transcript format evolves; bump deliberately and
// re-check the parser. See ./parser.ts.
defaultCliVersion: '2.1.191',
// re-check the parser. See ./parser.ts. 2.1.220 is the minimum for
// `--forward-subagent-text` (added in 2.1.212) and renames the Task tool
// to Agent.
defaultCliVersion: '2.1.220',
defaultModel: 'claude-sonnet-4-6',

async install(sandbox, version) {
Expand Down Expand Up @@ -65,6 +67,13 @@ export const claudeCodeRunner: AgentRunner<AnthropicModel> = {
// Newline-delimited JSON events on stdout (requires --verbose).
'--output-format stream-json',
'--verbose',
// Forward subagent text/thinking as lines tagged with
// `parent_tool_use_id` (see ./parser.ts), and request summarized
// thinking text — Opus 5-era models stream empty thinking blocks
// otherwise. Both are visibility-only: they change what the transcript
// captures, not how the agent behaves.
'--forward-subagent-text',
'--thinking-display summarized',
`--model ${shellQuote(model)}`,
// Reasoning effort for the session; omitted leaves Claude Code's default.
...(reasoningEffort ? [`--effort ${shellQuote(reasoningEffort)}`] : []),
Expand Down
Loading