@botcord/daemon 0.2.5 → 0.2.6
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/agent-discovery.d.ts +4 -0
- package/dist/agent-discovery.js +8 -0
- package/dist/agent-workspace.d.ts +62 -0
- package/dist/agent-workspace.js +140 -8
- package/dist/config.d.ts +49 -1
- package/dist/config.js +57 -1
- package/dist/daemon-config-map.d.ts +27 -9
- package/dist/daemon-config-map.js +105 -8
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +52 -5
- package/dist/doctor.d.ts +27 -1
- package/dist/doctor.js +22 -1
- package/dist/gateway/cli-resolver.d.ts +34 -0
- package/dist/gateway/cli-resolver.js +74 -0
- package/dist/gateway/dispatcher.d.ts +31 -1
- package/dist/gateway/dispatcher.js +337 -29
- package/dist/gateway/gateway.d.ts +29 -1
- package/dist/gateway/gateway.js +10 -0
- package/dist/gateway/index.d.ts +2 -0
- package/dist/gateway/index.js +2 -0
- package/dist/gateway/policy-resolver.d.ts +57 -0
- package/dist/gateway/policy-resolver.js +123 -0
- package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
- package/dist/gateway/runtimes/acp-stream.js +394 -0
- package/dist/gateway/runtimes/codex.js +7 -0
- package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
- package/dist/gateway/runtimes/hermes-agent.js +180 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
- package/dist/gateway/runtimes/ndjson-stream.js +16 -3
- package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
- package/dist/gateway/runtimes/openclaw-acp.js +500 -0
- package/dist/gateway/runtimes/registry.d.ts +4 -0
- package/dist/gateway/runtimes/registry.js +22 -0
- package/dist/gateway/transcript-paths.d.ts +30 -0
- package/dist/gateway/transcript-paths.js +114 -0
- package/dist/gateway/transcript.d.ts +123 -0
- package/dist/gateway/transcript.js +147 -0
- package/dist/gateway/types.d.ts +31 -0
- package/dist/index.js +286 -27
- package/dist/mention-scan.d.ts +22 -0
- package/dist/mention-scan.js +35 -0
- package/dist/provision.d.ts +72 -1
- package/dist/provision.js +370 -7
- package/dist/system-context.d.ts +5 -4
- package/dist/system-context.js +35 -5
- package/dist/url-utils.d.ts +9 -0
- package/dist/url-utils.js +18 -0
- package/package.json +2 -1
- package/src/__tests__/agent-workspace.test.ts +93 -0
- package/src/__tests__/daemon-config-map.test.ts +79 -0
- package/src/__tests__/openclaw-acp.test.ts +234 -0
- package/src/__tests__/policy-resolver.test.ts +124 -0
- package/src/__tests__/policy-updated-handler.test.ts +144 -0
- package/src/__tests__/provision.test.ts +160 -0
- package/src/__tests__/system-context.test.ts +52 -0
- package/src/__tests__/url-utils.test.ts +37 -0
- package/src/agent-discovery.ts +8 -0
- package/src/agent-workspace.ts +173 -7
- package/src/config.ts +132 -4
- package/src/daemon-config-map.ts +154 -9
- package/src/daemon.ts +66 -5
- package/src/doctor.ts +49 -2
- package/src/gateway/__tests__/dispatcher.test.ts +65 -0
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
- package/src/gateway/__tests__/transcript.test.ts +496 -0
- package/src/gateway/cli-resolver.ts +92 -0
- package/src/gateway/dispatcher.ts +394 -26
- package/src/gateway/gateway.ts +46 -0
- package/src/gateway/index.ts +25 -0
- package/src/gateway/policy-resolver.ts +171 -0
- package/src/gateway/runtimes/acp-stream.ts +535 -0
- package/src/gateway/runtimes/codex.ts +7 -0
- package/src/gateway/runtimes/hermes-agent.ts +206 -0
- package/src/gateway/runtimes/ndjson-stream.ts +16 -3
- package/src/gateway/runtimes/openclaw-acp.ts +606 -0
- package/src/gateway/runtimes/registry.ts +24 -0
- package/src/gateway/transcript-paths.ts +145 -0
- package/src/gateway/transcript.ts +300 -0
- package/src/gateway/types.ts +32 -0
- package/src/index.ts +295 -30
- package/src/mention-scan.ts +38 -0
- package/src/provision.ts +438 -9
- package/src/system-context.ts +41 -9
- package/src/url-utils.ts +17 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { PolicyResolver } from "../gateway/policy-resolver.js";
|
|
3
|
+
import type { AttentionPolicy } from "@botcord/protocol-core";
|
|
4
|
+
|
|
5
|
+
describe("PolicyResolver", () => {
|
|
6
|
+
it("returns default policy when fetchGlobal returns undefined", async () => {
|
|
7
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
8
|
+
const p = await resolver.resolve("ag_a", null);
|
|
9
|
+
expect(p.mode).toBe("always");
|
|
10
|
+
expect(p.keywords).toEqual([]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("caches the global fetch result and reuses on the next resolve", async () => {
|
|
14
|
+
const fetchGlobal = vi.fn(async () => ({ mode: "muted", keywords: [] }) as AttentionPolicy);
|
|
15
|
+
const resolver = new PolicyResolver({ fetchGlobal });
|
|
16
|
+
await resolver.resolve("ag_a", null);
|
|
17
|
+
await resolver.resolve("ag_a", null);
|
|
18
|
+
expect(fetchGlobal).toHaveBeenCalledTimes(1);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("invalidate(agent) drops all entries for that agent", async () => {
|
|
22
|
+
const fetchGlobal = vi.fn(async () => ({ mode: "always", keywords: [] }) as AttentionPolicy);
|
|
23
|
+
const resolver = new PolicyResolver({ fetchGlobal });
|
|
24
|
+
await resolver.resolve("ag_a", null);
|
|
25
|
+
await resolver.resolve("ag_a", null);
|
|
26
|
+
expect(fetchGlobal).toHaveBeenCalledTimes(1);
|
|
27
|
+
resolver.invalidate("ag_a");
|
|
28
|
+
await resolver.resolve("ag_a", null);
|
|
29
|
+
expect(fetchGlobal).toHaveBeenCalledTimes(2);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("invalidate(agent, room) only drops the matching room entry", async () => {
|
|
33
|
+
const policy: AttentionPolicy = { mode: "always", keywords: [] };
|
|
34
|
+
const fetchGlobal = vi.fn(async () => policy);
|
|
35
|
+
const fetchEffective = vi.fn(async () => policy);
|
|
36
|
+
const resolver = new PolicyResolver({ fetchGlobal, fetchEffective });
|
|
37
|
+
|
|
38
|
+
await resolver.resolve("ag_a", null);
|
|
39
|
+
await resolver.resolve("ag_a", "rm_1");
|
|
40
|
+
await resolver.resolve("ag_a", "rm_2");
|
|
41
|
+
expect(fetchGlobal).toHaveBeenCalledTimes(1);
|
|
42
|
+
expect(fetchEffective).toHaveBeenCalledTimes(2);
|
|
43
|
+
|
|
44
|
+
resolver.invalidate("ag_a", "rm_1");
|
|
45
|
+
await resolver.resolve("ag_a", null); // still cached
|
|
46
|
+
await resolver.resolve("ag_a", "rm_2"); // still cached
|
|
47
|
+
await resolver.resolve("ag_a", "rm_1"); // refetched
|
|
48
|
+
expect(fetchGlobal).toHaveBeenCalledTimes(1);
|
|
49
|
+
expect(fetchEffective).toHaveBeenCalledTimes(3);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("put() installs a policy without going through fetch", async () => {
|
|
53
|
+
const fetchGlobal = vi.fn(async () => undefined);
|
|
54
|
+
const resolver = new PolicyResolver({ fetchGlobal });
|
|
55
|
+
resolver.put("ag_a", null, { mode: "muted", keywords: [] });
|
|
56
|
+
const p = await resolver.resolve("ag_a", null);
|
|
57
|
+
expect(p.mode).toBe("muted");
|
|
58
|
+
expect(fetchGlobal).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("forces DM rooms (rm_dm_*) to mode=always even if cached muted", async () => {
|
|
62
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
63
|
+
resolver.put("ag_a", "rm_dm_xyz", { mode: "muted", keywords: [] });
|
|
64
|
+
const p = await resolver.resolve("ag_a", "rm_dm_xyz");
|
|
65
|
+
expect(p.mode).toBe("always");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("falls back to defaults when fetch throws", async () => {
|
|
69
|
+
const resolver = new PolicyResolver({
|
|
70
|
+
fetchGlobal: async () => {
|
|
71
|
+
throw new Error("boom");
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const p = await resolver.resolve("ag_a", null);
|
|
75
|
+
expect(p.mode).toBe("always");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("falls back to the cached global when resolving a room with no override", async () => {
|
|
79
|
+
// Regression: prior to the room→global fallback, group messages
|
|
80
|
+
// collapsed to mode=always whenever the daemon had no fetchEffective
|
|
81
|
+
// wired (the default state), silently breaking global mention_only/muted.
|
|
82
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
83
|
+
resolver.put("ag_a", null, { mode: "mention_only", keywords: [] });
|
|
84
|
+
const p = await resolver.resolve("ag_a", "rm_1");
|
|
85
|
+
expect(p.mode).toBe("mention_only");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("per-room override wins over the cached global", async () => {
|
|
89
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
90
|
+
resolver.put("ag_a", null, { mode: "always", keywords: [] });
|
|
91
|
+
resolver.put("ag_a", "rm_1", { mode: "muted", keywords: [] });
|
|
92
|
+
const p = await resolver.resolve("ag_a", "rm_1");
|
|
93
|
+
expect(p.mode).toBe("muted");
|
|
94
|
+
// Other rooms still inherit the global.
|
|
95
|
+
expect((await resolver.resolve("ag_a", "rm_2")).mode).toBe("always");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("invalidate(agent, room) drops the override and falls back to the cached global", async () => {
|
|
99
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
100
|
+
resolver.put("ag_a", null, { mode: "mention_only", keywords: [] });
|
|
101
|
+
resolver.put("ag_a", "rm_1", { mode: "muted", keywords: [] });
|
|
102
|
+
expect((await resolver.resolve("ag_a", "rm_1")).mode).toBe("muted");
|
|
103
|
+
resolver.invalidate("ag_a", "rm_1");
|
|
104
|
+
expect((await resolver.resolve("ag_a", "rm_1")).mode).toBe("mention_only");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("global update via put propagates to inheriting rooms without invalidation", async () => {
|
|
108
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
109
|
+
resolver.put("ag_a", null, { mode: "always", keywords: [] });
|
|
110
|
+
expect((await resolver.resolve("ag_a", "rm_1")).mode).toBe("always");
|
|
111
|
+
// Hub fires policy_updated with new global policy → daemon does put().
|
|
112
|
+
resolver.put("ag_a", null, { mode: "muted", keywords: [] });
|
|
113
|
+
expect((await resolver.resolve("ag_a", "rm_1")).mode).toBe("muted");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("expires cached entries after ttlMs", async () => {
|
|
117
|
+
const fetchGlobal = vi.fn(async () => ({ mode: "muted", keywords: [] }) as AttentionPolicy);
|
|
118
|
+
const resolver = new PolicyResolver({ fetchGlobal, ttlMs: 1 });
|
|
119
|
+
await resolver.resolve("ag_a", null);
|
|
120
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
121
|
+
await resolver.resolve("ag_a", null);
|
|
122
|
+
expect(fetchGlobal).toHaveBeenCalledTimes(2);
|
|
123
|
+
});
|
|
124
|
+
});
|