feat: MCP {env:VAR} key substitution + coder model/tool-result fixes + docs refactor (v2.7.9)

- 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>
This commit is contained in:
2026-06-02 17:01:03 +00:00
parent 7ca4a6b344
commit afaca9e426
23 changed files with 1284 additions and 256 deletions

View File

@@ -179,3 +179,73 @@ describe('mapSdkMessage — non-content messages', () => {
).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([]);
});
});

View File

@@ -49,6 +49,7 @@ import type { AcpToolSnapshot } from '../acp-tool-snapshot.js';
type StreamEvent = Extract<SDKMessage, { type: 'stream_event' }>['event'];
type AssistantContent = Extract<SDKMessage, { type: 'assistant' }>['message']['content'];
type ContentBlock = AssistantContent extends readonly (infer B)[] ? B : never;
type UserContent = Extract<SDKMessage, { type: 'user' }>['message']['content'];
/**
* Caller-owned accumulator threaded across `mapSdkMessage` calls within ONE turn.
@@ -81,6 +82,12 @@ export function mapSdkMessage(msg: SDKMessage, state: ClaudeSdkMapState): AgentE
return mapStreamEvent(msg.event, state);
case 'assistant':
return mapFinalAssistant(msg.message.content, state);
case 'user':
// Tool RESULTS ride in as user messages (tool_result blocks): the SDK ran
// the tool and feeds its output back. Without mapping these, the tool_call
// never reaches a terminal snapshot — it persists as status:'running' with
// no output and the UI spinner never stops (the bug this fixes).
return mapUserToolResults(msg.message.content, state);
default:
// system/init, status, result, hooks, task_*, etc. — no turn content here.
// (The backend reads session_id off the init message and usage/cost off the
@@ -180,6 +187,52 @@ function mapFinalAssistant(content: ContentBlock[], state: ClaudeSdkMapState): A
return out;
}
/**
* User-message tool_result blocks → terminal tool_update events. The SDK runs
* each tool and feeds the output back in a `user` message; we mark the matching
* snapshot completed (or failed, on is_error) WITH its output so the snapshot
* persists/renders as resolved instead of spinning. Unknown ids (no prior
* snapshot) are still surfaced so a stray result isn't silently lost.
*/
function mapUserToolResults(content: UserContent, state: ClaudeSdkMapState): AgentEvent[] {
if (!Array.isArray(content)) return [];
const out: AgentEvent[] = [];
for (const raw of content) {
const block = raw as { type?: string; tool_use_id?: string; content?: unknown; is_error?: boolean };
if (block.type !== 'tool_result' || !block.tool_use_id) continue;
const prev = state.snapshots.get(block.tool_use_id);
const snap: AcpToolSnapshot = {
toolCallId: block.tool_use_id,
title: prev?.title ?? block.tool_use_id,
kind: prev?.kind ?? null,
status: block.is_error ? 'failed' : 'completed',
rawInput: prev?.rawInput,
rawOutput: toolResultText(block.content),
};
state.snapshots.set(block.tool_use_id, snap);
out.push({ type: 'tool_update', toolCall: snap });
}
return out;
}
/** tool_result content is a string OR an array of content blocks (text/image).
* Flatten text blocks; fall back to the raw value so nothing is lost. */
function toolResultText(content: unknown): unknown {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
const text = content
.map((c) =>
c && typeof c === 'object' && (c as { type?: string }).type === 'text'
? String((c as { text?: unknown }).text ?? '')
: '',
)
.filter(Boolean)
.join('\n');
return text || content;
}
return content ?? '';
}
/** Parse a buffered JSON string; fall back to a prior value on empty/invalid. */
function parseJsonOr(buf: string, fallback: unknown): unknown {
const s = buf.trim();