switchroom 0.18.30 → 0.18.32

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 (67) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +4 -2
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +708 -255
  5. package/dist/host-control/main.js +5 -3
  6. package/dist/vault/approvals/kernel-server.js +4 -2
  7. package/dist/vault/broker/server.js +4 -2
  8. package/package.json +3 -2
  9. package/profiles/_base/start.sh.hbs +142 -7
  10. package/telegram-plugin/dist/gateway/gateway.js +25870 -25007
  11. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  12. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  13. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  14. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  15. package/telegram-plugin/gateway/gateway.ts +5479 -7069
  16. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  17. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  18. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  19. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  20. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  21. package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
  22. package/telegram-plugin/pending-user-notice.ts +59 -13
  23. package/telegram-plugin/subagent-watcher.ts +111 -28
  24. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  25. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  26. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
  27. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
  28. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  29. package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
  30. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  31. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
  32. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
  33. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  34. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  35. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
  36. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
  37. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  38. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  39. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  40. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  41. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
  42. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  43. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
  44. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  45. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  46. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
  47. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  48. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  49. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  50. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
  51. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  52. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
  53. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  54. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  55. package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
  56. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
  57. package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
  58. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  59. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
  60. package/telegram-plugin/tool-activity-summary.ts +27 -3
  61. package/telegram-plugin/worker-activity-feed.ts +24 -0
  62. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  63. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  64. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  65. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  66. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  67. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Tests for SubagentWatcher re-registration after a SendMessage resume
