heyio 0.6.0 → 0.8.0

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.
@@ -15,6 +15,7 @@ import { listSchedules, getSchedule, deleteSchedule, setScheduleEnabled } from "
15
15
  import { listIoSchedules, getIoSchedule, deleteIoSchedule, setIoScheduleEnabled } from "../store/io-schedules.js";
16
16
  import { runScheduleNow } from "../copilot/scheduler.js";
17
17
  import { runIoScheduleNow } from "../copilot/io-scheduler.js";
18
+ import { listRecentNotifications, listUnreadNotifications, countUnreadNotifications, markNotificationRead, markAllNotificationsRead, } from "../store/notifications.js";
18
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
20
  const WEB_DIST = path.resolve(__dirname, "../../web-dist");
20
21
  let messageHandler;
@@ -28,6 +29,12 @@ export function broadcastToSSE(text) {
28
29
  res.write(`data: ${payload}\n\n`);
29
30
  }
30
31
  }
32
+ export function broadcastNotificationToSSE(payload) {
33
+ const data = JSON.stringify({ type: "notification", ...payload });
34
+ for (const res of sseConnections) {
35
+ res.write(`data: ${data}\n\n`);
36
+ }
37
+ }
31
38
  export async function startApiServer() {
32
39
  const app = express();
33
40
  app.use(express.json());
@@ -192,7 +199,26 @@ export async function startApiServer() {
192
199
  const taskId = Array.isArray(req.params.taskId) ? req.params.taskId[0] : req.params.taskId;
193
200
  try {
194
201
  const events = getTaskEvents(taskId);
195
- const activity = summarize(events);
202
+ let activity = summarize(events);
203
+ // Fallback: when in-memory events are gone (e.g. daemon restart),
204
+ // build a minimal entry from the persisted task result so the UI
205
+ // doesn't show "no activity" for tasks that actually ran. (#66)
206
+ if (activity.length === 0) {
207
+ const task = getTask(taskId);
208
+ if (task?.result) {
209
+ activity = [{
210
+ ts: task.completed_at ? new Date(task.completed_at).getTime() : Date.now(),
211
+ kind: "outcome",
212
+ icon: task.status === "failed" ? "\u274c" : task.status === "done" ? "\u2705" : "\ud83d\udccb",
213
+ summary: task.status === "failed"
214
+ ? "Task failed (activity log unavailable after restart)"
215
+ : "Task completed (activity log unavailable after restart)",
216
+ rawType: "task.result.fallback",
217
+ detail: task.result,
218
+ raw: { result: task.result, status: task.status },
219
+ }];
220
+ }
221
+ }
196
222
  res.json({ taskId, activity });
197
223
  }
198
224
  catch (e) {
@@ -426,6 +452,69 @@ export async function startApiServer() {
426
452
  res.status(500).json({ error: (e instanceof Error ? e.message : String(e)) });
427
453
  }
428
454
  });
455
+ // Notifications endpoints
456
+ api.get("/notifications", (_req, res) => {
457
+ try {
458
+ const unreadOnly = _req.query.unread === "true";
459
+ const rows = unreadOnly
460
+ ? listUnreadNotifications()
461
+ : (() => {
462
+ const rawLimit = _req.query.limit;
463
+ const parsed = typeof rawLimit === "string" ? Number.parseInt(rawLimit, 10) : NaN;
464
+ const limit = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 200) : 50;
465
+ return listRecentNotifications(limit);
466
+ })();
467
+ const unreadCount = countUnreadNotifications();
468
+ const notifications = rows.map(({ id, title, text, created_at, read_at, source_type, source_ref }) => {
469
+ let source = { type: source_type };
470
+ if (source_ref) {
471
+ try {
472
+ const parsed = JSON.parse(source_ref);
473
+ source = { type: source_type, ...parsed };
474
+ }
475
+ catch {
476
+ // source_ref is not valid JSON — fall back to type-only
477
+ }
478
+ }
479
+ return { id, title, text, created_at, read_at, source };
480
+ });
481
+ res.json({ notifications, unreadCount });
482
+ }
483
+ catch (e) {
484
+ console.error("Error listing notifications:", e);
485
+ res.status(500).json({ error: "Failed to list notifications" });
486
+ }
487
+ });
488
+ api.post("/notifications/read-all", (_req, res) => {
489
+ try {
490
+ const marked = markAllNotificationsRead();
491
+ res.json({ marked });
492
+ }
493
+ catch (e) {
494
+ console.error("Error marking all notifications read:", e);
495
+ res.status(500).json({ error: "Failed to mark notifications read" });
496
+ }
497
+ });
498
+ api.post("/notifications/:id/read", (req, res) => {
499
+ try {
500
+ const rawId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
501
+ const id = Number.parseInt(rawId, 10);
502
+ if (Number.isNaN(id)) {
503
+ res.status(400).json({ error: "invalid id" });
504
+ return;
505
+ }
506
+ const found = markNotificationRead(id);
507
+ if (!found) {
508
+ res.status(404).json({ error: "notification not found" });
509
+ return;
510
+ }
511
+ res.json({ ok: true });
512
+ }
513
+ catch (e) {
514
+ console.error("Error marking notification read:", e);
515
+ res.status(500).json({ error: "Failed to mark notification read" });
516
+ }
517
+ });
429
518
  // Chat endpoints
