switchroom 0.18.32 → 0.19.0

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 (121) hide show
  1. package/dist/auth-broker/index.js +17 -1
  2. package/dist/cli/switchroom.js +847 -729
  3. package/dist/host-control/main.js +18 -2
  4. package/dist/vault/approvals/kernel-server.js +17 -1
  5. package/dist/vault/broker/server.js +44 -2
  6. package/package.json +2 -2
  7. package/profiles/_base/start.sh.hbs +105 -18
  8. package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
  9. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  10. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  11. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  12. package/telegram-plugin/gateway/boot-card.ts +169 -1
  13. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  14. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  16. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  17. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  18. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  19. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  20. package/telegram-plugin/gateway/gateway.ts +1347 -6758
  21. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  22. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  23. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  24. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  26. package/telegram-plugin/gateway/model-command.ts +23 -0
  27. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  28. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  29. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  30. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  31. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  32. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  33. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  34. package/telegram-plugin/gateway/turn-end.ts +606 -0
  35. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  36. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  37. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  38. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  39. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  40. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  41. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  42. package/telegram-plugin/narrative-dedup.ts +24 -1
  43. package/telegram-plugin/narrative-flush.ts +2 -2
  44. package/telegram-plugin/render/render.ts +25 -1
  45. package/telegram-plugin/status-no-truncate.ts +13 -0
  46. package/telegram-plugin/subagent-watcher.ts +186 -3
  47. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  48. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  49. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  50. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  51. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  52. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  53. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  54. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  55. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
  56. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  57. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
  58. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  59. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  60. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  61. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  62. package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
  63. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
  64. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  65. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
  66. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
  67. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
  68. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  69. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
  70. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  71. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  72. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  73. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
  74. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  75. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  76. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  77. package/telegram-plugin/tests/model-command.test.ts +30 -0
  78. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
  79. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  80. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  81. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  82. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  83. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
  84. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  85. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  86. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  87. package/telegram-plugin/tests/render/render.test.ts +42 -0
  88. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  89. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  90. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
  91. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  93. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  94. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  96. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
  97. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  98. package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
  99. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
  100. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  101. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  102. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  103. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  104. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  105. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  106. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  107. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  108. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  109. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
  110. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  111. package/telegram-plugin/tool-activity-summary.ts +83 -35
  112. package/telegram-plugin/turn-flush-safety.ts +80 -14
  113. package/telegram-plugin/uat/restart-capability.ts +76 -0
  114. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  115. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  116. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  117. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  118. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  119. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  120. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  121. package/telegram-plugin/worker-activity-feed.ts +10 -4
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Outcome pins for the checklist service-message handler extracted from
3
+ * gateway.ts (switchroom#2996 P6). Asserts the fail-closed allowlist gate
4
+ * (#472 finding #13), the exact per-task InboundMessage envelope broadcast to
5
+ * bridges (kind="checklist_task_changed"), the done/undone/added state
6
+ * derivation, and swallow-with-log error containment.
7
+ */
8
+
9
+ import { describe, it, expect, vi } from 'vitest'
10
+ import {
11
+ handleChecklistUpdate,
12
+ type ChecklistHandlerDeps,
13
+ } from '../gateway/checklist-message-handler.js'
14
+
15
+ function makeDeps(over: Partial<ChecklistHandlerDeps> = {}) {
16
+ const broadcast = vi.fn()
17
+ const log = vi.fn()
18
+ const deps: ChecklistHandlerDeps = {
19
+ loadAccess: () => ({ allowFrom: ['42'] }),
20
+ broadcast,
21
+ log,
22
+ ...over,
23
+ }
24
+ return { deps, broadcast, log }
25
+ }
26
+
27
+ function ctxWith(message: Record<string, unknown> | undefined, chatId: number | undefined = 42): any {
28
+ return {
29
+ message,
30
+ chat: chatId == null ? undefined : { id: chatId },
31
+ }
32
+ }
33
+
34
+ describe('handleChecklistUpdate — allowlist gate (fail-closed)', () => {
35
+ it('drops updates from non-allowlisted chats', () => {
36
+ const { deps, broadcast } = makeDeps({ loadAccess: () => ({ allowFrom: ['99'] }) })
37
+ handleChecklistUpdate(
38
+ ctxWith({ message_id: 7, date: 1000, checklist_tasks_done: [{ id: 1 }] }),
39
+ 'checklist_tasks_done',
40
+ deps,
41
+ )
42
+ expect(broadcast).not.toHaveBeenCalled()
43
+ })
44
+
45
+ it('drops EVERYTHING when the allowlist is empty (the #472 fail-open bug)', () => {
46
+ const { deps, broadcast } = makeDeps({ loadAccess: () => ({ allowFrom: [] }) })
47
+ handleChecklistUpdate(
48
+ ctxWith({ message_id: 7, date: 1000, checklist_tasks_done: [{ id: 1 }] }),
49
+ 'checklist_tasks_done',
50
+ deps,
51
+ )
52
+ expect(broadcast).not.toHaveBeenCalled()
53
+ })
54
+
55
+ it('does nothing without a message or chat', () => {
56
+ const { deps, broadcast } = makeDeps()
57
+ handleChecklistUpdate(ctxWith(undefined), 'checklist_tasks_done', deps)
58
+ handleChecklistUpdate(ctxWith({ message_id: 7 }, undefined), 'checklist_tasks_done', deps)
59
+ expect(broadcast).not.toHaveBeenCalled()
60
+ })
61
+ })
62
+
63
+ describe('handleChecklistUpdate — envelope', () => {
64
+ it('broadcasts one checklist_task_changed event per done task with the exact envelope', () => {
65
+ const { deps, broadcast, log } = makeDeps()
66
+ handleChecklistUpdate(
67
+ ctxWith({
68
+ message_id: 7,
69
+ date: 1_700_000_000,
70
+ checklist_tasks_done: [
71
+ { id: 3, user: { id: 11, username: 'ken' } },
72
+ { id: 4, user: { id: 12 }, done: false },
73
+ ],
74
+ }),
75
+ 'checklist_tasks_done',
76
+ deps,
77
+ )
78
+ expect(broadcast).toHaveBeenCalledTimes(2)
79
+ expect(broadcast.mock.calls[0][0]).toEqual({
80
+ type: 'inbound',
81
+ chatId: '42',
82
+ messageId: 7,
83
+ user: 'ken',
84
+ userId: 11,
85
+ ts: 1_700_000_000,
86
+ text: '(checklist task done: id=3)',
87
+ meta: {
88
+ chat_id: '42',
89
+ message_id: '7',
90
+ kind: 'checklist_task_changed',
91
+ task_id: '3',
92
+ state: 'done',
93
+ user: 'ken',
94
+ user_id: '11',
95
+ ts: new Date(1_700_000_000 * 1000).toISOString(),
96
+ },
97
+ })
98
+ // done === false → 'undone'; username absent → numeric id string.
99
+ const second = broadcast.mock.calls[1][0]
100
+ expect(second.meta.state).toBe('undone')
101
+ expect(second.user).toBe('12')
102
+ expect(log).toHaveBeenCalledTimes(2)
103
+ expect(log.mock.calls[0][0]).toContain('checklist checklist_tasks_done')
104
+ })
105
+
106
+ it('broadcasts added tasks with state=added', () => {
107
+ const { deps, broadcast } = makeDeps()
108
+ handleChecklistUpdate(
109
+ ctxWith({
110
+ message_id: 8,
111
+ date: 1000,
112
+ checklist_tasks_added: [{ id: 9, text: 'new task' }],
113
+ }),
114
+ 'checklist_tasks_added',
115
+ deps,
116
+ )
117
+ expect(broadcast).toHaveBeenCalledTimes(1)
118
+ const evt = broadcast.mock.calls[0][0]
119
+ expect(evt.meta.state).toBe('added')
120
+ expect(evt.meta.task_id).toBe('9')
121
+ expect(evt.user).toBe('unknown')
122
+ expect(evt.userId).toBe(0)
123
+ })
124
+
125
+ it('reads only the field matching the kind argument', () => {
126
+ const { deps, broadcast } = makeDeps()
127
+ handleChecklistUpdate(
128
+ ctxWith({
129
+ message_id: 8,
130
+ date: 1000,
131
+ checklist_tasks_done: [{ id: 1 }],
132
+ checklist_tasks_added: [{ id: 2 }, { id: 3 }],
133
+ }),
134
+ 'checklist_tasks_added',
135
+ deps,
136
+ )
137
+ expect(broadcast).toHaveBeenCalledTimes(2)
138
+ expect(broadcast.mock.calls.map(c => c[0].meta.task_id)).toEqual(['2', '3'])
139
+ })
140
+ })
141
+
142
+ describe('handleChecklistUpdate — error containment', () => {
143
+ it('swallows a throwing broadcast and logs the handler error', () => {
144
+ const { deps, log } = makeDeps({
145
+ broadcast: vi.fn(() => {
146
+ throw new Error('ipc down')
147
+ }),
148
+ })
149
+ expect(() =>
150
+ handleChecklistUpdate(
151
+ ctxWith({ message_id: 7, date: 1000, checklist_tasks_done: [{ id: 1 }] }),
152
+ 'checklist_tasks_done',
153
+ deps,
154
+ ),
155
+ ).not.toThrow()
156
+ const line = log.mock.calls.at(-1)?.[0] as string
157
+ expect(line).toContain('checklist handler error (checklist_tasks_done)')
158
+ expect(line).toContain('ipc down')
159
+ })
160
+ })
@@ -56,6 +56,26 @@ const sendPathSrc = readFileSync(
56
56
  resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
57
57
  'utf-8',
58
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
59
79
  /** The sendReply window: from the function decl to the next export. */
60
80
  function sendReplyWindow(): string {
61
81
  const after = sendPathSrc.split('export async function sendReply(')[1] ?? ''
@@ -68,6 +88,13 @@ function fnSrc(name: string): string {
68
88
  return after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
69
89
  }
70
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
+
71
98
  describe('SWITCHROOM_EMISSION_AUTHORITY kill-switch (default OFF)', () => {
72
99
  it('parses with `=== "1"` semantic so an unset env var is OFF (default off)', () => {
73
100
  expect(facadeSrc).toMatch(
@@ -328,7 +355,7 @@ describe('façade delegates to the existing emission primitives (call-site liter
328
355
 
329
356
  describe('the drain sites route through the façade with producers preserved verbatim', () => {
330
357
  it('the narrative SHOW site routes via openOrEditCard("narrative") + the producer-"narrative" drain', () => {
331
- const body = fnSrc('showNarrativeStep')
358
+ const body = laneFnSrc('showNarrativeStep')
332
359
  expect(body).toMatch(/openOrEditCard\('narrative'/)
333
360
  // The drain literal (producer arg verbatim) is preserved in the delegate.
334
361
  expect(body).toMatch(/drainActivitySummary\(turn,\s*'narrative'\)/)
@@ -348,8 +375,8 @@ describe('the drain sites route through the façade with producers preserved ver
348
375
  // - feedHeartbeatTick: the 0-label CLIMB of an already-open card
349
376
  // (Phase 1) + the labelled-feed stale-step maintain.
350
377
  // All must still route via the façade with the producer arg verbatim.
351
- const earlyOpen = fnSrc('openLivenessFeedIfDue')
352
- const heartbeat = fnSrc('feedHeartbeatTick')
378
+ const earlyOpen = laneFnSrc('openLivenessFeedIfDue')
379
+ const heartbeat = laneFnSrc('feedHeartbeatTick')
353
380
  const opens = [
354
381
  ...earlyOpen.matchAll(/openOrEditCard\('liveness'/g),
355
382
  ...heartbeat.matchAll(/openOrEditCard\('liveness'/g),
@@ -372,15 +399,20 @@ describe('the drain sites route through the façade with producers preserved ver
372
399
  it('both foreground sub-agent drains route via openOrEditCard("tool") with producer made EXPLICIT', () => {
373
400
  // Previously these called drainActivitySummary(turn) with the implicit
374
401
  // 'tool' default; PR-4a makes 'tool' explicit at both, per the plan.
375
- 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)]
376
405
  // tool_label site (1) + the two sub-agent drains (2) = 3 'tool' openers.
377
406
  expect(opens.length).toBeGreaterThanOrEqual(3)
378
407
  // No foreground drain still uses the bare implicit-producer form.
379
- expect(gatewaySrc).not.toMatch(/turn\.activityInFlight = drainActivitySummary\(turn\)\n/)
408
+ expect(gatewayAndStreamSrc).not.toMatch(/turn\.activityInFlight = drainActivitySummary\(turn\)\n/)
380
409
  })
381
410
 
382
411
  it('every routed drain site guards the single-flight via ea.mayDrain(turn), not a bare activityInFlight read', () => {
383
- 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)]
384
416
  // narrative SHOW + narrative RETRACT (timer-flush early-paint retract,
385
417
  // narrative-flush.ts) + 2 liveness + tool + 2 sub-agent + 1 post-answer
386
418
  // bg-liveness (Fix 2) = 8. The Phase-1 0-label climb
@@ -390,7 +422,7 @@ describe('the drain sites route through the façade with producers preserved ver
390
422
  // gateway call site — pinned by feed-heartbeat-liveness-open.test.ts +
391
423
  // silent-turn-climb-transport.test.ts.
392
424
  expect(mayDrainGuards).toHaveLength(8)
393
- expect(gatewaySrc).toMatch(/mayDrain:\s*\(\)\s*=>\s*ea\.mayDrain\(turn\)/)
425
+ expect(gatewayAndStreamSrc).toMatch(/mayDrain:\s*\(\)\s*=>\s*ea\.mayDrain\(turn\)/)
394
426
  })
395
427
  })
396
428
 
@@ -406,7 +438,9 @@ describe('per-turn construction — one façade per turn, explicit chat/thread k
406
438
  it('the turn ctor constructs a fresh EmissionAuthority with the explicit statusKey', () => {
407
439
  // Per-turn: born in the CurrentTurn object literal so it is discarded with
408
440
  // the turn and never persists across turns.
409
- 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\(/)
410
444
  })
411
445
 
412
446
  it('the façade constructor takes the chat/thread key EXPLICITLY (the PR-4e map seam)', () => {
@@ -514,12 +548,15 @@ describe('mayDrainCardNow — PR-4d card-drain gate (pure read; gateway holds th
514
548
  // thunks stay byte-identical, wrapped by the centralized helper.
515
549
  // 6 original + 1 post-answer background-agent liveness drain (Fix 2) + 1
516
550
  // narrative-retract drain (timer-flush early-paint retract, narrative-flush.ts).
517
- 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)]
518
555
  expect(wraps).toHaveLength(8)
519
556
  // The Phase-1 0-label climb (deterministic-turn-liveness.md) routes through
520
557
  // the SAME helper via an injected thunk — its call lives in the extracted
521
558
  // tick body (feed-heartbeat-climb.ts), wired at the gateway call site as
522
559
  // `cardDrainGate: (run) => cardDrainGate(turn, ea, run)`.
523
- 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\)/)
524
561
  })
525
562
  })
@@ -24,25 +24,43 @@ const sendPathSrc = readFileSync(
24
24
  resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
25
25
  'utf-8',
26
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
27
44
 
28
45
  /** Source of `drainActivitySummary` up to the next top-level function. */
29
46
  function drainSrc(): string {
30
- const after = gatewaySrc.split('async function drainActivitySummary(')[1] ?? ''
31
- 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
32
50
  }
33
51
 
34
52
  describe('sticky finalAnswerEverDelivered latch (lever 1 precondition / R0)', () => {
35
53
  it('is initialised false in the turn object literal (per-turn reset at turn start)', () => {
36
- expect(gatewaySrc).toMatch(/finalAnswerEverDelivered:\s*false/)
54
+ expect(gatewayAndStreamSrc).toMatch(/finalAnswerEverDelivered:\s*false/)
37
55
  })
38
56
 
39
57
  it('is reset to false in exactly ONE place — the turn initialiser (never cleared by reopen)', () => {
40
58
  // Mirrors activityEverOpened's sticky-true contract: the only `false` is the
41
59
  // per-turn init. A standalone `= false` reassignment would let reopen clear
42
60
  // the latch and reintroduce the reorder (the R0 correction).
43
- const initFalse = [...gatewaySrc.matchAll(/finalAnswerEverDelivered:\s*false/g)]
61
+ const initFalse = [...gatewayAndStreamSrc.matchAll(/finalAnswerEverDelivered:\s*false/g)]
44
62
  expect(initFalse).toHaveLength(1)
45
- const resetFalse = [...gatewaySrc.matchAll(/finalAnswerEverDelivered\s*=\s*false/g)]
63
+ const resetFalse = [...gatewayAndStreamSrc.matchAll(/finalAnswerEverDelivered\s*=\s*false/g)]
46
64
  expect(resetFalse).toHaveLength(0)
47
65
  })
48
66
 
@@ -118,14 +136,14 @@ describe('drainActivitySummary OPEN gate (levers 1 + 5, heartbeat-covered)', ()
118
136
 
119
137
  describe('drain producers — narrative may not OPEN, liveness + tool may', () => {
120
138
  it('showNarrativeStep drains with producer "narrative" (lever 5 base case)', () => {
121
- const after = gatewaySrc.split('function showNarrativeStep(')[1] ?? ''
122
- const body = after.split('\nfunction ')[0] ?? after
139
+ const after = laneSrc.split('function showNarrativeStep(')[1] ?? ''
140
+ const body = after.split('\n function ')[0] ?? after
123
141
  expect(body).toMatch(/drainActivitySummary\(turn,\s*'narrative'\)/)
124
142
  })
125
143
 
126
144
  it('the liveness-open path drains with producer "liveness" (producer C preserved)', () => {
127
- const after = gatewaySrc.split('function feedHeartbeatTick(')[1] ?? ''
128
- const body = after.split('\nfunction ')[0] ?? after
145
+ const after = laneSrc.split('function feedHeartbeatTick(')[1] ?? ''
146
+ const body = after.split('\n function ')[0] ?? after
129
147
  expect(body).toMatch(/drainActivitySummary\(turn,\s*'liveness'\)/)
130
148
  })
131
149
 
@@ -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
 
@@ -5,36 +5,41 @@
5
5
  * `startGateway()` at the bottom of the file.
6
6
  *
7
7
  * WHY this asserts on the parsed SOURCE, not a real `import()`:
8
- * gateway.ts statically reaches modules whose bun:sqlite access + the still-
9
- * ungated P0d process-handler / reaction-sweep registrations make a hermetic
10
- * `await import()` under vitest impractical without heavy mocking (see the P0e
11
- * note in the PR). The repo's oracle for "pin gateway structure without booting
12
- * it" is source-parsing via `ts.createSourceFile` — the same pattern as
8
+ * the repo's oracle for "pin gateway structure without booting it" is
9
+ * source-parsing via `ts.createSourceFile` the same pattern as
13
10
  * gateway-bot-construction-deferral.test.ts (P0b) and
14
- * gateway-handler-registration-wiring.test.ts (P0a). This test follows it and
15
- * asserts OUTCOMES:
11
+ * gateway-handler-registration-wiring.test.ts (P0a). (Since P0e, #3318, a
12
+ * hermetic `await import()` of gateway.ts does complete cleanly; upgrading
13
+ * this oracle to a real runtime import is possible but deliberately out of
14
+ * scope here — the source parse stays the pinned pattern.) This test follows
15
+ * it and asserts OUTCOMES:
16
16
  *
17
17
  * 1. `isGatewayMain` is a module-scope const whose value is the argv[1]
18
18
  * main-check (true only when this module is the process entrypoint).
19
19
  * 2. The boot sequence is `async function startGateway()`, invoked EXACTLY
20
20
  * once, and that sole invocation is guarded by `isGatewayMain`. The old
21
21
  * top-level boot IIFE (`void (async () => …)()`) is gone.
22
- * 3. No UNGUARDED module-scope P0c side effect remains: every top-level
22
+ * 3. No UNGUARDED module-scope side effect remains: every top-level
23
23
  * `setInterval` / `setTimeout` / `<x>.startTimer(…)` / `mkdirSync` /
24
24
  * `runHistoryReaperNow` / `openTurnsDb` / `initHistory` /
25
25
  * `acquireStartupLock` / `startWebhookIngestServer` /
26
- * `createPollHealthCheck` / `loadObligations` call, and every module-scope
27
- * `await`, sits inside an `isGatewayMain` guard (an `if (isGatewayMain …)`
28
- * or an `isGatewayMain ? : …` ternary) or inside a function body.
26
+ * `createPollHealthCheck` / `loadObligations` /
27
+ * `installPosthogErrorHandlers` / `sweepActiveReactions` /
28
+ * `process.on(…)` call, and every module-scope `await`, sits inside an
29
+ * `isGatewayMain` guard (an `if (isGatewayMain …)` or an
30
+ * `isGatewayMain ? … : …` ternary) — or inside a function body.
29
31
  *
30
32
  * A regression that re-hoists a timer, the startup-lock await, the boot IIFE,
31
33
  * or the turn-registry open back to unguarded module scope fails (3).
32
34
  *
33
- * OUT OF SCOPE (P0d, deliberately still unguarded — see the PR/issue): the
34
- * process signal/error handlers (`process.on('SIGTERM'|'SIGINT'|'beforeExit'|
35
- * 'unhandledRejection'|'uncaughtException')`, `installPosthogErrorHandlers()`)
36
- * and the startup reaction sweep (`sweepActiveReactions`). Those are NOT in the
37
- * sentinel set below.
35
+ * The process signal/error handlers (`process.on('SIGTERM'|'SIGINT'|
36
+ * 'beforeExit'|'unhandledRejection'|'uncaughtException')`,
37
+ * `installPosthogErrorHandlers()`) and the startup reaction sweep
38
+ * (`sweepActiveReactions`) the "P0d" inventory from the P0c handback — were
39
+ * gated behind `isGatewayMain` by P0e (#3318, `ab34bb7b`) too; #2996 P0d was
40
+ * verified already-delivered and closed with no PR (plan Amendment 11). They
41
+ * ARE in the sentinel set below, so a regression that un-gates any of them
42
+ * back to bare module scope fails (3) as well.
38
43
  */
39
44
 
40
45
  import { describe, it, expect } from 'vitest'
@@ -71,8 +76,9 @@ function lineOf(n: ts.Node): number {
71
76
  return sourceFile.getLineAndCharacterOfPosition(n.getStart(sourceFile)).line + 1
72
77
  }
73
78
 
74
- // The P0c-scoped side-effect sentinels. Each of these, at module scope, MUST be
75
- // isGatewayMain-guarded after P0c.
79
+ // The side-effect sentinels. Each of these, at module scope, MUST be
80
+ // isGatewayMain-guarded: the first block since P0c, the last two — the "P0d"
81
+ // process-wiring inventory, gated by P0e (#3318) — since P0e.
76
82
  const CALL_SENTINELS = new Set([
77
83
  'setInterval',
78
84
  'setTimeout',
@@ -84,6 +90,8 @@ const CALL_SENTINELS = new Set([
84
90
  'startWebhookIngestServer',
85
91
  'createPollHealthCheck',
86
92
  'loadObligations',
93
+ 'installPosthogErrorHandlers',
94
+ 'sweepActiveReactions',
87
95
  ])
88
96
 
89
97
  function sentinelName(n: ts.Node): string | null {
@@ -94,6 +102,19 @@ function sentinelName(n: ts.Node): string | null {
94
102
  if (ts.isPropertyAccessExpression(e) && e.name.text === 'startTimer') {
95
103
  return `${e.expression.getText(sourceFile)}.startTimer`
96
104
  }
105
+ // `process.on(...)` — the P0d signal/error-handler registrations
106
+ // (SIGTERM / SIGINT / beforeExit / unhandledRejection /
107
+ // uncaughtException), gated by P0e. Several route through shutdown() →
108
+ // process.exit, so an unguarded registration makes exit reachable from a
109
+ // bare import.
110
+ if (
111
+ ts.isPropertyAccessExpression(e) &&
112
+ e.name.text === 'on' &&
113
+ ts.isIdentifier(e.expression) &&
114
+ e.expression.text === 'process'
115
+ ) {
116
+ return 'process.on'
117
+ }
97
118
  }
98
119
  if (n.kind === ts.SyntaxKind.AwaitExpression) return 'await'
99
120
  return null