@botcord/daemon 0.1.1

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 (149) hide show
  1. package/dist/activity-tracker.d.ts +43 -0
  2. package/dist/activity-tracker.js +110 -0
  3. package/dist/adapters/runtimes.d.ts +14 -0
  4. package/dist/adapters/runtimes.js +18 -0
  5. package/dist/agent-discovery.d.ts +81 -0
  6. package/dist/agent-discovery.js +181 -0
  7. package/dist/agent-workspace.d.ts +31 -0
  8. package/dist/agent-workspace.js +221 -0
  9. package/dist/config.d.ts +116 -0
  10. package/dist/config.js +180 -0
  11. package/dist/control-channel.d.ts +99 -0
  12. package/dist/control-channel.js +388 -0
  13. package/dist/cross-room.d.ts +23 -0
  14. package/dist/cross-room.js +55 -0
  15. package/dist/daemon-config-map.d.ts +61 -0
  16. package/dist/daemon-config-map.js +153 -0
  17. package/dist/daemon.d.ts +123 -0
  18. package/dist/daemon.js +349 -0
  19. package/dist/doctor.d.ts +89 -0
  20. package/dist/doctor.js +191 -0
  21. package/dist/gateway/channel-manager.d.ts +54 -0
  22. package/dist/gateway/channel-manager.js +292 -0
  23. package/dist/gateway/channels/botcord.d.ts +93 -0
  24. package/dist/gateway/channels/botcord.js +510 -0
  25. package/dist/gateway/channels/index.d.ts +2 -0
  26. package/dist/gateway/channels/index.js +1 -0
  27. package/dist/gateway/channels/sanitize.d.ts +20 -0
  28. package/dist/gateway/channels/sanitize.js +56 -0
  29. package/dist/gateway/dispatcher.d.ts +73 -0
  30. package/dist/gateway/dispatcher.js +431 -0
  31. package/dist/gateway/gateway.d.ts +87 -0
  32. package/dist/gateway/gateway.js +158 -0
  33. package/dist/gateway/index.d.ts +15 -0
  34. package/dist/gateway/index.js +15 -0
  35. package/dist/gateway/log.d.ts +9 -0
  36. package/dist/gateway/log.js +20 -0
  37. package/dist/gateway/router.d.ts +10 -0
  38. package/dist/gateway/router.js +48 -0
  39. package/dist/gateway/runtimes/claude-code.d.ts +30 -0
  40. package/dist/gateway/runtimes/claude-code.js +162 -0
  41. package/dist/gateway/runtimes/codex.d.ts +83 -0
  42. package/dist/gateway/runtimes/codex.js +272 -0
  43. package/dist/gateway/runtimes/gemini.d.ts +15 -0
  44. package/dist/gateway/runtimes/gemini.js +29 -0
  45. package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
  46. package/dist/gateway/runtimes/ndjson-stream.js +169 -0
  47. package/dist/gateway/runtimes/probe.d.ts +17 -0
  48. package/dist/gateway/runtimes/probe.js +54 -0
  49. package/dist/gateway/runtimes/registry.d.ts +59 -0
  50. package/dist/gateway/runtimes/registry.js +94 -0
  51. package/dist/gateway/session-store.d.ts +39 -0
  52. package/dist/gateway/session-store.js +133 -0
  53. package/dist/gateway/types.d.ts +265 -0
  54. package/dist/gateway/types.js +1 -0
  55. package/dist/index.d.ts +2 -0
  56. package/dist/index.js +854 -0
  57. package/dist/log.d.ts +7 -0
  58. package/dist/log.js +44 -0
  59. package/dist/provision.d.ts +88 -0
  60. package/dist/provision.js +749 -0
  61. package/dist/room-context-fetcher.d.ts +18 -0
  62. package/dist/room-context-fetcher.js +101 -0
  63. package/dist/room-context.d.ts +53 -0
  64. package/dist/room-context.js +112 -0
  65. package/dist/sender-classify.d.ts +30 -0
  66. package/dist/sender-classify.js +32 -0
  67. package/dist/snapshot-writer.d.ts +37 -0
  68. package/dist/snapshot-writer.js +84 -0
  69. package/dist/status-render.d.ts +28 -0
  70. package/dist/status-render.js +97 -0
  71. package/dist/system-context.d.ts +57 -0
  72. package/dist/system-context.js +91 -0
  73. package/dist/turn-text.d.ts +36 -0
  74. package/dist/turn-text.js +57 -0
  75. package/dist/user-auth.d.ts +75 -0
  76. package/dist/user-auth.js +245 -0
  77. package/dist/working-memory.d.ts +46 -0
  78. package/dist/working-memory.js +274 -0
  79. package/package.json +39 -0
  80. package/src/__tests__/activity-tracker.test.ts +130 -0
  81. package/src/__tests__/agent-discovery.test.ts +191 -0
  82. package/src/__tests__/agent-workspace.test.ts +147 -0
  83. package/src/__tests__/control-channel.test.ts +327 -0
  84. package/src/__tests__/cross-room.test.ts +116 -0
  85. package/src/__tests__/daemon-config-map.test.ts +416 -0
  86. package/src/__tests__/daemon.test.ts +300 -0
  87. package/src/__tests__/device-code.test.ts +152 -0
  88. package/src/__tests__/doctor.test.ts +218 -0
  89. package/src/__tests__/protocol-core-reexport.test.ts +24 -0
  90. package/src/__tests__/provision.test.ts +922 -0
  91. package/src/__tests__/room-context.test.ts +233 -0
  92. package/src/__tests__/runtime-discovery.test.ts +173 -0
  93. package/src/__tests__/snapshot-writer.test.ts +141 -0
  94. package/src/__tests__/status-render.test.ts +137 -0
  95. package/src/__tests__/system-context.test.ts +315 -0
  96. package/src/__tests__/turn-text.test.ts +116 -0
  97. package/src/__tests__/user-auth.test.ts +125 -0
  98. package/src/__tests__/working-memory.test.ts +240 -0
  99. package/src/activity-tracker.ts +140 -0
  100. package/src/adapters/runtimes.ts +30 -0
  101. package/src/agent-discovery.ts +262 -0
  102. package/src/agent-workspace.ts +247 -0
  103. package/src/config.ts +290 -0
  104. package/src/control-channel.ts +455 -0
  105. package/src/cross-room.ts +89 -0
  106. package/src/daemon-config-map.ts +200 -0
  107. package/src/daemon.ts +478 -0
  108. package/src/doctor.ts +282 -0
  109. package/src/gateway/__tests__/.gitkeep +0 -0
  110. package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
  111. package/src/gateway/__tests__/channel-manager.test.ts +475 -0
  112. package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
  113. package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
  114. package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
  115. package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
  116. package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
  117. package/src/gateway/__tests__/gateway.test.ts +222 -0
  118. package/src/gateway/__tests__/router.test.ts +247 -0
  119. package/src/gateway/__tests__/sanitize.test.ts +193 -0
  120. package/src/gateway/__tests__/session-store.test.ts +235 -0
  121. package/src/gateway/channel-manager.ts +349 -0
  122. package/src/gateway/channels/botcord.ts +605 -0
  123. package/src/gateway/channels/index.ts +6 -0
  124. package/src/gateway/channels/sanitize.ts +68 -0
  125. package/src/gateway/dispatcher.ts +554 -0
  126. package/src/gateway/gateway.ts +211 -0
  127. package/src/gateway/index.ts +29 -0
  128. package/src/gateway/log.ts +30 -0
  129. package/src/gateway/router.ts +60 -0
  130. package/src/gateway/runtimes/claude-code.ts +180 -0
  131. package/src/gateway/runtimes/codex.ts +312 -0
  132. package/src/gateway/runtimes/gemini.ts +43 -0
  133. package/src/gateway/runtimes/ndjson-stream.ts +225 -0
  134. package/src/gateway/runtimes/probe.ts +73 -0
  135. package/src/gateway/runtimes/registry.ts +143 -0
  136. package/src/gateway/session-store.ts +157 -0
  137. package/src/gateway/types.ts +325 -0
  138. package/src/index.ts +961 -0
  139. package/src/log.ts +47 -0
  140. package/src/provision.ts +879 -0
  141. package/src/room-context-fetcher.ts +124 -0
  142. package/src/room-context.ts +167 -0
  143. package/src/sender-classify.ts +46 -0
  144. package/src/snapshot-writer.ts +103 -0
  145. package/src/status-render.ts +132 -0
  146. package/src/system-context.ts +162 -0
  147. package/src/turn-text.ts +93 -0
  148. package/src/user-auth.ts +295 -0
  149. package/src/working-memory.ts +352 -0
