switchroom 0.18.31 → 0.18.33

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 (159) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +21 -3
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +1410 -852
  5. package/dist/host-control/main.js +22 -4
  6. package/dist/vault/approvals/kernel-server.js +21 -3
  7. package/dist/vault/broker/server.js +48 -4
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +148 -23
  10. package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
  11. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  12. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  13. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  14. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  15. package/telegram-plugin/gateway/boot-card.ts +169 -1
  16. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  17. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  18. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  19. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  20. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  21. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  22. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  23. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  24. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  25. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  26. package/telegram-plugin/gateway/gateway.ts +6403 -13456
  27. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  28. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  29. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  30. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  32. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  33. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  34. package/telegram-plugin/gateway/model-command.ts +23 -0
  35. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  36. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  37. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  38. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  39. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  40. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  41. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  42. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  43. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  44. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  45. package/telegram-plugin/gateway/turn-end.ts +606 -0
  46. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  47. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  48. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  49. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  50. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  51. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  52. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  53. package/telegram-plugin/narrative-dedup.ts +24 -1
  54. package/telegram-plugin/narrative-flush.ts +2 -2
  55. package/telegram-plugin/pending-user-notice.ts +59 -13
  56. package/telegram-plugin/render/render.ts +25 -1
  57. package/telegram-plugin/status-no-truncate.ts +13 -0
  58. package/telegram-plugin/subagent-watcher.ts +297 -31
  59. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  60. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  61. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  62. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  63. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  64. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  65. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  66. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  67. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  68. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  69. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
  70. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  71. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
  72. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  73. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  74. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  75. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  76. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  77. package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
  78. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  79. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
  80. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  81. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
  82. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
  83. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  85. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
  86. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  87. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
  88. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  89. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  90. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  91. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  92. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  93. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  94. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  95. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
  96. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  97. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  98. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  99. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  100. package/telegram-plugin/tests/model-command.test.ts +30 -0
  101. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
  102. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  103. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  104. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  105. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  106. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  107. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  108. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
  109. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  111. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  112. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  113. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  114. package/telegram-plugin/tests/render/render.test.ts +42 -0
  115. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  116. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  117. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  118. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
  119. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  120. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  121. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  122. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  123. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  124. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  125. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
  126. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  127. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  128. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  129. package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
  130. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
  131. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  132. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  133. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  134. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  135. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  136. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  137. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  138. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  139. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  140. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  141. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
  142. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  143. package/telegram-plugin/tool-activity-summary.ts +110 -38
  144. package/telegram-plugin/turn-flush-safety.ts +80 -14
  145. package/telegram-plugin/uat/restart-capability.ts +76 -0
  146. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  147. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  148. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  149. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  150. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  151. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  152. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  153. package/telegram-plugin/worker-activity-feed.ts +34 -4
  154. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  155. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  156. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  157. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  158. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  159. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -48,6 +48,39 @@ const gatewaySrc = readFileSync(
48
48
  resolve(__dirname, '..', 'gateway', 'gateway.ts'),
49
49
  'utf-8',
50
50
  )
