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.
Files changed (149) hide show
  1. package/dist/agent-scheduler/index.js +122 -88
  2. package/dist/auth-broker/index.js +463 -177
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +17 -14
  5. package/dist/cli/notion-write-pretool.mjs +117 -86
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
  7. package/dist/cli/self-improve-stop.mjs +428 -0
  8. package/dist/cli/skill-validate-pretool.mjs +72 -72
  9. package/dist/cli/switchroom.js +3158 -1178
  10. package/dist/host-control/main.js +2833 -355
  11. package/dist/vault/approvals/kernel-server.js +7479 -7439
  12. package/dist/vault/broker/server.js +11312 -11272
  13. package/examples/minimal.yaml +1 -0
  14. package/examples/switchroom.yaml +1 -0
  15. package/package.json +3 -3
  16. package/profiles/_base/start.sh.hbs +88 -1
  17. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  18. package/profiles/default/CLAUDE.md.hbs +0 -19
  19. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  20. package/telegram-plugin/answer-stream-flag.ts +12 -49
  21. package/telegram-plugin/answer-stream.ts +5 -150
  22. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  23. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  24. package/telegram-plugin/context-exhaustion.ts +12 -0
  25. package/telegram-plugin/demo-mask.ts +154 -0
  26. package/telegram-plugin/dist/bridge/bridge.js +167 -124
  27. package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
  28. package/telegram-plugin/dist/server.js +215 -172
  29. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  30. package/telegram-plugin/draft-stream.ts +47 -410
  31. package/telegram-plugin/final-answer-detect.ts +17 -12
  32. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  33. package/telegram-plugin/format.ts +56 -19
  34. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  35. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  36. package/telegram-plugin/gateway/auth-command.ts +70 -14
  37. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  38. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  39. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  40. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  41. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  42. package/telegram-plugin/gateway/effort-command.ts +8 -3
  43. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  44. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  45. package/telegram-plugin/gateway/gateway.ts +1837 -291
  46. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  47. package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
  48. package/telegram-plugin/gateway/represent-guard.ts +72 -0
  49. package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
  50. package/telegram-plugin/gateway/status-surface-log.ts +14 -3
  51. package/telegram-plugin/history.ts +33 -11
  52. package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
  53. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
  54. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
  55. package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
  56. package/telegram-plugin/issues-card.ts +4 -0
  57. package/telegram-plugin/model-unavailable.ts +124 -0
  58. package/telegram-plugin/narrative-dedup.ts +69 -0
  59. package/telegram-plugin/over-ping-safety-net.ts +70 -4
  60. package/telegram-plugin/package.json +3 -3
  61. package/telegram-plugin/pending-work-progress.ts +12 -0
  62. package/telegram-plugin/permission-rule.ts +32 -5
  63. package/telegram-plugin/permission-title.ts +152 -9
  64. package/telegram-plugin/quota-check.ts +13 -0
  65. package/telegram-plugin/quota-watch.ts +135 -7
  66. package/telegram-plugin/registry/turns-schema.test.ts +24 -0
  67. package/telegram-plugin/registry/turns-schema.ts +9 -0
  68. package/telegram-plugin/runtime-metrics.ts +13 -0
  69. package/telegram-plugin/session-tail.ts +96 -11
  70. package/telegram-plugin/silence-poke.ts +170 -24
  71. package/telegram-plugin/slot-banner-driver.ts +3 -0
  72. package/telegram-plugin/status-no-truncate.ts +44 -0
  73. package/telegram-plugin/status-reactions.ts +20 -3
  74. package/telegram-plugin/stream-controller.ts +4 -23
  75. package/telegram-plugin/stream-reply-handler.ts +6 -24
  76. package/telegram-plugin/streaming-metrics.ts +91 -0
  77. package/telegram-plugin/subagent-watcher.ts +212 -66
  78. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
  79. package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
  80. package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
  82. package/telegram-plugin/tests/answer-stream.test.ts +2 -411
  83. package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
  84. package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
  87. package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
  88. package/telegram-plugin/tests/demo-mask.test.ts +127 -0
  89. package/telegram-plugin/tests/draft-stream.test.ts +0 -827
  90. package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
  91. package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
  92. package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
  93. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
  94. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
  95. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
  96. package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
  97. package/telegram-plugin/tests/feed-survival.test.ts +526 -0
  98. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
  99. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
  100. package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
  101. package/telegram-plugin/tests/history.test.ts +60 -0
  102. package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
  103. package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
  104. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
  105. package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
  106. package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
  107. package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
  108. package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
  109. package/telegram-plugin/tests/permission-rule.test.ts +17 -0
  110. package/telegram-plugin/tests/permission-title.test.ts +206 -17
  111. package/telegram-plugin/tests/quota-watch.test.ts +252 -9
  112. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
  113. package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
  114. package/telegram-plugin/tests/represent-guard.test.ts +162 -0
  115. package/telegram-plugin/tests/session-tail.test.ts +147 -3
  116. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
  117. package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
  118. package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
  119. package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
  120. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
  121. package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
  122. package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
  123. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
  124. package/telegram-plugin/tests/telegram-format.test.ts +101 -6
  125. package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
  126. package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
  127. package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
  128. package/telegram-plugin/tests/tool-labels.test.ts +67 -0
  129. package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
  130. package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
  131. package/telegram-plugin/tests/welcome-text.test.ts +32 -3
  132. package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
  133. package/telegram-plugin/tool-activity-summary.ts +375 -58
  134. package/telegram-plugin/turn-liveness-floor.ts +240 -0
  135. package/telegram-plugin/uat/assertions.ts +115 -0
  136. package/telegram-plugin/uat/driver.ts +68 -0
  137. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
  138. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
  139. package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
  140. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
  141. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
  142. package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
  143. package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
  144. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
  145. package/telegram-plugin/welcome-text.ts +13 -1
  146. package/telegram-plugin/worker-activity-feed.ts +157 -82
  147. package/telegram-plugin/draft-transport.ts +0 -122
  148. package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
  149. 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, __resetDraftIdForTests } from '../answer-stream.js'
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', () => {