@botcord/daemon 0.2.85 → 0.2.87

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.
@@ -0,0 +1,130 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ agentCodexHomeDir,
7
+ agentWorkspaceDir,
8
+ } from "../agent-workspace.js";
9
+ import {
10
+ buildSoftSkillIndexPrompt,
11
+ collectAgentSkillSnapshot,
12
+ scanSoftSkills,
13
+ } from "../skill-index.js";
14
+
15
+ let tmpDir = "";
16
+ let prevHome: string | undefined;
17
+
18
+ function writeSkill(dir: string, name: string, description: string): void {
19
+ const skillDir = path.join(dir, name);
20
+ mkdirSync(skillDir, { recursive: true });
21
+ writeFileSync(
22
+ path.join(skillDir, "SKILL.md"),
23
+ `---\nname: ${name}\ndescription: "${description}"\n---\n\n# ${name}\n`,
24
+ );
25
+ }
26
+
27
+ beforeEach(() => {
28
+ tmpDir = mkdtempSync(path.join(tmpdir(), "skill-index-"));
29
+ prevHome = process.env.HOME;
30
+ process.env.HOME = tmpDir;
31
+ });
32
+
33
+ afterEach(() => {
34
+ if (prevHome === undefined) delete process.env.HOME;
35
+ else process.env.HOME = prevHome;
36
+ rmSync(tmpDir, { recursive: true, force: true });
37
+ });
38
+
39
+ describe("skill snapshots", () => {
40
+ it("scans agent workspace/runtime-global skills and maps UI source buckets", () => {
41
+ const agentId = "ag_skilltest";
42
+ writeSkill(path.join(agentWorkspaceDir(agentId), ".claude", "skills"), "workspace-skill", "Workspace skill");
43
+ writeSkill(path.join(agentCodexHomeDir(agentId), "skills"), "codex-skill", "Codex skill");
44
+ writeSkill(path.join(tmpDir, ".codex", "skills"), "global-skill", "Global skill");
45
+
46
+ const scanned = scanSoftSkills(agentId);
47
+ expect(scanned.map((s) => s.name).sort()).toEqual([
48
+ "codex-skill",
49
+ "global-skill",
50
+ "workspace-skill",
51
+ ]);
52
+
53
+ const snapshot = collectAgentSkillSnapshot(agentId);
54
+ expect(snapshot.agentId).toBe(agentId);
55
+ expect(snapshot.skills).toHaveLength(3);
56
+ expect(snapshot.skills.find((s) => s.name === "workspace-skill")?.source)
57
+ .toBe("workspace");
58
+ expect(snapshot.skills.find((s) => s.name === "codex-skill")?.source)
59
+ .toBe("workspace");
60
+ expect(snapshot.skills.find((s) => s.name === "global-skill")?.source)
61
+ .toBe("runtime-global");
62
+ expect(snapshot.probedAt).toBeGreaterThan(0);
63
+ });
64
+
65
+ it("scans Codex .system skills and prefers Codex copies for Codex agents", () => {
66
+ const agentId = "ag_codex_system";
67
+ writeSkill(path.join(agentWorkspaceDir(agentId), ".claude", "skills"), "shared", "Claude copy");
68
+ writeSkill(path.join(agentCodexHomeDir(agentId), "skills"), "shared", "Codex copy");
69
+ writeSkill(
70
+ path.join(agentCodexHomeDir(agentId), "skills", ".system"),
71
+ "agent-system",
72
+ "Agent Codex system skill",
73
+ );
74
+ writeSkill(
75
+ path.join(tmpDir, ".codex", "skills", ".system"),
76
+ "imagegen",
77
+ "Codex global system skill",
78
+ );
79
+
80
+ const codexScanned = scanSoftSkills(agentId, { runtime: "codex" });
81
+ expect(codexScanned.map((s) => s.name).sort()).toEqual([
82
+ "agent-system",
83
+ "imagegen",
84
+ "shared",
85
+ ]);
86
+ expect(codexScanned.find((s) => s.name === "shared")).toMatchObject({
87
+ source: "agent-codex",
88
+ description: "Codex copy",
89
+ });
90
+
91
+ const claudeScanned = scanSoftSkills(agentId, { runtime: "claude-code" });
92
+ expect(claudeScanned.find((s) => s.name === "shared")).toMatchObject({
93
+ source: "agent-claude",
94
+ description: "Claude copy",
95
+ });
96
+
97
+ const snapshot = collectAgentSkillSnapshot(agentId, { runtime: "codex" });
98
+ expect(snapshot.skills.find((s) => s.name === "agent-system")?.source)
99
+ .toBe("workspace");
100
+ expect(snapshot.skills.find((s) => s.name === "imagegen")?.source)
101
+ .toBe("runtime-global");
102
+ });
103
+
104
+ it("returns complete snapshots while keeping the prompt soft index capped", () => {
105
+ const agentId = "ag_manyskills";
106
+ const workspaceSkills = path.join(agentWorkspaceDir(agentId), ".claude", "skills");
107
+ for (let i = 0; i < 30; i += 1) {
108
+ const name = `skill-${String(i).padStart(2, "0")}`;
109
+ writeSkill(workspaceSkills, name, `Skill ${i}`);
110
+ }
111
+
112
+ const scanned = scanSoftSkills(agentId);
113
+ expect(scanned).toHaveLength(30);
114
+ expect(scanned.map((s) => s.name)).toEqual(
115
+ Array.from({ length: 30 }, (_, i) => `skill-${String(i).padStart(2, "0")}`),
116
+ );
117
+
118
+ const snapshot = collectAgentSkillSnapshot(agentId);
119
+ expect(snapshot.skills).toHaveLength(30);
120
+
121
+ const prompt = buildSoftSkillIndexPrompt(agentId);
122
+ expect(prompt).not.toBeNull();
123
+ const skillLines = prompt
124
+ ?.split("\n")
125
+ .filter((line) => line.startsWith("- skill-"));
126
+ expect(skillLines).toHaveLength(24);
127
+ expect(skillLines?.at(0)).toContain("skill-00");
128
+ expect(skillLines?.at(-1)).toContain("skill-23");
129
+ });
130
+ });
@@ -370,3 +370,124 @@ describe("composeBotCordUserTurn", () => {
370
370
  expect(headerLines.length).toBe(1);
371
371
  });
