- MCP secrets: substituteEnvVars recursively resolves {env:NAME} in mcp.json string values from process.env before Zod (opencode-compatible); unset -> '' + boot warning, and invalid-config log names the unset vars (an empty {env:VAR} in a strict url/command field invalidates the whole config)
- data/mcp.json now untracked (.gitignore flips !data/mcp.json -> !data/mcp.example.json); tracked template data/mcp.example.json carries "{env:CONTEXT7_API_KEY}"; .env.example documents the key (9 mcp-config tests)
- Coder fix: message_complete frame model widened string -> string|null (server+web ws-frames parity); dispatcher publishes model: task.model at all 4 external completion points — a null model otherwise fail-closed in publishFrame and dropped the whole frame incl. status:'complete' (regression test)
- Coder fix: claude-sdk mapUserToolResults maps user-message tool_result blocks -> terminal tool_update events (completed/failed w/ output) so tool snapshots resolve instead of spinning forever
- Composer: AgentComposerBar drops §9b resumed/history/new chip + token readout, loses flex-wrap so the row stays one line; CoderPane gains a per-chat localStorage agent-config cache (restores last model on reopen) + threads model into the timeline/chip
- Docs: root CLAUDE.md slimmed (~190 lines), per-app refs split to apps/{coder,server,web}/CLAUDE.md; new docs/coder-backends.md, docs/project-discovery.md, docs/coding-standards/ (cross-app-contract-parity); ARCHITECTURE.md links the backends doc
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
252 lines
11 KiB
TypeScript
252 lines
11 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([]);
|
|
});
|
|
});
|
|
|
|
describe('mapSdkMessage — user tool results', () => {
|
|
/** A `user` message carrying tool_result blocks (the SDK feeds tool output back here). */
|
|
function userMsg(content: unknown): SDKMessage {
|
|
return msg({ type: 'user', message: { role: 'user', content }, parent_tool_use_id: null, uuid: 'u', session_id: 's' });
|
|
}
|
|
|
|
it('maps a string tool_result to a completed tool_update carrying the output', () => {
|
|
const state = createClaudeSdkMapState();
|
|
const out = mapSdkMessage(userMsg([{ type: 'tool_result', tool_use_id: 't1', content: 'done' }]), state);
|
|
expect(out).toEqual<AgentEvent[]>([
|
|
{
|
|
type: 'tool_update',
|
|
toolCall: { toolCallId: 't1', title: 't1', kind: null, status: 'completed', rawInput: undefined, rawOutput: 'done' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('marks an is_error result failed', () => {
|
|
const state = createClaudeSdkMapState();
|
|
const out = mapSdkMessage(userMsg([{ type: 'tool_result', tool_use_id: 't1', content: 'boom', is_error: true }]), state);
|
|
const ev = out[0]!;
|
|
if (ev.type !== 'tool_update') throw new Error('expected tool_update');
|
|
expect(ev.toolCall.status).toBe('failed');
|
|
expect(ev.toolCall.rawOutput).toBe('boom');
|
|
});
|
|
|
|
it('flattens array text blocks (skipping non-text) and reuses a prior snapshot title', () => {
|
|
const state = createClaudeSdkMapState();
|
|
mapSdkMessage(
|
|
streamEvent({ type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 't2', name: 'view_file', input: {} } }),
|
|
state,
|
|
);
|
|
const out = mapSdkMessage(
|
|
userMsg([
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 't2',
|
|
content: [
|
|
{ type: 'text', text: 'line1' },
|
|
{ type: 'image', source: {} },
|
|
{ type: 'text', text: 'line2' },
|
|
],
|
|
},
|
|
]),
|
|
state,
|
|
);
|
|
const ev = out[0]!;
|
|
if (ev.type !== 'tool_update') throw new Error('expected tool_update');
|
|
expect(ev.toolCall.toolCallId).toBe('t2');
|
|
expect(ev.toolCall.title).toBe('view_file');
|
|
expect(ev.toolCall.status).toBe('completed');
|
|
expect(ev.toolCall.rawOutput).toBe('line1\nline2');
|
|
});
|
|
|
|
it('surfaces a result for an unknown tool_use_id with the id as the title', () => {
|
|
const state = createClaudeSdkMapState();
|
|
const out = mapSdkMessage(userMsg([{ type: 'tool_result', tool_use_id: 'orphan-id', content: 'x' }]), state);
|
|
expect(out[0]).toMatchObject({
|
|
type: 'tool_update',
|
|
toolCall: { toolCallId: 'orphan-id', title: 'orphan-id', kind: null, status: 'completed' },
|
|
});
|
|
});
|
|
|
|
it('ignores non-tool_result blocks and non-array content', () => {
|
|
const state = createClaudeSdkMapState();
|
|
expect(mapSdkMessage(userMsg([{ type: 'text', text: 'hi' }]), state)).toEqual([]);
|
|
expect(mapSdkMessage(userMsg('plain string'), state)).toEqual([]);
|
|
});
|
|
});
|