@botcord/daemon 0.2.5 → 0.2.8

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 (88) hide show
  1. package/dist/agent-discovery.d.ts +4 -0
  2. package/dist/agent-discovery.js +8 -0
  3. package/dist/agent-workspace.d.ts +62 -0
  4. package/dist/agent-workspace.js +140 -8
  5. package/dist/config.d.ts +64 -1
  6. package/dist/config.js +73 -1
  7. package/dist/daemon-config-map.d.ts +27 -9
  8. package/dist/daemon-config-map.js +105 -8
  9. package/dist/daemon.d.ts +2 -0
  10. package/dist/daemon.js +76 -6
  11. package/dist/doctor.d.ts +27 -1
  12. package/dist/doctor.js +22 -1
  13. package/dist/gateway/cli-resolver.d.ts +34 -0
  14. package/dist/gateway/cli-resolver.js +74 -0
  15. package/dist/gateway/dispatcher.d.ts +31 -1
  16. package/dist/gateway/dispatcher.js +337 -29
  17. package/dist/gateway/gateway.d.ts +29 -1
  18. package/dist/gateway/gateway.js +10 -0
  19. package/dist/gateway/index.d.ts +2 -0
  20. package/dist/gateway/index.js +2 -0
  21. package/dist/gateway/policy-resolver.d.ts +57 -0
  22. package/dist/gateway/policy-resolver.js +123 -0
  23. package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
  24. package/dist/gateway/runtimes/acp-stream.js +394 -0
  25. package/dist/gateway/runtimes/codex.js +7 -0
  26. package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
  27. package/dist/gateway/runtimes/hermes-agent.js +180 -0
  28. package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
  29. package/dist/gateway/runtimes/ndjson-stream.js +16 -3
  30. package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
  31. package/dist/gateway/runtimes/openclaw-acp.js +500 -0
  32. package/dist/gateway/runtimes/registry.d.ts +4 -0
  33. package/dist/gateway/runtimes/registry.js +22 -0
  34. package/dist/gateway/transcript-paths.d.ts +30 -0
  35. package/dist/gateway/transcript-paths.js +114 -0
  36. package/dist/gateway/transcript.d.ts +123 -0
  37. package/dist/gateway/transcript.js +147 -0
  38. package/dist/gateway/types.d.ts +31 -0
  39. package/dist/index.js +309 -27
  40. package/dist/mention-scan.d.ts +22 -0
  41. package/dist/mention-scan.js +35 -0
  42. package/dist/openclaw-discovery.d.ts +28 -0
  43. package/dist/openclaw-discovery.js +228 -0
  44. package/dist/provision.d.ts +113 -1
  45. package/dist/provision.js +564 -12
  46. package/dist/system-context.d.ts +5 -4
  47. package/dist/system-context.js +35 -5
  48. package/dist/url-utils.d.ts +9 -0
  49. package/dist/url-utils.js +18 -0
  50. package/package.json +3 -2
  51. package/src/__tests__/agent-workspace.test.ts +93 -0
  52. package/src/__tests__/daemon-config-map.test.ts +79 -0
  53. package/src/__tests__/openclaw-acp.test.ts +234 -0
  54. package/src/__tests__/openclaw-discovery.test.ts +150 -0
  55. package/src/__tests__/policy-resolver.test.ts +124 -0
  56. package/src/__tests__/policy-updated-handler.test.ts +144 -0
  57. package/src/__tests__/provision.test.ts +265 -0
  58. package/src/__tests__/system-context.test.ts +52 -0
  59. package/src/__tests__/url-utils.test.ts +37 -0
  60. package/src/agent-discovery.ts +8 -0
  61. package/src/agent-workspace.ts +173 -7
  62. package/src/config.ts +168 -4
  63. package/src/daemon-config-map.ts +154 -9
  64. package/src/daemon.ts +96 -6
  65. package/src/doctor.ts +49 -2
  66. package/src/gateway/__tests__/dispatcher.test.ts +65 -0
  67. package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
  68. package/src/gateway/__tests__/transcript.test.ts +496 -0
  69. package/src/gateway/cli-resolver.ts +92 -0
  70. package/src/gateway/dispatcher.ts +394 -26
  71. package/src/gateway/gateway.ts +46 -0
  72. package/src/gateway/index.ts +25 -0
  73. package/src/gateway/policy-resolver.ts +171 -0
  74. package/src/gateway/runtimes/acp-stream.ts +535 -0
  75. package/src/gateway/runtimes/codex.ts +7 -0
  76. package/src/gateway/runtimes/hermes-agent.ts +206 -0
  77. package/src/gateway/runtimes/ndjson-stream.ts +16 -3
  78. package/src/gateway/runtimes/openclaw-acp.ts +606 -0
  79. package/src/gateway/runtimes/registry.ts +24 -0
  80. package/src/gateway/transcript-paths.ts +145 -0
  81. package/src/gateway/transcript.ts +300 -0
  82. package/src/gateway/types.ts +32 -0
  83. package/src/index.ts +321 -30
  84. package/src/mention-scan.ts +38 -0
  85. package/src/openclaw-discovery.ts +262 -0
  86. package/src/provision.ts +682 -14
  87. package/src/system-context.ts +41 -9
  88. package/src/url-utils.ts +17 -0