3
+ * (issue #3315).
4
+ *
5
+ * A background worker that completes normally (a REAL `sub_agent_turn_end`)
6
+ * is swept out of the registry by `cleanupTerminalAgent`, which records the
7
+ * agent id in `terminatedAgentIds` so a rescan of the still-present JSONL
8
+ * doesn't re-register and re-fire a duplicate handback (issue #1116 Bug B).
9
+ *
10
+ * But a SendMessage-resume reuses the SAME `agent-<id>.jsonl`, appending a
11
+ * NEW turn to it. Pre-fix the `terminatedAgentIds` guard suppressed that file
12
+ * forever regardless of growth, so the resumed worker was never tracked again:
13
+ * no FSWatcher, no `onProgress`, no progress card — the exact UX failure the
14
+ * card exists to prevent (operator: "I don't see any progress cards, anything
15
+ * happening??").
16
+ *
17
+ * This suite pins BOTH directions of the deterministic size-based mechanism:
18
+ *
19
+ * (a) terminal cleanup → resume (JSONL grows past the terminal size) ⇒ the
20
+ * watcher re-registers the agent as a LIVE worker and emits progress
21
+ * cues again. The re-registration reads only the NEW turn — it does NOT
22
+ * replay the already-handed-back completed turn (no duplicate onFinish
23
+ * until the resumed turn genuinely ends).
24
+ * (b) terminal cleanup with NO resume (JSONL static) ⇒ the agent stays
25
+ * cleaned up: no re-registration, no duplicate onFinish, no leaked card.
26
+ */
27
+
28
+ import { describe, it, expect, vi } from 'vitest'
29
+ import { startSubagentWatcher } from '../subagent-watcher.js'
30
+ import * as fs from 'fs'
31
+
32
+ function buildJSONL(...lines: object[]): string {
33
+ return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
34
+ }
35
+ function subAgentUserMsg(promptText: string) {
36
+ return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
37
+ }
38
+ function subAgentToolUse(name: string, id: string, input: Record<string, unknown> = {}) {
39
+ return { type: 'assistant', message: { content: [{ type: 'tool_use', name, id, input }] } }
40
+ }
41
+ function subAgentTurnEnd() {
42
+ return { type: 'system', subtype: 'turn_duration', duration_ms: 1234 }
43
+ }
44
+
45
+ const RESCAN_MS = 500
46
+ const CLEANUP_GRACE_MS = 30_000 // TERMINAL_CLEANUP_GRACE_MS in subagent-watcher.ts
47
+
48
+ interface ProgressCall {
49
+ agentId: string
50
+ skeleton: boolean
51
+ progressLine?: string
52
+ toolCount: number
53
+ }
54
+
55
+ function makeHarness(opts: { agentId?: string } = {}) {
56
+ const { agentId = 'resume-agent' } = opts
57
+
58
+ let currentTime = 1000
59
+ const progressCalls: ProgressCall[] = []
60
+ const finishCalls: Array<{ agentId: string; outcome: string }> = []
61
+ const terminalCleanupCalls: string[] = []
62
+ const logs: string[] = []
63
+
64
+ const agentDir = '/home/user/.switchroom/agents/myagent'
65
+ const sessionId = 'mock-session'
66
+ const projectsRoot = `${agentDir}/.claude/projects`
67
+ const projectDir = `${projectsRoot}/mock-cwd`
68
+ const sessionDir = `${projectDir}/${sessionId}`
69
+ const subagentsDir = `${sessionDir}/subagents`
70
+ const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
71
+
72
+ // The agent JSONL is NOT present at construction, so the boot scan registers
73
+ // nothing and the file — when it later appears — registers post-boot as a
74
+ // LIVE (non-historical) worker, exactly like a freshly-dispatched one.
75
+ const fileContents = new Map<string, Buffer>()
76
+
77
+ let lastOpenedPath: string | null = null
78
+ const mockFs = {
79
+ existsSync: ((p: fs.PathLike) => {
80
+ const ps = String(p)
81
+ if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
82
+ return fileContents.has(ps)
83
+ }) as typeof fs.existsSync,
84
+ readdirSync: ((p: fs.PathLike) => {
85
+ const ps = String(p)
86
+ if (ps === projectsRoot) return ['mock-cwd']
87
+ if (ps === projectDir) return [sessionId]
88
+ if (ps === sessionDir) return ['subagents']
89
+ if (ps === subagentsDir) {
90
+ // Dynamic: list the agent-*.jsonl files currently present on "disk".
91
+ const names: string[] = []
92
+ for (const key of fileContents.keys()) {
93
+ if (key.startsWith(subagentsDir + '/agent-') && key.endsWith('.jsonl')) {
94
+ names.push(key.slice(subagentsDir.length + 1))
95
+ }
96
+ }
97
+ return names
98
+ }
99
+ return []
100
+ }) as unknown as typeof fs.readdirSync,
101
+ statSync: ((p: fs.PathLike) =>
102
+ ({ size: fileContents.get(String(p))?.length ?? 0, mtimeMs: currentTime }) as fs.Stats) as typeof fs.statSync,
103
+ openSync: ((p: fs.PathLike) => {
104
+ lastOpenedPath = String(p)
105
+ return 42
106
+ }) as unknown as typeof fs.openSync,
107
+ closeSync: (() => { lastOpenedPath = null }) as typeof fs.closeSync,
108
+ readSync: ((
109
+ _fd: number,
110
+ buf: NodeJS.ArrayBufferView,
111
+ offset: number,
112
+ length: number,
113
+ position: number | null,
114
+ ): number => {
115
+ const content = lastOpenedPath != null ? fileContents.get(lastOpenedPath) : undefined
116
+ if (!content) return 0
117
+ const pos = position ?? 0
118
+ const src = content.slice(pos, pos + length)
119
+ ;(src as Buffer).copy(buf as Buffer, offset)
120
+ return src.length
121
+ }) as unknown as typeof fs.readSync,
122
+ watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
123
+ }
124
+
125
+ const intervals: Array<{ fn: () => void; ms: number; ref: number; fireAt: number }> = []
126
+ const timeouts: Array<{ fn: () => void; ref: number; fireAt: number }> = []
127
+ let nextRef = 1
128
+
129
+ const watcher = startSubagentWatcher({
130
+ agentDir,
131
+ rescanMs: RESCAN_MS,
132
+ onProgress: ({ agentId: id, skeleton, progressLine, toolCount }) =>
133
+ progressCalls.push({ agentId: id, skeleton: skeleton === true, progressLine, toolCount }),
134
+ onFinish: ({ agentId: id, outcome }) => finishCalls.push({ agentId: id, outcome }),
135
+ onTerminalCleanup: (id) => terminalCleanupCalls.push(id),
136
+ now: () => currentTime,
137
+ setInterval: (fn, ms) => {
138
+ const ref = nextRef++
139
+ intervals.push({ fn, ms, ref, fireAt: currentTime + ms })
140
+ return { ref }
141
+ },
142
+ clearInterval: (handle) => {
143
+ const { ref } = handle as { ref: number }
144
+ const idx = intervals.findIndex((i) => i.ref === ref)
145
+ if (idx !== -1) intervals.splice(idx, 1)
146
+ },
147
+ setTimeout: (fn, ms) => {
148
+ const ref = nextRef++
149
+ timeouts.push({ fn, ref, fireAt: currentTime + ms })
150
+ return { ref }
151
+ },
152
+ clearTimeout: (handle) => {
153
+ const { ref } = handle as { ref: number }
154
+ const idx = timeouts.findIndex((t) => t.ref === ref)
155
+ if (idx !== -1) timeouts.splice(idx, 1)
156
+ },
157
+ fs: mockFs,
158
+ log: (msg) => logs.push(msg),
159
+ })
160
+
161
+ const advance = (ms: number): void => {
162
+ currentTime += ms
163
+ for (;;) {
164
+ intervals.sort((a, b) => a.fireAt - b.fireAt)
165
+ timeouts.sort((a, b) => a.fireAt - b.fireAt)
166
+ const nextI = intervals[0]
167
+ const nextT = timeouts[0]
168
+ const iReady = nextI && nextI.fireAt <= currentTime
169
+ const tReady = nextT && nextT.fireAt <= currentTime
170
+ if (!iReady && !tReady) break
171
+ if (tReady && (!iReady || nextT!.fireAt <= nextI!.fireAt)) {
172
+ timeouts.shift()
173
+ nextT!.fn()
174
+ } else {
175
+ nextI!.fireAt += nextI!.ms
176
+ nextI!.fn()
177
+ }
178
+ }
179
+ }
180
+
181
+ const setFile = (content: string): void => {
182
+ fileContents.set(jsonlPath, Buffer.from(content, 'utf-8'))
183
+ }
184
+ const append = (content: string): void => {
185
+ const cur = fileContents.get(jsonlPath) ?? Buffer.alloc(0)
186
+ fileContents.set(jsonlPath, Buffer.concat([cur, Buffer.from(content, 'utf-8')]))
187
+ }
188
+
189
+ return {
190
+ agentId,
191
+ progressCalls,
192
+ finishCalls,
193
+ terminalCleanupCalls,
194
+ logs,
195
+ advance,
196
+ watcher,
197
+ setFile,
198
+ append,
199
+ }
200
+ }
201
+
202
+ describe('subagent-watcher resume re-registration (issue #3315)', () => {
203
+ it('(a) re-registers a terminal-then-resumed worker so its progress card resumes', () => {
204
+ const agentId = 'resume-live'
205
+ const h = makeHarness({ agentId })
206
+
207
+ // A worker is dispatched post-boot: its JSONL appears with a running turn.
208
+ h.setFile(buildJSONL(subAgentUserMsg('bg task'), subAgentToolUse('Bash', 't1')))
209
+ h.advance(RESCAN_MS) // rescan discovers the file → LIVE registration
210
+
211
+ const registered = h.watcher.getRegistry().get(agentId)
212
+ expect(registered).toBeDefined()
213
+ expect(registered!.state).toBe('running')
214
+ expect(registered!.historical).toBe(false)
215
+
216
+ // It completes normally with a REAL turn_end.
217
+ h.append(buildJSONL(subAgentTurnEnd()))
218
+ h.advance(RESCAN_MS) // poll reads turn_end → done → onFinish(completed)
219
+ expect(h.finishCalls).toHaveLength(1)
220
+ expect(h.finishCalls[0].outcome).toBe('completed')
221
+
222
+ // Grace elapses → the entry is swept out of the registry.
223
+ h.advance(CLEANUP_GRACE_MS)
224
+ expect(h.watcher.getRegistry().has(agentId)).toBe(false)
225
+ expect(h.terminalCleanupCalls).toContain(agentId)
226
+
227
+ const progressBeforeResume = h.progressCalls.length
228
+
229
+ // SendMessage resume: Claude Code appends a NEW turn to the SAME JSONL.
230
+ h.append(buildJSONL(
231
+ subAgentUserMsg('follow-up question'),
232
+ subAgentToolUse('Read', 't2', { file_path: '/tmp/x' }),
233
+ ))
234
+ h.advance(RESCAN_MS) // rescan sees growth past terminal size → re-register
235
+
236
+ // The worker is tracked again — live, running, non-historical.
237
+ const revived = h.watcher.getRegistry().get(agentId)
238
+ expect(revived).toBeDefined()
239
+ expect(revived!.state).toBe('running')
240
+ expect(revived!.historical).toBe(false)
241
+ expect(h.logs.some((l) => l.includes('resumed after terminal cleanup') && l.includes(agentId))).toBe(true)
242
+
243
+ // Progress cues resume (the card updates again) — the user-visible fix.
244
+ expect(h.progressCalls.length).toBeGreaterThan(progressBeforeResume)
245
+ expect(h.progressCalls.slice(progressBeforeResume).some((c) => c.agentId === agentId)).toBe(true)
246
+
247
+ // The completed turn was NOT replayed — no duplicate onFinish fired on
248
+ // re-registration (the old turn_end sits before the resume cursor).
249
+ expect(h.finishCalls).toHaveLength(1)
250
+
251
+ // When the resumed turn genuinely ends, onFinish fires again for the new
252
+ // turn — the resumed work gets its own handback.
253
+ h.append(buildJSONL(subAgentTurnEnd()))
254
+ h.advance(RESCAN_MS)
255
+ expect(h.finishCalls).toHaveLength(2)
256
+ expect(h.finishCalls[1].outcome).toBe('completed')
257
+
258
+ h.watcher.stop()
259
+ })
260
+
261
+ it('(b) a terminal worker that is NOT resumed stays cleaned up (no re-register, no duplicate handback)', () => {
262
+ const agentId = 'stay-clean'
263
+ const h = makeHarness({ agentId })
264
+
265
+ h.setFile(buildJSONL(subAgentUserMsg('bg task'), subAgentToolUse('Bash', 't1')))
266
+ h.advance(RESCAN_MS)
267
+ expect(h.watcher.getRegistry().has(agentId)).toBe(true)
268
+
269
+ h.append(buildJSONL(subAgentTurnEnd()))
270
+ h.advance(RESCAN_MS)
271
+ expect(h.finishCalls).toHaveLength(1)
272
+
273
+ h.advance(CLEANUP_GRACE_MS) // sweep
274
+ expect(h.watcher.getRegistry().has(agentId)).toBe(false)
275
+ const finishAfterCleanup = h.finishCalls.length
276
+ const progressAfterCleanup = h.progressCalls.length
277
+
278
+ // The JSONL stays on disk, static (Claude Code leaves it), and the worker
279
+ // is never resumed. Many rescans must NOT re-register it or re-fire the
280
+ // handback (issue #1116 Bug B guard preserved) — the reverse-bug direction.
281
+ for (let i = 0; i < 20; i++) h.advance(RESCAN_MS)
282
+
283
+ expect(h.watcher.getRegistry().has(agentId)).toBe(false)
284
+ expect(h.finishCalls).toHaveLength(finishAfterCleanup) // no duplicate
285
+ expect(h.progressCalls).toHaveLength(progressAfterCleanup) // no card churn
286
+ // Cleanup fired exactly once — no leaked/duplicated terminal sweep.
287
+ expect(h.terminalCleanupCalls.filter((id) => id === agentId)).toHaveLength(1)
288
+
289
+ h.watcher.stop()
290
+ })
291
+ })
@@ -395,4 +395,36 @@ describe('subagent-watcher card resurrection (issue #3023)', () => {
395
395
  h.advance(500)
396
396
  expect(h.resurrectCalls).toHaveLength(1)
397
397
  })
398
+
399
+ it('(f) a false finish resumed after cleanup grace is owned by resurrection, not the #3315 resume path', () => {
400
+ // Non-interference guard (issue #3315 × #3023): once the terminal-cleanup
401
+ // grace has elapsed, a falsely-finalised worker's id sits in BOTH
402
+ // `terminatedAgentIds` (the #3315 resume-detection map) AND
403
+ // `falseFinishTracker`. Its JSONL resuming must be handled by the
404
+ // resurrection path (bounded-chain + onResurrect semantics), NOT
405
+ // double-handled by the scanSubagentsDir resume branch. The branch defers
406
+ // when a false-finish record exists.
407
+ const agentId = 'false-finish-post-grace'
408
+ const h = makeHarness({ agentId })
409
+
410
+ h.advance(500)
411
+ unmarkHistorical(h, agentId)
412
+
413
+ driveToFalseFinish(h, agentId)
414
+ // Let the 30s terminal-cleanup grace elapse so the entry is swept — its id
415
+ // is now in BOTH terminatedAgentIds and falseFinishTracker.
416
+ h.advance(30_000)
417
+ expect(h.watcher.getRegistry().has(agentId)).toBe(false)
418
+
419
+ // JSONL resumes growing.
420
+ h.appendActivity()
421
+ h.advance(500)
422
+
423
+ // Resurrected exactly once via the #3023 path...
424
+ expect(h.resurrectCalls).toHaveLength(1)
425
+ expect(h.watcher.getRegistry().get(agentId)?.state).toBe('running')
426
+ // ...and NOT re-registered by the #3315 resume branch (its log marker is
427
+ // absent — the branch deferred because a false-finish record existed).
428
+ expect(h.logs.some((l) => l.includes('resumed after terminal cleanup'))).toBe(false)
429
+ })
398
430
  })
