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,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the resume-after-swap gate (auth-failover-stall Fix 1).
|
|
3
|
+
*
|
|
4
|
+
* The gate owns the decision the gateway consults in doFireFleetAutoFallback
|
|
5
|
+
* after a SUCCESSFUL swap: should we restart to resume the turn the mid-turn
|
|
6
|
+
* 429 killed? It must:
|
|
7
|
+
* - return 'resume' on the first switched outcome (so exactly one restart
|
|
8
|
+
* fires), recorded so a follow-on swap is suppressed;
|
|
9
|
+
* - return 'skip-inflight' on a SECOND swap within the single-flight window
|
|
10
|
+
* (a 429 storm cannot loop-restart the agent);
|
|
11
|
+
* - return 'skip-stale' when the failed turn is older than maxAgeMs (an
|
|
12
|
+
* ancient interrupted turn is not resurrected);
|
|
13
|
+
* - never be consulted on all-blocked (verified by the gateway-seam test
|
|
14
|
+
* below, which only calls decide() on 'switched').
|
|
15
|
+
*
|
|
16
|
+
* The gate is pure (no process restart), so these tests run with a fake clock
|
|
17
|
+
* and never touch a real process — the restart itself is a separate seam
|
|
18
|
+
* (triggerSelfRestart) that the gateway wires to a 'resume' verdict.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect } from 'vitest'
|
|
22
|
+
import {
|
|
23
|
+
createFleetFallbackResumeGate,
|
|
24
|
+
DEFAULT_RESUME_MAX_AGE_MS,
|
|
25
|
+
DEFAULT_RESUME_SINGLE_FLIGHT_MS,
|
|
26
|
+
} from '../fleet-fallback-resume.js'
|
|
27
|
+
|
|
28
|
+
function fakeClock(start = 1_000_000) {
|
|
29
|
+
let t = start
|
|
30
|
+
return {
|
|
31
|
+
now: () => t,
|
|
32
|
+
advance: (ms: number) => {
|
|
33
|
+
t += ms
|
|
34
|
+
},
|
|
35
|
+
set: (ms: number) => {
|
|
36
|
+
t = ms
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('createFleetFallbackResumeGate — resume verdict', () => {
|
|
42
|
+
it("returns 'resume' on the first switched outcome", () => {
|
|
43
|
+
const clk = fakeClock()
|
|
44
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now })
|
|
45
|
+
// Failed turn started just now → fresh, not stale.
|
|
46
|
+
expect(gate.decide(clk.now())).toBe('resume')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("treats a null (unknown) failed-turn timestamp as resumable", () => {
|
|
50
|
+
const clk = fakeClock()
|
|
51
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now })
|
|
52
|
+
// null defers staleness to the boot-resume 3h failsafe → resume here.
|
|
53
|
+
expect(gate.decide(null)).toBe('resume')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('createFleetFallbackResumeGate — single-flight guard', () => {
|
|
58
|
+
it('suppresses a SECOND swap inside the single-flight window (no double-resume)', () => {
|
|
59
|
+
const clk = fakeClock()
|
|
60
|
+
const gate = createFleetFallbackResumeGate({
|
|
61
|
+
nowFn: clk.now,
|
|
62
|
+
singleFlightMs: DEFAULT_RESUME_SINGLE_FLIGHT_MS,
|
|
63
|
+
})
|
|
64
|
+
expect(gate.decide(clk.now())).toBe('resume')
|
|
65
|
+
// A 429 storm: a second swap fires 1s later. Must NOT re-arm.
|
|
66
|
+
clk.advance(1_000)
|
|
67
|
+
expect(gate.decide(clk.now())).toBe('skip-inflight')
|
|
68
|
+
// Still suppressed near the end of the window.
|
|
69
|
+
clk.advance(DEFAULT_RESUME_SINGLE_FLIGHT_MS - 2_000)
|
|
70
|
+
expect(gate.decide(clk.now())).toBe('skip-inflight')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('re-arms once the single-flight window has fully elapsed', () => {
|
|
74
|
+
const clk = fakeClock()
|
|
75
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now, singleFlightMs: 60_000 })
|
|
76
|
+
expect(gate.decide(clk.now())).toBe('resume')
|
|
77
|
+
clk.advance(60_001)
|
|
78
|
+
// A genuinely new swap after the window resumes again (one per swap).
|
|
79
|
+
expect(gate.decide(clk.now())).toBe('resume')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('a rapid burst of N swaps yields exactly ONE resume', () => {
|
|
83
|
+
const clk = fakeClock()
|
|
84
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now, singleFlightMs: 60_000 })
|
|
85
|
+
let resumes = 0
|
|
86
|
+
for (let i = 0; i < 10; i++) {
|
|
87
|
+
if (gate.decide(clk.now()) === 'resume') resumes++
|
|
88
|
+
clk.advance(500) // 0.5s between storm events, all inside the window
|
|
89
|
+
}
|
|
90
|
+
expect(resumes).toBe(1)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('createFleetFallbackResumeGate — staleness guard', () => {
|
|
95
|
+
it("suppresses ('skip-stale') a failed turn older than maxAgeMs", () => {
|
|
96
|
+
const clk = fakeClock()
|
|
97
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now })
|
|
98
|
+
const ancientStart = clk.now() - (DEFAULT_RESUME_MAX_AGE_MS + 60_000)
|
|
99
|
+
expect(gate.decide(ancientStart)).toBe('skip-stale')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('a stale verdict does NOT arm the single-flight window', () => {
|
|
103
|
+
const clk = fakeClock()
|
|
104
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now })
|
|
105
|
+
expect(gate.decide(clk.now() - (DEFAULT_RESUME_MAX_AGE_MS + 1))).toBe('skip-stale')
|
|
106
|
+
// A subsequent FRESH turn must still resume — the stale skip must not have
|
|
107
|
+
// recorded an arm time.
|
|
108
|
+
expect(gate.decide(clk.now())).toBe('resume')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('a turn just under maxAgeMs still resumes', () => {
|
|
112
|
+
const clk = fakeClock()
|
|
113
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now, maxAgeMs: 10_800_000 })
|
|
114
|
+
expect(gate.decide(clk.now() - (10_800_000 - 1_000))).toBe('resume')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('honours a custom maxAgeMs', () => {
|
|
118
|
+
const clk = fakeClock()
|
|
119
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now, maxAgeMs: 60_000 })
|
|
120
|
+
expect(gate.decide(clk.now() - 61_000)).toBe('skip-stale')
|
|
121
|
+
expect(gate.decide(clk.now() - 30_000)).toBe('resume')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('createFleetFallbackResumeGate — reset / inspect seams', () => {
|
|
126
|
+
it('reset() clears the single-flight arm', () => {
|
|
127
|
+
const clk = fakeClock()
|
|
128
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now })
|
|
129
|
+
expect(gate.decide(clk.now())).toBe('resume')
|
|
130
|
+
expect(gate.decide(clk.now())).toBe('skip-inflight')
|
|
131
|
+
gate.reset()
|
|
132
|
+
expect(gate.inspect().lastResumedAtMs).toBe(Number.NEGATIVE_INFINITY)
|
|
133
|
+
expect(gate.decide(clk.now())).toBe('resume')
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Gateway-seam contract test. Mirrors how doFireFleetAutoFallback consults the
|
|
139
|
+
* gate: it calls decide() ONLY on outcome.kind === 'switched', and translates a
|
|
140
|
+
* 'resume' verdict into exactly one restart. We mock the restart as a counter,
|
|
141
|
+
* so no process is touched. This pins the "all-blocked is a no-op" and
|
|
142
|
+
* "exactly-once" contracts at the call-site shape.
|
|
143
|
+
*/
|
|
144
|
+
describe('gateway seam — decide() consulted only on switched, restart fires once', () => {
|
|
145
|
+
type Outcome = { kind: 'switched' | 'all-blocked' }
|
|
146
|
+
|
|
147
|
+
function simulateDispatch(
|
|
148
|
+
gate: ReturnType<typeof createFleetFallbackResumeGate>,
|
|
149
|
+
outcome: Outcome,
|
|
150
|
+
failedTurnStartedAtMs: number | null,
|
|
151
|
+
restart: () => void,
|
|
152
|
+
): void {
|
|
153
|
+
// The actual gateway code path: resume is reached ONLY on 'switched'.
|
|
154
|
+
if (outcome.kind === 'switched') {
|
|
155
|
+
if (gate.decide(failedTurnStartedAtMs) === 'resume') restart()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
it('switched → restart fires exactly once', () => {
|
|
160
|
+
const clk = fakeClock()
|
|
161
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now })
|
|
162
|
+
let restarts = 0
|
|
163
|
+
simulateDispatch(gate, { kind: 'switched' }, clk.now(), () => restarts++)
|
|
164
|
+
expect(restarts).toBe(1)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('all-blocked → decide() is never consulted, restart never fires', () => {
|
|
168
|
+
const clk = fakeClock()
|
|
169
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now })
|
|
170
|
+
let restarts = 0
|
|
171
|
+
simulateDispatch(gate, { kind: 'all-blocked' }, clk.now(), () => restarts++)
|
|
172
|
+
expect(restarts).toBe(0)
|
|
173
|
+
// The gate stayed unarmed, so a subsequent real switch still resumes.
|
|
174
|
+
simulateDispatch(gate, { kind: 'switched' }, clk.now(), () => restarts++)
|
|
175
|
+
expect(restarts).toBe(1)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('a 429 storm of switched outcomes restarts exactly once', () => {
|
|
179
|
+
const clk = fakeClock()
|
|
180
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now, singleFlightMs: 60_000 })
|
|
181
|
+
let restarts = 0
|
|
182
|
+
for (let i = 0; i < 5; i++) {
|
|
183
|
+
simulateDispatch(gate, { kind: 'switched' }, clk.now(), () => restarts++)
|
|
184
|
+
clk.advance(1_000)
|
|
185
|
+
}
|
|
186
|
+
expect(restarts).toBe(1)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('a stale switched outcome does not restart', () => {
|
|
190
|
+
const clk = fakeClock()
|
|
191
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now })
|
|
192
|
+
let restarts = 0
|
|
193
|
+
const ancient = clk.now() - (DEFAULT_RESUME_MAX_AGE_MS + 1)
|
|
194
|
+
simulateDispatch(gate, { kind: 'switched' }, ancient, () => restarts++)
|
|
195
|
+
expect(restarts).toBe(0)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
readCleanShutdownMarker,
|
|
36
36
|
clearCleanShutdownMarker,
|
|
37
37
|
shouldSuppressRecoveryBanner,
|
|
38
|
+
shouldSuppressBootResume,
|
|
38
39
|
resolveShutdownMarker,
|
|
39
40
|
DEFAULT_MAX_AGE_MS,
|
|
40
41
|
EXTERNAL_RESTART_FALLBACK_REASON,
|
|
@@ -344,6 +345,122 @@ describe("resolveShutdownMarker (SIGTERM-handler sequencing)", () => {
|
|
|
344
345
|
});
|
|
345
346
|
});
|
|
346
347
|
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
// Boot-resume gate: shouldSuppressBootResume
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
describe("shouldSuppressBootResume", () => {
|
|
353
|
+
// Core contract: clean shutdown (fresh marker) → suppress; crash (no marker
|
|
354
|
+
// or stale) → do not suppress; forceAlways override → never suppress.
|
|
355
|
+
|
|
356
|
+
it("returns false when no marker is present (crash/OOM — resume as before)", () => {
|
|
357
|
+
expect(shouldSuppressBootResume(null, Date.now())).toBe(false);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("returns true for a fresh clean-shutdown marker (operator/roll restart — suppress)", () => {
|
|
361
|
+
const now = 1_700_000_000_000;
|
|
362
|
+
const marker: CleanShutdownMarker = { ts: now - 5_000, signal: "SIGTERM" };
|
|
363
|
+
expect(shouldSuppressBootResume(marker, now)).toBe(true);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("returns true at age=0 (marker written right before boot)", () => {
|
|
367
|
+
const now = 1_700_000_000_000;
|
|
368
|
+
const marker: CleanShutdownMarker = { ts: now, signal: "SIGTERM" };
|
|
369
|
+
expect(shouldSuppressBootResume(marker, now)).toBe(true);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("returns false when marker age equals maxAgeMs (boundary is exclusive)", () => {
|
|
373
|
+
const now = 1_700_000_000_000;
|
|
374
|
+
const marker: CleanShutdownMarker = { ts: now - DEFAULT_MAX_AGE_MS, signal: "SIGTERM" };
|
|
375
|
+
expect(shouldSuppressBootResume(marker, now)).toBe(false);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("returns false for a stale marker (drain took >60s — treat as crash)", () => {
|
|
379
|
+
const now = 1_700_000_000_000;
|
|
380
|
+
const marker: CleanShutdownMarker = { ts: now - 90_000, signal: "SIGTERM" };
|
|
381
|
+
expect(shouldSuppressBootResume(marker, now)).toBe(false);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("treats clock skew (future ts) as stale to avoid false suppression", () => {
|
|
385
|
+
const now = 1_700_000_000_000;
|
|
386
|
+
const marker: CleanShutdownMarker = { ts: now + 10_000, signal: "SIGTERM" };
|
|
387
|
+
expect(shouldSuppressBootResume(marker, now)).toBe(false);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("respects a custom maxAgeMs", () => {
|
|
391
|
+
const now = 1_700_000_000_000;
|
|
392
|
+
const marker: CleanShutdownMarker = { ts: now - 30_000, signal: "SIGTERM" };
|
|
393
|
+
expect(shouldSuppressBootResume(marker, now, { maxAgeMs: 60_000 })).toBe(true);
|
|
394
|
+
expect(shouldSuppressBootResume(marker, now, { maxAgeMs: 10_000 })).toBe(false);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("forceAlways=true disables suppression even for a fresh clean marker (escape hatch)", () => {
|
|
398
|
+
// SWITCHROOM_BOOT_RESUME_ALWAYS=1 must restore unconditional resume.
|
|
399
|
+
const now = 1_700_000_000_000;
|
|
400
|
+
const marker: CleanShutdownMarker = { ts: now - 1_000, signal: "SIGTERM" };
|
|
401
|
+
expect(shouldSuppressBootResume(marker, now, { forceAlways: true })).toBe(false);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("forceAlways=false has no effect (default behaviour is the gate)", () => {
|
|
405
|
+
const now = 1_700_000_000_000;
|
|
406
|
+
const marker: CleanShutdownMarker = { ts: now - 1_000, signal: "SIGTERM" };
|
|
407
|
+
expect(shouldSuppressBootResume(marker, now, { forceAlways: false })).toBe(true);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("works for SIGTERM and SIGINT (signal value is opaque)", () => {
|
|
411
|
+
const now = 1_700_000_000_000;
|
|
412
|
+
expect(shouldSuppressBootResume({ ts: now, signal: "SIGTERM" }, now)).toBe(true);
|
|
413
|
+
expect(shouldSuppressBootResume({ ts: now, signal: "SIGINT" }, now)).toBe(true);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("works with a marker that carries a reason field (rollout attribution is preserved)", () => {
|
|
417
|
+
const now = 1_700_000_000_000;
|
|
418
|
+
const marker: CleanShutdownMarker = {
|
|
419
|
+
ts: now - 2_000,
|
|
420
|
+
signal: "SIGTERM",
|
|
421
|
+
reason: "operator: switchroom update",
|
|
422
|
+
};
|
|
423
|
+
expect(shouldSuppressBootResume(marker, now)).toBe(true);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// Boot-resume gate: gateway wiring (source-level)
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
describe("gateway.ts boot-resume clean-shutdown gate (source-level)", () => {
|
|
432
|
+
// Source-grep pins ensure the gate wiring in gateway.ts stays present
|
|
433
|
+
// after refactors. Pure unit tests on shouldSuppressBootResume cover the
|
|
434
|
+
// decision logic; these cover the wiring.
|
|
435
|
+
const gatewaySource = readFileSync(
|
|
436
|
+
join(import.meta.dir, "..", "gateway", "gateway.ts"),
|
|
437
|
+
"utf8",
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
it("imports shouldSuppressBootResume from clean-shutdown-marker", () => {
|
|
441
|
+
expect(gatewaySource).toContain("shouldSuppressBootResume");
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("reads the clean-shutdown marker before building the boot-resume inbound", () => {
|
|
445
|
+
expect(gatewaySource).toContain("bootResumeCleanMarker");
|
|
446
|
+
expect(gatewaySource).toContain("readCleanShutdownMarker(bootResumeMarkerPath)");
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("calls shouldSuppressBootResume with the marker, now, and forceAlways", () => {
|
|
450
|
+
expect(gatewaySource).toContain("shouldSuppressBootResume(bootResumeCleanMarker, Date.now()");
|
|
451
|
+
expect(gatewaySource).toContain("forceAlways: bootResumeForceAlways");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("provides the SWITCHROOM_BOOT_RESUME_ALWAYS escape hatch", () => {
|
|
455
|
+
expect(gatewaySource).toContain("SWITCHROOM_BOOT_RESUME_ALWAYS");
|
|
456
|
+
expect(gatewaySource).toContain("=== '1'");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("logs a diagnostic when boot-resume is suppressed", () => {
|
|
460
|
+
expect(gatewaySource).toContain("boot-resume suppressed (clean shutdown");
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
347
464
|
describe("gateway.ts shutdown-handler wiring (source-level)", () => {
|
|
348
465
|
// Source-grep pins so a future refactor can't silently drop the
|
|
349
466
|
// reason-preserving + fallback-writing behaviour the 2026-04-24 fix
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
-
import { createAnswerStream
|
|
2
|
+
import { createAnswerStream } from '../answer-stream.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* #656 — gateway turn_end no-reply path.
|
|
@@ -30,7 +30,6 @@ async function flushMicrotasks(times = 10): Promise<void> {
|
|
|
30
30
|
let nextMessageId = 5000
|
|
31
31
|
|
|
32
32
|
beforeEach(() => {
|
|
33
|
-
__resetDraftIdForTests()
|
|
34
33
|
nextMessageId = 5000
|
|
35
34
|
vi.useFakeTimers()
|
|
36
35
|
})
|
|
@@ -46,9 +45,7 @@ describe('#656 — answer-stream retract() at turn_end emits nothing', () => {
|
|
|
46
45
|
const deleteMessage = vi.fn(async () => {})
|
|
47
46
|
|
|
48
47
|
const stream = createAnswerStream({
|
|
49
|
-
chatId: 'chat-no-reply',
|
|
50
|
-
isPrivateChat: false,
|
|
51
|
-
minInitialChars: 400,
|
|
48
|
+
chatId: 'chat-no-reply', minInitialChars: 400,
|
|
52
49
|
throttleMs: 250,
|
|
53
50
|
sendMessage: sendMessage as never,
|
|
54
51
|
editMessageText: editMessageText as never,
|
|
@@ -81,9 +78,7 @@ describe('#656 — answer-stream retract() at turn_end emits nothing', () => {
|
|
|
81
78
|
const deleteMessage = vi.fn(async () => {})
|
|
82
79
|
|
|
83
80
|
const stream = createAnswerStream({
|
|
84
|
-
chatId: 'supergroup-topic',
|
|
85
|
-
isPrivateChat: false, // supergroup → message transport (no draft)
|
|
86
|
-
threadId: 4,
|
|
81
|
+
chatId: 'supergroup-topic', threadId: 4,
|
|
87
82
|
minInitialChars: Number.MAX_SAFE_INTEGER,
|
|
88
83
|
throttleMs: 250,
|
|
89
84
|
sendMessage: sendMessage as never,
|
|
@@ -110,9 +105,7 @@ describe('#656 — answer-stream retract() at turn_end emits nothing', () => {
|
|
|
110
105
|
const deleteMessage = vi.fn(async () => {})
|
|
111
106
|
|
|
112
107
|
const stream = createAnswerStream({
|
|
113
|
-
chatId: 'chat-no-reply',
|
|
114
|
-
isPrivateChat: false,
|
|
115
|
-
minInitialChars: 10,
|
|
108
|
+
chatId: 'chat-no-reply', minInitialChars: 10,
|
|
116
109
|
throttleMs: THROTTLE,
|
|
117
110
|
sendMessage: sendMessage as never,
|
|
118
111
|
editMessageText: editMessageText as never,
|
|
@@ -444,6 +444,66 @@ describe('hasOutboundDeliveredSince', () => {
|
|
|
444
444
|
it('returns false when no history is present for the chat', () => {
|
|
445
445
|
expect(hasOutboundDeliveredSince('-999', 0)).toBe(false)
|
|
446
446
|
})
|
|
447
|
+
|
|
448
|
+
// #2474 follow-up — the duplicate-represent guard passes a LOW minChars so a
|
|
449
|
+
// terse-but-genuine reply counts as "the user was answered". The escalate
|
|
450
|
+
// branch keeps the 200-char default.
|
|
451
|
+
describe('minChars parameter (decoupled represent-guard threshold)', () => {
|
|
452
|
+
it('default threshold (200) does NOT count a terse real reply', () => {
|
|
453
|
+
const openedAt = 1_000_000 * 1000
|
|
454
|
+
recordOutbound({
|
|
455
|
+
chat_id: '-100',
|
|
456
|
+
thread_id: null,
|
|
457
|
+
message_ids: [10],
|
|
458
|
+
texts: ['Yes — done.'], // < 200 chars
|
|
459
|
+
ts: 1_000_001,
|
|
460
|
+
})
|
|
461
|
+
// escalate-branch behavior is unchanged: a terse reply is NOT substantive
|
|
462
|
+
expect(hasOutboundDeliveredSince('-100', openedAt)).toBe(false)
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('minChars=1 DOES count a terse real reply (fixes the #2472 terse-reply gap)', () => {
|
|
466
|
+
const openedAt = 1_000_000 * 1000
|
|
467
|
+
recordOutbound({
|
|
468
|
+
chat_id: '-100',
|
|
469
|
+
thread_id: null,
|
|
470
|
+
message_ids: [10],
|
|
471
|
+
texts: ['Merged, all three landed.'], // genuine short reply
|
|
472
|
+
ts: 1_000_001,
|
|
473
|
+
})
|
|
474
|
+
// represent-guard threshold: any real reply suppresses the duplicate
|
|
475
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, undefined, 1)).toBe(true)
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it('minChars=1 still does NOT count an empty/whitespace-only row', () => {
|
|
479
|
+
// A degenerate outbound (no real content) must never read as "answered",
|
|
480
|
+
// even at the lowest threshold — minChars is clamped to >= 1.
|
|
481
|
+
const openedAt = 1_000_000 * 1000
|
|
482
|
+
recordOutbound({
|
|
483
|
+
chat_id: '-100',
|
|
484
|
+
thread_id: null,
|
|
485
|
+
message_ids: [10],
|
|
486
|
+
texts: [''],
|
|
487
|
+
ts: 1_000_001,
|
|
488
|
+
})
|
|
489
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, undefined, 1)).toBe(false)
|
|
490
|
+
// minChars=0 is clamped up to 1, so an empty row is still excluded
|
|
491
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, undefined, 0)).toBe(false)
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('minChars=1 respects the thread filter (terse reply scoped to its thread)', () => {
|
|
495
|
+
const openedAt = 1_000_000 * 1000
|
|
496
|
+
recordOutbound({
|
|
497
|
+
chat_id: '-100',
|
|
498
|
+
thread_id: 5,
|
|
499
|
+
message_ids: [10],
|
|
500
|
+
texts: ['ok'],
|
|
501
|
+
ts: 1_000_001,
|
|
502
|
+
})
|
|
503
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, 5, 1)).toBe(true)
|
|
504
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, 6, 1)).toBe(false)
|
|
505
|
+
})
|
|
506
|
+
})
|
|
447
507
|
})
|
|
448
508
|
|
|
449
509
|
describe('secret redaction at persistence (both directions)', () => {
|
|
@@ -151,6 +151,124 @@ describe('detectModelUnavailable — reset-time extraction', () => {
|
|
|
151
151
|
})
|
|
152
152
|
})
|
|
153
153
|
|
|
154
|
+
// ─── SESSION-cap (time-only) reset parsing — auth-failover-stall Fix 2 ─────────
|
|
155
|
+
//
|
|
156
|
+
// A session cap surfaces as "resets <time>" with NO month/day. Pre-fix this
|
|
157
|
+
// was unparseable → resetAt undefined → the 429 inference path applied the +7d
|
|
158
|
+
// weekly floor, benching the account for a WEEK. The new branch resolves it to
|
|
159
|
+
// the NEXT occurrence of that wall-clock time (hours away), tz-aware.
|
|
160
|
+
describe('detectModelUnavailable — time-only session-cap reset (Fix 2)', () => {
|
|
161
|
+
const HOUR = 3600_000
|
|
162
|
+
const WEEK = 7 * 24 * HOUR
|
|
163
|
+
|
|
164
|
+
// Next occurrence of a wall-clock time in a tz must be ≤24h away — and
|
|
165
|
+
// crucially NOT the +7d weekly floor.
|
|
166
|
+
function expectHoursAway(d: Date | undefined): void {
|
|
167
|
+
expect(d).toBeInstanceOf(Date)
|
|
168
|
+
const deltaMs = (d as Date).getTime() - Date.now()
|
|
169
|
+
expect(deltaMs).toBeGreaterThan(0)
|
|
170
|
+
expect(deltaMs).toBeLessThanOrEqual(24 * HOUR + 60_000)
|
|
171
|
+
// The whole point: never the weekly floor.
|
|
172
|
+
expect(deltaMs).toBeLessThan(WEEK - HOUR)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The next wall-clock occurrence of `hour:minute` in `tz` should land on
|
|
176
|
+
// that exact minute (sanity that we resolved the time, not a fudge).
|
|
177
|
+
function expectWallClock(d: Date | undefined, tz: string, hour: number, minute = 0): void {
|
|
178
|
+
expect(d).toBeInstanceOf(Date)
|
|
179
|
+
const parts = Object.fromEntries(
|
|
180
|
+
new Intl.DateTimeFormat('en-US', {
|
|
181
|
+
timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: false,
|
|
182
|
+
})
|
|
183
|
+
.formatToParts(d as Date)
|
|
184
|
+
.filter((p) => p.type !== 'literal')
|
|
185
|
+
.map((p) => [p.type, p.value]),
|
|
186
|
+
)
|
|
187
|
+
expect(Number(parts.hour) % 24).toBe(hour)
|
|
188
|
+
expect(Number(parts.minute)).toBe(minute)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
it('parses "resets 5pm (Australia/Melbourne)" to the next 17:00 there, hours away (NOT +7d)', () => {
|
|
192
|
+
const d = detectModelUnavailable(
|
|
193
|
+
"You've hit your session limit · resets 5pm (Australia/Melbourne)",
|
|
194
|
+
)
|
|
195
|
+
expect(d?.kind).toBe('quota_exhausted')
|
|
196
|
+
expectHoursAway(d?.resetAt)
|
|
197
|
+
expectWallClock(d?.resetAt, 'Australia/Melbourne', 17, 0)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('parses the "at"-prefixed form — "resets at 5pm (Australia/Melbourne)" (parity with wedge-watchdog parseWeeklyReset)', () => {
|
|
201
|
+
// wedge-watchdog's parseWeeklyReset time-only regex accepts an optional
|
|
202
|
+
// "(?:at\s+)?" token; this parser must accept the IDENTICAL grammar or the
|
|
203
|
+
// "at"-prefixed string falls through to the +7d weekly floor — the
|
|
204
|
+
// week-long-bench bug this PR exists to kill.
|
|
205
|
+
const d = detectModelUnavailable(
|
|
206
|
+
"You've hit your session limit · resets at 5pm (Australia/Melbourne)",
|
|
207
|
+
)
|
|
208
|
+
expect(d?.kind).toBe('quota_exhausted')
|
|
209
|
+
expectHoursAway(d?.resetAt)
|
|
210
|
+
expectWallClock(d?.resetAt, 'Australia/Melbourne', 17, 0)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('parses am times — "resets 8:50am (Australia/Melbourne)"', () => {
|
|
214
|
+
const d = detectModelUnavailable("You've hit your limit · resets 8:50am (Australia/Melbourne)")
|
|
215
|
+
expect(d?.kind).toBe('quota_exhausted')
|
|
216
|
+
expectHoursAway(d?.resetAt)
|
|
217
|
+
expectWallClock(d?.resetAt, 'Australia/Melbourne', 8, 50)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('parses a time WITHOUT minutes — "resets 9am (UTC)"', () => {
|
|
221
|
+
const d = detectModelUnavailable('hit your limit · resets 9am (UTC)')
|
|
222
|
+
expect(d?.kind).toBe('quota_exhausted')
|
|
223
|
+
expectHoursAway(d?.resetAt)
|
|
224
|
+
expectWallClock(d?.resetAt, 'UTC', 9, 0)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('parses a time WITHOUT a tz label (best-effort UTC) — "resets 11pm"', () => {
|
|
228
|
+
const d = detectModelUnavailable('usage limit hit · resets 11pm')
|
|
229
|
+
expect(d?.kind).toBe('quota_exhausted')
|
|
230
|
+
expectHoursAway(d?.resetAt)
|
|
231
|
+
expectWallClock(d?.resetAt, 'UTC', 23, 0)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('parses 24-hour clock times — "resets 17:00 (UTC)"', () => {
|
|
235
|
+
const d = detectModelUnavailable('hit your limit · resets 17:00 (UTC)')
|
|
236
|
+
expect(d?.kind).toBe('quota_exhausted')
|
|
237
|
+
expectHoursAway(d?.resetAt)
|
|
238
|
+
expectWallClock(d?.resetAt, 'UTC', 17, 0)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('STILL parses a bare ISO-8601 reset (calendar-path regression guard)', () => {
|
|
242
|
+
const d = detectModelUnavailable('quota exhausted, retry at 2026-05-03T11:00:00Z')
|
|
243
|
+
expect(d?.resetAt?.toISOString()).toBe('2026-05-03T11:00:00.000Z')
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('a month/day "resets" string is NOT hijacked into the time-only branch', () => {
|
|
247
|
+
// The negative lookahead must reject "May"/"Jun" so a date-bearing string
|
|
248
|
+
// never resolves to "tomorrow at HH:MM". (The month/day+time calendar form
|
|
249
|
+
// itself does not currently resolve to a Date — that is pre-existing
|
|
250
|
+
// behaviour; the load-bearing guard is that the time-only branch leaves it
|
|
251
|
+
// alone rather than producing a WRONG hours-away time.)
|
|
252
|
+
const may = detectModelUnavailable("You're out of extra usage · resets May 3, 11am")
|
|
253
|
+
expect(may?.kind).toBe('quota_exhausted')
|
|
254
|
+
// If the time-only branch had wrongly fired, resetAt would be ≤24h away.
|
|
255
|
+
if (may?.resetAt) {
|
|
256
|
+
const deltaMs = may.resetAt.getTime() - Date.now()
|
|
257
|
+
// A genuine May-3 resolution is many days away (or in the past); never the
|
|
258
|
+
// bare next-11am-tomorrow the time-only branch would have produced.
|
|
259
|
+
expect(Math.abs(deltaMs)).toBeGreaterThan(2 * 24 * HOUR)
|
|
260
|
+
}
|
|
261
|
+
const jun = detectModelUnavailable(
|
|
262
|
+
"hit your limit · resets Jun 9, 5am (Australia/Melbourne)",
|
|
263
|
+
)
|
|
264
|
+
expect(jun?.kind).toBe('quota_exhausted')
|
|
265
|
+
if (jun?.resetAt) {
|
|
266
|
+
const deltaMs = jun.resetAt.getTime() - Date.now()
|
|
267
|
+
expect(Math.abs(deltaMs)).toBeGreaterThan(2 * 24 * HOUR)
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
154
272
|
// ─── formatModelUnavailableCard ──────────────────────────────────────────────
|
|
155
273
|
|
|
156
274
|
describe('formatModelUnavailableCard — actionable card', () => {
|