@@ -6,10 +6,11 @@
6
6
  * `RuntimeRunOptions.systemContext`. This module composes the daemon's
7
7
  * system-context string from:
8
8
  *
9
- * 1. `[BotCord Scene: Owner Chat]` (owner-trust turns only)
10
- * 2. `[BotCord Working Memory]`
11
- * 3. `[BotCord Room Context]` (group rooms, via optional async fetcher)
12
- * 4. `[BotCord Cross-Room Awareness]` (optional activity tracker)
9
+ * 1. `[BotCord Identity]` (read fresh from workspace/identity.md each turn)
10
+ * 2. `[BotCord Scene: Owner Chat]` (owner-trust turns only)
11
+ * 3. `[BotCord Working Memory]`
12
+ * 4. `[BotCord Room Context]` (group rooms, via optional async fetcher)
13
+ * 5. `[BotCord Cross-Room Awareness]` (optional activity tracker)
13
14
  *
14
15
  * Behavior:
15
16
  * - Working memory is loaded fresh per turn, so a `memory set` from another
@@ -1,5 +1,6 @@
1
1
  import { buildCrossRoomDigest } from "./cross-room.js";
2
2
  import { buildWorkingMemoryPrompt, readWorkingMemory } from "./working-memory.js";
3
+ import { readIdentity } from "./agent-workspace.js";
3
4
  import { classifyActivitySender } from "./sender-classify.js";
4
5
  import { log } from "./log.js";
5
6
  /**
@@ -25,6 +26,32 @@ function safeReadWorkingMemory(agentId) {
25
26
  return null;
26
27
  }
27
28
  }
29
+ /**
30
+ * Read identity.md and wrap it as a system-context block. Placed before
31
+ * every other block so the agent answers "who are you" from this file
32
+ * rather than from the underlying CLI's default persona ("I am Claude
33
+ * Code"). Re-read every turn so dashboard reconcile (`applyAgentIdentity`)
34
+ * and self-edits take effect immediately, mirroring working-memory
35
+ * semantics.
36
+ */
37
+ function buildIdentityPrompt(agentId) {
38
+ let raw = null;
39
+ try {
40
+ raw = readIdentity(agentId);
41
+ }
42
+ catch (err) {
43
+ log.warn("identity read failed", { agentId, err: String(err) });
44
+ return null;
45
+ }
46
+ if (!raw)
47
+ return null;
48
+ return [
49
+ "[BotCord Identity]",
50
+ "Your persistent identity card. The fields below are the source of truth — when asked who you are, what you do, or what you will / will not do, answer from this block, not from the underlying CLI's default persona.",
51
+ "",
52
+ raw.trim(),
53
+ ].join("\n");
54
+ }
28
55
  /**
29
56
  * Build a {@link SystemContextBuilder} for the gateway dispatcher.
30
57
  *
@@ -34,6 +61,7 @@ function safeReadWorkingMemory(agentId) {
34
61
  */