@@ -4,10 +4,10 @@
4
4
  * Outcome contract: if a pre-purge op in the turn_end handler body THROWS
5
5
  * before the canonical purge (endCurrentTurnAtomic → purgeReactionTracking)
6
6
  * runs, the guarded finally must still clear this turn's gate state
7
- * (activeTurnStartedAt + the mirrored claudeBusyKeys) so the #1556 inbound
8
- * gate re-opens for the next inbound. On the happy path — where the body
9
- * clears the key itself — the backstop must be a no-op (no double purge), and
10
- * the original error must always propagate.
7
+ * (activeTurnStartedAt; the purge also emits the machine turnEnd) so the
8
+ * #1556 inbound gate re-opens for the next inbound. On the happy path —
9
+ * where the body clears the key itself — the backstop must be a no-op (no
10
+ * double purge), and the original error must always propagate.
11
11
  */
12
12
  import { describe, it, expect } from 'vitest'
13
13
  import { withTurnEndGateBackstop } from '../gateway/turn-end-gate-backstop.js'
@@ -22,19 +22,16 @@ function statusKey(chatId: string, threadId?: number): string {
22
22
  }
23
23
 
24
24
  /** Mirror of the production gate state + a purge that clears it, as
25
- * purgeReactionTracking does (activeTurnStartedAt.delete + claudeBusyKeys
26
- * drain for the key). */
25
+ * purgeReactionTracking does (activeTurnStartedAt.delete for the key). */
27
26
  function freshGate(turn: Turn) {
28
27
  const key = statusKey(turn.sessionChatId, turn.sessionThreadId)
29
28
  const activeTurnStartedAt = new Map<string, number>([[key, Date.now()]])
30
- const claudeBusyKeys = new Set<string>([key])
31
29
  const purgeCalls: Array<{ key: string; endingTurn: Turn | undefined }> = []
32
30
  const purge = (k: string, endingTurn: Turn | undefined) => {
33
31
  purgeCalls.push({ key: k, endingTurn })
34
32
  activeTurnStartedAt.delete(k)
35
- claudeBusyKeys.delete(k)
36
33
  }
37
- return { key, activeTurnStartedAt, claudeBusyKeys, purge, purgeCalls }
34
+ return { key, activeTurnStartedAt, purge, purgeCalls }
38
35
  }
