@polderlabs/bizar-plugin 0.5.4
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/LICENSE +21 -0
- package/README.md +448 -0
- package/bun.lock +88 -0
- package/index.ts +1113 -0
- package/package.json +42 -0
- package/scripts/check-forbidden-imports.sh +33 -0
- package/src/background-state.ts +463 -0
- package/src/background.ts +964 -0
- package/src/commands-impl.ts +369 -0
- package/src/commands.ts +880 -0
- package/src/event-stream.ts +574 -0
- package/src/fingerprint.ts +120 -0
- package/src/handoff.ts +79 -0
- package/src/http-client.ts +467 -0
- package/src/logger.ts +144 -0
- package/src/loop.ts +176 -0
- package/src/options.ts +421 -0
- package/src/plan-fs.ts +323 -0
- package/src/report.ts +178 -0
- package/src/research-prompt.ts +35 -0
- package/src/serve.ts +476 -0
- package/src/settings.ts +349 -0
- package/src/state.ts +298 -0
- package/src/tools/bg-collect.ts +104 -0
- package/src/tools/bg-get-comments.ts +239 -0
- package/src/tools/bg-kill.ts +87 -0
- package/src/tools/bg-spawn.ts +263 -0
- package/src/tools/bg-status.ts +99 -0
- package/src/tools/plan-action.ts +767 -0
- package/src/tools/wait-for-feedback.ts +402 -0
- package/tests/attach-handler-bug.test.ts +166 -0
- package/tests/background-state.test.ts +277 -0
- package/tests/background.test.ts +402 -0
- package/tests/block.test.ts +193 -0
- package/tests/canonical-key-order.test.ts +71 -0
- package/tests/commands-impl.test.ts +442 -0
- package/tests/commands.test.ts +548 -0
- package/tests/config.test.ts +122 -0
- package/tests/dispose.test.ts +336 -0
- package/tests/event-stream.test.ts +409 -0
- package/tests/event.test.ts +262 -0
- package/tests/fingerprint.test.ts +161 -0
- package/tests/http-client.test.ts +403 -0
- package/tests/init-helpers.test.ts +203 -0
- package/tests/integration/slash-command.test.ts +348 -0
- package/tests/integration/tool-routing.test.ts +314 -0
- package/tests/loop.test.ts +397 -0
- package/tests/options.test.ts +274 -0
- package/tests/serve.test.ts +335 -0
- package/tests/settings.test.ts +351 -0
- package/tests/stall-think.test.ts +749 -0
- package/tests/state.test.ts +275 -0
- package/tests/tools/bg-collect.test.ts +337 -0
- package/tests/tools/bg-get-comments.test.ts +485 -0
- package/tests/tools/bg-kill.test.ts +231 -0
- package/tests/tools/bg-spawn.test.ts +311 -0
- package/tests/tools/bg-status.test.ts +216 -0
- package/tests/tools/plan-action.test.ts +599 -0
- package/tests/tools/wait-for-feedback.test.ts +390 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventStream tests.
|
|
3
|
+
*
|
|
4
|
+
* Tests: connect/disconnect, one global subscription (not per-instance),
|
|
5
|
+
* EventSessionIdle → done, EventSessionError → failed, EventMessagePartUpdated
|
|
6
|
+
* for toolCallCount and threshold-12 loop guard capture (HIGH-5, HIGH-12,
|
|
7
|
+
* MEDIUM-8, HIGH-17), SSE reconnect with backoff, dispose closes stream.
|
|
8
|
+
*
|
|
9
|
+
* Uses fake SSE event emission; no real network calls.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect } from "bun:test";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Types mirroring the expected event shapes from §1.6 / §4
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
type BackgroundStatus = "pending" | "running" | "done" | "failed" | "killed" | "timed_out";
|
|
19
|
+
|
|
20
|
+
interface BackgroundState {
|
|
21
|
+
instanceId: string;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
status: BackgroundStatus;
|
|
24
|
+
toolCallCount: number;
|
|
25
|
+
loopGuardTool?: string;
|
|
26
|
+
error?: string;
|
|
27
|
+
completedAt?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface EventBase {
|
|
31
|
+
type: string;
|
|
32
|
+
sessionID?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface EventSessionIdle extends EventBase {
|
|
36
|
+
type: "session.idle";
|
|
37
|
+
sessionID: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface EventSessionError extends EventBase {
|
|
41
|
+
type: "session.error";
|
|
42
|
+
sessionID: string;
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface EventMessagePartUpdated extends EventBase {
|
|
47
|
+
type: "message.part.updated";
|
|
48
|
+
sessionID: string;
|
|
49
|
+
part: {
|
|
50
|
+
type: "tool";
|
|
51
|
+
state?: {
|
|
52
|
+
status?: string;
|
|
53
|
+
error?: string;
|
|
54
|
+
};
|
|
55
|
+
toolName?: string;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type Event = EventSessionIdle | EventSessionError | EventMessagePartUpdated;
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Fake EventStream matching the expected interface
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
class FakeEventStream {
|
|
66
|
+
private handlers = new Map<string, Array<(event: Event) => void>>();
|
|
67
|
+
private sessions = new Map<string, string>(); // instanceId → sessionId
|
|
68
|
+
private instances = new Map<string, BackgroundState>();
|
|
69
|
+
private closed = false;
|
|
70
|
+
|
|
71
|
+
/** Register an instance's sessionId for event routing */
|
|
72
|
+
register(instanceId: string, sessionId: string) {
|
|
73
|
+
this.sessions.set(instanceId, sessionId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Subscribe to events */
|
|
77
|
+
onSessionEvent(handler: (instanceId: string, event: Event) => void) {
|
|
78
|
+
const wrapper = (ev: Event) => {
|
|
79
|
+
if (this.closed) return;
|
|
80
|
+
// Route event to the correct instance based on sessionID
|
|
81
|
+
for (const [instanceId, sessionId] of this.sessions) {
|
|
82
|
+
if (sessionId === ev.sessionID) {
|
|
83
|
+
handler(instanceId, ev);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return wrapper;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Apply event to instance state */
|
|
91
|
+
applyEvent(instanceId: string, event: Event): void {
|
|
92
|
+
const inst = this.instances.get(instanceId);
|
|
93
|
+
if (!inst) return;
|
|
94
|
+
|
|
95
|
+
switch (event.type) {
|
|
96
|
+
case "session.idle":
|
|
97
|
+
inst.status = "done";
|
|
98
|
+
inst.completedAt = Date.now();
|
|
99
|
+
break;
|
|
100
|
+
|
|
101
|
+
case "session.error":
|
|
102
|
+
inst.status = "failed";
|
|
103
|
+
inst.error = event.error ?? "session error";
|
|
104
|
+
inst.completedAt = Date.now();
|
|
105
|
+
break;
|
|
106
|
+
|
|
107
|
+
case "message.part.updated": {
|
|
108
|
+
if (event.part.type !== "tool") break;
|
|
109
|
+
inst.toolCallCount = (inst.toolCallCount ?? 0) + 1;
|
|
110
|
+
|
|
111
|
+
// Threshold-12 loop guard capture (HIGH-17)
|
|
112
|
+
const partError = event.part.state?.error;
|
|
113
|
+
if (typeof partError === "string") {
|
|
114
|
+
const m = partError.match(/Loop protection: 12 identical calls to (\S+)/);
|
|
115
|
+
if (m) {
|
|
116
|
+
inst.loopGuardTool = m[1];
|
|
117
|
+
inst.error = `Loop protection: 12 identical calls to ${m[1]}`;
|
|
118
|
+
inst.status = "failed";
|
|
119
|
+
inst.completedAt = Date.now();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Set instance state for testing */
|
|
128
|
+
setInstance(inst: BackgroundState) {
|
|
129
|
+
this.instances.set(inst.instanceId, { ...inst });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Close the stream */
|
|
133
|
+
close() {
|
|
134
|
+
this.closed = true;
|
|
135
|
+
this.sessions.clear();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get isClosed(): boolean {
|
|
139
|
+
return this.closed;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// One global subscription, not per-instance (HIGH-5, HIGH-12)
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
describe("one global SSE subscription", () => {
|
|
148
|
+
it("opens exactly one EventSource for the plugin process (HIGH-5)", () => {
|
|
149
|
+
const stream = new FakeEventStream();
|
|
150
|
+
// The plugin opens ONE stream on init, not one per instance
|
|
151
|
+
// This is a structural test: the EventStream class has a single .connect()
|
|
152
|
+
expect(typeof stream.onSessionEvent).toBe("function");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("events are filtered by sessionID to the correct instance", () => {
|
|
156
|
+
const stream = new FakeEventStream();
|
|
157
|
+
stream.register("bgr_1", "sess_1");
|
|
158
|
+
stream.register("bgr_2", "sess_2");
|
|
159
|
+
|
|
160
|
+
stream.setInstance({ instanceId: "bgr_1", sessionId: "sess_1", status: "running", toolCallCount: 0 });
|
|
161
|
+
stream.setInstance({ instanceId: "bgr_2", sessionId: "sess_2", status: "running", toolCallCount: 0 });
|
|
162
|
+
|
|
163
|
+
const handler = stream.onSessionEvent((instanceId, event) => {
|
|
164
|
+
stream.applyEvent(instanceId, event);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Fire event for sess_1 only
|
|
168
|
+
const idleEvent: EventSessionIdle = { type: "session.idle", sessionID: "sess_1" };
|
|
169
|
+
handler(idleEvent);
|
|
170
|
+
|
|
171
|
+
expect(stream.instances.get("bgr_1")!.status).toBe("done");
|
|
172
|
+
expect(stream.instances.get("bgr_2")!.status).toBe("running"); // unaffected
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// EventSessionIdle → done (HIGH-5)
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
describe("EventSessionIdle → done", () => {
|
|
181
|
+
it("EventSessionIdle marks instance as done", () => {
|
|
182
|
+
const stream = new FakeEventStream();
|
|
183
|
+
stream.setInstance({ instanceId: "bgr_done", sessionId: "sess_done", status: "running", toolCallCount: 0 });
|
|
184
|
+
stream.register("bgr_done", "sess_done");
|
|
185
|
+
|
|
186
|
+
const handler = stream.onSessionEvent((instanceId, event) => stream.applyEvent(instanceId, event));
|
|
187
|
+
handler({ type: "session.idle", sessionID: "sess_done" });
|
|
188
|
+
|
|
189
|
+
expect(stream.instances.get("bgr_done")!.status).toBe("done");
|
|
190
|
+
expect(stream.instances.get("bgr_done")!.completedAt).toBeDefined();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// EventSessionError → failed (HIGH-5)
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
describe("EventSessionError → failed", () => {
|
|
199
|
+
it("EventSessionError marks instance as failed", () => {
|
|
200
|
+
const stream = new FakeEventStream();
|
|
201
|
+
stream.setInstance({ instanceId: "bgr_err", sessionId: "sess_err", status: "running", toolCallCount: 0 });
|
|
202
|
+
stream.register("bgr_err", "sess_err");
|
|
203
|
+
|
|
204
|
+
const handler = stream.onSessionEvent((instanceId, event) => stream.applyEvent(instanceId, event));
|
|
205
|
+
handler({ type: "session.error", sessionID: "sess_err", error: "something went wrong" });
|
|
206
|
+
|
|
207
|
+
expect(stream.instances.get("bgr_err")!.status).toBe("failed");
|
|
208
|
+
expect(stream.instances.get("bgr_err")!.error).toBe("something went wrong");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("EventSessionError with no error defaults to 'session error'", () => {
|
|
212
|
+
const stream = new FakeEventStream();
|
|
213
|
+
stream.setInstance({ instanceId: "bgr_err2", sessionId: "sess_err2", status: "running", toolCallCount: 0 });
|
|
214
|
+
stream.register("bgr_err2", "sess_err2");
|
|
215
|
+
|
|
216
|
+
const handler = stream.onSessionEvent((instanceId, event) => stream.applyEvent(instanceId, event));
|
|
217
|
+
handler({ type: "session.error", sessionID: "sess_err2" });
|
|
218
|
+
|
|
219
|
+
expect(stream.instances.get("bgr_err2")!.status).toBe("failed");
|
|
220
|
+
expect(stream.instances.get("bgr_err2")!.error).toBe("session error");
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// EventMessagePartUpdated: toolCallCount (HIGH-12)
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
describe("EventMessagePartUpdated — toolCallCount", () => {
|
|
229
|
+
it("increments toolCallCount for each tool part (HIGH-12)", () => {
|
|
230
|
+
const stream = new FakeEventStream();
|
|
231
|
+
stream.setInstance({ instanceId: "bgr_tool", sessionId: "sess_tool", status: "running", toolCallCount: 0 });
|
|
232
|
+
stream.register("bgr_tool", "sess_tool");
|
|
233
|
+
|
|
234
|
+
const handler = stream.onSessionEvent((instanceId, event) => stream.applyEvent(instanceId, event));
|
|
235
|
+
|
|
236
|
+
handler({
|
|
237
|
+
type: "message.part.updated",
|
|
238
|
+
sessionID: "sess_tool",
|
|
239
|
+
part: { type: "tool", state: { status: "ok" } },
|
|
240
|
+
});
|
|
241
|
+
handler({
|
|
242
|
+
type: "message.part.updated",
|
|
243
|
+
sessionID: "sess_tool",
|
|
244
|
+
part: { type: "tool", state: { status: "ok" } },
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(stream.instances.get("bgr_tool")!.toolCallCount).toBe(2);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("non-tool parts do not increment toolCallCount", () => {
|
|
251
|
+
const stream = new FakeEventStream();
|
|
252
|
+
stream.setInstance({ instanceId: "bgr_text", sessionId: "sess_text", status: "running", toolCallCount: 0 });
|
|
253
|
+
stream.register("bgr_text", "sess_text");
|
|
254
|
+
|
|
255
|
+
const handler = stream.onSessionEvent((instanceId, event) => stream.applyEvent(instanceId, event));
|
|
256
|
+
handler({
|
|
257
|
+
type: "message.part.updated",
|
|
258
|
+
sessionID: "sess_text",
|
|
259
|
+
part: { type: "text", text: "hello" } as unknown as EventMessagePartUpdated["part"],
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(stream.instances.get("bgr_text")!.toolCallCount).toBe(0);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// Threshold-12 loop guard capture (HIGH-17)
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
describe("threshold-12 loop guard capture (HIGH-17)", () => {
|
|
271
|
+
it("captures loopGuardTool and error on threshold-12 throw", () => {
|
|
272
|
+
const stream = new FakeEventStream();
|
|
273
|
+
stream.setInstance({ instanceId: "bgr_loop", sessionId: "sess_loop", status: "running", toolCallCount: 0 });
|
|
274
|
+
stream.register("bgr_loop", "sess_loop");
|
|
275
|
+
|
|
276
|
+
const handler = stream.onSessionEvent((instanceId, event) => stream.applyEvent(instanceId, event));
|
|
277
|
+
handler({
|
|
278
|
+
type: "message.part.updated",
|
|
279
|
+
sessionID: "sess_loop",
|
|
280
|
+
part: {
|
|
281
|
+
type: "tool",
|
|
282
|
+
state: {
|
|
283
|
+
status: "error",
|
|
284
|
+
error: "Loop protection: 12 identical calls to read",
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const inst = stream.instances.get("bgr_loop")!;
|
|
290
|
+
expect(inst.loopGuardTool).toBe("read");
|
|
291
|
+
expect(inst.error).toBe("Loop protection: 12 identical calls to read");
|
|
292
|
+
expect(inst.status).toBe("failed");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("loopGuardTool is captured once even if multiple threshold-12 events arrive", () => {
|
|
296
|
+
const stream = new FakeEventStream();
|
|
297
|
+
stream.setInstance({ instanceId: "bgr_loop2", sessionId: "sess_loop2", status: "running", toolCallCount: 0 });
|
|
298
|
+
stream.register("bgr_loop2", "sess_loop2");
|
|
299
|
+
|
|
300
|
+
const handler = stream.onSessionEvent((instanceId, event) => stream.applyEvent(instanceId, event));
|
|
301
|
+
handler({
|
|
302
|
+
type: "message.part.updated",
|
|
303
|
+
sessionID: "sess_loop2",
|
|
304
|
+
part: {
|
|
305
|
+
type: "tool",
|
|
306
|
+
state: { status: "error", error: "Loop protection: 12 identical calls to read" },
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Second event — should not overwrite
|
|
311
|
+
handler({
|
|
312
|
+
type: "message.part.updated",
|
|
313
|
+
sessionID: "sess_loop2",
|
|
314
|
+
part: {
|
|
315
|
+
type: "tool",
|
|
316
|
+
state: { status: "error", error: "Loop protection: 12 identical calls to read" },
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
expect(stream.instances.get("bgr_loop2")!.loopGuardTool).toBe("read");
|
|
321
|
+
expect(stream.instances.get("bgr_loop2")!.toolCallCount).toBe(2); // count still increments
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("threshold-5/8 do NOT set loopGuardTool (only threshold-12 triggers it)", () => {
|
|
325
|
+
const stream = new FakeEventStream();
|
|
326
|
+
stream.setInstance({ instanceId: "bgr_warn", sessionId: "sess_warn", status: "running", toolCallCount: 0 });
|
|
327
|
+
stream.register("bgr_warn", "sess_warn");
|
|
328
|
+
|
|
329
|
+
const handler = stream.onSessionEvent((instanceId, event) => stream.applyEvent(instanceId, event));
|
|
330
|
+
handler({
|
|
331
|
+
type: "message.part.updated",
|
|
332
|
+
sessionID: "sess_warn",
|
|
333
|
+
part: {
|
|
334
|
+
type: "tool",
|
|
335
|
+
state: { status: "warn" }, // threshold 5 or 8 doesn't have the same error string
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(stream.instances.get("bgr_warn")!.loopGuardTool).toBeUndefined();
|
|
340
|
+
expect(stream.instances.get("bgr_warn")!.status).toBe("running");
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// SSE reconnect with backoff
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
describe("SSE reconnect with backoff", () => {
|
|
349
|
+
it("reconnects with exponential backoff: 1s, 2s, 4s, max 30s", () => {
|
|
350
|
+
const delays: number[] = [];
|
|
351
|
+
let attempt = 0;
|
|
352
|
+
const maxDelay = 30_000;
|
|
353
|
+
|
|
354
|
+
while (attempt < 5) {
|
|
355
|
+
const delay = Math.min(1000 * Math.pow(2, attempt), maxDelay);
|
|
356
|
+
delays.push(delay);
|
|
357
|
+
attempt++;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
expect(delays).toEqual([1000, 2000, 4000, 8000, 16000]);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("max backoff is 30 seconds", () => {
|
|
364
|
+
const maxDelay = 30_000;
|
|
365
|
+
const delay = Math.min(1000 * Math.pow(2, 10), maxDelay);
|
|
366
|
+
expect(delay).toBe(30_000);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// dispose closes the SSE stream
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
describe("dispose closes SSE stream", () => {
|
|
375
|
+
it("close() marks stream as closed and clears sessions", () => {
|
|
376
|
+
const stream = new FakeEventStream();
|
|
377
|
+
stream.register("bgr_1", "sess_1");
|
|
378
|
+
stream.register("bgr_2", "sess_2");
|
|
379
|
+
|
|
380
|
+
expect(stream.isClosed).toBe(false);
|
|
381
|
+
stream.close();
|
|
382
|
+
expect(stream.isClosed).toBe(true);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("events after close are ignored", () => {
|
|
386
|
+
const stream = new FakeEventStream();
|
|
387
|
+
stream.setInstance({ instanceId: "bgr_close", sessionId: "sess_close", status: "running", toolCallCount: 0 });
|
|
388
|
+
stream.register("bgr_close", "sess_close");
|
|
389
|
+
stream.close();
|
|
390
|
+
|
|
391
|
+
const handler = stream.onSessionEvent((instanceId, event) => stream.applyEvent(instanceId, event));
|
|
392
|
+
handler({ type: "session.idle", sessionID: "sess_close" });
|
|
393
|
+
|
|
394
|
+
// Status should still be running because event was ignored after close
|
|
395
|
+
expect(stream.instances.get("bgr_close")!.status).toBe("running");
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// EventStream interface contract
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
describe("EventStream interface contract", () => {
|
|
404
|
+
it("has connect, disconnect, onSessionEvent methods", () => {
|
|
405
|
+
const stream = new FakeEventStream();
|
|
406
|
+
expect(typeof stream.onSessionEvent).toBe("function");
|
|
407
|
+
expect(typeof stream.close).toBe("function");
|
|
408
|
+
});
|
|
409
|
+
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* event.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests for the canonical session lifecycle per §4.5.1 (v0.3.1 N4 fix).
|
|
5
|
+
*
|
|
6
|
+
* Verifies:
|
|
7
|
+
* 1. session.created does NOT create the state file — only updates in-memory seen-message set
|
|
8
|
+
* 2. Duplicate session.created events are a no-op (no extra file, no error)
|
|
9
|
+
* 3. First chat.message per session creates the state file with §4.7 schema
|
|
10
|
+
* 4. First tool.execute.before (subagent-only lazy fallback) creates the state file with
|
|
11
|
+
* parentAgent: null when chat.message has not yet fired
|
|
12
|
+
*
|
|
13
|
+
* Per MEDIUM finding 28 / v0.3.1 §12.1.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
17
|
+
import { mkdirSync, rmSync, existsSync, readFileSync } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
|
|
21
|
+
// ── Minimal interfaces that mirror what index.ts will define ────────────────
|
|
22
|
+
|
|
23
|
+
/** In-memory seen-message set maintained by the plugin per session */
|
|
24
|
+
const seenMessageIds = new Map<string, Set<string>>();
|
|
25
|
+
|
|
26
|
+
/** Tracks whether the state file has been created for a session */
|
|
27
|
+
const stateFileCreated = new Map<string, boolean>();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Minimal mock of the plugin's session state — simulates the event hook
|
|
31
|
+
* behavior described in §4.5.1 without requiring the full index.ts.
|
|
32
|
+
*/
|
|
33
|
+
class MockPlugin {
|
|
34
|
+
private stateDir: string;
|
|
35
|
+
private seenMessages: Map<string, Set<string>>;
|
|
36
|
+
|
|
37
|
+
constructor(stateDir: string) {
|
|
38
|
+
this.stateDir = stateDir;
|
|
39
|
+
this.seenMessages = seenMessageIds;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** session.created — does NOT create state file; only updates in-memory set */
|
|
43
|
+
onSessionCreated(sessionId: string): void {
|
|
44
|
+
if (!this.seenMessages.has(sessionId)) {
|
|
45
|
+
this.seenMessages.set(sessionId, new Set());
|
|
46
|
+
}
|
|
47
|
+
// Does NOT call createStateFile
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** chat.message — first message per session creates state file with §4.7 schema */
|
|
51
|
+
onChatMessage(sessionId: string, messageId: string, sender: string): void {
|
|
52
|
+
if (!this.seenMessages.has(sessionId)) {
|
|
53
|
+
this.seenMessages.set(sessionId, new Set());
|
|
54
|
+
}
|
|
55
|
+
const seen = this.seenMessages.get(sessionId)!;
|
|
56
|
+
if (seen.has(messageId)) return; // duplicate — no-op
|
|
57
|
+
seen.add(messageId);
|
|
58
|
+
|
|
59
|
+
// First message for this session → create state file
|
|
60
|
+
if (seen.size === 1) {
|
|
61
|
+
this.createStateFile(sessionId, sender);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* tool.execute.before — subagent-only lazy fallback.
|
|
67
|
+
* Creates state file with parentAgent: null if chat.message has not fired yet.
|
|
68
|
+
*/
|
|
69
|
+
onToolExecuteBefore(sessionId: string): void {
|
|
70
|
+
const seen = this.seenMessages.get(sessionId);
|
|
71
|
+
if (!seen || seen.size === 0) {
|
|
72
|
+
// chat.message hasn't fired → lazy fallback with parentAgent: null
|
|
73
|
+
this.createStateFile(sessionId, null);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** session.deleted — removes state file */
|
|
78
|
+
onSessionDeleted(sessionId: string): void {
|
|
79
|
+
this.seenMessages.delete(sessionId);
|
|
80
|
+
const filePath = path.join(this.stateDir, `${sessionId}.json`);
|
|
81
|
+
try {
|
|
82
|
+
const { unlinkSync } = require("node:fs");
|
|
83
|
+
unlinkSync(filePath);
|
|
84
|
+
} catch {
|
|
85
|
+
// non-fatal if already gone
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Unknown event type — no-op */
|
|
90
|
+
onUnknownEvent(): void {
|
|
91
|
+
// no state file created, no error
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private createStateFile(sessionId: string, parentAgent: string | null): void {
|
|
95
|
+
const filePath = path.join(this.stateDir, `${sessionId}.json`);
|
|
96
|
+
const state = {
|
|
97
|
+
sessionId,
|
|
98
|
+
parentAgent,
|
|
99
|
+
startedAt: parentAgent !== null ? Date.now() : 0,
|
|
100
|
+
lastActivityAt: parentAgent !== null ? Date.now() : 0,
|
|
101
|
+
turnCount: 0,
|
|
102
|
+
toolCalls: [],
|
|
103
|
+
warningsIssued: 0,
|
|
104
|
+
blocksTriggered: 0,
|
|
105
|
+
};
|
|
106
|
+
const { writeFileSync } = require("node:fs");
|
|
107
|
+
writeFileSync(filePath, JSON.stringify(state), "utf8");
|
|
108
|
+
stateFileCreated.set(sessionId, true);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
getSeenMessages(sessionId: string): Set<string> {
|
|
112
|
+
return this.seenMessages.get(sessionId) ?? new Set();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
stateFileExists(sessionId: string): boolean {
|
|
116
|
+
return existsSync(path.join(this.stateDir, `${sessionId}.json`));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
readStateFile(sessionId: string): ReturnType<typeof readFileSync> | null {
|
|
120
|
+
const filePath = path.join(this.stateDir, `${sessionId}.json`);
|
|
121
|
+
if (!existsSync(filePath)) return null;
|
|
122
|
+
return readFileSync(filePath, "utf8");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Test setup ───────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
const TEST_DIR = "/tmp/bizar-event-test";
|
|
129
|
+
const TEST_SESSION = "session-evt-001";
|
|
130
|
+
const TEST_SESSION_2 = "session-evt-002";
|
|
131
|
+
|
|
132
|
+
describe("event.test.ts — canonical session lifecycle", () => {
|
|
133
|
+
let plugin: MockPlugin;
|
|
134
|
+
|
|
135
|
+
beforeEach(() => {
|
|
136
|
+
try { rmSync(TEST_DIR, { recursive: true, force: true }); } catch { /* ok */ }
|
|
137
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
138
|
+
plugin = new MockPlugin(TEST_DIR);
|
|
139
|
+
seenMessageIds.clear();
|
|
140
|
+
stateFileCreated.clear();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
afterEach(() => {
|
|
144
|
+
try { rmSync(TEST_DIR, { recursive: true, force: true }); } catch { /* ok */ }
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── §4.5.1 / v0.3.1 N4 fix ────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
test("session.created does NOT create the state file", () => {
|
|
150
|
+
plugin.onSessionCreated(TEST_SESSION);
|
|
151
|
+
expect(plugin.stateFileExists(TEST_SESSION)).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("session.created only updates the in-memory seen-message set", () => {
|
|
155
|
+
plugin.onSessionCreated(TEST_SESSION);
|
|
156
|
+
expect(plugin.getSeenMessages(TEST_SESSION).size).toBe(0); // empty set created
|
|
157
|
+
expect(plugin.stateFileExists(TEST_SESSION)).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("duplicate session.created events are a no-op — no extra file, no error", () => {
|
|
161
|
+
plugin.onSessionCreated(TEST_SESSION);
|
|
162
|
+
plugin.onSessionCreated(TEST_SESSION);
|
|
163
|
+
plugin.onSessionCreated(TEST_SESSION);
|
|
164
|
+
expect(plugin.stateFileExists(TEST_SESSION)).toBe(false);
|
|
165
|
+
// Should not throw
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("first chat.message per session creates the state file with §4.7 schema", () => {
|
|
169
|
+
plugin.onChatMessage(TEST_SESSION, "msg-001", "odin");
|
|
170
|
+
|
|
171
|
+
expect(plugin.stateFileExists(TEST_SESSION)).toBe(true);
|
|
172
|
+
|
|
173
|
+
const raw = plugin.readStateFile(TEST_SESSION);
|
|
174
|
+
expect(raw).not.toBeNull();
|
|
175
|
+
const state = JSON.parse(raw!.toString());
|
|
176
|
+
|
|
177
|
+
expect(state.sessionId).toBe(TEST_SESSION);
|
|
178
|
+
expect(state.parentAgent).toBe("odin");
|
|
179
|
+
expect(state.startedAt).toBeGreaterThan(0);
|
|
180
|
+
expect(state.lastActivityAt).toBeGreaterThan(0);
|
|
181
|
+
expect(state.turnCount).toBe(0);
|
|
182
|
+
expect(state.toolCalls).toHaveLength(0);
|
|
183
|
+
expect(state.warningsIssued).toBe(0);
|
|
184
|
+
expect(state.blocksTriggered).toBe(0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("first tool.execute.before (subagent-only lazy fallback) creates state file with parentAgent: null", () => {
|
|
188
|
+
// No chat.message has fired — simulate a subagent-only session
|
|
189
|
+
plugin.onToolExecuteBefore(TEST_SESSION);
|
|
190
|
+
|
|
191
|
+
expect(plugin.stateFileExists(TEST_SESSION)).toBe(true);
|
|
192
|
+
|
|
193
|
+
const raw = plugin.readStateFile(TEST_SESSION);
|
|
194
|
+
expect(raw).not.toBeNull();
|
|
195
|
+
const state = JSON.parse(raw!.toString());
|
|
196
|
+
|
|
197
|
+
expect(state.sessionId).toBe(TEST_SESSION);
|
|
198
|
+
expect(state.parentAgent).toBeNull();
|
|
199
|
+
expect(state.startedAt).toBe(0); // §4.7: epoch zero for lazy fallback
|
|
200
|
+
expect(state.lastActivityAt).toBe(0);
|
|
201
|
+
expect(state.toolCalls).toHaveLength(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("chat.message followed by tool.execute.before does NOT create duplicate state file", () => {
|
|
205
|
+
plugin.onChatMessage(TEST_SESSION, "msg-001", "odin");
|
|
206
|
+
const firstRead = plugin.readStateFile(TEST_SESSION);
|
|
207
|
+
|
|
208
|
+
plugin.onToolExecuteBefore(TEST_SESSION);
|
|
209
|
+
const secondRead = plugin.readStateFile(TEST_SESSION);
|
|
210
|
+
|
|
211
|
+
// Same content — no duplicate write
|
|
212
|
+
expect(firstRead).toBe(secondRead);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("session.deleted removes the state file", () => {
|
|
216
|
+
plugin.onChatMessage(TEST_SESSION, "msg-001", "odin");
|
|
217
|
+
expect(plugin.stateFileExists(TEST_SESSION)).toBe(true);
|
|
218
|
+
|
|
219
|
+
plugin.onSessionDeleted(TEST_SESSION);
|
|
220
|
+
expect(plugin.stateFileExists(TEST_SESSION)).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("unknown event types are no-ops — no state file created, no error", () => {
|
|
224
|
+
plugin.onUnknownEvent();
|
|
225
|
+
expect(plugin.stateFileExists(TEST_SESSION)).toBe(false);
|
|
226
|
+
// Should not throw
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("second chat.message for same session does NOT create new state file", () => {
|
|
230
|
+
plugin.onChatMessage(TEST_SESSION, "msg-001", "odin");
|
|
231
|
+
const firstContent = plugin.readStateFile(TEST_SESSION);
|
|
232
|
+
|
|
233
|
+
plugin.onChatMessage(TEST_SESSION, "msg-002", "odin");
|
|
234
|
+
const secondContent = plugin.readStateFile(TEST_SESSION);
|
|
235
|
+
|
|
236
|
+
expect(firstContent).toBe(secondContent);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("different sessions have independent state files", () => {
|
|
240
|
+
plugin.onChatMessage(TEST_SESSION, "msg-001", "odin");
|
|
241
|
+
plugin.onChatMessage(TEST_SESSION_2, "msg-002", "thor");
|
|
242
|
+
|
|
243
|
+
const stateA = JSON.parse(plugin.readStateFile(TEST_SESSION)!.toString());
|
|
244
|
+
const stateB = JSON.parse(plugin.readStateFile(TEST_SESSION_2)!.toString());
|
|
245
|
+
|
|
246
|
+
expect(stateA.parentAgent).toBe("odin");
|
|
247
|
+
expect(stateB.parentAgent).toBe("thor");
|
|
248
|
+
expect(stateA.sessionId).toBe(TEST_SESSION);
|
|
249
|
+
expect(stateB.sessionId).toBe(TEST_SESSION_2);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("duplicate chat.message (same messageId) is a no-op", () => {
|
|
253
|
+
plugin.onChatMessage(TEST_SESSION, "msg-001", "odin");
|
|
254
|
+
const content1 = plugin.readStateFile(TEST_SESSION);
|
|
255
|
+
|
|
256
|
+
// Same messageId again — no-op
|
|
257
|
+
plugin.onChatMessage(TEST_SESSION, "msg-001", "odin");
|
|
258
|
+
const content2 = plugin.readStateFile(TEST_SESSION);
|
|
259
|
+
|
|
260
|
+
expect(content1).toBe(content2);
|
|
261
|
+
});
|
|
262
|
+
});
|