35
62
  export function createDaemonSystemContextBuilder(deps) {
36
63
  const gatherSyncBlocks = (message) => {
64
+ const identity = buildIdentityPrompt(deps.agentId);
37
65
  const ownerScene = classifyActivitySender(message).kind === "owner"
38
66
  ? buildOwnerChatSceneContext()
39
67
  : null;
@@ -47,7 +75,7 @@ export function createDaemonSystemContextBuilder(deps) {
47
75
  currentTopic: message.conversation.threadId ?? null,
48
76
  }) || null
49
77
  : null;
50
- return { ownerScene, memory, digest };
78
+ return { identity, ownerScene, memory, digest };
51
79
  };
52
80
  const assemble = (parts) => {
53
81
  const filtered = parts.filter((p) => typeof p === "string" && p.length > 0);
@@ -70,11 +98,12 @@ export function createDaemonSystemContextBuilder(deps) {
70
98
  };
71
99
  if (!deps.roomContextBuilder) {
72
100
  const syncBuilder = (message) => {
73
- const { ownerScene, memory, digest } = gatherSyncBlocks(message);
101
+ const { identity, ownerScene, memory, digest } = gatherSyncBlocks(message);
74
102
  // Loop-risk sits at the end so its "reply NO_REPLY unless…" guidance
75
103
  // is the last thing the model sees before the user turn body.
104
+ // Identity sits at the very front so it frames every other block.
76
105
  const loopRisk = runLoopRisk(message);
77
- return assemble([ownerScene, memory, digest, loopRisk]);
106
+ return assemble([identity, ownerScene, memory, digest, loopRisk]);
78
107
  };
79
108
  // Compile-time witness that the narrower sync signature still satisfies
80
109
  // `SystemContextBuilder` (which allows async). Prevents the two contracts
@@ -85,11 +114,12 @@ export function createDaemonSystemContextBuilder(deps) {
85
114
  }
86
115
  const roomBuilder = deps.roomContextBuilder;
87
116
  const asyncBuilder = async (message) => {
88
- const { ownerScene, memory, digest } = gatherSyncBlocks(message);
117
+ const { identity, ownerScene, memory, digest } = gatherSyncBlocks(message);
89
118
  // Room context landing order: after owner-scene / memory, before digest —
90
119
  // "what room am I in" belongs with the session's own identity, while the
91
120
  // cross-room digest deliberately describes OTHER rooms and should stay
92
121
  // last so it doesn't get confused with the current room.
122
+ // Identity stays at the very front; see syncBuilder for rationale.
93
123
  let roomBlock = null;
94
124
  try {
95
125
  roomBlock = await roomBuilder(message);
@@ -102,7 +132,7 @@ export function createDaemonSystemContextBuilder(deps) {
102
132
  });
103
133
  }
104
134
  const loopRisk = runLoopRisk(message);
105
- return assemble([ownerScene, memory, roomBlock, digest, loopRisk]);
135
+ return assemble([identity, ownerScene, memory, roomBlock, digest, loopRisk]);
106
136
  };
107
137
  const _typecheck = asyncBuilder;
108
138
  void _typecheck;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Append a `next` query param to a URL. Used by the device-code flow to
3
+ * encode a post-auth redirect target into the Hub-issued verification URL,
4
+ * so the dashboard knows where to send the user after they click Authorize.
5
+ *
6
+ * Falls back to returning the original URL string if parsing fails — the
7
+ * device-code flow keeps working, just without the redirect convenience.
8
+ */
9
+ export declare function appendNextParam(url: string, next: string): string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Append a `next` query param to a URL. Used by the device-code flow to
3
+ * encode a post-auth redirect target into the Hub-issued verification URL,
4
+ * so the dashboard knows where to send the user after they click Authorize.
5
+ *
6
+ * Falls back to returning the original URL string if parsing fails — the
7
+ * device-code flow keeps working, just without the redirect convenience.
8
+ */
9
+ export function appendNextParam(url, next) {
10
+ try {
11
+ const u = new URL(url);
12
+ u.searchParams.set("next", next);
13
+ return u.toString();
14
+ }
15
+ catch {
16
+ return url;
17
+ }
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.5",
3
+ "version": "0.2.8",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,8 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@botcord/protocol-core": "^0.2.0",
30
+ "@botcord/cli": "^0.1.7",
31
+ "@botcord/protocol-core": "^0.2.2",
31
32
  "ws": "^8.18.0"
32
33
  },
33
34
  "devDependencies": {
@@ -15,6 +15,7 @@ import {
15
15
  agentHomeDir,
16
16
  agentStateDir,
17
17
  agentWorkspaceDir,
18
+ applyAgentIdentity,
18
19
  ensureAgentWorkspace,
19
20
  } from "../agent-workspace.js";
20
21
 
@@ -134,6 +135,98 @@ describe("ensureAgentWorkspace", () => {
134
135
  });
135
136
  });
