@botcord/daemon 0.2.78 → 0.2.80

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 (61) hide show
  1. package/dist/attention-policy-fetcher.d.ts +14 -0
  2. package/dist/attention-policy-fetcher.js +59 -0
  3. package/dist/cloud-daemon.js +8 -0
  4. package/dist/cloud-gateway-runtime.d.ts +29 -0
  5. package/dist/cloud-gateway-runtime.js +122 -0
  6. package/dist/daemon-singleton.d.ts +13 -0
  7. package/dist/daemon-singleton.js +68 -0
  8. package/dist/daemon.js +21 -6
  9. package/dist/gateway/channels/botcord.d.ts +1 -0
  10. package/dist/gateway/channels/botcord.js +62 -17
  11. package/dist/gateway/channels/login-session.d.ts +12 -0
  12. package/dist/gateway/channels/login-session.js +20 -2
  13. package/dist/gateway/channels/sanitize.d.ts +5 -18
  14. package/dist/gateway/channels/sanitize.js +5 -54
  15. package/dist/gateway/channels/text-split.d.ts +5 -11
  16. package/dist/gateway/channels/text-split.js +5 -31
  17. package/dist/gateway/dispatcher.d.ts +7 -1
  18. package/dist/gateway/dispatcher.js +88 -8
  19. package/dist/gateway/gateway.d.ts +16 -1
  20. package/dist/gateway/gateway.js +21 -0
  21. package/dist/gateway/policy-resolver.js +17 -9
  22. package/dist/gateway/runtimes/deepseek-tui.js +56 -13
  23. package/dist/gateway/types.d.ts +12 -57
  24. package/dist/gateway-control.js +18 -9
  25. package/dist/index.js +8 -3
  26. package/dist/provision.d.ts +7 -3
  27. package/dist/provision.js +115 -8
  28. package/dist/room-recovery-context.d.ts +11 -0
  29. package/dist/room-recovery-context.js +97 -0
  30. package/dist/status-render.d.ts +4 -0
  31. package/dist/status-render.js +14 -1
  32. package/package.json +2 -2
  33. package/src/__tests__/attention-policy-fetcher.test.ts +67 -0
  34. package/src/__tests__/cloud-gateway-runtime.test.ts +127 -0
  35. package/src/__tests__/daemon-singleton.test.ts +32 -0
  36. package/src/__tests__/gateway-control.test.ts +136 -0
  37. package/src/__tests__/policy-resolver.test.ts +20 -0
  38. package/src/__tests__/provision.test.ts +65 -0
  39. package/src/__tests__/status-render.test.ts +23 -0
  40. package/src/attention-policy-fetcher.ts +87 -0
  41. package/src/cloud-daemon.ts +8 -0
  42. package/src/cloud-gateway-runtime.ts +171 -0
  43. package/src/daemon-singleton.ts +85 -0
  44. package/src/daemon.ts +23 -6
  45. package/src/gateway/__tests__/botcord-channel.test.ts +211 -5
  46. package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +263 -0
  47. package/src/gateway/__tests__/dispatcher.test.ts +56 -0
  48. package/src/gateway/channels/botcord.ts +69 -17
  49. package/src/gateway/channels/login-session.ts +20 -2
  50. package/src/gateway/channels/sanitize.ts +8 -66
  51. package/src/gateway/channels/text-split.ts +5 -27
  52. package/src/gateway/dispatcher.ts +123 -27
  53. package/src/gateway/gateway.ts +29 -0
  54. package/src/gateway/policy-resolver.ts +20 -9
  55. package/src/gateway/runtimes/deepseek-tui.ts +63 -13
  56. package/src/gateway/types.ts +31 -59
  57. package/src/gateway-control.ts +21 -9
  58. package/src/index.ts +9 -2
  59. package/src/provision.ts +133 -7
  60. package/src/room-recovery-context.ts +131 -0
  61. package/src/status-render.ts +14 -1
