switchroom 0.19.41 → 0.19.43
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/agent-scheduler/index.js +23 -3
- package/dist/auth-broker/index.js +54 -9
- package/dist/cli/notion-write-pretool.mjs +23 -3
- package/dist/cli/switchroom.js +1956 -900
- package/dist/host-control/main.js +227 -30
- package/dist/vault/approvals/kernel-server.js +54 -9
- package/dist/vault/broker/server.js +54 -9
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +82 -4
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1318 -693
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +56 -58
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
- package/telegram-plugin/gateway/model-command.ts +21 -109
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +62 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
- package/vendor/hindsight-memory/CHANGELOG.md +28 -0
- package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
- package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +18 -3
- package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
- package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
- package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createHandbackOrphanRecovery,
|
|
7
|
+
formatOrphanEscalation,
|
|
8
|
+
} from '../gateway/handback-orphan-recovery.js'
|
|
9
|
+
import type { HandbackOrphanEscalation, PreTurnCardRecord } from '../gateway/handback-preturn-signal.js'
|
|
10
|
+
import type { InboundMessage } from '../gateway/ipc-protocol.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Unit test for the gateway-side EFFECTS of deterministic handback-orphan
|
|
14
|
+
* recovery, extracted out of gateway.ts (switchroom#2996 ratchet). The seam's
|
|
15
|
+
* DECISION logic is covered by `handback-preturn-signal.test.ts`; this file
|
|
16
|
+
* asserts the three effects behave as the seam's contract requires:
|
|
17
|
+
*
|
|
18
|
+
* - a genuine orphan's card is DELETED (never edited to a user-facing string)
|
|
19
|
+
* - a delete failure is swallowed, so a reap is never wedged by a Telegram error
|
|
20
|
+
* - the handback is re-injected through the pending-inbound buffer verbatim
|
|
21
|
+
* - retry exhaustion emits greppable telemetry, NOT a chat message
|
|
22
|
+
*
|
|
23
|
+
* Uses injected spies + an injected `writeLog`, so it runs identically under
|
|
24
|
+
* vitest and bun (no fake timers, no module mocks).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const record: PreTurnCardRecord = {
|
|
28
|
+
turnKey: 'preturn:chatA:_:1000',
|
|
29
|
+
chatId: 'chatA',
|
|
30
|
+
threadId: 7,
|
|
31
|
+
activityMessageId: 4242,
|
|
32
|
+
startedAt: 1000,
|
|
33
|
+
pinned: false,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const escalation: HandbackOrphanEscalation = {
|
|
37
|
+
statusKey: 'chatA:7',
|
|
38
|
+
chatId: 'chatA',
|
|
39
|
+
threadId: 7,
|
|
40
|
+
adoptTurnId: 'turn-abc',
|
|
41
|
+
reinjectCount: 2,
|
|
42
|
+
ageMs: 90_000,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function inbound(): InboundMessage {
|
|
46
|
+
return {
|
|
47
|
+
type: 'inbound',
|
|
48
|
+
chatId: 'chatA',
|
|
49
|
+
threadId: 7,
|
|
50
|
+
messageId: 900,
|
|
51
|
+
text: 'worker finished',
|
|
52
|
+
meta: { source: 'subagent_handback', handbackReinjectCount: '1' },
|
|
53
|
+
} as unknown as InboundMessage
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('handback orphan recovery effects', () => {
|
|
57
|
+
it('deletes the frozen pre-turn card with its chat/message/thread identity', async () => {
|
|
58
|
+
const deleteMessage = vi.fn(async () => ({ ok: true }))
|
|
59
|
+
const r = createHandbackOrphanRecovery({
|
|
60
|
+
deleteMessage,
|
|
61
|
+
pushInbound: vi.fn(),
|
|
62
|
+
writeLog: vi.fn(),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
await r.deleteCard(record)
|
|
66
|
+
|
|
67
|
+
expect(deleteMessage).toHaveBeenCalledTimes(1)
|
|
68
|
+
expect(deleteMessage).toHaveBeenCalledWith('chatA', 4242, 7)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('swallows a delete failure so the reap is never wedged by a Telegram error', async () => {
|
|
72
|
+
const deleteMessage = vi.fn(async () => {
|
|
73
|
+
throw new Error('message to delete not found')
|
|
74
|
+
})
|
|
75
|
+
const r = createHandbackOrphanRecovery({
|
|
76
|
+
deleteMessage,
|
|
77
|
+
pushInbound: vi.fn(),
|
|
78
|
+
writeLog: vi.fn(),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
await expect(r.deleteCard(record)).resolves.toBeUndefined()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('re-injects the orphaned handback verbatim through the pending-inbound buffer', () => {
|
|
85
|
+
const pushInbound = vi.fn()
|
|
86
|
+
const r = createHandbackOrphanRecovery({
|
|
87
|
+
deleteMessage: vi.fn(async () => ({})),
|
|
88
|
+
pushInbound,
|
|
89
|
+
writeLog: vi.fn(),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const msg = inbound()
|
|
93
|
+
r.reinjectHandback(msg)
|
|
94
|
+
|
|
95
|
+
expect(pushInbound).toHaveBeenCalledTimes(1)
|
|
96
|
+
// Same object identity: the seam already stamped the retry counter on
|
|
97
|
+
// `meta`, and this effect must not rebuild or strip the inbound.
|
|
98
|
+
expect(pushInbound.mock.calls[0]?.[0]).toBe(msg)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('escalates exhausted retries to telemetry with the fleet-health join keys, not to chat', () => {
|
|
102
|
+
const writeLog = vi.fn()
|
|
103
|
+
const pushInbound = vi.fn()
|
|
104
|
+
const deleteMessage = vi.fn(async () => ({}))
|
|
105
|
+
const r = createHandbackOrphanRecovery({ deleteMessage, pushInbound, writeLog })
|
|
106
|
+
|
|
107
|
+
r.escalateOrphan(escalation)
|
|
108
|
+
|
|
109
|
+
expect(writeLog).toHaveBeenCalledTimes(1)
|
|
110
|
+
const line = writeLog.mock.calls[0]?.[0] as string
|
|
111
|
+
expect(line).toContain('handback orphan escalation')
|
|
112
|
+
expect(line).toContain('key=chatA:7')
|
|
113
|
+
expect(line).toContain('turnId=turn-abc')
|
|
114
|
+
expect(line).toContain('reinjects=2')
|
|
115
|
+
expect(line).toContain('ageMs=90000')
|
|
116
|
+
expect(line.endsWith('\n')).toBe(true)
|
|
117
|
+
// Escalation is telemetry-only: it must not touch any chat transport.
|
|
118
|
+
expect(deleteMessage).not.toHaveBeenCalled()
|
|
119
|
+
expect(pushInbound).not.toHaveBeenCalled()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('formatOrphanEscalation is a single line (one log record, not a multi-line blob)', () => {
|
|
123
|
+
const line = formatOrphanEscalation(escalation)
|
|
124
|
+
expect(line.trimEnd().split('\n')).toHaveLength(1)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('the operator-facing "needs a nudge" string is gone from the recovery module', () => {
|
|
128
|
+
const src = readFileSync(new URL('../gateway/handback-orphan-recovery.ts', import.meta.url), 'utf8')
|
|
129
|
+
expect(src).not.toContain('it may need a nudge')
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
1
3
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
4
|
|
|
3
5
|
import {
|
|
4
6
|
createHandbackPreturnSignal,
|
|
5
7
|
isHandbackInbound,
|
|
8
|
+
HANDBACK_REINJECT_COUNT_META_KEY,
|
|
6
9
|
PRETURN_TURNKEY_PREFIX,
|
|
10
|
+
type HandbackOrphanEscalation,
|
|
7
11
|
type HandbackPreturnSignalDeps,
|
|
8
12
|
type PreTurnCardRecord,
|
|
9
13
|
} from '../gateway/handback-preturn-signal.js'
|
|
@@ -136,9 +140,13 @@ interface Harness {
|
|
|
136
140
|
sched: ReturnType<typeof makeScheduler>
|
|
137
141
|
sendChatAction: ReturnType<typeof vi.fn>
|
|
138
142
|
openCard: ReturnType<typeof vi.fn>
|
|
139
|
-
|
|
143
|
+
deleteCard: ReturnType<typeof vi.fn>
|
|
144
|
+
reinjectHandback: ReturnType<typeof vi.fn>
|
|
145
|
+
escalateOrphan: ReturnType<typeof vi.fn>
|
|
140
146
|
records: Map<string, PreTurnCardRecord>
|
|
141
|
-
|
|
147
|
+
deletedIds: number[]
|
|
148
|
+
reinjected: InboundMessage[]
|
|
149
|
+
escalations: HandbackOrphanEscalation[]
|
|
142
150
|
}
|
|
143
151
|
|
|
144
152
|
const harnesses: Harness[] = []
|
|
@@ -168,9 +176,19 @@ function makeHarness(overrides: Partial<HandbackPreturnSignalDeps> = {}): Harnes
|
|
|
168
176
|
if (cur != null && cur.activityMessageId === activityMessageId) records.delete(turnKey)
|
|
169
177
|
}
|
|
170
178
|
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
|
|
179
|
+
const deletedIds: number[] = []
|
|
180
|
+
const deleteCard = vi.fn<(r: PreTurnCardRecord) => void>((r) => {
|
|
181
|
+
deletedIds.push(r.activityMessageId)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const reinjected: InboundMessage[] = []
|
|
185
|
+
const reinjectHandback = vi.fn<(m: InboundMessage) => void>((m) => {
|
|
186
|
+
reinjected.push(m)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const escalations: HandbackOrphanEscalation[] = []
|
|
190
|
+
const escalateOrphan = vi.fn<(e: HandbackOrphanEscalation) => void>((e) => {
|
|
191
|
+
escalations.push(e)
|
|
174
192
|
})
|
|
175
193
|
|
|
176
194
|
const signal = createHandbackPreturnSignal({
|
|
@@ -179,7 +197,9 @@ function makeHarness(overrides: Partial<HandbackPreturnSignalDeps> = {}): Harnes
|
|
|
179
197
|
startTypingLoop: (c, t) => typing.start(c, t),
|
|
180
198
|
stopTypingLoop: (c, t) => typing.stop(c, t),
|
|
181
199
|
openCard,
|
|
182
|
-
|
|
200
|
+
deleteCard,
|
|
201
|
+
reinjectHandback,
|
|
202
|
+
escalateOrphan,
|
|
183
203
|
writeCardRecord,
|
|
184
204
|
clearCardRecord,
|
|
185
205
|
now: sched.now,
|
|
@@ -187,10 +207,28 @@ function makeHarness(overrides: Partial<HandbackPreturnSignalDeps> = {}): Harnes
|
|
|
187
207
|
clearTimer: sched.clearTimer,
|
|
188
208
|
debounceMs: 700,
|
|
189
209
|
adoptTimeoutMs: 30_000,
|
|
210
|
+
// Default the delivered-window TTL to 0 so tests that exercise the reap
|
|
211
|
+
// MECHANICS (idle-orphan, #3544 typing, isClaudeBusy gate) see the reap fire
|
|
212
|
+
// at the adopt timeout exactly as before this fix. Tests that specifically
|
|
213
|
+
// assert the delivered-but-not-yet-streaming deferral opt into a real TTL.
|
|
214
|
+
deliveredTtlMs: 0,
|
|
190
215
|
...overrides,
|
|
191
216
|
})
|
|
192
217
|
|
|
193
|
-
const h: Harness = {
|
|
218
|
+
const h: Harness = {
|
|
219
|
+
signal,
|
|
220
|
+
typing,
|
|
221
|
+
sched,
|
|
222
|
+
sendChatAction,
|
|
223
|
+
openCard,
|
|
224
|
+
deleteCard,
|
|
225
|
+
reinjectHandback,
|
|
226
|
+
escalateOrphan,
|
|
227
|
+
records,
|
|
228
|
+
deletedIds,
|
|
229
|
+
reinjected,
|
|
230
|
+
escalations,
|
|
231
|
+
}
|
|
194
232
|
harnesses.push(h)
|
|
195
233
|
return h
|
|
196
234
|
}
|
|
@@ -283,7 +321,7 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
|
|
|
283
321
|
expect(h.signal.pendingCount()).toBe(0)
|
|
284
322
|
})
|
|
285
323
|
|
|
286
|
-
it('never-adopted handback self-reaps
|
|
324
|
+
it('never-adopted handback self-reaps: DELETES the frozen card and re-injects (deterministic recovery, no nudge)', async () => {
|
|
287
325
|
const h = makeHarness()
|
|
288
326
|
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatD', messageId: 77 }))
|
|
289
327
|
await h.sched.advance(700)
|
|
@@ -291,10 +329,15 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
|
|
|
291
329
|
expect(h.typing.activeCount()).toBe(1)
|
|
292
330
|
expect(h.records.size).toBe(1)
|
|
293
331
|
|
|
294
|
-
// No enqueue ever arrives. Past the adopt TTL the orphan self-reaps
|
|
332
|
+
// No enqueue ever arrives. Past the adopt TTL the orphan self-reaps into
|
|
333
|
+
// deterministic recovery: the frozen card is DELETED (never rewritten to a
|
|
334
|
+
// user-facing "needs a nudge" string) and the handback is re-injected.
|
|
295
335
|
await h.sched.advance(30_000)
|
|
296
336
|
|
|
297
|
-
expect(h.
|
|
337
|
+
expect(h.deletedIds).toEqual([resolvedId]) // deleted exactly THAT card
|
|
338
|
+
expect(h.reinjected).toHaveLength(1) // re-injected for the machine to recover itself
|
|
339
|
+
expect(h.reinjected[0]!.meta[HANDBACK_REINJECT_COUNT_META_KEY]).toBe('1')
|
|
340
|
+
expect(h.escalations).toEqual([]) // first orphan → recover, do NOT escalate
|
|
298
341
|
expect(h.typing.activeCount()).toBe(0) // typing stopped
|
|
299
342
|
expect(h.records.size).toBe(0) // durable record cleared (at-most-once)
|
|
300
343
|
expect(h.signal.pendingCount()).toBe(0) // map empty
|
|
@@ -326,18 +369,20 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
|
|
|
326
369
|
// DEFER each time: no false orphan card, entry still live, typing still lit.
|
|
327
370
|
await h.sched.advance(30_000)
|
|
328
371
|
await h.sched.advance(30_000)
|
|
329
|
-
expect(h.
|
|
372
|
+
expect(h.deletedIds).toEqual([]) // NEVER reaped: card intact
|
|
373
|
+
expect(h.reinjected).toEqual([]) // NEVER re-injected while enqueued
|
|
330
374
|
expect(h.signal.pendingCount()).toBe(1) // still queued, not reaped
|
|
331
375
|
expect(h.typing.activeCount()).toBe(1) // indicator kept while queued
|
|
332
376
|
|
|
333
377
|
// The parent turn finally ends and the handback mints its own turn: it
|
|
334
|
-
// adopts the SAME card (an edit, not a second send / not an orphan
|
|
378
|
+
// adopts the SAME card (an edit, not a second send / not an orphan reap).
|
|
335
379
|
busy = false
|
|
336
380
|
const turnId = deriveTurnId('chatBusy', null, 700)!
|
|
337
381
|
const adoption = h.signal.tryAdopt(turnId)
|
|
338
382
|
expect(adoption).not.toBeNull()
|
|
339
383
|
expect(adoption!.activityMessageId).toBe(resolvedId)
|
|
340
|
-
expect(h.
|
|
384
|
+
expect(h.deletedIds).toEqual([]) // adopted, never reaped as orphan
|
|
385
|
+
expect(h.reinjected).toEqual([])
|
|
341
386
|
expect(h.openCard).toHaveBeenCalledTimes(1) // no double-send
|
|
342
387
|
expect(h.signal.pendingCount()).toBe(0)
|
|
343
388
|
})
|
|
@@ -345,7 +390,7 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
|
|
|
345
390
|
// The deferred reap is not a leak: once claude goes idle and the handback has
|
|
346
391
|
// STILL not been adopted (genuine bridge-death / dropped inbound), the re-armed
|
|
347
392
|
// reap finalizes honestly — the degenerate case the reap exists for.
|
|
348
|
-
it('
|
|
393
|
+
it('recovers a genuine orphan once claude goes idle, even after deferring while busy', async () => {
|
|
349
394
|
let busy = true
|
|
350
395
|
const h = makeHarness({ isClaudeBusy: () => busy })
|
|
351
396
|
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatIdleOrphan', messageId: 710 }))
|
|
@@ -354,19 +399,169 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
|
|
|
354
399
|
|
|
355
400
|
// Busy: the first reap defers.
|
|
356
401
|
await h.sched.advance(30_000)
|
|
357
|
-
expect(h.
|
|
402
|
+
expect(h.deletedIds).toEqual([])
|
|
403
|
+
expect(h.reinjected).toEqual([])
|
|
358
404
|
expect(h.signal.pendingCount()).toBe(1)
|
|
359
405
|
|
|
360
406
|
// Claude goes idle but NO adopting turn ever mints (the inbound was dropped).
|
|
361
|
-
// The re-armed reap now
|
|
407
|
+
// The re-armed reap now recovers the genuine orphan: delete + re-inject.
|
|
362
408
|
busy = false
|
|
363
409
|
await h.sched.advance(30_000)
|
|
364
|
-
expect(h.
|
|
410
|
+
expect(h.deletedIds).toEqual([resolvedId])
|
|
411
|
+
expect(h.reinjected).toHaveLength(1)
|
|
365
412
|
expect(h.typing.activeCount()).toBe(0)
|
|
366
413
|
expect(h.records.size).toBe(0)
|
|
367
414
|
expect(h.signal.pendingCount()).toBe(0)
|
|
368
415
|
})
|
|
369
416
|
|
|
417
|
+
// ── #4027 coverage gap: delivered-but-not-yet-streaming is NOT an orphan ──
|
|
418
|
+
// A handback that WAS delivered into claude's queue but whose adopting turn
|
|
419
|
+
// has not started streaming yet reads as idle (`isClaudeBusy()` false) + un-
|
|
420
|
+
// adopted — which #4027 still finalized as a false "needs a nudge" card. The
|
|
421
|
+
// `deliveredAwaitingTurnStart` gate DEFERS the reap for the whole delivered
|
|
422
|
+
// TTL. This test FAILS on revert of the gate: without it the reap fires at the
|
|
423
|
+
// 30 s adopt timeout (idle, un-adopted) and reaps a healthy delivery.
|
|
424
|
+
it('does NOT reap a delivered-but-not-yet-streaming handback within the delivered TTL', async () => {
|
|
425
|
+
// Real delivered TTL, no busy signal — the ONLY thing that can defer the
|
|
426
|
+
// reap here is the delivered-window gate.
|
|
427
|
+
const h = makeHarness({ deliveredTtlMs: 300_000 })
|
|
428
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatDeliv', messageId: 800 }))
|
|
429
|
+
await h.sched.advance(700)
|
|
430
|
+
const resolvedId = await h.openCard.mock.results[0]!.value
|
|
431
|
+
expect(h.records.size).toBe(1)
|
|
432
|
+
|
|
433
|
+
// Well past the 30 s adopt timeout but comfortably within the 5 min TTL: the
|
|
434
|
+
// reap must DEFER every tick — no delete, no re-inject, entry still live,
|
|
435
|
+
// typing still lit. (Pre-fix this fired a false orphan at 30 s.)
|
|
436
|
+
await h.sched.advance(120_000)
|
|
437
|
+
expect(h.deletedIds).toEqual([])
|
|
438
|
+
expect(h.reinjected).toEqual([])
|
|
439
|
+
expect(h.escalations).toEqual([])
|
|
440
|
+
expect(h.signal.pendingCount()).toBe(1)
|
|
441
|
+
expect(h.typing.activeCount()).toBe(1)
|
|
442
|
+
|
|
443
|
+
// The turn finally starts streaming and adopts the SAME card — an edit, not
|
|
444
|
+
// an orphan reap, and no double-send.
|
|
445
|
+
const turnId = deriveTurnId('chatDeliv', null, 800)!
|
|
446
|
+
const adoption = h.signal.tryAdopt(turnId)
|
|
447
|
+
expect(adoption).not.toBeNull()
|
|
448
|
+
expect(adoption!.activityMessageId).toBe(resolvedId)
|
|
449
|
+
expect(h.deletedIds).toEqual([])
|
|
450
|
+
expect(h.reinjected).toEqual([])
|
|
451
|
+
expect(h.openCard).toHaveBeenCalledTimes(1)
|
|
452
|
+
expect(h.signal.pendingCount()).toBe(0)
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('reaps a delivered handback ONCE the delivered TTL elapses (deterministic recovery, still no nudge)', async () => {
|
|
456
|
+
const h = makeHarness({ deliveredTtlMs: 300_000 })
|
|
457
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatStuck', messageId: 801 }))
|
|
458
|
+
await h.sched.advance(700)
|
|
459
|
+
const resolvedId = await h.openCard.mock.results[0]!.value
|
|
460
|
+
|
|
461
|
+
// Within the TTL: deferred.
|
|
462
|
+
await h.sched.advance(120_000)
|
|
463
|
+
expect(h.deletedIds).toEqual([])
|
|
464
|
+
|
|
465
|
+
// Past the TTL a truly-stuck delivery finally reaps into recovery rather than
|
|
466
|
+
// hanging forever — delete + re-inject, never a nudge card.
|
|
467
|
+
await h.sched.advance(210_000)
|
|
468
|
+
expect(h.deletedIds).toEqual([resolvedId])
|
|
469
|
+
expect(h.reinjected).toHaveLength(1)
|
|
470
|
+
expect(h.escalations).toEqual([])
|
|
471
|
+
expect(h.signal.pendingCount()).toBe(0)
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
// ── deterministic-recovery retry cap: re-inject up to 2×, THEN escalate ──
|
|
475
|
+
// A genuine orphan whose re-injections keep coming back orphaned must not loop
|
|
476
|
+
// forever, and must NEVER fall back to a chat nudge. After `maxReinjects` it
|
|
477
|
+
// escalates to fleet-health telemetry (the `escalateOrphan` sink). The retry
|
|
478
|
+
// counter rides `HANDBACK_REINJECT_COUNT_META_KEY` on the inbound across the
|
|
479
|
+
// buffer round trip, so the cap survives the drop-and-recreate of the entry.
|
|
480
|
+
it('re-injects a persistently-orphaned handback up to the cap, then escalates (never a nudge)', async () => {
|
|
481
|
+
const sched = makeScheduler()
|
|
482
|
+
const sendChatAction = vi.fn<(c: string, t: number | null) => void>()
|
|
483
|
+
const typing = createTurnTypingLoop({
|
|
484
|
+
sendChatAction,
|
|
485
|
+
chatKey: (c, t) => chatKey(c, t),
|
|
486
|
+
refreshMs: 4000,
|
|
487
|
+
})
|
|
488
|
+
let msgSeq = 5000
|
|
489
|
+
const deletedIds: number[] = []
|
|
490
|
+
const reinjectedCounts: (string | undefined)[] = []
|
|
491
|
+
const escalations: HandbackOrphanEscalation[] = []
|
|
492
|
+
const records = new Map<string, PreTurnCardRecord>()
|
|
493
|
+
|
|
494
|
+
// eslint-disable-next-line prefer-const
|
|
495
|
+
let signal: ReturnType<typeof createHandbackPreturnSignal>
|
|
496
|
+
signal = createHandbackPreturnSignal({
|
|
497
|
+
chatKey: (c, t) => chatKey(c, t),
|
|
498
|
+
deriveTurnId,
|
|
499
|
+
startTypingLoop: (c, t) => typing.start(c, t),
|
|
500
|
+
stopTypingLoop: (c, t) => typing.stop(c, t),
|
|
501
|
+
openCard: async () => ++msgSeq,
|
|
502
|
+
deleteCard: (r) => {
|
|
503
|
+
deletedIds.push(r.activityMessageId)
|
|
504
|
+
},
|
|
505
|
+
// Simulate the buffer round trip: a re-injected orphan comes straight back
|
|
506
|
+
// through the release chokepoint carrying its stamped retry counter.
|
|
507
|
+
reinjectHandback: (m) => {
|
|
508
|
+
reinjectedCounts.push(m.meta[HANDBACK_REINJECT_COUNT_META_KEY])
|
|
509
|
+
signal.noteHandbackRelease(m)
|
|
510
|
+
},
|
|
511
|
+
escalateOrphan: (e) => {
|
|
512
|
+
escalations.push(e)
|
|
513
|
+
},
|
|
514
|
+
writeCardRecord: (r) => {
|
|
515
|
+
records.set(r.turnKey, r)
|
|
516
|
+
},
|
|
517
|
+
clearCardRecord: (turnKey, id) => {
|
|
518
|
+
const cur = records.get(turnKey)
|
|
519
|
+
if (cur != null && cur.activityMessageId === id) records.delete(turnKey)
|
|
520
|
+
},
|
|
521
|
+
now: sched.now,
|
|
522
|
+
setTimer: sched.setTimer,
|
|
523
|
+
clearTimer: sched.clearTimer,
|
|
524
|
+
debounceMs: 700,
|
|
525
|
+
adoptTimeoutMs: 30_000,
|
|
526
|
+
deliveredTtlMs: 0, // no delivered-window deferral: exercise the retry cap directly
|
|
527
|
+
maxReinjects: 2,
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
signal.noteHandbackRelease(handbackInbound({ chatId: 'chatCap', messageId: 802 }))
|
|
532
|
+
// Drive several reap cycles: each orphan deletes its card, re-injects (up
|
|
533
|
+
// to the cap), which re-arms the seam via the simulated round trip.
|
|
534
|
+
await sched.advance(200_000)
|
|
535
|
+
|
|
536
|
+
// Exactly 2 re-injections, stamped 1 then 2, then a single escalation.
|
|
537
|
+
expect(reinjectedCounts).toEqual(['1', '2'])
|
|
538
|
+
expect(escalations).toHaveLength(1)
|
|
539
|
+
expect(escalations[0]!.reinjectCount).toBe(2)
|
|
540
|
+
// One card deleted per orphan cycle (initial + 2 re-injected), never edited
|
|
541
|
+
// to a "needs a nudge" string.
|
|
542
|
+
expect(deletedIds).toHaveLength(3)
|
|
543
|
+
expect(signal.pendingCount()).toBe(0)
|
|
544
|
+
} finally {
|
|
545
|
+
typing.stopAll()
|
|
546
|
+
}
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
// ── the user-facing "needs a nudge" string is GONE from the gateway source ──
|
|
550
|
+
// Part A of the fix removed the operator-facing card text entirely; the orphan
|
|
551
|
+
// path now deletes + re-injects. This is a durable regression guard: it FAILS
|
|
552
|
+
// if the string is reintroduced anywhere in the gateway source.
|
|
553
|
+
it('the "may need a nudge" user-facing string is absent from the gateway source', () => {
|
|
554
|
+
const gatewaySrc = readFileSync(
|
|
555
|
+
new URL('../gateway/gateway.ts', import.meta.url),
|
|
556
|
+
'utf8',
|
|
557
|
+
)
|
|
558
|
+
// The literal user-facing phrase must not appear as a rendered string. (The
|
|
559
|
+
// only surviving mentions are historical CODE COMMENTS explaining the removal
|
|
560
|
+
// — assert the load-bearing card fragment is gone from any send/edit call.)
|
|
561
|
+
expect(gatewaySrc).not.toContain('it may need a nudge')
|
|
562
|
+
expect(gatewaySrc).not.toContain('HANDBACK_PRETURN_ORPHAN_HTML')
|
|
563
|
+
})
|
|
564
|
+
|
|
370
565
|
it('identity race: a user inbound on the same topic never mis-adopts the handback, no double-send', async () => {
|
|
371
566
|
const h = makeHarness()
|
|
372
567
|
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatE', messageId: 200 }))
|
|
@@ -154,6 +154,9 @@ export function makeLivenessFixture(
|
|
|
154
154
|
purgeReactionTracking: () => {},
|
|
155
155
|
getPendingInboundBuffer: () => ({ drain: () => [] }),
|
|
156
156
|
trackRedeliveredInbound: () => {},
|
|
157
|
+
// Default: no parked store wired (returns 0). Tests exercising the parked
|
|
158
|
+
// drain override this with the real `drainParkedTurnStartsForChat`.
|
|
159
|
+
drainParkedTurnStarts: (_chatId: string, _threadId: number | null) => 0,
|
|
157
160
|
closeActivityLane: () => {},
|
|
158
161
|
closeProgressLane: () => {},
|
|
159
162
|
hangRestart: null,
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
3
|
+
import { join, relative } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { renderBootCard } from '../gateway/boot-card.js'
|
|
6
|
+
import { renderUpdateOutcomeLine } from '../gateway/update-announce.js'
|
|
7
|
+
import { NESTED_PREFIX } from '../status-no-truncate.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* #4115 — ONE indent idiom in the repo, and it is the one that actually
|
|
11
|
+
* renders.
|
|
12
|
+
*
|
|
13
|
+
* #3668 fixed the status card's nested lines by moving `NESTED_PREFIX` off
|
|
14
|
+
* ASCII spaces onto U+2800 BRAILLE PATTERN BLANK: card bodies reach Telegram
|
|
15
|
+
* as GFM markdown parsed SERVER-SIDE, and that parser left-trims a leading
|
|
16
|
+
* whitespace run off a content line, so an ASCII (or U+00A0) indent is dropped
|
|
17
|
+
* on the wire and the line renders FLAT. Live-verified on a phone 2026-07-26 —
|
|
18
|
+
* the record is on `WORKER_STEP_INDENT` in `status-no-truncate.ts`.
|
|
19
|
+
*
|
|
20
|
+
* The boot card and the update-announce line were NOT migrated by that PR, so
|
|
21
|
+
* they kept shipping the collapsed non-indent to the user. These tests assert
|
|
22
|
+
* what RENDERS (the leading run survives a server-side left-trim), not merely
|
|
23
|
+
* that a constant was referenced — a byte-equality assertion against the old
|
|
24
|
+
* literal is exactly what let the pre-#3668 indent ship green and inert.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Every rule a server-side trimmer could plausibly use. */
|
|
28
|
+
const isTrimmableWhitespace = (ch: string): boolean =>
|
|
29
|
+
/\s/u.test(ch) || /\p{White_Space}/u.test(ch) || /\p{Zs}/u.test(ch)
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The assertion that ties to rendering: after a leading-whitespace left-trim
|
|
33
|
+
* of the kind Telegram applies, the line STILL carries its full indent, and it
|
|
34
|
+
* does not lead with either character already proven flat on a phone.
|
|
35
|
+
*/
|
|
36
|
+
function expectSurvivesLeftTrim(line: string): void {
|
|
37
|
+
expect(line.startsWith(NESTED_PREFIX)).toBe(true)
|
|
38
|
+
expect(
|
|
39
|
+
line.replace(/^[\s\p{White_Space}]+/u, '').startsWith(NESTED_PREFIX),
|
|
40
|
+
`nested line "${line.slice(0, 40)}…" loses its indent to a leading-whitespace trim — ` +
|
|
41
|
+
`it renders FLAT on a phone (#3668/#4115). Use NESTED_PREFIX.`,
|
|
42
|
+
).toBe(true)
|
|
43
|
+
expect(line.startsWith(' ')).toBe(false)
|
|
44
|
+
expect(line.startsWith(' ')).toBe(false)
|
|
45
|
+
const indent = [...line.slice(0, line.indexOf('↳'))]
|
|
46
|
+
expect(indent.length).toBeGreaterThan(0)
|
|
47
|
+
for (const ch of indent) expect(isTrimmableWhitespace(ch)).toBe(false)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Card lines with the stack's own break chrome removed. */
|
|
51
|
+
function linesOf(card: string): string[] {
|
|
52
|
+
return card.split('\n').map((l) => l.replace(/[ \t]+$/, ''))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('boot card nested lines render as a real indent (#4115)', () => {
|
|
56
|
+
it("a probe's next-step continuation line survives a server-side left-trim", () => {
|
|
57
|
+
// Fail-before: this row was `' ↳ ' + nextStep` — four ASCII spaces, all
|
|
58
|
+
// of them trimmed, so the line rendered flush against its probe row.
|
|
59
|
+
const out = renderBootCard({
|
|
60
|
+
agentName: 'lawgpt',
|
|
61
|
+
version: 'v0.7.16',
|
|
62
|
+
probes: {
|
|
63
|
+
skills: {
|
|
64
|
+
status: 'degraded',
|
|
65
|
+
label: 'Skills',
|
|
66
|
+
detail: '10/10 dangling: a, b, c +7 more',
|
|
67
|
+
nextStep: 'Run `switchroom agent reconcile lawgpt` to rebuild symlinks',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
const nested = linesOf(out).filter((l) => l.includes('↳'))
|
|
72
|
+
expect(nested.length).toBe(1)
|
|
73
|
+
expectSurvivesLeftTrim(nested[0])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('the crash row tail-logs continuation line survives a server-side left-trim', () => {
|
|
77
|
+
const out = renderBootCard({
|
|
78
|
+
agentName: 'lawgpt',
|
|
79
|
+
version: 'v0.7.16',
|
|
80
|
+
restartReason: 'crash',
|
|
81
|
+
restartAgeMs: 6_100,
|
|
82
|
+
})
|
|
83
|
+
const nested = linesOf(out).filter((l) => l.includes('↳'))
|
|
84
|
+
expect(nested.length).toBe(1)
|
|
85
|
+
expect(nested[0]).toContain('Tail logs:')
|
|
86
|
+
expectSurvivesLeftTrim(nested[0])
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('update-announce recovery line renders as a real indent (#4115)', () => {
|
|
91
|
+
it('the failure card\'s recovery continuation survives a server-side left-trim', () => {
|
|
92
|
+
// Fail-before: the recovery row was `' ↳ Recovery: …'`.
|
|
93
|
+
const line = renderUpdateOutcomeLine({
|
|
94
|
+
ts: '2026-05-17T11:59:00.000Z',
|
|
95
|
+
op: 'update_apply',
|
|
96
|
+
caller: { kind: 'operator' },
|
|
97
|
+
request_id: 'req-2',
|
|
98
|
+
result: 'error',
|
|
99
|
+
exit_code: 1,
|
|
100
|
+
duration_ms: 100,
|
|
101
|
+
phase: 'terminal',
|
|
102
|
+
stderr_tail: 'compose pull failed: registry timeout',
|
|
103
|
+
install_context: { install_type: 'binary', detected_at: '2026-05-17T11:00:00Z' },
|
|
104
|
+
})
|
|
105
|
+
const nested = linesOf(line).filter((l) => l.includes('↳'))
|
|
106
|
+
expect(nested.length).toBe(1)
|
|
107
|
+
expect(nested[0]).toContain('Recovery:')
|
|
108
|
+
expectSurvivesLeftTrim(nested[0])
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('GUARD: no ASCII-space nested indent may reappear in the source (#4115)', () => {
|
|
113
|
+
// A run of ASCII spaces/tabs immediately before the `↳` nesting glyph — the
|
|
114
|
+
// exact shape that renders flat. The one exported constant is the only legal
|
|
115
|
+
// way to indent a nested card line.
|
|
116
|
+
const ASCII_INDENT_BEFORE_ARROW = /[ \t]{2,}↳/
|
|
117
|
+
|
|
118
|
+
const here = fileURLToPath(import.meta.url)
|
|
119
|
+
const repoRoot = join(here, '..', '..', '..')
|
|
120
|
+
const ROOTS = ['telegram-plugin', 'src']
|
|
121
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'coverage'])
|
|
122
|
+
|
|
123
|
+
function walk(dir: string, out: string[]): string[] {
|
|
124
|
+
for (const name of readdirSync(dir)) {
|
|
125
|
+
if (SKIP_DIRS.has(name)) continue
|
|
126
|
+
const p = join(dir, name)
|
|
127
|
+
if (statSync(p).isDirectory()) walk(p, out)
|
|
128
|
+
else if (p.endsWith('.ts') && p !== here) out.push(p)
|
|
129
|
+
}
|
|
130
|
+
return out
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
it('no source file hardcodes an ASCII-indent-plus-↳ nested line', () => {
|
|
134
|
+
const offenders: string[] = []
|
|
135
|
+
for (const root of ROOTS) {
|
|
136
|
+
for (const file of walk(join(repoRoot, root), [])) {
|
|
137
|
+
const text = readFileSync(file, 'utf8')
|
|
138
|
+
text.split('\n').forEach((line, i) => {
|
|
139
|
+
if (ASCII_INDENT_BEFORE_ARROW.test(line)) {
|
|
140
|
+
offenders.push(`${relative(repoRoot, file)}:${i + 1}: ${line.trim().slice(0, 100)}`)
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
expect(
|
|
146
|
+
offenders,
|
|
147
|
+
`Hardcoded ASCII indent before a '↳' nesting glyph. Telegram left-trims a leading ` +
|
|
148
|
+
`ASCII-whitespace run off a content line, so this renders FLAT on a phone (#3668/#4115). ` +
|
|
149
|
+
`Import NESTED_PREFIX from status-no-truncate.js instead:\n ${offenders.join('\n ')}`,
|
|
150
|
+
).toEqual([])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('the guard actually fires on the shape it is guarding against', () => {
|
|
154
|
+
// A guard that cannot fail is not a guard: pin the detector on the exact
|
|
155
|
+
// literal the two migrated call sites used to carry.
|
|
156
|
+
expect(ASCII_INDENT_BEFORE_ARROW.test("degradedRows.push(` ↳ ${next}`)")).toBe(true)
|
|
157
|
+
expect(ASCII_INDENT_BEFORE_ARROW.test(' ↳ Recovery: reinstall')).toBe(true)
|
|
158
|
+
// …and stays quiet on the legal idiom and on prose that merely mentions ↳.
|
|
159
|
+
expect(ASCII_INDENT_BEFORE_ARROW.test(`${NESTED_PREFIX}Recovery: reinstall`)).toBe(false)
|
|
160
|
+
expect(ASCII_INDENT_BEFORE_ARROW.test('the nested ↳ block is windowed')).toBe(false)
|
|
161
|
+
})
|
|
162
|
+
})
|