switchroom 0.18.7 → 0.18.9

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 (85) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/switchroom.js +905 -758
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/profiles/_base/start.sh.hbs +111 -34
  6. package/skills/switchroom-runtime/SKILL.md +2 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +46273 -44324
  8. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  9. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  10. package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
  11. package/telegram-plugin/gateway/boot-card.ts +27 -0
  12. package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
  13. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  14. package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
  15. package/telegram-plugin/gateway/gateway.ts +1169 -3043
  16. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
  17. package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
  18. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  19. package/telegram-plugin/gateway/model-command.ts +23 -11
  20. package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
  21. package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
  22. package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
  23. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  24. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  25. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  26. package/telegram-plugin/hooks/hooks.json +10 -10
  27. package/telegram-plugin/hooks/run-hook.sh +84 -0
  28. package/telegram-plugin/model-unavailable.ts +26 -0
  29. package/telegram-plugin/pty-partial-handler.ts +39 -0
  30. package/telegram-plugin/render/rich-render.ts +79 -1
  31. package/telegram-plugin/retry-api-call.ts +62 -0
  32. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  33. package/telegram-plugin/silence-poke.ts +14 -0
  34. package/telegram-plugin/stream-controller.ts +156 -38
  35. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  36. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  37. package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
  38. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  39. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  40. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  41. package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
  42. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
  43. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
  44. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  45. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
  46. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
  47. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
  48. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
  49. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
  50. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  51. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
  52. package/telegram-plugin/tests/model-command.test.ts +2 -2
  53. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  54. package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
  55. package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
  56. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
  57. package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
  58. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  59. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  60. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  61. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  62. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  63. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  64. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  65. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  66. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  67. package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
  68. package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
  69. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
  70. package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
  71. package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
  72. package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
  73. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
  74. package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
  75. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
  76. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  77. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  78. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  79. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  80. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  81. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  82. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  83. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  84. package/telegram-plugin/voice-ondemand.ts +25 -1
  85. package/telegram-plugin/voice-send.ts +154 -0
