feat(worker): ClaudeCodeAdapter runs claude -p stream-json in the per-user box

This commit is contained in:
Gabriel Brown
2026-07-11 10:35:56 -04:00
parent c9ee2e6cde
commit 76886621b5
7 changed files with 465 additions and 0 deletions
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'vitest';
import {
normalizeClaudeJsonLine,
normalizeCodexJsonLine,
normalizeOpenCodeEvent,
normalizeOpenCodeRunLine,
@@ -329,4 +330,73 @@ describe('agent event normalization', () => {
status: 'not json at all',
});
});
test('normalizes Claude Code stream-json output lines', () => {
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'system',
subtype: 'init',
session_id: 'abc',
}),
),
).toContainEqual({ kind: 'session', sessionId: 'abc' });
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'assistant',
message: {
content: [
{ type: 'text', text: 'hello ' },
{ type: 'text', text: 'world' },
],
},
}),
),
).toContainEqual({ kind: 'assistant_delta', content: 'hello world' });
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'assistant',
message: {
content: [
{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } },
],
},
}),
),
).toContainEqual({
kind: 'tool_started',
name: 'Bash',
input: JSON.stringify({ command: 'ls' }),
});
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'result',
subtype: 'success',
result: 'final text',
session_id: 'abc',
}),
),
).toContainEqual({ kind: 'assistant_completed', content: 'final text' });
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'result',
subtype: 'error_max_turns',
result: 'hit the turn limit',
}),
),
).toContainEqual({ kind: 'error', message: 'hit the turn limit' });
expect(normalizeClaudeJsonLine('not json at all')).toContainEqual({
kind: 'status',
status: 'not json at all',
});
});
});
@@ -0,0 +1,110 @@
import { describe, expect, test, vi } from 'vitest';
import type { NormalizedAgentEvent } from '../../src/agent-events';
import type { ExecStreamFn } from '../../src/runtime/agent-runtime';
import type { AdapterWorkspace, Claim } from '../../src/runtime/provider';
const loadAdapter = async () => {
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
return await import('../../src/runtime/claude-adapter');
};
const claim = {
job: {
_id: 'job-1',
prompt: 'hi',
baseBranch: 'main',
workBranch: 'spoon/x',
forkOwner: 'o',
forkRepo: 'r',
upstreamOwner: 'u',
upstreamRepo: 'r',
},
spoon: { name: 'Spoon' },
openai: { model: 'claude-sonnet-4', reasoningEffort: 'medium' },
aiProviderProfile: {
id: 'p',
name: 'Anthropic',
provider: 'anthropic',
authType: 'api_key',
secret: 'sk-ant-test',
model: 'claude-sonnet-4',
reasoningEffort: 'medium',
},
github: {},
secrets: [],
} as unknown as Claim;
const makeWorkspace = (): AdapterWorkspace => ({
claim,
workdir: '/tmp/spoon-claude-adapter-test-missing',
homeDir: '/tmp/spoon-claude-adapter-test-missing',
username: 'tester',
containerHome: '/home/tester',
containerRepo: '/home/tester/Code/spoon',
repoDir: '/tmp/spoon-claude-adapter-test-missing/Code/spoon',
boxName: 'spoon-box-tester',
redact: (value: string) => value,
runtime: 'claude',
});
describe('ClaudeCodeAdapter', () => {
test('streams normalized events and marks the turn via buildMarkedCommand', async () => {
const { createClaudeAdapter } = await loadAdapter();
const captured: unknown[] = [];
const execStream = vi.fn(async (args) => {
captured.push(args.command);
await args.onStdoutLine?.(
JSON.stringify({
type: 'system',
subtype: 'init',
session_id: 'abc',
}),
);
await args.onStdoutLine?.(
JSON.stringify({
type: 'assistant',
message: { content: [{ type: 'text', text: 'hi there' }] },
}),
);
await args.onStdoutLine?.(
JSON.stringify({
type: 'result',
subtype: 'success',
result: 'final text',
session_id: 'abc',
}),
);
return { exitCode: 0, output: '' };
}) as unknown as ExecStreamFn;
const adapter = createClaudeAdapter({ execStream });
const events: NormalizedAgentEvent[] = [];
const onEvent = (event: NormalizedAgentEvent): Promise<void> => {
events.push(event);
return Promise.resolve();
};
const result = await adapter.runTurn(makeWorkspace(), 'do it', onEvent);
expect(events).toContainEqual({ kind: 'session', sessionId: 'abc' });
expect(events).toContainEqual({
kind: 'assistant_delta',
content: 'hi there',
});
expect(result.finalMessage).toBe('final text');
expect(result.sessionId).toBe('abc');
expect(result.error).toBeUndefined();
// The turn ran through buildMarkedCommand (setsid bash session) carrying the
// `claude -p` argv in the script.
const command = captured[0] as string[];
expect(command[0]).toBe('setsid');
expect(command[1]).toBe('bash');
expect(command[3]).toContain("'claude' '-p'");
expect(command[3]).toContain("'--output-format' 'stream-json'");
});
});