switchroom 0.18.24 → 0.18.26

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.
Files changed (54) hide show
  1. package/dist/cli/switchroom.js +59 -11
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +2 -2
  4. package/telegram-plugin/dist/bridge/bridge.js +26 -0
  5. package/telegram-plugin/dist/gateway/gateway.js +1827 -831
  6. package/telegram-plugin/dist/server.js +26 -0
  7. package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
  8. package/telegram-plugin/gateway/gateway.ts +314 -3
  9. package/telegram-plugin/gateway/model-command.ts +188 -56
  10. package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
  11. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
  12. package/telegram-plugin/history.ts +118 -0
  13. package/telegram-plugin/registry/turns-schema.ts +89 -1
  14. package/telegram-plugin/render/code-segments.ts +210 -0
  15. package/telegram-plugin/render/dollar-math-guard.ts +126 -0
  16. package/telegram-plugin/render/emphasis-guard.ts +158 -0
  17. package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
  18. package/telegram-plugin/render/line-start-guard.ts +167 -0
  19. package/telegram-plugin/render/rich-render.ts +7 -0
  20. package/telegram-plugin/rich-send.ts +48 -2
  21. package/telegram-plugin/session-tail.ts +185 -0
  22. package/telegram-plugin/subagent-watcher.ts +45 -0
  23. package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
  24. package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
  25. package/telegram-plugin/tests/history.test.ts +91 -0
  26. package/telegram-plugin/tests/model-command.test.ts +189 -12
  27. package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
  28. package/telegram-plugin/tests/registry-turns.test.ts +51 -0
  29. package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
  30. package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
  31. package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
  32. package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
  33. package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +11 -0
  35. package/telegram-plugin/tests/session-tail.test.ts +145 -0
  36. package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
  37. package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
  38. package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
  39. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
  40. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
  41. package/telegram-plugin/tool-activity-summary.ts +54 -3
  42. package/telegram-plugin/worker-activity-feed.ts +104 -0
  43. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
  44. package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
  45. package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
  46. package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
  47. package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
  48. package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
  49. package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
  50. package/vendor/hindsight-memory/scripts/retain.py +299 -143
  51. package/vendor/hindsight-memory/scripts/session_start.py +14 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
  54. package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
@@ -125,6 +125,19 @@ export interface WorkerEntry {
125
125
  lastActivityAt: number
126
126
  /** Number of tool calls seen so far. */
127
127
  toolCount: number
128
+ /**
129
+ * Running TOTAL tokens across every assistant message the worker has emitted
130
+ * (input + output + cache_creation, summed via sumUsageTokens; cache_read is
131
+ * excluded — replayed cached context, not new work).
132
+ * Accumulated from `sub_agent_usage` events, deduped by `seenUsageMessageIds`
133
+ * so the multi-line split-message shape (one logical message persisted as
134
+ * several JSONL lines sharing one `message.id` + identical `usage`) counts
135
+ * once. Rendered on the worker card's metrics line. 0 for a worker that never
136
+ * emitted a usage block (e.g. a non-Claude/litellm transcript).
137
+ */
138
+ totalTokens: number
139
+ /** message.id set already folded into `totalTokens` (usage dedup). */
140
+ seenUsageMessageIds: Set<string>
128
141
  /** True once a stall notification has been sent (suppresses repeat). */
129
142
  stallNotified: boolean
130
143
  /**
@@ -557,6 +570,9 @@ export interface SubagentWatcherConfig {
557
570
  state: WorkerState
558
571
  outcome: 'completed' | 'failed' | 'orphan'
559
572
  toolCount: number
573
+ /** Final running TOTAL tokens across the worker's whole life
574
+ * (`WorkerEntry.totalTokens`). For the terminal worker-feed card. */
575
+ totalTokens: number
560
576
  durationMs: number
561
577
  /** Dispatch-time task description, for the handback envelope. */
562
578
  description: string
@@ -615,6 +631,10 @@ export interface SubagentWatcherConfig {
615
631
  lastTool: { name: string; sanitisedArg: string } | null
616
632
  /** Tool-use count observed so far. */
617
633
  toolCount: number
634
+ /** Running TOTAL tokens across the worker's assistant messages so far
635
+ * (`WorkerEntry.totalTokens`). Threaded onto the worker/nested card's
636
+ * metrics line. 0 for a worker that has emitted no usage yet / ever. */
637
+ totalTokens: number
618
638
  /** Friendly display line for THIS tick. Set on `sub_agent_tool_use`
619
639
  * events to a `describeToolUse` label ("Reading X", "Running a
620
640
  * command") so a foreground sub-agent that runs tools without
@@ -1089,6 +1109,8 @@ export function readSubTail(
1089
1109
  lastTool: { name: string; sanitisedArg: string } | null
1090
1110
  /** Tool-use count observed so far. */
1091
1111
  toolCount: number
1112
+ /** Running total tokens so far (see SubagentWatcherConfig.onProgress). */
1113
+ totalTokens: number
1092
1114
  /** Friendly display line for THIS tick (set on tool ticks; see the
1093
1115
  * SubagentWatcherConfig.onProgress doc). */
1094
1116
  progressLine?: string
@@ -1179,6 +1201,7 @@ export function readSubTail(
1179
1201
  },
1180
1202
  lastTool: entry.lastTool,
1181
1203
  toolCount: entry.toolCount,
1204
+ totalTokens: entry.totalTokens,
1182
1205
  model: entry.currentModel,
1183
1206
  skeleton: true,
1184
1207
  })
