@botcord/daemon 0.2.4 → 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 +7 -3
- package/dist/agent-discovery.js +9 -1
- package/dist/agent-workspace.d.ts +62 -0
- package/dist/agent-workspace.js +140 -10
- package/dist/config.d.ts +49 -1
- package/dist/config.js +57 -1
- package/dist/control-channel.d.ts +1 -4
- package/dist/control-channel.js +1 -4
- package/dist/daemon-config-map.d.ts +29 -12
- 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 +66 -1
- package/dist/gateway/dispatcher.js +583 -56
- 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 +73 -3
- package/dist/provision.js +373 -12
- package/dist/system-context.d.ts +5 -4
- package/dist/system-context.js +35 -5
- package/dist/turn-text.js +20 -1
- package/dist/url-utils.d.ts +9 -0
- package/dist/url-utils.js +18 -0
- package/dist/user-auth.js +0 -2
- package/dist/working-memory.js +1 -1
- 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 +12 -4
- package/src/agent-workspace.ts +173 -9
- package/src/config.ts +132 -4
- package/src/control-channel.ts +1 -4
- package/src/daemon-config-map.ts +156 -12
- package/src/daemon.ts +66 -5
- package/src/doctor.ts +49 -2
- package/src/gateway/__tests__/dispatcher.test.ts +440 -2
- 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 +681 -58
- 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 +446 -20
- package/src/system-context.ts +41 -9
- package/src/turn-text.ts +22 -1
- package/src/url-utils.ts +17 -0
- package/src/user-auth.ts +0 -2
- package/src/working-memory.ts +1 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockState = {
|
|
4
|
+
cfg: {
|
|
5
|
+
defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
|
|
6
|
+
routes: [],
|
|
7
|
+
streamBlocks: true,
|
|
8
|
+
} as Record<string, unknown>,
|
|
9
|
+
saved: [] as Array<Record<string, unknown>>,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
vi.mock("../config.js", async () => {
|
|
13
|
+
const actual = await vi.importActual<typeof import("../config.js")>("../config.js");
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
loadConfig: () => JSON.parse(JSON.stringify(mockState.cfg)) as Record<string, unknown>,
|
|
17
|
+
saveConfig: (next: Record<string, unknown>) => {
|
|
18
|
+
mockState.cfg = JSON.parse(JSON.stringify(next)) as Record<string, unknown>;
|
|
19
|
+
mockState.saved.push(JSON.parse(JSON.stringify(next)) as Record<string, unknown>);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const { createProvisioner } = await import("../provision.js");
|
|
25
|
+
const { CONTROL_FRAME_TYPES } = await import("@botcord/protocol-core");
|
|
26
|
+
import type { GatewayRoute, GatewayRuntimeSnapshot } from "../gateway/index.js";
|
|
27
|
+
import type { PolicyResolverLike } from "../gateway/policy-resolver.js";
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
mockState.cfg = {
|
|
31
|
+
defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
|
|
32
|
+
routes: [],
|
|
33
|
+
streamBlocks: true,
|
|
34
|
+
};
|
|
35
|
+
mockState.saved = [];
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function makeFakeGateway(): unknown {
|
|
39
|
+
const snap: GatewayRuntimeSnapshot = { channels: {}, turns: {} };
|
|
40
|
+
return {
|
|
41
|
+
addChannel: vi.fn(async () => {}),
|
|
42
|
+
removeChannel: vi.fn(async () => {}),
|
|
43
|
+
upsertManagedRoute: vi.fn(),
|
|
44
|
+
removeManagedRoute: vi.fn(),
|
|
45
|
+
replaceManagedRoutes: vi.fn(),
|
|
46
|
+
listManagedRoutes: () => [] as GatewayRoute[],
|
|
47
|
+
snapshot: () => snap,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeFakeResolver(): PolicyResolverLike & {
|
|
52
|
+
invalidate: ReturnType<typeof vi.fn>;
|
|
53
|
+
put: ReturnType<typeof vi.fn>;
|
|
54
|
+
resolve: ReturnType<typeof vi.fn>;
|
|
55
|
+
} {
|
|
56
|
+
return {
|
|
57
|
+
resolve: vi.fn(async () => ({ mode: "always", keywords: [] })),
|
|
58
|
+
invalidate: vi.fn(),
|
|
59
|
+
put: vi.fn(),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("policy_updated control-frame handler", () => {
|
|
64
|
+
it("invalidates the resolver cache for the agent when payload has no policy", async () => {
|
|
65
|
+
const gw = makeFakeGateway();
|
|
66
|
+
const resolver = makeFakeResolver();
|
|
67
|
+
const provisioner = createProvisioner({
|
|
68
|
+
gateway: gw as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
69
|
+
policyResolver: resolver,
|
|
70
|
+
});
|
|
71
|
+
const ack = await provisioner({
|
|
72
|
+
id: "f1",
|
|
73
|
+
type: CONTROL_FRAME_TYPES.POLICY_UPDATED,
|
|
74
|
+
params: { agent_id: "ag_a" },
|
|
75
|
+
});
|
|
76
|
+
expect(ack.ok).toBe(true);
|
|
77
|
+
expect(resolver.invalidate).toHaveBeenCalledWith("ag_a", undefined);
|
|
78
|
+
expect(resolver.put).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("invalidates only the per-room slot when room_id is present", async () => {
|
|
82
|
+
const resolver = makeFakeResolver();
|
|
83
|
+
const provisioner = createProvisioner({
|
|
84
|
+
gateway: makeFakeGateway() as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
85
|
+
policyResolver: resolver,
|
|
86
|
+
});
|
|
87
|
+
await provisioner({
|
|
88
|
+
id: "f2",
|
|
89
|
+
type: CONTROL_FRAME_TYPES.POLICY_UPDATED,
|
|
90
|
+
params: { agent_id: "ag_a", room_id: "rm_1" },
|
|
91
|
+
});
|
|
92
|
+
expect(resolver.invalidate).toHaveBeenCalledWith("ag_a", "rm_1");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("calls put() with the embedded policy when payload carries one", async () => {
|
|
96
|
+
const resolver = makeFakeResolver();
|
|
97
|
+
const provisioner = createProvisioner({
|
|
98
|
+
gateway: makeFakeGateway() as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
99
|
+
policyResolver: resolver,
|
|
100
|
+
});
|
|
101
|
+
const ack = await provisioner({
|
|
102
|
+
id: "f3",
|
|
103
|
+
type: CONTROL_FRAME_TYPES.POLICY_UPDATED,
|
|
104
|
+
params: {
|
|
105
|
+
agent_id: "ag_a",
|
|
106
|
+
policy: { mode: "keyword", keywords: ["foo", "bar"], muted_until: 123 },
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
expect(ack.ok).toBe(true);
|
|
110
|
+
expect(resolver.put).toHaveBeenCalledWith("ag_a", null, {
|
|
111
|
+
mode: "keyword",
|
|
112
|
+
keywords: ["foo", "bar"],
|
|
113
|
+
muted_until: 123,
|
|
114
|
+
});
|
|
115
|
+
expect(resolver.invalidate).not.toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("rejects payloads missing agent_id with bad_params", async () => {
|
|
119
|
+
const resolver = makeFakeResolver();
|
|
120
|
+
const provisioner = createProvisioner({
|
|
121
|
+
gateway: makeFakeGateway() as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
122
|
+
policyResolver: resolver,
|
|
123
|
+
});
|
|
124
|
+
const ack = await provisioner({
|
|
125
|
+
id: "f4",
|
|
126
|
+
type: CONTROL_FRAME_TYPES.POLICY_UPDATED,
|
|
127
|
+
params: {},
|
|
128
|
+
});
|
|
129
|
+
expect(ack.ok).toBe(false);
|
|
130
|
+
expect(ack.error?.code).toBe("bad_params");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("succeeds quietly when no resolver is wired", async () => {
|
|
134
|
+
const provisioner = createProvisioner({
|
|
135
|
+
gateway: makeFakeGateway() as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
136
|
+
});
|
|
137
|
+
const ack = await provisioner({
|
|
138
|
+
id: "f5",
|
|
139
|
+
type: CONTROL_FRAME_TYPES.POLICY_UPDATED,
|
|
140
|
+
params: { agent_id: "ag_a" },
|
|
141
|
+
});
|
|
142
|
+
expect(ack.ok).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -298,6 +298,58 @@ describe("set_route handler", () => {
|
|
|
298
298
|
expect(saved.routes[0].extraArgs).toEqual(["--debug"]);
|
|
299
299
|
});
|
|
300
300
|
|
|
301
|
+
it("inherits defaultRoute.extraArgs when caller omits them (mirrors adapter/cwd fallback)", () => {
|
|
302
|
+
mockState.cfg = {
|
|
303
|
+
defaultRoute: {
|
|
304
|
+
adapter: "claude-code",
|
|
305
|
+
cwd: process.env.HOME ?? "/tmp",
|
|
306
|
+
extraArgs: ["--permission-mode", "bypassPermissions"],
|
|
307
|
+
},
|
|
308
|
+
routes: [],
|
|
309
|
+
streamBlocks: true,
|
|
310
|
+
};
|
|
311
|
+
setRoute({ agentId: "ag_new", pattern: "rm_oc_" });
|
|
312
|
+
const saved = mockState.saved[mockState.saved.length - 1] as unknown as DaemonConfig;
|
|
313
|
+
expect(saved.routes[0].extraArgs).toEqual([
|
|
314
|
+
"--permission-mode",
|
|
315
|
+
"bypassPermissions",
|
|
316
|
+
]);
|
|
317
|
+
// Mutating the saved route must not bleed back into defaultRoute.
|
|
318
|
+
saved.routes[0].extraArgs!.push("--mutated");
|
|
319
|
+
expect(mockState.cfg.defaultRoute).toEqual({
|
|
320
|
+
adapter: "claude-code",
|
|
321
|
+
cwd: process.env.HOME ?? "/tmp",
|
|
322
|
+
extraArgs: ["--permission-mode", "bypassPermissions"],
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("explicit extraArgs override defaultRoute.extraArgs", () => {
|
|
327
|
+
mockState.cfg = {
|
|
328
|
+
defaultRoute: {
|
|
329
|
+
adapter: "claude-code",
|
|
330
|
+
cwd: process.env.HOME ?? "/tmp",
|
|
331
|
+
extraArgs: ["--permission-mode", "bypassPermissions"],
|
|
332
|
+
},
|
|
333
|
+
routes: [],
|
|
334
|
+
streamBlocks: true,
|
|
335
|
+
};
|
|
336
|
+
setRoute({
|
|
337
|
+
agentId: "ag_new",
|
|
338
|
+
route: { adapter: "claude-code", cwd: process.env.HOME ?? "/tmp", extraArgs: ["--debug"] },
|
|
339
|
+
});
|
|
340
|
+
const saved = mockState.saved[mockState.saved.length - 1] as unknown as DaemonConfig;
|
|
341
|
+
expect(saved.routes[0].extraArgs).toEqual(["--debug"]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("omits extraArgs when neither route nor defaultRoute provide them", () => {
|
|
345
|
+
setRoute({
|
|
346
|
+
agentId: "ag_new",
|
|
347
|
+
route: { adapter: "claude-code", cwd: process.env.HOME ?? "/tmp" },
|
|
348
|
+
});
|
|
349
|
+
const saved = mockState.saved[mockState.saved.length - 1] as unknown as DaemonConfig;
|
|
350
|
+
expect(saved.routes[0]).not.toHaveProperty("extraArgs");
|
|
351
|
+
});
|
|
352
|
+
|
|
301
353
|
it("forces match.accountId to the agentId even when callers omit it", () => {
|
|
302
354
|
setRoute({
|
|
303
355
|
agentId: "ag_x",
|
|
@@ -920,3 +972,111 @@ describe("revoke_agent respects deleteState / deleteWorkspace flags", () => {
|
|
|
920
972
|
});
|
|
921
973
|
});
|
|
922
974
|
});
|
|
975
|
+
|
|
976
|
+
// ---------------------------------------------------------------------------
|
|
977
|
+
// hello + update_agent identity sync (lightweight reconcile path)
|
|
978
|
+
// ---------------------------------------------------------------------------
|
|
979
|
+
|
|
980
|
+
describe("hello identity snapshot", () => {
|
|
981
|
+
it("rewrites identity.md for every agent in the snapshot", async () => {
|
|
982
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
983
|
+
const { ensureAgentWorkspace } = await import("../agent-workspace.js");
|
|
984
|
+
ensureAgentWorkspace("ag_h1", { displayName: "Old1", bio: "Old bio 1" });
|
|
985
|
+
ensureAgentWorkspace("ag_h2", { displayName: "Old2", bio: "Old bio 2" });
|
|
986
|
+
void tmp;
|
|
987
|
+
|
|
988
|
+
const gw = makeFakeGateway();
|
|
989
|
+
const provisioner = createProvisioner({
|
|
990
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
const ack = await provisioner({
|
|
994
|
+
id: "req_hello",
|
|
995
|
+
type: CONTROL_FRAME_TYPES.HELLO,
|
|
996
|
+
params: {
|
|
997
|
+
server_time: Date.now(),
|
|
998
|
+
agents: [
|
|
999
|
+
{ agentId: "ag_h1", displayName: "Fresh1", bio: "Fresh bio 1" },
|
|
1000
|
+
{ agentId: "ag_h2", displayName: "Fresh2", bio: null },
|
|
1001
|
+
{ agentId: "ag_missing", displayName: "Nope", bio: "Nope" },
|
|
1002
|
+
],
|
|
1003
|
+
},
|
|
1004
|
+
});
|
|
1005
|
+
expect(ack.ok).toBe(true);
|
|
1006
|
+
const result = ack.result as { updated: number; skipped: number };
|
|
1007
|
+
expect(result.updated).toBe(2);
|
|
1008
|
+
expect(result.skipped).toBe(1);
|
|
1009
|
+
|
|
1010
|
+
const id1 = fs.readFileSync(
|
|
1011
|
+
nodePath.join(tmp, ".botcord", "agents", "ag_h1", "workspace", "identity.md"),
|
|
1012
|
+
"utf8",
|
|
1013
|
+
);
|
|
1014
|
+
expect(id1).toContain("Fresh1");
|
|
1015
|
+
expect(id1).toContain("Fresh bio 1");
|
|
1016
|
+
expect(id1).not.toContain("Old1");
|
|
1017
|
+
|
|
1018
|
+
const id2 = fs.readFileSync(
|
|
1019
|
+
nodePath.join(tmp, ".botcord", "agents", "ag_h2", "workspace", "identity.md"),
|
|
1020
|
+
"utf8",
|
|
1021
|
+
);
|
|
1022
|
+
expect(id2).toContain("Fresh2");
|
|
1023
|
+
// bio cleared → placeholder
|
|
1024
|
+
expect(id2).toContain("_(none provided at provision time");
|
|
1025
|
+
});
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it("tolerates a hello frame with no agents array", async () => {
|
|
1029
|
+
const gw = makeFakeGateway();
|
|
1030
|
+
const provisioner = createProvisioner({
|
|
1031
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
1032
|
+
});
|
|
1033
|
+
const ack = await provisioner({
|
|
1034
|
+
id: "req_hello_empty",
|
|
1035
|
+
type: CONTROL_FRAME_TYPES.HELLO,
|
|
1036
|
+
params: { server_time: Date.now() },
|
|
1037
|
+
});
|
|
1038
|
+
expect(ack.ok).toBe(true);
|
|
1039
|
+
});
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
describe("update_agent handler", () => {
|
|
1043
|
+
it("rewrites identity.md for the targeted agent", async () => {
|
|
1044
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
1045
|
+
const { ensureAgentWorkspace } = await import("../agent-workspace.js");
|
|
1046
|
+
ensureAgentWorkspace("ag_u1", { displayName: "Before", bio: "Before bio" });
|
|
1047
|
+
|
|
1048
|
+
const gw = makeFakeGateway();
|
|
1049
|
+
const provisioner = createProvisioner({
|
|
1050
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
1051
|
+
});
|
|
1052
|
+
const ack = await provisioner({
|
|
1053
|
+
id: "req_update",
|
|
1054
|
+
type: CONTROL_FRAME_TYPES.UPDATE_AGENT,
|
|
1055
|
+
params: { agentId: "ag_u1", displayName: "After", bio: "After bio" },
|
|
1056
|
+
});
|
|
1057
|
+
expect(ack.ok).toBe(true);
|
|
1058
|
+
expect((ack.result as { changed: boolean }).changed).toBe(true);
|
|
1059
|
+
|
|
1060
|
+
const md = fs.readFileSync(
|
|
1061
|
+
nodePath.join(tmp, ".botcord", "agents", "ag_u1", "workspace", "identity.md"),
|
|
1062
|
+
"utf8",
|
|
1063
|
+
);
|
|
1064
|
+
expect(md).toContain("After");
|
|
1065
|
+
expect(md).toContain("After bio");
|
|
1066
|
+
});
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
it("rejects update_agent without agentId", async () => {
|
|
1070
|
+
const gw = makeFakeGateway();
|
|
1071
|
+
const provisioner = createProvisioner({
|
|
1072
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
1073
|
+
});
|
|
1074
|
+
const ack = await provisioner({
|
|
1075
|
+
id: "req_update_bad",
|
|
1076
|
+
type: CONTROL_FRAME_TYPES.UPDATE_AGENT,
|
|
1077
|
+
params: {},
|
|
1078
|
+
});
|
|
1079
|
+
expect(ack.ok).toBe(false);
|
|
1080
|
+
expect(ack.error?.code).toBe("bad_params");
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
@@ -25,6 +25,10 @@ const { updateWorkingMemory, clearWorkingMemory } = await import(
|
|
|
25
25
|
);
|
|
26
26
|
const { ActivityTracker } = await import("../activity-tracker.js");
|
|
27
27
|
const { createDaemonSystemContextBuilder } = await import("../system-context.js");
|
|
28
|
+
const { ensureAgentWorkspace, agentWorkspaceDir } = await import(
|
|
29
|
+
"../agent-workspace.js"
|
|
30
|
+
);
|
|
31
|
+
const { writeFileSync } = await import("node:fs");
|
|
28
32
|
|
|
29
33
|
function makeMessage(
|
|
30
34
|
partial: Partial<GatewayInboundMessage> = {},
|
|
@@ -82,6 +86,54 @@ describe("createDaemonSystemContextBuilder", () => {
|
|
|
82
86
|
expect(out).toContain("remember X");
|
|
83
87
|
});
|
|
84
88
|
|
|
89
|
+
it("injects the identity block from workspace/identity.md, placed before other blocks", () => {
|
|
90
|
+
ensureAgentWorkspace("ag_me", {
|
|
91
|
+
displayName: "Susan's Helper",
|
|
92
|
+
bio: "A friendly IM agent who tracks contacts.",
|
|
93
|
+
runtime: "claude-code",
|
|
94
|
+
});
|
|
95
|
+
updateWorkingMemory("ag_me", { goal: "ship feature" });
|
|
96
|
+
|
|
97
|
+
const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
|
|
98
|
+
const out = builder(makeMessage()) as string;
|
|
99
|
+
expect(out).toContain("[BotCord Identity]");
|
|
100
|
+
expect(out).toContain("Susan's Helper");
|
|
101
|
+
expect(out).toContain("A friendly IM agent who tracks contacts.");
|
|
102
|
+
// Identity must precede working memory so it frames every other block.
|
|
103
|
+
expect(out.indexOf("[BotCord Identity]")).toBeLessThan(
|
|
104
|
+
out.indexOf("[BotCord Working Memory]"),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("re-reads identity.md every turn so dashboard / agent edits take effect immediately", () => {
|
|
109
|
+
ensureAgentWorkspace("ag_me", { displayName: "Old Name" });
|
|
110
|
+
const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
|
|
111
|
+
const first = builder(makeMessage()) as string;
|
|
112
|
+
expect(first).toContain("Old Name");
|
|
113
|
+
|
|
114
|
+
// Simulate an out-of-band edit (dashboard reconcile, user, control frame…).
|
|
115
|
+
writeFileSync(
|
|
116
|
+
path.join(agentWorkspaceDir("ag_me"), "identity.md"),
|
|
117
|
+
"# Identity\n\n- **Display name**: New Name\n",
|
|
118
|
+
);
|
|
119
|
+
const second = builder(makeMessage()) as string;
|
|
120
|
+
expect(second).toContain("New Name");
|
|
121
|
+
expect(second).not.toContain("Old Name");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("skips the identity block cleanly when identity.md is missing", () => {
|
|
125
|
+
// No ensureAgentWorkspace — workspace never provisioned.
|
|
126
|
+
const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
|
|
127
|
+
expect(builder(makeMessage())).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("skips the identity block when identity.md is blank", () => {
|
|
131
|
+
ensureAgentWorkspace("ag_me", { displayName: "X" });
|
|
132
|
+
writeFileSync(path.join(agentWorkspaceDir("ag_me"), "identity.md"), "");
|
|
133
|
+
const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
|
|
134
|
+
expect(builder(makeMessage())).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
85
137
|
it("emits the 'memory is currently empty' notice when the memory file exists but is blank", () => {
|
|
86
138
|
clearWorkingMemory("ag_me");
|
|
87
139
|
const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { appendNextParam } from "../url-utils.js";
|
|
3
|
+
|
|
4
|
+
describe("appendNextParam", () => {
|
|
5
|
+
it("appends next to a URL with no existing query string", () => {
|
|
6
|
+
const out = appendNextParam(
|
|
7
|
+
"https://app.botcord.chat/activate",
|
|
8
|
+
"/settings/daemons",
|
|
9
|
+
);
|
|
10
|
+
expect(out).toBe("https://app.botcord.chat/activate?next=%2Fsettings%2Fdaemons");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("preserves existing query params (e.g. ?code=...)", () => {
|
|
14
|
+
const out = appendNextParam(
|
|
15
|
+
"https://app.botcord.chat/activate?code=ABCD-EFGH",
|
|
16
|
+
"/settings/daemons",
|
|
17
|
+
);
|
|
18
|
+
const u = new URL(out);
|
|
19
|
+
expect(u.searchParams.get("code")).toBe("ABCD-EFGH");
|
|
20
|
+
expect(u.searchParams.get("next")).toBe("/settings/daemons");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("overwrites an existing next param rather than duplicating it", () => {
|
|
24
|
+
const out = appendNextParam(
|
|
25
|
+
"https://app.botcord.chat/activate?next=/old",
|
|
26
|
+
"/settings/daemons",
|
|
27
|
+
);
|
|
28
|
+
const u = new URL(out);
|
|
29
|
+
// searchParams.getAll guards against ?next=/old&next=/new style duplicates
|
|
30
|
+
expect(u.searchParams.getAll("next")).toEqual(["/settings/daemons"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns the original string when the URL cannot be parsed", () => {
|
|
34
|
+
const out = appendNextParam("not a url", "/settings/daemons");
|
|
35
|
+
expect(out).toBe("not a url");
|
|
36
|
+
});
|
|
37
|
+
});
|
package/src/agent-discovery.ts
CHANGED
|
@@ -31,13 +31,17 @@ export interface DiscoveredAgentCredential {
|
|
|
31
31
|
hubUrl: string;
|
|
32
32
|
displayName?: string;
|
|
33
33
|
/**
|
|
34
|
-
* Runtime cached in the credentials file
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* Runtime cached in the credentials file. Null for legacy bind-code
|
|
35
|
+
* credentials without the field; the daemon falls back to `defaultRoute`
|
|
36
|
+
* in that case.
|
|
37
37
|
*/
|
|
38
38
|
runtime?: string;
|
|
39
39
|
/** Working directory cached alongside `runtime`. */
|
|
40
40
|
cwd?: string;
|
|
41
|
+
/** OpenClaw gateway profile name from credentials (only meaningful for openclaw-acp). */
|
|
42
|
+
openclawGateway?: string;
|
|
43
|
+
/** OpenClaw agent profile override from credentials. */
|
|
44
|
+
openclawAgent?: string;
|
|
41
45
|
/** Key id from the credentials file — surfaced so boot-time workspace
|
|
42
46
|
* seeding (see daemon-agent-workspace-plan.md §9) can render identity.md
|
|
43
47
|
* without re-reading the file. */
|
|
@@ -164,6 +168,8 @@ export function discoverAgentCredentials(
|
|
|
164
168
|
if (creds.displayName) entry.displayName = creds.displayName;
|
|
165
169
|
if (creds.runtime) entry.runtime = creds.runtime;
|
|
166
170
|
if (creds.cwd) entry.cwd = creds.cwd;
|
|
171
|
+
if (creds.openclawGateway) entry.openclawGateway = creds.openclawGateway;
|
|
172
|
+
if (creds.openclawAgent) entry.openclawAgent = creds.openclawAgent;
|
|
167
173
|
if (creds.keyId) entry.keyId = creds.keyId;
|
|
168
174
|
if (creds.savedAt) entry.savedAt = creds.savedAt;
|
|
169
175
|
agents.push(entry);
|
|
@@ -221,7 +227,7 @@ export function resolveBootAgents(
|
|
|
221
227
|
// Best-effort enrich with runtime/cwd cached in credentials. A missing
|
|
222
228
|
// or unreadable file is not fatal — the gateway channel will surface the
|
|
223
229
|
// real error at start. The fields we're after are purely for router
|
|
224
|
-
// fallback
|
|
230
|
+
// fallback.
|
|
225
231
|
const agents: DiscoveredAgentCredential[] = explicit.map((agentId) => {
|
|
226
232
|
const credentialsFile = defaultCredentialsFile(agentId);
|
|
227
233
|
const entry: DiscoveredAgentCredential = {
|
|
@@ -236,6 +242,8 @@ export function resolveBootAgents(
|
|
|
236
242
|
if (creds.displayName) entry.displayName = creds.displayName;
|
|
237
243
|
if (creds.runtime) entry.runtime = creds.runtime;
|
|
238
244
|
if (creds.cwd) entry.cwd = creds.cwd;
|
|
245
|
+
if (creds.openclawGateway) entry.openclawGateway = creds.openclawGateway;
|
|
246
|
+
if (creds.openclawAgent) entry.openclawAgent = creds.openclawAgent;
|
|
239
247
|
if (creds.keyId) entry.keyId = creds.keyId;
|
|
240
248
|
if (creds.savedAt) entry.savedAt = creds.savedAt;
|
|
241
249
|
} catch (err) {
|