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