switchroom 0.19.42 → 0.19.44
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 +4 -1
- package/dist/auth-broker/index.js +284 -124
- package/dist/cli/notion-write-pretool.mjs +4 -1
- package/dist/cli/switchroom.js +4094 -2711
- package/dist/host-control/main.js +472 -155
- package/dist/vault/approvals/kernel-server.js +231 -71
- package/dist/vault/broker/server.js +293 -133
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +93 -8
- 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 +1682 -936
- 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/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +49 -51
- 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/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 +5 -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/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- 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/scripts/lib/config.py +15 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -397,20 +397,166 @@ describe('stripExcessBold', () => {
|
|
|
397
397
|
expect(stripExcessBold(input)).toBe(input)
|
|
398
398
|
})
|
|
399
399
|
|
|
400
|
-
test('
|
|
401
|
-
// Heading length is measured WITH the `**` markers.
|
|
402
|
-
// total (exempt);
|
|
403
|
-
const
|
|
404
|
-
const
|
|
405
|
-
expect(
|
|
406
|
-
expect(
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
expect(
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
expect(
|
|
413
|
-
expect(
|
|
400
|
+
test('64/65-char pseudo-heading boundary honoured under global strip', () => {
|
|
401
|
+
// Heading length is measured WITH the `**` markers. 60 inner chars → 64
|
|
402
|
+
// total (exempt); 61 inner chars → 65 total (stripped).
|
|
403
|
+
const heading64 = '**' + 'H'.repeat(60) + '**' // length 64
|
|
404
|
+
const heading65 = '**' + 'H'.repeat(61) + '**' // length 65
|
|
405
|
+
expect(heading64.length).toBe(64)
|
|
406
|
+
expect(heading65.length).toBe(65)
|
|
407
|
+
|
|
408
|
+
const out64 = stripExcessBold(`${heading64}\n\n${boldDenseBody}`)
|
|
409
|
+
expect(out64).toContain(heading64)
|
|
410
|
+
|
|
411
|
+
const out65 = stripExcessBold(`${heading65}\n\n${boldDenseBody}`)
|
|
412
|
+
expect(out65).not.toContain(heading65)
|
|
413
|
+
expect(out65).toContain('H'.repeat(61))
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
// ── #4021: long-but-legitimate bold section labels survive ───────────────
|
|
417
|
+
// At PSEUDO_HEADING_MAX_CHARS=48 a real section label of 50-63 chars was
|
|
418
|
+
// read as an over-bolded paragraph and flattened. The cap is 64.
|
|
419
|
+
|
|
420
|
+
test.each([
|
|
421
|
+
// [label, survives?]
|
|
422
|
+
['**Deployment status across all three regions:**', true], // 46
|
|
423
|
+
['**Deployment status across all three AWS regions:**', true], // 50
|
|
424
|
+
['**What changed in the delivery path since last Tuesday:**', true], // 56
|
|
425
|
+
['**What actually changed in the delivery path since Tue:**', true], // 56
|
|
426
|
+
['**Everything that changed across the delivery path this week:**', true], // 62
|
|
427
|
+
['**Everything that changed across the whole delivery path today:**', false], // 65
|
|
428
|
+
])('bold section label %s survives=%s under global strip', (label, survives) => {
|
|
429
|
+
// Sanity: the fixtures must straddle the 64-char boundary.
|
|
430
|
+
expect(label.length <= 64).toBe(survives)
|
|
431
|
+
|
|
432
|
+
const out = stripExcessBold(`${label}\n\n${boldDenseBody}`)
|
|
433
|
+
const inner = label.slice(2, -2)
|
|
434
|
+
if (survives) {
|
|
435
|
+
expect(out).toContain(label)
|
|
436
|
+
} else {
|
|
437
|
+
expect(out).not.toContain(label)
|
|
438
|
+
expect(out).toContain(inner)
|
|
439
|
+
}
|
|
440
|
+
// Either way the body is still flattened — the ratio guard is intact.
|
|
441
|
+
expect(out).not.toContain('**alpha**')
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
// ── #4017: a message that is ALL pseudo-headings still strips ────────────
|
|
445
|
+
// Every block exempted meant `out === masked`: a 100%-bold digest stayed
|
|
446
|
+
// fully bold and onStrip never fired.
|
|
447
|
+
|
|
448
|
+
test('all-pseudo-heading message strips (no body to contrast against)', () => {
|
|
449
|
+
const input = [
|
|
450
|
+
'**Deploy status**',
|
|
451
|
+
'**Open incidents**',
|
|
452
|
+
'**Merged today**',
|
|
453
|
+
'**Blocked on review**',
|
|
454
|
+
'**Rollout window tomorrow**',
|
|
455
|
+
'**Next steps:**',
|
|
456
|
+
].join('\n\n')
|
|
457
|
+
expect(input.length).toBeGreaterThan(100)
|
|
458
|
+
|
|
459
|
+
const calls: Array<{ rule: string; ratio: number }> = []
|
|
460
|
+
const out = stripExcessBold(input, (d) => calls.push(d))
|
|
461
|
+
|
|
462
|
+
expect(out).not.toContain('**')
|
|
463
|
+
expect(out).toContain('Deploy status')
|
|
464
|
+
expect(out).toContain('Next steps:')
|
|
465
|
+
// Blank-line gaps between the headings are preserved.
|
|
466
|
+
expect(out).toBe(input.replace(/\*\*/g, ''))
|
|
467
|
+
// …and the strip is logged.
|
|
468
|
+
expect(calls).toHaveLength(1)
|
|
469
|
+
expect(calls[0].rule).toBe('global')
|
|
470
|
+
expect(calls[0].ratio).toBeGreaterThan(0.3)
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
test.each([
|
|
474
|
+
[
|
|
475
|
+
'all headings, no body → strip',
|
|
476
|
+
`**Deploy status**\n\n**Open incidents this morning**\n\n**Merged today since the last release**\n\n**Next steps:**`,
|
|
477
|
+
false,
|
|
478
|
+
],
|
|
479
|
+
[
|
|
480
|
+
'headings + bold-dense body → headings survive',
|
|
481
|
+
`**Deploy status**\n\n${boldDenseBody}`,
|
|
482
|
+
true,
|
|
483
|
+
],
|
|
484
|
+
[
|
|
485
|
+
'headings around a bold-dense body → headings survive',
|
|
486
|
+
`**Deploy status**\n\n**Open incidents**\n\n${boldDenseBody}\n\n**Next steps:**`,
|
|
487
|
+
true,
|
|
488
|
+
],
|
|
489
|
+
])('all-heading guard: %s', (_name, input, headingSurvives) => {
|
|
490
|
+
expect(input.length).toBeGreaterThan(100)
|
|
491
|
+
const out = stripExcessBold(input)
|
|
492
|
+
expect(out.includes('**Deploy status**')).toBe(headingSurvives)
|
|
493
|
+
expect(out).toContain('Deploy status')
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
// ── #4114: a code-only block is NOT body for the all-heading guard ───────
|
|
497
|
+
// #4108's short-circuit tested `blocks.every(isPseudoHeadingBlock)` on every
|
|
498
|
+
// non-blank block. A masked code block satisfies neither side, so ONE code
|
|
499
|
+
// fence in an all-headings digest flipped `every(...)` to false, re-exempted
|
|
500
|
+
// every heading, and reproduced #4017 exactly: 100% bold, onStrip silent.
|
|
501
|
+
// Decision pinned here: masked code is not body — it is invisible to every
|
|
502
|
+
// other measurement in stripExcessBold (it is stripped before the ratio is
|
|
503
|
+
// taken), so it does not buy the headings an exemption either.
|
|
504
|
+
|
|
505
|
+
const HEADINGS = ['**Deploy status**', '**Open incidents**', '**Merged today**']
|
|
506
|
+
const TAIL = ['**Blocked on review**', '**Rollout window tomorrow**', '**Next steps:**']
|
|
507
|
+
|
|
508
|
+
test.each([
|
|
509
|
+
['fenced code block', '```\nswitchroom agent restart klanker\n```'],
|
|
510
|
+
['inline code span', '`switchroom agent restart klanker`'],
|
|
511
|
+
['fenced block with a language tag', '```bash\nswitchroom agent restart klanker\n```'],
|
|
512
|
+
['several inline spans on one line', '`alpha` `beta` `gamma` `delta` `epsilon`'],
|
|
513
|
+
])('all-heading guard: headings + a code-only block (%s) still strips', (_name, code) => {
|
|
514
|
+
const input = [...HEADINGS, code, ...TAIL].join('\n\n')
|
|
515
|
+
expect(input.length).toBeGreaterThan(100)
|
|
516
|
+
|
|
517
|
+
const calls: Array<{ rule: string; ratio: number }> = []
|
|
518
|
+
const out = stripExcessBold(input, (d) => calls.push(d))
|
|
519
|
+
|
|
520
|
+
// Outcome 1: no bold survives — this is the assertion that fails on the
|
|
521
|
+
// real bug, where `out` was the input verbatim.
|
|
522
|
+
expect(out).not.toContain('**')
|
|
523
|
+
expect(out).toContain('Deploy status')
|
|
524
|
+
expect(out).toContain('Next steps:')
|
|
525
|
+
// Outcome 2: the code block itself is preserved byte-for-byte.
|
|
526
|
+
expect(out).toContain(code)
|
|
527
|
+
expect(out).toBe(input.replace(/\*\*/g, ''))
|
|
528
|
+
// Outcome 3: the strip is logged (the #4017 silence is what made this
|
|
529
|
+
// class of bug invisible in the first place).
|
|
530
|
+
expect(calls).toHaveLength(1)
|
|
531
|
+
expect(calls[0].rule).toBe('global')
|
|
532
|
+
expect(calls[0].ratio).toBeGreaterThan(0.3)
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
test('all-heading guard: real prose body CONTAINING inline code keeps headings', () => {
|
|
536
|
+
// The counterpart that stops the #4114 fix from over-correcting: a block
|
|
537
|
+
// with visible text around its code span IS body, so the headings keep
|
|
538
|
+
// their bold. Only blocks that are *nothing but* code are skipped.
|
|
539
|
+
const input = [
|
|
540
|
+
...HEADINGS,
|
|
541
|
+
'Run `switchroom agent restart klanker` now, then check the gateway log for errors.',
|
|
542
|
+
...TAIL,
|
|
543
|
+
].join('\n\n')
|
|
544
|
+
|
|
545
|
+
const calls: Array<{ rule: string; ratio: number }> = []
|
|
546
|
+
const out = stripExcessBold(input, (d) => calls.push(d))
|
|
547
|
+
|
|
548
|
+
expect(out).toContain('**Deploy status**')
|
|
549
|
+
expect(out).toContain('**Next steps:**')
|
|
550
|
+
expect(calls).toHaveLength(0)
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
test('all-heading guard: a code-only block does not rescue a headings-only digest', () => {
|
|
554
|
+
// Degenerate shape: the code block is the ONLY non-heading block and sits
|
|
555
|
+
// last. Same verdict, so the rule does not depend on block position.
|
|
556
|
+
const input = [...HEADINGS, ...TAIL, '```\nok\n```'].join('\n\n')
|
|
557
|
+
const out = stripExcessBold(input)
|
|
558
|
+
expect(out).not.toContain('**')
|
|
559
|
+
expect(out).toContain('```\nok\n```')
|
|
414
560
|
})
|
|
415
561
|
|
|
416
562
|
test('multi-line fully-bolded block is NOT mislabelled a heading (global)', () => {
|
|
@@ -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 }))
|