@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.
- package/dist/cloud-auth.d.ts +47 -0
- package/dist/cloud-auth.js +51 -0
- package/dist/cloud-daemon.d.ts +43 -0
- package/dist/cloud-daemon.js +252 -0
- package/dist/cloud-mode.d.ts +45 -0
- package/dist/cloud-mode.js +55 -0
- package/dist/cloud-settle.d.ts +81 -0
- package/dist/cloud-settle.js +100 -0
- package/dist/daemon-singleton.d.ts +26 -0
- package/dist/daemon-singleton.js +91 -0
- package/dist/daemon.d.ts +1 -1
- package/dist/daemon.js +15 -6
- package/dist/doctor.d.ts +4 -1
- package/dist/doctor.js +15 -4
- package/dist/gateway/channels/botcord.d.ts +1 -1
- package/dist/gateway/channels/botcord.js +280 -52
- package/dist/gateway/dispatcher.d.ts +34 -1
- package/dist/gateway/dispatcher.js +277 -20
- package/dist/gateway/gateway.d.ts +9 -1
- package/dist/gateway/gateway.js +4 -1
- package/dist/gateway/runtime-errors.d.ts +6 -0
- package/dist/gateway/runtime-errors.js +14 -0
- package/dist/gateway/runtimes/claude-code.d.ts +8 -0
- package/dist/gateway/runtimes/claude-code.js +92 -4
- package/dist/gateway/runtimes/deepseek-tui.js +19 -5
- package/dist/gateway/transcript.d.ts +1 -1
- package/dist/gateway/types.d.ts +33 -0
- package/dist/index.js +71 -80
- package/dist/provision.d.ts +2 -0
- package/dist/provision.js +39 -1
- package/dist/status-render.js +17 -0
- package/package.json +2 -2
- package/src/__tests__/cloud-auth.test.ts +42 -0
- package/src/__tests__/cloud-daemon.test.ts +237 -0
- package/src/__tests__/cloud-mode.test.ts +65 -0
- package/src/__tests__/cloud-settle.test.ts +287 -0
- package/src/__tests__/daemon-singleton.test.ts +89 -0
- package/src/__tests__/doctor.test.ts +34 -0
- package/src/__tests__/runtime-discovery.test.ts +90 -0
- package/src/__tests__/status-render.test.ts +34 -0
- package/src/cloud-auth.ts +78 -0
- package/src/cloud-daemon.ts +338 -0
- package/src/cloud-mode.ts +70 -0
- package/src/cloud-settle.ts +182 -0
- package/src/daemon-singleton.ts +122 -0
- package/src/daemon.ts +18 -5
- package/src/doctor.ts +18 -5
- package/src/gateway/__tests__/botcord-channel.test.ts +98 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +101 -1
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +19 -0
- package/src/gateway/__tests__/dispatcher.test.ts +120 -0
- package/src/gateway/channels/botcord.ts +299 -43
- package/src/gateway/dispatcher.ts +354 -21
- package/src/gateway/gateway.ts +16 -1
- package/src/gateway/runtime-errors.ts +15 -0
- package/src/gateway/runtimes/claude-code.ts +98 -2
- package/src/gateway/runtimes/deepseek-tui.ts +23 -5
- package/src/gateway/transcript.ts +1 -1
- package/src/gateway/types.ts +34 -0
- package/src/index.ts +83 -74
- package/src/provision.ts +45 -1
- 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
|
-
/**
|
|
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
|
-
|
|
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)
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
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 ||
|
|
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
|
|
982
|
-
//
|
|
983
|
-
|
|
984
|
-
const
|
|
985
|
-
if (
|
|
986
|
-
payload.name =
|
|
987
|
-
if (
|
|
988
|
-
if (
|
|
989
|
-
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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;
|