Merge remote-tracking branch 'origin/main'

This commit is contained in:
2026-06-02 21:30:28 +00:00
143 changed files with 6730 additions and 6069 deletions

View File

@@ -9,35 +9,21 @@ import {
ClientSideConnection,
type Client,
type SessionNotification,
type RequestPermissionRequest,
type RequestPermissionResponse,
type ReadTextFileRequest,
type ReadTextFileResponse,
type WriteTextFileRequest,
type WriteTextFileResponse,
type CreateTerminalRequest,
type CreateTerminalResponse,
type CreateElicitationRequest,
type CreateElicitationResponse,
type SessionConfigOption,
type ClientSideConnection as ConnectionType,
} from '@agentclientprotocol/sdk';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames';
import { spawn } from 'node:child_process';
import { findThoughtLevelConfigId } from './acp-derive.js';
import { resolveLaunchSpec } from './acp-spawn.js';
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
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 { cancelPendingPermission } from './permission-waiter.js';
import { mapSessionUpdate } from './acp-event-map.js';
import {
type AcpToolSnapshot,
snapshotToWireToolCall,
synthesizeCanceledSnapshots,
} from './acp-tool-snapshot.js';
import { type AcpToolSnapshot, synthesizeCanceledSnapshots } from './acp-tool-snapshot.js';
import { makeFrameEmitter, type FrameEmitter } from './frame-emitter.js';
import { buildAcpClient } from './acp-client.js';
export interface AcpDispatchResult {
exitCode: number;
@@ -111,144 +97,61 @@ async function applySessionOverrides(
}
class AcpStreamContext {
readonly textChunks: string[] = [];
readonly reasoningChunks: string[] = [];
readonly toolSnapshots = new Map<string, AcpToolSnapshot>();
private aborted = false;
/** AgentEvent → WS-frame mapping + text/reasoning/tool accumulation (shared
* `makeFrameEmitter`). The one-shot path passes no `dcp` stripper, so text is
* emitted verbatim — byte-identical to the prior inline switch. */
private readonly emitter: FrameEmitter;
constructor(
private readonly opts: Pick<
AcpDispatchOpts,
'broker' | 'sessionId' | 'chatId' | 'messageId' | 'taskId'
>,
opts: Pick<AcpDispatchOpts, 'broker' | 'sessionId' | 'chatId' | 'messageId' | 'taskId'>,
private readonly worktreePath: string,
) {}
) {
this.emitter = makeFrameEmitter({
broker: opts.broker,
sessionId: opts.sessionId,
chatId: opts.chatId,
assistantId: opts.messageId,
taskId: opts.taskId,
});
}
get reasoningText(): string {
return this.reasoningChunks.join('');
return this.emitter.reasoningText;
}
get output(): string {
return this.textChunks.join('');
return this.emitter.output;
}
get snapshots(): AcpToolSnapshot[] {
return [...this.toolSnapshots.values()];
return this.emitter.snapshots;
}
markAborted(): void {
this.aborted = true;
for (const snap of synthesizeCanceledSnapshots(this.toolSnapshots.values())) {
this.toolSnapshots.set(snap.toolCallId, snap);
this.publishToolSnapshot(snap);
// Synthesize 'canceled' updates for still-running tool calls so the UI doesn't
// leave them spinning, then emit them through the same frame path (tool_update
// → the same `tool_call` wire frame the original published).
for (const snap of synthesizeCanceledSnapshots(this.emitter.toolSnapshots.values())) {
this.emitter.onEvent({ type: 'tool_update', toolCall: snap });
}
}
private canStream(): boolean {
return !!(this.opts.broker && this.opts.sessionId && this.opts.chatId && this.opts.messageId);
}
private publishToolSnapshot(snapshot: AcpToolSnapshot): void {
if (!this.canStream()) return;
const wire = snapshotToWireToolCall(snapshot);
this.opts.broker!.publishFrame(this.opts.sessionId!, {
type: 'tool_call',
message_id: this.opts.messageId!,
chat_id: this.opts.chatId!,
tool_call: wire,
} as WsFrame);
}
async handleSessionUpdate(params: SessionNotification): Promise<void> {
// 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: event.text,
} as WsFrame);
}
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: event.text,
} 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;
}
handleSessionUpdate(params: SessionNotification): void {
// The merge accumulator (`this.emitter.toolSnapshots`) is the same Map the
// emitter publishes from, so a later tool_call_update merges over its tool_call.
for (const event of mapSessionUpdate(params, this.emitter.toolSnapshots)) {
this.emitter.onEvent(event);
}
}
buildClient(agent: string, modeId: string | undefined, taskId: string | undefined, sessionId: string | undefined): Client {
return {
sessionUpdate: (params) => this.handleSessionUpdate(params),
requestPermission: async (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
if (taskId && sessionId) {
return waitForPermissionResponse(taskId, sessionId, agent, modeId, params);
}
const firstOption = params.options[0];
if (firstOption) {
return { outcome: { outcome: 'selected', optionId: firstOption.optionId } };
}
return { outcome: { outcome: 'cancelled' } };
},
readTextFile: async (params: ReadTextFileRequest): Promise<ReadTextFileResponse> => {
const content = await readWorktreeTextFile(
this.worktreePath,
params.path,
params.line,
params.limit,
);
return { content };
},
writeTextFile: async (params: WriteTextFileRequest): Promise<WriteTextFileResponse> => {
await writeWorktreeTextFile(this.worktreePath, params.path, params.content);
return {};
},
createTerminal: async (_params: CreateTerminalRequest): Promise<CreateTerminalResponse> => {
return { terminalId: 'noop' };
},
unstable_createElicitation: async (params: CreateElicitationRequest): Promise<CreateElicitationResponse> => {
if (taskId && sessionId) {
return waitForElicitationResponse(taskId, sessionId, agent, modeId, params);
}
return { action: 'decline' };
},
};
return buildAcpClient(this.worktreePath, () => ({
taskId,
sessionId,
modeId,
agent,
onSessionUpdate: (params) => this.handleSessionUpdate(params),
}));
}
}