switchroom 0.15.45 → 0.16.5
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 +56 -15
- package/dist/auth-broker/index.js +383 -97
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +7 -4
- package/dist/cli/notion-write-pretool.mjs +35 -4
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/switchroom.js +2894 -841
- package/dist/host-control/main.js +2685 -207
- package/dist/vault/approvals/kernel-server.js +7453 -7413
- package/dist/vault/broker/server.js +11428 -11388
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +97 -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 +55 -12
- package/telegram-plugin/dist/gateway/gateway.js +2938 -977
- package/telegram-plugin/dist/server.js +55 -12
- 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 +1857 -292
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/model-command.ts +115 -4
- 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-command.test.ts +134 -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,395 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
decideOverPing,
|
|
6
|
+
type OverPingDecision,
|
|
7
|
+
} from '../over-ping-safety-net.js'
|
|
8
|
+
import type {
|
|
9
|
+
PingClaimInput,
|
|
10
|
+
PingClaimCtx,
|
|
11
|
+
} from '../gateway/emission-authority.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* PR-4c flag-parity proof — the HEART of the PR, at the façade layer.
|
|
15
|
+
*
|
|
16
|
+
* The emission-authority façade's `claimOrDowngradePing` now RELOCATES the
|
|
17
|
+
* over-ping ownership decision behind the `SWITCHROOM_EMISSION_AUTHORITY`
|
|
18
|
+
* kill-switch. The defining correctness property: flag-OFF ≡ flag-ON ≡ the
|
|
19
|
+
* direct `decideOverPing` oracle — not one device ping differs in either flag
|
|
20
|
+
* state. This drives `claimOrDowngradePing(input, ctx, applyDecision, disabled)`
|
|
21
|
+
* across the full cross-product:
|
|
22
|
+
*
|
|
23
|
+
* incoming {ack, substantive, silent} × slot-held-by {none, ack-held,
|
|
24
|
+
* substantive-held}
|
|
25
|
+
*
|
|
26
|
+
* for BOTH flag states, against a fake turn + a stub `applyDecision` that
|
|
27
|
+
* records `(suppress, claimSlot, upgrade)` and APPLIES the mutations (the atomic
|
|
28
|
+
* `firstPingAt`/`firstPingWasSubstantive` pair-set), exactly like the gateway
|
|
29
|
+
* call site. It asserts:
|
|
30
|
+
*
|
|
31
|
+
* - flag-ON outcome set == flag-OFF outcome set == direct `decideOverPing`
|
|
32
|
+
* oracle set (the parity proof).
|
|
33
|
+
* - after a claim/upgrade the `firstPingAt`/`firstPingWasSubstantive` pair is
|
|
34
|
+
* consistently set (the #2562 atomicity invariant).
|
|
35
|
+
* - the at-most-once UPGRADE via the ack→upgrade→trailing-ack sequence.
|
|
36
|
+
*
|
|
37
|
+
* The flag is read ONCE at module top (the asserted read-once invariant — we do
|
|
38
|
+
* NOT change that). We flip it per flag state by dynamically re-importing the
|
|
39
|
+
* façade module under a unique query string (bun/vite re-evaluates the module,
|
|
40
|
+
* so the read-once const is recomputed against the chosen env). This is a
|
|
41
|
+
* TEST-ONLY seam — it leaves the production read-once path untouched. Mirrors
|
|
42
|
+
* the `loadFacade` seam in emission-authority-open-gate.test.ts. NO sqlite
|
|
43
|
+
* import, so this runs cleanly under both vitest and bun.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const NOW = 1_700_000_000_000
|
|
47
|
+
const PRIOR_PING_AT = NOW - 5_000
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
delete process.env.SWITCHROOM_EMISSION_AUTHORITY
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
let reimportSeq = 0
|
|
54
|
+
async function loadFacade(
|
|
55
|
+
enabled: boolean,
|
|
56
|
+
): Promise<typeof import('../gateway/emission-authority.js')> {
|
|
57
|
+
if (enabled) process.env.SWITCHROOM_EMISSION_AUTHORITY = '1'
|
|
58
|
+
else delete process.env.SWITCHROOM_EMISSION_AUTHORITY
|
|
59
|
+
return import(`../gateway/emission-authority.js?pingcase=${reimportSeq++}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A minimal stand-in for the turn's ping-slot state. */
|
|
63
|
+
interface FakeTurn {
|
|
64
|
+
firstPingAt: number | null
|
|
65
|
+
firstPingWasSubstantive: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type SlotHeld = 'none' | 'ack' | 'substantive'
|
|
69
|
+
type Incoming = 'ack' | 'substantive' | 'silent'
|
|
70
|
+
|
|
71
|
+
function turnFor(slot: SlotHeld): FakeTurn {
|
|
72
|
+
switch (slot) {
|
|
73
|
+
case 'none':
|
|
74
|
+
return { firstPingAt: null, firstPingWasSubstantive: false }
|
|
75
|
+
case 'ack':
|
|
76
|
+
return { firstPingAt: PRIOR_PING_AT, firstPingWasSubstantive: false }
|
|
77
|
+
case 'substantive':
|
|
78
|
+
return { firstPingAt: PRIOR_PING_AT, firstPingWasSubstantive: true }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function inputFor(incoming: Incoming): PingClaimInput {
|
|
83
|
+
switch (incoming) {
|
|
84
|
+
case 'silent':
|
|
85
|
+
return { modelRequestedPing: false, substantive: false }
|
|
86
|
+
case 'ack':
|
|
87
|
+
return { modelRequestedPing: true, substantive: false }
|
|
88
|
+
case 'substantive':
|
|
89
|
+
return { modelRequestedPing: true, substantive: true }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The recorded outcome of one drive — what the call site would observe. */
|
|
94
|
+
interface Outcome {
|
|
95
|
+
suppress: boolean
|
|
96
|
+
claimSlot: boolean
|
|
97
|
+
upgrade: boolean
|
|
98
|
+
/** Post-state of the (atomic) pair after applyDecision ran. */
|
|
99
|
+
firstPingAt: number | null
|
|
100
|
+
firstPingWasSubstantive: boolean
|
|
101
|
+
/** Did the call-site closure write disableNotification=true? */
|
|
102
|
+
disableNotification: boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Drive `claimOrDowngradePing` once for the given flag state, mirroring the
|
|
107
|
+
* gateway call site: the `applyDecision` thunk records the decision AND applies
|
|
108
|
+
* the atomic pair-set + the disableNotification closure write; the `disabled`
|
|
109
|
+
* thunk computes its own decision via the literal `decideOverPing` and routes it
|
|
110
|
+
* through the SAME `applyDecision`.
|
|
111
|
+
*/
|
|
112
|
+
async function drive(
|
|
113
|
+
enabled: boolean,
|
|
114
|
+
incoming: Incoming,
|
|
115
|
+
slot: SlotHeld,
|
|
116
|
+
): Promise<Outcome> {
|
|
117
|
+
const { EmissionAuthority } = await loadFacade(enabled)
|
|
118
|
+
const turn = turnFor(slot)
|
|
119
|
+
const input = inputFor(incoming)
|
|
120
|
+
const ctx: PingClaimCtx = {
|
|
121
|
+
firstPingAt: turn.firstPingAt,
|
|
122
|
+
firstPingWasSubstantive: turn.firstPingWasSubstantive,
|
|
123
|
+
nowMs: NOW,
|
|
124
|
+
}
|
|
125
|
+
const replySubstantive = input.substantive
|
|
126
|
+
let disableNotification = false
|
|
127
|
+
let recorded: OverPingDecision | null = null
|
|
128
|
+
|
|
129
|
+
const applyDecision = (decision: OverPingDecision): void => {
|
|
130
|
+
recorded = decision
|
|
131
|
+
if (decision.suppress) {
|
|
132
|
+
disableNotification = true
|
|
133
|
+
} else if (decision.claimSlot) {
|
|
134
|
+
// The atomic two-adjacent-line pair-set (no await between).
|
|
135
|
+
turn.firstPingAt = NOW
|
|
136
|
+
turn.firstPingWasSubstantive = replySubstantive
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
new EmissionAuthority('k').claimOrDowngradePing(
|
|
141
|
+
input,
|
|
142
|
+
ctx,
|
|
143
|
+
applyDecision,
|
|
144
|
+
() => {
|
|
145
|
+
const decision = decideOverPing({
|
|
146
|
+
modelRequestedPing: input.modelRequestedPing,
|
|
147
|
+
firstPingAt: turn.firstPingAt,
|
|
148
|
+
substantive: replySubstantive,
|
|
149
|
+
firstPingWasSubstantive: turn.firstPingWasSubstantive,
|
|
150
|
+
nowMs: NOW,
|
|
151
|
+
})
|
|
152
|
+
applyDecision(decision)
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const d = recorded as OverPingDecision | null
|
|
157
|
+
if (d == null) throw new Error('applyDecision was never called')
|
|
158
|
+
return {
|
|
159
|
+
suppress: d.suppress,
|
|
160
|
+
claimSlot: d.claimSlot,
|
|
161
|
+
upgrade: d.upgrade,
|
|
162
|
+
firstPingAt: turn.firstPingAt,
|
|
163
|
+
firstPingWasSubstantive: turn.firstPingWasSubstantive,
|
|
164
|
+
disableNotification,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The direct oracle: `decideOverPing` over the same inputs, then the effects. */
|
|
169
|
+
function oracle(incoming: Incoming, slot: SlotHeld): Outcome {
|
|
170
|
+
const turn = turnFor(slot)
|
|
171
|
+
const input = inputFor(incoming)
|
|
172
|
+
const decision = decideOverPing({
|
|
173
|
+
modelRequestedPing: input.modelRequestedPing,
|
|
174
|
+
firstPingAt: turn.firstPingAt,
|
|
175
|
+
substantive: input.substantive,
|
|
176
|
+
firstPingWasSubstantive: turn.firstPingWasSubstantive,
|
|
177
|
+
nowMs: NOW,
|
|
178
|
+
})
|
|
179
|
+
let disableNotification = false
|
|
180
|
+
if (decision.suppress) {
|
|
181
|
+
disableNotification = true
|
|
182
|
+
} else if (decision.claimSlot) {
|
|
183
|
+
turn.firstPingAt = NOW
|
|
184
|
+
turn.firstPingWasSubstantive = input.substantive
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
suppress: decision.suppress,
|
|
188
|
+
claimSlot: decision.claimSlot,
|
|
189
|
+
upgrade: decision.upgrade,
|
|
190
|
+
firstPingAt: turn.firstPingAt,
|
|
191
|
+
firstPingWasSubstantive: turn.firstPingWasSubstantive,
|
|
192
|
+
disableNotification,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const INCOMING: Incoming[] = ['ack', 'substantive', 'silent']
|
|
197
|
+
const SLOTS: SlotHeld[] = ['none', 'ack', 'substantive']
|
|
198
|
+
|
|
199
|
+
describe('claimOrDowngradePing flag-parity — flag-ON ≡ flag-OFF ≡ decideOverPing oracle', () => {
|
|
200
|
+
for (const incoming of INCOMING) {
|
|
201
|
+
for (const slot of SLOTS) {
|
|
202
|
+
it(`incoming=${incoming} slot-held-by=${slot}: ON == OFF == oracle`, async () => {
|
|
203
|
+
const off = await drive(false, incoming, slot)
|
|
204
|
+
const on = await drive(true, incoming, slot)
|
|
205
|
+
const want = oracle(incoming, slot)
|
|
206
|
+
expect(off).toEqual(want)
|
|
207
|
+
expect(on).toEqual(want)
|
|
208
|
+
// Atomicity: after a claim/upgrade the pair is consistently set.
|
|
209
|
+
if (on.claimSlot) {
|
|
210
|
+
expect(on.firstPingAt).toBe(NOW)
|
|
211
|
+
expect(on.firstPingWasSubstantive).toBe(incoming === 'substantive')
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
it('full set equality — the ON outcome set equals the OFF set equals the oracle set', async () => {
|
|
218
|
+
const onSet: Outcome[] = []
|
|
219
|
+
const offSet: Outcome[] = []
|
|
220
|
+
const oracleSet: Outcome[] = []
|
|
221
|
+
for (const incoming of INCOMING) {
|
|
222
|
+
for (const slot of SLOTS) {
|
|
223
|
+
offSet.push(await drive(false, incoming, slot))
|
|
224
|
+
onSet.push(await drive(true, incoming, slot))
|
|
225
|
+
oracleSet.push(oracle(incoming, slot))
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
expect(onSet).toEqual(oracleSet)
|
|
229
|
+
expect(offSet).toEqual(oracleSet)
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
describe('claimOrDowngradePing — the documented R8 ownership outcomes', () => {
|
|
234
|
+
it('substantive over an ack-held slot UPGRADES (pings + claims, no suppress)', async () => {
|
|
235
|
+
for (const enabled of [false, true]) {
|
|
236
|
+
const o = await drive(enabled, 'substantive', 'ack')
|
|
237
|
+
expect(o.suppress).toBe(false)
|
|
238
|
+
expect(o.claimSlot).toBe(true)
|
|
239
|
+
expect(o.upgrade).toBe(true)
|
|
240
|
+
expect(o.disableNotification).toBe(false)
|
|
241
|
+
// The slot is upgraded to substantive.
|
|
242
|
+
expect(o.firstPingAt).toBe(NOW)
|
|
243
|
+
expect(o.firstPingWasSubstantive).toBe(true)
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('ack over a substantive-held slot SUPPRESSES (no spurious double-ping)', async () => {
|
|
248
|
+
for (const enabled of [false, true]) {
|
|
249
|
+
const o = await drive(enabled, 'ack', 'substantive')
|
|
250
|
+
expect(o.suppress).toBe(true)
|
|
251
|
+
expect(o.claimSlot).toBe(false)
|
|
252
|
+
expect(o.disableNotification).toBe(true)
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('substantive over a substantive-held slot SUPPRESSES (#1674 answer+wrap-up guard)', async () => {
|
|
257
|
+
for (const enabled of [false, true]) {
|
|
258
|
+
const o = await drive(enabled, 'substantive', 'substantive')
|
|
259
|
+
expect(o.suppress).toBe(true)
|
|
260
|
+
expect(o.claimSlot).toBe(false)
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('first ping (no slot held) CLAIMS without upgrade', async () => {
|
|
265
|
+
for (const enabled of [false, true]) {
|
|
266
|
+
const o = await drive(enabled, 'ack', 'none')
|
|
267
|
+
expect(o.suppress).toBe(false)
|
|
268
|
+
expect(o.claimSlot).toBe(true)
|
|
269
|
+
expect(o.upgrade).toBe(false)
|
|
270
|
+
expect(o.firstPingAt).toBe(NOW)
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('silent incoming is a no-op (the safety net never touches a model-silent reply)', async () => {
|
|
275
|
+
for (const enabled of [false, true]) {
|
|
276
|
+
for (const slot of SLOTS) {
|
|
277
|
+
const o = await drive(enabled, 'silent', slot)
|
|
278
|
+
expect(o.suppress).toBe(false)
|
|
279
|
+
expect(o.claimSlot).toBe(false)
|
|
280
|
+
expect(o.upgrade).toBe(false)
|
|
281
|
+
expect(o.disableNotification).toBe(false)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
describe('claimOrDowngradePing — at-most-once upgrade (ack → upgrade → trailing-ack)', () => {
|
|
288
|
+
/**
|
|
289
|
+
* Drive a 3-reply turn against ONE persistent fake turn (the façade decides,
|
|
290
|
+
* the applyDecision thunk mutates the shared turn): an interim ACK claims the
|
|
291
|
+
* slot, the SUBSTANTIVE answer UPGRADES it (one bounded second ping), and a
|
|
292
|
+
* trailing ACK is then SUPPRESSED — at most ONE upgrade per turn.
|
|
293
|
+
*/
|
|
294
|
+
async function driveSequence(
|
|
295
|
+
enabled: boolean,
|
|
296
|
+
): Promise<{ upgrades: number; pings: number; suppresses: number }> {
|
|
297
|
+
const { EmissionAuthority } = await loadFacade(enabled)
|
|
298
|
+
const turn: FakeTurn = { firstPingAt: null, firstPingWasSubstantive: false }
|
|
299
|
+
const ea = new EmissionAuthority('k')
|
|
300
|
+
let upgrades = 0
|
|
301
|
+
let pings = 0
|
|
302
|
+
let suppresses = 0
|
|
303
|
+
let tick = 0
|
|
304
|
+
|
|
305
|
+
const step = (incoming: Incoming): void => {
|
|
306
|
+
const input = inputFor(incoming)
|
|
307
|
+
const replySubstantive = input.substantive
|
|
308
|
+
const now = NOW + tick++
|
|
309
|
+
const ctx: PingClaimCtx = {
|
|
310
|
+
firstPingAt: turn.firstPingAt,
|
|
311
|
+
firstPingWasSubstantive: turn.firstPingWasSubstantive,
|
|
312
|
+
nowMs: now,
|
|
313
|
+
}
|
|
314
|
+
const applyDecision = (decision: OverPingDecision): void => {
|
|
315
|
+
if (decision.upgrade) upgrades++
|
|
316
|
+
if (decision.suppress) suppresses++
|
|
317
|
+
if (decision.claimSlot) {
|
|
318
|
+
// A claim/upgrade lets the ping through.
|
|
319
|
+
pings++
|
|
320
|
+
turn.firstPingAt = now
|
|
321
|
+
turn.firstPingWasSubstantive = replySubstantive
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
ea.claimOrDowngradePing(input, ctx, applyDecision, () => {
|
|
325
|
+
const decision = decideOverPing({
|
|
326
|
+
modelRequestedPing: input.modelRequestedPing,
|
|
327
|
+
firstPingAt: turn.firstPingAt,
|
|
328
|
+
substantive: replySubstantive,
|
|
329
|
+
firstPingWasSubstantive: turn.firstPingWasSubstantive,
|
|
330
|
+
nowMs: now,
|
|
331
|
+
})
|
|
332
|
+
applyDecision(decision)
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
step('ack') // interim ack — claims the slot (first ping)
|
|
337
|
+
step('substantive') // answer — UPGRADES over the ack's slot (second ping)
|
|
338
|
+
step('ack') // trailing ack — suppressed
|
|
339
|
+
return { upgrades, pings, suppresses }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (const enabled of [false, true]) {
|
|
343
|
+
it(`flag ${enabled ? 'ON' : 'OFF'}: exactly one upgrade, two pings (ack-claim + answer-upgrade), one suppress`, async () => {
|
|
344
|
+
const { upgrades, pings, suppresses } = await driveSequence(enabled)
|
|
345
|
+
expect(upgrades).toBe(1)
|
|
346
|
+
expect(pings).toBe(2)
|
|
347
|
+
expect(suppresses).toBe(1)
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
describe('claimOrDowngradePing — no await in the synchronous decide→apply→pair-set chain (source-read)', () => {
|
|
353
|
+
const facadeSrc = readFileSync(
|
|
354
|
+
resolve(__dirname, '..', 'gateway', 'emission-authority.ts'),
|
|
355
|
+
'utf-8',
|
|
356
|
+
)
|
|
357
|
+
const gatewaySrc = readFileSync(
|
|
358
|
+
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
359
|
+
'utf-8',
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
it('the façade method is synchronous and await-free', () => {
|
|
363
|
+
expect(facadeSrc).not.toMatch(/async\s+claimOrDowngradePing/)
|
|
364
|
+
const after = facadeSrc.split('claimOrDowngradePing(')[1] ?? ''
|
|
365
|
+
const body = after.split(/\n [A-Za-z]+[(<]/)[0] ?? after
|
|
366
|
+
expect(body).not.toMatch(/\bawait\b/)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('the call-site over-ping block is await-free (atomic pair-set preserved)', () => {
|
|
370
|
+
// Bound the window to the over-ping block itself — from the
|
|
371
|
+
// `applyOverPingDecision` thunk to the end of the `claimOrDowngradePing`
|
|
372
|
+
// call, BEFORE the Telegraph block below (which legitimately awaits).
|
|
373
|
+
const blockStart = gatewaySrc.indexOf('const applyOverPingDecision')
|
|
374
|
+
const telegraphIdx = gatewaySrc.indexOf('// Telegraph publish (#579)', blockStart)
|
|
375
|
+
expect(blockStart).toBeGreaterThan(-1)
|
|
376
|
+
expect(telegraphIdx).toBeGreaterThan(blockStart)
|
|
377
|
+
const block = gatewaySrc.slice(blockStart, telegraphIdx)
|
|
378
|
+
// Strip comment lines so a prose "no await between" in a doc-comment does
|
|
379
|
+
// not trip the executable-await check.
|
|
380
|
+
const blockCode = block
|
|
381
|
+
.split('\n')
|
|
382
|
+
.filter((l) => {
|
|
383
|
+
const t = l.trim()
|
|
384
|
+
return !(t.startsWith('*') || t.startsWith('//') || t.startsWith('/*'))
|
|
385
|
+
})
|
|
386
|
+
.join('\n')
|
|
387
|
+
// Sanity: the single claimOrDowngradePing call is inside this window.
|
|
388
|
+
expect(blockCode).toMatch(/\.claimOrDowngradePing\(/)
|
|
389
|
+
expect(blockCode).not.toMatch(/\bawait\b/)
|
|
390
|
+
// The two-adjacent-line pair-set is intact.
|
|
391
|
+
expect(block).toMatch(
|
|
392
|
+
/turn\.firstPingAt = now\s*\n\s*turn\.firstPingWasSubstantive = replySubstantive/,
|
|
393
|
+
)
|
|
394
|
+
})
|
|
395
|
+
})
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emission-determinism wiring — structural (source-read) assertions for the
|
|
3
|
+
* deterministic activity-card OPEN gating + reply-is-last ordering changes
|
|
4
|
+
* (design `docs/message-emission-determinism.md` §9 levers 1, 2, 5; #2556).
|
|
5
|
+
*
|
|
6
|
+
* The gateway IIFE can't be instantiated in-process, so these pin the
|
|
7
|
+
* load-bearing wiring by reading gateway.ts source — same pattern as
|
|
8
|
+
* activity-ever-opened-sticky.test.ts / feed-heartbeat-liveness-open.test.ts.
|
|
9
|
+
* The pure decision logic (mayOpenActivityCard) is exercised behaviourally in
|
|
10
|
+
* feed-open-gate.test.ts; this file guards that the gateway is wired to it.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from 'vitest'
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
import { resolve } from 'node:path'
|
|
15
|
+
|
|
16
|
+
const gatewaySrc = readFileSync(
|
|
17
|
+
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
18
|
+
'utf-8',
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
/** Source of `drainActivitySummary` up to the next top-level function. */
|
|
22
|
+
function drainSrc(): string {
|
|
23
|
+
const after = gatewaySrc.split('async function drainActivitySummary(')[1] ?? ''
|
|
24
|
+
return after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('sticky finalAnswerEverDelivered latch (lever 1 precondition / R0)', () => {
|
|
28
|
+
it('is initialised false in the turn object literal (per-turn reset at turn start)', () => {
|
|
29
|
+
expect(gatewaySrc).toMatch(/finalAnswerEverDelivered:\s*false/)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('is reset to false in exactly ONE place — the turn initialiser (never cleared by reopen)', () => {
|
|
33
|
+
// Mirrors activityEverOpened's sticky-true contract: the only `false` is the
|
|
34
|
+
// per-turn init. A standalone `= false` reassignment would let reopen clear
|
|
35
|
+
// the latch and reintroduce the reorder (the R0 correction).
|
|
36
|
+
const initFalse = [...gatewaySrc.matchAll(/finalAnswerEverDelivered:\s*false/g)]
|
|
37
|
+
expect(initFalse).toHaveLength(1)
|
|
38
|
+
const resetFalse = [...gatewaySrc.matchAll(/finalAnswerEverDelivered\s*=\s*false/g)]
|
|
39
|
+
expect(resetFalse).toHaveLength(0)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('feed-reopen-gate.ts resets only the MUTABLE flag, never the sticky latch (#2141 preserved)', () => {
|
|
43
|
+
const reopenSrc = readFileSync(
|
|
44
|
+
resolve(__dirname, '..', 'gateway', 'feed-reopen-gate.ts'),
|
|
45
|
+
'utf-8',
|
|
46
|
+
)
|
|
47
|
+
expect(reopenSrc).toMatch(/finalAnswerDelivered:\s*false/)
|
|
48
|
+
expect(reopenSrc).not.toMatch(/finalAnswerEverDelivered/)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('every site that sets the sticky latch true gates on finalAnswerSubstantive', () => {
|
|
52
|
+
// The latch is set true only at the points that set finalAnswerDelivered=true,
|
|
53
|
+
// and only when the reply was substantive — so an ack never latches it.
|
|
54
|
+
const setTrue = [...gatewaySrc.matchAll(/finalAnswerEverDelivered\s*=\s*true/g)]
|
|
55
|
+
// executeReply, executeStreamReply, silent-anchor merge, + the two lever-2
|
|
56
|
+
// finalize blocks (which are themselves substantive-gated).
|
|
57
|
+
expect(setTrue.length).toBeGreaterThanOrEqual(3)
|
|
58
|
+
// Each `finalAnswerEverDelivered = true` must sit in a substantive context:
|
|
59
|
+
// either guarded by `if (turn.finalAnswerSubstantive)` or inside an
|
|
60
|
+
// `isSubstantiveFinalReply(...)` branch. Assert the substantive-gating
|
|
61
|
+
// token co-occurs (no bare unconditional latch set).
|
|
62
|
+
const bareUnconditional = [
|
|
63
|
+
...gatewaySrc.matchAll(/\n\s*(?:turn|finalizeTurn)\.finalAnswerEverDelivered\s*=\s*true/g),
|
|
64
|
+
]
|
|
65
|
+
for (const m of bareUnconditional) {
|
|
66
|
+
const idx = m.index ?? 0
|
|
67
|
+
const window = gatewaySrc.slice(Math.max(0, idx - 600), idx)
|
|
68
|
+
expect(
|
|
69
|
+
/finalAnswerSubstantive/.test(window) || /isSubstantiveFinalReply/.test(window),
|
|
70
|
+
).toBe(true)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('drainActivitySummary OPEN gate (levers 1 + 5, heartbeat-covered)', () => {
|
|
76
|
+
it('consults mayOpenActivityCard in the OPEN branch (activityMessageId == null)', () => {
|
|
77
|
+
const src = drainSrc()
|
|
78
|
+
expect(src).toMatch(/mayOpenActivityCard\(/)
|
|
79
|
+
// The gate is keyed on the sticky latch + labeledToolCount + producer.
|
|
80
|
+
expect(src).toMatch(/finalAnswerEverDelivered:\s*turn\.finalAnswerEverDelivered/)
|
|
81
|
+
expect(src).toMatch(/labeledToolCount:\s*turn\.labeledToolCount/)
|
|
82
|
+
expect(src).toMatch(/producer/)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('refusing an OPEN does NOT advance activityLastSentRender (break, not mark-sent)', () => {
|
|
86
|
+
// The gate check must `break` out of the drain loop when an OPEN is refused,
|
|
87
|
+
// BEFORE the activityLastSentRender = target write — otherwise the
|
|
88
|
+
// accumulated render is marked sent and a later OPEN-eligible producer
|
|
89
|
+
// (a tool label) skips it via the `pending !== lastSent` guard.
|
|
90
|
+
const src = drainSrc()
|
|
91
|
+
const gateIdx = src.indexOf('mayOpenActivityCard(')
|
|
92
|
+
const lastSentIdx = src.indexOf('activityLastSentRender = target')
|
|
93
|
+
expect(gateIdx).toBeGreaterThan(-1)
|
|
94
|
+
expect(lastSentIdx).toBeGreaterThan(-1)
|
|
95
|
+
expect(gateIdx).toBeLessThan(lastSentIdx)
|
|
96
|
+
// A `break` follows the gate check.
|
|
97
|
+
const afterGate = src.slice(gateIdx, lastSentIdx)
|
|
98
|
+
expect(afterGate).toMatch(/break/)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('the gate only applies to the OPEN branch — guarded on activityMessageId == null', () => {
|
|
102
|
+
const src = drainSrc()
|
|
103
|
+
const gateIdx = src.indexOf('mayOpenActivityCard(')
|
|
104
|
+
const window = src.slice(Math.max(0, gateIdx - 200), gateIdx)
|
|
105
|
+
expect(window).toMatch(/activityMessageId == null/)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('drain producers — narrative may not OPEN, liveness + tool may', () => {
|
|
110
|
+
it('showNarrativeStep drains with producer "narrative" (lever 5 base case)', () => {
|
|
111
|
+
const after = gatewaySrc.split('function showNarrativeStep(')[1] ?? ''
|
|
112
|
+
const body = after.split('\nfunction ')[0] ?? after
|
|
113
|
+
expect(body).toMatch(/drainActivitySummary\(turn,\s*'narrative'\)/)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('the liveness-open path drains with producer "liveness" (producer C preserved)', () => {
|
|
117
|
+
const after = gatewaySrc.split('function feedHeartbeatTick(')[1] ?? ''
|
|
118
|
+
const body = after.split('\nfunction ')[0] ?? after
|
|
119
|
+
expect(body).toMatch(/drainActivitySummary\(turn,\s*'liveness'\)/)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('the tool_label path drains with producer "tool" (always OPEN-eligible)', () => {
|
|
123
|
+
expect(gatewaySrc).toMatch(/drainActivitySummary\(turn,\s*'tool'\)/)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('lever 2 — finalize the card BEFORE a substantive reply send', () => {
|
|
128
|
+
/** executeReply body up to executeStreamReply. */
|
|
129
|
+
function executeReplySrc(): string {
|
|
130
|
+
const after = gatewaySrc.split('async function executeReply(')[1] ?? ''
|
|
131
|
+
return after.split('async function executeStreamReply(')[0] ?? after
|
|
132
|
+
}
|
|
133
|
+
/** executeStreamReply body up to the next top-level function. */
|
|
134
|
+
function executeStreamReplySrc(): string {
|
|
135
|
+
const after = gatewaySrc.split('async function executeStreamReply(')[1] ?? ''
|
|
136
|
+
return after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it('executeReply finalizes (clearActivitySummary) before the chunk loop, gated on substantive', () => {
|
|
140
|
+
const src = executeReplySrc()
|
|
141
|
+
const clearIdx = src.indexOf('clearActivitySummary(')
|
|
142
|
+
const loopIdx = src.indexOf('for (let i = 0; i < chunks.length')
|
|
143
|
+
expect(clearIdx).toBeGreaterThan(-1)
|
|
144
|
+
expect(loopIdx).toBeGreaterThan(-1)
|
|
145
|
+
expect(clearIdx).toBeLessThan(loopIdx)
|
|
146
|
+
// The finalize is substantive-gated (acks do nothing — R3/#2141).
|
|
147
|
+
const window = src.slice(Math.max(0, clearIdx - 500), clearIdx)
|
|
148
|
+
expect(window).toMatch(/isSubstantiveFinalReply/)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('executeStreamReply finalizes before handleStreamReply, gated on substantive', () => {
|
|
152
|
+
const src = executeStreamReplySrc()
|
|
153
|
+
const clearIdx = src.indexOf('clearActivitySummary(')
|
|
154
|
+
const sendIdx = src.indexOf('const result = await handleStreamReply(')
|
|
155
|
+
expect(clearIdx).toBeGreaterThan(-1)
|
|
156
|
+
expect(sendIdx).toBeGreaterThan(-1)
|
|
157
|
+
expect(clearIdx).toBeLessThan(sendIdx)
|
|
158
|
+
// Window extended to 600 chars to account for the finalAnswerDeliveredAt stamp
|
|
159
|
+
// added inside the markSubstantiveFinalDelivered callback (Fix 2 / #2587).
|
|
160
|
+
const window = src.slice(Math.max(0, clearIdx - 600), clearIdx)
|
|
161
|
+
expect(window).toMatch(/isSubstantiveFinalReply/)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('acks do NOT finalize early — no unconditional clearActivitySummary before the reply send', () => {
|
|
165
|
+
// Both lever-2 finalize sites sit inside an isSubstantiveFinalReply guard.
|
|
166
|
+
// An ack (non-substantive) falls through and never finalizes early, so the
|
|
167
|
+
// reopen path keeps owning the card (the #2141 ack-then-work feed).
|
|
168
|
+
const replySrc = (() => {
|
|
169
|
+
const after = gatewaySrc.split('async function executeReply(')[1] ?? ''
|
|
170
|
+
return after.split('async function executeStreamReply(')[0] ?? after
|
|
171
|
+
})()
|
|
172
|
+
// The pre-loop clearActivitySummary must be the substantive-gated one.
|
|
173
|
+
const preLoop = replySrc.split('for (let i = 0; i < chunks.length')[0] ?? ''
|
|
174
|
+
const clears = [...preLoop.matchAll(/clearActivitySummary\(/g)]
|
|
175
|
+
expect(clears).toHaveLength(1)
|
|
176
|
+
})
|
|
177
|
+
})
|