switchroom 0.18.19 → 0.18.21
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/ms-365-write-pretool.mjs +92 -20
- package/dist/cli/switchroom.js +59 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
- package/profiles/_shared/dev-protocol.md.hbs +2 -0
- package/profiles/_shared/execution-discipline.md.hbs +2 -2
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1114 -184
- package/telegram-plugin/format.ts +179 -20
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +794 -106
- package/telegram-plugin/gateway/idle-clear.ts +170 -0
- package/telegram-plugin/gateway/inject-handler.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
- package/telegram-plugin/gateway/turn-record-status.ts +134 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
- package/telegram-plugin/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/registry/subagents-schema.ts +6 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -5
- package/telegram-plugin/subagent-watcher.ts +330 -82
- package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
- package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
- package/telegram-plugin/tests/format-consistency.test.ts +54 -34
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
- package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
- package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
- package/telegram-plugin/tests/silent-end.test.ts +296 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
- package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
- package/telegram-plugin/tests/telegram-format.test.ts +36 -23
- package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
- package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
- package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +165 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +4 -4
- package/telegram-plugin/worker-activity-feed.ts +181 -30
|
@@ -137,6 +137,62 @@ describe('scanTurnForFinalReply — final-reply detection', () => {
|
|
|
137
137
|
expect(r.reason).toBe('no-final-reply')
|
|
138
138
|
})
|
|
139
139
|
|
|
140
|
+
it('Option A: block result carries pendingText = the undelivered answer prose (#3227)', () => {
|
|
141
|
+
// The transcript-prose bridge: a turn that ends with a substantive answer
|
|
142
|
+
// written as plain text (no reply tool) must surface that prose so the
|
|
143
|
+
// gateway can deliver it directly on the first silent-end.
|
|
144
|
+
const answer = 'That was actually your FY25 NOA, not Bloomfield. ' + 'A'.repeat(2200)
|
|
145
|
+
const text = jsonl(
|
|
146
|
+
ENQUEUE,
|
|
147
|
+
assistantToolUse('mcp__switchroom-telegram__reply', {
|
|
148
|
+
text: "On it — checking…",
|
|
149
|
+
disable_notification: true,
|
|
150
|
+
}),
|
|
151
|
+
assistantToolUse('Bash', { command: 'ls' }),
|
|
152
|
+
assistantText(answer),
|
|
153
|
+
)
|
|
154
|
+
const r = scanTurnForFinalReply(text)
|
|
155
|
+
expect(r.decided).toBe('block')
|
|
156
|
+
expect(r.reason).toBe('no-final-reply')
|
|
157
|
+
expect(r.pendingText).toBe(answer)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('Option A: zero-outbound turn with a long plain-text answer → pendingText set', () => {
|
|
161
|
+
const answer = 'Here is the whole answer the model forgot to send. ' + 'Z'.repeat(400)
|
|
162
|
+
const text = jsonl(ENQUEUE, assistantText(answer))
|
|
163
|
+
const r = scanTurnForFinalReply(text)
|
|
164
|
+
expect(r.decided).toBe('block')
|
|
165
|
+
expect(r.reason).toBe('no-final-reply')
|
|
166
|
+
expect(r.pendingText).toBe(answer)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('Option A: trailing-text-after-reply block carries only the trailing prose (#3227)', () => {
|
|
170
|
+
const trailing = 'The real verdict the model wrote but never re-sent. ' + 'T'.repeat(400)
|
|
171
|
+
const text = jsonl(
|
|
172
|
+
ENQUEUE,
|
|
173
|
+
assistantText('some narration before the delivered answer'),
|
|
174
|
+
assistantToolUse('mcp__switchroom-telegram__reply', {
|
|
175
|
+
text: 'delivered answer, notification-bearing',
|
|
176
|
+
disable_notification: false,
|
|
177
|
+
}),
|
|
178
|
+
assistantText(trailing),
|
|
179
|
+
)
|
|
180
|
+
const r = scanTurnForFinalReply(text)
|
|
181
|
+
expect(r.decided).toBe('block')
|
|
182
|
+
expect(r.reason).toBe('trailing-text-after-reply')
|
|
183
|
+
// Only the prose AFTER the last delivery event — not the pre-reply narration.
|
|
184
|
+
expect(r.pendingText).toBe(trailing)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('Option A: a SHORT trailing/only fragment does NOT set pendingText (substance floor)', () => {
|
|
188
|
+
// A genuinely empty-ish turn: a short plain-text closer under the 200-char
|
|
189
|
+
// floor must not be re-delivered as if it were the answer.
|
|
190
|
+
const text = jsonl(ENQUEUE, assistantText('ok done, let me know if you need anything else'))
|
|
191
|
+
const r = scanTurnForFinalReply(text)
|
|
192
|
+
expect(r.decided).toBe('block')
|
|
193
|
+
expect(r.pendingText).toBeUndefined()
|
|
194
|
+
})
|
|
195
|
+
|
|
140
196
|
it('notification-bearing reply → allow', () => {
|
|
141
197
|
const text = jsonl(
|
|
142
198
|
ENQUEUE,
|
|
@@ -322,6 +378,144 @@ it('early qualifying reply + trailing SUBSTANTIVE undelivered text (≥ floor)
|
|
|
322
378
|
})
|
|
323
379
|
})
|
|
324
380
|
|
|
381
|
+
// ── Finding 2 (#3228): concatenated inter-tool narration must NOT masquerade
|
|
382
|
+
// as a final answer via the joined-prose floor ───────────────────────────
|
|
383
|
+
|
|
384
|
+
describe('scanTurnForFinalReply — pendingText is a single substantive block, not joined narration (#3228 Finding 2)', () => {
|
|
385
|
+
// Each narration block is well under FINAL_ANSWER_MIN_CHARS (200) on its own.
|
|
386
|
+
// Pre-fix the scan JOINED every post-delivery text block and surfaced the
|
|
387
|
+
// join whenever the COMBINED length hit the floor — so a run of short
|
|
388
|
+
// "Let me check…" / "Still querying…" narration crossed 200 and was delivered
|
|
389
|
+
// as if it were the answer. Post-fix only a single block that clears the floor
|
|
390
|
+
// ON ITS OWN becomes pendingText.
|
|
391
|
+
const NARRATION_A = 'Let me check the first data source now — pulling the records and scanning for the relevant rows.' // ~95
|
|
392
|
+
const NARRATION_B = 'Still querying; the second source is slower than expected, so hang tight while it finishes loading.' // ~98
|
|
393
|
+
const NARRATION_C = 'Almost there, cross-referencing the last set of figures against the ledger before I summarise it.' // ~96
|
|
394
|
+
|
|
395
|
+
it('zero-delivery turn of only short narration blocks → block WITHOUT pendingText (fails on the old joined-floor code)', () => {
|
|
396
|
+
// Combined length of the three blocks is ≥ 200, so the OLD code would join
|
|
397
|
+
// them and set pendingText (masquerade). The NEW code sets nothing because
|
|
398
|
+
// no single block clears the floor.
|
|
399
|
+
expect((NARRATION_A + '\n\n' + NARRATION_B + '\n\n' + NARRATION_C).length)
|
|
400
|
+
.toBeGreaterThanOrEqual(200)
|
|
401
|
+
const text = jsonl(
|
|
402
|
+
ENQUEUE,
|
|
403
|
+
assistantText(NARRATION_A),
|
|
404
|
+
assistantToolUse('Bash', { command: 'ls' }),
|
|
405
|
+
assistantText(NARRATION_B),
|
|
406
|
+
assistantToolUse('Read', { file_path: '/tmp/x' }),
|
|
407
|
+
assistantText(NARRATION_C),
|
|
408
|
+
)
|
|
409
|
+
const r = scanTurnForFinalReply(text)
|
|
410
|
+
expect(r.decided).toBe('block')
|
|
411
|
+
expect(r.reason).toBe('no-final-reply')
|
|
412
|
+
// The masquerade must NOT happen — no answer to deliver, take the re-prompt.
|
|
413
|
+
expect(r.pendingText).toBeUndefined()
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('trailing narration after a delivered reply, all sub-floor → block only if a real ≥floor block exists; here → allow, no pendingText', () => {
|
|
417
|
+
// Each trailing block is sub-floor, so `sawUndeliveredTextAfterAllow` is
|
|
418
|
+
// false → allow. (The old joined-floor logic never affected the block
|
|
419
|
+
// decision here, only pendingText; this pins the two stay consistent.)
|
|
420
|
+
const text = jsonl(
|
|
421
|
+
ENQUEUE,
|
|
422
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ok', disable_notification: false }),
|
|
423
|
+
assistantText(NARRATION_A),
|
|
424
|
+
assistantText(NARRATION_B),
|
|
425
|
+
)
|
|
426
|
+
const r = scanTurnForFinalReply(text)
|
|
427
|
+
expect(r.decided).toBe('allow')
|
|
428
|
+
expect(r.pendingText).toBeUndefined()
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('a genuine ≥floor answer followed by a SHORT closer → pendingText is the answer, not the closer', () => {
|
|
432
|
+
// "big answer then short closer" — the LAST substantive (≥floor) block is
|
|
433
|
+
// the answer; the trailing short closer must not displace it.
|
|
434
|
+
const answer = 'Here is the real answer you were waiting for: ' + 'A'.repeat(300)
|
|
435
|
+
const text = jsonl(
|
|
436
|
+
ENQUEUE,
|
|
437
|
+
assistantText(answer),
|
|
438
|
+
assistantText('Let me know if you need anything else.'),
|
|
439
|
+
)
|
|
440
|
+
const r = scanTurnForFinalReply(text)
|
|
441
|
+
expect(r.decided).toBe('block')
|
|
442
|
+
expect(r.pendingText).toBe(answer)
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('substance-floor boundary: a single trailing block at 199/200/201 chars', () => {
|
|
446
|
+
const at = (n: number) => {
|
|
447
|
+
const text = jsonl(ENQUEUE, assistantText('X'.repeat(n)))
|
|
448
|
+
return scanTurnForFinalReply(text)
|
|
449
|
+
}
|
|
450
|
+
// 199 → under floor, no pendingText (block still fires for the zero-delivery
|
|
451
|
+
// turn, but there is nothing substantive to deliver).
|
|
452
|
+
expect(at(199).pendingText).toBeUndefined()
|
|
453
|
+
// 200 → exactly the floor, delivered.
|
|
454
|
+
expect(at(200).pendingText).toBe('X'.repeat(200))
|
|
455
|
+
// 201 → over floor, delivered.
|
|
456
|
+
expect(at(201).pendingText).toBe('X'.repeat(201))
|
|
457
|
+
})
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
// ── Finding 3 (#3228): the block result carries a per-turn nonce (turnId) ────
|
|
461
|
+
|
|
462
|
+
describe('scanTurnForFinalReply — per-turn nonce in block result (#3228 Finding 3)', () => {
|
|
463
|
+
it('block carries turnId = ${chatKey}#${messageId} derived from the enqueue envelope', () => {
|
|
464
|
+
const enq = JSON.stringify({
|
|
465
|
+
type: 'queue-operation',
|
|
466
|
+
operation: 'enqueue',
|
|
467
|
+
content: '<channel source="switchroom-telegram" chat_id="abc" message_thread_id="42" message_id="9">hi</channel>',
|
|
468
|
+
})
|
|
469
|
+
const text = jsonl(
|
|
470
|
+
enq,
|
|
471
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ack', disable_notification: true }),
|
|
472
|
+
)
|
|
473
|
+
const r = scanTurnForFinalReply(text)
|
|
474
|
+
expect(r.decided).toBe('block')
|
|
475
|
+
expect(r.turnKey).toBe('abc:42')
|
|
476
|
+
// Matches gateway deriveTurnId: `${chatKey(chatId, threadId)}#${messageId}`.
|
|
477
|
+
expect(r.turnId).toBe('abc:42#9')
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it("DM (no thread) still derives the turnId with the '_' sentinel", () => {
|
|
481
|
+
// ENQUEUE has chat_id="111" message_id="42", no thread.
|
|
482
|
+
const text = jsonl(
|
|
483
|
+
ENQUEUE,
|
|
484
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ack', disable_notification: true }),
|
|
485
|
+
)
|
|
486
|
+
const r = scanTurnForFinalReply(text)
|
|
487
|
+
expect(r.turnKey).toBe('111:_')
|
|
488
|
+
expect(r.turnId).toBe('111:_#42')
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('no derivable message_id → turnId omitted (gateway falls back to turnKey match)', () => {
|
|
492
|
+
const enq = JSON.stringify({
|
|
493
|
+
type: 'queue-operation',
|
|
494
|
+
operation: 'enqueue',
|
|
495
|
+
content: '<channel source="subagent_handback" chat_id="c7">work is done</channel>',
|
|
496
|
+
})
|
|
497
|
+
const text = jsonl(enq, assistantText('a plain-text answer never sent ' + 'Q'.repeat(300)))
|
|
498
|
+
const r = scanTurnForFinalReply(text)
|
|
499
|
+
expect(r.decided).toBe('block')
|
|
500
|
+
expect(r.turnKey).toBe('c7:_')
|
|
501
|
+
expect(r.turnId).toBeUndefined()
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('a same-suffix sibling attribute (target_message_id) does NOT leak into turnId', () => {
|
|
505
|
+
// Left-anchored match: `target_message_id` must not be mistaken for the
|
|
506
|
+
// real `message_id`. Envelope carries the sibling FIRST to prove ordering
|
|
507
|
+
// does not confuse the parser.
|
|
508
|
+
const enq = JSON.stringify({
|
|
509
|
+
type: 'queue-operation',
|
|
510
|
+
operation: 'enqueue',
|
|
511
|
+
content: '<channel source="reaction" target_message_id="777" chat_id="c" message_id="5">x</channel>',
|
|
512
|
+
})
|
|
513
|
+
const text = jsonl(enq, assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ack', disable_notification: true }))
|
|
514
|
+
const r = scanTurnForFinalReply(text)
|
|
515
|
+
expect(r.turnId).toBe('c:_#5')
|
|
516
|
+
})
|
|
517
|
+
})
|
|
518
|
+
|
|
325
519
|
describe('scanTurnForFinalReply — silent-marker carve-out', () => {
|
|
326
520
|
it('NO_REPLY → allow', () => {
|
|
327
521
|
const text = jsonl(
|
|
@@ -9,10 +9,14 @@ import {
|
|
|
9
9
|
readSilentEndState,
|
|
10
10
|
recordSilentTurnEnd,
|
|
11
11
|
recordUndeliveredTurnEnd,
|
|
12
|
+
decideCapturedProseDelivery,
|
|
13
|
+
settleCapturedProseDelivery,
|
|
14
|
+
CAPTURED_PROSE_MIN_CHARS,
|
|
12
15
|
SILENT_END_MAX_RETRIES,
|
|
13
16
|
SILENT_END_STALE_RECORD_MAX_AGE_MS,
|
|
14
17
|
} from '../silent-end.js'
|
|
15
18
|
import { isFinalAnswerReply } from '../final-answer-detect.js'
|
|
19
|
+
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
16
20
|
|
|
17
21
|
let stateDir: string
|
|
18
22
|
const ORIG_ENV = process.env.TELEGRAM_STATE_DIR
|
|
@@ -683,4 +687,296 @@ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan
|
|
|
683
687
|
const r = runHook({}) // serialised as `{}` — but the hook also tolerates empty
|
|
684
688
|
expect(r.exit).toBe(0)
|
|
685
689
|
})
|
|
690
|
+
|
|
691
|
+
// ── Option A transcript-prose bridge (#3227) — end-to-end scan → hook →
|
|
692
|
+
// state → gateway decision. Proves the gateway would deliver the model's
|
|
693
|
+
// real answer directly on the FIRST silent-end.
|
|
694
|
+
describe('captured-prose delivery bridge (#3227)', () => {
|
|
695
|
+
const ANSWER = 'That was actually your FY25 NOA, not Bloomfield. ' + 'A'.repeat(2200)
|
|
696
|
+
|
|
697
|
+
it('FIRST silent-end with a genuine plain-text answer → hook persists pendingText and decide → deliver', () => {
|
|
698
|
+
// A turn that ended with a substantive answer written as plain text
|
|
699
|
+
// (never sent via the reply tool). The Stop hook scans, blocks, and now
|
|
700
|
+
// persists the answer prose into silent-end-pending.json.
|
|
701
|
+
const transcript = writeTranscript([
|
|
702
|
+
ENQUEUE,
|
|
703
|
+
replyToolUse('on it — checking now', { disable_notification: true }),
|
|
704
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: {} }] } },
|
|
705
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: ANSWER }] } },
|
|
706
|
+
])
|
|
707
|
+
const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
|
|
708
|
+
expect(r.exit).toBe(0)
|
|
709
|
+
expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
|
|
710
|
+
|
|
711
|
+
// The bridge: the hook wrote the answer prose into the state file...
|
|
712
|
+
const state = readSilentEndState()!
|
|
713
|
+
expect(state.pendingText).toBe(ANSWER)
|
|
714
|
+
expect(state.turnKey).toBe('c:_')
|
|
715
|
+
|
|
716
|
+
// ...and the gateway's pure decision core reads it back and decides to
|
|
717
|
+
// deliver directly on this FIRST silent-end (retryCount just went to 1).
|
|
718
|
+
const decision = decideCapturedProseDelivery({ turnKey: 'c:_' })
|
|
719
|
+
expect(decision.deliver).toBe(true)
|
|
720
|
+
expect(decision.text).toBe(ANSWER)
|
|
721
|
+
expect(decision.reason).toBe('captured-prose')
|
|
722
|
+
// FIRST silent-end: this is the retry-budget's first block, not exhaustion.
|
|
723
|
+
expect(state.retryCount).toBe(1)
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
it('a turn that DID call a final reply → no state, no pendingText, decide → no synthetic send', () => {
|
|
727
|
+
const transcript = writeTranscript([
|
|
728
|
+
ENQUEUE,
|
|
729
|
+
replyToolUse('here is the answer, notification-bearing', { disable_notification: false }),
|
|
730
|
+
])
|
|
731
|
+
const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
|
|
732
|
+
expect(r.stdout.trim()).toBe('')
|
|
733
|
+
expect(readSilentEndState()).toBeNull()
|
|
734
|
+
const decision = decideCapturedProseDelivery({ turnKey: 'c:_' })
|
|
735
|
+
expect(decision.deliver).toBe(false)
|
|
736
|
+
expect(decision.reason).toBe('no-state')
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
it('a genuinely empty turn (no substantive prose) → hook blocks WITHOUT pendingText → decide falls through', () => {
|
|
740
|
+
// Short closer under the substance floor: not a dropped answer. The hook
|
|
741
|
+
// still blocks (re-prompt), but writes NO pendingText, so the gateway
|
|
742
|
+
// takes the existing recordUndeliveredTurnEnd / represent path unchanged.
|
|
743
|
+
const transcript = writeTranscript([
|
|
744
|
+
ENQUEUE,
|
|
745
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'ok done' }] } },
|
|
746
|
+
])
|
|
747
|
+
const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
|
|
748
|
+
expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
|
|
749
|
+
const state = readSilentEndState()!
|
|
750
|
+
expect(state.pendingText).toBeUndefined()
|
|
751
|
+
const decision = decideCapturedProseDelivery({ turnKey: 'c:_' })
|
|
752
|
+
expect(decision.deliver).toBe(false)
|
|
753
|
+
expect(decision.reason).toBe('no-substantive-prose')
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
it('a stale pendingText for a DIFFERENT turn is never delivered (turnKey guard)', () => {
|
|
757
|
+
const path = join(stateDir, 'silent-end-pending.json')
|
|
758
|
+
mkdirSync(stateDir, { recursive: true })
|
|
759
|
+
writeFileSync(path, JSON.stringify({
|
|
760
|
+
chatId: 'c', threadId: null, turnKey: 'OTHER:_',
|
|
761
|
+
retryCount: 1, timestamp: Date.now(), pendingText: ANSWER,
|
|
762
|
+
}))
|
|
763
|
+
const decision = decideCapturedProseDelivery({ turnKey: 'c:_' })
|
|
764
|
+
expect(decision.deliver).toBe(false)
|
|
765
|
+
expect(decision.reason).toBe('turnkey-mismatch')
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
it('double-send guard: once the captured prose is recorded in the dedup cache, a late reply-tool retry is suppressed', () => {
|
|
769
|
+
// The gateway records what it delivered in the #546 dedup cache. This is
|
|
770
|
+
// the airtight guard: the re-prompted model's reply (same answer) hits
|
|
771
|
+
// the same cache at its send site and is dropped — the represent + the
|
|
772
|
+
// captured-prose delivery are mutually exclusive by construction.
|
|
773
|
+
const dedup = new OutboundDedupCache()
|
|
774
|
+
const t0 = 1_000_000
|
|
775
|
+
// Gateway captured-prose delivery records the answer.
|
|
776
|
+
dedup.record('c', undefined, ANSWER, t0, 'reg-1')
|
|
777
|
+
// Model re-prompt then calls reply with the same answer moments later.
|
|
778
|
+
const hit = dedup.check('c', undefined, ANSWER, t0 + 5_000, 'reg-1')
|
|
779
|
+
expect(hit).not.toBeNull()
|
|
780
|
+
// A genuinely different later answer is NOT suppressed.
|
|
781
|
+
const miss = dedup.check('c', undefined, 'a completely different answer ' + 'X'.repeat(50), t0 + 5_000, 'reg-1')
|
|
782
|
+
expect(miss).toBeNull()
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
it('CAPTURED_PROSE_MIN_CHARS matches the scan/final-answer substance floor', () => {
|
|
786
|
+
expect(CAPTURED_PROSE_MIN_CHARS).toBe(200)
|
|
787
|
+
})
|
|
788
|
+
})
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
// ── Finding 3 (#3228): per-turn nonce guards decideCapturedProseDelivery ─────
|
|
792
|
+
|
|
793
|
+
describe('decideCapturedProseDelivery — per-turn nonce (turnId) guard (#3228 Finding 3)', () => {
|
|
794
|
+
const ANSWER = 'A recovered final answer written as plain text. ' + 'A'.repeat(300)
|
|
795
|
+
const statePath = () => join(stateDir, 'silent-end-pending.json')
|
|
796
|
+
function writeState(partial: Record<string, unknown>) {
|
|
797
|
+
mkdirSync(stateDir, { recursive: true })
|
|
798
|
+
writeFileSync(statePath(), JSON.stringify({
|
|
799
|
+
chatId: 'c', threadId: null, turnKey: 'c:_', retryCount: 1, timestamp: Date.now(),
|
|
800
|
+
pendingText: ANSWER, ...partial,
|
|
801
|
+
}))
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
it('turnKey matches AND turnId matches → deliver', () => {
|
|
805
|
+
writeState({ turnId: 'c:_#100' })
|
|
806
|
+
const d = decideCapturedProseDelivery({ turnKey: 'c:_', turnId: 'c:_#100' })
|
|
807
|
+
expect(d.deliver).toBe(true)
|
|
808
|
+
expect(d.text).toBe(ANSWER)
|
|
809
|
+
expect(d.reason).toBe('captured-prose')
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
it('STALE record: same chat turnKey but a DIFFERENT turnId → NOT delivered (the carryover the finding describes)', () => {
|
|
813
|
+
// Turn A wrote pendingText+turnId=A. Turn B (same chat → same turnKey)
|
|
814
|
+
// ends in reprompt with its OWN turnId=B. The stale answer must not deliver.
|
|
815
|
+
writeState({ turnId: 'c:_#AAA' }) // turn A's record survived (hook overwrite bypassed)
|
|
816
|
+
const d = decideCapturedProseDelivery({ turnKey: 'c:_', turnId: 'c:_#BBB' })
|
|
817
|
+
expect(d.deliver).toBe(false)
|
|
818
|
+
expect(d.reason).toBe('turnid-mismatch')
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
it('record has NO turnId (synthetic inbound) → falls back to turnKey match, delivers', () => {
|
|
822
|
+
writeState({}) // no turnId field
|
|
823
|
+
const d = decideCapturedProseDelivery({ turnKey: 'c:_', turnId: 'c:_#123' })
|
|
824
|
+
expect(d.deliver).toBe(true)
|
|
825
|
+
expect(d.reason).toBe('captured-prose')
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
it('caller passes no turnId → falls back to turnKey match, delivers (never suppress on doubt)', () => {
|
|
829
|
+
writeState({ turnId: 'c:_#100' })
|
|
830
|
+
const d = decideCapturedProseDelivery({ turnKey: 'c:_' })
|
|
831
|
+
expect(d.deliver).toBe(true)
|
|
832
|
+
expect(d.reason).toBe('captured-prose')
|
|
833
|
+
})
|
|
834
|
+
|
|
835
|
+
it('substance-floor boundary at 199/200/201 (min-chars gate is unchanged by the nonce)', () => {
|
|
836
|
+
const check = (n: number) => {
|
|
837
|
+
writeState({ turnId: 'c:_#100', pendingText: 'X'.repeat(n) })
|
|
838
|
+
return decideCapturedProseDelivery({ turnKey: 'c:_', turnId: 'c:_#100' })
|
|
839
|
+
}
|
|
840
|
+
expect(check(199).deliver).toBe(false)
|
|
841
|
+
expect(check(199).reason).toBe('no-substantive-prose')
|
|
842
|
+
expect(check(200).deliver).toBe(true)
|
|
843
|
+
expect(check(201).deliver).toBe(true)
|
|
844
|
+
})
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
// ── Finding 1 (#3228): captured-prose send-failure arms the recovery net ─────
|
|
848
|
+
|
|
849
|
+
describe('settleCapturedProseDelivery — send-failure posture (#3228 Finding 1)', () => {
|
|
850
|
+
function spies(exhausted = false) {
|
|
851
|
+
const calls = { close: 0, clear: 0, undelivered: 0 }
|
|
852
|
+
const effects = {
|
|
853
|
+
closeObligation: () => { calls.close++ },
|
|
854
|
+
clearState: () => { calls.clear++ },
|
|
855
|
+
recordUndelivered: () => { calls.undelivered++; return { exhausted } },
|
|
856
|
+
}
|
|
857
|
+
return { calls, effects }
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
it("outcome 'failed' → arms the Stop-hook re-prompt net; does NOT close obligation or clear state", () => {
|
|
861
|
+
// THIS is the regression fix: in the interim-ack case the shared teardown
|
|
862
|
+
// has already closed the obligation (replyCalled=true), so "leave it open"
|
|
863
|
+
// is a myth — the net MUST be recordUndelivered. A pre-fix deliverCapturedProse
|
|
864
|
+
// catch that merely `return`ed (no recordUndelivered) would leave calls
|
|
865
|
+
// all-zero here → the answer is permanently lost.
|
|
866
|
+
const { calls, effects } = spies()
|
|
867
|
+
const r = settleCapturedProseDelivery('failed', effects)
|
|
868
|
+
expect(calls.undelivered).toBe(1)
|
|
869
|
+
expect(calls.close).toBe(0)
|
|
870
|
+
expect(calls.clear).toBe(0)
|
|
871
|
+
expect(r.exhausted).toBe(false)
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
it("outcome 'failed' with budget SPENT → threads exhausted:true out so the caller can fire the fallback (#3228 exhaustion-boundary)", () => {
|
|
875
|
+
// The exhaustion-boundary gap: recordUndeliveredTurnEnd took its exhausted
|
|
876
|
+
// branch (cleared state, returned exhausted:true). The settlement MUST
|
|
877
|
+
// surface that so the gateway delivers the plain-text/apology fallback —
|
|
878
|
+
// the obligation was already closed by the interim-ack teardown, so the
|
|
879
|
+
// re-prompt is gone AND the represent is gone. Pre-fix the return type was
|
|
880
|
+
// void, so this signal could not exist and the fallback never fired.
|
|
881
|
+
const { calls, effects } = spies(true)
|
|
882
|
+
const r = settleCapturedProseDelivery('failed', effects)
|
|
883
|
+
expect(calls.undelivered).toBe(1)
|
|
884
|
+
expect(r.exhausted).toBe(true)
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
it("outcome 'sent' → closes obligation + clears state; does NOT re-arm the re-prompt; exhausted:false", () => {
|
|
888
|
+
const { calls, effects } = spies()
|
|
889
|
+
const r = settleCapturedProseDelivery('sent', effects)
|
|
890
|
+
expect(calls.close).toBe(1)
|
|
891
|
+
expect(calls.clear).toBe(1)
|
|
892
|
+
expect(calls.undelivered).toBe(0)
|
|
893
|
+
expect(r.exhausted).toBe(false)
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
it("outcome 'skipped-dedup' → closes obligation + clears state (answer already went out); exhausted:false", () => {
|
|
897
|
+
const { calls, effects } = spies()
|
|
898
|
+
const r = settleCapturedProseDelivery('skipped-dedup', effects)
|
|
899
|
+
expect(calls.close).toBe(1)
|
|
900
|
+
expect(calls.clear).toBe(1)
|
|
901
|
+
expect(calls.undelivered).toBe(0)
|
|
902
|
+
expect(r.exhausted).toBe(false)
|
|
903
|
+
})
|
|
904
|
+
|
|
905
|
+
it('end-to-end failure net (budget remaining): a failed send leaves an undelivered-turn record armed, exhausted:false', () => {
|
|
906
|
+
// Simulate the gateway's `failed` settlement wiring against the REAL
|
|
907
|
+
// recordUndeliveredTurnEnd effect. The interim-ack teardown already closed
|
|
908
|
+
// the obligation; only the silent-end state can recover the turn now.
|
|
909
|
+
expect(readSilentEndState()).toBeNull()
|
|
910
|
+
const r = settleCapturedProseDelivery('failed', {
|
|
911
|
+
closeObligation: () => { throw new Error('must not be called on failure') },
|
|
912
|
+
clearState: () => { throw new Error('must not be called on failure') },
|
|
913
|
+
recordUndelivered: () =>
|
|
914
|
+
recordUndeliveredTurnEnd({ chatId: 'c', threadId: null, turnKey: 'c:_' }),
|
|
915
|
+
})
|
|
916
|
+
// The recovery net is armed: the Stop hook will find this and re-prompt.
|
|
917
|
+
expect(readSilentEndState()).toMatchObject({ turnKey: 'c:_' })
|
|
918
|
+
expect(r.exhausted).toBe(false)
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
it('end-to-end exhaustion boundary: a failed send AT the retry cap returns exhausted:true and clears state (the gateway must then fire the fallback)', () => {
|
|
922
|
+
// On-disk record already at the cap (the Stop hook blocked twice; the
|
|
923
|
+
// re-prompted turn's captured-prose send is now THROWING). The REAL
|
|
924
|
+
// recordUndeliveredTurnEnd takes its exhausted branch: clears state,
|
|
925
|
+
// returns exhausted:true. Pin that the settlement surfaces it — this is the
|
|
926
|
+
// signal the gateway uses to fire the plain-text/apology fallback so the
|
|
927
|
+
// user is never left with neither the answer nor the apology.
|
|
928
|
+
const path = join(stateDir, 'silent-end-pending.json')
|
|
929
|
+
mkdirSync(stateDir, { recursive: true })
|
|
930
|
+
writeFileSync(path, JSON.stringify({
|
|
931
|
+
chatId: 'c', threadId: null, turnKey: 'c:_',
|
|
932
|
+
retryCount: SILENT_END_MAX_RETRIES, timestamp: Date.now(),
|
|
933
|
+
}))
|
|
934
|
+
const r = settleCapturedProseDelivery('failed', {
|
|
935
|
+
closeObligation: () => { throw new Error('must not be called on failure') },
|
|
936
|
+
clearState: () => { throw new Error('must not be called on failure') },
|
|
937
|
+
recordUndelivered: () =>
|
|
938
|
+
recordUndeliveredTurnEnd({ chatId: 'c', threadId: null, turnKey: 'c:_' }),
|
|
939
|
+
})
|
|
940
|
+
expect(r.exhausted).toBe(true)
|
|
941
|
+
// recordUndeliveredTurnEnd's exhausted branch cleared the state — the
|
|
942
|
+
// Stop-hook re-prompt can no longer recover, so the caller owns delivery.
|
|
943
|
+
expect(readSilentEndState()).toBeNull()
|
|
944
|
+
})
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
// ── Finding 4/5 (#3228): honest documentation of the dedup divergence boundary ─
|
|
948
|
+
|
|
949
|
+
describe('recent-outbound-dedup — divergent-content double-send boundary (#3228 Finding 4/5)', () => {
|
|
950
|
+
it('divergent reply text vs captured prose under a different registryKey → NOT deduped (a real double-send vector)', () => {
|
|
951
|
+
// Documents the boundary the "airtight by construction" claim overstated:
|
|
952
|
+
// captured-prose records content C under registryKey K1. A re-present that
|
|
953
|
+
// lands REWORDED content C' under a DIFFERENT registryKey K2 has both a
|
|
954
|
+
// different hash AND a different turnKey — the cross-turn carve-out treats
|
|
955
|
+
// it as a miss, so it is NOT suppressed. Content-hash dedup cannot catch a
|
|
956
|
+
// divergent-content re-send; the obligation close + state clear are what
|
|
957
|
+
// make them mutually exclusive for the common case.
|
|
958
|
+
const dedup = new OutboundDedupCache()
|
|
959
|
+
const t0 = 1_000_000
|
|
960
|
+
const proseAnswer = 'The answer is 42, computed from the ledger. ' + 'A'.repeat(60)
|
|
961
|
+
dedup.record('c', undefined, proseAnswer, t0, 'reg-1')
|
|
962
|
+
|
|
963
|
+
// Same content, same key → suppressed (the common double-send).
|
|
964
|
+
expect(dedup.check('c', undefined, proseAnswer, t0 + 1_000, 'reg-1')).not.toBeNull()
|
|
965
|
+
|
|
966
|
+
// Reworded content under a different registryKey → NOT suppressed.
|
|
967
|
+
const reworded = 'To answer your question: it is 42 (per the ledger). ' + 'B'.repeat(60)
|
|
968
|
+
expect(dedup.check('c', undefined, reworded, t0 + 1_000, 'reg-2')).toBeNull()
|
|
969
|
+
})
|
|
970
|
+
|
|
971
|
+
it('identical content under two DIFFERENT non-null turnKeys → miss (cross-turn carve-out, documented boundary)', () => {
|
|
972
|
+
const dedup = new OutboundDedupCache()
|
|
973
|
+
const t0 = 2_000_000
|
|
974
|
+
const content = 'Identical answer text delivered in two separate turns. ' + 'C'.repeat(60)
|
|
975
|
+
dedup.record('c', undefined, content, t0, 'turnA')
|
|
976
|
+
// A later, genuinely different turn re-sending the SAME text is NOT a #546
|
|
977
|
+
// within-turn retry → not suppressed.
|
|
978
|
+
expect(dedup.check('c', undefined, content, t0 + 1_000, 'turnB')).toBeNull()
|
|
979
|
+
// But the SAME turnKey (a true within-turn retry) IS suppressed.
|
|
980
|
+
expect(dedup.check('c', undefined, content, t0 + 1_000, 'turnA')).not.toBeNull()
|
|
981
|
+
})
|
|
686
982
|
})
|
|
@@ -120,12 +120,13 @@ describe('handleStreamReply', () => {
|
|
|
120
120
|
expect(bot.api.sendMessage.mock.calls[0][2]?.parse_mode).toBeUndefined()
|
|
121
121
|
})
|
|
122
122
|
|
|
123
|
-
it('
|
|
124
|
-
// The NBSP paragraph-spacer pass was removed in the #2669 follow-up — the
|
|
125
|
-
// rich renderer shows a plain `\n\n` gap as one blank line, so the handler
|
|
126
|
-
// passes the text through unchanged.
|
|
123
|
+
it('applies addParagraphSpacers on the rich path (multi-paragraph gap spaced)', async () => {
|
|
127
124
|
const state = makeState()
|
|
128
|
-
|
|
125
|
+
// Spacer dep replaces every `\n\n` gap with a visible marker so we can
|
|
126
|
+
// assert the rich path ran it (mirrors the real gateway wiring).
|
|
127
|
+
const deps = makeDeps(bot, {
|
|
128
|
+
addParagraphSpacers: (t) => t.replace(/\n\n/g, '\n\nSPACER\n\n'),
|
|
129
|
+
})
|
|
129
130
|
|
|
130
131
|
const pending = handleStreamReply(
|
|
131
132
|
{ chat_id: '1', text: 'Para one.\n\nPara two.', done: true },
|
|
@@ -136,13 +137,14 @@ describe('handleStreamReply', () => {
|
|
|
136
137
|
await pending
|
|
137
138
|
|
|
138
139
|
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
139
|
-
expect(richSendMarkdown(bot)).toBe('Para one.\n\nPara two.')
|
|
140
|
-
expect(richSendMarkdown(bot)).not.toContain(String.fromCharCode(0xa0))
|
|
140
|
+
expect(richSendMarkdown(bot)).toBe('Para one.\n\nSPACER\n\nPara two.')
|
|
141
141
|
})
|
|
142
142
|
|
|
143
|
-
it('literal format=text path
|
|
143
|
+
it('does NOT apply addParagraphSpacers on the literal format=text path', async () => {
|
|
144
144
|
const state = makeState()
|
|
145
|
-
const deps = makeDeps(bot
|
|
145
|
+
const deps = makeDeps(bot, {
|
|
146
|
+
addParagraphSpacers: (t) => t.replace(/\n\n/g, '\n\nSPACER\n\n'),
|
|
147
|
+
})
|
|
146
148
|
|
|
147
149
|
const pending = handleStreamReply(
|
|
148
150
|
{ chat_id: '1', text: 'Para one.\n\nPara two.', format: 'text', done: true },
|
|
@@ -153,6 +155,7 @@ describe('handleStreamReply', () => {
|
|
|
153
155
|
await pending
|
|
154
156
|
|
|
155
157
|
expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
|
|
158
|
+
// Literal path is byte-exact — no spacer injected.
|
|
156
159
|
expect(bot.api.sendMessage.mock.calls[0][1]).toBe('Para one.\n\nPara two.')
|
|
157
160
|
})
|
|
158
161
|
|
|
@@ -264,6 +264,36 @@ describe('decideSubagentProgress', () => {
|
|
|
264
264
|
if (!d.deliver) expect(d.reason).toBe('foreground')
|
|
265
265
|
})
|
|
266
266
|
|
|
267
|
+
// #3233 — worker-feed-DISABLED legacy path: a contentless skeleton liveness
|
|
268
|
+
// cue must NOT be relayed as a synthesized "still working" inbound (that
|
|
269
|
+
// would be a blank card). It degrades to a no-op, deterministically, BEFORE
|
|
270
|
+
// bucketing so it can never advance the bucket tracker.
|
|
271
|
+
it('skeleton liveness cue is dropped (no blank card) even when every other gate would pass', () => {
|
|
272
|
+
// Same input that DELIVERS in the happy-path test above (bucket 1, chat
|
|
273
|
+
// resolves) — only `skeleton` flips it off. Empty summary mirrors the real
|
|
274
|
+
// skeleton cue.
|
|
275
|
+
const d = decideSubagentProgress(baseInput({ skeleton: true, latestSummary: '' }))
|
|
276
|
+
expect(d.deliver).toBe(false)
|
|
277
|
+
if (!d.deliver) expect(d.reason).toBe('skeleton-liveness')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('skeleton suppression fires before bucketing — a background skeleton at bucket>=1 never delivers', () => {
|
|
281
|
+
// ≤1 relay per interval is trivially satisfied: skeleton cues deliver ZERO
|
|
282
|
+
// inbounds regardless of how many no-growth polls fire within a bucket.
|
|
283
|
+
for (const elapsedMs of [7 * 60 * 1000, 8 * 60 * 1000, 9 * 60 * 1000]) {
|
|
284
|
+
const d = decideSubagentProgress(baseInput({ skeleton: true, latestSummary: '', elapsedMs }))
|
|
285
|
+
expect(d.deliver, `elapsedMs=${elapsedMs}`).toBe(false)
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('a NON-skeleton cue with identical inputs still delivers (guard is skeleton-scoped, not summary-scoped)', () => {
|
|
290
|
+
// Red-on-regression companion: proves the drop keys on `skeleton`, not on
|
|
291
|
+
// the empty summary — a real tool-only cue (empty prose summary) still
|
|
292
|
+
// delivers, so the guard cannot silently swallow genuine progress.
|
|
293
|
+
const d = decideSubagentProgress(baseInput({ skeleton: false, latestSummary: '' }))
|
|
294
|
+
expect(d.deliver).toBe(true)
|
|
295
|
+
})
|
|
296
|
+
|
|
267
297
|
it('falls back to owner chat when fleet chat is empty', () => {
|
|
268
298
|
const d = decideSubagentProgress(baseInput({ fleetChatId: '' }))
|
|
269
299
|
expect(d.deliver).toBe(true)
|