@app-vitals/agent-runtime 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +50 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
@@ -0,0 +1,1581 @@
1
+ /**
2
+ * agent/src/runtime/chat-poller.unit.test.ts
3
+ *
4
+ * Unit tests for the chat poll loop.
5
+ * Uses fake client + fake runner — no real HTTP, no global.fetch overrides.
6
+ */
7
+
8
+ import { describe, expect, it, mock } from "bun:test";
9
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { createChatPoller, deriveReply } from "./chat-poller.ts";
13
+ import type { ClaudeRunResult, ProgressCallback } from "./claude.ts";
14
+ import type { ChatServiceClient } from "./http-chat-service-client.ts";
15
+
16
+ // ─── Helpers ───────────────────────────────────────────────────────────────────
17
+
18
+ function makeThread(id: string) {
19
+ return {
20
+ id,
21
+ agentId: "agent-1",
22
+ memberId: null,
23
+ title: null,
24
+ createdAt: new Date(),
25
+ updatedAt: new Date(),
26
+ };
27
+ }
28
+
29
+ function makeMessage(threadId: string, messageId = "msg-1") {
30
+ return {
31
+ id: messageId,
32
+ threadId,
33
+ role: "user",
34
+ body: "Hello from user",
35
+ claimedBy: "agent-1",
36
+ claimedAt: new Date(),
37
+ heartbeatAt: null,
38
+ cancelRequestedAt: null,
39
+ repliedAt: null,
40
+ tokens: null,
41
+ costUsd: null,
42
+ attachmentFilename: null,
43
+ createdAt: new Date(),
44
+ updatedAt: new Date(),
45
+ };
46
+ }
47
+
48
+ function makeReplyResult(msg: ReturnType<typeof makeMessage>) {
49
+ return {
50
+ userMessage: msg,
51
+ assistantMessage: {
52
+ ...msg,
53
+ id: "msg-reply-1",
54
+ role: "assistant",
55
+ body: "Hi there!",
56
+ claimedBy: null,
57
+ claimedAt: null,
58
+ },
59
+ };
60
+ }
61
+
62
+ /** Create a fake ChatServiceClient. All methods are overridable per test. */
63
+ function makeFakeClient(
64
+ overrides: Partial<ChatServiceClient> = {},
65
+ ): ChatServiceClient {
66
+ return {
67
+ listThreads: async () => ({ threads: [], total: 0, limit: 50, offset: 0 }),
68
+ claimMessage: async () => null,
69
+ heartbeat: async () => ({ cancelRequested: false }),
70
+ replyToMessage: async () => ({
71
+ userMessage: makeMessage("thread-1"),
72
+ assistantMessage: makeMessage("thread-1"),
73
+ }),
74
+ getAttachment: async () => null,
75
+ ...overrides,
76
+ };
77
+ }
78
+
79
+ // ─── createChatPoller: basic structure ────────────────────────────────────────
80
+
81
+ describe("createChatPoller", () => {
82
+ it("returns an object with start and stop methods", () => {
83
+ const client = makeFakeClient();
84
+ const runner = async () => ({ result: "ok" });
85
+ const poller = createChatPoller({ client, runner });
86
+
87
+ expect(typeof poller.start).toBe("function");
88
+ expect(typeof poller.stop).toBe("function");
89
+ });
90
+ });
91
+
92
+ // ─── poll: no threads → no-op ─────────────────────────────────────────────────
93
+
94
+ describe("createChatPoller poll: no threads", () => {
95
+ it("does not call claimMessage when there are no threads", async () => {
96
+ const claimMessage = mock(async () => null);
97
+ const client = makeFakeClient({ claimMessage });
98
+ const runner = mock(async () => ({ result: "ok" }));
99
+
100
+ const poller = createChatPoller({ client, runner });
101
+ await poller.pollOnce();
102
+
103
+ expect(claimMessage).not.toHaveBeenCalled();
104
+ expect(runner).not.toHaveBeenCalled();
105
+ });
106
+ });
107
+
108
+ // ─── poll: thread with no unclaimed messages ───────────────────────────────────
109
+
110
+ describe("createChatPoller poll: thread with no unclaimed message", () => {
111
+ it("does not call runner when claimMessage returns null", async () => {
112
+ const thread = makeThread("thread-1");
113
+ const client = makeFakeClient({
114
+ listThreads: async () => ({
115
+ threads: [thread],
116
+ total: 1,
117
+ limit: 50,
118
+ offset: 0,
119
+ }),
120
+ claimMessage: async () => null,
121
+ });
122
+ const runner = mock(async () => ({ result: "ok" }));
123
+
124
+ const poller = createChatPoller({ client, runner });
125
+ await poller.pollOnce();
126
+
127
+ expect(runner).not.toHaveBeenCalled();
128
+ });
129
+ });
130
+
131
+ // ─── poll: claim + reply happy path ───────────────────────────────────────────
132
+
133
+ describe("createChatPoller poll: claim then reply", () => {
134
+ it("runs runner with chat:threadId session key and posts reply", async () => {
135
+ const threadId = "thread-42";
136
+ const messageId = "msg-99";
137
+ const thread = makeThread(threadId);
138
+ const message = makeMessage(threadId, messageId);
139
+ const replyResult = makeReplyResult(message);
140
+
141
+ const replyToMessage = mock(async () => replyResult);
142
+ const client = makeFakeClient({
143
+ listThreads: async () => ({
144
+ threads: [thread],
145
+ total: 1,
146
+ limit: 50,
147
+ offset: 0,
148
+ }),
149
+ claimMessage: async () => message,
150
+ replyToMessage,
151
+ });
152
+
153
+ const runnerResult = {
154
+ result: "Claude said hi",
155
+ sessionId: "session-abc",
156
+ usage: {
157
+ input_tokens: 100,
158
+ output_tokens: 50,
159
+ cache_read_input_tokens: 0,
160
+ cache_creation_input_tokens: 0,
161
+ },
162
+ totalCostUsd: 0.002,
163
+ };
164
+ const runner = mock(async () => runnerResult);
165
+
166
+ const poller = createChatPoller({ client, runner });
167
+ await poller.pollOnce();
168
+
169
+ // Runner called with the message body and correct session key
170
+ expect(runner).toHaveBeenCalledTimes(1);
171
+ const [runBody, runKey] = runner.mock.calls[0] as unknown as [
172
+ string,
173
+ string,
174
+ ];
175
+ expect(runBody).toBe(message.body);
176
+ expect(runKey).toBe(`chat:${threadId}`);
177
+
178
+ // Reply posted with runner output
179
+ expect(replyToMessage).toHaveBeenCalledTimes(1);
180
+ expect(replyToMessage).toHaveBeenCalledWith(
181
+ threadId,
182
+ messageId,
183
+ expect.objectContaining({
184
+ body: runnerResult.result,
185
+ tokens: runnerResult.usage,
186
+ costUsd: runnerResult.totalCostUsd,
187
+ }),
188
+ );
189
+ });
190
+
191
+ it("passes chat:threadId session key to runner for per-thread continuity", async () => {
192
+ // The poller passes a stable `chat:<threadId>` session key to the runner on
193
+ // every call. The runner (createRunClaude) uses this key to look up and
194
+ // persist session IDs in its own injected session store — the poller does
195
+ // not manage session persistence directly.
196
+ const threadId = "thread-session-test";
197
+ const thread = makeThread(threadId);
198
+ const message = makeMessage(threadId);
199
+ const replyResult = makeReplyResult(message);
200
+
201
+ const client = makeFakeClient({
202
+ listThreads: async () => ({
203
+ threads: [thread],
204
+ total: 1,
205
+ limit: 50,
206
+ offset: 0,
207
+ }),
208
+ claimMessage: async () => message,
209
+ replyToMessage: async () => replyResult,
210
+ });
211
+
212
+ const runner = mock(async () => ({
213
+ result: "reply",
214
+ sessionId: "session-stored-123",
215
+ }));
216
+
217
+ const poller = createChatPoller({ client, runner });
218
+ await poller.pollOnce();
219
+
220
+ // Runner must receive the stable session key so it can resume the session
221
+ const [runBody2, runKey2] = runner.mock.calls[0] as unknown as [
222
+ string,
223
+ string,
224
+ ];
225
+ expect(runBody2).toBe(message.body);
226
+ expect(runKey2).toBe(`chat:${threadId}`);
227
+ });
228
+ });
229
+
230
+ // ─── poll: per-thread error isolation ─────────────────────────────────────────
231
+
232
+ describe("createChatPoller poll: per-thread error isolation", () => {
233
+ it("continues processing other threads when one thread fails", async () => {
234
+ const thread1 = makeThread("thread-fail");
235
+ const thread2 = makeThread("thread-ok");
236
+ const messageOk = makeMessage("thread-ok", "msg-ok");
237
+ const replyResult = makeReplyResult(messageOk);
238
+
239
+ let callCount = 0;
240
+ const client = makeFakeClient({
241
+ listThreads: async () => ({
242
+ threads: [thread1, thread2],
243
+ total: 2,
244
+ limit: 50,
245
+ offset: 0,
246
+ }),
247
+ claimMessage: async (threadId) => {
248
+ if (threadId === "thread-fail") {
249
+ throw new Error("claim failed for thread-fail");
250
+ }
251
+ return messageOk;
252
+ },
253
+ replyToMessage: async () => replyResult,
254
+ });
255
+
256
+ const runner = mock(async () => ({
257
+ result: "ok reply",
258
+ sessionId: "s1",
259
+ }));
260
+
261
+ const poller = createChatPoller({ client, runner });
262
+
263
+ // Should not throw even though thread-fail errors
264
+ await expect(poller.pollOnce()).resolves.toBeUndefined();
265
+ callCount++;
266
+
267
+ // Runner should still be called for the successful thread
268
+ expect(runner).toHaveBeenCalledTimes(1);
269
+ expect(callCount).toBe(1);
270
+ });
271
+
272
+ it("does not throw when runner fails for one thread", async () => {
273
+ const thread = makeThread("thread-runner-fail");
274
+ const message = makeMessage("thread-runner-fail");
275
+
276
+ const client = makeFakeClient({
277
+ listThreads: async () => ({
278
+ threads: [thread],
279
+ total: 1,
280
+ limit: 50,
281
+ offset: 0,
282
+ }),
283
+ claimMessage: async () => message,
284
+ });
285
+
286
+ const runner = mock(async () => {
287
+ throw new Error("runner crashed");
288
+ });
289
+
290
+ const poller = createChatPoller({ client, runner });
291
+
292
+ // Must not throw — errors are caught per-thread
293
+ await expect(poller.pollOnce()).resolves.toBeUndefined();
294
+ });
295
+
296
+ it("does not throw when replyToMessage fails", async () => {
297
+ const thread = makeThread("thread-reply-fail");
298
+ const message = makeMessage("thread-reply-fail");
299
+
300
+ const client = makeFakeClient({
301
+ listThreads: async () => ({
302
+ threads: [thread],
303
+ total: 1,
304
+ limit: 50,
305
+ offset: 0,
306
+ }),
307
+ claimMessage: async () => message,
308
+ replyToMessage: async () => {
309
+ throw new Error("reply service down");
310
+ },
311
+ });
312
+
313
+ const runner = mock(async () => ({ result: "ok" }));
314
+
315
+ const poller = createChatPoller({ client, runner });
316
+
317
+ await expect(poller.pollOnce()).resolves.toBeUndefined();
318
+ });
319
+ });
320
+
321
+ // ─── poll: listThreads failure ─────────────────────────────────────────────────
322
+
323
+ describe("createChatPoller poll: listThreads failure", () => {
324
+ it("does not throw and skips the poll iteration when listThreads fails", async () => {
325
+ const listThreads = mock(async () => {
326
+ throw new Error("chat service unreachable");
327
+ });
328
+ const claimMessage = mock(async () => null);
329
+ const client = makeFakeClient({ listThreads, claimMessage });
330
+ const runner = mock(async () => ({ result: "ok" }));
331
+
332
+ const poller = createChatPoller({ client, runner });
333
+
334
+ await expect(poller.pollOnce()).resolves.toBeUndefined();
335
+ expect(listThreads).toHaveBeenCalledTimes(1);
336
+ expect(claimMessage).not.toHaveBeenCalled();
337
+ expect(runner).not.toHaveBeenCalled();
338
+ });
339
+ });
340
+
341
+ // ─── poll: heartbeat during a long-running reply ───────────────────────────────
342
+
343
+ /**
344
+ * Deterministic stand-in for setInterval/clearInterval. Registered callbacks
345
+ * fire only when `tick()` is called, so tests never depend on wall time.
346
+ */
347
+ function makeFakeInterval() {
348
+ let nextId = 1;
349
+ const active = new Map<number, () => void>();
350
+ const setIntervalFn = ((fn: () => void) => {
351
+ const id = nextId++;
352
+ active.set(id, fn);
353
+ return id;
354
+ }) as unknown as typeof setInterval;
355
+ const clearIntervalFn = ((id: number) => {
356
+ active.delete(id);
357
+ }) as unknown as typeof clearInterval;
358
+ const tick = (times = 1) => {
359
+ for (let i = 0; i < times; i++) for (const fn of [...active.values()]) fn();
360
+ };
361
+ return {
362
+ setIntervalFn,
363
+ clearIntervalFn,
364
+ tick,
365
+ activeCount: () => active.size,
366
+ };
367
+ }
368
+
369
+ describe("createChatPoller poll: heartbeat during a long-running reply", () => {
370
+ it("beats on an interval even when the run emits no progress at all", async () => {
371
+ const threadId = "thread-hb";
372
+ const messageId = "msg-hb";
373
+ const thread = makeThread(threadId);
374
+ const message = makeMessage(threadId, messageId);
375
+ const replyResult = makeReplyResult(message);
376
+
377
+ const heartbeat = mock(async () => ({ cancelRequested: false }));
378
+ const client = makeFakeClient({
379
+ listThreads: async () => ({
380
+ threads: [thread],
381
+ total: 1,
382
+ limit: 50,
383
+ offset: 0,
384
+ }),
385
+ claimMessage: async () => message,
386
+ replyToMessage: async () => replyResult,
387
+ heartbeat,
388
+ });
389
+
390
+ const fake = makeFakeInterval();
391
+
392
+ // The regression this guards: a single long tool call emits ONE stream
393
+ // event and then nothing until it returns. A progress-driven heartbeat
394
+ // goes silent here — exactly during the wait it exists to cover. This
395
+ // runner never reports progress, and the heartbeat must still fire.
396
+ const runner = mock(async () => {
397
+ fake.tick(3);
398
+ return { result: "ok" };
399
+ });
400
+
401
+ const poller = createChatPoller({
402
+ client,
403
+ runner,
404
+ setIntervalFn: fake.setIntervalFn,
405
+ clearIntervalFn: fake.clearIntervalFn,
406
+ });
407
+ await poller.pollOnce();
408
+
409
+ expect(heartbeat).toHaveBeenCalledTimes(3);
410
+ expect(heartbeat).toHaveBeenCalledWith(threadId, messageId);
411
+ });
412
+
413
+ it("stops beating once the run completes", async () => {
414
+ const threadId = "thread-hb-stop";
415
+ const message = makeMessage(threadId, "msg-hb-stop");
416
+ const replyResult = makeReplyResult(message);
417
+
418
+ const heartbeat = mock(async () => ({ cancelRequested: false }));
419
+ const client = makeFakeClient({
420
+ listThreads: async () => ({
421
+ threads: [makeThread(threadId)],
422
+ total: 1,
423
+ limit: 50,
424
+ offset: 0,
425
+ }),
426
+ claimMessage: async () => message,
427
+ replyToMessage: async () => replyResult,
428
+ heartbeat,
429
+ });
430
+
431
+ const fake = makeFakeInterval();
432
+ const runner = mock(async () => ({ result: "ok" }));
433
+
434
+ const poller = createChatPoller({
435
+ client,
436
+ runner,
437
+ setIntervalFn: fake.setIntervalFn,
438
+ clearIntervalFn: fake.clearIntervalFn,
439
+ });
440
+ await poller.pollOnce();
441
+
442
+ expect(fake.activeCount()).toBe(0);
443
+ fake.tick(5);
444
+ expect(heartbeat).not.toHaveBeenCalled();
445
+ });
446
+
447
+ it("stops beating when the run throws", async () => {
448
+ const threadId = "thread-hb-throw";
449
+ const message = makeMessage(threadId, "msg-hb-throw");
450
+
451
+ const heartbeat = mock(async () => ({ cancelRequested: false }));
452
+ const client = makeFakeClient({
453
+ listThreads: async () => ({
454
+ threads: [makeThread(threadId)],
455
+ total: 1,
456
+ limit: 50,
457
+ offset: 0,
458
+ }),
459
+ claimMessage: async () => message,
460
+ heartbeat,
461
+ });
462
+
463
+ const fake = makeFakeInterval();
464
+ const runner = mock(async () => {
465
+ throw new Error("runner exploded");
466
+ });
467
+
468
+ const poller = createChatPoller({
469
+ client,
470
+ runner,
471
+ setIntervalFn: fake.setIntervalFn,
472
+ clearIntervalFn: fake.clearIntervalFn,
473
+ });
474
+ await poller.pollOnce();
475
+
476
+ expect(fake.activeCount()).toBe(0);
477
+ });
478
+
479
+ it("does not let a heartbeat failure abort the reply", async () => {
480
+ const threadId = "thread-hb-fail";
481
+ const message = makeMessage(threadId, "msg-hb-fail");
482
+ const replyResult = makeReplyResult(message);
483
+
484
+ const replyToMessage = mock(async () => replyResult);
485
+ const client = makeFakeClient({
486
+ listThreads: async () => ({
487
+ threads: [makeThread(threadId)],
488
+ total: 1,
489
+ limit: 50,
490
+ offset: 0,
491
+ }),
492
+ claimMessage: async () => message,
493
+ replyToMessage,
494
+ heartbeat: async () => {
495
+ throw new Error("chat service unreachable");
496
+ },
497
+ });
498
+
499
+ const runner = mock(
500
+ async (_msg: string, _key?: string, onProgress?: ProgressCallback) => {
501
+ onProgress?.({});
502
+ return { result: "ok" };
503
+ },
504
+ );
505
+
506
+ const poller = createChatPoller({ client, runner });
507
+
508
+ await expect(poller.pollOnce()).resolves.toBeUndefined();
509
+ expect(replyToMessage).toHaveBeenCalledTimes(1);
510
+ });
511
+ });
512
+
513
+ // ─── poll: always replies (errorKind on failure, non-empty on incomplete) ──────
514
+
515
+ describe("createChatPoller poll: always replies with an errorKind on failure", () => {
516
+ it("replies with a non-empty body and an errorKind when the runner throws", async () => {
517
+ const threadId = "thread-fail-reply";
518
+ const message = makeMessage(threadId, "msg-fail");
519
+ const replyResult = makeReplyResult(message);
520
+
521
+ const replyToMessage = mock(async () => replyResult);
522
+ const client = makeFakeClient({
523
+ listThreads: async () => ({
524
+ threads: [makeThread(threadId)],
525
+ total: 1,
526
+ limit: 50,
527
+ offset: 0,
528
+ }),
529
+ claimMessage: async () => message,
530
+ replyToMessage,
531
+ });
532
+
533
+ const runner = mock(async () => {
534
+ throw new Error("runner exploded");
535
+ });
536
+
537
+ const poller = createChatPoller({ client, runner });
538
+ await poller.pollOnce();
539
+
540
+ // The message must NOT be left claimed-forever: a reply is always posted.
541
+ expect(replyToMessage).toHaveBeenCalledTimes(1);
542
+ const [, , opts] = replyToMessage.mock.calls[0] as unknown as [
543
+ string,
544
+ string,
545
+ { body: string; errorKind?: string },
546
+ ];
547
+ expect(opts.errorKind).toBeTruthy();
548
+ expect(opts.body.length).toBeGreaterThan(0);
549
+ });
550
+
551
+ it("replies with a non-empty body and errorKind=incomplete when the run streamIncomplete's with empty result", async () => {
552
+ const threadId = "thread-incomplete";
553
+ const message = makeMessage(threadId, "msg-inc");
554
+ const replyResult = makeReplyResult(message);
555
+
556
+ const replyToMessage = mock(async () => replyResult);
557
+ const client = makeFakeClient({
558
+ listThreads: async () => ({
559
+ threads: [makeThread(threadId)],
560
+ total: 1,
561
+ limit: 50,
562
+ offset: 0,
563
+ }),
564
+ claimMessage: async () => message,
565
+ replyToMessage,
566
+ });
567
+
568
+ // Empty result + streamIncomplete — the poller must NOT post an empty reply.
569
+ const runner = mock(async () => ({ result: "", streamIncomplete: true }));
570
+
571
+ const poller = createChatPoller({ client, runner });
572
+ await poller.pollOnce();
573
+
574
+ expect(replyToMessage).toHaveBeenCalledTimes(1);
575
+ const [, , opts] = replyToMessage.mock.calls[0] as unknown as [
576
+ string,
577
+ string,
578
+ { body: string; errorKind?: string },
579
+ ];
580
+ expect(opts.body.length).toBeGreaterThan(0);
581
+ expect(opts.errorKind).toBe("incomplete");
582
+ });
583
+ });
584
+
585
+ // ─── poll: cancel via the heartbeat tick ───────────────────────────────────────
586
+
587
+ describe("createChatPoller poll: cancel via heartbeat", () => {
588
+ it("aborts the in-flight run when a heartbeat reports cancelRequested, replying with errorKind=cancelled", async () => {
589
+ const threadId = "thread-cancel";
590
+ const message = makeMessage(threadId, "msg-cancel");
591
+ const replyResult = makeReplyResult(message);
592
+
593
+ const replyToMessage = mock(async () => replyResult);
594
+ // Heartbeat reports cancelRequested on the first beat.
595
+ const heartbeat = mock(async () => ({ cancelRequested: true }));
596
+ const client = makeFakeClient({
597
+ listThreads: async () => ({
598
+ threads: [makeThread(threadId)],
599
+ total: 1,
600
+ limit: 50,
601
+ offset: 0,
602
+ }),
603
+ claimMessage: async () => message,
604
+ replyToMessage,
605
+ heartbeat,
606
+ });
607
+
608
+ const fake = makeFakeInterval();
609
+
610
+ // The runner observes the injected AbortSignal and rejects when it aborts,
611
+ // mirroring how createRunClaude throws ClaudeAbortedError on cancel.
612
+ const runner = mock(
613
+ (
614
+ _msg: string,
615
+ _key?: string,
616
+ _onProgress?: ProgressCallback,
617
+ signal?: AbortSignal,
618
+ ) =>
619
+ new Promise<{ result: string }>((_resolve, reject) => {
620
+ if (signal) {
621
+ signal.addEventListener("abort", () => {
622
+ const err = new Error("aborted");
623
+ err.name = "ClaudeAbortedError";
624
+ reject(err);
625
+ });
626
+ }
627
+ // Fire a heartbeat tick — which sees cancelRequested and aborts.
628
+ fake.tick(1);
629
+ }),
630
+ );
631
+
632
+ const poller = createChatPoller({
633
+ client,
634
+ runner,
635
+ setIntervalFn: fake.setIntervalFn,
636
+ clearIntervalFn: fake.clearIntervalFn,
637
+ });
638
+ await poller.pollOnce();
639
+
640
+ // The run was cancelled and a cancelled reply was posted.
641
+ expect(replyToMessage).toHaveBeenCalledTimes(1);
642
+ const [, , opts] = replyToMessage.mock.calls[0] as unknown as [
643
+ string,
644
+ string,
645
+ { body: string; errorKind?: string },
646
+ ];
647
+ expect(opts.errorKind).toBe("cancelled");
648
+ });
649
+ });
650
+
651
+ // ─── start/stop: timer lifecycle ───────────────────────────────────────────────
652
+ //
653
+ // Moved to chat-poller.integration.test.ts — these tests drive real
654
+ // setInterval/clearInterval across real ~100ms wall-clock waits, which puts
655
+ // them over the unit-layer <200ms hard cap and outside its "no I/O of any
656
+ // kind" boundary (see docs/test-readiness/test-system.md).
657
+
658
+ // ─── poll: multiple threads ────────────────────────────────────────────────────
659
+
660
+ describe("createChatPoller poll: multiple threads", () => {
661
+ it("processes all threads in a single poll", async () => {
662
+ const threads = [makeThread("t1"), makeThread("t2"), makeThread("t3")];
663
+ const messages = {
664
+ t1: makeMessage("t1", "m1"),
665
+ t2: makeMessage("t2", "m2"),
666
+ t3: makeMessage("t3", "m3"),
667
+ };
668
+ const claimedThreadIds: string[] = [];
669
+ const repliedThreadIds: string[] = [];
670
+
671
+ const client = makeFakeClient({
672
+ listThreads: async () => ({
673
+ threads,
674
+ total: 3,
675
+ limit: 50,
676
+ offset: 0,
677
+ }),
678
+ claimMessage: async (threadId) => {
679
+ claimedThreadIds.push(threadId);
680
+ return messages[threadId as "t1" | "t2" | "t3"] ?? null;
681
+ },
682
+ replyToMessage: async (threadId) => {
683
+ repliedThreadIds.push(threadId);
684
+ return makeReplyResult(messages[threadId as "t1" | "t2" | "t3"]);
685
+ },
686
+ });
687
+
688
+ const runner = mock(async () => ({ result: "ok" }));
689
+
690
+ const poller = createChatPoller({ client, runner });
691
+ await poller.pollOnce();
692
+
693
+ expect(claimedThreadIds.sort()).toEqual(["t1", "t2", "t3"]);
694
+ expect(repliedThreadIds.sort()).toEqual(["t1", "t2", "t3"]);
695
+ expect(runner).toHaveBeenCalledTimes(3);
696
+ });
697
+ });
698
+
699
+ // ─── poll: attachment handling ─────────────────────────────────────────────────
700
+
701
+ function makeMessageWithAttachment(
702
+ threadId: string,
703
+ filename: string,
704
+ messageId = "msg-att",
705
+ ) {
706
+ return {
707
+ ...makeMessage(threadId, messageId),
708
+ attachmentFilename: filename,
709
+ };
710
+ }
711
+
712
+ describe("createChatPoller poll: attachment handling", () => {
713
+ it("pulls attachment, writes it to the workspace, and augments the runner message", async () => {
714
+ const workspaceDir = await mkdtemp(join(tmpdir(), "chat-poller-att-"));
715
+ try {
716
+ const threadId = "thread-att";
717
+ const messageId = "msg-att-1";
718
+ const thread = makeThread(threadId);
719
+ const message = makeMessageWithAttachment(
720
+ threadId,
721
+ "report.txt",
722
+ messageId,
723
+ );
724
+ const replyResult = makeReplyResult(makeMessage(threadId, messageId));
725
+
726
+ const fileBytes = new Uint8Array([104, 105]); // "hi"
727
+ const getAttachment = mock(async () => fileBytes);
728
+
729
+ const client = makeFakeClient({
730
+ listThreads: async () => ({
731
+ threads: [thread],
732
+ total: 1,
733
+ limit: 50,
734
+ offset: 0,
735
+ }),
736
+ claimMessage: async () => message,
737
+ replyToMessage: async () => replyResult,
738
+ getAttachment,
739
+ });
740
+
741
+ const runner = mock(async () => ({ result: "ok" }));
742
+
743
+ const poller = createChatPoller({ client, runner, workspaceDir });
744
+ await poller.pollOnce();
745
+
746
+ // getAttachment called with thread + message id
747
+ expect(getAttachment).toHaveBeenCalledTimes(1);
748
+ expect(getAttachment).toHaveBeenCalledWith(threadId, messageId);
749
+
750
+ // Runner message augmented with the attachment note
751
+ expect(runner).toHaveBeenCalledTimes(1);
752
+ const runnerArg = (runner.mock.calls[0] as unknown[])[0] as string;
753
+ expect(runnerArg).toContain(message.body);
754
+ expect(runnerArg).toContain("report.txt");
755
+
756
+ // File written to <workspace>/uploads/<id>-<filename>
757
+ const filePath = join(workspaceDir, "uploads", `${messageId}-report.txt`);
758
+ const written = await readFile(filePath);
759
+ expect(Array.from(new Uint8Array(written))).toEqual([104, 105]);
760
+ } finally {
761
+ await rm(workspaceDir, { recursive: true, force: true });
762
+ }
763
+ });
764
+
765
+ it("does not augment the runner message when getAttachment returns null", async () => {
766
+ const workspaceDir = await mkdtemp(join(tmpdir(), "chat-poller-att-"));
767
+ try {
768
+ const threadId = "thread-att-null";
769
+ const thread = makeThread(threadId);
770
+ const message = makeMessageWithAttachment(threadId, "gone.txt");
771
+ const replyResult = makeReplyResult(makeMessage(threadId));
772
+
773
+ const getAttachment = mock(async () => null);
774
+ const client = makeFakeClient({
775
+ listThreads: async () => ({
776
+ threads: [thread],
777
+ total: 1,
778
+ limit: 50,
779
+ offset: 0,
780
+ }),
781
+ claimMessage: async () => message,
782
+ replyToMessage: async () => replyResult,
783
+ getAttachment,
784
+ });
785
+
786
+ const runner = mock(async () => ({ result: "ok" }));
787
+ const poller = createChatPoller({ client, runner, workspaceDir });
788
+ await poller.pollOnce();
789
+
790
+ expect(getAttachment).toHaveBeenCalledTimes(1);
791
+ const runnerArg = (runner.mock.calls[0] as unknown[])[0] as string;
792
+ expect(runnerArg).toBe(message.body);
793
+ expect(runnerArg).not.toContain("gone.txt");
794
+ } finally {
795
+ await rm(workspaceDir, { recursive: true, force: true });
796
+ }
797
+ });
798
+
799
+ it("continues without augmenting the message when getAttachment throws", async () => {
800
+ const workspaceDir = await mkdtemp(join(tmpdir(), "chat-poller-att-"));
801
+ try {
802
+ const threadId = "thread-att-error";
803
+ const thread = makeThread(threadId);
804
+ const message = makeMessageWithAttachment(threadId, "broken.txt");
805
+ const replyResult = makeReplyResult(makeMessage(threadId));
806
+
807
+ const getAttachment = mock(async () => {
808
+ throw new Error("attachment fetch failed");
809
+ });
810
+ const client = makeFakeClient({
811
+ listThreads: async () => ({
812
+ threads: [thread],
813
+ total: 1,
814
+ limit: 50,
815
+ offset: 0,
816
+ }),
817
+ claimMessage: async () => message,
818
+ replyToMessage: async () => replyResult,
819
+ getAttachment,
820
+ });
821
+
822
+ const runner = mock(async () => ({ result: "ok" }));
823
+ const poller = createChatPoller({ client, runner, workspaceDir });
824
+
825
+ // Must not throw — the attachment fetch error is caught and logged, and
826
+ // the runner still gets called with the un-augmented message body.
827
+ await expect(poller.pollOnce()).resolves.toBeUndefined();
828
+
829
+ expect(getAttachment).toHaveBeenCalledTimes(1);
830
+ expect(runner).toHaveBeenCalledTimes(1);
831
+ const runnerArg = (runner.mock.calls[0] as unknown[])[0] as string;
832
+ expect(runnerArg).toBe(message.body);
833
+ expect(runnerArg).not.toContain("broken.txt");
834
+ } finally {
835
+ await rm(workspaceDir, { recursive: true, force: true });
836
+ }
837
+ });
838
+
839
+ it("does not call getAttachment when the message has no attachmentFilename", async () => {
840
+ const workspaceDir = await mkdtemp(join(tmpdir(), "chat-poller-att-"));
841
+ try {
842
+ const threadId = "thread-no-att";
843
+ const thread = makeThread(threadId);
844
+ const message = makeMessage(threadId);
845
+ const replyResult = makeReplyResult(message);
846
+
847
+ const getAttachment = mock(async () => new Uint8Array([1]));
848
+ const client = makeFakeClient({
849
+ listThreads: async () => ({
850
+ threads: [thread],
851
+ total: 1,
852
+ limit: 50,
853
+ offset: 0,
854
+ }),
855
+ claimMessage: async () => message,
856
+ replyToMessage: async () => replyResult,
857
+ getAttachment,
858
+ });
859
+
860
+ const runner = mock(async () => ({ result: "ok" }));
861
+ const poller = createChatPoller({ client, runner, workspaceDir });
862
+ await poller.pollOnce();
863
+
864
+ expect(getAttachment).not.toHaveBeenCalled();
865
+ const runnerArg = (runner.mock.calls[0] as unknown[])[0] as string;
866
+ expect(runnerArg).toBe(message.body);
867
+ } finally {
868
+ await rm(workspaceDir, { recursive: true, force: true });
869
+ }
870
+ });
871
+ });
872
+
873
+ // ─── poll: audio attachment transcription (STT) ────────────────────────────────
874
+
875
+ describe("createChatPoller poll: audio attachment transcription", () => {
876
+ it("detects every supported audio extension (case-insensitive) and transcribes it", async () => {
877
+ const extensions = [
878
+ "voice.webm",
879
+ "voice.ogg",
880
+ "voice.wav",
881
+ "voice.m4a",
882
+ "voice.mp3",
883
+ "voice.WEBM",
884
+ "voice.Wav",
885
+ ];
886
+
887
+ for (const filename of extensions) {
888
+ const workspaceDir = await mkdtemp(join(tmpdir(), "chat-poller-voice-"));
889
+ try {
890
+ const threadId = `thread-voice-${filename}`;
891
+ const messageId = "msg-voice-1";
892
+ const thread = makeThread(threadId);
893
+ const message = makeMessageWithAttachment(
894
+ threadId,
895
+ filename,
896
+ messageId,
897
+ );
898
+ const replyResult = makeReplyResult(makeMessage(threadId, messageId));
899
+
900
+ const fileBytes = new Uint8Array([1, 2, 3]);
901
+ const getAttachment = mock(async () => fileBytes);
902
+ const transcribeAudioFn = mock(async () => "hello from voice");
903
+
904
+ const client = makeFakeClient({
905
+ listThreads: async () => ({
906
+ threads: [thread],
907
+ total: 1,
908
+ limit: 50,
909
+ offset: 0,
910
+ }),
911
+ claimMessage: async () => message,
912
+ replyToMessage: async () => replyResult,
913
+ getAttachment,
914
+ });
915
+
916
+ const runner = mock(async () => ({ result: "ok" }));
917
+ const poller = createChatPoller({
918
+ client,
919
+ runner,
920
+ workspaceDir,
921
+ transcribeAudioFn,
922
+ });
923
+ await poller.pollOnce();
924
+
925
+ expect(transcribeAudioFn).toHaveBeenCalledTimes(1);
926
+ const [filePath] = transcribeAudioFn.mock.calls[0] as unknown[];
927
+ expect(filePath).toContain(messageId);
928
+
929
+ const runnerArg = (runner.mock.calls[0] as unknown[])[0] as string;
930
+ expect(runnerArg).toBe("[voice transcript: hello from voice]");
931
+ expect(runnerArg).not.toContain(message.body);
932
+ } finally {
933
+ await rm(workspaceDir, { recursive: true, force: true });
934
+ }
935
+ }
936
+ });
937
+
938
+ it("does not transcribe a non-audio attachment extension", async () => {
939
+ const workspaceDir = await mkdtemp(join(tmpdir(), "chat-poller-voice-"));
940
+ try {
941
+ const threadId = "thread-voice-nonaudio";
942
+ const messageId = "msg-voice-nonaudio";
943
+ const thread = makeThread(threadId);
944
+ const message = makeMessageWithAttachment(
945
+ threadId,
946
+ "notes.pdf",
947
+ messageId,
948
+ );
949
+ const replyResult = makeReplyResult(makeMessage(threadId, messageId));
950
+
951
+ const fileBytes = new Uint8Array([1, 2, 3]);
952
+ const getAttachment = mock(async () => fileBytes);
953
+ const transcribeAudioFn = mock(async () => "should not be called");
954
+
955
+ const client = makeFakeClient({
956
+ listThreads: async () => ({
957
+ threads: [thread],
958
+ total: 1,
959
+ limit: 50,
960
+ offset: 0,
961
+ }),
962
+ claimMessage: async () => message,
963
+ replyToMessage: async () => replyResult,
964
+ getAttachment,
965
+ });
966
+
967
+ const runner = mock(async () => ({ result: "ok" }));
968
+ const poller = createChatPoller({
969
+ client,
970
+ runner,
971
+ workspaceDir,
972
+ transcribeAudioFn,
973
+ });
974
+ await poller.pollOnce();
975
+
976
+ expect(transcribeAudioFn).not.toHaveBeenCalled();
977
+ const runnerArg = (runner.mock.calls[0] as unknown[])[0] as string;
978
+ expect(runnerArg).toContain(message.body);
979
+ expect(runnerArg).toContain("notes.pdf");
980
+ } finally {
981
+ await rm(workspaceDir, { recursive: true, force: true });
982
+ }
983
+ });
984
+
985
+ it("falls back to the attached-file note when transcribeAudioFn returns null", async () => {
986
+ const workspaceDir = await mkdtemp(join(tmpdir(), "chat-poller-voice-"));
987
+ try {
988
+ const threadId = "thread-voice-null";
989
+ const messageId = "msg-voice-null";
990
+ const thread = makeThread(threadId);
991
+ const message = makeMessageWithAttachment(
992
+ threadId,
993
+ "voice.webm",
994
+ messageId,
995
+ );
996
+ const replyResult = makeReplyResult(makeMessage(threadId, messageId));
997
+
998
+ const getAttachment = mock(async () => new Uint8Array([1, 2, 3]));
999
+ const transcribeAudioFn = mock(async () => null);
1000
+
1001
+ const client = makeFakeClient({
1002
+ listThreads: async () => ({
1003
+ threads: [thread],
1004
+ total: 1,
1005
+ limit: 50,
1006
+ offset: 0,
1007
+ }),
1008
+ claimMessage: async () => message,
1009
+ replyToMessage: async () => replyResult,
1010
+ getAttachment,
1011
+ });
1012
+
1013
+ const runner = mock(async () => ({ result: "ok" }));
1014
+ const poller = createChatPoller({
1015
+ client,
1016
+ runner,
1017
+ workspaceDir,
1018
+ transcribeAudioFn,
1019
+ });
1020
+ await poller.pollOnce();
1021
+
1022
+ expect(transcribeAudioFn).toHaveBeenCalledTimes(1);
1023
+ const runnerArg = (runner.mock.calls[0] as unknown[])[0] as string;
1024
+ expect(runnerArg).toContain(message.body);
1025
+ expect(runnerArg).toContain("voice.webm");
1026
+ expect(runnerArg).not.toContain("[voice transcript:");
1027
+ } finally {
1028
+ await rm(workspaceDir, { recursive: true, force: true });
1029
+ }
1030
+ });
1031
+
1032
+ it("falls back to the attached-file note when transcribeAudioFn throws", async () => {
1033
+ const workspaceDir = await mkdtemp(join(tmpdir(), "chat-poller-voice-"));
1034
+ try {
1035
+ const threadId = "thread-voice-throw";
1036
+ const messageId = "msg-voice-throw";
1037
+ const thread = makeThread(threadId);
1038
+ const message = makeMessageWithAttachment(
1039
+ threadId,
1040
+ "voice.ogg",
1041
+ messageId,
1042
+ );
1043
+ const replyResult = makeReplyResult(makeMessage(threadId, messageId));
1044
+
1045
+ const getAttachment = mock(async () => new Uint8Array([1, 2, 3]));
1046
+ const transcribeAudioFn = mock(async () => {
1047
+ throw new Error("network error");
1048
+ });
1049
+
1050
+ const client = makeFakeClient({
1051
+ listThreads: async () => ({
1052
+ threads: [thread],
1053
+ total: 1,
1054
+ limit: 50,
1055
+ offset: 0,
1056
+ }),
1057
+ claimMessage: async () => message,
1058
+ replyToMessage: async () => replyResult,
1059
+ getAttachment,
1060
+ });
1061
+
1062
+ const runner = mock(async () => ({ result: "ok" }));
1063
+ const poller = createChatPoller({
1064
+ client,
1065
+ runner,
1066
+ workspaceDir,
1067
+ transcribeAudioFn,
1068
+ });
1069
+
1070
+ await expect(poller.pollOnce()).resolves.toBeUndefined();
1071
+
1072
+ expect(transcribeAudioFn).toHaveBeenCalledTimes(1);
1073
+ const runnerArg = (runner.mock.calls[0] as unknown[])[0] as string;
1074
+ expect(runnerArg).toContain(message.body);
1075
+ expect(runnerArg).toContain("voice.ogg");
1076
+ } finally {
1077
+ await rm(workspaceDir, { recursive: true, force: true });
1078
+ }
1079
+ });
1080
+
1081
+ it("falls back to the attached-file note when the transcript is empty/whitespace-only", async () => {
1082
+ const workspaceDir = await mkdtemp(join(tmpdir(), "chat-poller-voice-"));
1083
+ try {
1084
+ const threadId = "thread-voice-blank";
1085
+ const messageId = "msg-voice-blank";
1086
+ const thread = makeThread(threadId);
1087
+ const message = makeMessageWithAttachment(
1088
+ threadId,
1089
+ "voice.m4a",
1090
+ messageId,
1091
+ );
1092
+ const replyResult = makeReplyResult(makeMessage(threadId, messageId));
1093
+
1094
+ const getAttachment = mock(async () => new Uint8Array([1, 2, 3]));
1095
+ const transcribeAudioFn = mock(async () => " ");
1096
+
1097
+ const client = makeFakeClient({
1098
+ listThreads: async () => ({
1099
+ threads: [thread],
1100
+ total: 1,
1101
+ limit: 50,
1102
+ offset: 0,
1103
+ }),
1104
+ claimMessage: async () => message,
1105
+ replyToMessage: async () => replyResult,
1106
+ getAttachment,
1107
+ });
1108
+
1109
+ const runner = mock(async () => ({ result: "ok" }));
1110
+ const poller = createChatPoller({
1111
+ client,
1112
+ runner,
1113
+ workspaceDir,
1114
+ transcribeAudioFn,
1115
+ });
1116
+ await poller.pollOnce();
1117
+
1118
+ const runnerArg = (runner.mock.calls[0] as unknown[])[0] as string;
1119
+ expect(runnerArg).toContain(message.body);
1120
+ expect(runnerArg).not.toContain("[voice transcript:");
1121
+ } finally {
1122
+ await rm(workspaceDir, { recursive: true, force: true });
1123
+ }
1124
+ });
1125
+ });
1126
+
1127
+ // ─── reply: [speak:] marker synthesis (TTS) ────────────────────────────────────
1128
+
1129
+ describe("createChatPoller reply: [speak:] marker synthesis", () => {
1130
+ it("synthesizes speech, strips the marker from the body, and attaches the audio to replyToMessage", async () => {
1131
+ const audioDir = await mkdtemp(join(tmpdir(), "chat-poller-tts-"));
1132
+ try {
1133
+ const audioPath = join(audioDir, "response.wav");
1134
+ const audioBytes = new Uint8Array([9, 8, 7, 6]);
1135
+ await writeFile(audioPath, audioBytes);
1136
+
1137
+ const threadId = "thread-speak";
1138
+ const thread = makeThread(threadId);
1139
+ const message = makeMessage(threadId, "msg-speak-1");
1140
+ const replyResult = makeReplyResult(message);
1141
+
1142
+ const replyToMessage = mock(async () => replyResult);
1143
+ const client = makeFakeClient({
1144
+ listThreads: async () => ({
1145
+ threads: [thread],
1146
+ total: 1,
1147
+ limit: 50,
1148
+ offset: 0,
1149
+ }),
1150
+ claimMessage: async () => message,
1151
+ replyToMessage,
1152
+ });
1153
+
1154
+ const runner = mock(async () => ({
1155
+ result: "Here you go. [speak:Here you go]",
1156
+ }));
1157
+ const synthesizeSpeechFn = mock(
1158
+ async (_text: string, _cfg: unknown) => audioPath,
1159
+ );
1160
+
1161
+ const poller = createChatPoller({
1162
+ client,
1163
+ runner,
1164
+ synthesizeSpeechFn,
1165
+ });
1166
+ await poller.pollOnce();
1167
+
1168
+ expect(synthesizeSpeechFn).toHaveBeenCalledTimes(1);
1169
+ expect(synthesizeSpeechFn.mock.calls[0][0]).toBe("Here you go");
1170
+
1171
+ expect(replyToMessage).toHaveBeenCalledTimes(1);
1172
+ const [, , opts] = replyToMessage.mock.calls[0] as unknown[];
1173
+ const replyOpts = opts as {
1174
+ body: string;
1175
+ attachmentFilename?: string;
1176
+ attachmentSize?: number;
1177
+ attachmentBytes?: Uint8Array;
1178
+ };
1179
+ expect(replyOpts.body).toBe("Here you go.");
1180
+ expect(replyOpts.body).not.toContain("[speak:");
1181
+ expect(replyOpts.attachmentFilename).toBe("response.wav");
1182
+ expect(replyOpts.attachmentSize).toBe(audioBytes.length);
1183
+ expect(Array.from(replyOpts.attachmentBytes ?? [])).toEqual(
1184
+ Array.from(audioBytes),
1185
+ );
1186
+ } finally {
1187
+ await rm(audioDir, { recursive: true, force: true });
1188
+ }
1189
+ });
1190
+
1191
+ it("leaves a reply with no [speak:] marker completely unaffected", async () => {
1192
+ const threadId = "thread-no-speak";
1193
+ const thread = makeThread(threadId);
1194
+ const message = makeMessage(threadId, "msg-no-speak-1");
1195
+ const replyResult = makeReplyResult(message);
1196
+
1197
+ const replyToMessage = mock(async () => replyResult);
1198
+ const client = makeFakeClient({
1199
+ listThreads: async () => ({
1200
+ threads: [thread],
1201
+ total: 1,
1202
+ limit: 50,
1203
+ offset: 0,
1204
+ }),
1205
+ claimMessage: async () => message,
1206
+ replyToMessage,
1207
+ });
1208
+
1209
+ const runner = mock(async () => ({ result: "Plain text reply." }));
1210
+ const synthesizeSpeechFn = mock(async () => "/tmp/should-not-be-used.wav");
1211
+
1212
+ const poller = createChatPoller({
1213
+ client,
1214
+ runner,
1215
+ synthesizeSpeechFn,
1216
+ });
1217
+ await poller.pollOnce();
1218
+
1219
+ expect(synthesizeSpeechFn).not.toHaveBeenCalled();
1220
+ expect(replyToMessage).toHaveBeenCalledTimes(1);
1221
+ const [, , opts] = replyToMessage.mock.calls[0] as unknown[];
1222
+ const replyOpts = opts as Record<string, unknown>;
1223
+ expect(replyOpts.body).toBe("Plain text reply.");
1224
+ expect(replyOpts.attachmentFilename).toBeUndefined();
1225
+ expect(replyOpts.attachmentBytes).toBeUndefined();
1226
+ });
1227
+
1228
+ it("strips the marker and sends a text-only reply when synthesizeSpeechFn returns null", async () => {
1229
+ const threadId = "thread-speak-null";
1230
+ const thread = makeThread(threadId);
1231
+ const message = makeMessage(threadId, "msg-speak-null-1");
1232
+ const replyResult = makeReplyResult(message);
1233
+
1234
+ const replyToMessage = mock(async () => replyResult);
1235
+ const client = makeFakeClient({
1236
+ listThreads: async () => ({
1237
+ threads: [thread],
1238
+ total: 1,
1239
+ limit: 50,
1240
+ offset: 0,
1241
+ }),
1242
+ claimMessage: async () => message,
1243
+ replyToMessage,
1244
+ });
1245
+
1246
+ const runner = mock(async () => ({
1247
+ result: "Got it. [speak:Got it]",
1248
+ }));
1249
+ const synthesizeSpeechFn = mock(async () => null);
1250
+
1251
+ const poller = createChatPoller({
1252
+ client,
1253
+ runner,
1254
+ synthesizeSpeechFn,
1255
+ });
1256
+ await poller.pollOnce();
1257
+
1258
+ expect(synthesizeSpeechFn).toHaveBeenCalledTimes(1);
1259
+ expect(replyToMessage).toHaveBeenCalledTimes(1);
1260
+ const [, , opts] = replyToMessage.mock.calls[0] as unknown[];
1261
+ const replyOpts = opts as Record<string, unknown>;
1262
+ expect(replyOpts.body).toBe("Got it.");
1263
+ expect(replyOpts.body).not.toContain("[speak:");
1264
+ expect(replyOpts.attachmentFilename).toBeUndefined();
1265
+ expect(replyOpts.attachmentBytes).toBeUndefined();
1266
+ });
1267
+
1268
+ it("strips the marker and sends a text-only reply when synthesizeSpeechFn throws", async () => {
1269
+ const threadId = "thread-speak-throw";
1270
+ const thread = makeThread(threadId);
1271
+ const message = makeMessage(threadId, "msg-speak-throw-1");
1272
+ const replyResult = makeReplyResult(message);
1273
+
1274
+ const replyToMessage = mock(async () => replyResult);
1275
+ const client = makeFakeClient({
1276
+ listThreads: async () => ({
1277
+ threads: [thread],
1278
+ total: 1,
1279
+ limit: 50,
1280
+ offset: 0,
1281
+ }),
1282
+ claimMessage: async () => message,
1283
+ replyToMessage,
1284
+ });
1285
+
1286
+ const runner = mock(async () => ({
1287
+ result: "Noted. [speak:Noted]",
1288
+ }));
1289
+ const synthesizeSpeechFn = mock(async () => {
1290
+ throw new Error("tts provider down");
1291
+ });
1292
+
1293
+ const poller = createChatPoller({
1294
+ client,
1295
+ runner,
1296
+ synthesizeSpeechFn,
1297
+ });
1298
+
1299
+ await expect(poller.pollOnce()).resolves.toBeUndefined();
1300
+
1301
+ expect(synthesizeSpeechFn).toHaveBeenCalledTimes(1);
1302
+ expect(replyToMessage).toHaveBeenCalledTimes(1);
1303
+ const [, , opts] = replyToMessage.mock.calls[0] as unknown[];
1304
+ const replyOpts = opts as Record<string, unknown>;
1305
+ expect(replyOpts.body).toBe("Noted.");
1306
+ expect(replyOpts.body).not.toContain("[speak:");
1307
+ expect(replyOpts.attachmentFilename).toBeUndefined();
1308
+ expect(replyOpts.attachmentBytes).toBeUndefined();
1309
+ });
1310
+
1311
+ it("falls back to the speak text when a marker-only reply yields no audio (null)", async () => {
1312
+ const threadId = "thread-speak-only-null";
1313
+ const thread = makeThread(threadId);
1314
+ const message = makeMessage(threadId, "msg-speak-only-null-1");
1315
+ const replyResult = makeReplyResult(message);
1316
+
1317
+ const replyToMessage = mock(async () => replyResult);
1318
+ const client = makeFakeClient({
1319
+ listThreads: async () => ({
1320
+ threads: [thread],
1321
+ total: 1,
1322
+ limit: 50,
1323
+ offset: 0,
1324
+ }),
1325
+ claimMessage: async () => message,
1326
+ replyToMessage,
1327
+ });
1328
+
1329
+ // Marker-only body — stripping the marker leaves an empty string.
1330
+ const runner = mock(async () => ({
1331
+ result: "[speak:All done, the deploy is green]",
1332
+ }));
1333
+ const synthesizeSpeechFn = mock(async () => null);
1334
+
1335
+ const poller = createChatPoller({
1336
+ client,
1337
+ runner,
1338
+ synthesizeSpeechFn,
1339
+ });
1340
+ await poller.pollOnce();
1341
+
1342
+ expect(synthesizeSpeechFn).toHaveBeenCalledTimes(1);
1343
+ expect(replyToMessage).toHaveBeenCalledTimes(1);
1344
+ const [, , opts] = replyToMessage.mock.calls[0] as unknown[];
1345
+ const replyOpts = opts as Record<string, unknown>;
1346
+ expect(replyOpts.body).toBe("All done, the deploy is green");
1347
+ expect(replyOpts.attachmentFilename).toBeUndefined();
1348
+ expect(replyOpts.attachmentSize).toBeUndefined();
1349
+ expect(replyOpts.attachmentBytes).toBeUndefined();
1350
+ });
1351
+
1352
+ it("falls back to the speak text when a marker-only reply's synthesis throws", async () => {
1353
+ const threadId = "thread-speak-only-throw";
1354
+ const thread = makeThread(threadId);
1355
+ const message = makeMessage(threadId, "msg-speak-only-throw-1");
1356
+ const replyResult = makeReplyResult(message);
1357
+
1358
+ const replyToMessage = mock(async () => replyResult);
1359
+ const client = makeFakeClient({
1360
+ listThreads: async () => ({
1361
+ threads: [thread],
1362
+ total: 1,
1363
+ limit: 50,
1364
+ offset: 0,
1365
+ }),
1366
+ claimMessage: async () => message,
1367
+ replyToMessage,
1368
+ });
1369
+
1370
+ const runner = mock(async () => ({
1371
+ result: "[speak:Sorry, I could not find that file]",
1372
+ }));
1373
+ const synthesizeSpeechFn = mock(async () => {
1374
+ throw new Error("tts provider down");
1375
+ });
1376
+
1377
+ const poller = createChatPoller({
1378
+ client,
1379
+ runner,
1380
+ synthesizeSpeechFn,
1381
+ });
1382
+
1383
+ await expect(poller.pollOnce()).resolves.toBeUndefined();
1384
+
1385
+ expect(synthesizeSpeechFn).toHaveBeenCalledTimes(1);
1386
+ expect(replyToMessage).toHaveBeenCalledTimes(1);
1387
+ const [, , opts] = replyToMessage.mock.calls[0] as unknown[];
1388
+ const replyOpts = opts as Record<string, unknown>;
1389
+ expect(replyOpts.body).toBe("Sorry, I could not find that file");
1390
+ expect(replyOpts.attachmentFilename).toBeUndefined();
1391
+ expect(replyOpts.attachmentSize).toBeUndefined();
1392
+ expect(replyOpts.attachmentBytes).toBeUndefined();
1393
+ });
1394
+
1395
+ it("posts an empty body with the audio attachment when a marker-only reply synthesizes successfully", async () => {
1396
+ const audioDir = await mkdtemp(join(tmpdir(), "chat-poller-tts-only-"));
1397
+ try {
1398
+ const audioPath = join(audioDir, "only.wav");
1399
+ await writeFile(audioPath, new Uint8Array([7, 7, 7]));
1400
+
1401
+ const threadId = "thread-speak-only-ok";
1402
+ const thread = makeThread(threadId);
1403
+ const message = makeMessage(threadId, "msg-speak-only-ok-1");
1404
+ const replyResult = makeReplyResult(message);
1405
+
1406
+ const replyToMessage = mock(async () => replyResult);
1407
+ const client = makeFakeClient({
1408
+ listThreads: async () => ({
1409
+ threads: [thread],
1410
+ total: 1,
1411
+ limit: 50,
1412
+ offset: 0,
1413
+ }),
1414
+ claimMessage: async () => message,
1415
+ replyToMessage,
1416
+ });
1417
+
1418
+ const runner = mock(async () => ({
1419
+ result: "[speak:Shipped it]",
1420
+ }));
1421
+ const synthesizeSpeechFn = mock(
1422
+ async (_text: string, _cfg: unknown) => audioPath,
1423
+ );
1424
+
1425
+ const poller = createChatPoller({
1426
+ client,
1427
+ runner,
1428
+ synthesizeSpeechFn,
1429
+ });
1430
+ await poller.pollOnce();
1431
+
1432
+ const [, , opts] = replyToMessage.mock.calls[0] as unknown[];
1433
+ const replyOpts = opts as Record<string, unknown>;
1434
+ // Audio carries the reply — no text fallback needed.
1435
+ expect(replyOpts.body).toBe("");
1436
+ expect(replyOpts.attachmentFilename).toBe("only.wav");
1437
+ } finally {
1438
+ await rm(audioDir, { recursive: true, force: true });
1439
+ }
1440
+ });
1441
+
1442
+ it("picks the first [speak:] marker's text when other markers are present", async () => {
1443
+ const audioDir = await mkdtemp(join(tmpdir(), "chat-poller-tts-multi-"));
1444
+ try {
1445
+ const audioPath = join(audioDir, "multi.wav");
1446
+ await writeFile(audioPath, new Uint8Array([1]));
1447
+
1448
+ const threadId = "thread-speak-multi";
1449
+ const thread = makeThread(threadId);
1450
+ const message = makeMessage(threadId, "msg-speak-multi-1");
1451
+ const replyResult = makeReplyResult(message);
1452
+
1453
+ const replyToMessage = mock(async () => replyResult);
1454
+ const client = makeFakeClient({
1455
+ listThreads: async () => ({
1456
+ threads: [thread],
1457
+ total: 1,
1458
+ limit: 50,
1459
+ offset: 0,
1460
+ }),
1461
+ claimMessage: async () => message,
1462
+ replyToMessage,
1463
+ });
1464
+
1465
+ const runner = mock(async () => ({
1466
+ result: "Done. [speak:Done] [react:eyes]",
1467
+ }));
1468
+ const synthesizeSpeechFn = mock(
1469
+ async (_text: string, _cfg: unknown) => audioPath,
1470
+ );
1471
+
1472
+ const poller = createChatPoller({
1473
+ client,
1474
+ runner,
1475
+ synthesizeSpeechFn,
1476
+ });
1477
+ await poller.pollOnce();
1478
+
1479
+ expect(synthesizeSpeechFn).toHaveBeenCalledTimes(1);
1480
+ expect(synthesizeSpeechFn.mock.calls[0][0]).toBe("Done");
1481
+ const [, , opts] = replyToMessage.mock.calls[0] as unknown[];
1482
+ const replyOpts = opts as Record<string, unknown>;
1483
+ expect(replyOpts.body).toBe("Done.");
1484
+ expect(replyOpts.body).not.toContain("[react:");
1485
+ } finally {
1486
+ await rm(audioDir, { recursive: true, force: true });
1487
+ }
1488
+ });
1489
+ });
1490
+
1491
+ // ─── start: overlap guard ─────────────────────────────────────────────────────
1492
+
1493
+ describe("createChatPoller start: overlap guard", () => {
1494
+ it("skips interval ticks while the previous poll is still in flight", async () => {
1495
+ let releaseList: () => void = () => {};
1496
+ const gate = new Promise<void>((resolve) => {
1497
+ releaseList = resolve;
1498
+ });
1499
+ const listThreads = mock(async () => {
1500
+ await gate;
1501
+ return { threads: [], total: 0, limit: 50, offset: 0 };
1502
+ });
1503
+
1504
+ const client = makeFakeClient({ listThreads });
1505
+ const runner = mock(async () => ({ result: "ok" }));
1506
+ const fake = makeFakeInterval();
1507
+
1508
+ const poller = createChatPoller({
1509
+ client,
1510
+ runner,
1511
+ setIntervalFn: fake.setIntervalFn,
1512
+ clearIntervalFn: fake.clearIntervalFn,
1513
+ });
1514
+ poller.start();
1515
+
1516
+ // A poll that outlives its interval would otherwise stack concurrent
1517
+ // iterations for as long as it runs.
1518
+ fake.tick(); // starts poll #1, which blocks on the gate
1519
+ fake.tick(); // must be skipped
1520
+ fake.tick(); // must be skipped
1521
+ expect(listThreads).toHaveBeenCalledTimes(1);
1522
+
1523
+ releaseList();
1524
+ await new Promise((resolve) => setTimeout(resolve, 0));
1525
+
1526
+ fake.tick(); // previous poll settled — free to run again
1527
+ expect(listThreads).toHaveBeenCalledTimes(2);
1528
+
1529
+ poller.stop();
1530
+ });
1531
+ });
1532
+
1533
+ // ─── deriveReply: always non-empty body + correct errorKind ────────────────────
1534
+
1535
+ describe("deriveReply", () => {
1536
+ const R = (over: Partial<ClaudeRunResult>): ClaudeRunResult => ({
1537
+ result: "",
1538
+ ...over,
1539
+ });
1540
+
1541
+ it("cancelled: fixed body + cancelled errorKind", () => {
1542
+ const d = deriveReply(undefined, "cancelled");
1543
+ expect(d.body.length).toBeGreaterThan(0);
1544
+ expect(d.errorKind).toBe("cancelled");
1545
+ });
1546
+
1547
+ it("thrown runner (stalled): non-empty body + stalled errorKind", () => {
1548
+ const d = deriveReply(undefined, "stalled");
1549
+ expect(d.body.length).toBeGreaterThan(0);
1550
+ expect(d.errorKind).toBe("stalled");
1551
+ });
1552
+
1553
+ it("streamIncomplete with empty result: non-empty body + incomplete errorKind", () => {
1554
+ const d = deriveReply(R({ result: "", streamIncomplete: true }), undefined);
1555
+ expect(d.body.length).toBeGreaterThan(0);
1556
+ expect(d.errorKind).toBe("incomplete");
1557
+ });
1558
+
1559
+ it("clean finish with empty result: non-empty body + incomplete errorKind", () => {
1560
+ const d = deriveReply(R({ result: " " }), undefined);
1561
+ expect(d.body.length).toBeGreaterThan(0);
1562
+ expect(d.errorKind).toBe("incomplete");
1563
+ });
1564
+
1565
+ it("clean finish with real result: passes body + usage through, no errorKind", () => {
1566
+ const usage = {
1567
+ input_tokens: 1,
1568
+ output_tokens: 2,
1569
+ cache_read_input_tokens: 0,
1570
+ cache_creation_input_tokens: 0,
1571
+ };
1572
+ const d = deriveReply(
1573
+ R({ result: "the answer", usage, totalCostUsd: 0.01 }),
1574
+ undefined,
1575
+ );
1576
+ expect(d.body).toBe("the answer");
1577
+ expect(d.tokens).toEqual(usage);
1578
+ expect(d.costUsd).toBe(0.01);
1579
+ expect(d.errorKind).toBeUndefined();
1580
+ });
1581
+ });