@zvada/agent-server 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/package.json +87 -0
  4. package/src/client/client.ts +589 -0
  5. package/src/client/index.ts +18 -0
  6. package/src/client/transports.ts +84 -0
  7. package/src/core/agents/acp/acp-agent.ts +322 -0
  8. package/src/core/agents/acp/adapter.ts +260 -0
  9. package/src/core/agents/acp/client.ts +212 -0
  10. package/src/core/agents/acp/known-agents.ts +29 -0
  11. package/src/core/agents/acp/mappings.ts +136 -0
  12. package/src/core/agents/base.ts +145 -0
  13. package/src/core/agents/claude-code/adapter.ts +451 -0
  14. package/src/core/agents/claude-code/claude-agent.ts +235 -0
  15. package/src/core/agents/claude-code/generator-session.ts +344 -0
  16. package/src/core/agents/claude-code/options.ts +161 -0
  17. package/src/core/agents/claude-code/session-manager.ts +159 -0
  18. package/src/core/agents/codex-app-server/adapter.ts +214 -0
  19. package/src/core/agents/codex-app-server/client.ts +221 -0
  20. package/src/core/agents/codex-app-server/codex-app-server-agent.ts +385 -0
  21. package/src/core/agents/codex-items.ts +122 -0
  22. package/src/core/agents/codex-sdk/adapter.ts +204 -0
  23. package/src/core/agents/codex-sdk/codex-sdk-agent.ts +236 -0
  24. package/src/core/agents/config-fingerprint.ts +19 -0
  25. package/src/core/agents/error-classifier.ts +68 -0
  26. package/src/core/agents/registry.ts +40 -0
  27. package/src/core/agents/session-store.ts +72 -0
  28. package/src/core/agents/tool-meta.ts +68 -0
  29. package/src/core/agents/types.ts +54 -0
  30. package/src/core/index.ts +114 -0
  31. package/src/core/presets.ts +78 -0
  32. package/src/core/provision/extract.ts +31 -0
  33. package/src/core/provision/index.ts +10 -0
  34. package/src/core/provision/npm.ts +114 -0
  35. package/src/core/provision/pins.ts +51 -0
  36. package/src/core/provision/platform.ts +73 -0
  37. package/src/core/provision/provisioner.ts +478 -0
  38. package/src/core/proxy/anthropic-proxy.ts +69 -0
  39. package/src/core/proxy/api-key-store.ts +34 -0
  40. package/src/core/proxy/index.ts +7 -0
  41. package/src/core/runtime/agent-runtime.ts +363 -0
  42. package/src/core/runtime/event-processor.ts +218 -0
  43. package/src/core/runtime/event-sink.ts +37 -0
  44. package/src/core/utils/errors.ts +41 -0
  45. package/src/index.ts +4 -0
  46. package/src/protocol/async-queue.ts +68 -0
  47. package/src/protocol/config.ts +100 -0
  48. package/src/protocol/factories.ts +125 -0
  49. package/src/protocol/harness.ts +50 -0
  50. package/src/protocol/ids.ts +53 -0
  51. package/src/protocol/index.ts +16 -0
  52. package/src/protocol/lifecycle.ts +309 -0
  53. package/src/protocol/models.ts +45 -0
  54. package/src/protocol/part-input.ts +58 -0
  55. package/src/protocol/parts.ts +60 -0
  56. package/src/protocol/thinking.ts +32 -0
  57. package/src/protocol/tokens.ts +39 -0
  58. package/src/protocol/tool-state.ts +89 -0
  59. package/src/protocol/wire.ts +313 -0
  60. package/src/server/acp/binding.ts +163 -0
  61. package/src/server/acp/translate.ts +160 -0
  62. package/src/server/agent-server.ts +357 -0
  63. package/src/server/bin.ts +174 -0
  64. package/src/server/index.ts +24 -0
  65. package/src/server/install.ts +51 -0
  66. package/src/server/session-log.ts +66 -0
  67. package/src/server/transports.ts +149 -0
