@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.
- package/dist/activity-tracker.d.ts +43 -0
- package/dist/activity-tracker.js +110 -0
- package/dist/adapters/runtimes.d.ts +14 -0
- package/dist/adapters/runtimes.js +18 -0
- package/dist/agent-discovery.d.ts +81 -0
- package/dist/agent-discovery.js +181 -0
- package/dist/agent-workspace.d.ts +31 -0
- package/dist/agent-workspace.js +221 -0
- package/dist/config.d.ts +116 -0
- package/dist/config.js +180 -0
- package/dist/control-channel.d.ts +99 -0
- package/dist/control-channel.js +388 -0
- package/dist/cross-room.d.ts +23 -0
- package/dist/cross-room.js +55 -0
- package/dist/daemon-config-map.d.ts +61 -0
- package/dist/daemon-config-map.js +153 -0
- package/dist/daemon.d.ts +123 -0
- package/dist/daemon.js +349 -0
- package/dist/doctor.d.ts +89 -0
- package/dist/doctor.js +191 -0
- package/dist/gateway/channel-manager.d.ts +54 -0
- package/dist/gateway/channel-manager.js +292 -0
- package/dist/gateway/channels/botcord.d.ts +93 -0
- package/dist/gateway/channels/botcord.js +510 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +1 -0
- package/dist/gateway/channels/sanitize.d.ts +20 -0
- package/dist/gateway/channels/sanitize.js +56 -0
- package/dist/gateway/dispatcher.d.ts +73 -0
- package/dist/gateway/dispatcher.js +431 -0
- package/dist/gateway/gateway.d.ts +87 -0
- package/dist/gateway/gateway.js +158 -0
- package/dist/gateway/index.d.ts +15 -0
- package/dist/gateway/index.js +15 -0
- package/dist/gateway/log.d.ts +9 -0
- package/dist/gateway/log.js +20 -0
- package/dist/gateway/router.d.ts +10 -0
- package/dist/gateway/router.js +48 -0
- package/dist/gateway/runtimes/claude-code.d.ts +30 -0
- package/dist/gateway/runtimes/claude-code.js +162 -0
- package/dist/gateway/runtimes/codex.d.ts +83 -0
- package/dist/gateway/runtimes/codex.js +272 -0
- package/dist/gateway/runtimes/gemini.d.ts +15 -0
- package/dist/gateway/runtimes/gemini.js +29 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
- package/dist/gateway/runtimes/ndjson-stream.js +169 -0
- package/dist/gateway/runtimes/probe.d.ts +17 -0
- package/dist/gateway/runtimes/probe.js +54 -0
- package/dist/gateway/runtimes/registry.d.ts +59 -0
- package/dist/gateway/runtimes/registry.js +94 -0
- package/dist/gateway/session-store.d.ts +39 -0
- package/dist/gateway/session-store.js +133 -0
- package/dist/gateway/types.d.ts +265 -0
- package/dist/gateway/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +854 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +44 -0
- package/dist/provision.d.ts +88 -0
- package/dist/provision.js +749 -0
- package/dist/room-context-fetcher.d.ts +18 -0
- package/dist/room-context-fetcher.js +101 -0
- package/dist/room-context.d.ts +53 -0
- package/dist/room-context.js +112 -0
- package/dist/sender-classify.d.ts +30 -0
- package/dist/sender-classify.js +32 -0
- package/dist/snapshot-writer.d.ts +37 -0
- package/dist/snapshot-writer.js +84 -0
- package/dist/status-render.d.ts +28 -0
- package/dist/status-render.js +97 -0
- package/dist/system-context.d.ts +57 -0
- package/dist/system-context.js +91 -0
- package/dist/turn-text.d.ts +36 -0
- package/dist/turn-text.js +57 -0
- package/dist/user-auth.d.ts +75 -0
- package/dist/user-auth.js +245 -0
- package/dist/working-memory.d.ts +46 -0
- package/dist/working-memory.js +274 -0
- package/package.json +39 -0
- package/src/__tests__/activity-tracker.test.ts +130 -0
- package/src/__tests__/agent-discovery.test.ts +191 -0
- package/src/__tests__/agent-workspace.test.ts +147 -0
- package/src/__tests__/control-channel.test.ts +327 -0
- package/src/__tests__/cross-room.test.ts +116 -0
- package/src/__tests__/daemon-config-map.test.ts +416 -0
- package/src/__tests__/daemon.test.ts +300 -0
- package/src/__tests__/device-code.test.ts +152 -0
- package/src/__tests__/doctor.test.ts +218 -0
- package/src/__tests__/protocol-core-reexport.test.ts +24 -0
- package/src/__tests__/provision.test.ts +922 -0
- package/src/__tests__/room-context.test.ts +233 -0
- package/src/__tests__/runtime-discovery.test.ts +173 -0
- package/src/__tests__/snapshot-writer.test.ts +141 -0
- package/src/__tests__/status-render.test.ts +137 -0
- package/src/__tests__/system-context.test.ts +315 -0
- package/src/__tests__/turn-text.test.ts +116 -0
- package/src/__tests__/user-auth.test.ts +125 -0
- package/src/__tests__/working-memory.test.ts +240 -0
- package/src/activity-tracker.ts +140 -0
- package/src/adapters/runtimes.ts +30 -0
- package/src/agent-discovery.ts +262 -0
- package/src/agent-workspace.ts +247 -0
- package/src/config.ts +290 -0
- package/src/control-channel.ts +455 -0
- package/src/cross-room.ts +89 -0
- package/src/daemon-config-map.ts +200 -0
- package/src/daemon.ts +478 -0
- package/src/doctor.ts +282 -0
- package/src/gateway/__tests__/.gitkeep +0 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
- package/src/gateway/__tests__/channel-manager.test.ts +475 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
- package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
- package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
- package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
- package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
- package/src/gateway/__tests__/gateway.test.ts +222 -0
- package/src/gateway/__tests__/router.test.ts +247 -0
- package/src/gateway/__tests__/sanitize.test.ts +193 -0
- package/src/gateway/__tests__/session-store.test.ts +235 -0
- package/src/gateway/channel-manager.ts +349 -0
- package/src/gateway/channels/botcord.ts +605 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/sanitize.ts +68 -0
- package/src/gateway/dispatcher.ts +554 -0
- package/src/gateway/gateway.ts +211 -0
- package/src/gateway/index.ts +29 -0
- package/src/gateway/log.ts +30 -0
- package/src/gateway/router.ts +60 -0
- package/src/gateway/runtimes/claude-code.ts +180 -0
- package/src/gateway/runtimes/codex.ts +312 -0
- package/src/gateway/runtimes/gemini.ts +43 -0
- package/src/gateway/runtimes/ndjson-stream.ts +225 -0
- package/src/gateway/runtimes/probe.ts +73 -0
- package/src/gateway/runtimes/registry.ts +143 -0
- package/src/gateway/session-store.ts +157 -0
- package/src/gateway/types.ts +325 -0
- package/src/index.ts +961 -0
- package/src/log.ts +47 -0
- package/src/provision.ts +879 -0
- package/src/room-context-fetcher.ts +124 -0
- package/src/room-context.ts +167 -0
- package/src/sender-classify.ts +46 -0
- package/src/snapshot-writer.ts +103 -0
- package/src/status-render.ts +132 -0
- package/src/system-context.ts +162 -0
- package/src/turn-text.ts +93 -0
- package/src/user-auth.ts +295 -0
- package/src/working-memory.ts +352 -0
|
@@ -0,0 +1,1159 @@
|
|
|
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 { Dispatcher, type RuntimeFactory } from "../dispatcher.js";
|
|
6
|
+
import { SessionStore } from "../session-store.js";
|
|
7
|
+
import type {
|
|
8
|
+
ChannelAdapter,
|
|
9
|
+
ChannelSendContext,
|
|
10
|
+
ChannelSendResult,
|
|
11
|
+
ChannelStreamBlockContext,
|
|
12
|
+
GatewayConfig,
|
|
13
|
+
GatewayInboundEnvelope,
|
|
14
|
+
GatewayInboundMessage,
|
|
15
|
+
RuntimeAdapter,
|
|
16
|
+
RuntimeRunOptions,
|
|
17
|
+
RuntimeRunResult,
|
|
18
|
+
StreamBlock,
|
|
19
|
+
} from "../types.js";
|
|
20
|
+
import type { GatewayLogger } from "../log.js";
|
|
21
|
+
|
|
22
|
+
function silentLogger(): GatewayLogger {
|
|
23
|
+
return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface FakeChannelOptions {
|
|
27
|
+
id?: string;
|
|
28
|
+
withStream?: boolean;
|
|
29
|
+
sendImpl?: (ctx: ChannelSendContext) => Promise<ChannelSendResult> | ChannelSendResult;
|
|
30
|
+
streamImpl?: (ctx: ChannelStreamBlockContext) => Promise<void> | void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class FakeChannel implements ChannelAdapter {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly type = "fake";
|
|
36
|
+
readonly sends: ChannelSendContext[] = [];
|
|
37
|
+
readonly streams: ChannelStreamBlockContext[] = [];
|
|
38
|
+
private readonly sendImpl?: FakeChannelOptions["sendImpl"];
|
|
39
|
+
private readonly streamImpl?: FakeChannelOptions["streamImpl"];
|
|
40
|
+
streamBlock?: (ctx: ChannelStreamBlockContext) => Promise<void>;
|
|
41
|
+
|
|
42
|
+
constructor(opts: FakeChannelOptions = {}) {
|
|
43
|
+
this.id = opts.id ?? "botcord";
|
|
44
|
+
this.sendImpl = opts.sendImpl;
|
|
45
|
+
this.streamImpl = opts.streamImpl;
|
|
46
|
+
if (opts.withStream !== false) {
|
|
47
|
+
this.streamBlock = async (ctx) => {
|
|
48
|
+
this.streams.push(ctx);
|
|
49
|
+
if (this.streamImpl) await this.streamImpl(ctx);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async start(): Promise<void> {}
|
|
55
|
+
async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
|
|
56
|
+
this.sends.push(ctx);
|
|
57
|
+
if (this.sendImpl) return this.sendImpl(ctx);
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface FakeRuntimeOptions {
|
|
63
|
+
id?: string;
|
|
64
|
+
reply?: string;
|
|
65
|
+
newSessionId?: string | ((opts: RuntimeRunOptions) => string);
|
|
66
|
+
delayMs?: number;
|
|
67
|
+
throwError?: Error | string;
|
|
68
|
+
errorText?: string;
|
|
69
|
+
blocks?: StreamBlock[];
|
|
70
|
+
hang?: boolean;
|
|
71
|
+
observeRun?: (opts: RuntimeRunOptions) => void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class FakeRuntime implements RuntimeAdapter {
|
|
75
|
+
readonly id: string;
|
|
76
|
+
readonly calls: RuntimeRunOptions[] = [];
|
|
77
|
+
private readonly opts: FakeRuntimeOptions;
|
|
78
|
+
|
|
79
|
+
constructor(opts: FakeRuntimeOptions = {}) {
|
|
80
|
+
this.id = opts.id ?? "claude-code";
|
|
81
|
+
this.opts = opts;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async run(options: RuntimeRunOptions): Promise<RuntimeRunResult> {
|
|
85
|
+
this.calls.push(options);
|
|
86
|
+
this.opts.observeRun?.(options);
|
|
87
|
+
if (this.opts.blocks) {
|
|
88
|
+
for (const b of this.opts.blocks) options.onBlock?.(b);
|
|
89
|
+
}
|
|
90
|
+
if (this.opts.hang) {
|
|
91
|
+
// Never resolve naturally; wait for abort.
|
|
92
|
+
await new Promise<void>((resolve, reject) => {
|
|
93
|
+
options.signal.addEventListener(
|
|
94
|
+
"abort",
|
|
95
|
+
() => reject(new Error("aborted")),
|
|
96
|
+
{ once: true },
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (this.opts.delayMs) {
|
|
101
|
+
await new Promise<void>((resolve, reject) => {
|
|
102
|
+
const t = setTimeout(resolve, this.opts.delayMs);
|
|
103
|
+
options.signal.addEventListener(
|
|
104
|
+
"abort",
|
|
105
|
+
() => {
|
|
106
|
+
clearTimeout(t);
|
|
107
|
+
reject(new Error("aborted"));
|
|
108
|
+
},
|
|
109
|
+
{ once: true },
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (this.opts.throwError) {
|
|
114
|
+
throw typeof this.opts.throwError === "string"
|
|
115
|
+
? new Error(this.opts.throwError)
|
|
116
|
+
: this.opts.throwError;
|
|
117
|
+
}
|
|
118
|
+
const newSessionId =
|
|
119
|
+
typeof this.opts.newSessionId === "function"
|
|
120
|
+
? this.opts.newSessionId(options)
|
|
121
|
+
: this.opts.newSessionId ?? "sid-1";
|
|
122
|
+
return {
|
|
123
|
+
text: this.opts.reply ?? "hello back",
|
|
124
|
+
newSessionId,
|
|
125
|
+
...(this.opts.errorText ? { error: this.opts.errorText } : {}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function makeMessage(partial: Partial<GatewayInboundMessage> = {}): GatewayInboundMessage {
|
|
131
|
+
return {
|
|
132
|
+
id: partial.id ?? "hub_msg_abc",
|
|
133
|
+
channel: partial.channel ?? "botcord",
|
|
134
|
+
accountId: partial.accountId ?? "ag_me",
|
|
135
|
+
conversation: partial.conversation ?? {
|
|
136
|
+
id: "rm_oc_1",
|
|
137
|
+
kind: "direct",
|
|
138
|
+
},
|
|
139
|
+
sender: partial.sender ?? { id: "ag_peer", name: "peer", kind: "agent" },
|
|
140
|
+
text: partial.text ?? "hello",
|
|
141
|
+
raw: partial.raw ?? {},
|
|
142
|
+
replyTo: partial.replyTo ?? null,
|
|
143
|
+
mentioned: partial.mentioned,
|
|
144
|
+
receivedAt: partial.receivedAt ?? Date.now(),
|
|
145
|
+
trace: partial.trace,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function makeEnvelope(
|
|
150
|
+
partial: Partial<GatewayInboundMessage> = {},
|
|
151
|
+
ack?: {
|
|
152
|
+
accept: () => Promise<void>;
|
|
153
|
+
reject?: (reason: string) => Promise<void>;
|
|
154
|
+
},
|
|
155
|
+
): GatewayInboundEnvelope {
|
|
156
|
+
return { message: makeMessage(partial), ack };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function baseConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
160
|
+
return {
|
|
161
|
+
channels: [{ id: "botcord", type: "botcord", accountId: "ag_me" }],
|
|
162
|
+
defaultRoute: {
|
|
163
|
+
runtime: "claude-code",
|
|
164
|
+
cwd: "/tmp/default",
|
|
165
|
+
},
|
|
166
|
+
routes: [],
|
|
167
|
+
...overrides,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function makeStore(): Promise<{ store: SessionStore; dir: string }> {
|
|
172
|
+
const dir = await mkdtemp(path.join(tmpdir(), "gw-dispatcher-"));
|
|
173
|
+
const store = new SessionStore({ path: path.join(dir, "sessions.json") });
|
|
174
|
+
await store.load();
|
|
175
|
+
return { store, dir };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
describe("Dispatcher", () => {
|
|
179
|
+
let tempDirs: string[];
|
|
180
|
+
|
|
181
|
+
beforeEach(() => {
|
|
182
|
+
tempDirs = [];
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
afterEach(async () => {
|
|
186
|
+
for (const d of tempDirs) {
|
|
187
|
+
await rm(d, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
async function scaffold(args: {
|
|
192
|
+
config?: GatewayConfig;
|
|
193
|
+
channel?: FakeChannel;
|
|
194
|
+
runtimeFactory?: RuntimeFactory;
|
|
195
|
+
turnTimeoutMs?: number;
|
|
196
|
+
}) {
|
|
197
|
+
const { store, dir } = await makeStore();
|
|
198
|
+
tempDirs.push(dir);
|
|
199
|
+
const channel = args.channel ?? new FakeChannel();
|
|
200
|
+
const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
|
|
201
|
+
const dispatcher = new Dispatcher({
|
|
202
|
+
config: args.config ?? baseConfig(),
|
|
203
|
+
channels,
|
|
204
|
+
runtime: args.runtimeFactory ?? (() => new FakeRuntime()),
|
|
205
|
+
sessionStore: store,
|
|
206
|
+
log: silentLogger(),
|
|
207
|
+
turnTimeoutMs: args.turnTimeoutMs,
|
|
208
|
+
});
|
|
209
|
+
return { dispatcher, channel, store };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
it("skips empty text and still acks", async () => {
|
|
213
|
+
const { dispatcher, channel } = await scaffold({});
|
|
214
|
+
const accept = vi.fn(async () => {});
|
|
215
|
+
await dispatcher.handle(makeEnvelope({ text: " " }, { accept }));
|
|
216
|
+
expect(accept).toHaveBeenCalledTimes(1);
|
|
217
|
+
expect(channel.sends.length).toBe(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("skips own-message (sender.id === accountId) and still acks", async () => {
|
|
221
|
+
const runtime = new FakeRuntime();
|
|
222
|
+
const { dispatcher, channel } = await scaffold({
|
|
223
|
+
runtimeFactory: () => runtime,
|
|
224
|
+
});
|
|
225
|
+
const accept = vi.fn(async () => {});
|
|
226
|
+
await dispatcher.handle(
|
|
227
|
+
makeEnvelope({ sender: { id: "ag_me", kind: "agent" } }, { accept }),
|
|
228
|
+
);
|
|
229
|
+
expect(accept).toHaveBeenCalledTimes(1);
|
|
230
|
+
expect(runtime.calls.length).toBe(0);
|
|
231
|
+
expect(channel.sends.length).toBe(0);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("happy path: routes, runs, writes session, sends reply with correct fields", async () => {
|
|
235
|
+
const runtime = new FakeRuntime({ reply: "ok", newSessionId: "new-sid" });
|
|
236
|
+
const { dispatcher, channel, store } = await scaffold({
|
|
237
|
+
runtimeFactory: () => runtime,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await dispatcher.handle(
|
|
241
|
+
makeEnvelope({
|
|
242
|
+
id: "msg_1",
|
|
243
|
+
conversation: { id: "rm_oc_1", kind: "direct", threadId: "t_1" },
|
|
244
|
+
trace: { id: "trace_1", streamable: false },
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
expect(runtime.calls.length).toBe(1);
|
|
249
|
+
expect(runtime.calls[0].cwd).toBe("/tmp/default");
|
|
250
|
+
expect(runtime.calls[0].trustLevel).toBe("trusted");
|
|
251
|
+
expect(channel.sends.length).toBe(1);
|
|
252
|
+
const out = channel.sends[0].message;
|
|
253
|
+
expect(out.conversationId).toBe("rm_oc_1");
|
|
254
|
+
expect(out.threadId).toBe("t_1");
|
|
255
|
+
expect(out.replyTo).toBe("msg_1");
|
|
256
|
+
expect(out.traceId).toBe("trace_1");
|
|
257
|
+
expect(out.text).toBe("ok");
|
|
258
|
+
|
|
259
|
+
expect(store.all().length).toBe(1);
|
|
260
|
+
expect(store.all()[0].runtimeSessionId).toBe("new-sid");
|
|
261
|
+
expect(store.all()[0].threadId).toBe("t_1");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("reuses session id on second message with same queue key", async () => {
|
|
265
|
+
const seen: Array<string | null> = [];
|
|
266
|
+
const runtime = new FakeRuntime({
|
|
267
|
+
newSessionId: (opts) => {
|
|
268
|
+
seen.push(opts.sessionId);
|
|
269
|
+
return "sid-" + (seen.length + 1);
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
const { dispatcher } = await scaffold({ runtimeFactory: () => runtime });
|
|
273
|
+
|
|
274
|
+
await dispatcher.handle(
|
|
275
|
+
makeEnvelope({
|
|
276
|
+
id: "msg_1",
|
|
277
|
+
conversation: { id: "rm_1", kind: "group" },
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
await dispatcher.handle(
|
|
281
|
+
makeEnvelope({
|
|
282
|
+
id: "msg_2",
|
|
283
|
+
conversation: { id: "rm_1", kind: "group" },
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
286
|
+
expect(seen).toEqual([null, "sid-2"]);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("drops the stored session when runtime signals an invalid resume (empty newSessionId + error)", async () => {
|
|
290
|
+
let callNo = 0;
|
|
291
|
+
const runtimeFactory: RuntimeFactory = () => {
|
|
292
|
+
callNo += 1;
|
|
293
|
+
// Turn 1: normal success, writes sid-1.
|
|
294
|
+
if (callNo === 1) return new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
|
|
295
|
+
// Turn 2: simulate Claude Code's "--resume <missing-uuid>" failure:
|
|
296
|
+
// adapter wipes newSessionId and sets error.
|
|
297
|
+
return new FakeRuntime({ newSessionId: "", errorText: "No conversation found" });
|
|
298
|
+
};
|
|
299
|
+
const { dispatcher, store } = await scaffold({ runtimeFactory });
|
|
300
|
+
|
|
301
|
+
await dispatcher.handle(
|
|
302
|
+
makeEnvelope({ id: "msg_1", conversation: { id: "rm_x", kind: "direct" } }),
|
|
303
|
+
);
|
|
304
|
+
expect(store.all().length).toBe(1);
|
|
305
|
+
expect(store.all()[0].runtimeSessionId).toBe("sid-1");
|
|
306
|
+
|
|
307
|
+
await dispatcher.handle(
|
|
308
|
+
makeEnvelope({ id: "msg_2", conversation: { id: "rm_x", kind: "direct" } }),
|
|
309
|
+
);
|
|
310
|
+
// Stale entry must be gone so the next turn starts fresh instead of
|
|
311
|
+
// re-resuming the missing UUID forever.
|
|
312
|
+
expect(store.all().length).toBe(0);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("applies composeUserTurn before handing text to the runtime", async () => {
|
|
316
|
+
const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
|
|
317
|
+
const { store, dir } = await makeStore();
|
|
318
|
+
tempDirs.push(dir);
|
|
319
|
+
const channel = new FakeChannel();
|
|
320
|
+
const dispatcher = new Dispatcher({
|
|
321
|
+
config: baseConfig(),
|
|
322
|
+
channels: new Map<string, ChannelAdapter>([[channel.id, channel]]),
|
|
323
|
+
runtime: () => runtime,
|
|
324
|
+
sessionStore: store,
|
|
325
|
+
log: silentLogger(),
|
|
326
|
+
composeUserTurn: (msg) => `WRAPPED:${msg.text}`,
|
|
327
|
+
});
|
|
328
|
+
await dispatcher.handle(makeEnvelope({ id: "msg_1", text: "hello" }));
|
|
329
|
+
expect(runtime.calls.length).toBe(1);
|
|
330
|
+
expect(runtime.calls[0].text).toBe("WRAPPED:hello");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("falls back to raw text when composeUserTurn throws", async () => {
|
|
334
|
+
const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
|
|
335
|
+
const { store, dir } = await makeStore();
|
|
336
|
+
tempDirs.push(dir);
|
|
337
|
+
const channel = new FakeChannel();
|
|
338
|
+
const dispatcher = new Dispatcher({
|
|
339
|
+
config: baseConfig(),
|
|
340
|
+
channels: new Map<string, ChannelAdapter>([[channel.id, channel]]),
|
|
341
|
+
runtime: () => runtime,
|
|
342
|
+
sessionStore: store,
|
|
343
|
+
log: silentLogger(),
|
|
344
|
+
composeUserTurn: () => {
|
|
345
|
+
throw new Error("boom");
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
await dispatcher.handle(makeEnvelope({ id: "msg_1", text: "hello" }));
|
|
349
|
+
expect(runtime.calls.length).toBe(1);
|
|
350
|
+
expect(runtime.calls[0].text).toBe("hello");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("does not crash when an errored turn has no prior session entry", async () => {
|
|
354
|
+
const runtime = new FakeRuntime({ newSessionId: "", errorText: "boom" });
|
|
355
|
+
const { dispatcher, store } = await scaffold({ runtimeFactory: () => runtime });
|
|
356
|
+
|
|
357
|
+
await dispatcher.handle(
|
|
358
|
+
makeEnvelope({ id: "msg_1", conversation: { id: "rm_y", kind: "direct" } }),
|
|
359
|
+
);
|
|
360
|
+
expect(store.all().length).toBe(0);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("cancel-previous: prior turn is aborted and does not write session, new turn writes", async () => {
|
|
364
|
+
const prior = new FakeRuntime({ hang: true, newSessionId: "prior-sid" });
|
|
365
|
+
const newer = new FakeRuntime({ reply: "newer", newSessionId: "newer-sid" });
|
|
366
|
+
let callNo = 0;
|
|
367
|
+
const runtimeFactory: RuntimeFactory = () => {
|
|
368
|
+
callNo += 1;
|
|
369
|
+
return callNo === 1 ? prior : newer;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const { dispatcher, channel, store } = await scaffold({ runtimeFactory });
|
|
373
|
+
|
|
374
|
+
const first = dispatcher.handle(
|
|
375
|
+
makeEnvelope({
|
|
376
|
+
id: "msg_1",
|
|
377
|
+
conversation: { id: "rm_oc_a", kind: "direct" },
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
// Give the prior run a tick to register.
|
|
381
|
+
await Promise.resolve();
|
|
382
|
+
await Promise.resolve();
|
|
383
|
+
|
|
384
|
+
await dispatcher.handle(
|
|
385
|
+
makeEnvelope({
|
|
386
|
+
id: "msg_2",
|
|
387
|
+
conversation: { id: "rm_oc_a", kind: "direct" },
|
|
388
|
+
}),
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
await first.catch(() => undefined);
|
|
392
|
+
|
|
393
|
+
expect(prior.calls[0].signal.aborted).toBe(true);
|
|
394
|
+
expect(channel.sends.length).toBe(1);
|
|
395
|
+
expect(channel.sends[0].message.text).toBe("newer");
|
|
396
|
+
expect(store.all().length).toBe(1);
|
|
397
|
+
expect(store.all()[0].runtimeSessionId).toBe("newer-sid");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("serial queue: second message waits for the first to finish", async () => {
|
|
401
|
+
const order: string[] = [];
|
|
402
|
+
let callNo = 0;
|
|
403
|
+
const runtimeFactory: RuntimeFactory = () => {
|
|
404
|
+
callNo += 1;
|
|
405
|
+
const tag = `r${callNo}`;
|
|
406
|
+
return new FakeRuntime({
|
|
407
|
+
reply: tag,
|
|
408
|
+
observeRun: () => order.push(`start:${tag}`),
|
|
409
|
+
delayMs: 20,
|
|
410
|
+
newSessionId: "sid",
|
|
411
|
+
});
|
|
412
|
+
};
|
|
413
|
+
const config = baseConfig({
|
|
414
|
+
defaultRoute: {
|
|
415
|
+
runtime: "claude-code",
|
|
416
|
+
cwd: "/tmp/default",
|
|
417
|
+
queueMode: "serial",
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
const { dispatcher, channel } = await scaffold({ config, runtimeFactory });
|
|
421
|
+
|
|
422
|
+
const p1 = dispatcher.handle(
|
|
423
|
+
makeEnvelope({
|
|
424
|
+
id: "m1",
|
|
425
|
+
conversation: { id: "rm_g1", kind: "group" },
|
|
426
|
+
}),
|
|
427
|
+
);
|
|
428
|
+
const p2 = dispatcher.handle(
|
|
429
|
+
makeEnvelope({
|
|
430
|
+
id: "m2",
|
|
431
|
+
conversation: { id: "rm_g1", kind: "group" },
|
|
432
|
+
}),
|
|
433
|
+
);
|
|
434
|
+
await Promise.all([p1, p2]);
|
|
435
|
+
|
|
436
|
+
expect(order).toEqual(["start:r1", "start:r2"]);
|
|
437
|
+
expect(channel.sends.map((s) => s.message.text)).toEqual(["r1", "r2"]);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("different queue keys run concurrently", async () => {
|
|
441
|
+
const running: Set<string> = new Set();
|
|
442
|
+
let maxConcurrent = 0;
|
|
443
|
+
let callNo = 0;
|
|
444
|
+
const runtimeFactory: RuntimeFactory = () => {
|
|
445
|
+
callNo += 1;
|
|
446
|
+
const tag = `r${callNo}`;
|
|
447
|
+
return new FakeRuntime({
|
|
448
|
+
reply: tag,
|
|
449
|
+
newSessionId: "sid",
|
|
450
|
+
observeRun: () => {
|
|
451
|
+
running.add(tag);
|
|
452
|
+
maxConcurrent = Math.max(maxConcurrent, running.size);
|
|
453
|
+
},
|
|
454
|
+
delayMs: 20,
|
|
455
|
+
});
|
|
456
|
+
};
|
|
457
|
+
const config = baseConfig({
|
|
458
|
+
defaultRoute: {
|
|
459
|
+
runtime: "claude-code",
|
|
460
|
+
cwd: "/tmp/default",
|
|
461
|
+
queueMode: "serial",
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
const { dispatcher } = await scaffold({ config, runtimeFactory });
|
|
465
|
+
|
|
466
|
+
const p1 = dispatcher.handle(
|
|
467
|
+
makeEnvelope({
|
|
468
|
+
id: "m1",
|
|
469
|
+
conversation: { id: "rm_a", kind: "group" },
|
|
470
|
+
}),
|
|
471
|
+
);
|
|
472
|
+
const p2 = dispatcher.handle(
|
|
473
|
+
makeEnvelope({
|
|
474
|
+
id: "m2",
|
|
475
|
+
conversation: { id: "rm_b", kind: "group" },
|
|
476
|
+
}),
|
|
477
|
+
);
|
|
478
|
+
await Promise.all([p1, p2]);
|
|
479
|
+
expect(maxConcurrent).toBe(2);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("streaming: forwards blocks when trace.streamable === true and channel has streamBlock", async () => {
|
|
483
|
+
const blocks: StreamBlock[] = [
|
|
484
|
+
{ raw: { type: "a" }, kind: "assistant_text", seq: 1 },
|
|
485
|
+
{ raw: { type: "b" }, kind: "tool_use", seq: 2 },
|
|
486
|
+
];
|
|
487
|
+
const runtime = new FakeRuntime({ blocks, newSessionId: "sid" });
|
|
488
|
+
const channel = new FakeChannel();
|
|
489
|
+
const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
|
|
490
|
+
|
|
491
|
+
await dispatcher.handle(
|
|
492
|
+
makeEnvelope({
|
|
493
|
+
trace: { id: "trace_abc", streamable: true },
|
|
494
|
+
}),
|
|
495
|
+
);
|
|
496
|
+
// streamBlock is fire-and-forget; give microtasks a chance.
|
|
497
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
498
|
+
expect(channel.streams.length).toBe(2);
|
|
499
|
+
expect(channel.streams[0].traceId).toBe("trace_abc");
|
|
500
|
+
expect(channel.streams.map((s) => (s.block as StreamBlock).seq)).toEqual([1, 2]);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("streaming: does not forward blocks when streamable is false", async () => {
|
|
504
|
+
const blocks: StreamBlock[] = [{ raw: {}, kind: "assistant_text", seq: 1 }];
|
|
505
|
+
const runtime = new FakeRuntime({ blocks, newSessionId: "sid" });
|
|
506
|
+
const channel = new FakeChannel();
|
|
507
|
+
const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
|
|
508
|
+
|
|
509
|
+
await dispatcher.handle(
|
|
510
|
+
makeEnvelope({ trace: { id: "trace_abc", streamable: false } }),
|
|
511
|
+
);
|
|
512
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
513
|
+
expect(channel.streams.length).toBe(0);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("channel without streamBlock: blocks dropped silently, turn still completes", async () => {
|
|
517
|
+
const blocks: StreamBlock[] = [{ raw: {}, kind: "assistant_text", seq: 1 }];
|
|
518
|
+
const runtime = new FakeRuntime({ blocks, newSessionId: "sid", reply: "ok" });
|
|
519
|
+
const channel = new FakeChannel({ withStream: false });
|
|
520
|
+
const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
|
|
521
|
+
|
|
522
|
+
await dispatcher.handle(
|
|
523
|
+
makeEnvelope({ trace: { id: "t1", streamable: true } }),
|
|
524
|
+
);
|
|
525
|
+
expect(channel.sends.length).toBe(1);
|
|
526
|
+
expect(channel.sends[0].message.text).toBe("ok");
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("runtime throws: sends error reply, does not write session", async () => {
|
|
530
|
+
const runtime = new FakeRuntime({ throwError: "boom" });
|
|
531
|
+
const channel = new FakeChannel();
|
|
532
|
+
const { dispatcher, store } = await scaffold({ channel, runtimeFactory: () => runtime });
|
|
533
|
+
|
|
534
|
+
await dispatcher.handle(makeEnvelope({ id: "m1" }));
|
|
535
|
+
expect(channel.sends.length).toBe(1);
|
|
536
|
+
expect(channel.sends[0].message.text).toContain("Runtime error");
|
|
537
|
+
expect(channel.sends[0].message.text).toContain("boom");
|
|
538
|
+
expect(store.all().length).toBe(0);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("runtime timeout: aborts, sends error reply, does not write session", async () => {
|
|
542
|
+
vi.useFakeTimers();
|
|
543
|
+
try {
|
|
544
|
+
const runtime = new FakeRuntime({ hang: true, newSessionId: "never" });
|
|
545
|
+
const channel = new FakeChannel();
|
|
546
|
+
const { dispatcher, store } = await scaffold({
|
|
547
|
+
channel,
|
|
548
|
+
runtimeFactory: () => runtime,
|
|
549
|
+
turnTimeoutMs: 1000,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const p = dispatcher.handle(makeEnvelope({ id: "m1" }));
|
|
553
|
+
await vi.advanceTimersByTimeAsync(1001);
|
|
554
|
+
await p;
|
|
555
|
+
|
|
556
|
+
expect(runtime.calls[0].signal.aborted).toBe(true);
|
|
557
|
+
expect(channel.sends.length).toBe(1);
|
|
558
|
+
expect(channel.sends[0].message.text).toContain("timeout");
|
|
559
|
+
expect(store.all().length).toBe(0);
|
|
560
|
+
} finally {
|
|
561
|
+
vi.useRealTimers();
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("turns() reports in-flight entries and removes them on completion", async () => {
|
|
566
|
+
const runtime = new FakeRuntime({
|
|
567
|
+
delayMs: 30,
|
|
568
|
+
newSessionId: "sid",
|
|
569
|
+
reply: "ok",
|
|
570
|
+
});
|
|
571
|
+
const { dispatcher } = await scaffold({ runtimeFactory: () => runtime });
|
|
572
|
+
|
|
573
|
+
const p = dispatcher.handle(
|
|
574
|
+
makeEnvelope({
|
|
575
|
+
id: "m1",
|
|
576
|
+
conversation: { id: "rm_oc_x", kind: "direct" },
|
|
577
|
+
}),
|
|
578
|
+
);
|
|
579
|
+
// Let the turn begin.
|
|
580
|
+
await Promise.resolve();
|
|
581
|
+
await Promise.resolve();
|
|
582
|
+
|
|
583
|
+
const inFlight = dispatcher.turns();
|
|
584
|
+
const keys = Object.keys(inFlight);
|
|
585
|
+
expect(keys.length).toBe(1);
|
|
586
|
+
expect(inFlight[keys[0]].conversationId).toBe("rm_oc_x");
|
|
587
|
+
expect(inFlight[keys[0]].runtime).toBe("claude-code");
|
|
588
|
+
|
|
589
|
+
await p;
|
|
590
|
+
expect(Object.keys(dispatcher.turns()).length).toBe(0);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("ack.accept() is called before runtime.run() starts", async () => {
|
|
594
|
+
const order: string[] = [];
|
|
595
|
+
const runtime = new FakeRuntime({
|
|
596
|
+
observeRun: () => order.push("run"),
|
|
597
|
+
newSessionId: "sid",
|
|
598
|
+
});
|
|
599
|
+
const { dispatcher } = await scaffold({ runtimeFactory: () => runtime });
|
|
600
|
+
const accept = vi.fn(async () => {
|
|
601
|
+
order.push("accept");
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
await dispatcher.handle(makeEnvelope({}, { accept }));
|
|
605
|
+
expect(order).toEqual(["accept", "run"]);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("route match wins over default: uses match's cwd / runtime / extraArgs", async () => {
|
|
609
|
+
const calls: RuntimeRunOptions[] = [];
|
|
610
|
+
const runtime = new FakeRuntime({
|
|
611
|
+
newSessionId: "sid",
|
|
612
|
+
observeRun: (opts) => calls.push(opts),
|
|
613
|
+
});
|
|
614
|
+
const seenIds: string[] = [];
|
|
615
|
+
const runtimeFactory: RuntimeFactory = (id, extraArgs) => {
|
|
616
|
+
seenIds.push(id);
|
|
617
|
+
expect(extraArgs).toEqual(["--flag", "value"]);
|
|
618
|
+
return runtime;
|
|
619
|
+
};
|
|
620
|
+
const config = baseConfig({
|
|
621
|
+
defaultRoute: {
|
|
622
|
+
runtime: "codex",
|
|
623
|
+
cwd: "/tmp/default",
|
|
624
|
+
},
|
|
625
|
+
routes: [
|
|
626
|
+
{
|
|
627
|
+
match: { conversationPrefix: "rm_oc_" },
|
|
628
|
+
runtime: "claude-code",
|
|
629
|
+
cwd: "/tmp/match",
|
|
630
|
+
extraArgs: ["--flag", "value"],
|
|
631
|
+
trustLevel: "owner",
|
|
632
|
+
},
|
|
633
|
+
],
|
|
634
|
+
});
|
|
635
|
+
const { dispatcher } = await scaffold({ config, runtimeFactory });
|
|
636
|
+
|
|
637
|
+
await dispatcher.handle(
|
|
638
|
+
makeEnvelope({ conversation: { id: "rm_oc_z", kind: "direct" } }),
|
|
639
|
+
);
|
|
640
|
+
expect(seenIds).toEqual(["claude-code"]);
|
|
641
|
+
expect(calls[0].cwd).toBe("/tmp/match");
|
|
642
|
+
expect(calls[0].trustLevel).toBe("owner");
|
|
643
|
+
expect(calls[0].extraArgs).toEqual(["--flag", "value"]);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("buildSystemContext: return value reaches runtime.run as systemContext", async () => {
|
|
647
|
+
const observed: Array<string | undefined> = [];
|
|
648
|
+
const runtime = new FakeRuntime({
|
|
649
|
+
newSessionId: "sid",
|
|
650
|
+
observeRun: (opts) => observed.push(opts.systemContext),
|
|
651
|
+
});
|
|
652
|
+
const { store, dir } = await makeStore();
|
|
653
|
+
tempDirs.push(dir);
|
|
654
|
+
const channel = new FakeChannel();
|
|
655
|
+
const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
|
|
656
|
+
const seenMessages: GatewayInboundMessage[] = [];
|
|
657
|
+
const dispatcher = new Dispatcher({
|
|
658
|
+
config: baseConfig(),
|
|
659
|
+
channels,
|
|
660
|
+
runtime: () => runtime,
|
|
661
|
+
sessionStore: store,
|
|
662
|
+
log: silentLogger(),
|
|
663
|
+
buildSystemContext: (msg) => {
|
|
664
|
+
seenMessages.push(msg);
|
|
665
|
+
return "hello-context";
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
await dispatcher.handle(makeEnvelope({ id: "msg_sc_1", text: "go" }));
|
|
670
|
+
expect(seenMessages.length).toBe(1);
|
|
671
|
+
expect(seenMessages[0].id).toBe("msg_sc_1");
|
|
672
|
+
expect(observed).toEqual(["hello-context"]);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it("buildSystemContext: hook is awaited before runtime.run runs", async () => {
|
|
676
|
+
const order: string[] = [];
|
|
677
|
+
const runtime = new FakeRuntime({
|
|
678
|
+
newSessionId: "sid",
|
|
679
|
+
observeRun: (opts) => {
|
|
680
|
+
order.push(`run:${opts.systemContext ?? "none"}`);
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
const { store, dir } = await makeStore();
|
|
684
|
+
tempDirs.push(dir);
|
|
685
|
+
const channel = new FakeChannel();
|
|
686
|
+
const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
|
|
687
|
+
const dispatcher = new Dispatcher({
|
|
688
|
+
config: baseConfig(),
|
|
689
|
+
channels,
|
|
690
|
+
runtime: () => runtime,
|
|
691
|
+
sessionStore: store,
|
|
692
|
+
log: silentLogger(),
|
|
693
|
+
buildSystemContext: async () => {
|
|
694
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
695
|
+
order.push("build-done");
|
|
696
|
+
return "async-ctx";
|
|
697
|
+
},
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
await dispatcher.handle(makeEnvelope({}));
|
|
701
|
+
expect(order).toEqual(["build-done", "run:async-ctx"]);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("buildSystemContext: returning undefined → runtime receives undefined systemContext", async () => {
|
|
705
|
+
const observed: Array<string | undefined> = [];
|
|
706
|
+
const runtime = new FakeRuntime({
|
|
707
|
+
newSessionId: "sid",
|
|
708
|
+
observeRun: (opts) => observed.push(opts.systemContext),
|
|
709
|
+
});
|
|
710
|
+
const { store, dir } = await makeStore();
|
|
711
|
+
tempDirs.push(dir);
|
|
712
|
+
const channel = new FakeChannel();
|
|
713
|
+
const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
|
|
714
|
+
const dispatcher = new Dispatcher({
|
|
715
|
+
config: baseConfig(),
|
|
716
|
+
channels,
|
|
717
|
+
runtime: () => runtime,
|
|
718
|
+
sessionStore: store,
|
|
719
|
+
log: silentLogger(),
|
|
720
|
+
buildSystemContext: () => undefined,
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
await dispatcher.handle(makeEnvelope({}));
|
|
724
|
+
expect(observed).toEqual([undefined]);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it("buildSystemContext: empty string is treated as undefined (not passed through)", async () => {
|
|
728
|
+
const observed: Array<string | undefined> = [];
|
|
729
|
+
const runtime = new FakeRuntime({
|
|
730
|
+
newSessionId: "sid",
|
|
731
|
+
observeRun: (opts) => observed.push(opts.systemContext),
|
|
732
|
+
});
|
|
733
|
+
const { store, dir } = await makeStore();
|
|
734
|
+
tempDirs.push(dir);
|
|
735
|
+
const channel = new FakeChannel();
|
|
736
|
+
const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
|
|
737
|
+
const dispatcher = new Dispatcher({
|
|
738
|
+
config: baseConfig(),
|
|
739
|
+
channels,
|
|
740
|
+
runtime: () => runtime,
|
|
741
|
+
sessionStore: store,
|
|
742
|
+
log: silentLogger(),
|
|
743
|
+
buildSystemContext: () => "",
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
await dispatcher.handle(makeEnvelope({}));
|
|
747
|
+
expect(observed).toEqual([undefined]);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("buildSystemContext: throwing hook is logged as warn, turn runs with undefined", async () => {
|
|
751
|
+
const observed: Array<string | undefined> = [];
|
|
752
|
+
const runtime = new FakeRuntime({
|
|
753
|
+
newSessionId: "sid",
|
|
754
|
+
reply: "ok",
|
|
755
|
+
observeRun: (opts) => observed.push(opts.systemContext),
|
|
756
|
+
});
|
|
757
|
+
const warnSpy = vi.fn();
|
|
758
|
+
const logger: GatewayLogger = {
|
|
759
|
+
info: () => {},
|
|
760
|
+
warn: warnSpy,
|
|
761
|
+
error: () => {},
|
|
762
|
+
debug: () => {},
|
|
763
|
+
};
|
|
764
|
+
const { store, dir } = await makeStore();
|
|
765
|
+
tempDirs.push(dir);
|
|
766
|
+
const channel = new FakeChannel();
|
|
767
|
+
const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
|
|
768
|
+
const dispatcher = new Dispatcher({
|
|
769
|
+
config: baseConfig(),
|
|
770
|
+
channels,
|
|
771
|
+
runtime: () => runtime,
|
|
772
|
+
sessionStore: store,
|
|
773
|
+
log: logger,
|
|
774
|
+
buildSystemContext: () => {
|
|
775
|
+
throw new Error("memory read failed");
|
|
776
|
+
},
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
await dispatcher.handle(makeEnvelope({ id: "msg_err" }));
|
|
780
|
+
expect(observed).toEqual([undefined]);
|
|
781
|
+
expect(channel.sends.length).toBe(1);
|
|
782
|
+
expect(channel.sends[0].message.text).toBe("ok");
|
|
783
|
+
const warnMessages = warnSpy.mock.calls.map((c) => c[0]);
|
|
784
|
+
expect(
|
|
785
|
+
warnMessages.some((m: string) => m.includes("buildSystemContext threw")),
|
|
786
|
+
).toBe(true);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it("onInbound: observer is invoked with the message between ack and runtime.run", async () => {
|
|
790
|
+
const order: string[] = [];
|
|
791
|
+
const runtime = new FakeRuntime({
|
|
792
|
+
newSessionId: "sid",
|
|
793
|
+
observeRun: () => order.push("run"),
|
|
794
|
+
});
|
|
795
|
+
const { store, dir } = await makeStore();
|
|
796
|
+
tempDirs.push(dir);
|
|
797
|
+
const channel = new FakeChannel();
|
|
798
|
+
const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
|
|
799
|
+
const seen: string[] = [];
|
|
800
|
+
const dispatcher = new Dispatcher({
|
|
801
|
+
config: baseConfig(),
|
|
802
|
+
channels,
|
|
803
|
+
runtime: () => runtime,
|
|
804
|
+
sessionStore: store,
|
|
805
|
+
log: silentLogger(),
|
|
806
|
+
onInbound: (msg) => {
|
|
807
|
+
order.push("observe");
|
|
808
|
+
seen.push(msg.id);
|
|
809
|
+
},
|
|
810
|
+
});
|
|
811
|
+
const accept = vi.fn(async () => {
|
|
812
|
+
order.push("ack");
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
await dispatcher.handle(makeEnvelope({ id: "msg_obs_1" }, { accept }));
|
|
816
|
+
expect(order).toEqual(["ack", "observe", "run"]);
|
|
817
|
+
expect(seen).toEqual(["msg_obs_1"]);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it("onInbound: observer throwing does not break the turn", async () => {
|
|
821
|
+
const runtime = new FakeRuntime({ newSessionId: "sid", reply: "ok" });
|
|
822
|
+
const warnSpy = vi.fn();
|
|
823
|
+
const logger: GatewayLogger = {
|
|
824
|
+
info: () => {},
|
|
825
|
+
warn: warnSpy,
|
|
826
|
+
error: () => {},
|
|
827
|
+
debug: () => {},
|
|
828
|
+
};
|
|
829
|
+
const { store, dir } = await makeStore();
|
|
830
|
+
tempDirs.push(dir);
|
|
831
|
+
const channel = new FakeChannel();
|
|
832
|
+
const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
|
|
833
|
+
const dispatcher = new Dispatcher({
|
|
834
|
+
config: baseConfig(),
|
|
835
|
+
channels,
|
|
836
|
+
runtime: () => runtime,
|
|
837
|
+
sessionStore: store,
|
|
838
|
+
log: logger,
|
|
839
|
+
onInbound: () => {
|
|
840
|
+
throw new Error("observer boom");
|
|
841
|
+
},
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
await dispatcher.handle(makeEnvelope({ id: "msg_obs_2" }));
|
|
845
|
+
expect(channel.sends.length).toBe(1);
|
|
846
|
+
expect(channel.sends[0].message.text).toBe("ok");
|
|
847
|
+
const warnMessages = warnSpy.mock.calls.map((c) => c[0]);
|
|
848
|
+
expect(warnMessages.some((m: string) => m.includes("onInbound"))).toBe(true);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it("unset queueMode + direct conversation → cancel-previous", async () => {
|
|
852
|
+
const prior = new FakeRuntime({ hang: true, newSessionId: "prior" });
|
|
853
|
+
const newer = new FakeRuntime({ reply: "newer", newSessionId: "newer" });
|
|
854
|
+
let callNo = 0;
|
|
855
|
+
const runtimeFactory: RuntimeFactory = () => {
|
|
856
|
+
callNo += 1;
|
|
857
|
+
return callNo === 1 ? prior : newer;
|
|
858
|
+
};
|
|
859
|
+
// defaultRoute has no queueMode.
|
|
860
|
+
const { dispatcher, channel } = await scaffold({ runtimeFactory });
|
|
861
|
+
|
|
862
|
+
const p1 = dispatcher.handle(
|
|
863
|
+
makeEnvelope({
|
|
864
|
+
id: "m1",
|
|
865
|
+
conversation: { id: "rm_oc_dm", kind: "direct" },
|
|
866
|
+
}),
|
|
867
|
+
);
|
|
868
|
+
await Promise.resolve();
|
|
869
|
+
await Promise.resolve();
|
|
870
|
+
await dispatcher.handle(
|
|
871
|
+
makeEnvelope({
|
|
872
|
+
id: "m2",
|
|
873
|
+
conversation: { id: "rm_oc_dm", kind: "direct" },
|
|
874
|
+
}),
|
|
875
|
+
);
|
|
876
|
+
await p1.catch(() => undefined);
|
|
877
|
+
|
|
878
|
+
expect(prior.calls[0].signal.aborted).toBe(true);
|
|
879
|
+
expect(channel.sends.length).toBe(1);
|
|
880
|
+
expect(channel.sends[0].message.text).toBe("newer");
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it("cancel-previous: three rapid-fire messages — only the newest turn runs, no concurrent runtime.run()", async () => {
|
|
884
|
+
// Controllable gate per runtime instance so we can drive timing.
|
|
885
|
+
function newGate() {
|
|
886
|
+
let release!: () => void;
|
|
887
|
+
const gate = new Promise<void>((r) => {
|
|
888
|
+
release = r;
|
|
889
|
+
});
|
|
890
|
+
let startedSignal!: () => void;
|
|
891
|
+
const started = new Promise<void>((r) => {
|
|
892
|
+
startedSignal = r;
|
|
893
|
+
});
|
|
894
|
+
return { gate, release, started, startedSignal };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const allGates: Array<ReturnType<typeof newGate>> = [];
|
|
898
|
+
|
|
899
|
+
let activeRuns = 0;
|
|
900
|
+
let maxActive = 0;
|
|
901
|
+
const completedReplies: string[] = [];
|
|
902
|
+
|
|
903
|
+
const runtimeFactory: RuntimeFactory = () => {
|
|
904
|
+
const g = newGate();
|
|
905
|
+
allGates.push(g);
|
|
906
|
+
return {
|
|
907
|
+
id: "claude-code",
|
|
908
|
+
async run(opts: RuntimeRunOptions): Promise<RuntimeRunResult> {
|
|
909
|
+
activeRuns += 1;
|
|
910
|
+
maxActive = Math.max(maxActive, activeRuns);
|
|
911
|
+
g.startedSignal();
|
|
912
|
+
try {
|
|
913
|
+
await new Promise<void>((resolve, reject) => {
|
|
914
|
+
if (opts.signal.aborted) {
|
|
915
|
+
reject(new Error("aborted"));
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
opts.signal.addEventListener(
|
|
919
|
+
"abort",
|
|
920
|
+
() => reject(new Error("aborted")),
|
|
921
|
+
{ once: true },
|
|
922
|
+
);
|
|
923
|
+
g.gate.then(resolve);
|
|
924
|
+
});
|
|
925
|
+
} catch (err) {
|
|
926
|
+
activeRuns -= 1;
|
|
927
|
+
throw err;
|
|
928
|
+
}
|
|
929
|
+
activeRuns -= 1;
|
|
930
|
+
const reply = `reply-${opts.text}`;
|
|
931
|
+
completedReplies.push(reply);
|
|
932
|
+
return { text: reply, newSessionId: `sid-${opts.text}` };
|
|
933
|
+
},
|
|
934
|
+
};
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
const { dispatcher, channel, store } = await scaffold({ runtimeFactory });
|
|
938
|
+
|
|
939
|
+
// Fire message #1 — starts running and blocks on its gate.
|
|
940
|
+
const p1 = dispatcher.handle(
|
|
941
|
+
makeEnvelope({
|
|
942
|
+
id: "m1",
|
|
943
|
+
text: "one",
|
|
944
|
+
conversation: { id: "rm_oc_race", kind: "direct" },
|
|
945
|
+
}),
|
|
946
|
+
);
|
|
947
|
+
await vi.waitFor(() => {
|
|
948
|
+
expect(allGates.length).toBeGreaterThanOrEqual(1);
|
|
949
|
+
});
|
|
950
|
+
await allGates[0].started;
|
|
951
|
+
|
|
952
|
+
// Fire #2 and #3 back-to-back. Under the old racing implementation,
|
|
953
|
+
// #2 could observe `current === null` after #1's abort and start a
|
|
954
|
+
// second concurrent runtime.run() alongside #3's.
|
|
955
|
+
const p2 = dispatcher.handle(
|
|
956
|
+
makeEnvelope({
|
|
957
|
+
id: "m2",
|
|
958
|
+
text: "two",
|
|
959
|
+
conversation: { id: "rm_oc_race", kind: "direct" },
|
|
960
|
+
}),
|
|
961
|
+
);
|
|
962
|
+
const p3 = dispatcher.handle(
|
|
963
|
+
makeEnvelope({
|
|
964
|
+
id: "m3",
|
|
965
|
+
text: "three",
|
|
966
|
+
conversation: { id: "rm_oc_race", kind: "direct" },
|
|
967
|
+
}),
|
|
968
|
+
);
|
|
969
|
+
|
|
970
|
+
// Wait for the newest runtime to start. It may be the 2nd or 3rd
|
|
971
|
+
// constructed instance, depending on whether #2 was superseded before
|
|
972
|
+
// it reached runTurn (expected under the fixed implementation).
|
|
973
|
+
await vi.waitFor(() => {
|
|
974
|
+
expect(allGates.length).toBeGreaterThanOrEqual(2);
|
|
975
|
+
// At least one gate after index 0 should have started.
|
|
976
|
+
const anyLaterStarted = allGates
|
|
977
|
+
.slice(1)
|
|
978
|
+
.some(() => true); // placeholder; real check below via Promise.race
|
|
979
|
+
expect(anyLaterStarted).toBe(true);
|
|
980
|
+
});
|
|
981
|
+
await Promise.race(allGates.slice(1).map((g) => g.started));
|
|
982
|
+
|
|
983
|
+
// Release every gate so any runtime still alive completes.
|
|
984
|
+
for (const g of allGates) g.release();
|
|
985
|
+
|
|
986
|
+
await Promise.allSettled([p1, p2, p3]);
|
|
987
|
+
|
|
988
|
+
// Critical: never more than one runtime.run() concurrently.
|
|
989
|
+
expect(maxActive).toBe(1);
|
|
990
|
+
// Exactly one reply, and it's the newest message's reply.
|
|
991
|
+
expect(channel.sends.length).toBe(1);
|
|
992
|
+
expect(channel.sends[0].message.text).toBe("reply-three");
|
|
993
|
+
expect(completedReplies).toEqual(["reply-three"]);
|
|
994
|
+
// Session persisted for newest only.
|
|
995
|
+
expect(store.all().length).toBe(1);
|
|
996
|
+
expect(store.all()[0].runtimeSessionId).toBe("sid-three");
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
it("cancel-previous aborts prior reply path — no stale send after supersede", async () => {
|
|
1000
|
+
// Simulate the race: runtime A's run() resolves while a cancel-previous
|
|
1001
|
+
// for message B is already racing through the queue. The prior turn's
|
|
1002
|
+
// post-runtime block must observe the abort signal and drop silently
|
|
1003
|
+
// instead of sending a stale reply.
|
|
1004
|
+
//
|
|
1005
|
+
// Key trick: the fake runtime for A does NOT honour the abort signal —
|
|
1006
|
+
// it only resolves when we explicitly call `resolveA`. We call
|
|
1007
|
+
// `resolveA` only after message B has arrived and aborted A's
|
|
1008
|
+
// controller. That exactly reproduces "signal aborted after runtime.run
|
|
1009
|
+
// resolved but before post-runtime work".
|
|
1010
|
+
let resolveA!: (v: RuntimeRunResult) => void;
|
|
1011
|
+
const aResult = new Promise<RuntimeRunResult>((r) => {
|
|
1012
|
+
resolveA = r;
|
|
1013
|
+
});
|
|
1014
|
+
const runtimeA: RuntimeAdapter = {
|
|
1015
|
+
id: "claude-code",
|
|
1016
|
+
async run(): Promise<RuntimeRunResult> {
|
|
1017
|
+
return aResult;
|
|
1018
|
+
},
|
|
1019
|
+
};
|
|
1020
|
+
const runtimeB = new FakeRuntime({ reply: "B-reply", newSessionId: "sid-B" });
|
|
1021
|
+
let callNo = 0;
|
|
1022
|
+
const runtimeFactory: RuntimeFactory = () => {
|
|
1023
|
+
callNo += 1;
|
|
1024
|
+
return callNo === 1 ? runtimeA : runtimeB;
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
const { dispatcher, channel, store } = await scaffold({ runtimeFactory });
|
|
1028
|
+
|
|
1029
|
+
// Start A. Its runtime.run is pending on the gate.
|
|
1030
|
+
const pA = dispatcher.handle(
|
|
1031
|
+
makeEnvelope({
|
|
1032
|
+
id: "msgA",
|
|
1033
|
+
text: "A",
|
|
1034
|
+
conversation: { id: "rm_oc_race2", kind: "direct" },
|
|
1035
|
+
}),
|
|
1036
|
+
);
|
|
1037
|
+
// Let the dispatcher reach `await runtime.run`.
|
|
1038
|
+
await Promise.resolve();
|
|
1039
|
+
await Promise.resolve();
|
|
1040
|
+
await Promise.resolve();
|
|
1041
|
+
|
|
1042
|
+
// Fire B — cancel-previous aborts A's controller and awaits prev.done.
|
|
1043
|
+
const pB = dispatcher.handle(
|
|
1044
|
+
makeEnvelope({
|
|
1045
|
+
id: "msgB",
|
|
1046
|
+
text: "B",
|
|
1047
|
+
conversation: { id: "rm_oc_race2", kind: "direct" },
|
|
1048
|
+
}),
|
|
1049
|
+
);
|
|
1050
|
+
// Give runCancelPrevious a tick to reach the abort + await prev.done.
|
|
1051
|
+
await Promise.resolve();
|
|
1052
|
+
await Promise.resolve();
|
|
1053
|
+
|
|
1054
|
+
// Now resolve A's runtime. A would have happily sent "A-reply" and
|
|
1055
|
+
// written its session under the old code; with the fix it must observe
|
|
1056
|
+
// its aborted signal and bail silently.
|
|
1057
|
+
resolveA({ text: "A-reply", newSessionId: "sid-A" });
|
|
1058
|
+
|
|
1059
|
+
await Promise.allSettled([pA, pB]);
|
|
1060
|
+
|
|
1061
|
+
// Prior reply was suppressed; only B's reply went out.
|
|
1062
|
+
expect(channel.sends.length).toBe(1);
|
|
1063
|
+
expect(channel.sends[0].message.text).toBe("B-reply");
|
|
1064
|
+
// Session was written for B only, not A.
|
|
1065
|
+
expect(store.all().length).toBe(1);
|
|
1066
|
+
expect(store.all()[0].runtimeSessionId).toBe("sid-B");
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
it("cancel-previous does not write session for superseded turn", async () => {
|
|
1070
|
+
// Same setup as above, but asserted specifically on the session store.
|
|
1071
|
+
let resolveA!: (v: RuntimeRunResult) => void;
|
|
1072
|
+
const aResult = new Promise<RuntimeRunResult>((r) => {
|
|
1073
|
+
resolveA = r;
|
|
1074
|
+
});
|
|
1075
|
+
const runtimeA: RuntimeAdapter = {
|
|
1076
|
+
id: "claude-code",
|
|
1077
|
+
async run(): Promise<RuntimeRunResult> {
|
|
1078
|
+
return aResult;
|
|
1079
|
+
},
|
|
1080
|
+
};
|
|
1081
|
+
const runtimeB = new FakeRuntime({ reply: "B", newSessionId: "sid-B" });
|
|
1082
|
+
let callNo = 0;
|
|
1083
|
+
const runtimeFactory: RuntimeFactory = () => {
|
|
1084
|
+
callNo += 1;
|
|
1085
|
+
return callNo === 1 ? runtimeA : runtimeB;
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
const { store, dir } = await makeStore();
|
|
1089
|
+
tempDirs.push(dir);
|
|
1090
|
+
const channel = new FakeChannel();
|
|
1091
|
+
const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
|
|
1092
|
+
// Spy on sessionStore.set to prove A never triggered a write.
|
|
1093
|
+
const setSpy = vi.spyOn(store, "set");
|
|
1094
|
+
const dispatcher = new Dispatcher({
|
|
1095
|
+
config: baseConfig(),
|
|
1096
|
+
channels,
|
|
1097
|
+
runtime: runtimeFactory,
|
|
1098
|
+
sessionStore: store,
|
|
1099
|
+
log: silentLogger(),
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
const pA = dispatcher.handle(
|
|
1103
|
+
makeEnvelope({
|
|
1104
|
+
id: "msgA",
|
|
1105
|
+
text: "A",
|
|
1106
|
+
conversation: { id: "rm_oc_race3", kind: "direct" },
|
|
1107
|
+
}),
|
|
1108
|
+
);
|
|
1109
|
+
await Promise.resolve();
|
|
1110
|
+
await Promise.resolve();
|
|
1111
|
+
await Promise.resolve();
|
|
1112
|
+
|
|
1113
|
+
const pB = dispatcher.handle(
|
|
1114
|
+
makeEnvelope({
|
|
1115
|
+
id: "msgB",
|
|
1116
|
+
text: "B",
|
|
1117
|
+
conversation: { id: "rm_oc_race3", kind: "direct" },
|
|
1118
|
+
}),
|
|
1119
|
+
);
|
|
1120
|
+
await Promise.resolve();
|
|
1121
|
+
await Promise.resolve();
|
|
1122
|
+
|
|
1123
|
+
resolveA({ text: "A-reply", newSessionId: "sid-A" });
|
|
1124
|
+
|
|
1125
|
+
await Promise.allSettled([pA, pB]);
|
|
1126
|
+
|
|
1127
|
+
// store.set must have been called exactly once — for B.
|
|
1128
|
+
expect(setSpy).toHaveBeenCalledTimes(1);
|
|
1129
|
+
const written = setSpy.mock.calls[0][0];
|
|
1130
|
+
expect(written.runtimeSessionId).toBe("sid-B");
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
it("timeout error reply still sent (not suppressed by supersede-drop check)", async () => {
|
|
1134
|
+
// A turn that times out has `signal.aborted === true` AND
|
|
1135
|
+
// `slot.timedOut === true`. The supersede check must only short-circuit
|
|
1136
|
+
// on the (aborted && !timedOut) case; timeouts still emit their error.
|
|
1137
|
+
vi.useFakeTimers();
|
|
1138
|
+
try {
|
|
1139
|
+
const runtime = new FakeRuntime({ hang: true, newSessionId: "never" });
|
|
1140
|
+
const channel = new FakeChannel();
|
|
1141
|
+
const { dispatcher, store } = await scaffold({
|
|
1142
|
+
channel,
|
|
1143
|
+
runtimeFactory: () => runtime,
|
|
1144
|
+
turnTimeoutMs: 500,
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
const p = dispatcher.handle(makeEnvelope({ id: "m1" }));
|
|
1148
|
+
await vi.advanceTimersByTimeAsync(501);
|
|
1149
|
+
await p;
|
|
1150
|
+
|
|
1151
|
+
expect(runtime.calls[0].signal.aborted).toBe(true);
|
|
1152
|
+
expect(channel.sends.length).toBe(1);
|
|
1153
|
+
expect(channel.sends[0].message.text).toContain("timeout");
|
|
1154
|
+
expect(store.all().length).toBe(0);
|
|
1155
|
+
} finally {
|
|
1156
|
+
vi.useRealTimers();
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
});
|