feat(coder): v2.6 Phase 2 — warm ACP backend for goose/qwen

WarmAcpBackend (AgentBackend) holds one persistent goose acp / qwen --acp child + ClientSideConnection + ACP session per (chat,agent); initialize+session/new once, reused across turns. Abort = session/cancel the prompt only (never kills the child); child exit -> agent_sessions.status='crashed' -> re-spawn next turn. Dispatcher routes goose/qwen chat-tab tasks to the pooled warm backend via pure shouldUseWarmBackend (needs session_id+chat_id); one-shot runExternalAgent kept as fallback for arena/MCP/new_task. handleSessionUpdate extracted to a shared pure acp-event-map.ts (one-shot path byte-identical). SDK: installed @agentclientprotocol/sdk@^0.22.1 has stable resumeSession/loadSession; resume moot in the warm hot path, deferred to Phase 3. 15 new tests (warm-acp-routing, acp-event-map); 180 coder tests pass; tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 23:57:03 +00:00
parent 0658d19b64
commit 0d3d08f5f2
8 changed files with 978 additions and 55 deletions

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import type { SessionNotification } from '@agentclientprotocol/sdk';
import { mapSessionUpdate } from '../acp-event-map.js';
import type { AcpToolSnapshot } from '../acp-tool-snapshot.js';
/**
* Pure event-mapping shared by the one-shot ACP dispatch (AcpStreamContext) and
* the warm ACP backend (Phase 2). Mirrors the original handleSessionUpdate switch
* verbatim but returns normalized AgentEvents instead of publishing broker frames.
*/
describe('mapSessionUpdate (shared ACP event mapping)', () => {
function note(update: SessionNotification['update']): SessionNotification {
return { sessionId: 's1', update };
}
it('maps an agent_message_chunk text → a text event', () => {
const events = mapSessionUpdate(
note({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hello' } }),
);
expect(events).toEqual([{ type: 'text', text: 'hello' }]);
});
it('maps an agent_thought_chunk text → a reasoning event', () => {
const events = mapSessionUpdate(
note({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'thinking' } }),
);
expect(events).toEqual([{ type: 'reasoning', text: 'thinking' }]);
});
it('ignores non-text content on message/thought chunks', () => {
const img = mapSessionUpdate(
note({
sessionUpdate: 'agent_message_chunk',
content: { type: 'image', data: 'x', mimeType: 'image/png' },
} as never),
);
expect(img).toEqual([]);
});
it('maps a tool_call → a tool_call event with a merged snapshot', () => {
const events = mapSessionUpdate(
note({
sessionUpdate: 'tool_call',
toolCallId: 't1',
title: 'read_file',
status: 'pending',
rawInput: { path: 'a.ts' },
} as never),
);
expect(events).toHaveLength(1);
expect(events[0]!.type).toBe('tool_call');
const snap = (events[0] as { type: 'tool_call'; toolCall: AcpToolSnapshot }).toolCall;
expect(snap.toolCallId).toBe('t1');
expect(snap.title).toBe('read_file');
expect(snap.status).toBe('pending');
expect(snap.rawInput).toEqual({ path: 'a.ts' });
});
it('maps a tool_call_update → a tool_update event merged over the prior snapshot', () => {
const prior = new Map<string, AcpToolSnapshot>([
['t1', { toolCallId: 't1', title: 'read_file', status: 'pending', rawInput: { path: 'a.ts' } }],
]);
const events = mapSessionUpdate(
note({
sessionUpdate: 'tool_call_update',
toolCallId: 't1',
status: 'completed',
rawOutput: 'file body',
} as never),
prior,
);
expect(events).toHaveLength(1);
expect(events[0]!.type).toBe('tool_update');
const snap = (events[0] as { type: 'tool_update'; toolCall: AcpToolSnapshot }).toolCall;
expect(snap.toolCallId).toBe('t1');
// merged: title carried from prior, status updated, output added, input retained
expect(snap.title).toBe('read_file');
expect(snap.status).toBe('completed');
expect(snap.rawOutput).toBe('file body');
expect(snap.rawInput).toEqual({ path: 'a.ts' });
});
it('maps available_commands_update → a commands event', () => {
const events = mapSessionUpdate(
note({
sessionUpdate: 'available_commands_update',
availableCommands: [
{ name: 'plan', description: 'make a plan' },
{ name: 'review', description: null },
],
} as never),
);
expect(events).toEqual([
{
type: 'commands',
commands: [
{ name: 'plan', description: 'make a plan' },
{ name: 'review', description: undefined },
],
},
]);
});
it('returns [] for unhandled update kinds (plan, mode change)', () => {
expect(mapSessionUpdate(note({ sessionUpdate: 'plan', entries: [] } as never))).toEqual([]);
expect(
mapSessionUpdate(note({ sessionUpdate: 'current_mode_update', currentModeId: 'code' } as never)),
).toEqual([]);
});
});