39
36
 
40
37
  function deps(gate: ReturnType<typeof freshGate>) {
@@ -58,10 +55,9 @@ describe('withTurnEndGateBackstop (#2094 finding 1)', () => {
58
55
  }, deps(gate)),
59
56
  ).toThrow('redactOutboundText blew up')
60
57
 
61
- // Outcome: gate is OPEN again — both maps cleared, so the #1556 inbound
62
- // gate no longer wedges the next inbound.
58
+ // Outcome: gate is OPEN again — the turn map is cleared, so the #1556
59
+ // inbound gate no longer wedges the next inbound.
63
60
  expect(gate.activeTurnStartedAt.has(gate.key)).toBe(false)
64
- expect(gate.claudeBusyKeys.has(gate.key)).toBe(false)
65
61
  // The backstop fired exactly once, forwarding the ending turn.
66
62
  expect(gate.purgeCalls).toEqual([{ key: gate.key, endingTurn: turn }])
67
63
  })
@@ -267,16 +267,18 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
267
267
  // (`normalizeOutboundBody`). The reply path delegates to it via
268
268
  // `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`; the
269
269
  // redact→normalize→scrub ordering is now pinned in the module source.
270
- const replyDelegates = gatewaySrc.indexOf(
271
- `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`,
272
- gatewaySrc.indexOf('async function executeReply('),
273
- )
274
- expect(replyDelegates).toBeGreaterThan(0)
275
-
276
270
  const moduleSrc = readFileSync(
277
271
  new URL('../gateway/outbound-send-path.ts', import.meta.url),
278
272
  'utf8',
279
273
  )