@@ -0,0 +1,171 @@
1
+ import {
2
+ RUNTIME_FRAME_TYPES,
3
+ type GatewayInboundFrame,
4
+ } from "@botcord/protocol-core";
5
+
6
+ import type {
7
+ ChannelAdapter,
8
+ ChannelSendContext,
9
+ ChannelSendResult,
10
+ ChannelStatusSnapshot,
11
+ Gateway,
12
+ GatewayInboundMessage,
13
+ GatewayLogger,
14
+ } from "./gateway/index.js";
15
+
16
+ export interface CloudGatewayRuntimeResult {
17
+ accepted: boolean;
18
+ eventId: string;
19
+ gatewayId: string;
20
+ agentId: string;
21
+ conversationId: string;
22
+ turnId: string;
23
+ outbound?: {
24
+ finalText: string;
25
+ providerMessageId?: string | null;
26
+ };
27
+ error?: {
28
+ code: string;
29
+ message: string;
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Execute one ingress-originated runtime frame through the normal Gateway
35
+ * dispatcher while keeping provider I/O outside the cloud sandbox.
36
+ *
37
+ * The temporary channel is scoped to this call and implements only the
38
+ * dispatcher-facing send/status surface. Its send() method captures the
39
+ * final runtime reply; the Hub relay converts that into
40
+ * gateway_outbound_complete for gateway-ingress, which then calls the real
41
+ * provider API.
42
+ */
43
+ export async function handleCloudGatewayRuntimeInbound(
44
+ gateway: Gateway,
45
+ frame: GatewayInboundFrame,
46
+ log?: GatewayLogger,
47
+ ): Promise<CloudGatewayRuntimeResult> {
48
+ if (frame.type !== RUNTIME_FRAME_TYPES.GATEWAY_INBOUND) {
49
+ return rejected(frame, "bad_frame_type", `unsupported frame type "${frame.type}"`);
50
+ }
51
+ if (!frame.gateway_id || !frame.agent_id || !frame.event_id) {
52
+ return rejected(frame, "bad_frame", "gateway_id, agent_id and event_id are required");
53
+ }
54
+ if (frame.message.accountId !== frame.agent_id) {
55
+ return rejected(frame, "account_mismatch", "message.accountId does not match frame.agent_id");
56
+ }
57
+ if (frame.message.channel !== frame.gateway_id) {
58
+ return rejected(frame, "channel_mismatch", "message.channel does not match frame.gateway_id");
59
+ }
60
+
61
+ let accepted = false;
62
+ let outboundText: string | null = null;
63
+ let providerMessageId: string | null | undefined;
64
+ const channel = createRuntimeRelayChannel({
65
+ id: frame.gateway_id,
66
+ provider: frame.provider,
67
+ accountId: frame.agent_id,
68
+ onSend: async (ctx) => {
69
+ outboundText = ctx.message.text ?? "";
70
+ providerMessageId = ctx.message.traceId ?? null;
71
+ return { providerMessageId };
72
+ },
73
+ });
74
+
75
+ const message: GatewayInboundMessage = {
76
+ ...frame.message,
77
+ raw: {
78
+ source_type: "cloud_gateway_ingress",
79
+ provider: frame.provider,
80
+ event_id: frame.event_id,
81
+ gateway_id: frame.gateway_id,
82
+ },
83
+ };
84
+
85
+ try {
86
+ await gateway.injectInboundThrough(message, channel, {
87
+ accept: async () => {
88
+ accepted = true;
89
+ },
90
+ });
91
+ } catch (err) {
92
+ const message = err instanceof Error ? err.message : String(err);
93
+ log?.warn("cloud gateway runtime dispatch failed", {
94
+ eventId: frame.event_id,
95
+ gatewayId: frame.gateway_id,
96
+ agentId: frame.agent_id,
97
+ error: message,
98
+ });
99
+ return rejected(frame, "dispatch_failed", message);
100
+ }
101
+
102
+ return {
103
+ accepted,
104
+ eventId: frame.event_id,
105
+ gatewayId: frame.gateway_id,
106
+ agentId: frame.agent_id,
107
+ conversationId: frame.message.conversation.id,
108
+ turnId: `turn_${frame.event_id}`,
109
+ ...(outboundText !== null
110
+ ? {
111
+ outbound: {
112
+ finalText: outboundText,
113
+ providerMessageId: providerMessageId ?? null,
114
+ },
115
+ }
116
+ : {}),
117
+ ...(!accepted
118
+ ? { error: { code: "not_accepted", message: "dispatcher did not accept inbound" } }
119
+ : {}),
120
+ };
121
+ }
122
+
123
+ function createRuntimeRelayChannel(opts: {
124
+ id: string;
125
+ provider: string;
126
+ accountId: string;
127
+ onSend: (ctx: ChannelSendContext) => Promise<ChannelSendResult>;
128
+ }): ChannelAdapter {
129
+ let lastSendAt: number | undefined;
130
+ return {
131
+ id: opts.id,
132
+ type: opts.provider,
133
+ async start() {
134
+ return undefined;
135
+ },
136
+ async stop() {
137
+ return undefined;
138
+ },
139
+ async send(ctx) {
140
+ lastSendAt = Date.now();
141
+ return opts.onSend(ctx);
142
+ },
143
+ status(): ChannelStatusSnapshot {
144
+ return {
145
+ channel: opts.id,
146
+ accountId: opts.accountId,
147
+ running: true,
148
+ connected: true,
149
+ authorized: true,
150
+ provider: opts.provider as ChannelStatusSnapshot["provider"],
151
+ ...(lastSendAt ? { lastSendAt } : {}),
152
+ };
153
+ },
154
+ };
155
+ }
156
+
157
+ function rejected(
158
+ frame: Partial<GatewayInboundFrame>,
159
+ code: string,
160
+ message: string,
161
+ ): CloudGatewayRuntimeResult {
162
+ return {
163
+ accepted: false,
164
+ eventId: frame.event_id ?? "",
165
+ gatewayId: frame.gateway_id ?? "",
166
+ agentId: frame.agent_id ?? "",
167
+ conversationId: frame.message?.conversation.id ?? "",
168
+ turnId: frame.event_id ? `turn_${frame.event_id}` : "turn_unknown",
169
+ error: { code, message },
170
+ };
171
+ }
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
3
  import { PID_PATH } from "./config.js";
3
4
 
@@ -31,6 +32,46 @@ export function pidAlive(pid: number): boolean {
31
32
  }
32
33
  }
33
34
 
35
+ export interface DaemonProcessInfo {
36
+ pid: number;
37
+ command: string;
38
+ }
39
+
40
+ export function parseDaemonProcesses(
41
+ psOutput: string,
42
+ currentPid: number = process.pid,
43
+ ): DaemonProcessInfo[] {
44
+ const out: DaemonProcessInfo[] = [];
45
+ for (const line of psOutput.split(/\r?\n/)) {
46
+ const trimmed = line.trim();
47
+ const match = /^(\d+)\s+(.+)$/.exec(trimmed);
48
+ if (!match) continue;
49
+ const pid = Number(match[1]);
50
+ if (!Number.isFinite(pid) || pid <= 0 || pid === currentPid) continue;
51
+ const command = match[2] ?? "";
52
+ if (!isBotCordDaemonStartCommand(command)) continue;
53
+ out.push({ pid, command });
54
+ }
55
+ return out;
56
+ }
57
+
58
+ export function findOtherDaemonProcesses(
59
+ opts: {
60
+ currentPid?: number;
61
+ } = {},
62
+ ): DaemonProcessInfo[] {
63
+ const currentPid = opts.currentPid ?? process.pid;
64
+ try {
65
+ const output = execFileSync("ps", ["-axo", "pid=,command="], {
66
+ encoding: "utf8",
67
+ stdio: ["ignore", "pipe", "ignore"],
68
+ });
69
+ return parseDaemonProcesses(output, currentPid).filter((p) => pidAlive(p.pid));
70
+ } catch {
71
+ return [];
72
+ }
73
+ }
74
+
34
75
  export async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
35
76
  const deadline = Date.now() + timeoutMs;
36
77
  while (Date.now() < deadline) {
@@ -85,6 +126,41 @@ export async function stopDaemonFromPidFileForRestart(
85
126
  }
86
127
  }
87
128
 
129
+ export async function stopOtherDaemonProcessesForRestart(
130
+ opts: {
131
+ currentPid?: number;
132
+ logger?: SingletonLogger;
133
+ processes?: DaemonProcessInfo[];
134
+ } = {},
135
+ ): Promise<DaemonProcessInfo[]> {
136
+ const currentPid = opts.currentPid ?? process.pid;
137
+ const logger = opts.logger ?? noopLogger;
138
+ const processes = opts.processes ?? findOtherDaemonProcesses({ currentPid });
139
+ for (const proc of processes) {
140
+ logger.info("additional daemon process found; restarting", {
141
+ pid: proc.pid,
142
+ command: proc.command,
143
+ });
144
+ try {
145
+ process.kill(proc.pid, "SIGTERM");
146
+ } catch {
147
+ continue;
148
+ }
149
+ if (!(await waitForPidExit(proc.pid, 5_000))) {
150
+ logger.warn("additional daemon did not stop after SIGTERM; sending SIGKILL", {
151
+ pid: proc.pid,
152
+ });
153
+ try {
154
+ process.kill(proc.pid, "SIGKILL");
155
+ } catch {
156
+ // ignore
157
+ }
158
+ await waitForPidExit(proc.pid, 2_000);
159
+ }
160
+ }
161
+ return processes;
162
+ }
163
+
88
164
  export function ensureNoOtherDaemonFromPidFile(
89
165
  opts: {
90
166
  pidPath?: string;
@@ -117,6 +193,15 @@ export function removePidFile(pidPath = PID_PATH): void {
117
193
  }
118
194
  }
119
195
 
196
+ function isBotCordDaemonStartCommand(command: string): boolean {
197
+ if (!/\bstart\b/.test(command)) return false;
198
+ return (
199
+ command.includes("botcord-daemon") ||
200
+ /(?:^|\s)\S*botcord\S*\/daemon\/dist\/index\.js(?:\s|$)/.test(command) ||
201
+ /(?:^|\s)\S*packages\/daemon\/dist\/index\.js(?:\s|$)/.test(command)
202
+ );
203
+ }
204
+
120
205
  function delay(ms: number): Promise<void> {
121
206
  return new Promise((resolve) => setTimeout(resolve, ms));
122
207
  }
package/src/daemon.ts CHANGED
@@ -39,6 +39,7 @@ import { createDaemonSystemContextBuilder } from "./system-context.js";
39
39
  import { readWorkingMemorySnapshot } from "./working-memory.js";
40
40
  import { createRoomStaticContextBuilder } from "./room-context.js";
41
41
  import { createRoomContextFetcher } from "./room-context-fetcher.js";
42
+ import { createRecentRoomMessagesRecoveryBuilder } from "./room-recovery-context.js";
42
43
  import {
43
44
  buildLoopRiskPrompt,
44
45
  loopRiskSessionKey,
@@ -50,6 +51,7 @@ import { UserAuthManager } from "./user-auth.js";
50
51
  import { PolicyResolver, type DaemonAttentionPolicy } from "./gateway/policy-resolver.js";
51
52
  import { scanMention } from "./mention-scan.js";
52
53
  import { createDiagnosticBundle, uploadDiagnosticBundle } from "./diagnostics.js";
54
+ import { createAttentionPolicyFetcher } from "./attention-policy-fetcher.js";
53
55
 
54
56
  /**
55
57
  * Default hard cap for a single runtime turn. Long-running coding/research
@@ -364,6 +366,13 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
364
366
  fetchRoomInfo: roomContextFetcher,
365
367
  log: logger,
366
368
  });
369
+ const buildRuntimeRecoveryContext = createRecentRoomMessagesRecoveryBuilder({
370
+ credentialPathByAgentId,
371
+ ...(opts.credentialsPath ? { defaultCredentialsPath: opts.credentialsPath } : {}),
372
+ ...(opts.hubBaseUrl ? { hubBaseUrl: opts.hubBaseUrl } : {}),
373
+ limit: 20,
374
+ log: logger,
375
+ });
367
376
 
368
377
  // Cache one system-context builder per configured agentId. The gateway
369
378
  // calls this with each inbound message and we pick the right builder by
@@ -442,13 +451,20 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
442
451
  });
443
452
  };
444
453
 
445
- // Per-agent attention policy cache (PR3, design §4.2 / §5). Seeded from
446
- // the optional `defaultAttention` / `attentionKeywords` carried by
447
- // `provision_agent`, refreshed in-place by the `policy_updated` control
448
- // frame. PR2 will plug per-room overrides into `fetchEffective`; PR3
449
- // leaves it absent so the resolver collapses to per-agent state.
454
+ // Per-agent attention policy cache (design §4.2 / §5). It is seeded from
455
+ // `provision_agent` / `policy_updated` frames when available and falls back
456
+ // to Hub on cold misses, so daemon restarts preserve dashboard policy.
457
+ const fetchAttentionPolicy = createAttentionPolicyFetcher({
458
+ credentialPathByAgentId,
459
+ defaultCredentialsPath: opts.credentialsPath,
460
+ hubBaseUrl: opts.hubBaseUrl,
461
+ log: logger,
462
+ });
450
463
  const policyResolver = new PolicyResolver({
451
- fetchGlobal: async (_agentId: string) => undefined,
464
+ fetchGlobal: async (agentId: string) =>
465
+ fetchAttentionPolicy({ agentId, roomId: null }),
466
+ fetchEffective: async (agentId: string, roomId: string) =>
467
+ fetchAttentionPolicy({ agentId, roomId }),
452
468
  });
453
469
 
454
470
  // Display-name lookup for the mention text-fallback. Populated from boot
@@ -528,6 +544,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
528
544
  turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
529
545
  buildSystemContext,
530
546
  buildMemoryContext,
547
+ buildRuntimeRecoveryContext,
531
548
  onInbound,
532
549
  onOutbound,
533
550
  onRuntimeCircuitBreakerChange: pushLiveRuntimeSnapshot,
@@ -699,7 +699,7 @@ describe("createBotCordChannel — streamBlock()", () => {
699
699
  },
700
700
  1,
701
701
  ),
702
- ).toEqual({
702
+ ).toMatchObject({
703
703
  kind: "tool_call",
704
704
  seq: 1,
705
705
  payload: {
@@ -725,7 +725,7 @@ describe("createBotCordChannel — streamBlock()", () => {
725
725
  },
726
726
  2,
727
727
  ),
728
- ).toEqual({
728
+ ).toMatchObject({
729
729
  kind: "tool_call",
730
730
  seq: 2,
731
731
  payload: {
@@ -758,7 +758,7 @@ describe("createBotCordChannel — streamBlock()", () => {
758
758
  },
759
759
  3,
760
760
  ),
761
- ).toEqual({
761
+ ).toMatchObject({
762
762
  kind: "tool_result",
763
763
  seq: 3,
764
764
  payload: {
@@ -782,7 +782,7 @@ describe("createBotCordChannel — streamBlock()", () => {
782
782
  },
783
783
  4,
784
784
  ),
785
- ).toEqual({
785
+ ).toMatchObject({
786
786
  kind: "tool_call",
787
787
  seq: 4,
788
788
  payload: {
@@ -925,6 +925,211 @@ describe("createBotCordChannel — streamBlock()", () => {
925
925
  }
926
926
  });
927
927
 
928
+ it("normalizes current DeepSeek item.delta assistant text", async () => {
929
+ const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
930
+ const realFetch = globalThis.fetch;
931
+ globalThis.fetch = fetchSpy as unknown as typeof fetch;
932
+ try {
933
+ const client = makeClient({
934
+ getHubUrl: vi.fn().mockReturnValue("https://hub.example.com"),
935
+ });
936
+ const channel = createBotCordChannel({
937
+ id: "botcord-main",
938
+ accountId: "ag_self",
939
+ agentId: "ag_self",
940
+ client,
941
+ hubBaseUrl: "https://hub.example.com",
942
+ });
943
+ await channel.streamBlock!({
944
+ traceId: "m_trace",
945
+ accountId: "ag_self",
946
+ conversationId: "rm_oc_1",
947
+ block: {
948
+ kind: "assistant_text",
949
+ seq: 6,
950
+ raw: {
951
+ event: "item.delta",
952
+ payload: { thread_id: "thr_1", turn_id: "turn_1", kind: "agent_message", delta: "hello" },
953
+ },
954
+ },
955
+ log: silentLog,
956
+ });
957
+ const [, init] = fetchSpy.mock.calls[0];
958
+ const body = JSON.parse(init.body as string);
959
+ expect(body.block).toEqual({
960
+ kind: "assistant",
961
+ seq: 6,
962
+ payload: { text: "hello" },
963
+ });
964
+ } finally {
965
+ globalThis.fetch = realFetch;
966
+ }
967
+ });
968
+
969
+ it("normalizes current DeepSeek item.started tool input", () => {
970
+ expect(
971
+ __normalizeBlockForHubForTests(
972
+ {
973
+ kind: "tool_use",
974
+ seq: 7,
975
+ raw: {
976
+ event: "item.started",
977
+ payload: {
978
+ item: { id: "item_tool", kind: "tool_call", status: "in_progress" },
979
+ tool: { id: "call_1", name: "web_search", input: { query: "上海天气" } },
980
+ },
981
+ },
982
+ },
983
+ 7,
984
+ ),
985
+ ).toMatchObject({
986
+ kind: "tool_call",
987
+ seq: 7,
988
+ payload: {
989
+ id: "call_1",
990
+ name: "web_search",
991
+ params: { query: "上海天气" },
992
+ status: "in_progress",
993
+ },
994
+ raw: {
995
+ event: "item.started",
996
+ payload: {
997
+ item: { id: "item_tool", kind: "tool_call", status: "in_progress" },
998
+ tool: { id: "call_1", name: "web_search", input: { query: "上海天气" } },
999
+ },
1000
+ },
1001
+ });
1002
+ });
1003
+
1004
+ it("infers current DeepSeek tool input from item summary and detail", () => {
1005
+ expect(
1006
+ __normalizeBlockForHubForTests(
1007
+ {
1008
+ kind: "tool_use",
1009
+ seq: 8,
1010
+ raw: {
1011
+ event: "item.started",
1012
+ payload: {
1013
+ item: {
1014
+ id: "item_exec",
1015
+ kind: "tool_call",
1016
+ status: "in_progress",
1017
+ summary: "exec_shell started",
1018
+ detail: "{\"cmd\":\"botcord-daemon --version\"}",
1019
+ },
1020
+ },
1021
+ },
1022
+ },
1023
+ 8,
1024
+ ),
1025
+ ).toMatchObject({
1026
+ kind: "tool_call",
1027
+ seq: 8,
1028
+ payload: {
1029
+ id: "item_exec",
1030
+ name: "exec_shell",
1031
+ params: { cmd: "botcord-daemon --version" },
1032
+ status: "in_progress",
1033
+ },
1034
+ raw: {
1035
+ event: "item.started",
1036
+ payload: {
1037
+ item: {
1038
+ id: "item_exec",
1039
+ kind: "tool_call",
1040
+ status: "in_progress",
1041
+ summary: "exec_shell started",
1042
+ detail: "{\"cmd\":\"botcord-daemon --version\"}",
1043
+ },
1044
+ },
1045
+ },
1046
+ });
1047
+ });
1048
+
1049
+ it("infers current DeepSeek tool result name from item summary", () => {
1050
+ expect(
1051
+ __normalizeBlockForHubForTests(
1052
+ {
1053
+ kind: "tool_result",
1054
+ seq: 9,
1055
+ raw: {
1056
+ event: "item.completed",
1057
+ payload: {
1058
+ item: {
1059
+ id: "item_exec",
1060
+ kind: "tool_call",
1061
+ status: "completed",
1062
+ summary: "exec_shell: botcord-daemon 0.2.78",
1063
+ detail: "botcord-daemon 0.2.78",
1064
+ },
1065
+ },
1066
+ },
1067
+ },
1068
+ 9,
1069
+ ),
1070
+ ).toMatchObject({
1071
+ kind: "tool_result",
1072
+ seq: 9,
1073
+ payload: {
1074
+ name: "exec_shell",
1075
+ result: "botcord-daemon 0.2.78",
1076
+ tool_use_id: "item_exec",
1077
+ },
1078
+ raw: {
1079
+ event: "item.completed",
1080
+ payload: {
1081
+ item: {
1082
+ id: "item_exec",
1083
+ kind: "tool_call",
1084
+ status: "completed",
1085
+ summary: "exec_shell: botcord-daemon 0.2.78",
1086
+ detail: "botcord-daemon 0.2.78",
1087
+ },
1088
+ },
1089
+ },
1090
+ });
1091
+ });
1092
+
1093
+ it("normalizes current DeepSeek agent_reasoning details", () => {
1094
+ expect(
1095
+ __normalizeBlockForHubForTests(
1096
+ {
1097
+ kind: "thinking",
1098
+ seq: 8,
1099
+ raw: {
1100
+ event: "item.completed",
1101
+ payload: {
1102
+ item: {
1103
+ id: "item_reasoning",
1104
+ kind: "agent_reasoning",
1105
+ status: "completed",
1106
+ summary: "I should answer briefly.",
1107
+ detail: "I should answer briefly.",
1108
+ },
1109
+ },
1110
+ },
1111
+ },
1112
+ 8,
1113
+ ),
1114
+ ).toMatchObject({
1115
+ kind: "thinking",
1116
+ seq: 8,
1117
+ payload: { details: "I should answer briefly." },
1118
+ raw: {
1119
+ event: "item.completed",
1120
+ payload: {
1121
+ item: {
1122
+ id: "item_reasoning",
1123
+ kind: "agent_reasoning",
1124
+ status: "completed",
1125
+ summary: "I should answer briefly.",
1126
+ detail: "I should answer briefly.",
1127
+ },
1128
+ },
1129
+ },
1130
+ });
1131
+ });
1132
+
928
1133
  it("marks DeepSeek terminal events for owner-chat stream cleanup", async () => {
929
1134
  const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
930
1135
  const realFetch = globalThis.fetch;
@@ -993,10 +1198,11 @@ describe("createBotCordChannel — streamBlock()", () => {
993
1198
  log: silentLog,
994
1199
  });
995
1200
  const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string);
996
- expect(body.block).toEqual({
1201
+ expect(body.block).toMatchObject({
997
1202
  kind: "thinking",
998
1203
  seq: 7,
999
1204
  payload: { phase: "updated", label: "Searching web", source: "runtime", details: "Searching web" },
1205
+ raw: { phase: "updated", label: "Searching web", source: "runtime" },
1000
1206
  });
1001
1207
  } finally {
1002
1208
  globalThis.fetch = realFetch;