@oh-my-pi/pi-coding-agent 15.1.3 → 15.1.5

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.
@@ -66,7 +66,11 @@ import {
66
66
  import { ACP_BUILTIN_SLASH_COMMANDS, executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
67
67
  import { parseThinkingLevel } from "../../thinking";
68
68
  import { createAcpClientBridge } from "./acp-client-bridge";
69
- import { mapAgentSessionEventToAcpSessionUpdates, mapToolKind } from "./acp-event-mapper";
69
+ import {
70
+ buildToolCallStartUpdate,
71
+ mapAgentSessionEventToAcpSessionUpdates,
72
+ normalizeReplayToolArguments,
73
+ } from "./acp-event-mapper";
70
74
  import { ACP_TERMINAL_AUTH_FLAG } from "./terminal-auth";
71
75
 
72
76
  const ACP_DEFAULT_MODE_ID = "default";
@@ -87,6 +91,8 @@ const SESSION_PAGE_SIZE = 50;
87
91
  */
88
92
  export const ACP_BOOTSTRAP_RACE_GUARD_MS = 50;
89
93
  const ACP_CANCEL_CLEANUP_TIMEOUT_MS = 5_000;
94
+ const ACP_ASYNC_DELIVERY_DRAIN_TIMEOUT_MS = 250;
95
+ const ACP_ASYNC_DELIVERY_DRAIN_MAX_PASSES = 3;
90
96
 
91
97
  type AgentImageContent = {
92
98
  type: "image";
@@ -134,6 +140,7 @@ type ManagedSessionRecord = {
134
140
  promptQueue: PromptQueueState;
135
141
  liveMessageId: string | undefined;
136
142
  liveMessageProgress: { textEmitted: boolean; thoughtEmitted: boolean } | undefined;
143
+ toolArgsById: Map<string, unknown>;
137
144
  extensionsConfigured: boolean;
138
145
  // Installed inside `#scheduleBootstrapUpdates` (post-race-guard); released
139
146
  // in `#disposeSessionRecord`. Lives independent of any prompt turn.
@@ -150,6 +157,14 @@ type ReplayableMessage = {
150
157
  isError?: boolean;
151
158
  };
152
159
 
160
+ type ReplayableToolItem = {
161
+ type?: unknown;
162
+ id?: unknown;
163
+ name?: unknown;
164
+ arguments?: unknown;
165
+ input?: unknown;
166
+ };
167
+
153
168
  type MCPConfigMap = {
154
169
  [name: string]: MCPServerConfig;
155
170
  };
@@ -357,7 +372,7 @@ export class AcpAgent implements Agent {
357
372
  #clientCapabilities: ClientCapabilities | undefined;
358
373
  #cancelCleanupTimeoutMs = ACP_CANCEL_CLEANUP_TIMEOUT_MS;
359
374
 
360
- constructor(connection: AgentSideConnection, initialSession: AgentSession, createSession: CreateAcpSession) {
375
+ constructor(connection: AgentSideConnection, createSession: CreateAcpSession, initialSession?: AgentSession) {
361
376
  this.#connection = connection;
362
377
  this.#initialSession = initialSession;
363
378
  this.#createSession = createSession;
@@ -634,7 +649,7 @@ export class AcpAgent implements Agent {
634
649
  const builtinResult = await executeAcpBuiltinSlashCommand(text, {
635
650
  session: record.session,
636
651
  sessionManager: record.session.sessionManager,
637
- settings: Settings.instance,
652
+ settings: record.session.settings,
638
653
  cwd: record.session.sessionManager.getCwd(),
639
654
  output: output => this.#emitCommandOutput(record, output),
640
655
  refreshCommands: () => this.#emitAvailableCommandsUpdate(record),
@@ -806,6 +821,9 @@ export class AcpAgent implements Agent {
806
821
  case "_omp/usage": {
807
822
  const [firstRecord] = this.#sessions.values();
808
823
  const target = firstRecord?.session ?? this.#initialSession;
824
+ if (!target) {
825
+ return { reports: [] };
826
+ }
809
827
  const reports = await target.fetchUsageReports();
810
828
  return { reports: reports ?? [] };
811
829
  }
@@ -958,6 +976,7 @@ export class AcpAgent implements Agent {
958
976
  promptQueue: { promise: Promise.resolve(), release: undefined },
959
977
  liveMessageId: undefined,
960
978
  liveMessageProgress: undefined,
979
+ toolArgsById: new Map(),
961
980
  extensionsConfigured: false,
962
981
  lifetimeUnsubscribe: undefined,
963
982
  };
@@ -1020,19 +1039,27 @@ export class AcpAgent implements Agent {
1020
1039
  return;
1021
1040
  }
1022
1041
 
1042
+ if (event.type === "tool_execution_start" || event.type === "tool_execution_update") {
1043
+ record.toolArgsById.set(event.toolCallId, event.args);
1044
+ }
1045
+
1023
1046
  this.#prepareLiveAssistantMessage(record, event);
1024
1047
  for (const notification of mapAgentSessionEventToAcpSessionUpdates(event, record.session.sessionId, {
1025
1048
  getMessageId: message => this.#getLiveMessageId(record, message),
1026
1049
  getMessageProgress: message => this.#getLiveMessageProgress(record, message),
1050
+ getToolArgs: toolCallId => record.toolArgsById.get(toolCallId),
1027
1051
  cwd: record.session.sessionManager.getCwd(),
1028
1052
  })) {
1029
1053
  await this.#connection.sessionUpdate(notification);
1030
1054
  }
1055
+ if (event.type === "tool_execution_end") {
1056
+ record.toolArgsById.delete(event.toolCallId);
1057
+ }
1031
1058
  this.#clearLiveAssistantMessageAfterEvent(record, event);
1032
1059
 
1033
1060
  if (event.type === "agent_end") {
1034
1061
  await this.#emitEndOfTurnUpdates(record);
1035
- await record.session.waitForIdle();
1062
+ await this.#waitForAcpPromptIdle(record);
1036
1063
  this.#finishPrompt(record, {
1037
1064
  stopReason: this.#resolveStopReason(event, promptTurn.cancelRequested),
1038
1065
  usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
@@ -1041,6 +1068,20 @@ export class AcpAgent implements Agent {
1041
1068
  }
1042
1069
  }
1043
1070
 
1071
+ async #waitForAcpPromptIdle(record: ManagedSessionRecord): Promise<void> {
1072
+ for (let pass = 0; pass < ACP_ASYNC_DELIVERY_DRAIN_MAX_PASSES; pass++) {
1073
+ await record.session.waitForIdle();
1074
+ const delivered = await record.session.drainAsyncJobDeliveriesForAcp({
1075
+ timeoutMs: ACP_ASYNC_DELIVERY_DRAIN_TIMEOUT_MS,
1076
+ });
1077
+ if (!delivered) {
1078
+ return;
1079
+ }
1080
+ }
1081
+
1082
+ await record.session.waitForIdle();
1083
+ }
1084
+
1044
1085
  #prepareLiveAssistantMessage(record: ManagedSessionRecord, event: AgentSessionEvent): void {
1045
1086
  if (
1046
1087
  (event.type === "message_start" || event.type === "message_update" || event.type === "message_end") &&
@@ -1297,7 +1338,7 @@ export class AcpAgent implements Agent {
1297
1338
 
1298
1339
  #getAvailableModes(session: AgentSession): Array<{ id: string; name: string; description: string }> {
1299
1340
  const modes = [{ id: ACP_DEFAULT_MODE_ID, name: "Default", description: "Standard ACP headless mode" }];
1300
- if (Settings.instance.get("plan.enabled")) {
1341
+ if (session.settings.get("plan.enabled")) {
1301
1342
  modes.push({
1302
1343
  id: ACP_PLAN_MODE_ID,
1303
1344
  name: "Plan",
@@ -1581,16 +1622,30 @@ export class AcpAgent implements Agent {
1581
1622
 
1582
1623
  async #replaySessionHistory(record: ManagedSessionRecord): Promise<void> {
1583
1624
  const cwd = record.session.sessionManager.getCwd();
1625
+ const replayedToolCallIds = new Set<string>();
1626
+ const replayedToolCallArgs = new Map<string, unknown>();
1584
1627
  for (const message of record.session.sessionManager.buildSessionContext().messages as ReplayableMessage[]) {
1585
- for (const notification of this.#messageToReplayNotifications(record.session.sessionId, message, cwd)) {
1628
+ for (const notification of this.#messageToReplayNotifications(
1629
+ record.session.sessionId,
1630
+ message,
1631
+ cwd,
1632
+ replayedToolCallIds,
1633
+ replayedToolCallArgs,
1634
+ )) {
1586
1635
  await this.#connection.sessionUpdate(notification);
1587
1636
  }
1588
1637
  }
1589
1638
  }
1590
1639
 
1591
- #messageToReplayNotifications(sessionId: string, message: ReplayableMessage, cwd: string): SessionNotification[] {
1640
+ #messageToReplayNotifications(
1641
+ sessionId: string,
1642
+ message: ReplayableMessage,
1643
+ cwd: string,
1644
+ replayedToolCallIds: Set<string>,
1645
+ replayedToolCallArgs: Map<string, unknown>,
1646
+ ): SessionNotification[] {
1592
1647
  if (message.role === "assistant") {
1593
- return this.#replayAssistantMessage(sessionId, message);
1648
+ return this.#replayAssistantMessage(sessionId, message, cwd, replayedToolCallIds, replayedToolCallArgs);
1594
1649
  }
1595
1650
  if (
1596
1651
  message.role === "user" ||
@@ -1610,11 +1665,19 @@ export class AcpAgent implements Agent {
1610
1665
  typeof message.toolCallId === "string" &&
1611
1666
  typeof message.toolName === "string"
1612
1667
  ) {
1613
- return this.#replayToolResult(sessionId, cwd, {
1614
- ...message,
1615
- toolCallId: message.toolCallId,
1616
- toolName: message.toolName,
1617
- });
1668
+ return this.#replayToolResult(
1669
+ sessionId,
1670
+ cwd,
1671
+ {
1672
+ ...message,
1673
+ toolCallId: message.toolCallId,
1674
+ toolName: message.toolName,
1675
+ },
1676
+ {
1677
+ includeStart: !replayedToolCallIds.has(message.toolCallId),
1678
+ toolArgs: replayedToolCallArgs.get(message.toolCallId),
1679
+ },
1680
+ );
1618
1681
  }
1619
1682
  if (
1620
1683
  message.role === "bashExecution" ||
@@ -1631,7 +1694,13 @@ export class AcpAgent implements Agent {
1631
1694
  return [];
1632
1695
  }
1633
1696
 
1634
- #replayAssistantMessage(sessionId: string, message: ReplayableMessage): SessionNotification[] {
1697
+ #replayAssistantMessage(
1698
+ sessionId: string,
1699
+ message: ReplayableMessage,
1700
+ cwd: string,
1701
+ replayedToolCallIds: Set<string>,
1702
+ replayedToolCallArgs: Map<string, unknown>,
1703
+ ): SessionNotification[] {
1635
1704
  const notifications: SessionNotification[] = [];
1636
1705
  const messageId = crypto.randomUUID();
1637
1706
  if (Array.isArray(message.content)) {
@@ -1666,24 +1735,23 @@ export class AcpAgent implements Agent {
1666
1735
  });
1667
1736
  continue;
1668
1737
  }
1738
+ const toolItem = item as ReplayableToolItem;
1669
1739
  if (
1670
- (item.type === "toolCall" || item.type === "tool_use") &&
1671
- "id" in item &&
1672
- typeof item.id === "string" &&
1673
- "name" in item &&
1674
- typeof item.name === "string"
1740
+ (toolItem.type === "toolCall" || toolItem.type === "tool_use") &&
1741
+ typeof toolItem.id === "string" &&
1742
+ typeof toolItem.name === "string"
1675
1743
  ) {
1676
- const update: SessionUpdate = {
1677
- sessionUpdate: "tool_call",
1678
- toolCallId: item.id,
1679
- title: item.name,
1680
- kind: mapToolKind(item.name),
1744
+ const args = this.#buildReplayAssistantToolArgs(toolItem);
1745
+ const update = buildToolCallStartUpdate({
1746
+ toolCallId: toolItem.id,
1747
+ toolName: toolItem.name,
1748
+ args,
1681
1749
  status: "completed",
1682
- };
1683
- if ("arguments" in item && typeof item.arguments === "string") {
1684
- update.rawInput = item.arguments;
1685
- }
1750
+ cwd,
1751
+ });
1686
1752
  notifications.push({ sessionId, update });
1753
+ replayedToolCallIds.add(toolItem.id);
1754
+ replayedToolCallArgs.set(toolItem.id, args);
1687
1755
  }
1688
1756
  }
1689
1757
  }
@@ -1700,10 +1768,21 @@ export class AcpAgent implements Agent {
1700
1768
  return notifications;
1701
1769
  }
1702
1770
 
1771
+ #buildReplayAssistantToolArgs(item: ReplayableToolItem): unknown {
1772
+ if ("arguments" in item) {
1773
+ return normalizeReplayToolArguments(item.arguments).args;
1774
+ }
1775
+ if (item.type === "tool_use" && "input" in item) {
1776
+ return item.input;
1777
+ }
1778
+ return {};
1779
+ }
1780
+
1703
1781
  #replayToolResult(
1704
1782
  sessionId: string,
1705
1783
  cwd: string,
1706
1784
  message: Required<Pick<ReplayableMessage, "toolCallId" | "toolName">> & ReplayableMessage,
1785
+ options: { includeStart?: boolean; toolArgs?: unknown } = {},
1707
1786
  ): SessionNotification[] {
1708
1787
  const args = this.#buildReplayToolArgs(message.details);
1709
1788
  const startEvent: AgentSessionEvent = {
@@ -1723,10 +1802,14 @@ export class AcpAgent implements Agent {
1723
1802
  errorMessage: message.errorMessage,
1724
1803
  },
1725
1804
  };
1726
- return [
1727
- ...mapAgentSessionEventToAcpSessionUpdates(startEvent, sessionId, { cwd }),
1728
- ...mapAgentSessionEventToAcpSessionUpdates(endEvent, sessionId, { cwd }),
1729
- ];
1805
+ const notifications = mapAgentSessionEventToAcpSessionUpdates(endEvent, sessionId, {
1806
+ cwd,
1807
+ getToolArgs: toolCallId => (toolCallId === message.toolCallId ? options.toolArgs : undefined),
1808
+ });
1809
+ if (options.includeStart === false) {
1810
+ return notifications;
1811
+ }
1812
+ return [...mapAgentSessionEventToAcpSessionUpdates(startEvent, sessionId, { cwd }), ...notifications];
1730
1813
  }
1731
1814
 
1732
1815
  #buildReplayToolArgs(details: unknown): { path?: string } {
@@ -36,7 +36,7 @@ export function createAcpClientBridge(
36
36
  requestPermission: true,
37
37
  };
38
38
 
39
- const bridge: ClientBridge = { capabilities };
39
+ const bridge: ClientBridge = { capabilities, deferAgentInitiatedTurns: true };
40
40
 
41
41
  if (capabilities.readTextFile) {
42
42
  bridge.readTextFile = async params => {
@@ -122,6 +122,7 @@ async function requestPermission(
122
122
  toolCallId: toolCall.toolCallId,
123
123
  title: toolCall.title,
124
124
  ...(toolCall.kind ? { kind: toolCall.kind as ToolCallUpdate["kind"] } : {}),
125
+ ...(toolCall.status ? { status: toolCall.status as ToolCallUpdate["status"] } : {}),
125
126
  ...(toolCall.rawInput !== undefined ? { rawInput: toolCall.rawInput } : {}),
126
127
  ...(toolCall.locations ? { locations: toolCall.locations } : {}),
127
128
  };
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  SessionNotification,
3
3
  SessionUpdate,
4
+ ToolCall,
4
5
  ToolCallContent,
5
6
  ToolCallLocation,
6
7
  ToolKind,
@@ -17,6 +18,7 @@ interface MessageProgress {
17
18
  interface AcpEventMapperOptions {
18
19
  getMessageId?: (message: unknown) => string | undefined;
19
20
  getMessageProgress?: (message: unknown) => MessageProgress | undefined;
21
+ getToolArgs?: (toolCallId: string) => unknown;
20
22
  /**
21
23
  * Session cwd. Tool call locations sent to ACP clients must be absolute
22
24
  * (the editor host needs them to open or focus files). When provided,
@@ -30,6 +32,10 @@ interface ContentArrayContainer {
30
32
  content?: unknown;
31
33
  }
32
34
 
35
+ interface DetailsContainer {
36
+ details?: unknown;
37
+ }
38
+
33
39
  interface TypedValue {
34
40
  type?: unknown;
35
41
  }
@@ -38,6 +44,10 @@ interface TextLikeContent extends TypedValue {
38
44
  text?: unknown;
39
45
  }
40
46
 
47
+ interface TerminalIdContainer {
48
+ terminalId?: unknown;
49
+ }
50
+
41
51
  interface BinaryLikeContent extends TypedValue {
42
52
  data?: unknown;
43
53
  mimeType?: unknown;
@@ -118,6 +128,8 @@ export function mapToolKind(toolName: string): ToolKind {
118
128
  case "move":
119
129
  return "move";
120
130
  case "bash":
131
+ case "shell":
132
+ case "exec":
121
133
  case "eval":
122
134
  return "execute";
123
135
  case "search":
@@ -144,24 +156,20 @@ export function mapAgentSessionEventToAcpSessionUpdates(
144
156
  case "message_end":
145
157
  return mapAssistantMessageEnd(event, sessionId, options);
146
158
  case "tool_execution_start": {
147
- const update: SessionUpdate = {
148
- sessionUpdate: "tool_call",
159
+ const update = buildToolCallStartUpdate({
149
160
  toolCallId: event.toolCallId,
150
- title: buildToolTitle(event.toolName, event.args, event.intent),
151
- kind: mapToolKind(event.toolName),
152
- status: "pending",
153
- rawInput: event.args,
154
- };
155
- const locations = extractToolLocations(event.args, options.cwd);
156
- if (locations.length > 0) {
157
- update.locations = locations;
158
- }
161
+ toolName: event.toolName,
162
+ args: event.args,
163
+ intent: event.intent,
164
+ cwd: options.cwd,
165
+ });
159
166
  return [toSessionNotification(sessionId, update)];
160
167
  }
161
168
  case "tool_execution_update": {
162
- const terminalContent = extractTerminalToolCallContent(event.partialResult);
163
- const otherContent = terminalContent.length > 0 ? [] : extractToolCallContent(event.partialResult);
164
- const content = [...terminalContent, ...otherContent];
169
+ const content = mergeToolUpdateContent(
170
+ buildToolStartContent(event.toolName, event.args),
171
+ extractToolCallContent(event.partialResult),
172
+ );
165
173
  const update: SessionUpdate = {
166
174
  sessionUpdate: "tool_call_update",
167
175
  toolCallId: event.toolCallId,
@@ -178,10 +186,11 @@ export function mapAgentSessionEventToAcpSessionUpdates(
178
186
  return [toSessionNotification(sessionId, update)];
179
187
  }
180
188
  case "tool_execution_end": {
181
- const diffContent = extractDiffToolCallContent(event.result);
182
- const terminalContent = extractTerminalToolCallContent(event.result);
183
- const otherContent = extractToolCallContent(event.result);
184
- const content = [...diffContent, ...terminalContent, ...otherContent];
189
+ const resultContent = [...extractDiffToolCallContent(event.result), ...extractToolCallContent(event.result)];
190
+ const content = mergeToolUpdateContent(
191
+ buildToolStartContent(event.toolName, getToolExecutionEndArgs(event, options)),
192
+ resultContent,
193
+ );
185
194
  const update: SessionUpdate = {
186
195
  sessionUpdate: "tool_call_update",
187
196
  toolCallId: event.toolCallId,
@@ -195,7 +204,12 @@ export function mapAgentSessionEventToAcpSessionUpdates(
195
204
  if (locations.length > 0) {
196
205
  update.locations = locations;
197
206
  }
198
- return [toSessionNotification(sessionId, update)];
207
+ const notifications = [toSessionNotification(sessionId, update)];
208
+ const planUpdate = mapTodoWriteResultToPlanUpdate(event);
209
+ if (planUpdate) {
210
+ notifications.push(toSessionNotification(sessionId, planUpdate));
211
+ }
212
+ return notifications;
199
213
  }
200
214
  case "todo_reminder": {
201
215
  const entries = event.todos.map(todo => ({
@@ -312,6 +326,144 @@ function mapTodoStatus(status: TodoStatus): "pending" | "in_progress" | "complet
312
326
  return todoStatusMap[status];
313
327
  }
314
328
 
329
+ function mapTodoWriteResultToPlanUpdate(
330
+ event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>,
331
+ ): SessionUpdate | undefined {
332
+ if (event.toolName !== "todo_write" || event.isError) {
333
+ return undefined;
334
+ }
335
+ const phases = extractTodoWritePhases(event.result);
336
+ if (!Array.isArray(phases)) {
337
+ return undefined;
338
+ }
339
+ return {
340
+ sessionUpdate: "plan",
341
+ entries: extractTodoEntries(phases).map(todo => ({
342
+ content: todo.content,
343
+ priority: "medium" as const,
344
+ status: mapTodoStatus(todo.status),
345
+ })),
346
+ };
347
+ }
348
+
349
+ function extractTodoWritePhases(result: unknown): unknown {
350
+ if (typeof result !== "object" || result === null || !("details" in result)) {
351
+ return undefined;
352
+ }
353
+ const details = (result as { details?: unknown }).details;
354
+ if (typeof details !== "object" || details === null || !("phases" in details)) {
355
+ return undefined;
356
+ }
357
+ return (details as { phases?: unknown }).phases;
358
+ }
359
+
360
+ function extractTodoEntries(phases: unknown[]): Array<{ content: string; status: TodoStatus }> {
361
+ const entries: Array<{ content: string; status: TodoStatus }> = [];
362
+ for (const phase of phases) {
363
+ if (typeof phase !== "object" || phase === null || !("tasks" in phase)) {
364
+ continue;
365
+ }
366
+ const tasks = (phase as { tasks?: unknown }).tasks;
367
+ if (!Array.isArray(tasks)) {
368
+ continue;
369
+ }
370
+ for (const task of tasks) {
371
+ if (typeof task !== "object" || task === null || !("content" in task)) {
372
+ continue;
373
+ }
374
+ const content = (task as { content?: unknown }).content;
375
+ if (typeof content !== "string" || content.length === 0) {
376
+ continue;
377
+ }
378
+ const status = (task as { status?: TodoStatus }).status;
379
+ entries.push({ content, status: isTodoStatus(status) ? status : "pending" });
380
+ }
381
+ }
382
+ return entries;
383
+ }
384
+
385
+ function isTodoStatus(status: unknown): status is TodoStatus {
386
+ return status === "pending" || status === "in_progress" || status === "completed" || status === "abandoned";
387
+ }
388
+ export function buildToolCallStartUpdate(input: {
389
+ toolCallId: string;
390
+ toolName: string;
391
+ args: unknown;
392
+ intent?: string;
393
+ cwd?: string;
394
+ status?: "pending" | "completed";
395
+ }): SessionUpdate {
396
+ const update: ToolCall & { sessionUpdate: "tool_call" } = {
397
+ sessionUpdate: "tool_call",
398
+ toolCallId: input.toolCallId,
399
+ title: buildToolTitle(input.toolName, input.args, input.intent),
400
+ kind: mapToolKind(input.toolName),
401
+ status: input.status ?? "pending",
402
+ rawInput: input.args,
403
+ };
404
+ const content = buildToolStartContent(input.toolName, input.args);
405
+ if (content.length > 0) {
406
+ update.content = content;
407
+ }
408
+ const locations = extractToolLocations(input.args, input.cwd);
409
+ if (locations.length > 0) {
410
+ update.locations = locations;
411
+ }
412
+ return update;
413
+ }
414
+
415
+ export function normalizeReplayToolArguments(value: unknown): { args: unknown } {
416
+ if (typeof value !== "string") {
417
+ return { args: value ?? {} };
418
+ }
419
+ try {
420
+ const parsed: unknown = JSON.parse(value);
421
+ return { args: parsed };
422
+ } catch {
423
+ return { args: value };
424
+ }
425
+ }
426
+
427
+ function getToolExecutionEndArgs(
428
+ event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>,
429
+ options: AcpEventMapperOptions,
430
+ ): unknown {
431
+ if ("args" in event) {
432
+ return (event as { args?: unknown }).args;
433
+ }
434
+ return options.getToolArgs?.(event.toolCallId);
435
+ }
436
+
437
+ function buildToolStartContent(toolName: string, args: unknown): ToolCallContent[] {
438
+ if (!isCommandToolName(toolName)) {
439
+ return [];
440
+ }
441
+ const command = extractStringProperty<CommandContainer>(args, "command");
442
+ return command ? [textToolCallContent(`$ ${command}`)] : [];
443
+ }
444
+
445
+ function mergeToolUpdateContent(startContent: ToolCallContent[], resultContent: ToolCallContent[]): ToolCallContent[] {
446
+ if (startContent.length === 0) {
447
+ return resultContent;
448
+ }
449
+ const merged = [...startContent];
450
+ for (const item of resultContent) {
451
+ if (
452
+ item.type === "content" &&
453
+ item.content.type === "text" &&
454
+ hasEquivalentTextContent(merged, item.content.text)
455
+ ) {
456
+ continue;
457
+ }
458
+ merged.push(item);
459
+ }
460
+ return merged;
461
+ }
462
+
463
+ function isCommandToolName(toolName: string): boolean {
464
+ return toolName === "bash" || toolName === "shell" || toolName === "exec";
465
+ }
466
+
315
467
  function buildToolTitle(toolName: string, args: unknown, intent: string | undefined): string {
316
468
  const trimmedIntent = intent?.trim();
317
469
  if (trimmedIntent) {
@@ -418,26 +570,33 @@ function buildDiffContent(entry: unknown): ToolCallContent | undefined {
418
570
  };
419
571
  }
420
572
 
421
- /** Emit a `terminal` ToolCallContent when a tool result carries a `details.terminalId` (e.g. bash routed through ACP terminal/*). */
422
- function extractTerminalToolCallContent(result: unknown): ToolCallContent[] {
423
- if (typeof result !== "object" || result === null) return [];
424
- const details = (result as { details?: unknown }).details;
425
- if (typeof details !== "object" || details === null) return [];
426
- const terminalId = (details as { terminalId?: unknown }).terminalId;
427
- if (typeof terminalId !== "string" || terminalId.length === 0) return [];
428
- return [{ type: "terminal", terminalId }];
573
+ function extractTerminalId(value: unknown): string | undefined {
574
+ const direct = extractStringProperty<TerminalIdContainer>(value, "terminalId");
575
+ if (direct) return direct;
576
+ if (typeof value !== "object" || value === null) return undefined;
577
+ const details = (value as DetailsContainer).details;
578
+ return extractStringProperty<TerminalIdContainer>(details, "terminalId");
579
+ }
580
+
581
+ function terminalToolCallContent(terminalId: string): ToolCallContent {
582
+ return { type: "terminal", terminalId };
429
583
  }
430
584
 
431
585
  function extractToolCallContent(value: unknown): ToolCallContent[] {
432
586
  const richContent = extractStructuredToolCallContent(value);
587
+ const terminalId = extractTerminalId(value);
588
+ const content =
589
+ terminalId && !hasTerminalContent(richContent, terminalId)
590
+ ? [...richContent, terminalToolCallContent(terminalId)]
591
+ : richContent;
433
592
  const fallbackText = extractReadableText(value);
434
593
  if (!fallbackText) {
435
- return richContent;
594
+ return content;
436
595
  }
437
- if (hasEquivalentTextContent(richContent, fallbackText)) {
438
- return richContent;
596
+ if (hasEquivalentTextContent(content, fallbackText)) {
597
+ return content;
439
598
  }
440
- return [...richContent, textToolCallContent(fallbackText)];
599
+ return [...content, textToolCallContent(fallbackText)];
441
600
  }
442
601
 
443
602
  function extractStructuredToolCallContent(value: unknown): ToolCallContent[] {
@@ -596,6 +755,10 @@ function hasEquivalentTextContent(content: ToolCallContent[], text: string): boo
596
755
  return content.some(item => item.type === "content" && item.content.type === "text" && item.content.text === text);
597
756
  }
598
757
 
758
+ function hasTerminalContent(content: ToolCallContent[], terminalId: string): boolean {
759
+ return content.some(item => item.type === "terminal" && item.terminalId === terminalId);
760
+ }
761
+
599
762
  function extractReadableText(value: unknown): string | undefined {
600
763
  if (typeof value === "string") {
601
764
  return normalizeText(value);
@@ -625,11 +788,24 @@ function extractReadableText(value: unknown): string | undefined {
625
788
  return normalizeText(text);
626
789
  }
627
790
  }
628
-
791
+ if (isTerminalOnlyDetails(value)) {
792
+ return undefined;
793
+ }
629
794
  const serialized = safeJsonStringify(value);
630
795
  return normalizeText(serialized);
631
796
  }
632
797
 
798
+ function isTerminalOnlyDetails(value: unknown): boolean {
799
+ if (typeof value !== "object" || value === null) {
800
+ return false;
801
+ }
802
+ if (extractTerminalId(value) === undefined) {
803
+ return false;
804
+ }
805
+ const content = (value as ContentArrayContainer).content;
806
+ return content === undefined || (Array.isArray(content) && content.length === 0);
807
+ }
808
+
633
809
  function extractAssistantMessageText(value: unknown): string {
634
810
  if (typeof value !== "object" || value === null || !("content" in value)) {
635
811
  return "";
@@ -1,15 +1,23 @@
1
1
  import * as stream from "node:stream";
2
- import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
2
+ import { AgentSideConnection, ndJsonStream, type Stream } from "@agentclientprotocol/sdk";
3
3
  import type { AgentSession } from "../../session/agent-session";
4
4
  import { AcpAgent } from "./acp-agent";
5
5
 
6
6
  export type AcpSessionFactory = (cwd: string) => Promise<AgentSession>;
7
7
 
8
- export async function runAcpMode(session: AgentSession, createSession: AcpSessionFactory): Promise<never> {
8
+ export function createAcpConnection(
9
+ transport: Stream,
10
+ createSession: AcpSessionFactory,
11
+ initialSession?: AgentSession,
12
+ ): AgentSideConnection {
13
+ return new AgentSideConnection(conn => new AcpAgent(conn, createSession, initialSession), transport);
14
+ }
15
+
16
+ export async function runAcpMode(createSession: AcpSessionFactory, initialSession?: AgentSession): Promise<never> {
9
17
  const input = stream.Writable.toWeb(process.stdout);
10
18
  const output = stream.Readable.toWeb(process.stdin);
11
19
  const transport = ndJsonStream(input, output);
12
- const connection = new AgentSideConnection(conn => new AcpAgent(conn, session, createSession), transport);
20
+ const connection = createAcpConnection(transport, createSession, initialSession);
13
21
  await connection.closed;
14
22
  process.exit(0);
15
23
  }