@@ -0,0 +1,180 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { Gateway } from "../gateway.js";
6
+ import type {
7
+ ChannelAdapter,
8
+ ChannelSendContext,
9
+ ChannelSendResult,
10
+ ChannelStartContext,
11
+ ChannelStopContext,
12
+ GatewayChannelConfig,
13
+ RuntimeAdapter,
14
+ RuntimeRunResult,
15
+ } from "../types.js";
16
+ import type { GatewayLogger } from "../log.js";
17
+
18
+ function silentLogger(): GatewayLogger {
19
+ return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
20
+ }
21
+
22
+ class StubChannel implements ChannelAdapter {
23
+ readonly type = "stub";
24
+ readonly sends: ChannelSendContext[] = [];
25
+ readonly stops: ChannelStopContext[] = [];
26
+ starts: ChannelStartContext[] = [];
27
+
28
+ constructor(public readonly id: string, public readonly accountId: string) {}
29
+
30
+ async start(ctx: ChannelStartContext): Promise<void> {
31
+ this.starts.push(ctx);
32
+ await new Promise<void>((resolve) => {
33
+ ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true });
34
+ });
35
+ }
36
+
37
+ async stop(ctx: ChannelStopContext): Promise<void> {
38
+ this.stops.push(ctx);
39
+ }
40
+
41
+ async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
42
+ this.sends.push(ctx);
43
+ return {};
44
+ }
45
+ }
46
+
47
+ class StubRuntime implements RuntimeAdapter {
48
+ readonly id = "claude-code";
49
+ async run(): Promise<RuntimeRunResult> {
50
+ return { text: "ok", newSessionId: "s1" };
51
+ }
52
+ }
53
+
54
+ describe("Gateway.addChannel / removeChannel", () => {
55
+ let dirs: string[] = [];
56
+
57
+ beforeEach(() => {
58
+ dirs = [];
59
+ });
60
+
61
+ afterEach(async () => {
62
+ for (const d of dirs) await rm(d, { recursive: true, force: true });
63
+ });
64
+
65
+ async function makeTempPath(): Promise<string> {
66
+ const dir = await mkdtemp(path.join(tmpdir(), "gw-add-"));
67
+ dirs.push(dir);
68
+ return path.join(dir, "sessions.json");
69
+ }
70
+
71
+ it("addChannel starts a new channel without restarting existing ones", async () => {
72
+ const createChannel = vi.fn(
73
+ (cfg: GatewayChannelConfig) => new StubChannel(cfg.id, cfg.accountId),
74
+ );
75
+ const gw = new Gateway({
76
+ config: {
77
+ channels: [{ id: "ag_a", type: "stub", accountId: "ag_a" }],
78
+ defaultRoute: { runtime: "claude-code", cwd: "/tmp" },
79
+ routes: [],
80
+ },
81
+ sessionStorePath: await makeTempPath(),
82
+ createChannel,
83
+ createRuntime: () => new StubRuntime(),
84
+ log: silentLogger(),
85
+ });
86
+
87
+ await gw.start();
88
+ await new Promise((r) => setTimeout(r, 5));
89
+ expect(createChannel).toHaveBeenCalledTimes(1);
90
+
91
+ await gw.addChannel({ id: "ag_b", type: "stub", accountId: "ag_b" });
92
+ await new Promise((r) => setTimeout(r, 5));
93
+
94
+ expect(createChannel).toHaveBeenCalledTimes(2);
95
+ const snap = gw.snapshot();
96
+ expect(Object.keys(snap.channels).sort()).toEqual(["ag_a", "ag_b"]);
97
+ expect(snap.channels["ag_a"].running).toBe(true);
98
+ expect(snap.channels["ag_b"].running).toBe(true);
99
+
100
+ await gw.stop();
101
+ });
102
+
103
+ it("addChannel rejects duplicate id", async () => {
104
+ const createChannel = (cfg: GatewayChannelConfig) =>
105
+ new StubChannel(cfg.id, cfg.accountId);
106
+ const gw = new Gateway({
107
+ config: {
108
+ channels: [{ id: "ag_a", type: "stub", accountId: "ag_a" }],
109
+ defaultRoute: { runtime: "claude-code", cwd: "/tmp" },
110
+ routes: [],
111
+ },
112
+ sessionStorePath: await makeTempPath(),
113
+ createChannel,
114
+ createRuntime: () => new StubRuntime(),
115
+ log: silentLogger(),
116
+ });
117
+ await gw.start();
118
+
119
+ await expect(
120
+ gw.addChannel({ id: "ag_a", type: "stub", accountId: "ag_a" }),
121
+ ).rejects.toThrow(/already registered/);
122
+ await gw.stop();
123
+ });
124
+
125
+ it("removeChannel stops the channel and drops it from the snapshot", async () => {
126
+ const channels: StubChannel[] = [];
127
+ const createChannel = (cfg: GatewayChannelConfig) => {
128
+ const c = new StubChannel(cfg.id, cfg.accountId);
129
+ channels.push(c);
130
+ return c;
131
+ };
132
+ const gw = new Gateway({
133
+ config: {
134
+ channels: [
135
+ { id: "ag_a", type: "stub", accountId: "ag_a" },
136
+ { id: "ag_b", type: "stub", accountId: "ag_b" },
137
+ ],
138
+ defaultRoute: { runtime: "claude-code", cwd: "/tmp" },
139
+ routes: [],
140
+ },
141
+ sessionStorePath: await makeTempPath(),
142
+ createChannel,
143
+ createRuntime: () => new StubRuntime(),
144
+ log: silentLogger(),
145
+ });
146
+
147
+ await gw.start();
148
+ await new Promise((r) => setTimeout(r, 5));
149
+
150
+ await gw.removeChannel("ag_a", "test");
151
+
152
+ const snap = gw.snapshot();
153
+ expect(Object.keys(snap.channels)).toEqual(["ag_b"]);
154
+ const a = channels.find((c) => c.id === "ag_a")!;
155
+ expect(a.stops.length).toBe(1);
156
+ expect(a.starts[0].abortSignal.aborted).toBe(true);
157
+ // ag_b untouched
158
+ const b = channels.find((c) => c.id === "ag_b")!;
159
+ expect(b.stops.length).toBe(0);
160
+
161
+ await gw.stop();
162
+ });
163
+
164
+ it("removeChannel is a no-op on unknown id", async () => {
165
+ const gw = new Gateway({
166
+ config: {
167
+ channels: [{ id: "ag_a", type: "stub", accountId: "ag_a" }],
168
+ defaultRoute: { runtime: "claude-code", cwd: "/tmp" },
169
+ routes: [],
170
+ },
171
+ sessionStorePath: await makeTempPath(),
172
+ createChannel: (cfg) => new StubChannel(cfg.id, cfg.accountId),
173
+ createRuntime: () => new StubRuntime(),
174
+ log: silentLogger(),
175
+ });
176
+ await gw.start();
177
+ await expect(gw.removeChannel("ag_nope")).resolves.toBeUndefined();
178
+ await gw.stop();
179
+ });
180
+ });
@@ -0,0 +1,181 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { Gateway } from "../gateway.js";
6
+ import { resolveRoute } from "../router.js";
7
+ import type {
8
+ ChannelAdapter,
9
+ ChannelSendContext,
10
+ ChannelSendResult,
11
+ ChannelStartContext,
12
+ ChannelStopContext,
13
+ GatewayChannelConfig,
14
+ GatewayInboundMessage,
15
+ GatewayRoute,
16
+ } from "../types.js";
17
+ import type { GatewayLogger } from "../log.js";
18
+
19
+ function silentLogger(): GatewayLogger {
20
+ return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
21
+ }
22
+
23
+ class StubChannel implements ChannelAdapter {
24
+ readonly type = "stub";
25
+ constructor(public readonly id: string, public readonly accountId: string) {}
26
+ async start(ctx: ChannelStartContext): Promise<void> {
27
+ await new Promise<void>((resolve) => {
28
+ ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true });
29
+ });
30
+ }
31
+ async stop(_ctx: ChannelStopContext): Promise<void> {}
32
+ async send(_ctx: ChannelSendContext): Promise<ChannelSendResult> {
33
+ return {};
34
+ }
35
+ }
36
+
37
+ function makeMessage(overrides: Partial<GatewayInboundMessage> = {}): GatewayInboundMessage {
38
+ return {
39
+ id: "m_1",
40
+ channel: "botcord",
41
+ accountId: "ag_1",
42
+ conversation: { id: "rm_1", kind: "group" },
43
+ sender: { id: "ag_sender", kind: "agent" },
44
+ text: "hi",
45
+ raw: {},
46
+ receivedAt: 0,
47
+ ...overrides,
48
+ };
49
+ }
50
+
51
+ function makeRoute(overrides: Partial<GatewayRoute> = {}): GatewayRoute {
52
+ return { runtime: "claude-code", cwd: "/tmp", ...overrides };
53
+ }
54
+
55
+ describe("Gateway managed-route API", () => {
56
+ const defaultRoute: GatewayRoute = makeRoute({ runtime: "default", cwd: "/default" });
57
+ let dirs: string[] = [];
58
+ let gateway: Gateway | null = null;
59
+
60
+ beforeEach(() => {
61
+ dirs = [];
62
+ gateway = null;
63
+ });
64
+
65
+ afterEach(async () => {
66
+ if (gateway) await gateway.stop("test");
67
+ for (const d of dirs) await rm(d, { recursive: true, force: true });
68
+ });
69
+
70
+ async function makeGateway(
71
+ channels: GatewayChannelConfig[] = [],
72
+ userRoutes: GatewayRoute[] = [],
73
+ ): Promise<Gateway> {
74
+ const dir = await mkdtemp(path.join(tmpdir(), "gw-mr-"));
75
+ dirs.push(dir);
76
+ const gw = new Gateway({
77
+ config: { channels, defaultRoute, routes: userRoutes },
78
+ sessionStorePath: path.join(dir, "sessions.json"),
79
+ createChannel: (cfg) => new StubChannel(cfg.id, cfg.accountId),
80
+ log: silentLogger(),
81
+ });
82
+ gateway = gw;
83
+ return gw;
84
+ }
85
+
86
+ it("upsertManagedRoute adds a route and listManagedRoutes reflects it", async () => {
87
+ const gw = await makeGateway();
88
+ const route = makeRoute({ runtime: "r1", cwd: "/ws/ag_1", match: { accountId: "ag_1" } });
89
+ gw.upsertManagedRoute("ag_1", route);
90
+ expect(gw.listManagedRoutes()).toEqual([route]);
91
+ });
92
+
93
+ it("upsertManagedRoute on existing accountId replaces (no duplicate)", async () => {
94
+ const gw = await makeGateway();
95
+ const first = makeRoute({ runtime: "r1", match: { accountId: "ag_1" } });
96
+ const second = makeRoute({ runtime: "r2", match: { accountId: "ag_1" } });
97
+ gw.upsertManagedRoute("ag_1", first);
98
+ gw.upsertManagedRoute("ag_1", second);
99
+ expect(gw.listManagedRoutes()).toEqual([second]);
100
+ });
101
+
102
+ it("removeManagedRoute drops the entry; cfg.routes with same accountId untouched", async () => {
103
+ const user = makeRoute({ runtime: "user", match: { accountId: "ag_1" } });
104
+ const gw = await makeGateway([], [user]);
105
+ gw.upsertManagedRoute("ag_1", makeRoute({ runtime: "managed", match: { accountId: "ag_1" } }));
106
+ gw.removeManagedRoute("ag_1");
107
+ expect(gw.listManagedRoutes()).toEqual([]);
108
+
109
+ const msg = makeMessage({ accountId: "ag_1" });
110
+ expect(resolveRoute(msg, { defaultRoute, routes: [user] }, gw.listManagedRoutes())).toBe(user);
111
+ });
112
+
113
+ it("removeManagedRoute on unknown accountId is a no-op", async () => {
114
+ const gw = await makeGateway();
115
+ expect(() => gw.removeManagedRoute("ag_missing")).not.toThrow();
116
+ expect(gw.listManagedRoutes()).toEqual([]);
117
+ });
118
+
119
+ it("replaceManagedRoutes(new Map()) wipes synthesized without touching cfg.routes", async () => {
120
+ const user = makeRoute({ runtime: "user", match: { accountId: "ag_2" } });
121
+ const gw = await makeGateway([], [user]);
122
+ gw.upsertManagedRoute("ag_1", makeRoute({ runtime: "m1" }));
123
+ gw.upsertManagedRoute("ag_9", makeRoute({ runtime: "m2" }));
124
+ gw.replaceManagedRoutes(new Map());
125
+ expect(gw.listManagedRoutes()).toEqual([]);
126
+
127
+ const msg = makeMessage({ accountId: "ag_2" });
128
+ expect(resolveRoute(msg, { defaultRoute, routes: [user] }, gw.listManagedRoutes())).toBe(user);
129
+ });
130
+
131
+ it("replaceManagedRoutes swaps contents atomically", async () => {
132
+ const gw = await makeGateway();
133
+ gw.upsertManagedRoute("ag_old", makeRoute({ runtime: "old" }));
134
+ const next = new Map<string, GatewayRoute>();
135
+ const newRoute = makeRoute({ runtime: "new", match: { accountId: "ag_new" } });
136
+ next.set("ag_new", newRoute);
137
+ gw.replaceManagedRoutes(next);
138
+ expect(gw.listManagedRoutes()).toEqual([newRoute]);
139
+ });
140
+
141
+ it("replaceManagedRoutes decouples from the caller's Map", async () => {
142
+ const gw = await makeGateway();
143
+ const src = new Map<string, GatewayRoute>();
144
+ src.set("ag_1", makeRoute({ runtime: "m1" }));
145
+ gw.replaceManagedRoutes(src);
146
+ src.clear();
147
+ expect(gw.listManagedRoutes()).toHaveLength(1);
148
+ });
149
+
150
+ it("ctor logs (not silently drops) seed managed routes missing accountId", async () => {
151
+ const dir = await mkdtemp(path.join(tmpdir(), "gw-mr-bad-"));
152
+ dirs.push(dir);
153
+ const warnCalls: unknown[][] = [];
154
+ const log: GatewayLogger = {
155
+ info: () => {},
156
+ warn: (...args: unknown[]) => warnCalls.push(args),
157
+ error: () => {},
158
+ debug: () => {},
159
+ };
160
+ const gw = new Gateway({
161
+ config: {
162
+ channels: [],
163
+ defaultRoute,
164
+ routes: [],
165
+ managedRoutes: [
166
+ { match: { accountId: "ag_ok" }, runtime: "r", cwd: "/w/ok" },
167
+ // match.accountId missing — must not crash, must log
168
+ { match: {}, runtime: "r", cwd: "/w/bad" },
169
+ // match undefined — same
170
+ { runtime: "r", cwd: "/w/bad2" },
171
+ ],
172
+ },
173
+ sessionStorePath: path.join(dir, "sessions.json"),
174
+ createChannel: (cfg) => new StubChannel(cfg.id, cfg.accountId),
175
+ log,
176
+ });
177
+ gateway = gw;
178
+ expect(gw.listManagedRoutes()).toHaveLength(1);
179
+ expect(warnCalls.length).toBe(2);
180
+ });
181
+ });
@@ -0,0 +1,222 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { Gateway } from "../gateway.js";
6
+ import type {
7
+ ChannelAdapter,
8
+ ChannelSendContext,
9
+ ChannelSendResult,
10
+ ChannelStartContext,
11
+ ChannelStopContext,
12
+ GatewayChannelConfig,
13
+ GatewayConfig,
14
+ GatewayInboundEnvelope,
15
+ RuntimeAdapter,
16
+ RuntimeRunResult,
17
+ } from "../types.js";
18
+ import type { GatewayLogger } from "../log.js";
19
+
20
+ function silentLogger(): GatewayLogger {
21
+ return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
22
+ }
23
+
24
+ interface StartCall {
25
+ ctx: ChannelStartContext;
26
+ resolve: () => void;
27
+ promise: Promise<void>;
28
+ }
29
+
30
+ class StubChannel implements ChannelAdapter {
31
+ readonly type = "stub";
32
+ readonly sends: ChannelSendContext[] = [];
33
+ readonly stops: ChannelStopContext[] = [];
34
+ starts: StartCall[] = [];
35
+
36
+ constructor(public readonly id: string, public readonly accountId: string) {}
37
+
38
+ async start(ctx: ChannelStartContext): Promise<void> {
39
+ let resolveFn!: () => void;
40
+ const p = new Promise<void>((r) => {
41
+ resolveFn = r;
42
+ });
43
+ const call: StartCall = { ctx, resolve: resolveFn, promise: p };
44
+ this.starts.push(call);
45
+ ctx.abortSignal.addEventListener("abort", () => call.resolve(), { once: true });
46
+ await p;
47
+ }
48
+
49
+ async stop(ctx: ChannelStopContext): Promise<void> {
50
+ this.stops.push(ctx);
51
+ }
52
+
53
+ async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
54
+ this.sends.push(ctx);
55
+ return {};
56
+ }
57
+
58
+ async emit(env: GatewayInboundEnvelope): Promise<void> {
59
+ await this.starts[this.starts.length - 1].ctx.emit(env);
60
+ }
61
+ }
62
+
63
+ class StubRuntime implements RuntimeAdapter {
64
+ constructor(public readonly id: string) {}
65
+ async run(): Promise<RuntimeRunResult> {
66
+ return { text: "runtime-reply", newSessionId: "sid-1" };
67
+ }
68
+ }
69
+
70
+ function baseConfig(): GatewayConfig {
71
+ return {
72
+ channels: [
73
+ { id: "botcord-main", type: "botcord", accountId: "ag_me" },
74
+ { id: "botcord-b", type: "botcord", accountId: "ag_b" },
75
+ ],
76
+ defaultRoute: { runtime: "claude-code", cwd: "/tmp/cwd" },
77
+ routes: [],
78
+ };
79
+ }
80
+
81
+ describe("Gateway", () => {
82
+ let dirs: string[] = [];
83
+
84
+ beforeEach(() => {
85
+ dirs = [];
86
+ });
87
+
88
+ afterEach(async () => {
89
+ for (const d of dirs) await rm(d, { recursive: true, force: true });
90
+ });
91
+
92
+ async function makeTempPath(): Promise<string> {
93
+ const dir = await mkdtemp(path.join(tmpdir(), "gw-gateway-"));
94
+ dirs.push(dir);
95
+ return path.join(dir, "sessions.json");
96
+ }
97
+
98
+ it("start() calls createChannel for each config entry, loads session store, starts ChannelManager", async () => {
99
+ const createChannel = vi.fn((cfg: GatewayChannelConfig) => new StubChannel(cfg.id, cfg.accountId));
100
+ const sessionStorePath = await makeTempPath();
101
+
102
+ const gw = new Gateway({
103
+ config: baseConfig(),
104
+ sessionStorePath,
105
+ createChannel,
106
+ createRuntime: (id) => new StubRuntime(id),
107
+ log: silentLogger(),
108
+ });
109
+
110
+ await gw.start();
111
+
112
+ expect(createChannel).toHaveBeenCalledTimes(2);
113
+ expect(createChannel.mock.calls[0][0].id).toBe("botcord-main");
114
+ expect(createChannel.mock.calls[1][0].id).toBe("botcord-b");
115
+
116
+ const snap = gw.snapshot();
117
+ expect(Object.keys(snap.channels).sort()).toEqual(["botcord-b", "botcord-main"]);
118
+ expect(snap.channels["botcord-main"].running).toBe(true);
119
+
120
+ await gw.stop("test");
121
+ });
122
+
123
+ it("inbound envelope from a channel reaches the runtime and a reply is sent via that channel", async () => {
124
+ const channels: StubChannel[] = [];
125
+ const createChannel = (cfg: GatewayChannelConfig) => {
126
+ const c = new StubChannel(cfg.id, cfg.accountId);
127
+ channels.push(c);
128
+ return c;
129
+ };
130
+ const runtime = new StubRuntime("claude-code");
131
+ const createRuntime = vi.fn(() => runtime);
132
+
133
+ const gw = new Gateway({
134
+ config: {
135
+ channels: [{ id: "botcord-main", type: "botcord", accountId: "ag_me" }],
136
+ defaultRoute: { runtime: "claude-code", cwd: "/tmp/cwd" },
137
+ routes: [],
138
+ },
139
+ sessionStorePath: await makeTempPath(),
140
+ createChannel,
141
+ createRuntime,
142
+ log: silentLogger(),
143
+ });
144
+
145
+ await gw.start();
146
+ const ch = channels[0];
147
+ // Wait for ChannelManager's microtask that transitions starting→running.
148
+ await new Promise((r) => setTimeout(r, 5));
149
+ expect(ch.starts.length).toBe(1);
150
+
151
+ const accept = vi.fn(async () => {});
152
+ await ch.emit({
153
+ message: {
154
+ id: "m1",
155
+ channel: "botcord-main",
156
+ accountId: "ag_me",
157
+ conversation: { id: "rm_oc_1", kind: "direct" },
158
+ sender: { id: "ag_peer", kind: "agent" },
159
+ text: "hi there",
160
+ raw: {},
161
+ receivedAt: Date.now(),
162
+ },
163
+ ack: { accept },
164
+ });
165
+
166
+ expect(accept).toHaveBeenCalledTimes(1);
167
+ expect(createRuntime).toHaveBeenCalledWith("claude-code", undefined);
168
+ expect(ch.sends.length).toBe(1);
169
+ expect(ch.sends[0].message.text).toBe("runtime-reply");
170
+ expect(ch.sends[0].message.replyTo).toBe("m1");
171
+
172
+ await gw.stop();
173
+ });
174
+
175
+ it("snapshot() returns { channels, turns } with expected shape", async () => {
176
+ const createChannel = (cfg: GatewayChannelConfig) => new StubChannel(cfg.id, cfg.accountId);
177
+ const gw = new Gateway({
178
+ config: baseConfig(),
179
+ sessionStorePath: await makeTempPath(),
180
+ createChannel,
181
+ createRuntime: (id) => new StubRuntime(id),
182
+ log: silentLogger(),
183
+ });
184
+
185
+ const snap0 = gw.snapshot();
186
+ expect(snap0).toHaveProperty("channels");
187
+ expect(snap0).toHaveProperty("turns");
188
+ expect(snap0.turns).toEqual({});
189
+
190
+ await gw.start();
191
+ const snap1 = gw.snapshot();
192
+ expect(Object.keys(snap1.channels).length).toBe(2);
193
+ await gw.stop();
194
+ });
195
+
196
+ it("stop() aborts running channels and is idempotent", async () => {
197
+ const channels: StubChannel[] = [];
198
+ const createChannel = (cfg: GatewayChannelConfig) => {
199
+ const c = new StubChannel(cfg.id, cfg.accountId);
200
+ channels.push(c);
201
+ return c;
202
+ };
203
+ const gw = new Gateway({
204
+ config: baseConfig(),
205
+ sessionStorePath: await makeTempPath(),
206
+ createChannel,
207
+ createRuntime: (id) => new StubRuntime(id),
208
+ log: silentLogger(),
209
+ });
210
+
211
+ await gw.start();
212
+ await new Promise((r) => setTimeout(r, 5));
213
+ await gw.stop("first");
214
+ await gw.stop("second"); // idempotent
215
+
216
+ for (const c of channels) {
217
+ expect(c.stops.length).toBe(1);
218
+ expect(c.starts[0].ctx.abortSignal.aborted).toBe(true);
219
+ }
220
+ expect(gw.snapshot().channels["botcord-main"].running).toBe(false);
221
+ });
222
+ });