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.
- package/dist/auth-broker/index.js +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/gateway.ts +1347 -6758
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- 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 =
|
|
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 =
|
|
352
|
-
const heartbeat =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
31
|
-
|
|
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(
|
|
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 = [...
|
|
61
|
+
const initFalse = [...gatewayAndStreamSrc.matchAll(/finalAnswerEverDelivered:\s*false/g)]
|
|
44
62
|
expect(initFalse).toHaveLength(1)
|
|
45
|
-
const resetFalse = [...
|
|
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 =
|
|
122
|
-
const body = after.split('\
|
|
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 =
|
|
128
|
-
const body = after.split('\
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
return after.split('\
|
|
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
|
-
|
|
57
|
-
|
|
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(
|
|
125
|
-
expect(
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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).
|
|
15
|
-
*
|
|
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
|
|
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`
|
|
27
|
-
* `
|
|
28
|
-
*
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
75
|
-
// isGatewayMain-guarded
|
|
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
|