@@ -1320,6 +1343,7 @@ export function readSubTail(
1320
1343
  },
1321
1344
  lastTool: entry.lastTool,
1322
1345
  toolCount: entry.toolCount,
1346
+ totalTokens: entry.totalTokens,
1323
1347
  model: entry.currentModel,
1324
1348
  })
1325
1349
  return true
@@ -1502,6 +1526,22 @@ export function readSubTail(
1502
1526
  }
1503
1527
  continue
1504
1528
  }
1529
+ if (ev.kind === 'sub_agent_usage') {
1530
+ // Accumulate the worker's running total tokens, deduped by
1531
+ // message.id: the ≥2.1.x split-message shape stamps the SAME `usage`
1532
+ // block on every JSONL line of one logical assistant message, so
1533
+ // counting each line would 2-3x over-count. A null messageId is
1534
+ // un-dedupable (older/edge shapes) — count it as-is (its usage is
1535
+ // real). No card render here; the total rides the next onProgress
1536
+ // tick's payload like the model does.
1537
+ if (ev.messageId == null) {
1538
+ entry.totalTokens += ev.totalTokens
1539
+ } else if (!entry.seenUsageMessageIds.has(ev.messageId)) {
1540
+ entry.seenUsageMessageIds.add(ev.messageId)
1541
+ entry.totalTokens += ev.totalTokens
1542
+ }
1543
+ continue
1544
+ }
1505
1545
  if (ev.kind === 'sub_agent_tool_use') {
1506
1546
  // Narrative-dedup gate step 2: a sub_agent_text block was pending;
1507
1547
  // this tool is the lookahead that decides it (SHOW unless it drafts
@@ -1567,6 +1607,7 @@ export function readSubTail(
1567
1607
  },
1568
1608
  lastTool: entry.lastTool,
1569
1609
  toolCount: entry.toolCount,
1610
+ totalTokens: entry.totalTokens,
1570
1611
  progressLine: toolLine,
1571
1612
  model: entry.currentModel,
1572
1613
  })
@@ -1892,6 +1933,8 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1892
1933
  dispatchedAt: n,
1893
1934
  lastActivityAt: n,
1894
1935
  toolCount: 0,
1936
+ totalTokens: 0,
1937
+ seenUsageMessageIds: new Set<string>(),
1895
1938
  stallNotified: false,
1896
1939
  stalledAt: null,
1897
1940
  completionNotified: false,
@@ -2157,6 +2200,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
2157
2200
  // registerAgent's short-circuit + Gap 1 promotion).
2158
2201
  outcome: entry.errored ? 'failed' : entry.historical ? 'orphan' : 'completed',
2159
2202
  toolCount: entry.toolCount,
2203
+ totalTokens: entry.totalTokens,
2160
2204
  durationMs: nowFn() - entry.dispatchedAt,
2161
2205
  description: entry.description,
2162
2206
  // For a failure, fall back to the error detail when the worker
@@ -2184,6 +2228,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
2184
2228
  state: entry.state,
2185
2229
  outcome: 'failed',
2186
2230
  toolCount: entry.toolCount,
2231
+ totalTokens: entry.totalTokens,
2187
2232
  durationMs: nowFn() - entry.dispatchedAt,