372
372
  });
373
+
374
+ describe("composeBotCordUserTurn quote-reply", () => {
375
+ it("inserts a [quoting …] line above the body when reply_preview is present", () => {
376
+ const out = composeBotCordUserTurn(
377
+ makeMessage({
378
+ text: "agreed, ship it",
379
+ sender: { id: "ag_alice", name: "Alice", kind: "agent" },
380
+ raw: {
381
+ reply_preview: {
382
+ msg_id: "h_orig",
383
+ sender_id: "ag_bob",
384
+ sender_display_name: "Bob",
385
+ text_preview: "We should ship the feature next sprint",
386
+ topic_id: null,
387
+ deleted: false,
388
+ },
389
+ },
390
+ }),
391
+ );
392
+ expect(out).toContain('<agent-message sender="ag_alice" sender_kind="agent">');
393
+ expect(out).toContain('[quoting Bob: "We should ship the feature next sprint"]');
394
+ expect(out).toContain("agreed, ship it");
395
+ // Quote line precedes body inside the tag block.
396
+ const quoteIdx = out.indexOf("[quoting Bob");
397
+ const bodyIdx = out.indexOf("agreed, ship it");
398
+ expect(quoteIdx).toBeGreaterThan(-1);
399
+ expect(quoteIdx).toBeLessThan(bodyIdx);
400
+ });
401
+
402
+ it("renders a tombstone line when the quote target was deleted", () => {
403
+ const out = composeBotCordUserTurn(
404
+ makeMessage({
405
+ text: "RE: that thing",
406
+ sender: { id: "ag_alice", kind: "agent" },
407
+ raw: {
408
+ reply_preview: {
409
+ msg_id: "h_gone",
410
+ sender_id: null,
411
+ sender_display_name: null,
412
+ text_preview: null,
413
+ topic_id: null,
414
+ deleted: true,
415
+ },
416
+ },
417
+ }),
418
+ );
419
+ expect(out).toContain("[quoting (deleted message)]");
420
+ expect(out).toContain("RE: that thing");
421
+ });
422
+
423
+ it("falls back to sender_id when display name is missing", () => {
424
+ const out = composeBotCordUserTurn(
425
+ makeMessage({
426
+ text: "ack",
427
+ sender: { id: "ag_alice", kind: "agent" },
428
+ raw: {
429
+ reply_preview: {
430
+ msg_id: "h_orig",
431
+ sender_id: "ag_bob",
432
+ sender_display_name: null,
433
+ text_preview: "hi",
434
+ topic_id: null,
435
+ deleted: false,
436
+ },
437
+ },
438
+ }),
439
+ );
440
+ expect(out).toContain('[quoting ag_bob: "hi"]');
441
+ });
442
+
443
+ it("emits no quote line when reply_preview is absent (regression guard)", () => {
444
+ const out = composeBotCordUserTurn(
445
+ makeMessage({
446
+ text: "just a normal message",
447
+ sender: { id: "ag_alice", kind: "agent" },
448
+ }),
449
+ );
450
+ expect(out).not.toContain("[quoting");
451
+ });
452
+
453
+ it("renders per-entry quote lines in a batched turn", () => {
454
+ const batchedRaw = {
455
+ batch: [
456
+ {
457
+ hub_msg_id: "h_1",
458
+ text: "first reply",
459
+ envelope: { from: "ag_alice", type: "message" },
460
+ source_type: "agent",
461
+ reply_preview: {
462
+ msg_id: "h_orig1",
463
+ sender_id: "ag_bob",
464
+ sender_display_name: "Bob",
465
+ text_preview: "the plan",
466
+ topic_id: null,
467
+ deleted: false,
468
+ },
469
+ },
470
+ {
471
+ hub_msg_id: "h_2",
472
+ text: "second reply (no quote)",
473
+ envelope: { from: "ag_alice", type: "message" },
474
+ source_type: "agent",
475
+ },
476
+ ],
477
+ };
478
+ const out = composeBotCordUserTurn(
479
+ makeMessage({
480
+ text: "ignored — batch path reads raw.batch",
481
+ sender: { id: "ag_alice", kind: "agent" },
482
+ raw: batchedRaw,
483
+ }),
484
+ );
485
+ expect(out).toContain("[BotCord Messages (2 new)]");
486
+ expect(out).toContain('[quoting Bob: "the plan"]');
487
+ expect(out).toContain("first reply");
488
+ expect(out).toContain("second reply (no quote)");
489
+ // The second entry has no quote line.
490
+ const quoteCount = (out.match(/\[quoting /g) || []).length;
491
+ expect(quoteCount).toBe(1);
492
+ });
493
+ });
@@ -27,7 +27,7 @@ import { ControlChannel } from "./control-channel.js";
27
27
  import { toGatewayConfig } from "./daemon-config-map.js";
28
28
  import { log as daemonLog } from "./log.js";
29
29
  import { createProvisioner } from "./provision.js";
30
- import { createDaemonChannel, pushRuntimeSnapshot } from "./daemon.js";
30
+ import { createDaemonChannel, pushAgentSkillSnapshot, pushRuntimeSnapshot } from "./daemon.js";
31
31
  import { SnapshotWriter } from "./snapshot-writer.js";
32
32
  import { createDaemonSystemContextBuilder } from "./system-context.js";
33
33
  import { readWorkingMemorySnapshot } from "./working-memory.js";
@@ -234,7 +234,24 @@ export async function startCloudDaemon(
234
234
  });
