switchroom 0.16.15 → 0.16.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +100 -17
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/answer-stream.ts +37 -3
- package/telegram-plugin/dist/gateway/gateway.js +255 -71
- package/telegram-plugin/gateway/feed-open-gate.ts +54 -1
- package/telegram-plugin/gateway/gateway.ts +176 -86
- package/telegram-plugin/gateway/turn-typing-loop.ts +102 -0
- package/telegram-plugin/tests/answer-stream.test.ts +118 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +21 -6
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +46 -40
- package/telegram-plugin/tests/feed-open-gate.test.ts +75 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +20 -0
- package/telegram-plugin/tests/turn-typing-loop.test.ts +124 -0
|
@@ -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
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
|
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(/
|
|
71
|
+
expect(initBlock).toMatch(/1[_]?200/)
|
|
73
72
|
})
|
|
74
73
|
|
|
75
|
-
it('
|
|
76
|
-
|
|
77
|
-
|
|
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('
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
// Use the actual call site (assignment to activityInFlight) not
|
|
84
|
-
const drainCallIdx =
|
|
85
|
-
expect(
|
|
86
|
-
expect(drainCallIdx).toBeGreaterThan(
|
|
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('
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
expect(
|
|
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 {
|
|
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
|
+
})
|