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.
Files changed (150) hide show
  1. package/dist/agent-scheduler/index.js +56 -15
  2. package/dist/auth-broker/index.js +383 -97
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +7 -4
  5. package/dist/cli/notion-write-pretool.mjs +35 -4
  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/switchroom.js +2894 -841
  9. package/dist/host-control/main.js +2685 -207
  10. package/dist/vault/approvals/kernel-server.js +7453 -7413
  11. package/dist/vault/broker/server.js +11428 -11388
  12. package/examples/minimal.yaml +1 -0
  13. package/examples/switchroom.yaml +1 -0
  14. package/package.json +3 -3
  15. package/profiles/_base/start.sh.hbs +97 -1
  16. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  17. package/profiles/default/CLAUDE.md.hbs +0 -19
  18. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  19. package/telegram-plugin/answer-stream-flag.ts +12 -49
  20. package/telegram-plugin/answer-stream.ts +5 -150
  21. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  22. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  23. package/telegram-plugin/context-exhaustion.ts +12 -0
  24. package/telegram-plugin/demo-mask.ts +154 -0
  25. package/telegram-plugin/dist/bridge/bridge.js +55 -12
  26. package/telegram-plugin/dist/gateway/gateway.js +2938 -977
  27. package/telegram-plugin/dist/server.js +55 -12
  28. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  29. package/telegram-plugin/draft-stream.ts +47 -410
  30. package/telegram-plugin/final-answer-detect.ts +17 -12
  31. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  32. package/telegram-plugin/format.ts +56 -19
  33. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  34. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  35. package/telegram-plugin/gateway/auth-command.ts +70 -14
  36. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  37. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  38. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  39. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  40. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  41. package/telegram-plugin/gateway/effort-command.ts +8 -3
  42. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  43. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  44. package/telegram-plugin/gateway/gateway.ts +1857 -292
  45. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  46. package/telegram-plugin/gateway/model-command.ts +115 -4
  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-command.test.ts +134 -0
  103. package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
  104. package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
  105. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
  106. package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
  107. package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
  108. package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
  109. package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
  110. package/telegram-plugin/tests/permission-rule.test.ts +17 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +206 -17
  112. package/telegram-plugin/tests/quota-watch.test.ts +252 -9
  113. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
  114. package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
  115. package/telegram-plugin/tests/represent-guard.test.ts +162 -0
  116. package/telegram-plugin/tests/session-tail.test.ts +147 -3
  117. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
  118. package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
  119. package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
  120. package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
  121. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
  122. package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
  123. package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
  124. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
  125. package/telegram-plugin/tests/telegram-format.test.ts +101 -6
  126. package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
  127. package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
  128. package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
  129. package/telegram-plugin/tests/tool-labels.test.ts +67 -0
  130. package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
  131. package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
  132. package/telegram-plugin/tests/welcome-text.test.ts +32 -3
  133. package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
  134. package/telegram-plugin/tool-activity-summary.ts +375 -58
  135. package/telegram-plugin/turn-liveness-floor.ts +240 -0
  136. package/telegram-plugin/uat/assertions.ts +115 -0
  137. package/telegram-plugin/uat/driver.ts +68 -0
  138. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
  139. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
  140. package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
  141. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
  142. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
  143. package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
  144. package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
  145. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
  146. package/telegram-plugin/welcome-text.ts +13 -1
  147. package/telegram-plugin/worker-activity-feed.ts +157 -82
  148. package/telegram-plugin/draft-transport.ts +0 -122
  149. package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
  150. 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)', () => {
@@ -253,7 +253,10 @@ import {
253
253
  handleModelMenuCallback,
254
254
  modelSelectCallbackData,
255
255
  sessionModelFromConfirmation,
256
+ classifyDiscoveredOptions,
256
257
  MODEL_CALLBACK_REFRESH,
258
+ MODEL_CALLBACK_SR,
259
+ SR_MODEL_LABELS,
257
260
  type ModelMenuDeps,
258
261
  } from "../gateway/model-command.js";
259
262
  import { labelTag } from "../../src/agents/model-picker.js";
@@ -422,3 +425,134 @@ describe("sessionModelFromConfirmation", () => {
422
425
  expect(out.reply.keyboard).toBeDefined();
423
426
  });
424
427
  });