@@ -0,0 +1,322 @@
1
+ import type {
2
+ InitializeResponse,
3
+ PromptRequest,
4
+ RequestPermissionRequest,
5
+ RequestPermissionResponse,
6
+ } from "@agentclientprotocol/sdk";
7
+ import type { AgentCapabilities, AgentInput, PermissionMode } from "../../../protocol/index.ts";
8
+ import { AsyncQueue } from "../../../protocol/index.ts";
9
+ import type { AgentExecuteOptions, PermissionRequestHandler, RawAgentEvent } from "../base.ts";
10
+ import { BaseAgent } from "../base.ts";
11
+ import { configFingerprint } from "../config-fingerprint.ts";
12
+ import { SessionStore } from "../session-store.ts";
13
+ import { AcpClient, type AcpClientOptions, type AcpLaunch } from "./client.ts";
14
+ import {
15
+ CANCELLED_OUTCOME,
16
+ autoDecide,
17
+ chooseOutcome,
18
+ permissionToolCall,
19
+ toAcpMcpServers,
20
+ toPromptBlocks,
21
+ } from "./mappings.ts";
22
+
23
+ const CAPABILITIES: AgentCapabilities = {
24
+ multiTurn: true,
25
+ // The engine always *accepts* resumeSessionId; whether context actually
26
+ // survives depends on the target agent's `sessionCapabilities.resume`
27
+ // (falls back to a fresh session when unsupported).
28
+ sessionResume: true,
29
+ // v1 exposes model selection only through agent-defined config options;
30
+ // there is no portable model parameter to pass through yet.
31
+ modelSwitch: "unsupported",
32
+ thinkingLevels: false,
33
+ images: true,
34
+ mcpServers: true,
35
+ permissionRequests: true,
36
+ };
37
+
38
+ /** ACP protocol major version this harness speaks. */
39
+ const PROTOCOL_VERSION = 1;
40
+
41
+ /** Idle time before an unused agent subprocess is shut down. */
42
+ const IDLE_TIMEOUT_MS = 5 * 60_000;
43
+
44
+ /** How long a cancelled prompt may take to resolve before the turn is failed. */
45
+ const CANCEL_GRACE_MS = 10_000;
46
+
47
+ /** Deadline for `initialize` on a freshly spawned agent (mirrors codex startup). */
48
+ const INITIALIZE_TIMEOUT_MS = 15_000;
49
+
50
+ /** Deadline for session lifecycle requests (`session/new`, `session/resume`). */
51
+ const SESSION_REQUEST_TIMEOUT_MS = 30_000;
52
+
53
+ export interface AcpAgentOptions {
54
+ /**
55
+ * Resolve the ACP agent subprocess to launch. Operator-level only — never
56
+ * sourced from the wire (the command is arbitrary code execution).
57
+ */
58
+ resolveLaunch: () => Promise<AcpLaunch | undefined> | AcpLaunch | undefined;
59
+ /** `clientInfo.name` advertised to agents. */
60
+ clientName?: string;
61
+ idleTimeoutMs?: number;
62
+ /** Grace period for a `session/cancel` to resolve the in-flight prompt. */
63
+ cancelGraceMs?: number;
64
+ /** Injectable client factory for deterministic tests. */
65
+ createClient?: (options: AcpClientOptions) => Promise<AcpClient>;
66
+ }
67
+
68
+ /** Permission context of the turn currently running on a session — set and
69
+ * cleared as ONE value so stale partial state (an authorization bypass) is
70
+ * unrepresentable. `undefined` between turns → the auto policy rejects. */
71
+ interface TurnPermissions {
72
+ broker?: PermissionRequestHandler;
73
+ mode?: PermissionMode;
74
+ signal?: AbortSignal;
75
+ }
76
+
77
+ interface AcpSession {
78
+ client: AcpClient;
79
+ acpSessionId: string;
80
+ cwd: string;
81
+ envFingerprint: string;
82
+ mcpFingerprint: string;
83
+ launchFingerprint: string;
84
+ /** From `initialize`: whether the agent accepts image content blocks. */
85
+ imagesSupported: boolean;
86
+ turn?: TurnPermissions;
87
+ idleTimer?: ReturnType<typeof setTimeout>;
88
+ }
89
+
90
+ export function acpSessionCompatible(
91
+ existing: AcpSession | undefined,
92
+ options: AgentExecuteOptions,
93
+ launchFingerprint: string,
94
+ ): boolean {
95
+ return Boolean(
96
+ existing &&
97
+ !existing.client.closed &&
98
+ existing.cwd === options.cwd &&
99
+ existing.envFingerprint === configFingerprint(options.env) &&
100
+ existing.mcpFingerprint === configFingerprint(options.mcpServers) &&
101
+ existing.launchFingerprint === launchFingerprint &&
102
+ (!options.resumeSessionId || options.resumeSessionId === existing.acpSessionId),
103
+ );
104
+ }
105
+
106
+ /** Answer an agent permission request against the session's current turn. */
107
+ export async function answerPermission(
108
+ session: AcpSession,
109
+ request: RequestPermissionRequest,
110
+ ): Promise<RequestPermissionResponse> {
111
+ if (request.sessionId !== session.acpSessionId) return CANCELLED_OUTCOME;
112
+ const turn = session.turn;
113
+ const asks = (turn?.mode ?? "default") === "default";
114
+ if (!turn?.broker || !asks) return autoDecide(request, turn?.mode);
115
+ const decision = await turn.broker(permissionToolCall(request), { signal: turn.signal });
116
+ if (decision.decision === "cancel") return CANCELLED_OUTCOME;
117
+ return chooseOutcome(request.options, decision.decision === "allow");
118
+ }
119
+
120
+ /**
121
+ * ACP harness: drives any Agent Client Protocol v1 agent as a long-lived
122
+ * subprocess (one per logical session, reused across turns). The launch
123
+ * command comes from the operator's `resolveLaunch`; `initialize` negotiates
124
+ * the protocol version and gates optional features (resume, images) on the
125
+ * agent's advertised capabilities. The turn's stopReason arrives as the
126
+ * `session/prompt` *response*, so `execute` re-injects it into the raw stream
127
+ * as a synthetic `session/prompt_result` event for the adapter.
128
+ */
129
+ export class AcpAgent extends BaseAgent {
130
+ readonly harness = "acp" as const;
131
+ readonly capabilities = CAPABILITIES;
132
+ private readonly sessions = new SessionStore<AcpSession>();
133
+
134
+ constructor(private readonly agentOptions: AcpAgentOptions) {
135
+ super();
136
+ }
137
+
138
+ private async resolveSession(options: AgentExecuteOptions): Promise<AcpSession> {
139
+ const launch = await this.agentOptions.resolveLaunch();
140
+ if (!launch) {
141
+ throw new Error(
142
+ "acp harness is not configured: resolveLaunch returned no agent launch command",
143
+ );
144
+ }
145
+ const launchFingerprint = configFingerprint({
146
+ command: launch.command,
147
+ args: launch.args?.join(" ") ?? "",
148
+ ...launch.env,
149
+ });
150
+
151
+ const existing = this.sessions.get(options.sessionId);
152
+ let resumeTarget = options.resumeSessionId;
153
+ if (existing && !existing.client.closed) {
154
+ if (acpSessionCompatible(existing, options, launchFingerprint)) {
155
+ this.sessions.clearIdle(existing);
156
+ return existing;
157
+ }
158
+ // Preserve context within the same cwd/env boundary when the agent can
159
+ // resume (mirrors the claude-code/codex restart policy).
160
+ if (
161
+ resumeTarget === undefined &&
162
+ existing.cwd === options.cwd &&
163
+ existing.envFingerprint === configFingerprint(options.env)
164
+ ) {
165
+ resumeTarget = existing.acpSessionId;
166
+ }
167
+ this.sessions.close(options.sessionId);
168
+ }
169
+
170
+ const clientOptions: AcpClientOptions = {
171
+ launch,
172
+ cwd: options.cwd,
173
+ env: options.env,
174
+ clientName: this.agentOptions.clientName,
175
+ };
176
+ const client = this.agentOptions.createClient
177
+ ? await this.agentOptions.createClient(clientOptions)
178
+ : await AcpClient.create(clientOptions);
179
+ client.onClose(() => this.sessions.dropIfCurrent(options.sessionId, client));
180
+
181
+ let init: InitializeResponse;
182
+ let acpSessionId: string | undefined;
183
+ try {
184
+ init = await client.request(
185
+ "initialize",
186
+ { protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} },
187
+ INITIALIZE_TIMEOUT_MS,
188
+ );
189
+ const mcpServers = toAcpMcpServers(options.mcpServers);
190
+ if (resumeTarget && init.agentCapabilities?.sessionCapabilities?.resume) {
191
+ try {
192
+ await client.request(
193
+ "session/resume",
194
+ { sessionId: resumeTarget, cwd: options.cwd, mcpServers },
195
+ SESSION_REQUEST_TIMEOUT_MS,
196
+ );
197
+ acpSessionId = resumeTarget;
198
+ } catch {
199
+ // Unknown/expired session id — fall through to a fresh one.
200
+ }
201
+ }
202
+ if (!acpSessionId) {
203
+ const created = await client.request(
204
+ "session/new",
205
+ { cwd: options.cwd, mcpServers },
206
+ SESSION_REQUEST_TIMEOUT_MS,
207
+ );
208
+ acpSessionId = created.sessionId;
209
+ }
210
+ if (!acpSessionId) throw new Error("acp agent returned no session id");
211
+ } catch (error) {
212
+ client.close();
213
+ throw error;
214
+ }
215
+
216
+ const session: AcpSession = {
217
+ client,
218
+ acpSessionId,
219
+ cwd: options.cwd,
220
+ envFingerprint: configFingerprint(options.env),
221
+ mcpFingerprint: configFingerprint(options.mcpServers),
222
+ launchFingerprint,
223
+ imagesSupported: Boolean(init.agentCapabilities?.promptCapabilities?.image),
224
+ };
225
+ client.setPermissionHandler((request) => answerPermission(session, request));
226
+ this.sessions.set(options.sessionId, session);
227
+ return session;
228
+ }
229
+
230
+ async *execute(
231
+ input: AgentInput,
232
+ options: AgentExecuteOptions,
233
+ ): AsyncIterableIterator<RawAgentEvent> {
234
+ const controller = this.trackTurn(options.sessionId, options.signal);
235
+ const queue = new AsyncQueue<RawAgentEvent>();
236
+ const unsubscribe: Array<() => void> = [];
237
+ let cancelTimer: ReturnType<typeof setTimeout> | undefined;
238
+ let session: AcpSession | undefined;
239
+ try {
240
+ session = await this.resolveSession(options);
241
+ session.turn = {
242
+ broker: options.onPermissionRequest,
243
+ mode: options.permissionMode,
244
+ signal: controller.signal,
245
+ };
246
+ options.onNativeSession?.(session.acpSessionId, {
247
+ resumed: session.acpSessionId === options.resumeSessionId,
248
+ });
249
+ const { client, acpSessionId } = session;
250
+
251
+ unsubscribe.push(
252
+ client.onSessionUpdate((notification) => {
253
+ if (notification.sessionId !== acpSessionId) return;
254
+ queue.push({ method: "session/update", params: notification });
255
+ }),
256
+ );
257
+ unsubscribe.push(
258
+ client.onClose((err) => queue.fail(err ?? new Error("acp agent exited mid-turn"))),
259
+ );
260
+
261
+ const onAbort = () => {
262
+ // ACP semantics: session/cancel makes the in-flight prompt resolve
263
+ // with stopReason=cancelled. Bound the wait for misbehaving agents.
264
+ void client.notify("session/cancel", { sessionId: acpSessionId });
265
+ cancelTimer = setTimeout(
266
+ () => queue.fail(new Error("acp agent ignored session/cancel")),
267
+ this.agentOptions.cancelGraceMs ?? CANCEL_GRACE_MS,
268
+ );
269
+ cancelTimer.unref?.();
270
+ };
271
+
272
+ if (controller.signal.aborted) {
273
+ // Aborted while the session was being set up (a listener added now
274
+ // would never fire) — never start the prompt at all.
275
+ queue.push({ method: "session/prompt_result", params: { stopReason: "cancelled" } });
276
+ queue.end();
277
+ } else {
278
+ controller.signal.addEventListener("abort", onAbort, { once: true });
279
+ const prompt: PromptRequest = {
280
+ sessionId: acpSessionId,
281
+ prompt: toPromptBlocks(input, session.imagesSupported),
282
+ };
283
+ client.request("session/prompt", prompt).then(
284
+ (response) => {
285
+ queue.push({ method: "session/prompt_result", params: response });
286
+ queue.end();
287
+ },
288
+ (error: unknown) => {
289
+ queue.push({ method: "error", params: { message: String(error) } });
290
+ queue.end();
291
+ },
292
+ );
293
+ }
294
+
295
+ for await (const event of queue) {
296
+ yield event;
297
+ }
298
+ } finally {
299
+ if (cancelTimer) clearTimeout(cancelTimer);
300
+ for (const off of unsubscribe) off();
301
+ if (session) session.turn = undefined;
302
+ this.endTurn(options.sessionId, controller);
303
+ if (session && !session.client.closed) {
304
+ this.sessions.armIdle(
305
+ options.sessionId,
306
+ session,
307
+ this.agentOptions.idleTimeoutMs ?? IDLE_TIMEOUT_MS,
308
+ );
309
+ }
310
+ }
311
+ }
312
+
313
+ override async release(sessionId: string): Promise<void> {
314
+ await super.release(sessionId);
315
+ this.sessions.close(sessionId);
316
+ }
317
+
318
+ override async terminateAll(): Promise<void> {
319
+ await super.terminateAll();
320
+ this.sessions.closeAll();
321
+ }
322
+ }
@@ -0,0 +1,260 @@
1
+ import type {
2
+ ContentBlock,
3
+ PromptResponse,
4
+ SessionNotification,
5
+ SessionUpdate,
6
+ ToolCallContent,
7
+ ToolCallUpdate,
8
+ } from "@agentclientprotocol/sdk";
9
+ import {
10
+ DEFAULT_TOKEN_USAGE,
11
+ type ReasoningPart,
12
+ STOP_REASONS,
13
+ type StopReason,
14
+ type StreamContext,
15
+ type TextPart,
16
+ type ToolPart,
17
+ completeToolPart,
18
+ createReasoningPart,
19
+ createTextPart,
20
+ createToolPart,
21
+ setToolMeta,
22
+ startToolPart,
23
+ } from "../../../protocol/index.ts";
24
+ import type { AdapterEvent, EventTransformer, TransformResult } from "../types.ts";
25
+ import { isRecord, toToolLocations, toolKindOf } from "./mappings.ts";
26
+
27
+ /**
28
+ * Parses ACP v1 raw events into AdapterEvents. The mapping is nearly identity:
29
+ * our part vocabulary (ToolKind, tool status, locations) was aligned to ACP up
30
+ * front (DESIGN D2), so chunks append to text/reasoning parts and
31
+ * `tool_call`/`tool_call_update` upsert tool parts by `toolCallId`. Everything
32
+ * arrives through the SDK connection, which zod-validates inbound payloads —
33
+ * so the SDK's own types ARE the raw shapes (no defensive re-modelling).
34
+ *
35
+ * Raw events are the shapes AcpAgent yields:
36
+ * - `{method: "session/update", params: SessionNotification}`
37
+ * - `{method: "session/prompt_result", params: PromptResponse}` (synthetic —
38
+ * ACP returns the turn's stopReason as the prompt *response*, not as a
39
+ * notification, so the agent re-injects it into the event stream)
40
+ * - `{method: "error", params: {message}}` (synthetic, prompt failure)
41
+ *
42
+ * v1 `messageId` on chunks is optional: when present, a change opens a new
43
+ * wire message; when absent the whole turn is one message (the processor
44
+ * synthesizes the boundary).
45
+ */
46
+ type RawEvent =
47
+ | { method: "session/update"; params: SessionNotification }
48
+ | { method: "session/prompt_result"; params: Partial<PromptResponse> }
49
+ | { method: "error"; params: { message?: string } };
50
+
51
+ function stopReasonOf(value: string | undefined): StopReason | undefined {
52
+ return value && (STOP_REASONS as readonly string[]).includes(value)
53
+ ? (value as StopReason)
54
+ : undefined;
55
+ }
56
+
57
+ /** Flatten a tool call's content blocks (plus rawOutput) into one output string. */
58
+ function toolOutput(content: ToolCallContent[] | null | undefined, rawOutput: unknown): string {
59
+ const texts = (content ?? [])
60
+ .map((item) =>
61
+ item.type === "content" && item.content.type === "text" ? item.content.text : "",
62
+ )
63
+ .filter(Boolean);
64
+ if (texts.length) return texts.join("\n");
65
+ return rawOutput !== undefined && rawOutput !== null ? JSON.stringify(rawOutput) : "";
66
+ }
67
+
68
+ type StreamSlot = "text" | "reasoning";
69
+
70
+ export class AcpTransformer implements EventTransformer<unknown> {
71
+ private readonly ctx: StreamContext;
72
+ private readonly toolParts = new Map<string, ToolPart>();
73
+ private readonly streamParts = new Map<StreamSlot, TextPart | ReasoningPart>();
74
+ /** messageId of the currently open wire message (undefined = implicit single message). */
75
+ private currentMessageId?: string;
76
+ private messageOpen = false;
77
+
78
+ private stopReason?: StopReason;
79
+ private finishReason?: string;
80
+ private error?: string;
81
+
82
+ constructor(ctx: { sessionId: string }) {
83
+ this.ctx = { sessionId: ctx.sessionId, messageId: "" };
84
+ }
85
+
86
+ process(raw: unknown): AdapterEvent[] {
87
+ const event = raw as RawEvent;
88
+ switch (event.method) {
89
+ case "session/update":
90
+ return this.onUpdate(event.params.update);
91
+ case "session/prompt_result":
92
+ this.stopReason = stopReasonOf(event.params.stopReason);
93
+ this.finishReason = event.params.stopReason;
94
+ return this.finalizeStreamingParts();
95
+ case "error":
96
+ this.error = event.params.message ?? "acp agent error";
97
+ this.finishReason = "error";
98
+ // A failed prompt still closes any parts left streaming.
99
+ return this.finalizeStreamingParts();
100
+ default:
101
+ return [];
102
+ }
103
+ }
104
+
105
+ finish(): TransformResult {
106
+ // ACP v1 has no per-turn token usage (the end-turn-token-usage RFD is
107
+ // still draft); `usage_update` is a context-window gauge, not a bill.
108
+ return {
109
+ usage: { ...DEFAULT_TOKEN_USAGE },
110
+ stopReason: this.stopReason,
111
+ finishReason: this.finishReason,
112
+ error: this.error,
113
+ };
114
+ }
115
+
116
+ private onUpdate(update: SessionUpdate): AdapterEvent[] {
117
+ switch (update.sessionUpdate) {
118
+ case "agent_message_chunk":
119
+ return this.chunk("text", update.content, update.messageId);
120
+ case "agent_thought_chunk":
121
+ return this.chunk("reasoning", update.content, update.messageId);
122
+ case "tool_call":
123
+ return this.toolCall(update);
124
+ case "tool_call_update":
125
+ return this.toolCallUpdate(update);
126
+ case "usage_update": {
127
+ // ACP's stable context gauge maps 1:1 onto session.usage. ACP cost is
128
+ // {amount, currency}; ours is a bare USD number — map USD only.
129
+ const usd = update.cost?.currency === "USD" ? update.cost.amount : undefined;
130
+ return [
131
+ {
132
+ kind: "usage",
133
+ used: update.used,
134
+ size: update.size,
135
+ ...(usd !== undefined && { cost: usd }),
136
+ },
137
+ ];
138
+ }
139
+ default:
140
+ // plan / available_commands / current_mode / config_option /
141
+ // session_info / usage updates have no part representation yet;
142
+ // `includeRaw` consumers still see them.
143
+ return [];
144
+ }
145
+ }
146
+
147
+ private chunk(
148
+ slot: StreamSlot,
149
+ content: ContentBlock,
150
+ messageId: string | null | undefined,
151
+ ): AdapterEvent[] {
152
+ if (content.type !== "text" || !content.text) return [];
153
+ const events: AdapterEvent[] = [...this.rolloverMessage(messageId ?? undefined)];
154
+ let part = this.streamParts.get(slot);
155
+ if (!part) {
156
+ part =
157
+ slot === "text"
158
+ ? createTextPart(this.ctx, "", true)
159
+ : createReasoningPart(this.ctx, "", true);
160
+ this.streamParts.set(slot, part);
161
+ events.push({ kind: "part-open", part });
162
+ }
163
+ part.text += content.text;
164
+ events.push(
165
+ slot === "text"
166
+ ? { kind: "text-delta", partId: part.id, text: content.text }
167
+ : { kind: "reasoning-delta", partId: part.id, text: content.text },
168
+ );
169
+ return events;
170
+ }
171
+
172
+ private toolCall(update: ToolCallUpdate): AdapterEvent[] {
173
+ if (this.toolParts.has(update.toolCallId)) return this.toolCallUpdate(update); // duplicate open
174
+ const part = this.openTool(update);
175
+ const events: AdapterEvent[] = [{ kind: "part-open", part }];
176
+ if (this.applyToolUpdate(part, update)) events.push({ kind: "part-update", part });
177
+ return events;
178
+ }
179
+
180
+ private toolCallUpdate(update: ToolCallUpdate): AdapterEvent[] {
181
+ // Update before (or without) its tool_call — open a part so nothing is lost.
182
+ const part = this.toolParts.get(update.toolCallId) ?? this.openTool(update);
183
+ this.applyToolUpdate(part, update);
184
+ return [{ kind: "part-update", part }];
185
+ }
186
+
187
+ private openTool(update: ToolCallUpdate): ToolPart {
188
+ const part = createToolPart(
189
+ this.ctx,
190
+ update.toolCallId,
191
+ update.name ?? update.title ?? "tool",
192
+ isRecord(update.rawInput) ? update.rawInput : {},
193
+ {
194
+ kind: toolKindOf(update.kind),
195
+ title: update.title ?? undefined,
196
+ locations: toToolLocations(update.locations),
197
+ },
198
+ );
199
+ this.toolParts.set(update.toolCallId, part);
200
+ return part;
201
+ }
202
+
203
+ /** Fold an update's meta/input/status into the part; true when it just completed. */
204
+ private applyToolUpdate(part: ToolPart, update: ToolCallUpdate): boolean {
205
+ setToolMeta(part, {
206
+ kind: toolKindOf(update.kind),
207
+ title: update.title ?? undefined,
208
+ locations: toToolLocations(update.locations),
209
+ });
210
+ const terminal = part.state.status === "completed" || part.state.status === "failed";
211
+ if (terminal) return false;
212
+ if (isRecord(update.rawInput)) {
213
+ if (part.state.status === "pending") startToolPart(part, update.rawInput);
214
+ else part.state.input = update.rawInput;
215
+ }
216
+ if (update.status !== "completed" && update.status !== "failed") return false;
217
+ completeToolPart(part, {
218
+ output: toolOutput(update.content, update.rawOutput),
219
+ isError: update.status === "failed",
220
+ });
221
+ return true;
222
+ }
223
+
224
+ /** A new v1 `messageId` closes the current wire message and opens the next. */
225
+ private rolloverMessage(messageId: string | undefined): AdapterEvent[] {
226
+ if (!messageId) {
227
+ // No id: everything belongs to one implicit message; open it lazily so
228
+ // the assistant role is explicit (parts still work without it).
229
+ if (this.messageOpen) return [];
230
+ this.messageOpen = true;
231
+ return [{ kind: "message-start", role: "assistant" }];
232
+ }
233
+ if (messageId === this.currentMessageId) return [];
234
+ const events: AdapterEvent[] = [];
235
+ if (this.messageOpen) {
236
+ events.push(...this.finalizeStreamingParts(), { kind: "message-end" });
237
+ }
238
+ this.currentMessageId = messageId;
239
+ this.messageOpen = true;
240
+ this.streamParts.clear();
241
+ events.push({ kind: "message-start", role: "assistant" });
242
+ return events;
243
+ }
244
+
245
+ /** Mark still-streaming text/reasoning parts done (turn or message boundary). */
246
+ private finalizeStreamingParts(): AdapterEvent[] {
247
+ const events: AdapterEvent[] = [];
248
+ for (const part of this.streamParts.values()) {
249
+ if (part.state === "streaming") {
250
+ part.state = "done";
251
+ events.push({ kind: "part-update", part });
252
+ }
253
+ }
254
+ return events;
255
+ }
256
+ }
257
+
258
+ export function createAcpTransformer(ctx: { sessionId: string }): EventTransformer {
259
+ return new AcpTransformer(ctx);
260
+ }