@botcord/daemon 0.2.75 → 0.2.77

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 (62) hide show
  1. package/dist/cloud-auth.d.ts +47 -0
  2. package/dist/cloud-auth.js +51 -0
  3. package/dist/cloud-daemon.d.ts +43 -0
  4. package/dist/cloud-daemon.js +252 -0
  5. package/dist/cloud-mode.d.ts +45 -0
  6. package/dist/cloud-mode.js +55 -0
  7. package/dist/cloud-settle.d.ts +81 -0
  8. package/dist/cloud-settle.js +100 -0
  9. package/dist/daemon-singleton.d.ts +26 -0
  10. package/dist/daemon-singleton.js +91 -0
  11. package/dist/daemon.d.ts +1 -1
  12. package/dist/daemon.js +15 -6
  13. package/dist/doctor.d.ts +4 -1
  14. package/dist/doctor.js +15 -4
  15. package/dist/gateway/channels/botcord.d.ts +1 -1
  16. package/dist/gateway/channels/botcord.js +280 -52
  17. package/dist/gateway/dispatcher.d.ts +34 -1
  18. package/dist/gateway/dispatcher.js +277 -20
  19. package/dist/gateway/gateway.d.ts +9 -1
  20. package/dist/gateway/gateway.js +4 -1
  21. package/dist/gateway/runtime-errors.d.ts +6 -0
  22. package/dist/gateway/runtime-errors.js +14 -0
  23. package/dist/gateway/runtimes/claude-code.d.ts +8 -0
  24. package/dist/gateway/runtimes/claude-code.js +92 -4
  25. package/dist/gateway/runtimes/deepseek-tui.js +19 -5
  26. package/dist/gateway/transcript.d.ts +1 -1
  27. package/dist/gateway/types.d.ts +33 -0
  28. package/dist/index.js +71 -80
  29. package/dist/provision.d.ts +2 -0
  30. package/dist/provision.js +39 -1
  31. package/dist/status-render.js +17 -0
  32. package/package.json +2 -2
  33. package/src/__tests__/cloud-auth.test.ts +42 -0
  34. package/src/__tests__/cloud-daemon.test.ts +237 -0
  35. package/src/__tests__/cloud-mode.test.ts +65 -0
  36. package/src/__tests__/cloud-settle.test.ts +287 -0
  37. package/src/__tests__/daemon-singleton.test.ts +89 -0
  38. package/src/__tests__/doctor.test.ts +34 -0
  39. package/src/__tests__/runtime-discovery.test.ts +90 -0
  40. package/src/__tests__/status-render.test.ts +34 -0
  41. package/src/cloud-auth.ts +78 -0
  42. package/src/cloud-daemon.ts +338 -0
  43. package/src/cloud-mode.ts +70 -0
  44. package/src/cloud-settle.ts +182 -0
  45. package/src/daemon-singleton.ts +122 -0
  46. package/src/daemon.ts +18 -5
  47. package/src/doctor.ts +18 -5
  48. package/src/gateway/__tests__/botcord-channel.test.ts +98 -0
  49. package/src/gateway/__tests__/claude-code-adapter.test.ts +101 -1
  50. package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +19 -0
  51. package/src/gateway/__tests__/dispatcher.test.ts +120 -0
  52. package/src/gateway/channels/botcord.ts +299 -43
  53. package/src/gateway/dispatcher.ts +354 -21
  54. package/src/gateway/gateway.ts +16 -1
  55. package/src/gateway/runtime-errors.ts +15 -0
  56. package/src/gateway/runtimes/claude-code.ts +98 -2
  57. package/src/gateway/runtimes/deepseek-tui.ts +23 -5
  58. package/src/gateway/transcript.ts +1 -1
  59. package/src/gateway/types.ts +34 -0
  60. package/src/index.ts +83 -74
  61. package/src/provision.ts +45 -1
  62. package/src/status-render.ts +24 -0
@@ -150,6 +150,25 @@ describe("DeepseekTuiAdapter", () => {
150
150
  }
151
151
  });
152
152
 