428
+
429
+ // ---------------------------------------------------------------------------
430
+ // Ship D — sr-* (LiteLLM non-Anthropic) model support
431
+ // ---------------------------------------------------------------------------
432
+
433
+ const OPTIONS_WITH_SR = [
434
+ { index: 1, label: "Default (recommended)", detail: "Opus 4.8 with 1M context", current: false },
435
+ { index: 2, label: "Sonnet", detail: "Sonnet 4.6", current: true },
436
+ { index: 3, label: "sr-gemini-2.5-pro", detail: "", current: false },
437
+ { index: 4, label: "sr-deepseek-r1", detail: "", current: false },
438
+ // internal path — should be filtered out
439
+ { index: 5, label: "openrouter/google/gemini-2.5-pro", detail: "", current: false },
440
+ // bare OpenAI models from GATEWAY_MODEL_DISCOVERY — should also be filtered out
441
+ { index: 6, label: "gpt-4", detail: "", current: false },
442
+ { index: 7, label: "gpt-4o", detail: "", current: false },
443
+ { index: 8, label: "voyage-law-2", detail: "", current: false },
444
+ // full claude ID — should be in claude bucket
445
+ { index: 9, label: "claude-opus-4-8", detail: "", current: false },
446
+ ];
447
+
448
+ describe("classifyDiscoveredOptions", () => {
449
+ it("puts native Claude options in claude, sr-* in sr, drops others", () => {
450
+ const { claude, sr } = classifyDiscoveredOptions(OPTIONS_WITH_SR);
451
+ expect(claude.map((o) => o.label)).toEqual([
452
+ "Default (recommended)", "Sonnet", "claude-opus-4-8",
453
+ ]);
454
+ expect(sr.map((o) => o.label)).toEqual(["sr-gemini-2.5-pro", "sr-deepseek-r1"]);
455
+ // openrouter/*, gpt-*, voyage-* not present in either bucket
456
+ const all = [...claude, ...sr];
457
+ expect(all.find((o) => o.label.includes("openrouter"))).toBeUndefined();
458
+ expect(all.find((o) => o.label.startsWith("gpt-"))).toBeUndefined();
459
+ expect(all.find((o) => o.label.startsWith("voyage-"))).toBeUndefined();
460
+ });
461
+
462
+ it("handles a list with no sr-* models", () => {
463
+ const { claude, sr } = classifyDiscoveredOptions(OPTIONS);
464
+ expect(claude).toHaveLength(3);
465
+ expect(sr).toHaveLength(0);
466
+ });
467
+ });
468
+
469
+ describe("SR_MODEL_LABELS", () => {
470
+ it("has friendly names for the standard sr-* models", () => {
471
+ expect(SR_MODEL_LABELS["sr-gemini-2.5-pro"]).toBe("Gemini 2.5 Pro");
472
+ expect(SR_MODEL_LABELS["sr-deepseek-r1"]).toBe("DeepSeek R1");
473
+ });
474
+ });
475
+
476
+ describe("buildModelMenu — with sr-* models", () => {
477
+ function makeMenuDepsWithSr(overrides: Partial<ModelMenuDeps> = {}) {
478
+ return makeMenuDeps({
479
+ discover: async () => ({
480
+ ok: true as const,
481
+ options: OPTIONS_WITH_SR,
482
+ currentLabel: "Sonnet",
483
+ }),
484
+ ...overrides,
485
+ });
486
+ }
487
+
488
+ it("shows 🌐 buttons for sr-* models, normal buttons for claude models", async () => {
489
+ const { deps } = makeMenuDepsWithSr();
490
+ const menu = await buildModelMenu(deps);
491
+ expect(menu.keyboard).toBeDefined();
492
+ const allButtons = menu.keyboard!.flat();
493
+ // 🌐 buttons for sr-*
494
+ expect(allButtons.find((b) => b.text === "🌐 Gemini 2.5 Pro")).toBeDefined();
495
+ expect(allButtons.find((b) => b.text === "🌐 DeepSeek R1")).toBeDefined();
496
+ // Regular buttons for Claude models
497
+ expect(allButtons.find((b) => b.text === "Default (recommended)")).toBeDefined();
498
+ // openrouter/* not shown at all
499
+ expect(allButtons.find((b) => b.text.includes("openrouter"))).toBeUndefined();
500
+ });
501
+
502
+ it("sr-* buttons use mdl:sr: callback prefix", async () => {
503
+ const { deps } = makeMenuDepsWithSr();
504
+ const menu = await buildModelMenu(deps);
505
+ const srButton = menu.keyboard!.flat().find((b) => b.text === "🌐 Gemini 2.5 Pro");
506
+ expect(srButton?.callback_data).toBe(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`);
507
+ });
508
+
509
+ it("shows 🌐 = non-Anthropic legend when sr-* models are present", async () => {
510
+ const { deps } = makeMenuDepsWithSr();
511
+ const menu = await buildModelMenu(deps);
512
+ expect(menu.text).toContain("🌐 = non-Anthropic");
513
+ });
514
+
515
+ it("no legend when no sr-* models in picker", async () => {
516
+ const { deps } = makeMenuDeps();
517
+ const menu = await buildModelMenu(deps);
518
+ expect(menu.text).not.toContain("🌐 = non-Anthropic");
519
+ });
520
+ });
521
+
522
+ describe("handleModelMenuCallback — sr-* selection", () => {
523
+ function makeMenuDepsWithSr(overrides: Partial<ModelMenuDeps> = {}) {
524
+ return makeMenuDeps({
525
+ discover: async () => ({
526
+ ok: true as const,
527
+ options: OPTIONS_WITH_SR,
528
+ currentLabel: "Sonnet",
529
+ }),
530
+ ...overrides,
531
+ });
532
+ }
533
+
534
+ it("sr-* tap uses inject path, not cursor nav", async () => {
535
+ const { deps, calls, injectCalls } = makeMenuDepsWithSr();
536
+ const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`, deps);
537
+ // inject was called with the raw /model command
538
+ expect(injectCalls).toContainEqual({ agent: "klanker", command: "/model sr-gemini-2.5-pro" });
539
+ // select (cursor nav) was NOT called
540
+ expect(calls.select).toHaveLength(0);
541
+ expect(out.answer).toContain("Set model to sonnet");
542
+ expect(out.selectedModel).toBe("sr-gemini-2.5-pro");
543
+ expect(out.reply.keyboard).toBeDefined();
544
+ });
545
+
546
+ it("sr-* tap while busy returns toast-only with no inject", async () => {
547
+ const { deps, injectCalls } = makeMenuDepsWithSr({ isBusy: () => true });
548
+ const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`, deps);
549
+ expect(out.toastOnly).toBe(true);
550
+ expect(injectCalls).toHaveLength(0);
551
+ });
552
+
553
+ it("rejects malformed sr-* callback data", async () => {
554
+ const { deps } = makeMenuDepsWithSr();
555
+ const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}bad name with spaces`, deps);
556
+ expect(out.answer).toBe("Invalid model name");
557
+ });
558
+ });