235
235
  };
236
236
 
237
+ const installedAgentIds = new Set<string>();
238
+ const runtimeByAgentId = new Map<string, string>();
239
+ let controlChannel: ControlChannel | null = null;
240
+ const pushInstalledAgentSkillSnapshot = (agentId: string, reason: string): void => {
241
+ if (!controlChannel) return;
242
+ const runtime = runtimeByAgentId.get(agentId) ?? opts.config.defaultRoute.adapter;
243
+ const pushed = pushAgentSkillSnapshot(controlChannel, agentId, { runtime });
244
+ logger.info("cloud control-channel: agent_skill_snapshot pushed", {
245
+ agentId,
246
+ runtime,
247
+ reason,
248
+ ok: pushed,
249
+ });
250
+ };
251
+
237
252
  const onAgentInstalled: OnAgentInstalledHook = (info: InstalledAgentInfo) => {
253
+ installedAgentIds.add(info.agentId);
254
+ if (info.runtime) runtimeByAgentId.set(info.agentId, info.runtime);
238
255
  credentialPathByAgentId.set(info.agentId, info.credentialsFile);
239
256
  if (info.hubUrl) hubUrlByAgentId.set(info.agentId, info.hubUrl);
240
257
  if (info.displayName) displayNameByAgent.set(info.agentId, info.displayName);
@@ -251,6 +268,7 @@ export async function startCloudDaemon(
251
268
  }),