136
137
 
138
+ describe("applyAgentIdentity", () => {
139
+ it("rewrites display name + bio while preserving Role/Boundaries", () => {
140
+ ensureAgentWorkspace("ag_edit", {
141
+ displayName: "Old",
142
+ bio: "Old bio",
143
+ runtime: "claude-code",
144
+ });
145
+ const identityPath = path.join(agentWorkspaceDir("ag_edit"), "identity.md");
146
+ const original = readFileSync(identityPath, "utf8");
147
+ // User personalises Role/Boundaries — must survive identity sync.
148
+ const customised = original
149
+ .replace("_(Describe what you do and for whom. Edit this section.)_", "I write poetry.")
150
+ .replace("_(What you will and will not do. Edit this section.)_", "No financial advice.");
151
+ writeFileSync(identityPath, customised);
152
+
153
+ const result = applyAgentIdentity("ag_edit", {
154
+ displayName: "New Name",
155
+ bio: "Refreshed bio.",
156
+ });
157
+ expect(result.changed).toBe(true);
158
+
159
+ const updated = readFileSync(identityPath, "utf8");
160
+ expect(updated).toContain("- **Display name**: New Name");
161
+ expect(updated).toContain("Refreshed bio.");
162
+ expect(updated).not.toContain("Old bio");
163
+ expect(updated).toContain("I write poetry.");
164
+ expect(updated).toContain("No financial advice.");
165
+ });
166
+
167
+ it("clears bio back to placeholder when null is passed", () => {
168
+ ensureAgentWorkspace("ag_clearbio", { bio: "Some bio" });
169
+ const result = applyAgentIdentity("ag_clearbio", { bio: null });
170
+ expect(result.changed).toBe(true);
171
+ const updated = readFileSync(
172
+ path.join(agentWorkspaceDir("ag_clearbio"), "identity.md"),
173
+ "utf8",
174
+ );
175
+ expect(updated).not.toContain("Some bio");
176
+ expect(updated).toContain("_(none provided at provision time");
177
+ });
178
+
179
+ it("returns no-change when patch matches current values", () => {
180
+ ensureAgentWorkspace("ag_idempotent", { displayName: "Same", bio: "Same bio" });
181
+ const result = applyAgentIdentity("ag_idempotent", {
182
+ displayName: "Same",
183
+ bio: "Same bio",
184
+ });
185
+ expect(result.changed).toBe(false);
186
+ expect(result.skipped).toBe("no-change");
187
+ });
188
+
189
+ it("skips when identity.md is missing", () => {
190
+ const result = applyAgentIdentity("ag_missing", { displayName: "X" });
191
+ expect(result.changed).toBe(false);
192
+ expect(result.skipped).toBe("missing-file");
193
+ });
194
+
195
+ it("rewrites correctly when identity.md has no trailing sections after Bio", () => {
196
+ ensureAgentWorkspace("ag_eofbio", { displayName: "Old", bio: "Old bio" });
197
+ const identityPath = path.join(agentWorkspaceDir("ag_eofbio"), "identity.md");
198
+ // Strip everything after `## Bio` so the Bio section runs to EOF.
199
+ const truncated =
200
+ readFileSync(identityPath, "utf8").replace(/(## Bio\n\nOld bio)[\s\S]*$/, "$1\n");
201
+ writeFileSync(identityPath, truncated);
202
+
203
+ const result = applyAgentIdentity("ag_eofbio", { bio: "New bio" });
204
+ expect(result.changed).toBe(true);
205
+ const updated = readFileSync(identityPath, "utf8");
206
+ expect(updated).toContain("New bio");
207
+ expect(updated).not.toContain("Old bio");
208
+ });
209
+
210
+ it("returns unparseable when the canonical metadata header is missing", () => {
211
+ ensureAgentWorkspace("ag_corrupt", {});
212
+ const identityPath = path.join(agentWorkspaceDir("ag_corrupt"), "identity.md");
213
+ writeFileSync(identityPath, "# Identity\n\nThis file was rewritten by a user.\n");
214
+
215
+ const result = applyAgentIdentity("ag_corrupt", { displayName: "X" });
216
+ expect(result.changed).toBe(false);
217
+ expect(result.skipped).toBe("unparseable");
218
+ });
219
+
220
+ it("treats display names containing regex specials literally", () => {
221
+ ensureAgentWorkspace("ag_specials", { displayName: "old" });
222
+ const identityPath = path.join(agentWorkspaceDir("ag_specials"), "identity.md");
223
+ const result = applyAgentIdentity("ag_specials", { displayName: "$1 backref $&" });
224
+ expect(result.changed).toBe(true);
225
+ const updated = readFileSync(identityPath, "utf8");
226
+ expect(updated).toContain("- **Display name**: $1 backref $&");
227
+ });
228
+ });
229
+
137
230
  it("tightens perms on a pre-existing agent home with looser mode", () => {
138
231
  // Simulate a home dir created by an older daemon with mode 0o755.
139
232
  const home = agentHomeDir("ag_upgrade");
@@ -400,6 +400,27 @@ describe("buildManagedRoutes", () => {
400
400
  });
401
401
  });
402
402
 
403
+ it("inherits defaultRoute.extraArgs (e.g. --permission-mode bypassPermissions)", () => {
404
+ const withExtraArgs: GatewayRoute = {
405
+ runtime: "claude-code",
406
+ cwd: "/home/default",
407
+ extraArgs: ["--permission-mode", "bypassPermissions"],
408
+ };
409
+ const map = buildManagedRoutes(["ag_one"], {}, withExtraArgs);
410
+ expect(map.get("ag_one")?.extraArgs).toEqual([
411
+ "--permission-mode",
412
+ "bypassPermissions",
413
+ ]);
414
+ // Ensure it's a copy, not the same reference — caller should not be able
415
+ // to mutate the defaultRoute by editing a managed route's extraArgs.
416
+ expect(map.get("ag_one")?.extraArgs).not.toBe(withExtraArgs.extraArgs);
417
+ });
418
+
419
+ it("omits extraArgs when defaultRoute has none", () => {
420
+ const map = buildManagedRoutes(["ag_one"], {}, defaultRoute);
421
+ expect(map.get("ag_one")).not.toHaveProperty("extraArgs");
422
+ });
423
+
403
424
  it("preserves agentIds insertion order in the returned map", () => {
404
425
  const map = buildManagedRoutes(
405
426
  ["ag_b", "ag_a", "ag_c"],
@@ -414,3 +435,61 @@ describe("buildManagedRoutes", () => {
414
435
  expect(map.size).toBe(0);
415
436
  });
416
437
  });
438
+
439
+ describe("openclawGateways resolution", () => {
440
+ it("resolves a route gateway profile name into ResolvedOpenclawGateway", () => {
441
+ const cfg = baseConfig({
442
+ defaultRoute: { adapter: "claude-code", cwd: "/home/alice" },
443
+ openclawGateways: [
444
+ { name: "local", url: "ws://127.0.0.1:1", token: "t1", defaultAgent: "main" },
445
+ ],
446
+ routes: [
447
+ { match: { conversationId: "rm_x" }, adapter: "openclaw-acp", cwd: "/home/alice", gateway: "local" },
448
+ ],
449
+ });
450
+ const gw = toGatewayConfig(cfg);
451
+ expect(gw.routes[0].gateway).toEqual({
452
+ name: "local",
453
+ url: "ws://127.0.0.1:1",
454
+ token: "t1",
455
+ openclawAgent: "main",
456
+ });
457
+ });
458
+
459
+ it("route.openclawAgent overrides profile.defaultAgent", () => {
460
+ const cfg = baseConfig({
461
+ openclawGateways: [{ name: "p1", url: "ws://x", defaultAgent: "main" }],
462
+ routes: [{ match: {}, adapter: "openclaw-acp", cwd: "/home/alice", gateway: "p1", openclawAgent: "design" }],
463
+ });
464
+ const gw = toGatewayConfig(cfg);
465
+ expect(gw.routes[0].gateway?.openclawAgent).toBe("design");
466
+ });
467
+
468
+ it("buildManagedRoutes uses credentials openclawGateway / openclawAgent", () => {
469
+ const cfg = baseConfig({
470
+ agents: ["ag_one"],
471
+ openclawGateways: [{ name: "p1", url: "ws://x", defaultAgent: "main" }],
472
+ });
473
+ const gw = toGatewayConfig(cfg, {
474
+ agentIds: ["ag_one"],
475
+ agentRuntimes: { ag_one: { runtime: "openclaw-acp", openclawGateway: "p1", openclawAgent: "qa" } },
476
+ });
477
+ const managed = gw.managedRoutes?.find((r) => r.match?.accountId === "ag_one");
478
+ expect(managed?.runtime).toBe("openclaw-acp");
479
+ expect(managed?.gateway?.name).toBe("p1");
480
+ expect(managed?.gateway?.openclawAgent).toBe("qa");
481
+ });
482
+
483
+ it("buildManagedRoutes skips an openclaw-acp managed route when its gateway is unknown", () => {
484
+ const cfg = baseConfig({
485
+ agents: ["ag_one"],
486
+ openclawGateways: [{ name: "p1", url: "ws://x" }],
487
+ });
488
+ const gw = toGatewayConfig(cfg, {
489
+ agentIds: ["ag_one"],
490
+ agentRuntimes: { ag_one: { runtime: "openclaw-acp", openclawGateway: "missing" } },
491
+ });
492
+ expect(gw.managedRoutes).toEqual([]);
493
+ });
494
+ });
495
+
@@ -0,0 +1,234 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { PassThrough } from "node:stream";
4
+ import {
5
+ OpenclawAcpAdapter,
6
+ __resetOpenclawAcpPoolForTests,
7
+ buildAcpSessionKey,
8
+ } from "../gateway/runtimes/openclaw-acp.js";
9
+ import type { ResolvedOpenclawGateway } from "../gateway/types.js";
10
+
11
+ class FakeChild extends EventEmitter {
12
+ stdin = new PassThrough();
13
+ stdout = new PassThrough();
14
+ stderr = new PassThrough();
15
+ killed = false;
16
+ kill(): void {
17
+ this.killed = true;
18
+ }
19
+ }
20
+
21
+ function makeSpawn(child: FakeChild): any {
22
+ return () => child as unknown as ReturnType<typeof import("node:child_process").spawn>;
23
+ }
24
+
25
+ function readFrames(child: FakeChild): Promise<any[]> {
26
+ return new Promise((resolve) => {
27
+ const frames: any[] = [];
28
+ child.stdin.on("data", (chunk: Buffer) => {
29
+ const lines = chunk.toString("utf8").split("\n").filter(Boolean);
30
+ for (const line of lines) frames.push(JSON.parse(line));
31
+ });
32
+ setTimeout(() => resolve(frames), 50);
33
+ });
34
+ }
35
+
36
+ afterEach(() => {
37
+ __resetOpenclawAcpPoolForTests();
38
+ });
39
+
40
+ describe("buildAcpSessionKey", () => {
41
+ it("includes accountId so two daemon agents can't collide on a gateway key", () => {
42
+ const a = buildAcpSessionKey({
43
+ openclawAgent: "main",
44
+ accountId: "ag_alice",
45
+ conversationKey: "rm_x",
46
+ });
47
+ const b = buildAcpSessionKey({
48
+ openclawAgent: "main",
49
+ accountId: "ag_bob",
50
+ conversationKey: "rm_x",
51
+ });
52
+ expect(a).not.toBe(b);
53
+ expect(a).toContain("ag_alice");
54
+ expect(b).toContain("ag_bob");
55
+ });
56
+ });
57
+
58
+ describe("OpenclawAcpAdapter.run", () => {
59
+ it("fails fast when gateway is not provided", async () => {
60
+ const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(new FakeChild()) });
61
+ const res = await adapter.run({
62
+ text: "hi",
63
+ sessionId: null,
64
+ cwd: "/tmp",
65
+ accountId: "ag_alice",
66
+ signal: new AbortController().signal,
67
+ trustLevel: "owner",
68
+ });
69
+ expect(res.error).toMatch(/missing gateway/);
70
+ });
71
+
72
+ it("fails when gateway has no openclawAgent resolved", async () => {
73
+ const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(new FakeChild()) });
74
+ const gateway: ResolvedOpenclawGateway = {
75
+ name: "local",
76
+ url: "ws://127.0.0.1:1",
77
+ };
78
+ const res = await adapter.run({
79
+ text: "hi",
80
+ sessionId: null,
81
+ cwd: "/tmp",
82
+ accountId: "ag_alice",
83
+ signal: new AbortController().signal,
84
+ trustLevel: "owner",
85
+ gateway,
86
+ });
87
+ expect(res.error).toMatch(/openclawAgent/);
88
+ });
89
+
90
+ it("performs initialize → newSession → prompt and returns final text", async () => {
91
+ const child = new FakeChild();
92
+ const spawnFn = vi.fn().mockReturnValue(child);
93
+ const adapter = new OpenclawAcpAdapter({ spawnFn: spawnFn as any });
94
+ const gateway: ResolvedOpenclawGateway = {
95
+ name: "local",
96
+ url: "ws://127.0.0.1:1",
97
+ openclawAgent: "main",
98
+ };
99
+
100
+ // Seed the child stdout with replies as soon as stdin is written.
101
+ let nextSessionId = "acp-uuid-1";
102
+ let promptId: number | null = null;
103
+ child.stdin.on("data", (chunk: Buffer) => {
104
+ for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
105
+ const frame = JSON.parse(line);
106
+ if (frame.method === "initialize") {
107
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
108
+ } else if (frame.method === "session/new") {
109
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: nextSessionId } }) + "\n");
110
+ } else if (frame.method === "session/prompt") {
111
+ promptId = frame.id;
112
+ // Stream a chunk then resolve.
113
+ child.stdout.write(
114
+ JSON.stringify({
115
+ jsonrpc: "2.0",
116
+ method: "session/update",
117
+ params: {
118
+ sessionId: nextSessionId,
119
+ update: { sessionUpdate: "agent_message_chunk", content: { text: "hello world" } },
120
+ },
121
+ }) + "\n",
122
+ );
123
+ setTimeout(() => {
124
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: promptId, result: { text: "hello world" } }) + "\n");
125
+ }, 5);
126
+ }
127
+ }
128
+ });
129
+
130
+ const blocks: any[] = [];
131
+ const res = await adapter.run({
132
+ text: "hi",
133
+ sessionId: null,
134
+ cwd: "/tmp",
135
+ accountId: "ag_alice",
136
+ signal: new AbortController().signal,
137
+ trustLevel: "owner",
138
+ gateway,
139
+ onBlock: (b) => blocks.push(b),
140
+ });
141
+
142
+ expect(res.error).toBeUndefined();
143
+ expect(res.text).toBe("hello world");
144
+ expect(res.newSessionId).toBe("acp-uuid-1");
145
+ expect(blocks.length).toBeGreaterThan(0);
146
+ expect(blocks[0].kind).toBe("assistant_text");
147
+ expect(spawnFn).toHaveBeenCalledTimes(1);
148
+ expect(spawnFn.mock.calls[0][1]).toEqual(["acp", "--url", "ws://127.0.0.1:1"]);
149
+ });
150
+
151
+ it("respawns the pooled child when gateway.url or gateway.token changes under the same name", async () => {
152
+ function newChild(): FakeChild {
153
+ const c = new FakeChild();
154
+ c.stdin.on("data", (chunk: Buffer) => {
155
+ for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
156
+ const frame = JSON.parse(line);
157
+ if (frame.method === "initialize") {
158
+ c.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: {} }) + "\n");
159
+ } else if (frame.method === "session/new") {
160
+ c.stdout.write(
161
+ JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "s" } }) + "\n",
162
+ );
163
+ } else if (frame.method === "session/prompt") {
164
+ c.stdout.write(
165
+ JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { text: "ok" } }) + "\n",
166
+ );
167
+ }
168
+ }
169
+ });
170
+ return c;
171
+ }
172
+ const children = [newChild(), newChild(), newChild()];
173
+ const spawnFn = vi.fn().mockImplementation(() => children.shift()! as any);
174
+ const adapter = new OpenclawAcpAdapter({ spawnFn: spawnFn as any });
175
+ const baseOpts = {
176
+ text: "hi",
177
+ sessionId: null,
178
+ cwd: "/tmp",
179
+ accountId: "ag_alice",
180
+ signal: new AbortController().signal,
181
+ trustLevel: "owner" as const,
182
+ };
183
+ await adapter.run({
184
+ ...baseOpts,
185
+ gateway: { name: "p1", url: "ws://a", token: "t1", openclawAgent: "main" },
186
+ });
187
+ await adapter.run({
188
+ ...baseOpts,
189
+ gateway: { name: "p1", url: "ws://b", token: "t1", openclawAgent: "main" },
190
+ });
191
+ await adapter.run({
192
+ ...baseOpts,
193
+ gateway: { name: "p1", url: "ws://b", token: "t2", openclawAgent: "main" },
194
+ });
195
+ expect(spawnFn).toHaveBeenCalledTimes(3);
196
+ });
197
+
198
+ it("reuses the pooled child for the same (accountId, gateway)", async () => {
199
+ const child = new FakeChild();
200
+ const spawnFn = vi.fn().mockReturnValue(child);
201
+ const adapter = new OpenclawAcpAdapter({ spawnFn: spawnFn as any });
202
+ const gateway: ResolvedOpenclawGateway = {
203
+ name: "local",
204
+ url: "ws://127.0.0.1:1",
205
+ openclawAgent: "main",
206
+ };
207
+
208
+ child.stdin.on("data", (chunk: Buffer) => {
209
+ for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
210
+ const frame = JSON.parse(line);
211
+ if (frame.method === "initialize") {
212
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
213
+ } else if (frame.method === "session/new") {
214
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "s1" } }) + "\n");
215
+ } else if (frame.method === "session/prompt") {
216
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { text: "ok" } }) + "\n");
217
+ }
218
+ }
219
+ });
220
+
221
+ const opts = {
222
+ text: "hi",
223
+ sessionId: null,
224
+ cwd: "/tmp",
225
+ accountId: "ag_alice",
226
+ signal: new AbortController().signal,
227
+ trustLevel: "owner" as const,
228
+ gateway,
229
+ };
230
+ await adapter.run(opts);
231
+ await adapter.run({ ...opts, sessionId: "s1" });
232
+ expect(spawnFn).toHaveBeenCalledTimes(1);
233
+ });
234
+ });