153
+ it("treats DeepSeek embedded terminal events as turn completion", async () => {
154
+ const server = await startMockDeepseekServer({
155
+ events: [
156
+ { event: "status", data: { event: "turn.started", thread_id: "thr_test", turn_id: "turn_test" } },
157
+ { event: "message.delta", data: { thread_id: "thr_test", turn_id: "turn_test", content: "done" } },
158
+ { event: "status", data: { event: "turn.finished", thread_id: "thr_test", turn_id: "turn_test" } },
159
+ ],
160
+ });
161
+ try {
162
+ const { result, blocks, status } = runAdapter(server.baseUrl, server.token);
163
+ const res = await result;
164
+ expect(res).toEqual({ text: "done", newSessionId: server.threadId });
165
+ expect(blocks).toContain("assistant_text");
166
+ expect(status.at(-1)).toEqual({ phase: "stopped", label: undefined });
167
+ } finally {
168
+ await server.close();
169
+ }
170
+ });
171
+
153
172
  it("reuses an existing DeepSeek thread id and patches per-turn system context", async () => {
154
173
  const server = await startMockDeepseekServer({ threadId: "thr_existing" });
155
174
  try {
@@ -188,6 +188,23 @@ function makeEnvelope(
188
188
  return { message: makeMessage(partial), ack };
189
189
  }
190
190
 
191
+ function cloudRunRaw(
192
+ budget: { max_wall_time_seconds?: number; max_tool_calls?: number },
193
+ ): Record<string, unknown> {
194
+ return {
195
+ envelope: {
196
+ type: "cloud_run",
197
+ payload: {
198
+ text: "run this",
199
+ cloud_run: {
200
+ run_id: "crun_test",
201
+ budget,
202
+ },
203
+ },
204
+ },
205
+ };
206
+ }
207
+
191
208
  function baseConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
192
209
  return {
193
210
  channels: [{ id: "botcord", type: "botcord", accountId: "ag_me" }],
@@ -225,6 +242,8 @@ describe("Dispatcher", () => {
225
242
  channel?: FakeChannel;
226
243
  runtimeFactory?: RuntimeFactory;
227
244
  turnTimeoutMs?: number;
245
+ runtimeAuthFailureThreshold?: number;
246
+ runtimeAuthFailureCooldownMs?: number;
228
247
  }) {
229
248
  const { store, dir } = await makeStore();
230
249
  tempDirs.push(dir);
@@ -237,6 +256,8 @@ describe("Dispatcher", () => {
237
256
  sessionStore: store,
238
257
  log: silentLogger(),
239
258
  turnTimeoutMs: args.turnTimeoutMs,
259
+ runtimeAuthFailureThreshold: args.runtimeAuthFailureThreshold,
260
+ runtimeAuthFailureCooldownMs: args.runtimeAuthFailureCooldownMs,
240
261
  });
241
262
  return { dispatcher, channel, store };
242
263
  }
@@ -293,6 +314,51 @@ describe("Dispatcher", () => {
293
314
  expect(store.all()[0].threadId).toBe("t_1");
294
315
  });
295
316
 
317
+ it("cloud_run: forwards budget caps to the runtime", async () => {
318
+ const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-cloud" });
319
+ const { dispatcher } = await scaffold({
320
+ runtimeFactory: () => runtime,
321
+ });
322
+
323
+ await dispatcher.handle(
324
+ makeEnvelope({
325
+ id: "msg_cloud_budget",
326
+ raw: cloudRunRaw({ max_wall_time_seconds: 12, max_tool_calls: 7 }),
327
+ }),
328
+ );
329
+
330
+ expect(runtime.calls.length).toBe(1);
331
+ expect(runtime.calls[0].budget).toEqual({
332
+ maxWallTimeMs: 12000,
333
+ maxToolCalls: 7,
334
+ });
335
+ });
336
+
337
+ it("cloud_run: aborts the turn when tool-call budget is exceeded", async () => {
338
+ const runtime = new FakeRuntime({
339
+ reply: "should not deliver",
340
+ newSessionId: "sid-budget",
341
+ blocks: [
342
+ { raw: { type: "tool", name: "one" }, kind: "tool_use", seq: 1 },
343
+ { raw: { type: "tool", name: "two" }, kind: "tool_use", seq: 2 },
344
+ ],
345
+ });
346
+ const { dispatcher, channel, store } = await scaffold({
347
+ runtimeFactory: () => runtime,
348
+ });
349
+
350
+ await dispatcher.handle(
351
+ makeEnvelope({
352
+ id: "msg_cloud_tool_budget",
353
+ raw: cloudRunRaw({ max_tool_calls: 1 }),
354
+ }),
355
+ );
356
+
357
+ expect(channel.sends.length).toBe(1);
358
+ expect(channel.sends[0].message.text).toContain("Cloud run budget exceeded");
359
+ expect(store.all().length).toBe(0);
360
+ });
361
+
296
362
  it("sends replies to the provider reply id when it differs from the internal message id", async () => {
297
363
  const runtime = new FakeRuntime({ reply: "ok" });
298
364
  const feishuChannel = new FakeChannel({ id: "gw_feishu_1", type: "feishu" });
@@ -394,6 +460,60 @@ describe("Dispatcher", () => {
394
460
  expect(channel.sends[0].message.type).toBe("error");
395
461
  });
396
462
 
463
+ it("treats auth failure text as an error and does not persist the failed session", async () => {
464
+ let callNo = 0;
465
+ const runtimeFactory: RuntimeFactory = () => {
466
+ callNo += 1;
467
+ if (callNo === 1) return new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
468
+ return new FakeRuntime({
469
+ reply: "Failed to authenticate. API Error: 403 Request not allowed",
470
+ newSessionId: "sid-bad",
471
+ });
472
+ };
473
+ const { dispatcher, store, channel } = await scaffold({ runtimeFactory });
474
+
475
+ await dispatcher.handle(
476
+ makeEnvelope({ id: "msg_1", conversation: { id: "rm_oc_auth", kind: "direct" } }),
477
+ );
478
+ expect(store.all()[0].runtimeSessionId).toBe("sid-1");
479
+
480
+ await dispatcher.handle(
481
+ makeEnvelope({ id: "msg_2", conversation: { id: "rm_oc_auth", kind: "direct" } }),
482
+ );
483
+
484
+ expect(store.all().length).toBe(0);
485
+ expect(channel.sends).toHaveLength(2);
486
+ expect(channel.sends[1].message.type).toBe("error");
487
+ expect(channel.sends[1].message.text).toContain("Runtime error");
488
+ expect(channel.sends[1].message.text).toContain("Failed to authenticate");
489
+ });
490
+
491
+ it("opens an auth circuit breaker after repeated failures and skips runtime spawn", async () => {
492
+ const runtime = new FakeRuntime({
493
+ reply: "Failed to authenticate. API Error: 403 Request not allowed",
494
+ newSessionId: "sid-bad",
495
+ });
496
+ const { dispatcher, channel, store } = await scaffold({
497
+ runtimeFactory: () => runtime,
498
+ runtimeAuthFailureThreshold: 2,
499
+ runtimeAuthFailureCooldownMs: 60_000,
500
+ });
501
+ const conversation = { id: "rm_oc_auth_breaker", kind: "direct" as const };
502
+
503
+ await dispatcher.handle(makeEnvelope({ id: "msg_1", conversation }));
504
+ await dispatcher.handle(makeEnvelope({ id: "msg_2", conversation }));
505
+ expect(runtime.calls).toHaveLength(2);
506
+ expect(Object.values(dispatcher.runtimeCircuitBreakers())).toHaveLength(1);
507
+
508
+ await dispatcher.handle(makeEnvelope({ id: "msg_3", conversation }));
509
+
510
+ expect(runtime.calls).toHaveLength(2);
511
+ expect(store.all()).toHaveLength(0);
512
+ expect(channel.sends).toHaveLength(3);
513
+ expect(channel.sends[2].message.type).toBe("error");
514
+ expect(channel.sends[2].message.text).toContain("dispatch paused");
515
+ });
516
+
397
517
  it("applies composeUserTurn before handing text to the runtime", async () => {
398
518
  const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
399
519
  const { store, dir } = await makeStore();
@@ -43,7 +43,8 @@ type InboxDrainTrigger =
43
43
  | "ws_auth_ok"
44
44
  | "ws_inbox_update"
45
45
  | "coalesced_inbox_update"
46
- | "has_more_continue";
46
+ | "has_more_continue"
47
+ | "poll_interval";
47
48
 
48
49
  /** Minimal surface the adapter needs from `BotCordClient`. Matches the subset used at runtime. */
49
50
  export interface BotCordChannelClient {
@@ -90,7 +91,7 @@ export interface BotCordChannelOptions {
90
91
  credentialsPath?: string;
91
92
  /** Override the Hub base URL. Defaults to the `hubUrl` stored in credentials. */
92
93
  hubBaseUrl?: string;
93
- /** Not used by the WS-only loop today; kept for future polling fallback. */
94
+ /** Periodic inbox polling fallback. Set to 0 to disable. Defaults to 30s. */
94
95
  pollIntervalMs?: number;
95
96
  /** Test hook: supply a pre-built client instead of loading credentials from disk. */
96
97
  client?: BotCordChannelClient;
@@ -152,7 +153,11 @@ function defaultClientFactory(input: {
152
153
  */
153
154
  function isOwnerTrust(msg: InboxMessage): boolean {
154
155
  if (msg.room_id?.startsWith(OWNER_CHAT_PREFIX)) return true;
155
- if (msg.source_type === "dashboard_user_chat") return true;
156
+ const sourceType = msg.source_type as string | undefined;
157
+ if (sourceType === "dashboard_user_chat") return true;
158
+ // Cloud Agent run tasks are Hub-issued on the user's behalf, same
159
+ // trust posture as owner chat.
160
+ if (sourceType === "cloud_agent_run") return true;
156
161
  return false;
157
162
  }
158
163
 
@@ -186,11 +191,20 @@ function normalizeInbox(
186
191
  if (!env) return null;
187
192
  // `message` is the normal conversational envelope; `contact_request` is
188
193
  // a lightweight inbound asking the agent to notify its owner (the
189
- // composer appends the notify-owner hint). All other envelope types
190
- // (notification, system, contact_added/removed, …) are still filtered
191
- // out here they belong in a separate push-notification path that
192
- // daemon does not yet implement.
193
- if (env.type !== "message" && env.type !== "contact_request") return null;
194
+ // composer appends the notify-owner hint); `cloud_run` carries a
195
+ // Cloud Agent run task with embedded run_id + budget (the cloud
196
+ // daemon's runtime adapter reads them from `raw.envelope.payload.cloud_run`
197
+ // and reports usage back via /internal/cloud-agents/.../settle when the
198
+ // run completes). All other envelope types (notification, system,
199
+ // contact_added/removed, …) are still filtered out — they belong in
200
+ // a separate push-notification path that daemon does not yet implement.
201
+ const envType = env.type as string;
202
+ if (
203
+ envType !== "message" &&
204
+ envType !== "contact_request" &&
205
+ envType !== "cloud_run"
206
+ )
207
+ return null;
194
208
  if (!msg.room_id) return null;
195
209
 
196
210
  const rawText =
@@ -202,8 +216,9 @@ function normalizeInbox(
202
216
 
203
217
  const isDm = msg.room_id.startsWith(DM_ROOM_PREFIX);
204
218
  const isOwnerChat = msg.room_id.startsWith(OWNER_CHAT_PREFIX);
219
+ const sourceType = msg.source_type as string | undefined;
205
220
  const senderKind: "user" | "agent" =
206
- ownerTrust || msg.source_type === "dashboard_human_room" ? "user" : "agent";
221
+ ownerTrust || sourceType === "dashboard_human_room" ? "user" : "agent";
207
222
 
208
223
  const senderName = msg.source_user_name ?? undefined;
209
224
  const threadId = msg.topic_id ?? msg.topic ?? null;
@@ -482,6 +497,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
482
497
  let ws: WebSocket | null = null;
483
498
  let reconnectTimer: NodeJS.Timeout | null = null;
484
499
  let keepaliveTimer: NodeJS.Timeout | null = null;
500
+ let pollTimer: NodeJS.Timeout | null = null;
485
501
  let reconnectAttempt = 0;
486
502
  let connectionSeq = 0;
487
503
  let consecutiveAuthFailures = 0;
@@ -507,6 +523,10 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
507
523
  clearInterval(keepaliveTimer);
508
524
  keepaliveTimer = null;
509
525
  }
526
+ if (pollTimer) {
527
+ clearInterval(pollTimer);
528
+ pollTimer = null;
529
+ }
510
530
  }
511
531
 
512
532
  function markStatus(patch: Partial<ChannelStatusSnapshot>) {
@@ -709,6 +729,16 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
709
729
  });
710
730
  log.info("botcord ws authenticated", { agentId: msg.agent_id });
711
731
  void fireInbox("ws_auth_ok");
732
+ const pollIntervalMs = options.pollIntervalMs ?? 30_000;
733
+ if (pollTimer) clearInterval(pollTimer);
734
+ if (pollIntervalMs > 0) {
735
+ pollTimer = setInterval(() => {
736
+ if (ws === socket && socket.readyState === WebSocket.OPEN) {
737
+ void fireInbox("poll_interval");
738
+ }
739
+ }, pollIntervalMs);
740
+ pollTimer.unref?.();
741
+ }
712
742
  if (keepaliveTimer) clearInterval(keepaliveTimer);
713
743
  keepaliveTimer = setInterval(() => {
714
744
  if (ws === socket && socket.readyState === WebSocket.OPEN) {
@@ -978,45 +1008,25 @@ function normalizeBlockForHub(
978
1008
  }
979
1009
 
980
1010
  if (kind === "tool_use") {
981
- // Claude Code: assistant message w/ content[].type === "tool_use" {id,name,input}
982
- // Codex: item.started for command_execution, file_change, mcp_tool_call, web_search
983
- const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
984
- const tu = contents.find((c: any) => c?.type === "tool_use");
985
- if (tu) {
986
- payload.name = typeof tu.name === "string" ? tu.name : "tool";
987
- if (tu.input && typeof tu.input === "object") payload.params = tu.input;
988
- if (typeof tu.id === "string") payload.id = tu.id;
989
- } else if (raw?.item && typeof raw.item === "object") {
990
- payload.name = typeof raw.item.type === "string" ? raw.item.type : "tool";
991
- const params = codexToolParams(raw.item);
992
- if (Object.keys(params).length > 0) payload.params = params;
993
- if (typeof raw.item.id === "string") payload.id = raw.item.id;
994
- if (typeof raw.item.status === "string") payload.status = raw.item.status;
1011
+ // Claude Code, Codex, DeepSeek TUI, Kimi, and ACP all expose tool calls
1012
+ // with slightly different field names. Preserve the real invocation input
1013
+ // so the dashboard can show more than a bare "tool" label.
1014
+ const call = extractToolCall(raw);
1015
+ if (call) {
1016
+ payload.name = call.name;
1017
+ if (call.params !== undefined && !isEmptyRecord(call.params)) payload.params = call.params;
1018
+ if (call.id) payload.id = call.id;
1019
+ if (call.status) payload.status = call.status;
995
1020
  }
996
1021
  return { kind: "tool_call", seq, payload };
997
1022
  }
998
1023
 
999
1024
  if (kind === "tool_result") {
1000
- // Claude Code: {type:"user", message:{content:[{type:"tool_result",tool_use_id,content}]}}
1001
- // Codex: item.completed for command_execution, file_change, mcp_tool_call, web_search
1002
- const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
1003
- const tr = contents.find((c: any) => c?.type === "tool_result");
1004
- if (tr) {
1005
- let resultStr = "";
1006
- if (typeof tr.content === "string") {
1007
- resultStr = tr.content;
1008
- } else if (Array.isArray(tr.content)) {
1009
- resultStr = tr.content
1010
- .map((c: any) => (typeof c?.text === "string" ? c.text : JSON.stringify(c)))
1011
- .join("\n");
1012
- }
1013
- payload.result = resultStr;
1014
- if (typeof tr.tool_use_id === "string") payload.tool_use_id = tr.tool_use_id;
1015
- } else if (raw?.item && typeof raw.item === "object") {
1016
- payload.name = typeof raw.item.type === "string" ? raw.item.type : "tool";
1017
- if (typeof raw.item.id === "string") payload.tool_use_id = raw.item.id;
1018
- const result = codexToolResult(raw.item);
1019
- if (result) payload.result = result;
1025
+ const result = extractToolResult(raw);
1026
+ if (result) {
1027
+ if (result.name) payload.name = result.name;
1028
+ payload.result = result.result;
1029
+ if (result.id) payload.tool_use_id = result.id;
1020
1030
  }
1021
1031
  return { kind: "tool_result", seq, payload };
1022
1032
  }
@@ -1042,6 +1052,14 @@ function normalizeBlockForHub(
1042
1052
  }
1043
1053
 
1044
1054
  // "other" — e.g. Claude Code `type:"result"` end-of-turn summary.
1055
+ if (isTerminalRuntimeBlock(raw)) {
1056
+ payload.terminal = true;
1057
+ payload.details = formatBlockDetails(raw);
1058
+ const event = typeof raw?.event === "string" ? raw.event : undefined;
1059
+ const embedded = typeof raw?.payload?.event === "string" ? raw.payload.event : undefined;
1060
+ if (event || embedded) payload.event = event ?? embedded;
1061
+ return { kind: "other", seq, payload };
1062
+ }
1045
1063
  if (raw?.type === "result") {
1046
1064
  if (typeof raw.result === "string") payload.text = raw.result;
1047
1065
  if (typeof raw.subtype === "string") payload.subtype = raw.subtype;
@@ -1050,6 +1068,203 @@ function normalizeBlockForHub(
1050
1068
  return { kind: "other", seq, payload };
1051
1069
  }
1052
1070
 
1071
+ function isTerminalRuntimeBlock(raw: any): boolean {
1072
+ const event = typeof raw?.event === "string" ? raw.event : undefined;
1073
+ const embedded = typeof raw?.payload?.event === "string" ? raw.payload.event : undefined;
1074
+ const terminal = event ?? embedded;
1075
+ return (
1076
+ terminal === "turn.completed" ||
1077
+ terminal === "turn.finished" ||
1078
+ terminal === "turn.done" ||
1079
+ terminal === "done"
1080
+ );
1081
+ }
1082
+
1083
+ function extractToolCall(raw: any): { name: string; params?: unknown; id?: string; status?: string } | null {
1084
+ const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
1085
+ const tu = contents.find((c: any) => c?.type === "tool_use");
1086
+ if (tu) {
1087
+ return {
1088
+ name: stringField(tu, "name") ?? "tool",
1089
+ params: parseMaybeJson(tu.input ?? tu.arguments),
1090
+ id: stringField(tu, "id"),
1091
+ };
1092
+ }
1093
+
1094
+ const deepseek = extractDeepseekToolCall(raw);
1095
+ if (deepseek) return deepseek;
1096
+
1097
+ const item = raw?.item;
1098
+ if (item && typeof item === "object") {
1099
+ const params = codexToolParams(item);
1100
+ return {
1101
+ name: stringField(item, "type") ?? stringField(item, "name") ?? "tool",
1102
+ params,
1103
+ id: stringField(item, "id"),
1104
+ status: stringField(item, "status"),
1105
+ };
1106
+ }
1107
+
1108
+ const toolCalls = Array.isArray(raw?.tool_calls) ? raw.tool_calls : [];
1109
+ const toolCall = toolCalls.find((t: any) => t && typeof t === "object");
1110
+ if (toolCall) {
1111
+ const fn = toolCall.function && typeof toolCall.function === "object" ? toolCall.function : undefined;
1112
+ return {
1113
+ name: stringField(fn, "name") ?? stringField(toolCall, "name") ?? "tool",
1114
+ params: parseMaybeJson(fn?.arguments ?? toolCall.arguments ?? toolCall.input ?? toolCall.rawInput),
1115
+ id: stringField(toolCall, "id"),
1116
+ };
1117
+ }
1118
+
1119
+ const update = raw?.params?.update ?? raw?.update;
1120
+ const acpTool = update?.toolCall ?? update?.tool_call ?? update?.tool;
1121
+ if (acpTool && typeof acpTool === "object") {
1122
+ return {
1123
+ name: stringField(acpTool, "name") ?? stringField(update, "name") ?? "tool",
1124
+ params: parseMaybeJson(
1125
+ acpTool.rawInput ??
1126
+ acpTool.raw_input ??
1127
+ acpTool.input ??
1128
+ acpTool.arguments ??
1129
+ acpTool.args ??
1130
+ acpTool.params,
1131
+ ) ?? acpTool,
1132
+ id: stringField(acpTool, "id") ?? stringField(update, "toolCallId"),
1133
+ };
1134
+ }
1135
+
1136
+ return null;
1137
+ }
1138
+
1139
+ function extractToolResult(raw: any): { name?: string; result: string; id?: string } | null {
1140
+ const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
1141
+ const tr = contents.find((c: any) => c?.type === "tool_result");
1142
+ if (tr) {
1143
+ return {
1144
+ result: stringifyToolResult(tr.content),
1145
+ id: stringField(tr, "tool_use_id"),
1146
+ };
1147
+ }
1148
+
1149
+ const deepseek = extractDeepseekToolResult(raw);
1150
+ if (deepseek) return deepseek;
1151
+
1152
+ const item = raw?.item;
1153
+ if (item && typeof item === "object") {
1154
+ const result = codexToolResult(item);
1155
+ return {
1156
+ name: stringField(item, "type") ?? stringField(item, "name"),
1157
+ result: result || stringifyToolResult(item),
1158
+ id: stringField(item, "id"),
1159
+ };
1160
+ }
1161
+
1162
+ if (raw?.role === "tool") {
1163
+ return {
1164
+ result: stringifyToolResult(raw.content),
1165
+ id: stringField(raw, "tool_call_id"),
1166
+ };
1167
+ }
1168
+
1169
+ const update = raw?.params?.update ?? raw?.update;
1170
+ const acpTool = update?.toolCall ?? update?.tool_call ?? update?.tool;
1171
+ if (acpTool && typeof acpTool === "object") {
1172
+ const result =
1173
+ acpTool.output ??
1174
+ acpTool.result ??
1175
+ acpTool.content ??
1176
+ acpTool.error ??
1177
+ update.content ??
1178
+ update;
1179
+ return {
1180
+ name: stringField(acpTool, "name") ?? stringField(update, "name"),
1181
+ result: stringifyToolResult(result),
1182
+ id: stringField(acpTool, "id") ?? stringField(update, "toolCallId"),
1183
+ };
1184
+ }
1185
+
1186
+ return null;
1187
+ }
1188
+
1189
+ function extractDeepseekToolCall(raw: any): { name: string; params?: unknown; id?: string; status?: string } | null {
1190
+ const payload = raw?.payload;
1191
+ if (!payload || typeof payload !== "object") return null;
1192
+
1193
+ if (raw?.event === "tool.started") {
1194
+ const tool = payload.tool && typeof payload.tool === "object" ? payload.tool : undefined;
1195
+ return {
1196
+ name: stringField(payload, "name") ?? stringField(tool, "name") ?? "tool",
1197
+ params: parseMaybeJson(payload.input ?? payload.arguments ?? payload.params ?? tool?.input ?? tool?.rawInput),
1198
+ id: stringField(payload, "id") ?? stringField(tool, "id"),
1199
+ status: stringField(payload, "status") ?? stringField(tool, "status"),
1200
+ };
1201
+ }
1202
+
1203
+ if (payload.event === "item.started") {
1204
+ const inner = payload.payload && typeof payload.payload === "object" ? payload.payload : {};
1205
+ const item = inner.item && typeof inner.item === "object" ? inner.item : undefined;
1206
+ const tool = inner.tool && typeof inner.tool === "object" ? inner.tool : item?.tool;
1207
+ return {
1208
+ name:
1209
+ stringField(tool, "name") ??
1210
+ stringField(inner, "name") ??
1211
+ stringField(item, "name") ??
1212
+ stringField(item, "type") ??
1213
+ "tool",
1214
+ params: parseMaybeJson(
1215
+ tool?.input ??
1216
+ tool?.rawInput ??
1217
+ tool?.arguments ??
1218
+ inner.input ??
1219
+ item?.input ??
1220
+ item?.arguments,
1221
+ ) ?? tool ?? item,
1222
+ id: stringField(tool, "id") ?? stringField(inner, "id") ?? stringField(item, "id"),
1223
+ status: stringField(tool, "status") ?? stringField(inner, "status") ?? stringField(item, "status"),
1224
+ };
1225
+ }
1226
+
1227
+ return null;
1228
+ }
1229
+
1230
+ function extractDeepseekToolResult(raw: any): { name?: string; result: string; id?: string } | null {
1231
+ const payload = raw?.payload;
1232
+ if (!payload || typeof payload !== "object") return null;
1233
+
1234
+ if (raw?.event === "tool.completed") {
1235
+ const result = payload.output ?? payload.result ?? payload.content ?? payload.error ?? payload;
1236
+ return {
1237
+ name: stringField(payload, "name"),
1238
+ result: stringifyToolResult(result),
1239
+ id: stringField(payload, "id"),
1240
+ };
1241
+ }
1242
+
1243
+ if (payload.event === "item.completed" || payload.event === "item.failed") {
1244
+ const inner = payload.payload && typeof payload.payload === "object" ? payload.payload : {};
1245
+ const item = inner.item && typeof inner.item === "object" ? inner.item : undefined;
1246
+ const result =
1247
+ item?.output ??
1248
+ item?.result ??
1249
+ item?.content ??
1250
+ item?.detail ??
1251
+ item?.summary ??
1252
+ item?.error ??
1253
+ inner.output ??
1254
+ inner.result ??
1255
+ inner.error ??
1256
+ item ??
1257
+ inner;
1258
+ return {
1259
+ name: stringField(item, "name") ?? stringField(item, "type") ?? stringField(inner, "name"),
1260
+ result: stringifyToolResult(result),
1261
+ id: stringField(item, "id") ?? stringField(inner, "id"),
1262
+ };
1263
+ }
1264
+
1265
+ return null;
1266
+ }
1267
+
1053
1268
  function formatBlockDetails(raw: unknown): string {
1054
1269
  if (!raw || typeof raw !== "object") return "";
1055
1270
  const r = raw as any;
@@ -1121,6 +1336,47 @@ function codexToolResult(item: Record<string, unknown>): string {
1121
1336
  return parts.join("\n");
1122
1337
  }
1123
1338
 
1339
+ function stringifyToolResult(value: unknown): string {
1340
+ if (value == null) return "";
1341
+ if (typeof value === "string") return value;
1342
+ if (Array.isArray(value)) {
1343
+ return value
1344
+ .map((c: any) => {
1345
+ if (typeof c === "string") return c;
1346
+ if (typeof c?.text === "string") return c.text;
1347
+ return stringifyToolResult(c);
1348
+ })
1349
+ .filter(Boolean)
1350
+ .join("\n");
1351
+ }
1352
+ try {
1353
+ return JSON.stringify(value, null, 2);
1354
+ } catch {
1355
+ return String(value);
1356
+ }
1357
+ }
1358
+
1359
+ function parseMaybeJson(value: unknown): unknown {
1360
+ if (typeof value !== "string") return value;
1361
+ const trimmed = value.trim();
1362
+ if (!trimmed) return value;
1363
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value;
1364
+ try {
1365
+ return JSON.parse(trimmed);
1366
+ } catch {
1367
+ return value;
1368
+ }
1369
+ }
1370
+
1371
+ function isEmptyRecord(value: unknown): boolean {
1372
+ return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0;
1373
+ }
1374
+
1375
+ function stringField(obj: any, key: string): string | undefined {
1376
+ const value = obj?.[key];
1377
+ return typeof value === "string" && value.length > 0 ? value : undefined;
1378
+ }
1379
+
1124
1380
  function extractContentText(content: unknown): string {
1125
1381
  if (!content) return "";
1126
1382
  if (typeof content === "string") return content;