252
269
  );
253
270
  }
271
+ pushInstalledAgentSkillSnapshot(info.agentId, "agent_installed");
254
272
  };
255
273
 
256
274
  const gateway = new Gateway({
@@ -281,7 +299,6 @@ export async function startCloudDaemon(
281
299
  await gateway.start();
282
300
  logger.info("cloud daemon gateway started (zero agents at boot)");
283
301
 
284
- let controlChannel: ControlChannel | null = null;
285
302
  if (!opts.disableControlChannel) {
286
303
  const auth = asUserAuthManager(new CloudAuthManager(cloudCfg));
287
304
  const provisionerFactory = opts.provisionerFactory ?? createProvisioner;
@@ -330,6 +347,9 @@ export async function startCloudDaemon(
330
347
  logger.info("cloud control-channel started; runtime_snapshot pushed", {
331
348
  ok: pushed,
332
349
  });
350
+ for (const agentId of installedAgentIds) {
351
+ pushInstalledAgentSkillSnapshot(agentId, "control_channel_started");
352
+ }
333
353
  } catch (err) {
334
354
  logger.warn("cloud control-channel start failed; daemon will retry", {
335
355
  error: err instanceof Error ? err.message : String(err),
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { PID_PATH } from "./config.js";
5
5
 
@@ -17,6 +17,18 @@ const noopLogger: SingletonLogger = {
17
17
  },
18
18
  };
19
19
 
20
+ const DEFAULT_LOCK_WAIT_MS = 15_000;
21
+ const DEFAULT_LOCK_RETRY_MS = 50;
22
+
23
+ export interface DaemonSingletonLock {
24
+ lockPath: string;
25
+ release(): void;
26
+ }
27
+
28
+ export function defaultLockPath(pidPath = PID_PATH): string {
29
+ return `${pidPath}.lock`;
30
+ }
31
+
20
32
  export function readPid(pidPath = PID_PATH): number | null {
21
33
  if (!existsSync(pidPath)) return null;
22
34
  const raw = readFileSync(pidPath, "utf8").trim();
@@ -24,6 +36,10 @@ export function readPid(pidPath = PID_PATH): number | null {
24
36
  return Number.isFinite(pid) && pid > 0 ? pid : null;
25
37
  }
26
38
 
39
+ function readLockOwner(lockPath: string): number | null {
40
+ return readPid(path.join(lockPath, "owner.pid"));
41
+ }
42
+
27
43
  export function pidAlive(pid: number): boolean {
28
44
  try {
29
45
  process.kill(pid, 0);
@@ -127,6 +143,78 @@ export async function stopDaemonFromPidFileForRestart(
127
143
  }
128
144
  }
129
145
 
146
+ export async function acquireDaemonSingletonLock(
147
+ opts: {
148
+ lockPath?: string;
149
+ pidPath?: string;
150
+ currentPid?: number;
151
+ logger?: SingletonLogger;
152
+ timeoutMs?: number;
153
+ } = {},
154
+ ): Promise<DaemonSingletonLock> {
155
+ const pidPath = opts.pidPath ?? PID_PATH;
156
+ const lockPath = opts.lockPath ?? defaultLockPath(pidPath);
157
+ const currentPid = opts.currentPid ?? process.pid;
158
+ const logger = opts.logger ?? noopLogger;
159
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_LOCK_WAIT_MS;
160
+ const deadline = Date.now() + timeoutMs;
161
+
162
+ ensureParentDir(lockPath);
163
+ while (true) {
164
+ try {
165
+ mkdirSync(lockPath, { mode: 0o700 });
166
+ writeFileSync(path.join(lockPath, "owner.pid"), String(currentPid), { mode: 0o600 });
167
+ return {
168
+ lockPath,
169
+ release() {
170
+ const owner = readLockOwner(lockPath);
171
+ if (owner !== null && owner !== currentPid) return;
172
+ try {
173
+ rmSync(lockPath, { recursive: true, force: true });
174
+ } catch {
175
+ // ignore
176
+ }
177
+ },
178
+ };
179
+ } catch (err) {
180
+ const code = (err as NodeJS.ErrnoException).code;
181
+ if (code !== "EEXIST") throw err;
182
+ }
183
+
184
+ const owner = readLockOwner(lockPath);
185
+ if (owner === currentPid) {
186
+ return {
187
+ lockPath,
188
+ release() {
189
+ try {
190
+ rmSync(lockPath, { recursive: true, force: true });
191
+ } catch {
192
+ // ignore
193
+ }
194
+ },
195
+ };
196
+ }
197
+ if (owner !== null && pidAlive(owner)) {
198
+ logger.info("daemon singleton lock owner found; restarting", { pid: owner });
199
+ await stopExistingDaemonForRestart(owner, { pidPath, currentPid, logger });
200
+ }
201
+
202
+ const refreshedOwner = readLockOwner(lockPath);
203
+ if (refreshedOwner === null || !pidAlive(refreshedOwner)) {
204
+ try {
205
+ rmSync(lockPath, { recursive: true, force: true });
206
+ } catch {
207
+ // another starter may have removed/recreated it
208
+ }
209
+ }
210
+
211
+ if (Date.now() >= deadline) {
212
+ throw new Error(`timed out acquiring daemon singleton lock at ${lockPath}`);
213
+ }
214
+ await delay(DEFAULT_LOCK_RETRY_MS);
215
+ }
216
+ }
217
+
130
218
  export async function stopOtherDaemonProcessesForRestart(
131
219
  opts: {
132
220
  currentPid?: number;
@@ -187,11 +275,7 @@ export function writeCurrentPid(
187
275
  // Cloud-mode startup writes the PID file before `saveConfig` runs, so
188
276
  // the daemon dir may not exist yet. mkdir its parent (0700) so the
189
277
  // first write doesn't crash with ENOENT.
190
- try {
191
- mkdirSync(path.dirname(pidPath), { recursive: true, mode: 0o700 });
192
- } catch {
193
- // best-effort — writeFileSync below will surface the real error
194
- }
278
+ ensureParentDir(pidPath);
195
279
  writeFileSync(pidPath, String(opts.currentPid ?? process.pid), { mode: 0o600 });
196
280
  }
197
281
 
@@ -231,3 +315,11 @@ export function isBotCordDaemonStartCommand(command: string): boolean {
231
315
  function delay(ms: number): Promise<void> {
232
316
  return new Promise((resolve) => setTimeout(resolve, ms));
233
317
  }
318
+
319
+ function ensureParentDir(filePath: string): void {
320
+ try {
321
+ mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
322
+ } catch {
323
+ // best-effort — the next filesystem operation will surface real errors
324
+ }
325
+ }
package/src/daemon.ts CHANGED
@@ -52,6 +52,7 @@ import { PolicyResolver, type DaemonAttentionPolicy } from "./gateway/policy-res
52
52
  import { scanMention } from "./mention-scan.js";
53
53
  import { createDiagnosticBundle, uploadDiagnosticBundle } from "./diagnostics.js";
54
54
  import { createAttentionPolicyFetcher } from "./attention-policy-fetcher.js";
55
+ import { collectAgentSkillSnapshot } from "./skill-index.js";
55
56
 
56
57
  /**
57
58
  * Default hard cap for a single runtime turn. Long-running coding/research
@@ -245,6 +246,27 @@ export function pushRuntimeSnapshot(
245
246
  return ok;
246
247
  }
247
248
 
249
+ export function pushAgentSkillSnapshot(
250
+ sink: RuntimeSnapshotSink,
251
+ agentId: string,
252
+ opts: { runtime?: string } = {},
253
+ ): boolean {
254
+ const snap = collectAgentSkillSnapshot(agentId, opts);
255
+ const ok = sink.send({
256
+ id: `skill_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
257
+ type: "agent_skill_snapshot",
258
+ params: snap as unknown as Record<string, unknown>,
259
+ ts: Date.now(),
260
+ });
261
+ if (!ok) {
262
+ daemonLog.warn("agent-skill-snapshot: control-channel send returned false", {
263
+ agentId,
264
+ skills: snap.skills.length,
265
+ });
266
+ }
267
+ return ok;
268
+ }
269
+
248
270
  /** Options accepted by {@link startDaemon} — the P0.5 compatibility shim. */
249
271
  export interface DaemonRuntimeOptions {
250
272
  config: DaemonConfig;
@@ -508,6 +530,12 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
508
530
  // next room-context fetch re-loads the BotCordClient against the new
509
531
  // credential file.
510
532
  credentialPathByAgentId.set(info.agentId, info.credentialsFile);
533
+ if (info.runtime) {
534
+ agentRuntimes[info.agentId] = {
535
+ ...(agentRuntimes[info.agentId] ?? {}),
536
+ runtime: info.runtime,
537
+ };
538
+ }
511
539
  if (info.hubUrl) hubUrlByAgentId.set(info.agentId, info.hubUrl);
512
540
  if (info.displayName) displayNameByAgent.set(info.agentId, info.displayName);
513
541
  if (!scBuilders.has(info.agentId)) {
@@ -648,6 +676,15 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
648
676
  logger.info("control-channel: initial runtime_snapshot push", {
649
677
  ok: pushed,
650
678
  });
679
+ for (const agentId of agentIds) {
680
+ const runtime = agentRuntimes[agentId]?.runtime ?? opts.config.defaultRoute.adapter;
681
+ const skillsPushed = pushAgentSkillSnapshot(controlChannel, agentId, { runtime });
682
+ logger.info("control-channel: initial agent_skill_snapshot push", {
683
+ agentId,
684
+ runtime,
685
+ ok: skillsPushed,
686
+ });
687
+ }
651
688
  } catch (err) {
652
689
  logger.warn("control-channel failed to start; continuing without it", {
653
690
  error: err instanceof Error ? err.message : String(err),
@@ -793,6 +793,85 @@ describe("createBotCordChannel — streamBlock()", () => {
793
793
  });
794
794
  });
795
795
 
796
+ it("normalizes wrapped DeepSeek item.started tool input from the runtime event stream", () => {
797
+ expect(
798
+ __normalizeBlockForHubForTests(
799
+ {
800
+ kind: "tool_use",
801
+ seq: 5,
802
+ raw: {
803
+ event: "item.started",
804
+ payload: {
805
+ seq: 922,
806
+ thread_id: "thr_test",
807
+ turn_id: "turn_test",
808
+ item_id: "item_exec",
809
+ event: "item.started",
810
+ payload: {
811
+ item: {
812
+ id: "item_exec",
813
+ kind: "tool_call",
814
+ status: "in_progress",
815
+ summary: "exec_shell started",
816
+ detail: "{\"cmd\":\"botcord-daemon status\"}",
817
+ },
818
+ },
819
+ },
820
+ },
821
+ },
822
+ 5,
823
+ ),
824
+ ).toMatchObject({
825
+ kind: "tool_call",
826
+ seq: 5,
827
+ payload: {
828
+ id: "item_exec",
829
+ name: "exec_shell",
830
+ params: { cmd: "botcord-daemon status" },
831
+ status: "in_progress",
832
+ },
833
+ });
834
+ });
835
+
836
+ it("normalizes wrapped DeepSeek item.completed output without showing the event envelope", () => {
837
+ expect(
838
+ __normalizeBlockForHubForTests(
839
+ {
840
+ kind: "tool_result",
841
+ seq: 6,
842
+ raw: {
843
+ event: "item.completed",
844
+ payload: {
845
+ seq: 955,
846
+ thread_id: "thr_test",
847
+ turn_id: "turn_test",
848
+ item_id: "item_exec",
849
+ event: "item.completed",
850
+ payload: {
851
+ item: {
852
+ id: "item_exec",
853
+ kind: "command_execution",
854
+ status: "completed",
855
+ summary: "exec_shell: daemon: pid 49616",
856
+ detail: "daemon: pid 49616 (alive)",
857
+ },
858
+ },
859
+ },
860
+ },
861
+ },
862
+ 6,
863
+ ),
864
+ ).toMatchObject({
865
+ kind: "tool_result",
866
+ seq: 6,
867
+ payload: {
868
+ name: "exec_shell",
869
+ result: "daemon: pid 49616 (alive)",
870
+ tool_use_id: "item_exec",
871
+ },
872
+ });
873
+ });
874
+
796
875
  it("POSTs to /hub/stream-block with the right trace_id + block", async () => {
797
876
  const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
798
877
  const realFetch = globalThis.fetch;
@@ -353,6 +353,55 @@ describe("DeepseekTuiAdapter", () => {
353
353
  }
354
354
  });
355
355
 
356
+ it("treats DeepSeek command_execution item.started events as tool blocks", async () => {
357
+ const server = await startMockDeepseekServer({
358
+ events: [
359
+ {
360
+ event: "turn.started",
361
+ data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.started" },
362
+ },
363
+ {
364
+ event: "item.started",
365
+ data: {
366
+ thread_id: "thr_test",
367
+ turn_id: "turn_test",
368
+ event: "item.started",
369
+ payload: {
370
+ item: {
371
+ id: "item_exec",
372
+ kind: "command_execution",
373
+ status: "in_progress",
374
+ summary: "exec_shell started",
375
+ detail: "{\"cmd\":\"date\"}",
376
+ },
377
+ },
378
+ },
379
+ },
380
+ {
381
+ event: "item.delta",
382
+ data: {
383
+ thread_id: "thr_test",
384
+ turn_id: "turn_test",
385
+ event: "item.delta",
386
+ payload: { kind: "agent_message", delta: "done" },
387
+ },
388
+ },
389
+ {
390
+ event: "turn.completed",
391
+ data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.completed" },
392
+ },
393
+ ],
394
+ });
395
+ try {
396
+ const { result, blocks, status } = runAdapter(server.baseUrl, server.token);
397
+ await expect(result).resolves.toMatchObject({ text: "done" });
398
+ expect(blocks).toEqual(expect.arrayContaining(["tool_use", "assistant_text"]));
399
+ expect(status).toContainEqual({ phase: "updated", label: "exec_shell" });
400
+ } finally {
401
+ await server.close();
402
+ }
403
+ });
404
+
356
405
  it("emits current DeepSeek agent_reasoning completions as thinking blocks", async () => {
357
406
  const server = await startMockDeepseekServer({
358
407
  events: [