430
519
  api.post("/message", async (req, res) => {
431
520
  const { text } = req.body;
package/dist/config.js CHANGED
@@ -4,6 +4,9 @@ const DEFAULT_CONFIG = {
4
4
  telegramEnabled: false,
5
5
  selfEditEnabled: false,
6
6
  port: 3170,
7
+ backgroundNotifyMode: "meaningful",
8
+ backgroundNotifyTelegram: true,
9
+ backgroundNotifyTui: true,
7
10
  };
8
11
  function loadConfig() {
9
12
  mkdirSync(IO_HOME, { recursive: true });
@@ -6,6 +6,8 @@
6
6
  import { listIoSchedules, listDueIoSchedules, recordIoScheduleRun, setIoScheduleTimestamps, updateIoScheduleNextRun, } from "../store/io-schedules.js";
7
7
  import { sendToOrchestrator } from "./orchestrator.js";
8
8
  import { nextRun } from "./cron.js";
9
+ import { notifyBackground } from "../notify.js";
10
+ import { startScheduleRun, completeScheduleRun, failScheduleRun } from "../store/schedule-runs.js";
9
11
  const TICK_MS = 30_000;
10
12
  let timer;
11
13
  const inFlight = new Set();
@@ -30,13 +32,34 @@ async function fireSchedule(schedule) {
30
32
  }
31
33
  recordIoScheduleRun(schedule.id, ranAt, nextIso);
32
34
  console.log(`[io] io-scheduler: firing schedule "${schedule.name}" (next run: ${nextIso ?? "never"})`);
35
+ const run = startScheduleRun({
36
+ schedule_type: "io",
37
+ schedule_id: schedule.id,
38
+ schedule_name: schedule.name,
39
+ });
33
40
  try {
34
- await sendToOrchestrator(buildPrompt(schedule), { type: "background" }, () => {
35
- // No-op: scheduled work is fire-and-forget; output is captured in
36
- // the orchestrator's conversation log.
41
+ let buffer = "";
42
+ await sendToOrchestrator(buildPrompt(schedule), { type: "background" }, (text, done) => {
43
+ buffer += text;
44
+ if (done) {
45
+ void notifyBackground({
46
+ source: {
47
+ type: "io-schedule",
48
+ scheduleId: schedule.id,
49
+ scheduleName: schedule.name,
50
+ },
51
+ title: `IO schedule: ${schedule.name}`,
52
+ text: buffer.trim(),
53
+ }).then((notifyResult) => {
54
+ completeScheduleRun(run.id, notifyResult.id);
55
+ }).catch((err) => {
56
+ failScheduleRun(run.id, err instanceof Error ? err.message : String(err));
57
+ });
58
+ }
37
59
  });
38
60
  }
39
61
  catch (err) {
62
+ failScheduleRun(run.id, err instanceof Error ? err.message : String(err));
40
63
  console.error(`[io] io-scheduler: failed to dispatch schedule ${schedule.id}:`, err instanceof Error ? err.message : err);
41
64
  }
42
65
  finally {
@@ -14,6 +14,8 @@ import { listSchedules, listDueSchedules, recordScheduleRun, setScheduleTimestam
14
14
  import { getSquad } from "../store/squads.js";
15
15
  import { delegateToAgent } from "./agents.js";
16
16
  import { nextRun } from "./cron.js";
17
+ import { notifyBackground } from "../notify.js";
18
+ import { startScheduleRun, completeScheduleRun, failScheduleRun } from "../store/schedule-runs.js";
17
19
  const TICK_MS = 30_000;
18
20
  const AGENDA_BLOCKS = {
19
21
  triage: `**Triage**
@@ -70,12 +72,32 @@ async function fireSchedule(schedule) {
70
72
  recordScheduleRun(schedule.id, ranAt, nextIso);
71
73
  const prompt = buildStandupPrompt({ name: squad.name, slug: squad.slug, project_path: squad.project_path }, schedule);
72
74
  console.log(`[io] scheduler: firing schedule "${schedule.name}" for squad "${squad.slug}" (next run: ${nextIso ?? "never"})`);
75
+ const run = startScheduleRun({
76
+ schedule_type: "squad",
77
+ schedule_id: schedule.id,
78
+ schedule_name: schedule.name,
79
+ squad_slug: squad.slug,
80
+ });
73
81
  try {
74
- await delegateToAgent(squad.slug, prompt, () => {
75
- // No-op: result is recorded on the agent task; the standup is fire-and-forget.
82
+ await delegateToAgent(squad.slug, prompt, (_taskId, result) => {
83
+ void notifyBackground({
84
+ source: {
85
+ type: "squad-schedule",
86
+ scheduleId: schedule.id,
87
+ squadSlug: squad.slug,
88
+ scheduleName: schedule.name,
89
+ },
90
+ title: `${squad.name}: ${schedule.name}`,
91
+ text: result,
92
+ }).then((notifyResult) => {
93
+ completeScheduleRun(run.id, notifyResult.id);
94
+ }).catch((err) => {
95
+ failScheduleRun(run.id, err instanceof Error ? err.message : String(err));
96
+ });
76
97
  });
77
98
  }
78
99
  catch (err) {
100
+ failScheduleRun(run.id, err instanceof Error ? err.message : String(err));
79
101
  console.error(`[io] scheduler: failed to delegate stand-up for schedule ${schedule.id}:`, err instanceof Error ? err.message : err);
80
102
  }
81
103
  finally {
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Tests for src/copilot/session-timeout.ts — sendWithIdleTimeout
3
+ *
4
+ * Strategy: build a FakeSession class whose sendAndWait() is controlled by
5
+ * a deferred Promise. Event handlers registered via .on() are stored in a
6
+ * subscriber list and can be triggered manually with .emit(). Timer
7
+ * behaviour is driven by node:test's mock.timers so no real setTimeout fires.
8
+ */
9
+ import { describe, it, before, after, beforeEach, mock } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { sendWithIdleTimeout, } from "./session-timeout.js";
12
+ /** Minimal fake implementing only what sendWithIdleTimeout needs. */
13
+ function makeFakeSession() {
14
+ const broadcastHandlers = [];
15
+ const deltaHandlers = [];
16
+ let resolveWait;
17
+ let rejectWait;
18
+ let aborted = false;
19
+ const session = {
20
+ // Typed overload: session.on("assistant.message_delta", handler)
21
+ // Generic overload: session.on(handler)
22
+ on(typeOrHandler, handler) {
23
+ if (typeof typeOrHandler === "function") {
24
+ broadcastHandlers.push(typeOrHandler);
25
+ return () => {
26
+ const idx = broadcastHandlers.indexOf(typeOrHandler);
27
+ if (idx !== -1)
28
+ broadcastHandlers.splice(idx, 1);
29
+ };
30
+ }
31
+ // typed: only care about delta for accumulation
32
+ if (typeOrHandler === "assistant.message_delta" && handler) {
33
+ deltaHandlers.push(handler);
34
+ return () => {
35
+ const idx = deltaHandlers.indexOf(handler);
36
+ if (idx !== -1)
37
+ deltaHandlers.splice(idx, 1);
38
+ };
39
+ }
40
+ return () => { };
41
+ },
42
+ sendAndWait(_opts, _timeout) {
43
+ return new Promise((resolve, reject) => {
44
+ resolveWait = resolve;
45
+ rejectWait = reject;
46
+ });
47
+ },
48
+ abort() {
49
+ aborted = true;
50
+ return Promise.resolve();
51
+ },
52
+ // ── Test-only helpers ──────────────────────────────────────────────────
53
+ /** Emit an event to all broad-listener handlers. */
54
+ emit(event) {
55
+ for (const h of broadcastHandlers)
56
+ h(event);
57
+ },
58
+ /** Emit an assistant.message_delta event with the given text chunk. */
59
+ emitDelta(deltaContent) {
60
+ const deltaEvent = {
61
+ type: "assistant.message_delta",
62
+ id: "evt-1",
63
+ timestamp: new Date().toISOString(),
64
+ parentId: null,
65
+ ephemeral: true,
66
+ data: { messageId: "msg-1", deltaContent },
67
+ };
68
+ // Notify delta-specific handlers
69
+ for (const h of deltaHandlers) {
70
+ h(deltaEvent);
71
+ }
72
+ // Also broadcast to all-event handlers so idle timer resets
73
+ this.emit(deltaEvent);
74
+ },
75
+ /** Emit a generic progress event (e.g. tool.execution_complete). */
76
+ emitProgress(type) {
77
+ const event = {
78
+ type,
79
+ id: "evt-2",
80
+ timestamp: new Date().toISOString(),
81
+ parentId: null,
82
+ ephemeral: false,
83
+ data: {},
84
+ };
85
+ this.emit(event);
86
+ },
87
+ /** Resolve the sendAndWait promise with a final content string. */
88
+ resolve(content) {
89
+ resolveWait?.({ data: { content } });
90
+ },
91
+ /** Resolve the sendAndWait promise with undefined (triggers accumulated fallback). */
92
+ resolveUndefined() {
93
+ resolveWait?.(undefined);
94
+ },
95
+ /** Reject the sendAndWait promise with a timeout-style error. */
96
+ rejectWithTimeout() {
97
+ rejectWait?.(new Error("Timeout after 600000ms waiting for session.idle"));
98
+ },
99
+ /** Reject the sendAndWait promise with a non-timeout error. */
100
+ rejectWithError(msg) {
101
+ rejectWait?.(new Error(msg));
102
+ },
103
+ get wasAborted() {
104
+ return aborted;
105
+ },
106
+ };
107
+ return session;
108
+ }
109
+ // Cast FakeSession to CopilotSession for type-compatibility with sendWithIdleTimeout.
110
+ // The fake satisfies the structural subset used by the function.
111
+ function asSession(fake) {
112
+ return fake;
113
+ }
114
+ // ── Helpers ──────────────────────────────────────────────────────────────────
115
+ const DEFAULT_OPTS = {
116
+ idleMs: 5_000,
117
+ hardCapMs: 30_000,
118
+ };
119
+ // ── Tests ─────────────────────────────────────────────────────────────────────
120
+ describe("sendWithIdleTimeout", () => {
121
+ before(() => {
122
+ mock.timers.enable({ apis: ["setTimeout"] });
123
+ });
124
+ after(() => {
125
+ mock.timers.reset();
126
+ });
127
+ beforeEach(() => {
128
+ // Reset the fake timer state between tests without disabling
129
+ mock.timers.reset();
130
+ mock.timers.enable({ apis: ["setTimeout"] });
131
+ });
132
+ // ── happy path ──────────────────────────────────────────────────────────
133
+ it("returns content and timedOut=false when session resolves normally", async () => {
134
+ const fake = makeFakeSession();
135
+ const promise = sendWithIdleTimeout(asSession(fake), "do something", DEFAULT_OPTS);
136
+ // Let the initial idle timer be registered, then resolve immediately
137
+ fake.resolve("task done successfully");
138
+ const result = await promise;
139
+ assert.equal(result.timedOut, false);
140
+ assert.equal(result.content, "task done successfully");
141
+ assert.equal(result.timeoutReason, undefined);
142
+ });
143
+ it("accumulates delta content during streaming (verified via resolveUndefined)", async () => {
144
+ const fake = makeFakeSession();
145
+ const promise = sendWithIdleTimeout(asSession(fake), "stream this", DEFAULT_OPTS);
146
+ fake.emitDelta("Hello ");
147
+ fake.emitDelta("world");
148
+ fake.emitDelta("!");
149
+ // resolveUndefined triggers `response?.data?.content ?? accumulated` → uses accumulated
150
+ fake.resolveUndefined();
151
+ const result = await promise;
152
+ assert.equal(result.timedOut, false);
153
+ assert.equal(result.content, "Hello world!");
154
+ });
155
+ it("falls back to accumulated delta when sendAndWait resolves with undefined", async () => {
156
+ const fake = makeFakeSession();
157
+ const promise = sendWithIdleTimeout(asSession(fake), "stream this", DEFAULT_OPTS);
158
+ fake.emitDelta("partial ");
159
+ fake.emitDelta("output");
160
+ // Resolve with undefined to trigger the `response?.data?.content ?? accumulated` fallback
161
+ fake.resolveUndefined();
162
+ const result = await promise;
163
+ assert.equal(result.timedOut, false);
164
+ assert.equal(result.content, "partial output");
165
+ });
166
+ // ── idle timer reset ────────────────────────────────────────────────────
167
+ it("resets idle timer on assistant.message_delta", async () => {
168
+ const fake = makeFakeSession();
169
+ const idleTimeoutFired = { value: false };
170
+ const opts = {
171
+ ...DEFAULT_OPTS,
172
+ idleMs: 1_000,
173
+ onIdleTimeout: () => { idleTimeoutFired.value = true; },
174
+ };
175
+ const promise = sendWithIdleTimeout(asSession(fake), "do work", opts);
176
+ // Advance to just before idle timeout — timer should NOT fire yet
177
+ mock.timers.tick(800);
178
+ assert.equal(idleTimeoutFired.value, false);
179
+ // Emit a progress event — should reset the idle timer
180
+ fake.emitProgress("tool.execution_complete");
181
+ // Advance another 800ms — if reset worked, timer still hasn't fired (800 < 1000)
182
+ mock.timers.tick(800);
183
+ assert.equal(idleTimeoutFired.value, false, "idle timer should have been reset by progress event");
184
+ // Resolve and clean up
185
+ fake.resolve("done");
186
+ await promise;
187
+ });
188
+ it("fires idle timeout after idleMs of silence", async () => {
189
+ const fake = makeFakeSession();
190
+ let idleFired = false;
191
+ const opts = {
192
+ ...DEFAULT_OPTS,
193
+ idleMs: 2_000,
194
+ onIdleTimeout: () => { idleFired = true; },
195
+ };
196
+ const promise = sendWithIdleTimeout(asSession(fake), "long task", opts);
197
+ // Advance past idle timeout — no events emitted
198
+ mock.timers.tick(2_001);
199
+ // Abort fires async; wait a tick then check
200
+ await Promise.resolve();
201
+ assert.equal(idleFired, true, "onIdleTimeout should have fired");
202
+ assert.equal(fake.wasAborted, true, "session.abort() should have been called");
203
+ // Simulate the sendAndWait timing out after abort
204
+ fake.rejectWithTimeout();
205
+ const result = await promise;
206
+ assert.equal(result.timedOut, true);
207
+ });
208
+ it("resets idle timer on tool.execution_complete", async () => {
209
+ const fake = makeFakeSession();
210
+ let idleFired = false;
211
+ const opts = {
212
+ ...DEFAULT_OPTS,
213
+ idleMs: 1_000,
214
+ onIdleTimeout: () => { idleFired = true; },
215
+ };
216
+ const promise = sendWithIdleTimeout(asSession(fake), "tool task", opts);
217
+ mock.timers.tick(700);
218
+ fake.emitProgress("tool.execution_complete");
219
+ mock.timers.tick(700); // 700ms since last reset — still under 1000ms
220
+ assert.equal(idleFired, false, "idle timer should have been reset by tool event");
221
+ fake.resolve("done");
222
+ await promise;
223
+ assert.equal(idleFired, false);
224
+ });
225
+ it("resets idle timer on assistant.turn_start", async () => {
226
+ const fake = makeFakeSession();
227
+ let idleFired = false;
228
+ const opts = {
229
+ ...DEFAULT_OPTS,
230
+ idleMs: 1_000,
231
+ onIdleTimeout: () => { idleFired = true; },
232
+ };
233
+ const promise = sendWithIdleTimeout(asSession(fake), "turn task", opts);
234
+ mock.timers.tick(700);
235
+ fake.emitProgress("assistant.turn_start");
236
+ mock.timers.tick(700);
237
+ assert.equal(idleFired, false, "idle timer should reset on assistant.turn_start");
238
+ fake.resolve("done");
239
+ await promise;
240
+ });
241
+ it("does NOT reset idle timer for unrecognised event types", async () => {
242
+ const fake = makeFakeSession();
243
+ let idleFired = false;
244
+ const opts = {
245
+ ...DEFAULT_OPTS,
246
+ idleMs: 1_000,
247
+ onIdleTimeout: () => { idleFired = true; },
248
+ };
249
+ const promise = sendWithIdleTimeout(asSession(fake), "noisy task", opts);
250
+ mock.timers.tick(700);
251
+ // This event type is not in PROGRESS_EVENT_TYPES
252
+ fake.emitProgress("some.unknown.event");
253
+ mock.timers.tick(400); // 700 + 400 = 1100ms since last real reset
254
+ await Promise.resolve();
255
+ assert.equal(idleFired, true, "idle timer should fire — unknown event should not reset it");
256
+ fake.rejectWithTimeout();
257
+ const result = await promise;
258
+ assert.equal(result.timedOut, true);
259
+ });
260
+ // ── graceful timeout capture ─────────────────────────────────────────────
261
+ it("captures partial content when idle timeout fires mid-stream", async () => {
262
+ const fake = makeFakeSession();
263
+ const opts = {
264
+ ...DEFAULT_OPTS,
265
+ idleMs: 1_000,
266
+ };
267
+ const promise = sendWithIdleTimeout(asSession(fake), "long output", opts);
268
+ // Agent emits some content then goes silent
269
+ fake.emitDelta("Step 1 done. ");
270
+ fake.emitDelta("Step 2 in progress...");
271
+ // Advance past idle timeout
272
+ mock.timers.tick(1_001);
273
+ await Promise.resolve();
274
+ // sendAndWait throws a timeout error after abort
275
+ fake.rejectWithTimeout();
276
+ const result = await promise;
277
+ assert.equal(result.timedOut, true);
278
+ // idle timer fires first → abortReason="idle"; catch block sees aborted=true and preserves it
279
+ assert.equal(result.timeoutReason, "idle", "idle fired before sendAndWait rejection → reason stays idle");
280
+ assert.ok(result.content.includes("Step 1 done.") && result.content.includes("Step 2 in progress..."), `partial content should be captured; got: ${result.content}`);
281
+ });
282
+ it("returns fallback message when no content was accumulated before timeout", async () => {
283
+ const fake = makeFakeSession();
284
+ const opts = { ...DEFAULT_OPTS, idleMs: 500 };
285
+ const promise = sendWithIdleTimeout(asSession(fake), "silent agent", opts);
286
+ mock.timers.tick(501);
287
+ await Promise.resolve();
288
+ fake.rejectWithTimeout();
289
+ const result = await promise;
290
+ assert.equal(result.timedOut, true);
291
+ assert.ok(result.content.includes("no output captured"), `should report no output captured; got: ${result.content}`);
292
+ });
293
+ it("sets timeoutReason=idle when abort fires before sendAndWait throws", async () => {
294
+ const fake = makeFakeSession();
295
+ let idleCallbackInfo;
296
+ const opts = {
297
+ ...DEFAULT_OPTS,
298
+ idleMs: 500,
299
+ onIdleTimeout: (info) => { idleCallbackInfo = info; },
300
+ };
301
+ const promise = sendWithIdleTimeout(asSession(fake), "silent", opts);
302
+ mock.timers.tick(501);
303
+ await Promise.resolve(); // let abort() fire
304
+ // When aborted=true, the resolve branch returns timedOut:true with timeoutReason from abortReason
305
+ fake.resolve("some final content");
306
+ const result = await promise;
307
+ assert.equal(result.timedOut, true);
308
+ assert.equal(result.timeoutReason, "idle");
309
+ assert.ok(idleCallbackInfo, "onIdleTimeout callback should have been called");
310
+ assert.equal(idleCallbackInfo?.idleMs, 500);
311
+ });
312
+ it("calls onProgress for each recognised event type", async () => {
313
+ const fake = makeFakeSession();
314
+ const progressEvents = [];
315
+ const opts = {
316
+ ...DEFAULT_OPTS,
317
+ onProgress: (type) => progressEvents.push(type),
318
+ };
319
+ const promise = sendWithIdleTimeout(asSession(fake), "track progress", opts);
320
+ fake.emitProgress("tool.execution_start");
321
+ fake.emitProgress("tool.execution_complete");
322
+ fake.emitDelta("some output");
323
+ fake.resolve("done");
324
+ await promise;
325
+ assert.ok(progressEvents.includes("tool.execution_start"));
326
+ assert.ok(progressEvents.includes("tool.execution_complete"));
327
+ assert.ok(progressEvents.includes("assistant.message_delta"));
328
+ });
329
+ it("tracks lastEventType in result", async () => {
330
+ const fake = makeFakeSession();
331
+ const promise = sendWithIdleTimeout(asSession(fake), "track last", DEFAULT_OPTS);
332
+ fake.emitProgress("tool.execution_start");
333
+ fake.emitProgress("tool.execution_complete");
334
+ fake.resolve("done");
335
+ const result = await promise;
336
+ assert.equal(result.lastEventType, "tool.execution_complete");
337
+ });
338
+ // ── error handling ───────────────────────────────────────────────────────
339
+ it("re-throws non-timeout errors", async () => {
340
+ const fake = makeFakeSession();
341
+ const promise = sendWithIdleTimeout(asSession(fake), "bad prompt", DEFAULT_OPTS);
342
+ fake.rejectWithError("unexpected authentication failure");
343
+ await assert.rejects(() => promise, (err) => {
344
+ assert.ok(err.message.includes("unexpected authentication failure"));
345
+ return true;
346
+ });
347
+ });
348
+ it("calls onHardCap when sendAndWait throws timeout and aborted=false", async () => {
349
+ const fake = makeFakeSession();
350
+ let hardCapFired = false;
351
+ const opts = {
352
+ ...DEFAULT_OPTS,
353
+ onHardCap: () => { hardCapFired = true; },
354
+ };
355
+ const promise = sendWithIdleTimeout(asSession(fake), "hard task", opts);
356
+ // Don't advance past idle timer — just let the hard cap throw
357
+ fake.rejectWithTimeout();
358
+ const result = await promise;
359
+ assert.equal(result.timedOut, true);
360
+ assert.equal(result.timeoutReason, "hard_cap");
361
+ assert.equal(hardCapFired, true);
362
+ });
363
+ it("cleans up subscriptions after normal completion (no memory leak)", async () => {
364
+ const fake = makeFakeSession();
365
+ const promise = sendWithIdleTimeout(asSession(fake), "cleanup test", DEFAULT_OPTS);
366
+ fake.resolve("done");
367
+ const result = await promise;
368
+ assert.equal(result.timedOut, false);
369
+ // If unsubscribe threw, the test would fail — we're asserting no throw
370
+ });
371
+ });
372
+ //# sourceMappingURL=session-timeout.test.js.map