2188
2233
  description: entry.description,
2189
2234
  resultText: entry.lastResultText,
@@ -0,0 +1,133 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { decideRedeliverCapture } from '../gateway/redelivery-decision.js'
3
+ import {
4
+ decideBootResumeKind,
5
+ RESUME_SYNTHETIC_PROMPT_PREFIX,
6
+ } from '../gateway/resume-inbound-builder.js'
7
+ import type { Turn, TurnEndedVia } from '../registry/turns-schema.js'
8
+
9
+ /**
10
+ * Pins the MUTUAL EXCLUSION between crash-survival redelivery and the resume
11
+ * synthetic — the double-send guard. The gateway boot block composes exactly
12
+ * these two pure predicates: `decideBootResumeKind` classifies the interrupted
13
+ * turn, then `decideRedeliverCapture({ willBeResumed: kind === 'resume', ... })`
14
+ * decides whether to ALSO stage a redelivery.
15
+ *
16
+ * The load-bearing outcome: an interrupted turn that WILL be resumed (the model
17
+ * re-runs and emits a fresh answer) must NOT also redeliver its recovered draft
18
+ * — otherwise the same answer reaches the user twice. Conversely, a turn that
19
+ * will NOT be resumed (watchdog report, boot_resume:never suppression,
20
+ * resume-of-a-resume loop-guard) MUST redeliver, because nothing else re-answers.
21
+ *
22
+ * This mirrors the gateway wiring exactly, so it asserts the real composed
23
+ * outcome, not an isolated code path.
24
+ */
25
+
26
+ const RESUME_MAX_AGE_MS = 10_800_000 // 3h, gateway default
27
+
28
+ function makeTurn(over: Partial<Turn> = {}): Turn {
29
+ return {
30
+ turn_key: '900001:_#7',
31
+ chat_id: '900001',
32
+ thread_id: null,
33
+ started_at: Date.now() - 60_000, // 1 min ago — well within maxAge
34
+ ended_at: null,
35
+ ended_via: null,
36
+ last_assistant_msg_id: null,
37
+ last_assistant_done: null,
38
+ last_user_msg_id: null,
39
+ user_prompt_preview: 'deploy the staging stack',
40
+ assistant_reply_preview: null,
41
+ tool_call_count: 2,
42
+ interrupt_reason: null,
43
+ resumed_at: null,
44
+ session_id: 'sess-abcd', // durably pinned → redelivery is eligible on the floor
45
+ ...over,
46
+ } as Turn
47
+ }
48
+
49
+ /** Compose the two predicates exactly as the gateway boot block does. */
50
+ function composeGate(turn: Turn, suppressed: boolean) {
51
+ const bootResumeKind = decideBootResumeKind({
52
+ pending: turn,
53
+ suppressed,
54
+ ageMs: Math.max(0, Date.now() - turn.started_at),
55
+ maxAgeMs: RESUME_MAX_AGE_MS,
56
+ })
57
+ const capture = decideRedeliverCapture({
58
+ willBeResumed: bootResumeKind === 'resume',
59
+ hasSessionId: Boolean(turn.session_id),
60
+ })
61
+ return { bootResumeKind, capture }
62
+ }
63
+
64
+ describe('crash-redelivery ↔ resume mutual exclusion (no double-send)', () => {
65
+ it('(a) an interrupted turn that WILL be resumed does NOT also redeliver', () => {
66
+ // ended_via 'restart' → decideBootResumeKind returns 'resume': the model
67
+ // re-runs and emits a fresh answer that supersedes any recovered draft.
68
+ const turn = makeTurn({ ended_via: 'restart' as TurnEndedVia })
69
+ const { bootResumeKind, capture } = composeGate(turn, /* suppressed */ false)
70
+ expect(bootResumeKind).toBe('resume')
71
+ expect(capture.capture).toBe(false)
72
+ expect(capture.skipReason).toBe('will-be-resumed')
73
+ })
74
+
75
+ it('(a2) a still-open (ended_via=null) killed-mid-flight turn is resumed, not redelivered', () => {
76
+ const turn = makeTurn({ ended_via: null })
77
+ const { bootResumeKind, capture } = composeGate(turn, false)
78
+ expect(bootResumeKind).toBe('resume')
79
+ expect(capture.capture).toBe(false)
80
+ })
81
+
82
+ it('(b) a watchdog-timeout turn (report, no auto re-answer) DOES redeliver', () => {
83
+ // ended_via 'timeout' → 'report': the synthetic only ASKS the user whether
84
+ // to retry — it does not auto-re-answer. Redelivery is the correct recovery.
85
+ const turn = makeTurn({ ended_via: 'timeout' as TurnEndedVia })
86
+ const { bootResumeKind, capture } = composeGate(turn, false)
87
+ expect(bootResumeKind).toBe('report')
88
+ expect(capture.capture).toBe(true)
89
+ expect(capture.skipReason).toBeUndefined()
90
+ })
91
+
92
+ it('(b2) a boot_resume:never-suppressed turn (defer-suppressed) DOES redeliver', () => {
93
+ // suppressed=true → 'defer-suppressed': no synthetic re-run, so redelivery
94
+ // is the ONLY recovery send.
95
+ const turn = makeTurn({ ended_via: 'restart' as TurnEndedVia })
96
+ const { bootResumeKind, capture } = composeGate(turn, /* suppressed */ true)
97
+ expect(bootResumeKind).toBe('defer-suppressed')
98
+ expect(capture.capture).toBe(true)
99
+ })
100
+
101
+ it('(b3) a resume-of-a-resume (loop-guard → defer-loop) turn DOES redeliver', () => {
102
+ // A turn whose prompt is itself a resume synthetic → 'defer-loop': the chain
103
+ // is capped, no re-run happens, so redelivery must still recover the answer.
104
+ const turn = makeTurn({
105
+ ended_via: 'restart' as TurnEndedVia,
106
+ user_prompt_preview: `${RESUME_SYNTHETIC_PROMPT_PREFIX} Continue the interrupted deploy.`,
107
+ })
108
+ const { bootResumeKind, capture } = composeGate(turn, false)
109
+ expect(bootResumeKind).toBe('defer-loop')
110
+ expect(capture.capture).toBe(true)
111
+ })
112
+
113
+ it('(b4) a STALE resume downgraded to report DOES redeliver (not suppressed)', () => {
114
+ // A 'restart' turn older than maxAge downgrades resume→report in
115
+ // selectResumeBuilder. Because the final kind is 'report' (no re-run),
116
+ // redelivery must fire — the gate keys on the FINAL kind, not the raw
117
+ // ended_via.
118
+ const turn = makeTurn({
119
+ ended_via: 'restart' as TurnEndedVia,
120
+ started_at: Date.now() - (RESUME_MAX_AGE_MS + 60_000),
121
+ })
122
+ const { bootResumeKind, capture } = composeGate(turn, false)
123
+ expect(bootResumeKind).toBe('report')
124
+ expect(capture.capture).toBe(true)
125
+ })
126
+
127
+ it('eligibility floor still applies: no session_id → no redelivery even when not resumed', () => {
128
+ const turn = makeTurn({ ended_via: 'timeout' as TurnEndedVia, session_id: null })
129
+ const { capture } = composeGate(turn, false)
130
+ expect(capture.capture).toBe(false)
131
+ expect(capture.skipReason).toBe('no-session-id')
132
+ })
133
+ })
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { projectTrailingAnswerFromTranscript } from '../session-tail.js'
3
+ import { decideRedeliver, REDELIVERY_PREFIX } from '../gateway/redelivery-decision.js'
4
+
5
+ /**
6
+ * Pins the crash-survival redelivery WIRING GLUE — the composition
7
+ * `maybeRedeliverUndeliveredAnswer` performs in the boot send: project the
8
+ * interrupted turn's trailing answer from its transcript, then feed
9
+ * (`text`, `trailingIsText`) plus the durable oracle result into
10
+ * `decideRedeliver`. The oracle (`hasOutboundWithText`) and the projector are
11
+ * unit-tested in their own suites (history.test.ts / trailing-answer-projector
12
+ * .test.ts); this asserts the two ends plumb together — the projected answer is
13
+ * what gets framed, and a dangling mid-tool turn is refused before any send.
14
+ *
15
+ * The raw Telegram socket send and the post-`getMe()` connect sequencing are NOT
16
+ * unit-testable here (they require a connected grammy client); the ordering
17
+ * guarantee is documented on `maybeRedeliverUndeliveredAnswer` and enforced by
18
+ * its single call site inside the `didOneTimeSetup` block, which is unreachable
19
+ * until `bot.api.getMe()` resolves.
20
+ */
21
+
22
+ const FINAL_ANSWER = 'The deploy finished — all three services are green and healthy.'
23
+
24
+ function compose(transcript: string, hasDeliveredText: boolean) {
25
+ const projected = projectTrailingAnswerFromTranscript(transcript)
26
+ return decideRedeliver({
27
+ capturedText: projected.text,
28
+ trailingIsText: projected.trailingIsText,
29
+ hasDeliveredText,
30
+ alreadyRedelivered: false,
31
+ ageMs: 60_000,
32
+ maxAgeMs: 10_800_000,
33
+ })
34
+ }
35
+
36
+ function line(obj: unknown): string {
37
+ return JSON.stringify(obj)
38
+ }
39
+ const completedTurn = [
40
+ line({ type: 'user', message: { role: 'user', content: 'deploy status?' } }),
41
+ line({ type: 'assistant', message: { content: [{ type: 'text', text: 'Checking.' }] } }),
42
+ line({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', id: 'toolu_x', input: {} }] } }),
43
+ line({ type: 'assistant', message: { content: [{ type: 'text', text: FINAL_ANSWER }] } }),
44
+ ].join('\n')
45
+ const danglingToolTurn = [
46
+ line({ type: 'user', message: { role: 'user', content: 'run the migration' } }),
47
+ line({ type: 'assistant', message: { content: [{ type: 'text', text: 'On it.' }] } }),
48
+ line({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', id: 'toolu_y', input: {} }] } }),
49
+ ].join('\n')
50
+
51
+ describe('crash-redelivery wiring glue (project → decide)', () => {
52
+ it('frames the PROJECTED trailing answer when the oracle says it was not delivered', () => {
53
+ const d = compose(completedTurn, /* hasDeliveredText */ false)
54
+ expect(d.redeliver).toBe(true)
55
+ expect(d.framedText?.startsWith(REDELIVERY_PREFIX)).toBe(true)
56
+ expect(d.framedText).toContain(FINAL_ANSWER)
57
+ })
58
+
59
+ it('skips when the oracle reports the projected answer was already delivered', () => {
60
+ expect(compose(completedTurn, /* hasDeliveredText */ true).skipReason).toBe('already-delivered')
61
+ })
62
+
63
+ it('refuses a turn killed mid-tool before any send', () => {
64
+ // The projector RESETS the buffer on the trailing tool_use, so the recovered
65
+ // text is empty AND trailingIsText is false — either guard refuses. The
66
+ // decision reports 'empty-text' (checked first); the load-bearing property is
67
+ // simply that no send happens for a dangling mid-tool turn.
68
+ const d = compose(danglingToolTurn, false)
69
+ expect(d.redeliver).toBe(false)
70
+ expect(d.skipReason).toBe('empty-text')
71
+ })
72
+ })
@@ -14,6 +14,8 @@ import {
14
14
  getRecentOutboundCount,
15
15
  getLatestInboundMessageId,
16
16
  hasOutboundDeliveredSince,
17
+ hasOutboundWithText,
18
+ normalizeDeliveryText,
17
19
  _resetForTests,
18
20
  } from '../history.js'
19
21
 
@@ -872,3 +874,92 @@ describe('forwarded-message origin columns', () => {
872
874
  expect(stored).toContain('Bob') // surrounding name preserved
873
875
  })
874
876
  })
877
+
878
+ // ---------------------------------------------------------------------------
879
+ // hasOutboundWithText — durable text-identity delivery oracle (crash-survival
880
+ // redelivery). Keys on the ANSWER TEXT, not a chat+time window, so an interim
881
+ // progress_update earlier in the same turn does NOT false-positive "delivered".
882
+ // ---------------------------------------------------------------------------
883
+
884
+ describe('hasOutboundWithText (durable text-identity oracle)', () => {
885
+ it('does NOT match an interim progress message against the (undelivered) final answer', () => {
886
+ initHistory(stateDir, 30)
887
+ // The turn sent only an interim progress_update; the real final answer was
888
+ // lost in the crash and never recorded. The time-windowed oracle would say
889
+ // "delivered" off the progress row — the text-identity oracle must not.
890
+ recordOutbound({
891
+ chat_id: '1', thread_id: null, message_ids: [10],
892
+ texts: ['on it — pulling yesterday’s GitHub activity'], ts: 200,
893
+ })
894
+ const finalAnswer = 'The deploy finished — all three services are green.'
895
+ expect(hasOutboundWithText('1', finalAnswer, null)).toBe(false)
896
+ // sanity: the coarse time-window oracle DOES false-positive here (this is
897
+ // exactly why we cannot use it as the redelivery gate).
898
+ expect(hasOutboundDeliveredSince('1', 100 * 1000, null, 1)).toBe(true)
899
+ })
900
+
901
+ it('matches when the final answer text was actually delivered', () => {
902
+ initHistory(stateDir, 30)
903
+ const finalAnswer = 'The deploy finished — all three services are green.'
904
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [11], texts: [finalAnswer], ts: 300 })
905
+ expect(hasOutboundWithText('1', finalAnswer, null)).toBe(true)
906
+ })
907
+
908
+ it('matches a delivered chunk-1 against a longer projected answer (multi-chunk, no double-send)', () => {
909
+ initHistory(stateDir, 30)
910
+ const chunk1 = 'Part one of a long answer that was split across chunks.'
911
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [12], texts: [chunk1], ts: 300 })
912
+ // The re-projected full answer starts with chunk-1 → treated as delivered.
913
+ expect(hasOutboundWithText('1', chunk1 + ' Part two continues here.', null)).toBe(true)
914
+ })
915
+
916
+ it('ignores whitespace/spacer differences via normalization', () => {
917
+ initHistory(stateDir, 30)
918
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [13], texts: ['hello world'], ts: 300 })
919
+ expect(hasOutboundWithText('1', 'hello world', null)).toBe(true)
920
+ expect(normalizeDeliveryText('hello world')).toBe('hello world')
921
+ })
922
+
923
+ it('empty/whitespace text never matches', () => {
924
+ initHistory(stateDir, 30)
925
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [14], texts: ['real'], ts: 300 })
926
+ expect(hasOutboundWithText('1', ' ', null)).toBe(false)
927
+ })
928
+
929
+ it('scopes by chat (a different chat does not satisfy the match)', () => {
930
+ initHistory(stateDir, 30)
931
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [15], texts: ['scoped answer'], ts: 300 })
932
+ expect(hasOutboundWithText('2', 'scoped answer', null)).toBe(false)
933
+ expect(hasOutboundWithText('1', 'scoped answer', null)).toBe(true)
934
+ })
935
+
936
+ // diff-review defect #1 — a SHORT final answer must not false-positive-match an
937
+ // unrelated earlier row via the bidirectional-prefix rule (that would suppress a
938
+ // genuine redelivery = permanent silence). Short texts require full equality.
939
+ it('does NOT suppress a short answer that merely shares a prefix with an unrelated row', () => {
940
+ initHistory(stateDir, 30)
941
+ // An earlier turn delivered a longer line that starts with the short answer.
942
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [20], texts: ['Done, deploying now.'], ts: 300 })
943
+ // The interrupted turn's real final answer was the short "Done." — never sent.
944
+ expect(hasOutboundWithText('1', 'Done.', null)).toBe(false)
945
+ })
946
+
947
+ it('still suppresses a short answer that was genuinely delivered (exact match)', () => {
948
+ initHistory(stateDir, 30)
949
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [21], texts: ['Done.'], ts: 300 })
950
+ expect(hasOutboundWithText('1', 'Done.', null)).toBe(true)
951
+ })
952
+
953
+ // sinceMs scope: only rows delivered at/after the interrupted turn's started_at
954
+ // count, so an unrelated PRIOR turn's identical text can never suppress.
955
+ it('scopes by sinceMs (a prior-turn row before the floor does not match)', () => {
956
+ initHistory(stateDir, 30)
957
+ // Prior turn delivered this exact text at ts=200s.
958
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [22], texts: ['repeated answer'], ts: 200 })
959
+ // Interrupted turn started at 250s (250_000 ms) — the prior row is out of scope.
960
+ expect(hasOutboundWithText('1', 'repeated answer', null, 250_000)).toBe(false)
961
+ // A row delivered within the turn window (ts=300s) does match.
962
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [23], texts: ['repeated answer'], ts: 300 })
963
+ expect(hasOutboundWithText('1', 'repeated answer', null, 250_000)).toBe(true)
964
+ })
965
+ })