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.
- package/dist/cli/switchroom.js +38 -7
- 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
|
@@ -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
|
|
328
|
-
|
|
329
|
-
|
|
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 = [
|
|
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(
|
|
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
|
|
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
|
+
})
|