@@ -0,0 +1,222 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ repairEscapedWhitespace,
4
+ normalizeParagraphBreaks,
5
+ normalizePunctuation,
6
+ stripExcessBold,
7
+ addParagraphSpacers,
8
+ splitMarkdownChunks,
9
+ hardSliceToCap,
10
+ RICH_MESSAGE_MAX_CHARS,
11
+ } from '../format.js'
12
+ import { scrubVoice } from '../text-voice-scrub.js'
13
+ import { redact } from '../secret-detect/redact.js'
14
+ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
15
+ import {
16
+ normalizeOutboundBody,
17
+ computeEffectiveText,
18
+ computeReplyChunks,
19
+ resplitOversizeChunk,
20
+ chunkText,
21
+ } from '../gateway/outbound-send-path.js'
22
+
23
+ /**
24
+ * Golden-transcript harness for the outbound send-path extraction (#2996,
25
+ * plan §3B).
26
+ *
27
+ * `executeReply` is not exported (same constraint the sibling
28
+ * gateway-outbound-redact.test.ts documents), so the golden reference is a
29
+ * VERBATIM inline copy of the pre-extraction pipeline transforms (below).
30
+ * Every fixture is run through BOTH the inline reference and the extracted
31
+ * `outbound-send-path.ts` module; the test asserts byte-identical output.
32
+ * If the extraction ever drifts from the inline pipeline, these diverge.
33
+ *
34
+ * The reference below is copied line-for-line from the executeReply entry
35
+ * (normalize → redact → punctuation/bold → voice scrub), the effective-text
36
+ * spacing decision, the chunk decision, and the oversize re-split. The
37
+ * `redact` injected here is the same `redact()` the gateway's
38
+ * `redactOutboundText` wraps.
39
+ */
40
+
41
+ // ── Verbatim inline reference (the code as it lived in gateway.ts) ──────────
42
+
43
+ function referenceNormalize(rawText: string): { text: string; voiceReplaced: number } {
44
+ let text = normalizeParagraphBreaks(repairEscapedWhitespace(rawText))
45
+ // redactOutboundText(text, 'reply') → redact(text) (the stderr log is a
46
+ // side effect the pure module leaves to the injected redactor).
47
+ text = redact(text)
48
+ text = stripExcessBold(normalizePunctuation(text))
49
+ let voiceReplaced = 0
50
+ const scrub = scrubVoice(text)
51
+ if (scrub.replaced > 0) {
52
+ text = scrub.scrubbed
53
+ voiceReplaced = scrub.replaced
54
+ }
55
+ return { text, voiceReplaced }
56
+ }
57
+
58
+ function referenceEffectiveText(text: string, literalText: boolean): string {
59
+ return literalText ? text : addParagraphSpacers(text)
60
+ }
61
+
62
+ function referenceChunks(
63
+ effectiveText: string,
64
+ literalText: boolean,
65
+ limit: number,
66
+ chunkMode: 'length' | 'newline',
67
+ ): string[] {
68
+ return literalText
69
+ ? chunkText(effectiveText, limit, chunkMode)
70
+ : splitMarkdownChunks(effectiveText, limit)
71
+ }
72
+
73
+ function referenceResplit(chunk: string): string[] {
74
+ const subPieces = splitMarkdownChunks(chunk, RICH_MESSAGE_MAX_CHARS)
75
+ return subPieces.length > 1 ? subPieces : hardSliceToCap(chunk, RICH_MESSAGE_MAX_CHARS)
76
+ }
77
+
78
+ const injectedRedact = (text: string, _site: string): string => redact(text)
79
+
80
+ // ── Representative outbound fixtures ────────────────────────────────────────
81
+
82
+ const FIXTURES: Record<string, string> = {
83
+ plain: 'Hello there, this is a normal reply.',
84
+ multiParagraph: 'First paragraph here.\n\nSecond paragraph here.\n\nThird one.',
85
+ codeFence:
86
+ 'Here is code:\n\n```ts\nconst x = 1 // a comment — with an em-dash\nconst y = 2\n```\n\nDone.',
87
+ emDashes: 'This — that — and the other thing. En–dash here too.',
88
+ excessBold: '**bold one** and **bold two** and **bold three** and **bold four**.',
89
+ // Assembled at runtime so the source holds no contiguous token literal
90
+ // (scripts/check-no-pii-secrets.mjs rejects contiguous sk-ant-… literals).
91
+ secretApiKey: `Your key is ${'sk-ant-' + 'api03-' + 'ABCD'.repeat(12)} and more text.`,
92
+ jsonEscaped: 'Line one\\nLine two\\n\\nParagraph two with a \\t tab.',
93
+ bullets: '- item one\n- item two\n- item three with a — dash',
94
+ oversize: 'x'.repeat(RICH_MESSAGE_MAX_CHARS + 5000),
95
+ multiChunkProse: Array.from({ length: 60 }, (_, i) => `Paragraph number ${i} with some filler text to grow length.`).join('\n\n'),
96
+ }
97
+
98
+ describe('outbound-send-path — normalizeOutboundBody parity with inline pipeline', () => {
99
+ for (const [name, raw] of Object.entries(FIXTURES)) {
100
+ it(`normalize byte-identical: ${name}`, () => {
101
+ const ref = referenceNormalize(raw)
102
+ const got = normalizeOutboundBody(raw, 'reply', injectedRedact)
103
+ expect(got.text).toBe(ref.text)
104
+ expect(got.voiceReplaced).toBe(ref.voiceReplaced)
105
+ })
106
+ }
107
+
108
+ it('secret fixture is actually redacted (mask fired)', () => {
109
+ const got = normalizeOutboundBody(FIXTURES.secretApiKey, 'reply', injectedRedact)
110
+ expect(got.text).not.toContain('sk-ant-' + 'api03-' + 'ABCD'.repeat(12))
111
+ })
112
+
113
+ it('em/en dashes are removed by the normalize pipeline', () => {
114
+ const got = normalizeOutboundBody(FIXTURES.emDashes, 'reply', injectedRedact)
115
+ // normalizePunctuation + scrubVoice between them strip em/en dashes from
116
+ // prose; the exact stage that fires is an implementation detail, but no
117
+ // raw em-dash survives the pipeline.
118
+ expect(got.text).not.toContain('—')
119
+ })
120
+ })
121
+
122
+ describe('outbound-send-path — effective text + chunk parity', () => {
123
+ for (const [name, raw] of Object.entries(FIXTURES)) {
124
+ for (const literalText of [true, false]) {
125
+ it(`effectiveText + chunks byte-identical: ${name} literal=${literalText}`, () => {
126
+ const { text } = normalizeOutboundBody(raw, 'reply', injectedRedact)
127
+ const refEff = referenceEffectiveText(text, literalText)
128
+ const gotEff = computeEffectiveText(text, literalText)
129
+ expect(gotEff).toBe(refEff)
130
+
131
+ const limit = RICH_MESSAGE_MAX_CHARS
132
+ const chunkMode: 'length' | 'newline' = 'length'
133
+ const refChunks = referenceChunks(refEff, literalText, limit, chunkMode)
134
+ const gotChunks = computeReplyChunks({ effectiveText: gotEff, literalText, limit, chunkMode })
135
+ expect(gotChunks).toEqual(refChunks)
136
+ })
137
+ }
138
+ }
139
+
140
+ it('literal newline-mode chunking parity on a large body', () => {
141
+ const body = FIXTURES.multiChunkProse
142
+ const limit = 400
143
+ expect(computeReplyChunks({ effectiveText: body, literalText: true, limit, chunkMode: 'newline' }))
144
+ .toEqual(chunkText(body, limit, 'newline'))
145
+ })
146
+
147
+ it('oversize chunk re-split parity + every piece under the wire cap', () => {
148
+ const oversize = FIXTURES.oversize
149
+ const ref = referenceResplit(oversize)
150
+ const got = resplitOversizeChunk(oversize)
151
+ expect(got).toEqual(ref)
152
+ for (const piece of got) expect(piece.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS)
153
+ })
154
+ })
155
+
156
+ describe('outbound-send-path — chunkText golden snapshots', () => {
157
+ it('length mode hard-cuts at the limit', () => {
158
+ expect(chunkText('abcdefghij', 4, 'length')).toEqual(['abcd', 'efgh', 'ij'])
159
+ })
160
+ it('newline mode prefers a paragraph break past halfway', () => {
161
+ expect(chunkText('aaaa\n\nbbbbbbbb', 8, 'newline')).toEqual(['aaaa\n', 'bbbbbbbb'])
162
+ })
163
+ it('short text is returned whole', () => {
164
+ expect(chunkText('short', 100, 'length')).toEqual(['short'])
165
+ })
166
+ })
167
+
168
+ // ── Cross-surface dedup suppression (the load-bearing singleton contract) ────
169
+ //
170
+ // The plan calls out that `outboundDedup` MUST be the SAME injected instance
171
+ // across executeReply / answer-stream / turn-flush — a hoist-once bug (the
172
+ // gateway landmine comment) broke all three. The dedup KEY on every surface is
173
+ // the post-`normalizeOutboundBody` text, so a stream-path record must suppress
174
+ // a reply-path check for the same normalized content on the same singleton.
175
+
176
+ describe('outbound-send-path — cross-surface dedup suppression', () => {
177
+ it('stream-path record suppresses reply-path check on the shared singleton', () => {
178
+ const dedup = new OutboundDedupCache()
179
+ const chatId = '12345'
180
+ const threadId = undefined
181
+ const turnKey = 'turn-abc'
182
+ const t0 = 1_000_000
183
+
184
+ // Both surfaces normalize the same raw model text identically.
185
+ const rawFromModel = 'The answer is 42 — computed carefully across the whole set of inputs.'
186
+ const streamText = normalizeOutboundBody(rawFromModel, 'stream_reply', injectedRedact).text
187
+ const replyText = normalizeOutboundBody(rawFromModel, 'reply', injectedRedact).text
188
+ expect(replyText).toBe(streamText) // same key on every surface
189
+
190
+ // Miss before anything recorded.
191
+ expect(dedup.check(chatId, threadId, replyText, t0, turnKey)).toBeNull()
192
+
193
+ // Answer-stream records first (same singleton, same turnKey).
194
+ dedup.record(chatId, threadId, streamText, t0, turnKey)
195
+
196
+ // Reply path now sees the within-turn duplicate and is suppressed.
197
+ const hit = dedup.check(chatId, threadId, replyText, t0 + 500, turnKey)
198
+ expect(hit).not.toBeNull()
199
+ expect(hit!.matched).toBe(true)
200
+ })
201
+
202
+ it('a DISTINCT normalized reply is NOT suppressed (no false dedup)', () => {
203
+ const dedup = new OutboundDedupCache()
204
+ const chatId = '12345'
205
+ const turnKey = 'turn-abc'
206
+ const t0 = 2_000_000
207
+ const a = normalizeOutboundBody('First distinct answer with enough length to record.', 'stream_reply', injectedRedact).text
208
+ const b = normalizeOutboundBody('Second completely different answer, also long enough.', 'reply', injectedRedact).text
209
+ dedup.record(chatId, undefined, a, t0, turnKey)
210
+ expect(dedup.check(chatId, undefined, b, t0 + 500, turnKey)).toBeNull()
211
+ })
212
+
213
+ it('cross-turn identical content is NOT suppressed (distinct turnKeys)', () => {
214
+ const dedup = new OutboundDedupCache()
215
+ const chatId = '12345'
216
+ const t0 = 3_000_000
217
+ const text = normalizeOutboundBody('Same content typed twice across two turns, long enough to record.', 'reply', injectedRedact).text
218
+ dedup.record(chatId, undefined, text, t0, 'turn-1')
219
+ // A later, separate turn with the same content still delivers.
220
+ expect(dedup.check(chatId, undefined, text, t0 + 500, 'turn-2')).toBeNull()
221
+ })
222
+ })
@@ -20,6 +20,16 @@ import { dirname, resolve } from 'node:path'
20
20
  const __dirname = dirname(fileURLToPath(import.meta.url))
