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>
182 lines
7.8 KiB
TypeScript
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([]);
|
|
});
|
|
});
|