51
+ // #2996 P2: executeReply's body (the over-ping block + the lever-2 finalize)
52
+ // moved VERBATIM to outbound-send-path.ts `sendReply()`. The façade call-site
53
+ // assertions that used to grep the executeReply window grep the sendReply
54
+ // window there instead — the wiring contract they pin is unchanged.
55
+ const sendPathSrc = readFileSync(
56
+ resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
57
+ 'utf-8',
58
+ )
59
+ // #2996 P4-A: `handleSessionEvent`'s body (the tool_label / sub-agent drains
60
+ // and their `ea.mayDrain(turn)` single-flight guards) moved VERBATIM to
61
+ // stream-render.ts. Drain-site assertions that grep for the full population of
62
+ // façade call sites therefore scan gateway.ts + stream-render.ts combined —
63
+ // the wiring contract they pin is unchanged, only its file home split.
64
+ const streamRenderSrc = readFileSync(
65
+ resolve(__dirname, '..', 'gateway', 'stream-render.ts'),
66
+ 'utf-8',
67
+ )
68
+ // #2996 P4-B: the narrative/activity lane (showNarrativeStep,
69
+ // openLivenessFeedIfDue, feedHeartbeatTick, drainActivitySummary,
70
+ // clearActivitySummary + the cardDrainGate-wrapped drains inside them) moved
71
+ // VERBATIM into narrative-lane.ts (factory scope, bodies indented +2).
72
+ // gateway.ts keeps thin same-name wrappers, so lane-body assertions must read
73
+ // the MODULE source, not the wrapper.
74
+ const laneSrc = readFileSync(
75
+ resolve(__dirname, '..', 'gateway', 'narrative-lane.ts'),
76
+ 'utf-8',
77
+ )
78
+ const gatewayAndStreamSrc = gatewaySrc + '\n' + streamRenderSrc + '\n' + laneSrc
79
+ /** The sendReply window: from the function decl to the next export. */
80
+ function sendReplyWindow(): string {
81
+ const after = sendPathSrc.split('export async function sendReply(')[1] ?? ''
82
+ return after.split('\nexport ')[0] ?? after
83
+ }
51
84
 
52
85
  /** Source of a named gateway function up to the next top-level definition. */