274
+ // #2996 P2: the reply orchestration itself now lives in the module
275
+ // (`sendReply`); the delegation line is pinned there.
276
+ const replyDelegates = moduleSrc.indexOf(
277
+ `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`,
278
+ moduleSrc.indexOf('export async function sendReply('),
279
+ )
280
+ expect(replyDelegates).toBeGreaterThan(0)
281
+
280
282
  const start = moduleSrc.indexOf('export function normalizeOutboundBody(')
281
283
  const redactIdx = moduleSrc.indexOf('redact(text, site)', start)
282
284
  const normIdx = moduleSrc.indexOf('stripExcessBold(normalizePunctuation(text))', start)
@@ -0,0 +1,112 @@
1
+ /**
2
+ * S1/S2/S4 WIRING (fable red-team 2026-07-17, fixed in #3299 @ a6e09dfb).
3
+ *
4
+ * The behavioural suite `turn-flush-suppression.test.ts` proves the scoped
5
+ * suppression predicate in isolation, and `answer-ready-flush.test.ts` proves
6
+ * the quiescence controller's reset/re-arm semantics — but neither says
7
+ * whether the GATEWAY actually calls them, or what it does with the answer.
8
+ * That decision-vs-delivery gap is exactly how the original S1 bug shipped:
9
+ * every routine was individually correct while the gateway's call site
10
+ * counted ANY chat-wide outbound and then CLOSED the obligation, silently
11
+ * dropping the user's real answer.
12
+ *
13
+ * gateway.ts is a side-effecting IIFE that cannot be imported in a unit test
14
+ * (same constraint as activity-card-wiring.test.ts /
15
+ * silence-liveness-wiring.test.ts), so these are STRUCTURAL assertions that
16
+ * pin the load-bearing call sites. They COMPLEMENT the behavioural suites —
17
+ * the outcomes are proven at routine level; this guards the wiring those
18
+ * routines depend on. Each test names the regression it would catch.
19
+ */
20
+ import { describe, it, expect } from 'vitest'
21
+ import { readFileSync } from 'node:fs'
22
+ import { resolve } from 'node:path'
23
+
24
+ const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
25
+
26
+ function between(src: string, startMarker: string, endMarker: string): string {
27
+ const after = src.split(startMarker)[1] ?? ''
28
+ return after.split(endMarker)[0] ?? ''
29
+ }
30
+
31
+ /** Strip // comment lines so prose quoting the OLD (buggy) shape can't
32
+ * satisfy — or trip — an assertion about the CODE. */
33
+ function codeOnly(src: string): string {
34
+ return src
35
+ .split('\n')
36
+ .filter((l) => !l.trim().startsWith('//'))
37
+ .filter((l) => !l.trim().startsWith('*') && !l.trim().startsWith('/*'))
38
+ .join('\n')
39
+ }
40
+
41
+ describe('S1 wiring — turn-flush suppression call site', () => {
42
+ // The whole suppression branch: predicate call through the end of the
43
+ // `if (suppress)` early-return.
44
+ const branch = between(
45
+ gatewaySrc,
46
+ 'const { shouldSuppressTurnFlush }',
47
+ '// #3276 guard 5',
48
+ )
49
+
50
+ it('the gateway asks the SCOPED predicate, not a chat-wide outbound count', () => {
51
+ expect(branch.length).toBeGreaterThan(100)
52
+ const code = codeOnly(branch)
53
+ // The oracle is the thread/length-scoped history query…
54
+ expect(code).toMatch(/hasSubstantiveOutbound:\s*hasOutboundDeliveredSince/)
55
+ // …with an EXPLICIT null thread for chat-root turns (undefined would
56
+ // match rows in ANY topic and re-open the cross-topic false positive)…
57
+ expect(code).toMatch(/threadId:\s*backstopThreadId\s*\?\?\s*null/)
58
+ // …and the captured answer's length driving the substantive floor.
59
+ expect(code).toMatch(/answerLength:\s*capturedText\.length/)
60
+ // The OLD chat-wide predicate must never come back anywhere in the branch.
61
+ expect(code).not.toMatch(/getRecentOutboundCount/)
62
+ })
63
+
64
+ it('suppression leaves the obligation OPEN (noteTurnEnded) — never closes it', () => {
65
+ const code = codeOnly(branch)
66
+ // A false-positive suppression must stay recoverable: the sweep/liveness
67
+ // floor arbitrates. Closing here made the drop PERMANENT (the S1 bug).
68
+ expect(code).toMatch(/obligationLedger\.noteTurnEnded\(turn\.turnId/)
69
+ expect(code).not.toMatch(/obligationLedger\.close\(/)
70
+ })
71
+
72
+ it('the old predicate is gone from the whole gateway turn-flush path', () => {
73
+ // getRecentOutboundCount must not be consulted anywhere in gateway.ts —
74
+ // any reintroduction re-opens the chat-wide suppression class.
75
+ expect(codeOnly(gatewaySrc)).not.toMatch(/getRecentOutboundCount/)
76
+ })
77
+ })
78
+
79
+ describe('S2 wiring — thinking events re-arm the answer-ready quiescence flush', () => {
80
+ it("the 'thinking' event arm calls resetAnswerReadyFlushTimeout()", () => {
81
+ // Without this, "prose → >1s thinking pause → trailing NO_REPLY" lets the
82
+ // quiescence timer fire mid-pause and deliver a turn the model intended
83
+ // silent. Scope to the thinking case arm only.
84
+ const arm = between(gatewaySrc, "case 'thinking': {", "case 'tool_use': {")
85
+ expect(arm.length).toBeGreaterThan(50)
86
+ expect(codeOnly(arm)).toMatch(/resetAnswerReadyFlushTimeout\(\)/)
87
+ })
88
+ })
89
+
90
+ describe('S4 wiring — flushed answers quote-anchor to the inbound they answer', () => {
91
+ it('deliverAnswer anchors the FIRST chunk with reply_parameters + allow_sending_without_reply', () => {
92
+ const sendChunk = between(
93
+ gatewaySrc,
94
+ 'const sendChunk = async (chunkIndex: number',
95
+ 'buildSendOpts:',
96
+ )
97
+ expect(sendChunk.length).toBeGreaterThan(50)
98
+ const code = codeOnly(sendChunk)
99
+ // First chunk only, and only when the turn has an inbound to anchor to.
100
+ expect(code).toMatch(/chunkIndex === 0 && args\.replyToMessageId != null/)
101
+ // allow_sending_without_reply keeps the send alive if the user deleted
102
+ // their message — without it the whole flush would fail on a dangling id.
103
+ expect(code).toMatch(/allow_sending_without_reply:\s*true/)
104
+ expect(code).toMatch(/reply_parameters/)
105
+ })
106
+
107
+ it('the turn-flush call site passes the turn’s inbound id (bare for synthesized turns)', () => {
108
+ // The anchor must be the message this TURN answers — turn.sourceMessageId
109
+ // is null for cron/handback turns, which therefore still send bare.
110
+ expect(codeOnly(gatewaySrc)).toMatch(/replyToMessageId:\s*turn\.sourceMessageId/)
111
+ })
112
+ })
@@ -0,0 +1,90 @@
1
+ /**
2
+ * S1 fix (fable red-team 2026-07-17) — turn-flush pre-delivery suppression.
3
+ *
4
+ * The old gateway predicate (`getRecentOutboundCount(chatId, 2) > 0`) counted
5
+ * ANY assistant row in the WHOLE chat, so a worker progress ping, a command
6
+ * ack, or a reply in a different forum topic suppressed the flush and closed
7
+ * the obligation — silently dropping the user's real answer. These tests pin
8
+ * the scoped replacement: the oracle must be asked for a SAME-THREAD row of
9
+ * substantive length within the 2s window, and oracle failures fail OPEN
10
+ * (deliver, don't drop). The oracle's own thread/length SQL semantics are
11
+ * pinned separately in `history.test.ts` (`hasOutboundDeliveredSince`).
12
+ */
13
+ import { describe, it, expect } from 'vitest'
14
+ import {
15
+ shouldSuppressTurnFlush,
16
+ FLUSH_SUPPRESSION_WINDOW_MS,
17
+ } from '../gateway/turn-flush-suppression.js'
18
+ import { FINAL_ANSWER_MIN_CHARS } from '../final-answer-detect.js'
19
+
20
+ type OracleCall = { chatId: string; sinceMs: number; threadId: number | null; minChars: number }
21
+
22
+ function capture(result: boolean) {
23
+ const calls: OracleCall[] = []
24
+ const deps = {
25
+ hasSubstantiveOutbound: (chatId: string, sinceMs: number, threadId: number | null, minChars: number) => {
26
+ calls.push({ chatId, sinceMs, threadId, minChars })
27
+ return result
28
+ },
29
+ }
30
+ return { calls, deps }
31
+ }
32
+
33
+ describe('shouldSuppressTurnFlush', () => {
34
+ it('suppresses iff the oracle finds a substantive same-thread outbound', () => {
35
+ const yes = capture(true)
36
+ expect(
37
+ shouldSuppressTurnFlush(yes.deps, { chatId: '-100', threadId: 7, answerLength: 500, nowMs: 10_000 }),
38
+ ).toBe(true)
39
+ const no = capture(false)
40
+ expect(
41
+ shouldSuppressTurnFlush(no.deps, { chatId: '-100', threadId: 7, answerLength: 500, nowMs: 10_000 }),
42
+ ).toBe(false)
43
+ })
44
+
45
+ it('scopes the oracle query to the turn thread and the 2s window', () => {
46
+ const { calls, deps } = capture(true)
47
+ shouldSuppressTurnFlush(deps, { chatId: '-100', threadId: 7, answerLength: 500, nowMs: 10_000 })
48
+ expect(calls).toHaveLength(1)
49
+ expect(calls[0]!.chatId).toBe('-100')
50
+ // Explicit thread id — never `undefined` (undefined would match rows in
51
+ // ANY topic and re-open the cross-topic false positive).
52
+ expect(calls[0]!.threadId).toBe(7)
53
+ expect(calls[0]!.sinceMs).toBe(10_000 - FLUSH_SUPPRESSION_WINDOW_MS)
54
+ })
55
+
56
+ it('passes an explicit null thread for DM / chat-root turns', () => {
57
+ const { calls, deps } = capture(false)
58
+ shouldSuppressTurnFlush(deps, { chatId: '55', threadId: null, answerLength: 300, nowMs: 5_000 })
59
+ expect(calls[0]!.threadId).toBeNull()
60
+ })
61
+
62
+ it('caps the length floor at FINAL_ANSWER_MIN_CHARS for long answers (a short ping can never suppress a long answer)', () => {
63
+ const { calls, deps } = capture(false)
64
+ shouldSuppressTurnFlush(deps, { chatId: '-100', threadId: null, answerLength: 5_000, nowMs: 10_000 })
65
+ expect(calls[0]!.minChars).toBe(FINAL_ANSWER_MIN_CHARS)
66
+ })
67
+
68
+ it('lowers the floor to the answer length for terse answers (their own stream materialization still suppresses a duplicate)', () => {
69
+ const { calls, deps } = capture(true)
70
+ shouldSuppressTurnFlush(deps, { chatId: '-100', threadId: null, answerLength: 12, nowMs: 10_000 })
71
+ expect(calls[0]!.minChars).toBe(12)
72
+ })
73
+
74
+ it('clamps the floor to >= 1 for a degenerate empty answer', () => {
75
+ const { calls, deps } = capture(false)
76
+ shouldSuppressTurnFlush(deps, { chatId: '-100', threadId: null, answerLength: 0, nowMs: 10_000 })
77
+ expect(calls[0]!.minChars).toBe(1)
78
+ })
79
+
80
+ it('fails open (no suppression) when the oracle throws — deliver beats drop', () => {
81
+ const deps = {
82
+ hasSubstantiveOutbound: () => {
83
+ throw new Error('sqlite unavailable')
84
+ },
85
+ }
86
+ expect(
87
+ shouldSuppressTurnFlush(deps, { chatId: '-100', threadId: null, answerLength: 300, nowMs: 10_000 }),
88
+ ).toBe(false)
89
+ })
90
+ })
@@ -414,8 +414,8 @@ describe('approve vs deny shape invariants', () => {
414
414
  // grant/save-outcome inbound must carry that topic so the resumed turn's
415
415
  // reply lands back in it — not General. The carrier is two-fold and BOTH
416
416
  // halves are load-bearing:
417
- // - top-level `threadId` → the gateway's per-topic busy-key / deliver-
418
- // until-acked keying (markClaudeBusyForInbound reads it).
417
+ // - top-level `threadId` → the gateway's per-topic deliver-until-acked
418
+ // keying (chatKeyForInbound reads it).
419
419
  // - `meta.message_thread_id` (stringified) → rendered into the
420
420
  // `<channel message_thread_id="…">` XML, which session-tail's
421
421
  // parseChannelMeta re-extracts to set currentTurn.sessionThreadId, which