switchroom 0.15.45 → 0.16.4
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/agent-scheduler/index.js +122 -88
- package/dist/auth-broker/index.js +463 -177
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +17 -14
- package/dist/cli/notion-write-pretool.mjs +117 -86
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +3158 -1178
- package/dist/host-control/main.js +2833 -355
- package/dist/vault/approvals/kernel-server.js +7479 -7439
- package/dist/vault/broker/server.js +11312 -11272
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +88 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +0 -19
- package/telegram-plugin/.claude-plugin/plugin.json +2 -2
- package/telegram-plugin/answer-stream-flag.ts +12 -49
- package/telegram-plugin/answer-stream.ts +5 -150
- package/telegram-plugin/auth-snapshot-format.ts +280 -48
- package/telegram-plugin/auto-fallback-fleet.ts +44 -1
- package/telegram-plugin/context-exhaustion.ts +12 -0
- package/telegram-plugin/demo-mask.ts +154 -0
- package/telegram-plugin/dist/bridge/bridge.js +167 -124
- package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
- package/telegram-plugin/dist/server.js +215 -172
- package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
- package/telegram-plugin/draft-stream.ts +47 -410
- package/telegram-plugin/final-answer-detect.ts +17 -12
- package/telegram-plugin/fleet-fallback-resume.ts +131 -0
- package/telegram-plugin/format.ts +56 -19
- package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
- package/telegram-plugin/gateway/auth-command.ts +70 -14
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
- package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
- package/telegram-plugin/gateway/current-turn-map.ts +188 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
- package/telegram-plugin/gateway/effort-command.ts +8 -3
- package/telegram-plugin/gateway/emission-authority.ts +369 -0
- package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
- package/telegram-plugin/gateway/gateway.ts +1837 -291
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
- package/telegram-plugin/gateway/represent-guard.ts +72 -0
- package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
- package/telegram-plugin/gateway/status-surface-log.ts +14 -3
- package/telegram-plugin/history.ts +33 -11
- package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
- package/telegram-plugin/issues-card.ts +4 -0
- package/telegram-plugin/model-unavailable.ts +124 -0
- package/telegram-plugin/narrative-dedup.ts +69 -0
- package/telegram-plugin/over-ping-safety-net.ts +70 -4
- package/telegram-plugin/package.json +3 -3
- package/telegram-plugin/pending-work-progress.ts +12 -0
- package/telegram-plugin/permission-rule.ts +32 -5
- package/telegram-plugin/permission-title.ts +152 -9
- package/telegram-plugin/quota-check.ts +13 -0
- package/telegram-plugin/quota-watch.ts +135 -7
- package/telegram-plugin/registry/turns-schema.test.ts +24 -0
- package/telegram-plugin/registry/turns-schema.ts +9 -0
- package/telegram-plugin/runtime-metrics.ts +13 -0
- package/telegram-plugin/session-tail.ts +96 -11
- package/telegram-plugin/silence-poke.ts +170 -24
- package/telegram-plugin/slot-banner-driver.ts +3 -0
- package/telegram-plugin/status-no-truncate.ts +44 -0
- package/telegram-plugin/status-reactions.ts +20 -3
- package/telegram-plugin/stream-controller.ts +4 -23
- package/telegram-plugin/stream-reply-handler.ts +6 -24
- package/telegram-plugin/streaming-metrics.ts +91 -0
- package/telegram-plugin/subagent-watcher.ts +212 -66
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
- package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
- package/telegram-plugin/tests/answer-stream.test.ts +2 -411
- package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
- package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
- package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
- package/telegram-plugin/tests/demo-mask.test.ts +127 -0
- package/telegram-plugin/tests/draft-stream.test.ts +0 -827
- package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
- package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
- package/telegram-plugin/tests/feed-survival.test.ts +526 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
- package/telegram-plugin/tests/history.test.ts +60 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
- package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
- package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
- package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
- package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
- package/telegram-plugin/tests/permission-rule.test.ts +17 -0
- package/telegram-plugin/tests/permission-title.test.ts +206 -17
- package/telegram-plugin/tests/quota-watch.test.ts +252 -9
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
- package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
- package/telegram-plugin/tests/represent-guard.test.ts +162 -0
- package/telegram-plugin/tests/session-tail.test.ts +147 -3
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
- package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
- package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
- package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
- package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
- package/telegram-plugin/tests/telegram-format.test.ts +101 -6
- package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
- package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
- package/telegram-plugin/tests/tool-labels.test.ts +67 -0
- package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
- package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
- package/telegram-plugin/tests/welcome-text.test.ts +32 -3
- package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
- package/telegram-plugin/tool-activity-summary.ts +375 -58
- package/telegram-plugin/turn-liveness-floor.ts +240 -0
- package/telegram-plugin/uat/assertions.ts +115 -0
- package/telegram-plugin/uat/driver.ts +68 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
- package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
- package/telegram-plugin/welcome-text.ts +13 -1
- package/telegram-plugin/worker-activity-feed.ts +157 -82
- package/telegram-plugin/draft-transport.ts +0 -122
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
- package/telegram-plugin/tests/draft-transport.test.ts +0 -211
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emission-authority façade — structural (source-read) assertions for PR-4a of
|
|
3
|
+
* the message-emission-determinism refactor.
|
|
4
|
+
*
|
|
5
|
+
* PR-4a introduces a kill-switched, behaviourally-IDENTICAL no-op seam: a
|
|
6
|
+
* per-foreground-turn emission-authority façade (`emission-authority.ts`) that
|
|
7
|
+
* the foreground-lane card/ping emission call sites route through. In PR-4a
|
|
8
|
+
* EVERY façade method is a thin delegate — no decision logic moves in (that is
|
|
9
|
+
* PR-4b-4e). These tests pin that the seam is wired AND that it is still a
|
|
10
|
+
* no-op:
|
|
11
|
+
*
|
|
12
|
+
* 1. The 6 drain sites + 2 finalize blocks + the over-ping block now reference
|
|
13
|
+
* the façade methods, with the per-site producer args preserved verbatim.
|
|
14
|
+
* 2. The façade's disabled and enabled branches are behaviourally identical in
|
|
15
|
+
* PR-4a (no decision token like `mayOpenActivityCard` has moved into the
|
|
16
|
+
* façade yet — that is PR-4b).
|
|
17
|
+
* 3. `SWITCHROOM_EMISSION_AUTHORITY` parses default-OFF (`=== '1'`).
|
|
18
|
+
* 4. The seam delegates to the existing
|
|
19
|
+
* `drainActivitySummary`/`clearActivitySummary`/`decideOverPing` — the
|
|
20
|
+
* load-bearing literals stay AT THE CALL SITE.
|
|
21
|
+
*
|
|
22
|
+
* Pure source-reads (the gateway IIFE can't be instantiated in-process; the
|
|
23
|
+
* pattern mirrors emission-determinism-wiring.test.ts). NO sqlite import, so
|
|
24
|
+
* this runs cleanly under vitest (the bun/vitest split gotcha).
|
|
25
|
+
*/
|
|
26
|
+
import { describe, it, expect } from 'vitest'
|
|
27
|
+
import { readFileSync } from 'node:fs'
|
|
28
|
+
import { resolve } from 'node:path'
|
|
29
|
+
|
|
30
|
+
const facadeSrc = readFileSync(
|
|
31
|
+
resolve(__dirname, '..', 'gateway', 'emission-authority.ts'),
|
|
32
|
+
'utf-8',
|
|
33
|
+
)
|
|
34
|
+
/**
|
|
35
|
+
* The façade source with comment lines stripped, so "no executable reference to
|
|
36
|
+
* X" assertions don't trip on a legitimate prose mention of a primitive name in
|
|
37
|
+
* a doc-comment (the seam is heavily documented). Drops `//`/`*`/`/**`-style
|
|
38
|
+
* comment lines; keeps real code.
|
|
39
|
+
*/
|
|
40
|
+
const facadeCode = facadeSrc
|
|
41
|
+
.split('\n')
|
|
42
|
+
.filter((l) => {
|
|
43
|
+
const t = l.trim()
|
|
44
|
+
return !(t.startsWith('*') || t.startsWith('//') || t.startsWith('/*'))
|
|
45
|
+
})
|
|
46
|
+
.join('\n')
|
|
47
|
+
const gatewaySrc = readFileSync(
|
|
48
|
+
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
49
|
+
'utf-8',
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
/** Source of a named gateway function up to the next top-level definition. */
|
|
53
|
+
function fnSrc(name: string): string {
|
|
54
|
+
const after = gatewaySrc.split(`function ${name}(`)[1] ?? ''
|
|
55
|
+
return after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('SWITCHROOM_EMISSION_AUTHORITY kill-switch (default OFF)', () => {
|
|
59
|
+
it('parses with `=== "1"` semantic so an unset env var is OFF (default off)', () => {
|
|
60
|
+
expect(facadeSrc).toMatch(
|
|
61
|
+
/EMISSION_AUTHORITY_ENABLED\s*=\s*process\.env\.SWITCHROOM_EMISSION_AUTHORITY\s*===\s*'1'/,
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('is read ONCE at module top, not per-call (a single module-level const)', () => {
|
|
66
|
+
const reads = [...facadeSrc.matchAll(/process\.env\.SWITCHROOM_EMISSION_AUTHORITY/g)]
|
|
67
|
+
expect(reads).toHaveLength(1)
|
|
68
|
+
// The const initialiser sits at module scope (no leading indentation),
|
|
69
|
+
// mirroring the gateway flag region convention.
|
|
70
|
+
expect(facadeSrc).toMatch(/\nexport const EMISSION_AUTHORITY_ENABLED =/)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('façade is a no-op in PR-4a — both kill-switch branches delegate identically', () => {
|
|
75
|
+
/** Body of a façade method up to the next method/`}` at method indentation. */
|
|
76
|
+
function methodBody(name: string): string {
|
|
77
|
+
const after = facadeSrc.split(`${name}(`)[1] ?? ''
|
|
78
|
+
// Stop at the next method declaration (two-space indent + identifier + `(`)
|
|
79
|
+
// or the class close.
|
|
80
|
+
return after.split(/\n [A-Za-z]+[(<]/)[0] ?? after
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// PR-4b: `openOrEditCard` is DELIBERATELY dropped from this no-op loop — its
|
|
84
|
+
// enabled branch now GATES `apply()` behind the OPEN verdict (no longer an
|
|
85
|
+
// identical no-op). PR-4c: `claimOrDowngradePing` is ALSO dropped — its
|
|
86
|
+
// enabled branch now COMPUTES the over-ping decision via `decideOverPing` and
|
|
87
|
+
// hands it to `applyDecision` (no longer an identical no-op). Both get
|
|
88
|
+
// dedicated shape tests below. The two remaining façade methods stay identical
|
|
89
|
+
// no-ops in both branches.
|
|
90
|
+
for (const m of [
|
|
91
|
+
'finalizeCard',
|
|
92
|
+
'markSubstantiveFinalDelivered',
|
|
93
|
+
]) {
|
|
94
|
+
it(`${m} runs the SAME delegate in the enabled and disabled branch (identical no-op)`, () => {
|
|
95
|
+
const body = methodBody(m)
|
|
96
|
+
// The enabled branch is gated on the flag and, in PR-4a, just runs apply()
|
|
97
|
+
// exactly like the disabled fall-through. Assert BOTH an
|
|
98
|
+
// `if (EMISSION_AUTHORITY_ENABLED)` branch and a bare `apply()` exist, and
|
|
99
|
+
// that NO decision token has leaked into these façade methods.
|
|
100
|
+
expect(body).toMatch(/if\s*\(EMISSION_AUTHORITY_ENABLED\)/)
|
|
101
|
+
const applyCalls = [...body.matchAll(/apply\(\)/g)]
|
|
102
|
+
// One in the enabled branch, one in the disabled fall-through.
|
|
103
|
+
expect(applyCalls.length).toBeGreaterThanOrEqual(2)
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it('no decision token leaked into the remaining no-op façade methods (finalize / markSubstantive)', () => {
|
|
108
|
+
// The two remaining no-op methods (finalize / markSubstantive) move NO
|
|
109
|
+
// decision logic in: both kill-switch branches run the same `apply()`. Only
|
|
110
|
+
// `openOrEditCard` (PR-4b) and `claimOrDowngradePing` (PR-4c) consult a
|
|
111
|
+
// decision — each does so ONLY inside its EMISSION_AUTHORITY_ENABLED branch
|
|
112
|
+
// (asserted in the dedicated describes below).
|
|
113
|
+
function methodBody(name: string): string {
|
|
114
|
+
const after = facadeSrc.split(`${name}(`)[1] ?? ''
|
|
115
|
+
const body = after.split(/\n [A-Za-z]+[(<]/)[0] ?? after
|
|
116
|
+
// Strip comment lines: the body can spill into the NEXT method's leading
|
|
117
|
+
// doc-comment (which legitimately names decideOverPing in prose). Only
|
|
118
|
+
// executable references count as a "leaked decision token".
|
|
119
|
+
return body
|
|
120
|
+
.split('\n')
|
|
121
|
+
.filter((l) => {
|
|
122
|
+
const t = l.trim()
|
|
123
|
+
return !(t.startsWith('*') || t.startsWith('//') || t.startsWith('/*'))
|
|
124
|
+
})
|
|
125
|
+
.join('\n')
|
|
126
|
+
}
|
|
127
|
+
for (const m of ['finalizeCard', 'markSubstantiveFinalDelivered']) {
|
|
128
|
+
const body = methodBody(m)
|
|
129
|
+
expect(body).not.toMatch(/computeFeedOpenVerdict\(/)
|
|
130
|
+
expect(body).not.toMatch(/decideOverPing\(/)
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('mayDrain is a PURE READ — returns activityInFlight == null, acquires no lock', () => {
|
|
135
|
+
const after = facadeSrc.split('mayDrain(')[1] ?? ''
|
|
136
|
+
const body = after.split('\n}')[0] ?? after
|
|
137
|
+
expect(body).toMatch(/activityInFlight == null/)
|
|
138
|
+
// PR-4d invariant: mayDrain must not acquire chatLock. No lock token in body.
|
|
139
|
+
expect(body).not.toMatch(/chatLock|acquire|lock\(/i)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('the PR-4d deadlock invariant is documented verbatim-ish in the module header', () => {
|
|
143
|
+
expect(facadeSrc).toMatch(/mayDrain` is a (pure|PURE) read/i)
|
|
144
|
+
expect(facadeSrc).toMatch(/must NOT acquire `chatLock`/)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('openOrEditCard — PR-4b OPEN-gate moves into the façade (inverts the 4a no-op proof for this method)', () => {
|
|
149
|
+
/** Body of `openOrEditCard` up to the next method declaration / class close. */
|
|
150
|
+
function openOrEditBody(): string {
|
|
151
|
+
const after = facadeSrc.split('openOrEditCard(')[1] ?? ''
|
|
152
|
+
return after.split(/\n [A-Za-z]+[(<]/)[0] ?? after
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
it('the façade NOW imports + consults the open verdict (computeFeedOpenVerdict) — inverted from PR-4a', () => {
|
|
156
|
+
// PR-4a asserted NO decision token had moved in. PR-4b INVERTS that for the
|
|
157
|
+
// OPEN gate: the verdict helper is imported and CALLED in the façade.
|
|
158
|
+
expect(facadeSrc).toMatch(/import\s*\{[^}]*computeFeedOpenVerdict[^}]*\}\s*from\s*'\.\/feed-open-gate\.js'/)
|
|
159
|
+
expect(facadeCode).toMatch(/computeFeedOpenVerdict\(/)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('the verdict is consulted ONLY inside the EMISSION_AUTHORITY_ENABLED branch (disabled branch stays a pure pass-through)', () => {
|
|
163
|
+
const body = openOrEditBody()
|
|
164
|
+
const flagIdx = body.indexOf('if (EMISSION_AUTHORITY_ENABLED)')
|
|
165
|
+
const verdictIdx = body.indexOf('computeFeedOpenVerdict(')
|
|
166
|
+
expect(flagIdx).toBeGreaterThan(-1)
|
|
167
|
+
expect(verdictIdx).toBeGreaterThan(-1)
|
|
168
|
+
// The verdict call sits AFTER the enabled-branch guard opens.
|
|
169
|
+
expect(verdictIdx).toBeGreaterThan(flagIdx)
|
|
170
|
+
// The DISABLED fall-through (after the enabled branch returns) is a bare
|
|
171
|
+
// apply() with no verdict: the LAST statement of the method is `apply()` and
|
|
172
|
+
// there is no second computeFeedOpenVerdict call outside the enabled branch.
|
|
173
|
+
const verdictCalls = [...body.matchAll(/computeFeedOpenVerdict\(/g)]
|
|
174
|
+
expect(verdictCalls).toHaveLength(1)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('enabled branch GUARDS apply() behind the verdict; disabled branch calls apply() UNCONDITIONALLY', () => {
|
|
178
|
+
const body = openOrEditBody()
|
|
179
|
+
// Enabled branch: a refusal returns BEFORE apply() when the OPEN is refused
|
|
180
|
+
// (the relocated `break`). The guard keys on the verdict's isOpen/mayOpen.
|
|
181
|
+
expect(body).toMatch(/if\s*\(!isOpen\s*&&\s*!mayOpen\)\s*return/)
|
|
182
|
+
// Both isOpen and mayOpen come from the destructured verdict.
|
|
183
|
+
expect(body).toMatch(/const\s*\{\s*isOpen,\s*mayOpen\s*\}\s*=\s*computeFeedOpenVerdict\(/)
|
|
184
|
+
// Disabled branch: after the enabled branch returns, the method falls
|
|
185
|
+
// through to an unconditional `apply()` then the method `}` — 4a behaviour
|
|
186
|
+
// preserved when the flag is OFF (no verdict consulted on this path).
|
|
187
|
+
expect(body).toMatch(/return\s*\n\s*\}\s*\n\s*apply\(\)\s*\n\s*\}/)
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// ─── PR-4c block — kept SEPARATED from (adjacent to, not interleaved with) the
|
|
192
|
+
// PR-4b openOrEditCard describe above, to minimize rebase conflict with 4b's
|
|
193
|
+
// edits to this same file. ────────────────────────────────────────────────────
|
|
194
|
+
describe('claimOrDowngradePing — PR-4c over-ping decision moves into the façade (inverts the 4a no-op proof for this method)', () => {
|
|
195
|
+
/** Body of `claimOrDowngradePing` up to the next method declaration / class close. */
|
|
196
|
+
function pingBody(): string {
|
|
197
|
+
const after = facadeSrc.split('claimOrDowngradePing(')[1] ?? ''
|
|
198
|
+
return after.split(/\n [A-Za-z]+[(<]/)[0] ?? after
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
it('the façade NOW imports + consults the over-ping decision (decideOverPing) — inverted from PR-4a', () => {
|
|
202
|
+
// PR-4a asserted NO decision token had moved in. PR-4c INVERTS that for the
|
|
203
|
+
// ping gate: the pure predicate is imported and CALLED in the façade.
|
|
204
|
+
expect(facadeSrc).toMatch(/import\s*\{[^}]*decideOverPing[^}]*\}\s*from\s*'\.\.\/over-ping-safety-net\.js'/)
|
|
205
|
+
expect(facadeCode).toMatch(/decideOverPing\(/)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('the decision is consulted ONLY inside the EMISSION_AUTHORITY_ENABLED branch (disabled branch stays a pure pass-through)', () => {
|
|
209
|
+
const body = pingBody()
|
|
210
|
+
const flagIdx = body.indexOf('if (EMISSION_AUTHORITY_ENABLED)')
|
|
211
|
+
const decideIdx = body.indexOf('decideOverPing(')
|
|
212
|
+
expect(flagIdx).toBeGreaterThan(-1)
|
|
213
|
+
expect(decideIdx).toBeGreaterThan(-1)
|
|
214
|
+
// The decision call sits AFTER the enabled-branch guard opens.
|
|
215
|
+
expect(decideIdx).toBeGreaterThan(flagIdx)
|
|
216
|
+
// Exactly one decideOverPing call in the façade method (the enabled branch);
|
|
217
|
+
// the disabled fall-through delegates via `disabled()` with no decision.
|
|
218
|
+
const decideCalls = [...body.matchAll(/decideOverPing\(/g)]
|
|
219
|
+
expect(decideCalls).toHaveLength(1)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('enabled branch computes-and-hands-back; disabled branch delegates via disabled() with NO decision', () => {
|
|
223
|
+
const body = pingBody()
|
|
224
|
+
// Enabled branch: compute the decision then hand it to applyDecision.
|
|
225
|
+
expect(body).toMatch(/const\s+decision\s*=\s*decideOverPing\(/)
|
|
226
|
+
expect(body).toMatch(/applyDecision\(decision\)/)
|
|
227
|
+
// Disabled branch: after the enabled branch returns, the method falls
|
|
228
|
+
// through to a bare `disabled()` then the method `}` — the disabled path
|
|
229
|
+
// never touches the façade's decision (it computes its own at the call site).
|
|
230
|
+
expect(body).toMatch(/return\s*\n\s*\}\s*\n\s*disabled\(\)\s*\n\s*\}/)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('the façade method is SYNCHRONOUS — no async, no await in the decide→applyDecision chain (atomicity invariant)', () => {
|
|
234
|
+
const body = pingBody()
|
|
235
|
+
// The #2562 atomicity invariant: the decision + the pair-set run in one
|
|
236
|
+
// synchronous block, no await between, so a racing second reply reads a
|
|
237
|
+
// consistent pair. The façade method must therefore not be async / await.
|
|
238
|
+
expect(facadeSrc).not.toMatch(/async\s+claimOrDowngradePing/)
|
|
239
|
+
expect(body).not.toMatch(/\bawait\b/)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('the call-site applyDecision thunk performs the atomic two-adjacent-line pair-set with NO await between', () => {
|
|
243
|
+
// The relocation must NOT split the #2562 pair across the façade boundary:
|
|
244
|
+
// the façade decides; the call-site thunk sets firstPingAt AND
|
|
245
|
+
// firstPingWasSubstantive on two adjacent lines, no await between.
|
|
246
|
+
expect(gatewaySrc).toMatch(
|
|
247
|
+
/turn\.firstPingAt = now\s*\n\s*turn\.firstPingWasSubstantive = replySubstantive/,
|
|
248
|
+
)
|
|
249
|
+
// No await anywhere in the executeReply over-ping block (the decide→apply→
|
|
250
|
+
// pair-set synchronous chain). Bound to the block, BEFORE the Telegraph
|
|
251
|
+
// 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)
|
|
255
|
+
const blockCode = block
|
|
256
|
+
.split('\n')
|
|
257
|
+
.filter((l) => {
|
|
258
|
+
const t = l.trim()
|
|
259
|
+
return !(t.startsWith('*') || t.startsWith('//') || t.startsWith('/*'))
|
|
260
|
+
})
|
|
261
|
+
.join('\n')
|
|
262
|
+
expect(blockCode).toMatch(/\.claimOrDowngradePing\(/)
|
|
263
|
+
expect(blockCode).not.toMatch(/\bawait\b/)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('claimOrDowngradePing appears EXACTLY ONCE in the gateway, inside the executeReply window (stream path untouched)', () => {
|
|
267
|
+
// The over-ping net exists ONLY in executeReply. executeStreamReply has no
|
|
268
|
+
// decideOverPing / firstPingAt / wasOverPingSuppressed and never calls
|
|
269
|
+
// claimOrDowngradePing — PR-4c does not touch the stream path.
|
|
270
|
+
const calls = [...gatewaySrc.matchAll(/\.claimOrDowngradePing\(/g)]
|
|
271
|
+
expect(calls).toHaveLength(1)
|
|
272
|
+
const callIdx = gatewaySrc.indexOf('.claimOrDowngradePing(')
|
|
273
|
+
const execReplyIdx = gatewaySrc.indexOf('async function executeReply(')
|
|
274
|
+
const execStreamIdx = gatewaySrc.indexOf('async function executeStreamReply(')
|
|
275
|
+
expect(execReplyIdx).toBeGreaterThan(-1)
|
|
276
|
+
expect(execStreamIdx).toBeGreaterThan(execReplyIdx)
|
|
277
|
+
// The single call is inside executeReply (before executeStreamReply starts).
|
|
278
|
+
expect(callIdx).toBeGreaterThan(execReplyIdx)
|
|
279
|
+
expect(callIdx).toBeLessThan(execStreamIdx)
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
describe('façade delegates to the existing emission primitives (call-site literals preserved)', () => {
|
|
284
|
+
it('openOrEditCard / mayDrain wrap drainActivitySummary at the call sites (not CALLED inside the façade)', () => {
|
|
285
|
+
// The façade does NOT IMPORT or CALL drainActivitySummary — the delegate
|
|
286
|
+
// thunk at the call site does, so the existing wiring oracle still sees it.
|
|
287
|
+
// (A prose mention in a doc-comment is fine.)
|
|
288
|
+
expect(facadeSrc).not.toMatch(/import .*drainActivitySummary/)
|
|
289
|
+
expect(facadeCode).not.toMatch(/drainActivitySummary\(/)
|
|
290
|
+
// The drain call literal is preserved inside an openOrEditCard delegate
|
|
291
|
+
// thunk: `openOrEditCard('<producer>', () => { turn.activityInFlight =
|
|
292
|
+
// drainActivitySummary(...) })`.
|
|
293
|
+
expect(gatewaySrc).toMatch(
|
|
294
|
+
/openOrEditCard\('[a-z]+', \(\) => \{\s*\n\s*turn\.activityInFlight = drainActivitySummary/,
|
|
295
|
+
)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('finalizeCard wraps clearActivitySummary at the call sites (not CALLED inside the façade)', () => {
|
|
299
|
+
expect(facadeSrc).not.toMatch(/import .*clearActivitySummary/)
|
|
300
|
+
expect(facadeCode).not.toMatch(/clearActivitySummary\(/)
|
|
301
|
+
expect(gatewaySrc).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(/)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('claimOrDowngradePing — the call-site disabled thunk STILL contains a literal decideOverPing( (disabled-path proof)', () => {
|
|
305
|
+
// PR-4c: the façade's ENABLED branch now imports + calls `decideOverPing`
|
|
306
|
+
// (asserted in the dedicated describe below). But the DISABLED branch keeps
|
|
307
|
+
// its OWN literal `decideOverPing(` call inside the call-site thunk, VERBATIM
|
|
308
|
+
// from PR-4b-base — so the disabled path is provably byte-identical (never
|
|
309
|
+
// depends on the façade for the decision).
|
|
310
|
+
const pingIdx = gatewaySrc.indexOf('claimOrDowngradePing(')
|
|
311
|
+
expect(pingIdx).toBeGreaterThan(-1)
|
|
312
|
+
const window = gatewaySrc.slice(pingIdx, pingIdx + 1600)
|
|
313
|
+
expect(window).toMatch(/decideOverPing\(/)
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
describe('the 7 drain sites route through the façade with producers preserved verbatim', () => {
|
|
318
|
+
it('the narrative SHOW site routes via openOrEditCard("narrative") + the producer-"narrative" drain', () => {
|
|
319
|
+
const body = fnSrc('showNarrativeStep')
|
|
320
|
+
expect(body).toMatch(/openOrEditCard\('narrative'/)
|
|
321
|
+
// The drain literal (producer arg verbatim) is preserved in the delegate.
|
|
322
|
+
expect(body).toMatch(/drainActivitySummary\(turn,\s*'narrative'\)/)
|
|
323
|
+
// The single-flight read is routed through the pure mayDrain.
|
|
324
|
+
expect(body).toMatch(/ea\.mayDrain\(turn\)/)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('both liveness sites in feedHeartbeatTick route via openOrEditCard("liveness") + producer-"liveness" drains', () => {
|
|
328
|
+
const body = fnSrc('feedHeartbeatTick')
|
|
329
|
+
const opens = [...body.matchAll(/openOrEditCard\('liveness'/g)]
|
|
330
|
+
expect(opens).toHaveLength(2)
|
|
331
|
+
const drains = [...body.matchAll(/drainActivitySummary\(turn,\s*'liveness'\)/g)]
|
|
332
|
+
expect(drains).toHaveLength(2)
|
|
333
|
+
// The literal the feed-heartbeat oracle greps must still be present.
|
|
334
|
+
expect(body).toMatch(/turn\.activityInFlight = drainActivitySummary/)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('the tool_label site routes via openOrEditCard("tool") + the producer-"tool" drain', () => {
|
|
338
|
+
expect(gatewaySrc).toMatch(/openOrEditCard\('tool',\s*\(\) => \{\s*\n\s*turn\.activityInFlight = drainActivitySummary\(turn,\s*'tool'\)/)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('both foreground sub-agent drains route via openOrEditCard("tool") with producer made EXPLICIT', () => {
|
|
342
|
+
// Previously these called drainActivitySummary(turn) with the implicit
|
|
343
|
+
// 'tool' default; PR-4a makes 'tool' explicit at both, per the plan.
|
|
344
|
+
const opens = [...gatewaySrc.matchAll(/ea\.openOrEditCard\('tool', \(\) => \{\s*\n\s*turn\.activityInFlight = drainActivitySummary\(turn, 'tool'\)/g)]
|
|
345
|
+
// tool_label site (1) + the two sub-agent drains (2) = 3 'tool' openers.
|
|
346
|
+
expect(opens.length).toBeGreaterThanOrEqual(3)
|
|
347
|
+
// No foreground drain still uses the bare implicit-producer form.
|
|
348
|
+
expect(gatewaySrc).not.toMatch(/turn\.activityInFlight = drainActivitySummary\(turn\)\n/)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('every routed drain site guards the single-flight via ea.mayDrain(turn), not a bare activityInFlight read', () => {
|
|
352
|
+
const mayDrainGuards = [...gatewaySrc.matchAll(/if \(ea\.mayDrain\(turn\)\)/g)]
|
|
353
|
+
// narrative + 2 liveness + tool + 2 sub-agent + 1 post-answer bg-liveness (Fix 2) = 7.
|
|
354
|
+
expect(mayDrainGuards).toHaveLength(7)
|
|
355
|
+
})
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
describe('the 2 lever-2 finalize blocks route through the façade', () => {
|
|
359
|
+
it('executeReply finalize routes via markSubstantiveFinalDelivered + finalizeCard (latch + clear preserved)', () => {
|
|
360
|
+
const after = gatewaySrc.split('async function executeReply(')[1] ?? ''
|
|
361
|
+
const body = after.split('async function executeStreamReply(')[0] ?? after
|
|
362
|
+
expect(body).toMatch(/markSubstantiveFinalDelivered\(\(\) => \{\s*\n\s*finalizeTurn\.finalAnswerEverDelivered = true/)
|
|
363
|
+
expect(body).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(finalizeTurn\)/)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('executeStreamReply finalize routes via markSubstantiveFinalDelivered + finalizeCard (latch + clear preserved)', () => {
|
|
367
|
+
const after = gatewaySrc.split('async function executeStreamReply(')[1] ?? ''
|
|
368
|
+
const body = after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
|
|
369
|
+
expect(body).toMatch(/markSubstantiveFinalDelivered\(\(\) => \{\s*\n\s*turn\.finalAnswerEverDelivered = true/)
|
|
370
|
+
expect(body).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(turn\)/)
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
describe('per-turn construction — one façade per turn, explicit chat/thread key (PR-4e seam)', () => {
|
|
375
|
+
it('the turn ctor constructs a fresh EmissionAuthority with the explicit statusKey', () => {
|
|
376
|
+
// Per-turn: born in the CurrentTurn object literal so it is discarded with
|
|
377
|
+
// the turn and never persists across turns.
|
|
378
|
+
expect(gatewaySrc).toMatch(/emissionAuthority: new EmissionAuthority\(\s*\n\s*statusKey\(/)
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('the façade constructor takes the chat/thread key EXPLICITLY (the PR-4e map seam)', () => {
|
|
382
|
+
expect(facadeSrc).toMatch(/constructor\(private readonly chatKey: string\)/)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('the accessor lazily backfills a per-turn façade keyed on the turn\'s statusKey', () => {
|
|
386
|
+
const body = fnSrc('emissionAuthorityFor')
|
|
387
|
+
expect(body).toMatch(/new EmissionAuthority\(\s*\n\s*statusKey\(turn\.sessionChatId, turn\.sessionThreadId\)/)
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
// ─── PR-4d block — APPENDED. The card-drain path now unifies the single-flight
|
|
392
|
+
// read with the #2137 deliver-before-drain gate, behind the kill-switch. The
|
|
393
|
+
// new façade method is a PURE read; the GATEWAY acquires chatLock around it.
|
|
394
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
395
|
+
describe('mayDrainCardNow — PR-4d card-drain gate (pure read; gateway holds the lock)', () => {
|
|
396
|
+
/** Body of `mayDrainCardNow` up to the next method declaration / class close. */
|
|
397
|
+
function cardNowBody(): string {
|
|
398
|
+
const after = facadeSrc.split('mayDrainCardNow(')[1] ?? ''
|
|
399
|
+
return after.split(/\n [A-Za-z]+[(<]/)[0] ?? after
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
it('the new card-drain gate method EXISTS on the façade', () => {
|
|
403
|
+
expect(facadeSrc).toMatch(/mayDrainCardNow\(turn: EmissionTurnView, ctx: CardDrainGateCtx\): boolean/)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('routes the single-flight via the PURE mayDrain (this.mayDrain(turn)) in BOTH branches', () => {
|
|
407
|
+
const body = cardNowBody()
|
|
408
|
+
// OFF (default) → exactly this.mayDrain(turn). ON → this.mayDrain(turn) && …
|
|
409
|
+
const mayDrainCalls = [...body.matchAll(/this\.mayDrain\(turn\)/g)]
|
|
410
|
+
expect(mayDrainCalls.length).toBeGreaterThanOrEqual(2)
|
|
411
|
+
// Enabled branch combines the single-flight read with the deliver-before-
|
|
412
|
+
// drain predicate.
|
|
413
|
+
expect(body).toMatch(/this\.mayDrain\(turn\)\s*&&\s*\n?\s*mayDrainBufferedInbound\(/)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('imports the PURE mayDrainBufferedInbound from serialize-drain-gate.js (façade imports the helper, never the gateway)', () => {
|
|
417
|
+
expect(facadeSrc).toMatch(
|
|
418
|
+
/import\s*\{\s*mayDrainBufferedInbound\s*\}\s*from\s*'\.\/serialize-drain-gate\.js'/,
|
|
419
|
+
)
|
|
420
|
+
expect(facadeCode).toMatch(/mayDrainBufferedInbound\(/)
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('the deliver-before-drain term is consulted ONLY inside the EMISSION_AUTHORITY_ENABLED branch; OFF is byte-equivalent to mayDrain', () => {
|
|
424
|
+
const body = cardNowBody()
|
|
425
|
+
const flagIdx = body.indexOf('if (EMISSION_AUTHORITY_ENABLED)')
|
|
426
|
+
const predIdx = body.indexOf('mayDrainBufferedInbound(')
|
|
427
|
+
expect(flagIdx).toBeGreaterThan(-1)
|
|
428
|
+
expect(predIdx).toBeGreaterThan(flagIdx)
|
|
429
|
+
// Exactly one mayDrainBufferedInbound call (the enabled branch).
|
|
430
|
+
expect([...body.matchAll(/mayDrainBufferedInbound\(/g)]).toHaveLength(1)
|
|
431
|
+
// The disabled fall-through is a bare `return this.mayDrain(turn)`.
|
|
432
|
+
expect(body).toMatch(/return this\.mayDrain\(turn\)\s*\n\s*\}/)
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('the card-gate method itself acquires NO lock — no chatLock / lock token in the body', () => {
|
|
436
|
+
const body = cardNowBody()
|
|
437
|
+
expect(body).not.toMatch(/chatLock|\.run\(|acquire|lock\(/i)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('the gateway acquires the card-path chatLock — a SINGLE chatLock.run inside the EMISSION_AUTHORITY_ENABLED branch, NOT wrapping an await on drainActivitySummary/sendMessage', () => {
|
|
441
|
+
// The card-drain gate helper is the ONLY place the card path touches
|
|
442
|
+
// chatLock. Locate the helper and assert its shape.
|
|
443
|
+
const helperIdx = gatewaySrc.indexOf('function cardDrainGate(')
|
|
444
|
+
expect(helperIdx).toBeGreaterThan(-1)
|
|
445
|
+
const helper = gatewaySrc.slice(helperIdx, helperIdx + 700)
|
|
446
|
+
const helperCode = helper
|
|
447
|
+
.split('\n')
|
|
448
|
+
.filter((l) => {
|
|
449
|
+
const t = l.trim()
|
|
450
|
+
return !(t.startsWith('*') || t.startsWith('//') || t.startsWith('/*'))
|
|
451
|
+
})
|
|
452
|
+
.join('\n')
|
|
453
|
+
// The lock acquisition sits INSIDE the EMISSION_AUTHORITY_ENABLED branch.
|
|
454
|
+
const flagIdx = helperCode.indexOf('if (EMISSION_AUTHORITY_ENABLED)')
|
|
455
|
+
const lockIdx = helperCode.indexOf('chatLock.run(')
|
|
456
|
+
expect(flagIdx).toBeGreaterThan(-1)
|
|
457
|
+
expect(lockIdx).toBeGreaterThan(flagIdx)
|
|
458
|
+
// It consults the pure mayDrainCardNow inside the lock.
|
|
459
|
+
expect(helperCode).toMatch(/ea\.mayDrainCardNow\(turn, cardDrainGateCtx\(\)\)/)
|
|
460
|
+
// The lock body does NOT await drainActivitySummary / sendMessage — the
|
|
461
|
+
// drain assignment runs synchronously inside `run()`; the async send is NOT
|
|
462
|
+
// awaited inside the lock (so a card OPEN never holds chatLock across the
|
|
463
|
+
// gate's release).
|
|
464
|
+
expect(helperCode).not.toMatch(/await\s+drainActivitySummary/)
|
|
465
|
+
expect(helperCode).not.toMatch(/await\s+\w*[sS]endMessage/)
|
|
466
|
+
// Exactly ONE chatLock.run in the card-drain helper.
|
|
467
|
+
expect([...helperCode.matchAll(/chatLock\.run\(/g)]).toHaveLength(1)
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('the card-path gate threads endingTurnFinalAnswerDelivered: null (the §5 modeling decision)', () => {
|
|
471
|
+
// The gateway ctx builder fixes the card path to null so the deliver-before-
|
|
472
|
+
// drain predicate degenerates to !turnInFlight — the card single-flight is
|
|
473
|
+
// governed by activityInFlight, not an ending turn's delivery state.
|
|
474
|
+
const ctxIdx = gatewaySrc.indexOf('function cardDrainGateCtx(')
|
|
475
|
+
expect(ctxIdx).toBeGreaterThan(-1)
|
|
476
|
+
const ctxFn = gatewaySrc.slice(ctxIdx, ctxIdx + 400)
|
|
477
|
+
expect(ctxFn).toMatch(/endingTurnFinalAnswerDelivered:\s*null/)
|
|
478
|
+
expect(ctxFn).toMatch(/turnInFlight:\s*turnInFlightForGate\(\)/)
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it('the 7 card-drain sites each route their guarded block through cardDrainGate (single-flight gate stays byte-identical)', () => {
|
|
482
|
+
// Option A: the 7 `if (ea.mayDrain(turn))` guards + drainActivitySummary
|
|
483
|
+
// thunks stay byte-identical, wrapped by the centralized helper.
|
|
484
|
+
// 6 original + 1 new post-answer background-agent liveness drain (Fix 2).
|
|
485
|
+
const wraps = [...gatewaySrc.matchAll(/cardDrainGate\(turn, ea, \(\) => \{/g)]
|
|
486
|
+
expect(wraps).toHaveLength(7)
|
|
487
|
+
})
|
|
488
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
|
3
|
+
import { tmpdir } from 'os'
|
|
4
|
+
import { join } from 'path'
|
|
5
|
+
import {
|
|
6
|
+
initHistory,
|
|
7
|
+
recordOutbound,
|
|
8
|
+
hasOutboundDeliveredSince,
|
|
9
|
+
_resetForTests,
|
|
10
|
+
} from '../history.js'
|
|
11
|
+
import {
|
|
12
|
+
mayOpenActivityCard,
|
|
13
|
+
computeCrossTurnAnswerDelivered,
|
|
14
|
+
type FeedOpenGateView,
|
|
15
|
+
type FeedOpenGateDeps,
|
|
16
|
+
type FeedOpenProducer,
|
|
17
|
+
} from '../gateway/feed-open-gate.js'
|
|
18
|
+
import { FINAL_ANSWER_MIN_CHARS } from '../final-answer-detect.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* PR-4b flag-parity proof — the HEART of the PR, at the façade layer.
|
|
22
|
+
*
|
|
23
|
+
* The emission-authority façade's `openOrEditCard` now RELOCATES main's drain
|
|
24
|
+
* OPEN-gate decision behind the `SWITCHROOM_EMISSION_AUTHORITY` kill-switch. The
|
|
25
|
+
* defining correctness property: flag-OFF ≡ flag-ON ≡ main — not one emitted
|
|
26
|
+
* card differs. This drives `openOrEditCard(producer, applySpy)` across the full
|
|
27
|
+
* input cross-product, for BOTH flag states, and asserts:
|
|
28
|
+
*
|
|
29
|
+
* - flag OFF → `applySpy` is ALWAYS called (4a behaviour — the drain's own
|
|
30
|
+
* gate is what refuses an OPEN; the façade is a pass-through).
|
|
31
|
+
* - flag ON → `applySpy` is called IFF `mayOpen` OR the card is already
|
|
32
|
+
* open (an EDIT is never gated); the SKIP set is exactly the cases where
|
|
33
|
+
* main would `break` (refuse an OPEN): `activityMessageId == null && !mayOpen`.
|
|
34
|
+
*
|
|
35
|
+
* The flag is read ONCE at module top (the asserted read-once invariant — we do
|
|
36
|
+
* NOT change that). We flip it per flag state by dynamically re-importing the
|
|
37
|
+
* façade module under a unique query string (bun re-evaluates the module, so the
|
|
38
|
+
* read-once const is recomputed against the chosen env). This is a TEST-ONLY
|
|
39
|
+
* seam — it does not touch the production read-once path. The history harness is
|
|
40
|
+
* real (a substantive row ⇒ a true cross-turn flag), so this also proves the
|
|
41
|
+
* wired predicate end-to-end.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
let stateDir: string
|
|
45
|
+
|
|
46
|
+
const SUBSTANTIVE = 'A'.repeat(FINAL_ANSWER_MIN_CHARS)
|
|
47
|
+
const CHAT = '-100888'
|
|
48
|
+
const OPENED_AT_MS = 1_000_000 * 1000
|
|
49
|
+
|
|
50
|
+
const deps: FeedOpenGateDeps = {
|
|
51
|
+
hasOutboundDeliveredSince,
|
|
52
|
+
historyEnabled: true,
|
|
53
|
+
finalAnswerMinChars: FINAL_ANSWER_MIN_CHARS,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
stateDir = mkdtempSync(join(tmpdir(), 'emission-open-gate-'))
|
|
58
|
+
initHistory(stateDir, 30)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
_resetForTests()
|
|
63
|
+
delete process.env.SWITCHROOM_EMISSION_AUTHORITY
|
|
64
|
+
if (existsSync(stateDir)) rmSync(stateDir, { recursive: true, force: true })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
let reimportSeq = 0
|
|
68
|
+
/**
|
|
69
|
+
* Re-import the façade module with the flag set as requested. Bun re-evaluates a
|
|
70
|
+
* module imported under a fresh query string, so the read-once
|
|
71
|
+
* `EMISSION_AUTHORITY_ENABLED` const is recomputed against the env we set here —
|
|
72
|
+
* a test-only seam that leaves the production read-once path untouched.
|
|
73
|
+
*/
|
|
74
|
+
async function loadFacade(enabled: boolean): Promise<typeof import('../gateway/emission-authority.js')> {
|
|
75
|
+
if (enabled) process.env.SWITCHROOM_EMISSION_AUTHORITY = '1'
|
|
76
|
+
else delete process.env.SWITCHROOM_EMISSION_AUTHORITY
|
|
77
|
+
return import(`../gateway/emission-authority.js?flagcase=${reimportSeq++}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The full cross-product of OPEN-decision inputs (mirrors the verdict test).
|
|
81
|
+
type Case = {
|
|
82
|
+
crossTurnTrue: boolean
|
|
83
|
+
finalAnswerEverDelivered: boolean
|
|
84
|
+
producer: FeedOpenProducer
|
|
85
|
+
labeledToolCount: number
|
|
86
|
+
activityMessageId: number | null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function* cases(): Generator<Case> {
|
|
90
|
+
for (const crossTurnTrue of [false, true]) {
|
|
91
|
+
for (const finalAnswerEverDelivered of [false, true]) {
|
|
92
|
+
for (const producer of ['narrative', 'tool', 'liveness'] as FeedOpenProducer[]) {
|
|
93
|
+
for (const labeledToolCount of [0, 1]) {
|
|
94
|
+
for (const activityMessageId of [null, 123] as (number | null)[]) {
|
|
95
|
+
yield { crossTurnTrue, finalAnswerEverDelivered, producer, labeledToolCount, activityMessageId }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function viewFor(c: Case): FeedOpenGateView {
|
|
104
|
+
return {
|
|
105
|
+
activityMessageId: c.activityMessageId,
|
|
106
|
+
finalAnswerEverDelivered: c.finalAnswerEverDelivered,
|
|
107
|
+
labeledToolCount: c.labeledToolCount,
|
|
108
|
+
crossTurnGate: { sinceMs: OPENED_AT_MS },
|
|
109
|
+
sessionChatId: CHAT,
|
|
110
|
+
sessionThreadId: null,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The exact `mayOpen` oracle (direct mayOpenActivityCard, real history flag). */
|
|
115
|
+
function oracleMayOpen(c: Case, view: FeedOpenGateView): boolean {
|
|
116
|
+
const crossTurnAnswerDelivered = computeCrossTurnAnswerDelivered(view, deps)
|
|
117
|
+
return mayOpenActivityCard({
|
|
118
|
+
producer: c.producer,
|
|
119
|
+
finalAnswerEverDelivered: c.finalAnswerEverDelivered,
|
|
120
|
+
labeledToolCount: c.labeledToolCount,
|
|
121
|
+
crossTurnAnswerDelivered,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe('openOrEditCard flag-parity — flag OFF always applies, flag ON applies iff main would not break', () => {
|
|
126
|
+
it('flag OFF: applySpy is ALWAYS called (4a pass-through, every input)', async () => {
|
|
127
|
+
const { EmissionAuthority } = await loadFacade(false)
|
|
128
|
+
for (const c of cases()) {
|
|
129
|
+
if (c.crossTurnTrue) {
|
|
130
|
+
recordOutbound({ chat_id: CHAT, thread_id: null, message_ids: [42], texts: [SUBSTANTIVE], ts: 1_000_001 })
|
|
131
|
+
}
|
|
132
|
+
const view = viewFor(c)
|
|
133
|
+
const ea = new EmissionAuthority('k')
|
|
134
|
+
ea.wireFeedOpenGate(() => view, deps)
|
|
135
|
+
const applySpy = vi.fn()
|
|
136
|
+
ea.openOrEditCard(c.producer, applySpy)
|
|
137
|
+
expect(applySpy).toHaveBeenCalledTimes(1)
|
|
138
|
+
_resetForTests()
|
|
139
|
+
initHistory(stateDir, 30)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('flag ON: applySpy called IFF (isOpen || mayOpen); SKIP set == cases main would break', async () => {
|
|
144
|
+
const { EmissionAuthority } = await loadFacade(true)
|
|
145
|
+
const skippedKeys: string[] = []
|
|
146
|
+
const expectedBreakKeys: string[] = []
|
|
147
|
+
for (const c of cases()) {
|
|
148
|
+
if (c.crossTurnTrue) {
|
|
149
|
+
recordOutbound({ chat_id: CHAT, thread_id: null, message_ids: [42], texts: [SUBSTANTIVE], ts: 1_000_001 })
|
|
150
|
+
}
|
|
151
|
+
const view = viewFor(c)
|
|
152
|
+
const isOpen = c.activityMessageId != null
|
|
153
|
+
const mayOpen = oracleMayOpen(c, view)
|
|
154
|
+
// Main's drain refuses (break) iff about to OPEN AND !mayOpen.
|
|
155
|
+
const mainWouldBreak = !isOpen && !mayOpen
|
|
156
|
+
|
|
157
|
+
const ea = new EmissionAuthority('k')
|
|
158
|
+
ea.wireFeedOpenGate(() => view, deps)
|
|
159
|
+
const applySpy = vi.fn()
|
|
160
|
+
ea.openOrEditCard(c.producer, applySpy)
|
|
161
|
+
|
|
162
|
+
const key = `crossTurn=${c.crossTurnTrue} final=${c.finalAnswerEverDelivered} producer=${c.producer} tools=${c.labeledToolCount} msgId=${c.activityMessageId ?? 'null'}`
|
|
163
|
+
if (applySpy.mock.calls.length === 0) skippedKeys.push(key)
|
|
164
|
+
if (mainWouldBreak) expectedBreakKeys.push(key)
|
|
165
|
+
|
|
166
|
+
// Per-case parity: applied iff NOT(main would break).
|
|
167
|
+
expect(applySpy).toHaveBeenCalledTimes(mainWouldBreak ? 0 : 1)
|
|
168
|
+
// An already-open card (EDIT) is never gated under the flag.
|
|
169
|
+
if (isOpen) expect(applySpy).toHaveBeenCalledTimes(1)
|
|
170
|
+
|
|
171
|
+
_resetForTests()
|
|
172
|
+
initHistory(stateDir, 30)
|
|
173
|
+
}
|
|
174
|
+
// Set equality: the flag-ON skip set is EXACTLY main's break set.
|
|
175
|
+
expect(skippedKeys.sort()).toEqual(expectedBreakKeys.sort())
|
|
176
|
+
// Sanity: the skip set is non-empty (the gate actually fires for some input).
|
|
177
|
+
expect(skippedKeys.length).toBeGreaterThan(0)
|
|
178
|
+
})
|
|
179
|
+
})
|