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:
@@ -32,9 +32,9 @@ import { createAcpNdJsonStream } from './acp-stream.js';
|
||||
import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js';
|
||||
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
||||
import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js';
|
||||
import { mapSessionUpdate } from './acp-event-map.js';
|
||||
import {
|
||||
type AcpToolSnapshot,
|
||||
mergeToolSnapshot,
|
||||
snapshotToWireToolCall,
|
||||
synthesizeCanceledSnapshots,
|
||||
} from './acp-tool-snapshot.js';
|
||||
@@ -159,75 +159,57 @@ class AcpStreamContext {
|
||||
} as WsFrame);
|
||||
}
|
||||
|
||||
handleToolUpdate(toolCallId: string, update: Parameters<typeof mergeToolSnapshot>[1]): void {
|
||||
const previous = this.toolSnapshots.get(toolCallId);
|
||||
const snapshot = mergeToolSnapshot(toolCallId, update, previous);
|
||||
this.toolSnapshots.set(toolCallId, snapshot);
|
||||
this.publishToolSnapshot(snapshot);
|
||||
}
|
||||
|
||||
async handleSessionUpdate(params: SessionNotification): Promise<void> {
|
||||
const update = params.update;
|
||||
switch (update.sessionUpdate) {
|
||||
case 'agent_message_chunk': {
|
||||
const content = update.content;
|
||||
if (content.type === 'text' && 'text' in content) {
|
||||
const text = (content as { text: string }).text;
|
||||
this.textChunks.push(text);
|
||||
// v2.6 Phase 2: the case-by-case mapping now lives in the shared, pure
|
||||
// `mapSessionUpdate` (reused by the warm ACP backend). This method keeps the
|
||||
// identical broker-publishing side effects — it just translates the normalized
|
||||
// AgentEvents back into the same frames it always emitted. `this.toolSnapshots`
|
||||
// is the merge accumulator, so a later tool_call_update merges over its
|
||||
// tool_call (the prior `handleToolUpdate` behavior, byte-for-byte).
|
||||
for (const event of mapSessionUpdate(params, this.toolSnapshots)) {
|
||||
switch (event.type) {
|
||||
case 'text':
|
||||
this.textChunks.push(event.text);
|
||||
if (this.canStream()) {
|
||||
this.opts.broker!.publishFrame(this.opts.sessionId!, {
|
||||
type: 'delta',
|
||||
message_id: this.opts.messageId!,
|
||||
chat_id: this.opts.chatId!,
|
||||
content: text,
|
||||
content: event.text,
|
||||
} as WsFrame);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'agent_thought_chunk': {
|
||||
const content = update.content;
|
||||
if (content.type === 'text' && 'text' in content) {
|
||||
const text = (content as { text: string }).text;
|
||||
this.reasoningChunks.push(text);
|
||||
break;
|
||||
case 'reasoning':
|
||||
this.reasoningChunks.push(event.text);
|
||||
if (this.canStream()) {
|
||||
this.opts.broker!.publishFrame(this.opts.sessionId!, {
|
||||
type: 'reasoning_delta',
|
||||
message_id: this.opts.messageId!,
|
||||
chat_id: this.opts.chatId!,
|
||||
content: text,
|
||||
content: event.text,
|
||||
} as WsFrame);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool_call':
|
||||
this.handleToolUpdate(update.toolCallId, update);
|
||||
break;
|
||||
case 'tool_call_update':
|
||||
this.handleToolUpdate(update.toolCallId, update);
|
||||
break;
|
||||
case 'available_commands_update': {
|
||||
const commands = update.availableCommands.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description ?? undefined,
|
||||
}));
|
||||
if (this.opts.taskId && commands.length > 0) {
|
||||
mergeTaskCommands(this.opts.taskId, commands);
|
||||
if (this.canStream() && this.opts.sessionId) {
|
||||
const all = getTaskCommands(this.opts.taskId) ?? commands;
|
||||
this.opts.broker!.publishFrame(this.opts.sessionId, {
|
||||
type: 'agent_commands',
|
||||
task_id: this.opts.taskId,
|
||||
session_id: this.opts.sessionId,
|
||||
commands: all,
|
||||
} as WsFrame);
|
||||
break;
|
||||
case 'tool_call':
|
||||
case 'tool_update':
|
||||
// mapSessionUpdate already stored the merged snapshot in this.toolSnapshots.
|
||||
this.publishToolSnapshot(event.toolCall);
|
||||
break;
|
||||
case 'commands':
|
||||
if (this.opts.taskId && event.commands.length > 0) {
|
||||
mergeTaskCommands(this.opts.taskId, event.commands);
|
||||
if (this.canStream() && this.opts.sessionId) {
|
||||
const all = getTaskCommands(this.opts.taskId) ?? event.commands;
|
||||
this.opts.broker!.publishFrame(this.opts.sessionId, {
|
||||
type: 'agent_commands',
|
||||
task_id: this.opts.taskId,
|
||||
session_id: this.opts.sessionId,
|
||||
commands: all,
|
||||
} as WsFrame);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user