switchroom 0.16.15 → 0.16.16

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.
@@ -4,6 +4,15 @@ import {
4
4
  MIN_INITIAL_CHARS,
5
5
  } from '../answer-stream.js'
6
6
  import { resolveAnswerLaneConfig, ANSWER_LANE_NEVER_OPENS } from '../answer-stream-flag.js'
7
+ import { markdownToHtml } from '../format.js'
8
+ import { sanitizeTelegramHtml } from '../html-sanitize.js'
9
+
10
+ /**
11
+ * The exact renderer the gateway injects into createAnswerStream — markdown →
12
+ * Telegram HTML, then HTML sanitize. Mirrors gateway.ts so the test pins the
13
+ * real conversion, not a hand-rolled one.
14
+ */
15
+ const renderText = (text: string): string => sanitizeTelegramHtml(markdownToHtml(text))
7
16
 
8
17
  // ─── Helpers ──────────────────────────────────────────────────────────────────
9
18
 
@@ -134,6 +143,115 @@ describe('answer-stream — minInitialChars threshold', () => {
134
143
  })
135
144
  })
136
145
 
146
+ describe('answer-stream — markdown → HTML conversion (renderText)', () => {
147
+ // Regression for the answer-stream-raw-markdown bug: this lane historically
148
+ // shipped the RAW assistant transcript under parse_mode:'HTML', so `**bold**`
149
+ // arrived as literal asterisks while every other lane converted. The fix
150
+ // injects the same renderText the other lanes use; these tests assert the
151
+ // wire payload is converted, not raw.
152
+
153
+ it('converts **bold** to <b>bold</b> on the opening sendMessage', async () => {
154
+ const sendMessage = makeSendMessage()
155
+ const editMessageText = makeEditMessageText()
156
+ const stream = createAnswerStream({
157
+ chatId: 'chat1',
158
+ minInitialChars: 10,
159
+ throttleMs: 250,
160
+ renderText,
161
+ sendMessage,
162
+ editMessageText,
163
+ })
164
+
165
+ stream.update('Here is some **bold** narration text for the user.')
166
+ await flushMicrotasks()
167
+
168
+ expect(sendMessage).toHaveBeenCalledTimes(1)
169
+ const sentText = sendMessage.mock.calls[0][1] as string
170
+ expect(sentText).toContain('<b>bold</b>')
171
+ expect(sentText).not.toContain('**bold**')
172
+ expect(sendMessage).toHaveBeenCalledWith(
173
+ 'chat1',
174
+ expect.stringContaining('<b>bold</b>'),
175
+ expect.objectContaining({ parse_mode: 'HTML' }),
176
+ )
177
+ })
178
+
179
+ it('converts **bold** to <b>bold</b> on a follow-up editMessageText', async () => {
180
+ const sendMessage = makeSendMessage()
181
+ const editMessageText = makeEditMessageText()
182
+ const stream = createAnswerStream({
183
+ chatId: 'chat1',
184
+ minInitialChars: 10,
185
+ throttleMs: 250,
186
+ renderText,
187
+ sendMessage,
188
+ editMessageText,
189
+ })
190
+
191
+ // First update opens the message.
192
+ stream.update('first chunk of the answer arriving')
193
+ await flushMicrotasks()
194
+ expect(sendMessage).toHaveBeenCalledTimes(1)
195
+
196
+ // Second update (past the throttle window) edits in place with markdown.
197
+ vi.advanceTimersByTime(300)
198
+ stream.update('first chunk of the answer arriving with **bold** added')
199
+ vi.advanceTimersByTime(300)
200
+ await flushMicrotasks()
201
+
202
+ expect(editMessageText).toHaveBeenCalled()
203
+ const editText = editMessageText.mock.calls.at(-1)![2] as string
204
+ expect(editText).toContain('<b>bold</b>')
205
+ expect(editText).not.toContain('**bold**')
206
+ })
207
+
208
+ it('converts **bold** to <b>bold</b> on materialize', async () => {
209
+ const sendMessage = makeSendMessage()
210
+ const editMessageText = makeEditMessageText()
211
+ const stream = createAnswerStream({
212
+ chatId: 'chat1',
213
+ minInitialChars: 10,
214
+ throttleMs: 250,
215
+ renderText,
216
+ sendMessage,
217
+ editMessageText,
218
+ })
219
+
220
+ stream.update('The final answer is **definitely** forty-two.')
221
+ await flushMicrotasks()
222
+ // Opening send already converted; capture how many sends precede materialize.
223
+ const sendsBeforeMaterialize = sendMessage.mock.calls.length
224
+
225
+ const id = await stream.materialize()
226
+ expect(typeof id).toBe('number')
227
+ // materialize always sends a fresh message for the push notification.
228
+ expect(sendMessage.mock.calls.length).toBe(sendsBeforeMaterialize + 1)
229
+ const sentText = sendMessage.mock.calls.at(-1)![1] as string
230
+ expect(sentText).toContain('<b>definitely</b>')
231
+ expect(sentText).not.toContain('**definitely**')
232
+ })
233
+
234
+ it('sends raw text verbatim when no renderText is injected (back-compat)', async () => {
235
+ const sendMessage = makeSendMessage()
236
+ const editMessageText = makeEditMessageText()
237
+ const stream = createAnswerStream({
238
+ chatId: 'chat1',
239
+ minInitialChars: 10,
240
+ throttleMs: 250,
241
+ sendMessage,
242
+ editMessageText,
243
+ })
244
+
245
+ stream.update('Here is some **bold** narration text for the user.')
246
+ await flushMicrotasks()
247
+
248
+ expect(sendMessage).toHaveBeenCalledTimes(1)
249
+ const sentText = sendMessage.mock.calls[0][1] as string
250
+ // No renderer → unchanged (preserves prior behaviour for unwired callers).
251
+ expect(sentText).toContain('**bold**')
252
+ })
253
+ })
254
+
137
255
  describe('answer-stream — throttling', () => {
138
256
  it('three rapid updates within throttleMs result in at most two transport calls', async () => {
139
257
  const sendMessage = makeSendMessage()
@@ -324,14 +324,29 @@ describe('the 7 drain sites route through the façade with producers preserved v
324
324
  expect(body).toMatch(/ea\.mayDrain\(turn\)/)
325
325
  })
326
326
 
327
- it('both liveness sites in feedHeartbeatTick route via openOrEditCard("liveness") + producer-"liveness" drains', () => {
328
- const body = fnSrc('feedHeartbeatTick')
329
- const opens = [...body.matchAll(/openOrEditCard\('liveness'/g)]
327
+ it('both liveness sites route via openOrEditCard("liveness") + producer-"liveness" drains (one in openLivenessFeedIfDue, one in feedHeartbeatTick)', () => {
328
+ // The early-card-open refactor extracted the 0-tool liveness OPEN out of
329
+ // feedHeartbeatTick into the shared `openLivenessFeedIfDue` helper (so the
330
+ // 6 s heartbeat and the enqueue-time early-open timer reach ONE open path
331
+ // and never double-open). So the two liveness sites now live in:
332
+ // - openLivenessFeedIfDue: the 0-tool early-open / climb;
333
+ // - feedHeartbeatTick: the labelled-feed stale-step maintain.
334
+ // Both must still route via the façade with the producer arg verbatim.
335
+ const earlyOpen = fnSrc('openLivenessFeedIfDue')
336
+ const heartbeat = fnSrc('feedHeartbeatTick')
337
+ const opens = [
338
+ ...earlyOpen.matchAll(/openOrEditCard\('liveness'/g),
339
+ ...heartbeat.matchAll(/openOrEditCard\('liveness'/g),
340
+ ]
330
341
  expect(opens).toHaveLength(2)
331
- const drains = [...body.matchAll(/drainActivitySummary\(turn,\s*'liveness'\)/g)]
342
+ const drains = [
343
+ ...earlyOpen.matchAll(/drainActivitySummary\(turn,\s*'liveness'\)/g),
344
+ ...heartbeat.matchAll(/drainActivitySummary\(turn,\s*'liveness'\)/g),
345
+ ]
332
346
  expect(drains).toHaveLength(2)
333
- // The literal the feed-heartbeat oracle greps must still be present.
334
- expect(body).toMatch(/turn\.activityInFlight = drainActivitySummary/)
347
+ // The literal the feed-heartbeat oracle greps must still be present in both.
348
+ expect(earlyOpen).toMatch(/turn\.activityInFlight = drainActivitySummary/)
349
+ expect(heartbeat).toMatch(/turn\.activityInFlight = drainActivitySummary/)
335
350
  })
336
351
 
337
352
  it('the tool_label site routes via openOrEditCard("tool") + the producer-"tool" drain', () => {
@@ -12,12 +12,22 @@
12
12
  * `evaluatePostAnswerLiveness(...)` consulted each tick, with the cap fed
13
13
  * from `POST_ANSWER_LIVENESS_STALE_MS`.
14
14
  *
15
- * Pre-answer liveness-open branch:
15
+ * Pre-answer liveness-open path (`openLivenessFeedIfDue`, the shared helper the
16
+ * heartbeat AND the enqueue-time early-open timer both call — early-card-open
17
+ * change):
16
18
  * 1. The SWITCHROOM_FEED_LIVENESS_OPEN kill-switch (default ON, i.e. `!== '0'`)
17
- * gates the liveness-open path — operators can disable it with =0.
18
- * 2. FEED_LIVENESS_OPEN_MS is parsed from env with a sane default (12 000 ms).
19
- * 3. The `age < FEED_LIVENESS_OPEN_MS` return-guard fires BEFORE the 0-tool
20
- * feed opens a turn that hasn't yet passed the threshold returns early.
19
+ * gates the liveness-open path — operators can disable it with =0. The
20
+ * WHEN-gate (`shouldEarlyOpenLiveness`, feed-open-gate.ts) reads it.
21
+ * 2. FEED_LIVENESS_OPEN_MS is parsed from env with a sane default of ~1.2 s
22
+ * (dropped from 12 s to kill the dead-air gap before the card opens).
23
+ * 3. The age-vs-threshold + already-open return-guards live in the pure
24
+ * `shouldEarlyOpenLiveness` decision, consulted at the top of
25
+ * `openLivenessFeedIfDue` BEFORE the drain — a turn that hasn't passed the
26
+ * threshold, or one whose card is already open, returns early (no
27
+ * double-open).
28
+ * 4. The 0-tool branch of `feedHeartbeatTick` delegates to that one shared
29
+ * helper rather than inlining the open — so the heartbeat and the
30
+ * enqueue-time timer can never double-open.
21
31
  *
22
32
  * These are STRUCTURAL (source-read) assertions; the gateway IIFE can't be
23
33
  * instantiated in-process. Pattern matches silence-liveness-wiring.test.ts.
@@ -38,21 +48,10 @@ function feedHeartbeatTickSrc(): string {
38
48
  return after.split('\nfunction ')[0] ?? after
39
49
  }
40
50
 
41
- /**
42
- * Extract the 0-tool liveness-open branch: the section from
43
- * `if (turn.mirrorLines.length === 0)` to its closing `return`. This scopes
44
- * the FEED_LIVENESS_OPEN_ENABLED and age-guard assertions to the correct branch
45
- * (the post-answer Fix-2 block now precedes it and also has a drain call).
46
- */
47
- function liveness0ToolBranchSrc(): string {
48
- const body = feedHeartbeatTickSrc()
49
- const start = body.indexOf('if (turn.mirrorLines.length === 0)')
50
- if (start === -1) return ''
51
- // Capture until the closing `return\n }` of this if-block (the next blank-line-terminated return).
52
- const after = body.slice(start)
53
- // Take up to the labelled-feed heartbeat comment that follows.
54
- const end = after.indexOf('// Labelled-feed heartbeat')
55
- return end === -1 ? after : after.slice(0, end)
51
+ /** Return the source text of `openLivenessFeedIfDue` (the shared open helper). */
52
+ function openLivenessFeedIfDueSrc(): string {
53
+ const after = gatewaySrc.split('function openLivenessFeedIfDue(turn: CurrentTurn): void {')[1] ?? ''
54
+ return after.split('\nfunction ')[0] ?? after
56
55
  }
57
56
 
58
57
  describe('H-1: feedHeartbeatTick liveness-open threshold', () => {
@@ -63,36 +62,43 @@ describe('H-1: feedHeartbeatTick liveness-open threshold', () => {
63
62
  )
64
63
  })
65
64
 
66
- it('FEED_LIVENESS_OPEN_MS defaults to 12_000 ms when env is unset', () => {
65
+ it('FEED_LIVENESS_OPEN_MS defaults to ~1.2 s (dropped from 12 s to kill the dead-air gap)', () => {
67
66
  // The IIFE initialiser `const FEED_LIVENESS_OPEN_MS = (() => { ... })()` must
68
- // contain the fallback 12_000. Split on the const definition (not the comment).
67
+ // contain the new fallback 1_200. Split on the const definition (not the comment).
69
68
  const afterConst = gatewaySrc.split('const FEED_LIVENESS_OPEN_MS')[1] ?? ''
70
69
  // The IIFE closes with `})()` — take everything before that.
71
70
  const initBlock = afterConst.split('})()')[0] ?? ''
72
- expect(initBlock).toMatch(/12[_]?000/)
71
+ expect(initBlock).toMatch(/1[_]?200/)
73
72
  })
74
73
 
75
- it('age < FEED_LIVENESS_OPEN_MS return-guard exists inside the 0-tool liveness branch', () => {
76
- const branch = liveness0ToolBranchSrc()
77
- expect(branch).toMatch(/if\s*\(age\s*<\s*FEED_LIVENESS_OPEN_MS\)\s*return/)
74
+ it('the 0-tool liveness branch delegates to the shared openLivenessFeedIfDue helper', () => {
75
+ // The open logic lives in ONE place so the heartbeat and the enqueue-time
76
+ // early-open timer cannot double-open. The 0-tool branch must call the
77
+ // shared helper rather than inline its own open.
78
+ const body = feedHeartbeatTickSrc()
79
+ const start = body.indexOf('if (turn.mirrorLines.length === 0)')
80
+ expect(start).toBeGreaterThan(-1)
81
+ const branch = body.slice(start)
82
+ const end = branch.indexOf('// Labelled-feed heartbeat')
83
+ const scoped = end === -1 ? branch : branch.slice(0, end)
84
+ expect(scoped).toMatch(/openLivenessFeedIfDue\(turn\)/)
78
85
  })
79
86
 
80
- it('FEED_LIVENESS_OPEN_ENABLED check precedes the drain call in the 0-tool liveness branch', () => {
81
- const branch = liveness0ToolBranchSrc()
82
- const enabledIdx = branch.indexOf('FEED_LIVENESS_OPEN_ENABLED')
83
- // Use the actual call site (assignment to activityInFlight) not the comment mention.
84
- const drainCallIdx = branch.indexOf('turn.activityInFlight = drainActivitySummary')
85
- expect(enabledIdx).toBeGreaterThan(-1)
86
- expect(drainCallIdx).toBeGreaterThan(enabledIdx)
87
+ it('the WHEN-gate (shouldEarlyOpenLiveness) precedes the drain call inside openLivenessFeedIfDue', () => {
88
+ const body = openLivenessFeedIfDueSrc()
89
+ const gateIdx = body.indexOf('shouldEarlyOpenLiveness(')
90
+ // Use the actual call site (assignment to activityInFlight) not a comment mention.
91
+ const drainCallIdx = body.indexOf('turn.activityInFlight = drainActivitySummary')
92
+ expect(gateIdx).toBeGreaterThan(-1)
93
+ expect(drainCallIdx).toBeGreaterThan(gateIdx)
87
94
  })
88
95
 
89
- it('age < FEED_LIVENESS_OPEN_MS guard precedes the drain call in the 0-tool branch (early-return)', () => {
90
- const branch = liveness0ToolBranchSrc()
91
- const guardIdx = branch.indexOf('age < FEED_LIVENESS_OPEN_MS')
92
- // Use the actual call site (assignment to activityInFlight) not the comment mention.
93
- const drainCallIdx = branch.indexOf('turn.activityInFlight = drainActivitySummary')
94
- expect(guardIdx).toBeGreaterThan(-1)
95
- expect(drainCallIdx).toBeGreaterThan(guardIdx)
96
+ it('the early-open timer is scheduled at turn enqueue and torn down at turn-end', () => {
97
+ // The enqueue-time early-open (the dead-air fix): a one-shot timer fires the
98
+ // shared open helper at turn start, and it is cancelled at the canonical
99
+ // turn-end so a leaked timer can never fire against a successor turn.
100
+ expect(gatewaySrc).toMatch(/scheduleEarlyLivenessOpen\(next\)/)
101
+ expect(gatewaySrc).toMatch(/stopEarlyLivenessOpen\(key as string\)/)
96
102
  })
97
103
  })
98
104
 
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
- import { mayOpenActivityCard } from '../gateway/feed-open-gate.js'
3
+ import {
4
+ mayOpenActivityCard,
5
+ shouldEarlyOpenLiveness,
6
+ } from '../gateway/feed-open-gate.js'
4
7
 
5
8
  /**
6
9
  * Feed-OPEN gate — pure decision (design `docs/message-emission-determinism.md`
@@ -176,6 +179,77 @@ describe('mayOpenActivityCard — lever 1 exception: post-answer sub-agent liven
176
179
  })
177
180
  })
178
181
 
182
+ describe('shouldEarlyOpenLiveness — the early-open WHEN gate (enqueue + heartbeat)', () => {
183
+ // The minimal "Working…" placeholder is due to open for a 0-label turn once it
184
+ // has been alive past the threshold and NO card is open yet. Both the
185
+ // enqueue-time early-open timer and the 6 s heartbeat consult this, so an
186
+ // already-open card returns false (the drain EDITs instead) — they can never
187
+ // double-open. This is the fix for the 12 s dead-air gap before the card first
188
+ // appears on a thinking / pure-narration turn.
189
+
190
+ const base = {
191
+ enabled: true,
192
+ ageMs: 1_500,
193
+ thresholdMs: 1_200,
194
+ mirrorLineCount: 0,
195
+ activityMessageId: null as number | null,
196
+ sessionChatId: 'chat-1' as string | null,
197
+ }
198
+
199
+ it('opens at the liveness threshold for a 0-tool turn with no card yet', () => {
200
+ // The core change: a turn alive past the (now ~1.2 s) threshold with no tool
201
+ // label and no card opens the placeholder — narration before the first tool
202
+ // surfaces right away instead of after 12 s.
203
+ expect(shouldEarlyOpenLiveness({ ...base })).toBe(true)
204
+ })
205
+
206
+ it('is a NO-OP once a card is already open (never double-open / race)', () => {
207
+ // A tool/narrative drain already opened the card (activityMessageId set).
208
+ // Both callers see false here, so the second one maintains via EDIT, not a
209
+ // fresh OPEN.
210
+ expect(
211
+ shouldEarlyOpenLiveness({ ...base, activityMessageId: 4242 }),
212
+ ).toBe(false)
213
+ })
214
+
215
+ it('does not open before the threshold (turn still fresh)', () => {
216
+ expect(
217
+ shouldEarlyOpenLiveness({ ...base, ageMs: 300, thresholdMs: 1_200 }),
218
+ ).toBe(false)
219
+ })
220
+
221
+ it('opens exactly AT the threshold (>= boundary)', () => {
222
+ expect(
223
+ shouldEarlyOpenLiveness({ ...base, ageMs: 1_200, thresholdMs: 1_200 }),
224
+ ).toBe(true)
225
+ })
226
+
227
+ it('never opens when the feature flag is off', () => {
228
+ expect(shouldEarlyOpenLiveness({ ...base, enabled: false })).toBe(false)
229
+ })
230
+
231
+ it('never opens with no target chat (e.g. an anonymous / null-agent surface)', () => {
232
+ expect(shouldEarlyOpenLiveness({ ...base, sessionChatId: null })).toBe(false)
233
+ })
234
+
235
+ it('opens to render accumulated narration: mirrorLines staged but no card yet (§3 case)', () => {
236
+ // The edge the early open serves: narration was staged (mirrorLineCount > 0)
237
+ // but no card opened yet (activityMessageId == null). The placeholder opens
238
+ // and renders that accumulated narration rather than a bare "Working…".
239
+ expect(
240
+ shouldEarlyOpenLiveness({ ...base, mirrorLineCount: 3, activityMessageId: null }),
241
+ ).toBe(true)
242
+ })
243
+
244
+ it('does NOT fight the labelled feed once a card is open AND labels exist', () => {
245
+ // A real tool label drives the labelled-feed heartbeat (card open). The
246
+ // placeholder must not fight it → no fresh OPEN.
247
+ expect(
248
+ shouldEarlyOpenLiveness({ ...base, mirrorLineCount: 2, activityMessageId: 99 }),
249
+ ).toBe(false)
250
+ })
251
+ })
252
+
179
253
  describe('mayOpenActivityCard — lever 4 (no OPEN below an EARLIER turn answer; race C/D)', () => {
180
254
  // The cross-turn case: a synthetic represent/owed-reply turn starts with a
181
255
  // CLEARED per-turn `finalAnswerEverDelivered` latch even though a substantive
@@ -397,6 +397,26 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
397
397
  "<i>✓ Working…</i>",
398
398
  );
399
399
  });
400
+
401
+ it("early open renders ACCUMULATED narration, not a bare 'Working…' (§3 case)", () => {
402
+ // The early-open path (gateway `openLivenessFeedIfDue`) passes the turn's
403
+ // accumulated `mirrorLines` when non-empty instead of the bare "Working…"
404
+ // placeholder. This is the §3 case: narration emitted BEFORE the first
405
+ // tool was staged into mirrorLines, and the early open surfaces it on the
406
+ // card the instant it opens — so the pre-tool narration is visible
407
+ // immediately, not lost until a tool label lands.
408
+ const staged = [
409
+ "Let me check the migration history first",
410
+ "Now comparing against the live schema",
411
+ ];
412
+ const out = renderActivityFeedWithNested(staged, [], false, " · 1s")!;
413
+ // The live in-progress line carries the LAST staged narration line, and the
414
+ // earlier line is retained in the feed body — the accumulated narration
415
+ // renders, not "Working…".
416
+ expect(out).toContain("Now comparing against the live schema");
417
+ expect(out).toContain("Let me check the migration history first");
418
+ expect(out).not.toContain("Working…");
419
+ });
400
420
  });
401
421
 
402
422
  it("stepCount=0 → no footer even on final=true", () => {
@@ -0,0 +1,124 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { createTurnTypingLoop } from '../gateway/turn-typing-loop.js'
4
+
5
+ /**
6
+ * Turn-level `typing…` indicator lifecycle — the second "something is happening"
7
+ * signal that runs alongside the early-card-open. Telegram's typing action
8
+ * auto-expires after ~5 s, so the loop must re-fire on a periodic refresh while
9
+ * the turn is in flight and stop cleanly at turn-end WITHOUT leaking a refresh
10
+ * interval. The factory is unit-tested over fake timers + a spy `sendChatAction`
11
+ * so the lifecycle is proven without the gateway or the bot API.
12
+ */
13
+
14
+ function makeDeps(overrides: { refreshMs?: number } = {}) {
15
+ const sendChatAction =
16
+ vi.fn<(chatId: string, threadId: number | null) => void>()
17
+ // Mirror the gateway's chatKey null/0-collapse: undefined/null thread → '_'.
18
+ const chatKey = (chatId: string, threadId: number | null) =>
19
+ `${chatId}:${threadId == null ? '_' : threadId}`
20
+ return { sendChatAction, chatKey, refreshMs: overrides.refreshMs }
21
+ }
22
+
23
+ describe('createTurnTypingLoop — turn-long typing indicator lifecycle', () => {
24
+ beforeEach(() => {
25
+ vi.useFakeTimers()
26
+ })
27
+ afterEach(() => {
28
+ vi.useRealTimers()
29
+ })
30
+
31
+ it('fires a typing action immediately on turn start (no wait for the first refresh)', () => {
32
+ const deps = makeDeps()
33
+ const loop = createTurnTypingLoop(deps)
34
+ loop.start('chat-A')
35
+ expect(deps.sendChatAction).toHaveBeenCalledTimes(1)
36
+ expect(deps.sendChatAction).toHaveBeenCalledWith('chat-A', null)
37
+ expect(loop.activeCount()).toBe(1)
38
+ })
39
+
40
+ it('refreshes the typing action every refreshMs while the turn is in flight', () => {
41
+ const deps = makeDeps({ refreshMs: 4000 })
42
+ const loop = createTurnTypingLoop(deps)
43
+ loop.start('chat-A')
44
+ expect(deps.sendChatAction).toHaveBeenCalledTimes(1) // immediate
45
+ vi.advanceTimersByTime(4000)
46
+ expect(deps.sendChatAction).toHaveBeenCalledTimes(2) // first refresh
47
+ vi.advanceTimersByTime(8000)
48
+ expect(deps.sendChatAction).toHaveBeenCalledTimes(4) // two more refreshes
49
+ })
50
+
51
+ it('stops cleanly on turn end and leaks NO refresh interval afterwards', () => {
52
+ const deps = makeDeps({ refreshMs: 4000 })
53
+ const loop = createTurnTypingLoop(deps)
54
+ loop.start('chat-A')
55
+ vi.advanceTimersByTime(4000)
56
+ expect(deps.sendChatAction).toHaveBeenCalledTimes(2)
57
+ loop.stop('chat-A')
58
+ expect(loop.activeCount()).toBe(0)
59
+ // No further fires after the stop — the interval was cleared, not leaked.
60
+ vi.advanceTimersByTime(60_000)
61
+ expect(deps.sendChatAction).toHaveBeenCalledTimes(2)
62
+ })
63
+
64
+ it('stop is idempotent (a no-op when no loop is registered)', () => {
65
+ const deps = makeDeps()
66
+ const loop = createTurnTypingLoop(deps)
67
+ // Stop before any start — must not throw and must remain at zero loops.
68
+ expect(() => loop.stop('chat-A')).not.toThrow()
69
+ expect(loop.activeCount()).toBe(0)
70
+ })
71
+
72
+ it('restart on the same key never leaks a second interval (self-heal)', () => {
73
+ const deps = makeDeps({ refreshMs: 4000 })
74
+ const loop = createTurnTypingLoop(deps)
75
+ loop.start('chat-A') // turn 1
76
+ loop.start('chat-A') // turn 2 on the same key (abnormal abort skipped stop)
77
+ // Exactly one live loop, not two.
78
+ expect(loop.activeCount()).toBe(1)
79
+ // After one refresh window, only ONE refresh fired (plus the 2 immediates).
80
+ vi.advanceTimersByTime(4000)
81
+ // 2 immediates (one per start) + 1 refresh from the single live interval.
82
+ expect(deps.sendChatAction).toHaveBeenCalledTimes(3)
83
+ })
84
+
85
+ it('isolates per-(chat,thread): stopping topic A leaves topic B running', () => {
86
+ const deps = makeDeps({ refreshMs: 4000 })
87
+ const loop = createTurnTypingLoop(deps)
88
+ loop.start('chat-A', 17) // topic A
89
+ loop.start('chat-A', 23) // topic B — same chat, different thread, own lane
90
+ expect(loop.activeCount()).toBe(2)
91
+ expect(deps.sendChatAction).toHaveBeenNthCalledWith(1, 'chat-A', 17)
92
+ expect(deps.sendChatAction).toHaveBeenNthCalledWith(2, 'chat-A', 23)
93
+
94
+ loop.stop('chat-A', 17) // end topic A's turn only
95
+ expect(loop.activeCount()).toBe(1) // topic B still live
96
+
97
+ deps.sendChatAction.mockClear()
98
+ vi.advanceTimersByTime(4000)
99
+ // Only topic B refreshes; topic A is fully stopped.
100
+ expect(deps.sendChatAction).toHaveBeenCalledTimes(1)
101
+ expect(deps.sendChatAction).toHaveBeenCalledWith('chat-A', 23)
102
+ })
103
+
104
+ it('stopAll clears every live loop (shutdown-drain cleanup) and leaks none', () => {
105
+ const deps = makeDeps({ refreshMs: 4000 })
106
+ const loop = createTurnTypingLoop(deps)
107
+ loop.start('chat-A', 17)
108
+ loop.start('chat-B')
109
+ expect(loop.activeCount()).toBe(2)
110
+ loop.stopAll()
111
+ expect(loop.activeCount()).toBe(0)
112
+ deps.sendChatAction.mockClear()
113
+ vi.advanceTimersByTime(60_000)
114
+ expect(deps.sendChatAction).not.toHaveBeenCalled()
115
+ })
116
+
117
+ it('collapses undefined and null threadId to the same lane', () => {
118
+ const deps = makeDeps()
119
+ const loop = createTurnTypingLoop(deps)
120
+ loop.start('chat-A') // undefined thread
121
+ loop.start('chat-A', null) // null thread — same lane (restart, not a 2nd loop)
122
+ expect(loop.activeCount()).toBe(1)
123
+ })
124
+ })