53
86
  function fnSrc(name: string): string {
@@ -55,6 +88,13 @@ function fnSrc(name: string): string {
55
88
  return after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
56
89
  }
57
90
 
91
+ /** Source of a named narrative-lane function (factory scope, indented +2) up
92
+ * to the next lane function definition (#2996 P4-B). */
93
+ function laneFnSrc(name: string): string {
94
+ const after = laneSrc.split(`function ${name}(`)[1] ?? ''
95
+ return after.split('\n async function ')[0]?.split('\n function ')[0] ?? after
96
+ }
97
+
58
98
  describe('SWITCHROOM_EMISSION_AUTHORITY kill-switch (default OFF)', () => {
59
99
  it('parses with `=== "1"` semantic so an unset env var is OFF (default off)', () => {
60
100
  expect(facadeSrc).toMatch(
@@ -243,15 +283,15 @@ describe('claimOrDowngradePing — PR-4c over-ping decision moves into the faça
243
283
  // The relocation must NOT split the #2562 pair across the façade boundary:
244
284
  // the façade decides; the call-site thunk sets firstPingAt AND
245
285
  // firstPingWasSubstantive on two adjacent lines, no await between.
246
- expect(gatewaySrc).toMatch(
286
+ expect(sendPathSrc).toMatch(
247
287
  /turn\.firstPingAt = now\s*\n\s*turn\.firstPingWasSubstantive = replySubstantive/,
248
288
  )
249
289
  // No await anywhere in the executeReply over-ping block (the decide→apply→
250
290
  // pair-set synchronous chain). Bound to the block, BEFORE the Telegraph
251
291
  // block below (which legitimately awaits).
252
- const blockStart = gatewaySrc.indexOf('const applyOverPingDecision')
253
- const telegraphIdx = gatewaySrc.indexOf('// Telegraph publish (#579)', blockStart)
254
- const block = gatewaySrc.slice(blockStart, telegraphIdx)
292
+ const blockStart = sendPathSrc.indexOf('const applyOverPingDecision')
293
+ const telegraphIdx = sendPathSrc.indexOf('// Telegraph publish (#579)', blockStart)
294
+ const block = sendPathSrc.slice(blockStart, telegraphIdx)
255
295
  const blockCode = block
256
296
  .split('\n')
257
297
  .filter((l) => {
@@ -267,17 +307,14 @@ describe('claimOrDowngradePing — PR-4c over-ping decision moves into the faça
267
307
  // The over-ping net exists ONLY in executeReply. The retired stream_reply
268
308
  // tool (executeStreamReply) had no decideOverPing / firstPingAt /
269
309
  // wasOverPingSuppressed and never called claimOrDowngradePing.
270
- const calls = [...gatewaySrc.matchAll(/\.claimOrDowngradePing\(/g)]
310
+ // #2996 P2: the single call now lives in the extracted sendReply body;
311
+ // gateway.ts must have ZERO (the wrapper delegates).
312
+ const gwCalls = [...gatewaySrc.matchAll(/\.claimOrDowngradePing\(/g)]
313
+ expect(gwCalls).toHaveLength(0)
314
+ const calls = [...sendPathSrc.matchAll(/\.claimOrDowngradePing\(/g)]
271
315
  expect(calls).toHaveLength(1)
272
- const callIdx = gatewaySrc.indexOf('.claimOrDowngradePing(')
273
- const execReplyIdx = gatewaySrc.indexOf('async function executeReply(')
274
- // Upper bound: the next top-level function after executeReply.
275
- const nextFnIdx = gatewaySrc.indexOf('\nasync function ', execReplyIdx + 1)
276
- expect(execReplyIdx).toBeGreaterThan(-1)
277
- expect(nextFnIdx).toBeGreaterThan(execReplyIdx)
278
- // The single call is inside executeReply.
279
- expect(callIdx).toBeGreaterThan(execReplyIdx)
280
- expect(callIdx).toBeLessThan(nextFnIdx)
316
+ // The single call is inside the sendReply window.
317
+ expect(sendReplyWindow()).toMatch(/\.claimOrDowngradePing\(/)
281
318
  })
282
319
  })
283
320
 
@@ -299,7 +336,8 @@ describe('façade delegates to the existing emission primitives (call-site liter
299
336
  it('finalizeCard wraps clearActivitySummary at the call sites (not CALLED inside the façade)', () => {
300
337
  expect(facadeSrc).not.toMatch(/import .*clearActivitySummary/)
301
338
  expect(facadeCode).not.toMatch(/clearActivitySummary\(/)
302
- expect(gatewaySrc).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(/)
339
+ // #2996 P2: the lever-2 call site moved (verbatim) into sendReply.
340
+ expect(sendPathSrc).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(/)
303
341
  })
304
342
 
305
343
  it('claimOrDowngradePing — the call-site disabled thunk STILL contains a literal decideOverPing( (disabled-path proof)', () => {
@@ -308,16 +346,16 @@ describe('façade delegates to the existing emission primitives (call-site liter
308
346
  // its OWN literal `decideOverPing(` call inside the call-site thunk, VERBATIM
309
347
  // from PR-4b-base — so the disabled path is provably byte-identical (never
310
348
  // depends on the façade for the decision).
311
- const pingIdx = gatewaySrc.indexOf('claimOrDowngradePing(')
349
+ const pingIdx = sendPathSrc.indexOf('claimOrDowngradePing(')
312
350
  expect(pingIdx).toBeGreaterThan(-1)
313
- const window = gatewaySrc.slice(pingIdx, pingIdx + 1600)
351
+ const window = sendPathSrc.slice(pingIdx, pingIdx + 1600)
314
352
  expect(window).toMatch(/decideOverPing\(/)
315
353
  })
316
354
  })
317
355
 
318
356
  describe('the drain sites route through the façade with producers preserved verbatim', () => {
319
357
  it('the narrative SHOW site routes via openOrEditCard("narrative") + the producer-"narrative" drain', () => {
320
- const body = fnSrc('showNarrativeStep')
358
+ const body = laneFnSrc('showNarrativeStep')
321
359
  expect(body).toMatch(/openOrEditCard\('narrative'/)
322
360
  // The drain literal (producer arg verbatim) is preserved in the delegate.
323
361
  expect(body).toMatch(/drainActivitySummary\(turn,\s*'narrative'\)/)
@@ -337,8 +375,8 @@ describe('the drain sites route through the façade with producers preserved ver
337
375
  // - feedHeartbeatTick: the 0-label CLIMB of an already-open card
338
376
  // (Phase 1) + the labelled-feed stale-step maintain.
339
377
  // All must still route via the façade with the producer arg verbatim.
340
- const earlyOpen = fnSrc('openLivenessFeedIfDue')
341
- const heartbeat = fnSrc('feedHeartbeatTick')
378
+ const earlyOpen = laneFnSrc('openLivenessFeedIfDue')
379
+ const heartbeat = laneFnSrc('feedHeartbeatTick')
342
380
  const opens = [
343
381
  ...earlyOpen.matchAll(/openOrEditCard\('liveness'/g),
344
382
  ...heartbeat.matchAll(/openOrEditCard\('liveness'/g),
@@ -361,15 +399,20 @@ describe('the drain sites route through the façade with producers preserved ver
361
399
  it('both foreground sub-agent drains route via openOrEditCard("tool") with producer made EXPLICIT', () => {
362
400
  // Previously these called drainActivitySummary(turn) with the implicit
363
401
  // 'tool' default; PR-4a makes 'tool' explicit at both, per the plan.
364
- const opens = [...gatewaySrc.matchAll(/ea\.openOrEditCard\('tool', \(\) => \{\s*\n\s*turn\.activityInFlight = drainActivitySummary\(turn, 'tool'\)/g)]
402
+ // #2996 P4-A: these sites now live in stream-render.ts (tool_label + the
403
+ // sub-agent drains moved with handleSessionEvent), so scan the combined src.
404
+ const opens = [...gatewayAndStreamSrc.matchAll(/ea\.openOrEditCard\('tool', \(\) => \{\s*\n\s*turn\.activityInFlight = drainActivitySummary\(turn, 'tool'\)/g)]
365
405
  // tool_label site (1) + the two sub-agent drains (2) = 3 'tool' openers.
366
406
  expect(opens.length).toBeGreaterThanOrEqual(3)
367
407
  // No foreground drain still uses the bare implicit-producer form.
368
- expect(gatewaySrc).not.toMatch(/turn\.activityInFlight = drainActivitySummary\(turn\)\n/)
408
+ expect(gatewayAndStreamSrc).not.toMatch(/turn\.activityInFlight = drainActivitySummary\(turn\)\n/)
369
409
  })
370
410
 
371
411
  it('every routed drain site guards the single-flight via ea.mayDrain(turn), not a bare activityInFlight read', () => {
372
- const mayDrainGuards = [...gatewaySrc.matchAll(/if \(ea\.mayDrain\(turn\)\)/g)]
412
+ // #2996 P4-A: the tool / sub-agent / post-answer bg-liveness guards moved
413
+ // to stream-render.ts with handleSessionEvent; the narrative + liveness
414
+ // guards stay in gateway.ts. The full population (8) spans both files.
415
+ const mayDrainGuards = [...gatewayAndStreamSrc.matchAll(/if \(ea\.mayDrain\(turn\)\)/g)]
373
416
  // narrative SHOW + narrative RETRACT (timer-flush early-paint retract,
374
417
  // narrative-flush.ts) + 2 liveness + tool + 2 sub-agent + 1 post-answer
375
418
  // bg-liveness (Fix 2) = 8. The Phase-1 0-label climb
@@ -379,14 +422,13 @@ describe('the drain sites route through the façade with producers preserved ver
379
422
  // gateway call site — pinned by feed-heartbeat-liveness-open.test.ts +
380
423
  // silent-turn-climb-transport.test.ts.
381
424
  expect(mayDrainGuards).toHaveLength(8)
382
- expect(gatewaySrc).toMatch(/mayDrain:\s*\(\)\s*=>\s*ea\.mayDrain\(turn\)/)
425
+ expect(gatewayAndStreamSrc).toMatch(/mayDrain:\s*\(\)\s*=>\s*ea\.mayDrain\(turn\)/)
383
426
  })
384
427
  })
385
428
 
386
429
  describe('the lever-2 finalize block routes through the façade', () => {
387
430
  it('executeReply finalize routes via markSubstantiveFinalDelivered + finalizeCard (latch + clear preserved)', () => {
388
- const after = gatewaySrc.split('async function executeReply(')[1] ?? ''
389
- const body = after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
431
+ const body = sendReplyWindow()
390
432
  expect(body).toMatch(/markSubstantiveFinalDelivered\(\(\) => \{\s*\n\s*finalizeTurn\.finalAnswerEverDelivered = true/)
391
433
  expect(body).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(finalizeTurn\)/)
392
434
  })
@@ -396,7 +438,9 @@ describe('per-turn construction — one façade per turn, explicit chat/thread k
396
438
  it('the turn ctor constructs a fresh EmissionAuthority with the explicit statusKey', () => {
397
439
  // Per-turn: born in the CurrentTurn object literal so it is discarded with
398
440
  // the turn and never persists across turns.
399
- expect(gatewaySrc).toMatch(/emissionAuthority: new EmissionAuthority\(\s*\n\s*statusKey\(/)
441
+ // #2996 P4-A: the turn ctor lives in handleSessionEvent's `enqueue` branch,
442
+ // which moved to stream-render.ts.
443
+ expect(gatewayAndStreamSrc).toMatch(/emissionAuthority: new EmissionAuthority\(\s*\n\s*statusKey\(/)
400
444
  })
401
445
 
402
446
  it('the façade constructor takes the chat/thread key EXPLICITLY (the PR-4e map seam)', () => {
@@ -504,12 +548,15 @@ describe('mayDrainCardNow — PR-4d card-drain gate (pure read; gateway holds th
504
548
  // thunks stay byte-identical, wrapped by the centralized helper.
505
549
  // 6 original + 1 post-answer background-agent liveness drain (Fix 2) + 1
506
550
  // narrative-retract drain (timer-flush early-paint retract, narrative-flush.ts).
507
- const wraps = [...gatewaySrc.matchAll(/cardDrainGate\(turn, ea, \(\) => \{/g)]
551
+ // #2996 P4-A: one of these guarded drains (the post-answer bg-liveness
552
+ // drain) moved to stream-render.ts with handleSessionEvent; the population
553
+ // of 8 now spans gateway.ts + stream-render.ts.
554
+ const wraps = [...gatewayAndStreamSrc.matchAll(/cardDrainGate\(turn, ea, \(\) => \{/g)]
508
555
  expect(wraps).toHaveLength(8)
509
556
  // The Phase-1 0-label climb (deterministic-turn-liveness.md) routes through
510
557
  // the SAME helper via an injected thunk — its call lives in the extracted
511
558
  // tick body (feed-heartbeat-climb.ts), wired at the gateway call site as
512
559
  // `cardDrainGate: (run) => cardDrainGate(turn, ea, run)`.
513
- expect(gatewaySrc).toMatch(/cardDrainGate:\s*\(run\)\s*=>\s*cardDrainGate\(turn, ea, run\)/)
560
+ expect(gatewayAndStreamSrc).toMatch(/cardDrainGate:\s*\(run\)\s*=>\s*cardDrainGate\(turn, ea, run\)/)
514
561
  })
515
562
  })
@@ -354,8 +354,11 @@ describe('claimOrDowngradePing — no await in the synchronous decide→apply→
354
354
  resolve(__dirname, '..', 'gateway', 'emission-authority.ts'),
355
355
  'utf-8',
356
356
  )
357
+ // #2996 P2: the over-ping call site moved VERBATIM (with executeReply's
358
+ // body) into outbound-send-path.ts `sendReply()`; the await-free window
359
+ // assertion reads there. Contract unchanged.
357
360
  const gatewaySrc = readFileSync(
358
- resolve(__dirname, '..', 'gateway', 'gateway.ts'),
361
+ resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
359
362
  'utf-8',
360
363
  )
361
364
 
@@ -17,25 +17,50 @@ const gatewaySrc = readFileSync(
17
17
  resolve(__dirname, '..', 'gateway', 'gateway.ts'),
18
18
  'utf-8',
19
19
  )
20
+ // #2996 P2: executeReply's body (incl. the lever-2 finalize + the latch sets)
21
+ // moved VERBATIM to outbound-send-path.ts `sendReply()`; the assertions that
22
+ // pinned that body now read it there. The wiring contract is unchanged.
23
+ const sendPathSrc = readFileSync(
24
+ resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
25
+ 'utf-8',
26
+ )
27
+ // #2996 P4-A: the per-turn CurrentTurn object literal (which initialises the
28
+ // sticky `finalAnswerEverDelivered: false` latch) is constructed in
29
+ // handleSessionEvent's `enqueue` branch, which moved VERBATIM to
30
+ // stream-render.ts. The init-latch assertions scan gateway.ts + that module.
31
+ const streamSrc = readFileSync(
32
+ resolve(__dirname, '..', 'gateway', 'stream-render.ts'),
33
+ 'utf-8',
34
+ )
35
+ // #2996 P4-B: the narrative/activity lane (drainActivitySummary,
36
+ // showNarrativeStep, feedHeartbeatTick, clearActivitySummary) moved VERBATIM
37
+ // into narrative-lane.ts (factory scope, bodies indented +2); gateway keeps
38
+ // thin same-name wrappers. Lane-body windows read the module source.
39
+ const laneSrc = readFileSync(
40
+ resolve(__dirname, '..', 'gateway', 'narrative-lane.ts'),
41
+ 'utf-8',
42
+ )
43
+ const gatewayAndStreamSrc = gatewaySrc + '\n' + streamSrc + '\n' + laneSrc
20
44
 
21
45
  /** Source of `drainActivitySummary` up to the next top-level function. */
22
46
  function drainSrc(): string {
23
- const after = gatewaySrc.split('async function drainActivitySummary(')[1] ?? ''
24
- return after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
47
+ // #2996 P4-B: reads the lane module (factory scope — next fn at 2-space indent).
48
+ const after = laneSrc.split('async function drainActivitySummary(')[1] ?? ''
49
+ return after.split('\n async function ')[0]?.split('\n function ')[0] ?? after
25
50
  }
26
51
 
27
52
  describe('sticky finalAnswerEverDelivered latch (lever 1 precondition / R0)', () => {
28
53
  it('is initialised false in the turn object literal (per-turn reset at turn start)', () => {
29
- expect(gatewaySrc).toMatch(/finalAnswerEverDelivered:\s*false/)
54
+ expect(gatewayAndStreamSrc).toMatch(/finalAnswerEverDelivered:\s*false/)
30
55
  })
31
56
 
32
57
  it('is reset to false in exactly ONE place — the turn initialiser (never cleared by reopen)', () => {
33
58
  // Mirrors activityEverOpened's sticky-true contract: the only `false` is the
34
59
  // per-turn init. A standalone `= false` reassignment would let reopen clear
35
60
  // the latch and reintroduce the reorder (the R0 correction).
36
- const initFalse = [...gatewaySrc.matchAll(/finalAnswerEverDelivered:\s*false/g)]
61
+ const initFalse = [...gatewayAndStreamSrc.matchAll(/finalAnswerEverDelivered:\s*false/g)]
37
62
  expect(initFalse).toHaveLength(1)
38
- const resetFalse = [...gatewaySrc.matchAll(/finalAnswerEverDelivered\s*=\s*false/g)]
63
+ const resetFalse = [...gatewayAndStreamSrc.matchAll(/finalAnswerEverDelivered\s*=\s*false/g)]
39
64
  expect(resetFalse).toHaveLength(0)
40
65
  })
41
66
 
@@ -51,20 +76,23 @@ describe('sticky finalAnswerEverDelivered latch (lever 1 precondition / R0)', ()
51
76
  it('every site that sets the sticky latch true gates on finalAnswerSubstantive', () => {
52
77
  // The latch is set true only at the points that set finalAnswerDelivered=true,
53
78
  // and only when the reply was substantive — so an ack never latches it.
54
- const setTrue = [...gatewaySrc.matchAll(/finalAnswerEverDelivered\s*=\s*true/g)]
55
- // executeReply, silent-anchor merge, + the lever-2 finalize block
79
+ // #2996 P2: all three latch-set sites live in the extracted sendReply body.
80
+ const setTrue = [...sendPathSrc.matchAll(/finalAnswerEverDelivered\s*=\s*true/g)]
81
+ // sendReply post-send, silent-anchor merge, + the lever-2 finalize block
56
82
  // (which is itself substantive-gated).
57
83
  expect(setTrue.length).toBeGreaterThanOrEqual(3)
84
+ // And gateway.ts has none left (the wrapper delegates).
85
+ expect(gatewaySrc).not.toMatch(/finalAnswerEverDelivered\s*=\s*true/)
58
86
  // Each `finalAnswerEverDelivered = true` must sit in a substantive context:
59
87
  // either guarded by `if (turn.finalAnswerSubstantive)` or inside an
60
88
  // `isSubstantiveFinalReply(...)` branch. Assert the substantive-gating
61
89
  // token co-occurs (no bare unconditional latch set).
62
90
  const bareUnconditional = [
63
- ...gatewaySrc.matchAll(/\n\s*(?:turn|finalizeTurn)\.finalAnswerEverDelivered\s*=\s*true/g),
91
+ ...sendPathSrc.matchAll(/\n\s*(?:turn|finalizeTurn)\.finalAnswerEverDelivered\s*=\s*true/g),
64
92
  ]
65
93
  for (const m of bareUnconditional) {
66
94
  const idx = m.index ?? 0
67
- const window = gatewaySrc.slice(Math.max(0, idx - 600), idx)
95
+ const window = sendPathSrc.slice(Math.max(0, idx - 600), idx)
68
96
  expect(
69
97
  /finalAnswerSubstantive/.test(window) || /isSubstantiveFinalReply/.test(window),
70
98
  ).toBe(true)
@@ -108,14 +136,14 @@ describe('drainActivitySummary OPEN gate (levers 1 + 5, heartbeat-covered)', ()
108
136
 
109
137
  describe('drain producers — narrative may not OPEN, liveness + tool may', () => {
110
138
  it('showNarrativeStep drains with producer "narrative" (lever 5 base case)', () => {
111
- const after = gatewaySrc.split('function showNarrativeStep(')[1] ?? ''
112
- const body = after.split('\nfunction ')[0] ?? after
139
+ const after = laneSrc.split('function showNarrativeStep(')[1] ?? ''
140
+ const body = after.split('\n function ')[0] ?? after
113
141
  expect(body).toMatch(/drainActivitySummary\(turn,\s*'narrative'\)/)
114
142
  })
115
143
 
116
144
  it('the liveness-open path drains with producer "liveness" (producer C preserved)', () => {
117
- const after = gatewaySrc.split('function feedHeartbeatTick(')[1] ?? ''
118
- const body = after.split('\nfunction ')[0] ?? after
145
+ const after = laneSrc.split('function feedHeartbeatTick(')[1] ?? ''
146
+ const body = after.split('\n function ')[0] ?? after
119
147
  expect(body).toMatch(/drainActivitySummary\(turn,\s*'liveness'\)/)
120
148
  })
121
149
 
@@ -125,10 +153,11 @@ describe('drain producers — narrative may not OPEN, liveness + tool may', () =
125
153
  })
126
154
 
127
155
  describe('lever 2 — finalize the card BEFORE a substantive reply send', () => {
128
- /** executeReply body up to the next top-level function. */
156
+ /** The extracted reply-orchestration body (#2996 P2): the sendReply window
157
+ * in outbound-send-path.ts, up to the next export. */
129
158
  function executeReplySrc(): string {
130
- const after = gatewaySrc.split('async function executeReply(')[1] ?? ''
131
- return after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
159
+ const after = sendPathSrc.split('export async function sendReply(')[1] ?? ''
160
+ return after.split('\nexport ')[0] ?? after
132
161
  }
133
162
 
134
163
  // #2996 step 1: the chunk send loop was relocated verbatim from executeReply
@@ -43,18 +43,41 @@ const gatewaySrc = readFileSync(
43
43
  resolve(__dirname, '..', 'gateway', 'gateway.ts'),
44
44
  'utf-8',
45
45
  )
46
+ // #2996 P4-A: the enqueue-time `scheduleEarlyLivenessOpen(next)` moved with
47
+ // handleSessionEvent into stream-render.ts; the turn-end teardown stays in
48
+ // gateway.ts. The timer-lifecycle assertion spans both files.
49
+ const streamSrc = readFileSync(
50
+ resolve(__dirname, '..', 'gateway', 'stream-render.ts'),
51
+ 'utf-8',
52
+ )
53
+ // #2996 P4-B: feedHeartbeatTick / openLivenessFeedIfDue (and the whole
54
+ // activity lane) moved VERBATIM into narrative-lane.ts (factory scope, bodies
55
+ // indented +2); gateway keeps thin same-name wrappers, so the body helpers
56
+ // below read the module source.
57
+ const laneSrc = readFileSync(
58
+ resolve(__dirname, '..', 'gateway', 'narrative-lane.ts'),
59
+ 'utf-8',
60
+ )
61
+ // #2996 P8 PR-B: the turn-end funnel (incl. the `stopEarlyLivenessOpen(key as
62
+ // string)` teardown inside purgeReactionTracking) moved verbatim to turn-end.ts.
63
+ const turnEndSrc = readFileSync(
64
+ resolve(__dirname, '..', 'gateway', 'turn-end.ts'),
65
+ 'utf-8',
66
+ )
67
+ const gatewayAndStreamSrc = gatewaySrc + '\n' + streamSrc + '\n' + laneSrc + '\n' + turnEndSrc
46
68
 
47
69
  /** Return the source text of `feedHeartbeatTick` (everything up to the next top-level function). */
48
70
  function feedHeartbeatTickSrc(): string {
49
- const after = gatewaySrc.split('function feedHeartbeatTick(): void {')[1] ?? ''
50
- // Stop at the next top-level function definition.
51
- return after.split('\nfunction ')[0] ?? after
71
+ // #2996 P4-B: reads narrative-lane.ts; next lane fn sits at 2-space indent.
72
+ const after = laneSrc.split('function feedHeartbeatTick(): void {')[1] ?? ''
73
+ return after.split('\n async function ')[0]?.split('\n function ')[0] ?? after
52
74
  }
53
75
 
54
76
  /** Return the source text of `openLivenessFeedIfDue` (the shared open helper). */
55
77
  function openLivenessFeedIfDueSrc(): string {
56
- const after = gatewaySrc.split('function openLivenessFeedIfDue(turn: CurrentTurn): void {')[1] ?? ''
57
- return after.split('\nfunction ')[0] ?? after
78
+ // #2996 P4-B: reads narrative-lane.ts; next lane fn sits at 2-space indent.
79
+ const after = laneSrc.split('function openLivenessFeedIfDue(turn: CurrentTurn): void {')[1] ?? ''
80
+ return after.split('\n async function ')[0]?.split('\n function ')[0] ?? after
58
81
  }
59
82
 
60
83
  describe('H-1: feedHeartbeatTick liveness-open threshold', () => {
@@ -121,8 +144,8 @@ describe('H-1: feedHeartbeatTick liveness-open threshold', () => {
121
144
  // The enqueue-time early-open (the dead-air fix): a one-shot timer fires the
122
145
  // shared open helper at turn start, and it is cancelled at the canonical
123
146
  // turn-end so a leaked timer can never fire against a successor turn.
124
- expect(gatewaySrc).toMatch(/scheduleEarlyLivenessOpen\(next\)/)
125
- expect(gatewaySrc).toMatch(/stopEarlyLivenessOpen\(key as string\)/)
147
+ expect(gatewayAndStreamSrc).toMatch(/scheduleEarlyLivenessOpen\(next\)/)
148
+ expect(gatewayAndStreamSrc).toMatch(/stopEarlyLivenessOpen\(key as string\)/)
126
149
  })
127
150
  })
128
151