Files
boocode/apps/coder/src/services/backends/__tests__/claude-sdk-map.test.ts
indifferentketchup f3a0197d6a feat: Claude Agent SDK backend + clean-room PostgresSessionStore (v2.7.5)
Lands the lean-SDK direction (boocode_code_review_v2 §1 #9) behind a flag.
Adds @anthropic-ai/claude-agent-sdk@0.3.159 (Commercial Terms, runtime dep).

- PostgresSessionStore: clean-room impl of the SDK's real SessionStore type
  over a new claude_session_entries table. Typechecks against the SDK type;
  8 DB-integration tests.
- ClaudeSdkBackend (implements AgentBackend): one warm query() per (chat,claude)
  in streaming-input mode via a pushable async-iterable pump, sessionStore +
  resume continuity, pure mapSdkMessage->AgentEvent, session_id from init,
  usage/cost onto agent_sessions (backend CHECK gains 'claude_sdk').
- Routing env-gated by CLAUDE_SDK_BACKEND (default off) -> PTY path UNCHANGED.
- Built against real SDK 0.3.159 types (install paid off: partial=stream_event
  needing includePartialMessages, MessageParam, result error arm).
- Fix latent test-infra deadlock: serialize DB suites (fileParallelism:false).

Coder 269 passing default / 290 with DB; tsc clean vs SDK types; builds clean.
LIVE pump + resume + actual claude turn need a host smoke (CLAUDE_SDK_BACKEND=1
+ claude binary + auth). zod peer-dep wants ^4 (workspace 3.25). Builds on v2.7.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 13:37:57 +00:00

182 lines
7.8 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
import { mapSdkMessage, createClaudeSdkMapState } from '../claude-sdk-map.js';
import type { AgentEvent } from '../../agent-backend.js';
/**
* Pure mapper for Claude-SDK messages → AgentEvents (claude-sdk-sessionstore #9 Part 2).
* Verifies the partial-stream → live-delta mapping, tool assembly across blocks, and
* the final-assistant dedup, with no live `claude` binary involved.
*
* Messages are cast through `unknown` to `SDKMessage`: the real SDK shapes carry many
* fields (uuid, parent_tool_use_id, …) irrelevant to the mapper, which reads only the
* `type`/`event`/`message.content` it discriminates on. The cast keeps the fixtures
* minimal while the production code path sees the full real types (the backend's
* typecheck against the real SDK is the type-safety proof).
*/
function msg(m: unknown): SDKMessage {
return m as SDKMessage;
}
/** A partial-stream message wrapping one BetaRawMessageStreamEvent. */
function streamEvent(event: unknown): SDKMessage {
return msg({ type: 'stream_event', event, parent_tool_use_id: null, uuid: 'u', session_id: 's' });
}
describe('mapSdkMessage — partial stream deltas', () => {
it('maps a text_delta to a text event', () => {
const state = createClaudeSdkMapState();
const out = mapSdkMessage(
streamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello' } }),
state,
);
expect(out).toEqual<AgentEvent[]>([{ type: 'text', text: 'Hello' }]);
});
it('maps a thinking_delta to a reasoning event', () => {
const state = createClaudeSdkMapState();
const out = mapSdkMessage(
streamEvent({
type: 'content_block_delta',
index: 0,
delta: { type: 'thinking_delta', thinking: 'pondering', estimated_tokens: null },
}),
state,
);
expect(out).toEqual<AgentEvent[]>([{ type: 'reasoning', text: 'pondering' }]);
});
it('drops empty text/thinking deltas', () => {
const state = createClaudeSdkMapState();
expect(
mapSdkMessage(streamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: '' } }), state),
).toEqual([]);
expect(
mapSdkMessage(
streamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: '', estimated_tokens: null } }),
state,
),
).toEqual([]);
});
it('ignores message framing + signature/citation deltas', () => {
const state = createClaudeSdkMapState();
expect(mapSdkMessage(streamEvent({ type: 'message_start', message: {} }), state)).toEqual([]);
expect(mapSdkMessage(streamEvent({ type: 'message_stop' }), state)).toEqual([]);
expect(
mapSdkMessage(streamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'signature_delta', signature: 'x' } }), state),
).toEqual([]);
});
});
describe('mapSdkMessage — tool assembly across blocks', () => {
it('opens a tool_call on content_block_start, buffers input_json_delta, emits tool_update with parsed input on stop', () => {
const state = createClaudeSdkMapState();
const started = mapSdkMessage(
streamEvent({
type: 'content_block_start',
index: 1,
content_block: { type: 'tool_use', id: 'tool-1', name: 'view_file', input: {} },
}),
state,
);
expect(started).toEqual<AgentEvent[]>([
{ type: 'tool_call', toolCall: { toolCallId: 'tool-1', title: 'view_file', kind: null, status: 'in_progress', rawInput: {}, rawOutput: undefined } },
]);
// args stream in fragments under the same block index
expect(
mapSdkMessage(streamEvent({ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"path":' } }), state),
).toEqual([]);
expect(
mapSdkMessage(streamEvent({ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '"a.ts"}' } }), state),
).toEqual([]);
const stopped = mapSdkMessage(streamEvent({ type: 'content_block_stop', index: 1 }), state);
expect(stopped).toHaveLength(1);
const ev = stopped[0]!;
expect(ev.type).toBe('tool_update');
if (ev.type === 'tool_update') {
expect(ev.toolCall.toolCallId).toBe('tool-1');
expect(ev.toolCall.title).toBe('view_file');
expect(ev.toolCall.rawInput).toEqual({ path: 'a.ts' });
}
});
it('content_block_stop for a non-tool block (no tracked index) emits nothing', () => {
const state = createClaudeSdkMapState();
// text block was streamed at index 0 but never tracked as a tool
mapSdkMessage(streamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hi' } }), state);
expect(mapSdkMessage(streamEvent({ type: 'content_block_stop', index: 0 }), state)).toEqual([]);
});
it('falls back to the prior input when the buffered tool JSON is invalid', () => {
const state = createClaudeSdkMapState();
mapSdkMessage(
streamEvent({ type: 'content_block_start', index: 2, content_block: { type: 'tool_use', id: 't2', name: 'grep', input: { q: 'seed' } } }),
state,
);
mapSdkMessage(streamEvent({ type: 'content_block_delta', index: 2, delta: { type: 'input_json_delta', partial_json: '{not json' } }), state);
const stopped = mapSdkMessage(streamEvent({ type: 'content_block_stop', index: 2 }), state);
const ev = stopped[0]!;
if (ev.type === 'tool_update') {
expect(ev.toolCall.rawInput).toEqual({ q: 'seed' });
} else {
throw new Error('expected tool_update');
}
});
});
describe('mapSdkMessage — final assistant message', () => {
function assistant(content: unknown[]): SDKMessage {
return msg({ type: 'assistant', message: { content }, parent_tool_use_id: null, uuid: 'u', session_id: 's' });
}
it('dedups text/thinking (already streamed) and emits a completed tool_update per tool_use block', () => {
const state = createClaudeSdkMapState();
const out = mapSdkMessage(
assistant([
{ type: 'text', text: 'final answer', citations: null },
{ type: 'thinking', thinking: 'reasoned', signature: 'sig' },
{ type: 'tool_use', id: 'tool-9', name: 'find_files', input: { glob: '**/*.ts' } },
]),
state,
);
expect(out).toEqual<AgentEvent[]>([
{
type: 'tool_update',
toolCall: { toolCallId: 'tool-9', title: 'find_files', kind: null, status: 'completed', rawInput: { glob: '**/*.ts' }, rawOutput: undefined },
},
]);
});
it('preserves a title from a prior partial tool_call snapshot', () => {
const state = createClaudeSdkMapState();
mapSdkMessage(
streamEvent({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'tool-x', name: 'view_file', input: {} } }),
state,
);
const out = mapSdkMessage(assistant([{ type: 'tool_use', id: 'tool-x', name: 'view_file', input: { path: 'z' } }]), state);
const ev = out[0]!;
if (ev.type === 'tool_update') {
expect(ev.toolCall.status).toBe('completed');
expect(ev.toolCall.title).toBe('view_file');
expect(ev.toolCall.rawInput).toEqual({ path: 'z' });
} else {
throw new Error('expected tool_update');
}
});
});
describe('mapSdkMessage — non-content messages', () => {
it('returns [] for system/init, status, result, and other variants', () => {
const state = createClaudeSdkMapState();
expect(mapSdkMessage(msg({ type: 'system', subtype: 'init', session_id: 's', uuid: 'u' }), state)).toEqual([]);
expect(mapSdkMessage(msg({ type: 'system', subtype: 'status', status: null, session_id: 's', uuid: 'u' }), state)).toEqual([]);
expect(
mapSdkMessage(msg({ type: 'result', subtype: 'success', result: 'done', session_id: 's', uuid: 'u' }), state),
).toEqual([]);
});
});