21
21
  const read = (p: string) => readFileSync(resolve(__dirname, '..', p), 'utf8')
22
22
  const GATEWAY = read('gateway/gateway.ts')
23
+ // #2996 Phase 3: the in-memory approval-card Maps + their TTL sweeps moved
24
+ // behind approval-card-stores.ts. The gateway now delegates each family sweep
25
+ // to `<store>.sweep(now)`; the per-entry sweepExpiredEntries guard lives in the
26
+ // store module.
27
+ const CARD_STORES = read('gateway/approval-card-stores.ts')
28
+ // #2996 Phase 5: the callback-query handler families (vault access/save,
29
+ // mental-model, deferred-secret, grant wizard, operator-event, auth dashboard)
30
+ // moved verbatim behind callback-query-handlers.ts; handler-body pins read
31
+ // that module while staging/boot/sweep wiring stays pinned on gateway.ts.
32
+ const CB_HANDLERS = read('gateway/callback-query-handlers.ts')
23
33
 
24
34
  function slice(src: string, header: string, span = 3000): string {
25
35
  const start = src.indexOf(header)
@@ -90,7 +100,7 @@ describe('boot restore (Defect A)', () => {
90
100
  })
91
101
 
92
102
  it('a Save tap on a restored (valueless) card degrades gracefully instead of writing empty', () => {
93
- const fn = slice(GATEWAY, 'async function handleVaultRequestSaveCallback', 9000)
103
+ const fn = slice(CB_HANDLERS, 'async function handleVaultRequestSaveCallback', 9000)
94
104
  expect(fn).toMatch(/pending\.restoredWithoutValue \|\| pending\.value\.length === 0/)
95
105
  expect(fn).toMatch(/lost to a gateway restart/)
96
106
  expect(fn).toMatch(/buildVaultSaveFailedInbound/)
@@ -102,15 +112,15 @@ describe('boot restore (Defect A)', () => {
102
112
  describe('resolution clears the durable store (Defect A)', () => {
103
113
  it('vault access approve/deny remove from the store', () => {
104
114
  // deny path
105
- const deny = slice(GATEWAY, 'async function handleVaultRequestAccessCallback', 4000)
115
+ const deny = slice(CB_HANDLERS, 'async function handleVaultRequestAccessCallback', 4000)
106
116
  expect(deny).toMatch(/pendingCardStore\.remove\(stageId\)/)
107
117
  // approve path (performVaultAccessApproval) removes on every terminal branch
108
- const approve = slice(GATEWAY, 'async function performVaultAccessApproval', 9000)
118
+ const approve = slice(CB_HANDLERS, 'async function performVaultAccessApproval', 9000)
109
119
  expect(approve).toMatch(/pendingCardStore\.remove\(stageId\)/)
110
120
  })
111
121
 
112
122
  it('vault save resolution paths remove from the store', () => {
113
- const fn = slice(GATEWAY, 'async function handleVaultRequestSaveCallback', 12000)
123
+ const fn = slice(CB_HANDLERS, 'async function handleVaultRequestSaveCallback', 12000)
114
124
  // discard / write-fail / success / passphrase-missing all clear the store.
115
125
  const count = (fn.match(/pendingCardStore\.remove\(stageId\)/g) ?? []).length
116
126
  expect(count).toBeGreaterThanOrEqual(3)
@@ -124,7 +134,7 @@ describe('resolution clears the durable store (Defect A)', () => {
124
134
  })
125
135
 
126
136
  it('mental model resolve removes from the store', () => {
127
- const fn = slice(GATEWAY, 'async function handleMentalModelProposeCallback', 4000)
137
+ const fn = slice(CB_HANDLERS, 'async function handleMentalModelProposeCallback', 4000)
128
138
  expect(fn).toMatch(/pendingCardStore\.remove\(stageId\)/)
129
139
  })
130
140
  })
@@ -141,10 +151,13 @@ describe('TTL expiry wakes the parked agent (Defect B)', () => {
141
151
  })
142
152
 
143
153
  it('sweepExpiredApprovalCards runs all four family sweeps', () => {
154
+ // #2996 Phase 3: three families now delegate to their store's `.sweep(now)`;
155
+ // request_secret still routes via sweepSecretRequests (which also sweeps the
156
+ // transient armedSecretCaptures) and that in turn calls the store sweep.
144
157
  const fn = slice(GATEWAY, 'function sweepExpiredApprovalCards', 600)
145
- expect(fn).toMatch(/sweepPendingVaultRequestAccesses\(now\)/)
146
- expect(fn).toMatch(/sweepPendingVaultRequestSaves\(now\)/)
147
- expect(fn).toMatch(/sweepPendingMentalModelProposes\(now\)/)
158
+ expect(fn).toMatch(/pendingVaultRequestAccesses\.sweep\(now\)/)
159
+ expect(fn).toMatch(/pendingVaultRequestSaves\.sweep\(now\)/)
160
+ expect(fn).toMatch(/pendingMentalModelProposes\.sweep\(now\)/)
148
161
  expect(fn).toMatch(/sweepSecretRequests\(now\)/)
149
162
  })
150
163
 
@@ -173,14 +186,20 @@ describe('TTL expiry wakes the parked agent (Defect B)', () => {
173
186
  })
174
187
 
175
188
  it('each family sweep is per-entry guarded via sweepExpiredEntries', () => {
176
- for (const fnName of [
177
- 'function sweepPendingVaultRequestAccesses',
178
- 'function sweepPendingVaultRequestSaves',
179
- 'function sweepPendingMentalModelProposes',
180
- 'function sweepSecretRequests',
189
+ // #2996 Phase 3: the per-entry guard moved into the store module's `.sweep`,
190
+ // which is a thin pass-through to the same pure sweepExpiredEntries core.
191
+ // The three vault/mental families delegate to that store method; the
192
+ // request_secret family's sweepSecretRequests delegates to it too.
193
+ expect(CARD_STORES).toMatch(/sweep:\s*\(now\)\s*=>\s*\n?\s*sweepExpiredEntries\(/)
194
+ const secretSweep = slice(GATEWAY, 'function sweepSecretRequests', 500)
195
+ expect(secretSweep).toMatch(/pendingSecretRequests\.sweep\(now\)/)
196
+ for (const store of [
197
+ 'const pendingVaultRequestAccesses = createSweepableCardStore',
198
+ 'const pendingVaultRequestSaves = createSweepableCardStore',
199
+ 'const pendingMentalModelProposes = createSweepableCardStore',
200
+ 'const pendingSecretRequests = createSweepableCardStore',
181
201
  ]) {
182
- const fn = slice(GATEWAY, fnName, 500)
183
- expect(fn, fnName).toMatch(/sweepExpiredEntries\(/)
202
+ expect(GATEWAY, store).toContain(store)
184
203
  }
185
204
  })
186
205
 
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Contract + race/ordering pins for the long-tail pending-state stores
3
+ * (gateway/pending-state-stores.ts), extracted in #2996 Phase 3 step 2.
4
+ *
5
+ * Written BEFORE the long-tail Maps moved behind the store module. They lock:
6
+ *
7
+ * 1. Each family's EXACT `isExpired` comparison direction survives verbatim —
8
+ * TTL directions differ across families (`now - staged_at > TTL`,
9
+ * `now - startedAt > TTL`, `now - createdAt > TTL`, `now - armed_at > TTL`,
10
+ * and the absolute `now > expiresAt`), and a store must delete exactly the
11
+ * entries the old open-coded loop deleted, not one tick early or late.
12
+ * 2. `sweep` is delete-during-iteration safe (JS Map iterators tolerate
13
+ * deleting the current key — the raw reaper loops relied on this).
14
+ * 3. The gateway-visible EAGER-SWEEP-AT-ADD pattern (the sweep called right
15
+ * after a `.set` at a staging site, e.g. `sweepSecretRequests()` after the
16
+ * `pendingSecretRequests.set` in executeRequestSecret) is single-shot: an
17
+ * add followed by an eager sweep expires only entries already past TTL and
18
+ * leaves the just-added fresh entry intact.
19
+ * 4. Map-surface parity for every operation the call sites use, including the
20
+ * LRU eviction pattern `agentButtonMeta` uses (`keys().next().value`) and
21
+ * the live `size` getter.
22
+ */
23
+
24
+ import { describe, it, expect } from 'vitest'
25
+ import {
26
+ createSweepableStore,
27
+ createPlainStore,
28
+ } from '../gateway/pending-state-stores.js'
29
+
30
+ // TTL constants mirror the gateway family values so the direction pins are
31
+ // meaningful (values themselves are not the contract — the direction is).
32
+ const VAULT_INPUT_TTL_MS = 5 * 60 * 1000
33
+ const VAULT_PASSPHRASE_TTL_MS = 30 * 60 * 1000
34
+ const DEFERRED_SECRET_TTL_MS = 5 * 60 * 1000
35
+ const ARMED_SECRET_CAPTURE_TTL_MS = 10 * 60_000
36
+ const ALWAYS_ALLOW_CORRELATION_TTL_MS = 30_000
37
+ const MENTAL_MODEL_CORRELATION_TTL_MS = 720_000
38
+ const REAUTH_INTERCEPT_TTL_MS = 10 * 60_000
39
+
40
+ describe('pending-state-stores: per-family isExpired direction (byte-identical)', () => {
41
+ it('pendingVaultOps — now - startedAt > VAULT_INPUT_TTL_MS', () => {
42
+ const store = createSweepableStore<{ startedAt: number }>(
43
+ (v, now) => now - v.startedAt > VAULT_INPUT_TTL_MS,
44
+ )
45
+ const now = 10_000_000
46
+ store.set('fresh', { startedAt: now - VAULT_INPUT_TTL_MS }) // exactly TTL old: NOT expired (> is strict)
47
+ store.set('stale', { startedAt: now - VAULT_INPUT_TTL_MS - 1 })
48
+ store.sweep(now)
49
+ expect(store.has('fresh')).toBe(true)
50
+ expect(store.has('stale')).toBe(false)
51
+ })
52
+
53
+ it('vaultPassphraseCache — absolute now > expiresAt', () => {
54
+ const store = createSweepableStore<{ expiresAt: number }>(
55
+ (v, now) => now > v.expiresAt,
56
+ )
57
+ const now = 10_000_000
58
+ store.set('atExpiry', { expiresAt: now }) // now > now is false → NOT expired
59
+ store.set('past', { expiresAt: now - 1 })
60
+ store.sweep(now)
61
+ expect(store.has('atExpiry')).toBe(true)
62
+ expect(store.has('past')).toBe(false)
63
+ void VAULT_PASSPHRASE_TTL_MS
64
+ })
65
+
66
+ it('deferredSecrets — now - staged_at > DEFERRED_SECRET_TTL_MS', () => {
67
+ const store = createSweepableStore<{ staged_at: number }>(
68
+ (v, now) => now - v.staged_at > DEFERRED_SECRET_TTL_MS,
69
+ )
70
+ const now = 10_000_000
71
+ store.set('fresh', { staged_at: now - DEFERRED_SECRET_TTL_MS })
72
+ store.set('stale', { staged_at: now - DEFERRED_SECRET_TTL_MS - 1 })
73
+ store.sweep(now)
74
+ expect(store.has('fresh')).toBe(true)
75
+ expect(store.has('stale')).toBe(false)
76
+ })
77
+
78
+ it('armedSecretCaptures — now - armed_at > ARMED_SECRET_CAPTURE_TTL_MS', () => {
79
+ const store = createSweepableStore<{ armed_at: number }>(
80
+ (v, now) => now - v.armed_at > ARMED_SECRET_CAPTURE_TTL_MS,
81
+ )
82
+ const now = 10_000_000
83
+ store.set('fresh', { armed_at: now - ARMED_SECRET_CAPTURE_TTL_MS })
84
+ store.set('stale', { armed_at: now - ARMED_SECRET_CAPTURE_TTL_MS - 1 })
85
+ store.sweep(now)
86
+ expect(store.has('fresh')).toBe(true)
87
+ expect(store.has('stale')).toBe(false)
88
+ })
89
+
90
+ it('pendingAlwaysAllowCorrelations — 30s now - createdAt > TTL', () => {
91
+ const store = createSweepableStore<{ createdAt: number }>(
92
+ (v, now) => now - v.createdAt > ALWAYS_ALLOW_CORRELATION_TTL_MS,
93
+ )
94
+ const now = 10_000_000
95
+ store.set('fresh', { createdAt: now - ALWAYS_ALLOW_CORRELATION_TTL_MS })
96
+ store.set('stale', { createdAt: now - ALWAYS_ALLOW_CORRELATION_TTL_MS - 1 })
97
+ store.sweep(now)
98
+ expect(store.has('fresh')).toBe(true)
99
+ expect(store.has('stale')).toBe(false)
100
+ })
101
+
102
+ it('pendingMentalModelCorrelations — 720s now - createdAt > TTL (outlives the 30s window)', () => {
103
+ const store = createSweepableStore<{ createdAt: number }>(
104
+ (v, now) => now - v.createdAt > MENTAL_MODEL_CORRELATION_TTL_MS,
105
+ )
106
+ const now = 10_000_000
107
+ // A slow-but-valid tap 10 min in must NOT be swept (the whole point of the
108
+ // dedicated 720s TTL vs the 30s always-allow window).
109
+ store.set('slowTap', { createdAt: now - 10 * 60_000 })
110
+ store.set('stale', { createdAt: now - MENTAL_MODEL_CORRELATION_TTL_MS - 1 })
111
+ store.sweep(now)
112
+ expect(store.has('slowTap')).toBe(true)
113
+ expect(store.has('stale')).toBe(false)
114
+ })
115
+
116
+ it('pendingReauthFlows — now - startedAt > REAUTH_INTERCEPT_TTL_MS', () => {
117
+ const store = createSweepableStore<{ startedAt: number }>(
118
+ (v, now) => now - v.startedAt > REAUTH_INTERCEPT_TTL_MS,
119
+ )
120
+ const now = 10_000_000
121
+ store.set('fresh', { startedAt: now - REAUTH_INTERCEPT_TTL_MS })
122
+ store.set('stale', { startedAt: now - REAUTH_INTERCEPT_TTL_MS - 1 })
123
+ store.sweep(now)
124
+ expect(store.has('fresh')).toBe(true)
125
+ expect(store.has('stale')).toBe(false)
126
+ })
127
+ })
128
+
129
+ describe('pending-state-stores: sweep safety', () => {
130
+ it('deletes every past-TTL entry across a multi-entry map (delete-during-iteration safe)', () => {
131
+ const store = createSweepableStore<{ staged_at: number }>(
132
+ (v, now) => now - v.staged_at > 1000,
133
+ )
134
+ for (let i = 0; i < 10; i++) store.set(`e${i}`, { staged_at: i % 2 === 0 ? 0 : 9_999_999 })
135
+ store.sweep(1_000_000) // even entries stale, odd entries fresh
136
+ for (let i = 0; i < 10; i++) {
137
+ expect(store.has(`e${i}`)).toBe(i % 2 === 1)
138
+ }
139
+ })
140
+
141
+ it('is single-shot across two sweep ticks', () => {
142
+ const store = createSweepableStore<{ staged_at: number }>(
143
+ (v, now) => now - v.staged_at > 1000,
144
+ )
145
+ store.set('one', { staged_at: 0 })
146
+ store.sweep(1_000_000)
147
+ expect(store.size).toBe(0)
148
+ expect(() => store.sweep(1_000_000)).not.toThrow() // second tick — nothing left
149
+ expect(store.size).toBe(0)
150
+ })
151
+ })
152
+
153
+ describe('pending-state-stores: eager-sweep-at-add pattern (gateway-visible)', () => {
154
+ // Mirrors executeRequestSecret: dedupe-drop prior stage for the same target,
155
+ // .set the new one, then eagerly sweep. The just-added fresh entry survives;
156
+ // a stale sibling is reaped in the same eager pass.
157
+ it('add + eager sweep expires only stale siblings, keeps the fresh add', () => {
158
+ const TTL = 30 * 60_000
159
+ const store = createSweepableStore<{ chat_id: string; key: string; staged_at: number }>(
160
+ (v, now) => now - v.staged_at > TTL,
161
+ )
162
+ const now = 100_000_000
163
+ store.set('old', { chat_id: 'c', key: 'k1', staged_at: now - TTL - 1 }) // stale
164
+ // Dedupe pass (same target) would delete a live dup; here targets differ.
165
+ const fresh = { chat_id: 'c', key: 'k2', staged_at: now }
166
+ store.set('new', fresh)
167
+ store.sweep(now) // eager sweep at the add site
168
+ expect(store.has('new')).toBe(true) // fresh add never self-expires
169
+ expect(store.get('new')).toBe(fresh)
170
+ expect(store.has('old')).toBe(false) // stale sibling reaped in the eager pass
171
+ })
172
+
173
+ it('dedupe-then-add-then-sweep leaves exactly the new entry', () => {
174
+ const TTL = 30 * 60_000
175
+ const store = createSweepableStore<{ chat_id: string; key: string; staged_at: number }>(
176
+ (v, now) => now - v.staged_at > TTL,
177
+ )
178
+ const now = 100_000_000
179
+ store.set('dup', { chat_id: 'c', key: 'k', staged_at: now - 1000 })
180
+ // dedupe: drop prior stage for the same (chat, key)
181
+ for (const [sid, p] of store) {
182
+ if (p.chat_id === 'c' && p.key === 'k') store.delete(sid)
183
+ }
184
+ store.set('fresh', { chat_id: 'c', key: 'k', staged_at: now })
185
+ store.sweep(now)
186
+ expect([...store.keys()]).toEqual(['fresh'])
187
+ })
188
+ })
189
+
190
+ describe('pending-state-stores: plain store (no sweep)', () => {
191
+ it('has no sweep method and preserves Map surface', () => {
192
+ const store = createPlainStore<{ n: number }>()
193
+ expect((store as { sweep?: unknown }).sweep).toBeUndefined()
194
+ store.set('a', { n: 1 })
195
+ store.set('b', { n: 2 })
196
+ expect(store.size).toBe(2)
197
+ expect(store.get('a')?.n).toBe(1)
198
+ expect(store.has('b')).toBe(true)
199
+ expect(store.delete('a')).toBe(true)
200
+ expect(store.has('a')).toBe(false)
201
+ })
202
+
203
+ it('agentButtonMeta LRU eviction — keys().next().value is the oldest insertion', () => {
204
+ const AGENT_BUTTON_META_MAX = 3
205
+ const store = createPlainStore<Map<string, { ack: string }>>()
206
+ const remember = (key: string) => {
207
+ store.set(key, new Map([['cb', { ack: key }]]))
208
+ while (store.size > AGENT_BUTTON_META_MAX) {
209
+ const oldest = store.keys().next().value
210
+ if (oldest === undefined) break
211
+ store.delete(oldest)
212
+ }
213
+ }
214
+ remember('m1')
215
+ remember('m2')
216
+ remember('m3')
217
+ remember('m4') // evicts m1 (oldest insertion)
218
+ expect(store.has('m1')).toBe(false)
219
+ expect([...store.keys()]).toEqual(['m2', 'm3', 'm4'])
220
+ expect(store.get('m4')?.get('cb')?.ack).toBe('m4')
221
+ })
222
+
223
+ it('pendingAskUser surface — values()/entries()/get/delete used by call sites', () => {
224
+ const store = createPlainStore<{ chatId: string; messageId: number | null }>()
225
+ store.set('ask1', { chatId: 'c1', messageId: 10 })
226
+ store.set('ask2', { chatId: 'c2', messageId: null })
227
+ expect([...store.values()].map((v) => v.chatId).sort()).toEqual(['c1', 'c2'])
228
+ const collected: string[] = []
229
+ for (const [askId] of store.entries()) collected.push(askId)
230
+ expect(collected.sort()).toEqual(['ask1', 'ask2'])
231
+ expect(store.get('ask1')?.messageId).toBe(10)
232
+ store.delete('ask1')
233
+ expect(store.size).toBe(1)
234
+ })
235
+ })
@@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
9
9
  import {
10
10
  createPtyPartialHandler,
11
11
  handlePtyPartialPure,
12
+ looksLikeRawApiError,
12
13
  type PtyHandlerState,
13
14
  type PtyHandlerDeps,
14
15
  } from '../pty-partial-handler.js'
@@ -65,6 +66,35 @@ describe('handlePtyPartialPure', () => {
65
66
  expect(bot.api.sendMessage).not.toHaveBeenCalled()
66
67
  })
67
68
 
69
+ it('suppresses a raw API-error TUI line so it never leaks to chat (#2922 Bug 3)', async () => {
70
+ const state = makeState({ currentSessionChatId: '1' })
71
+ const deps = makeDeps(bot)
72
+ // The exact shape Claude Code's TUI renders on a transient 429.
73
+ const raw =
74
+ "API Error: Server is temporarily limiting requests (not your usage limit) · " +
75
+ "b'{\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\"}}'"
76
+ const action = handlePtyPartialPure(raw, state, deps)
77
+ expect(action).toBe('error-suppressed')
78
+ await microtaskFlush()
79
+ // Nothing sent — the operator-event pipeline owns the user-facing card.
80
+ expect(bot.api.sendMessage).not.toHaveBeenCalled()
81
+ expect(state.activeDraftStreams.size).toBe(0)
82
+ // Not recorded as a preview, so it can't poison later dedup either.
83
+ expect(state.lastPtyPreviewByChat.size).toBe(0)
84
+ })
85
+
86
+ it('does NOT suppress ordinary assistant text that merely mentions errors', async () => {
87
+ const state = makeState({ currentSessionChatId: '1' })
88
+ const action = handlePtyPartialPure(
89
+ "Here's how to handle an error in your retry loop:",
90
+ state,
91
+ makeDeps(bot),
92
+ )
93
+ expect(action).toBe('update-new')
94
+ await microtaskFlush()
95
+ expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
96
+ })
97
+
68
98
  it('dedups when same text arrives twice in a row', async () => {
69
99
  const state = makeState({ currentSessionChatId: '1' })
70
100
  const deps = makeDeps(bot)
@@ -324,3 +354,29 @@ describe('createPtyPartialHandler — session + buffer replay', () => {
324
354
  expect(state.activeDraftStreams.size).toBe(0)
325
355
  })
326
356
  })
357
+
358
+ describe('looksLikeRawApiError (#2922 Bug 3)', () => {
359
+ it('flags the CLI "API Error: … · b\'{…}\'" line', () => {
360
+ expect(
361
+ looksLikeRawApiError(
362
+ "API Error: Server is temporarily limiting requests · b'{\"type\":\"error\"}'",
363
+ ),
364
+ ).toBe(true)
365
+ })
366
+
367
+ it('flags a bare rate_limit_error JSON body', () => {
368
+ expect(
369
+ looksLikeRawApiError('{"type":"error","error":{"type":"rate_limit_error"}}'),
370
+ ).toBe(true)
371
+ })
372
+
373
+ it('flags overloaded_error and is_error markers', () => {
374
+ expect(looksLikeRawApiError('{"type":"overloaded_error"}')).toBe(true)
375
+ expect(looksLikeRawApiError('{"is_error":true,"content":"boom"}')).toBe(true)
376
+ })
377
+
378
+ it('does not flag ordinary prose mentioning "error"', () => {
379
+ expect(looksLikeRawApiError('I hit an error handling that request')).toBe(false)
380
+ expect(looksLikeRawApiError('')).toBe(false)
381
+ })
382
+ })