switchroom 0.18.9 → 0.18.10

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 (62) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +198 -13
  3. package/dist/cli/notion-write-pretool.mjs +1 -0
  4. package/dist/cli/switchroom.js +28 -4
  5. package/dist/host-control/main.js +3 -2
  6. package/dist/vault/approvals/kernel-server.js +2 -1
  7. package/dist/vault/broker/server.js +2 -1
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +119 -37
  10. package/profiles/_shared/dev-protocol.md.hbs +42 -0
  11. package/skills/dev-protocol/SKILL.md +131 -0
  12. package/telegram-plugin/README.md +2 -1
  13. package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
  14. package/telegram-plugin/admin-commands/index.ts +6 -1
  15. package/telegram-plugin/bridge/bridge.ts +23 -1
  16. package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
  17. package/telegram-plugin/chat-lock.ts +13 -0
  18. package/telegram-plugin/dist/bridge/bridge.js +24 -1
  19. package/telegram-plugin/dist/gateway/gateway.js +1831 -263
  20. package/telegram-plugin/dist/server.js +29 -2
  21. package/telegram-plugin/fallback-card-collapse.ts +131 -0
  22. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
  23. package/telegram-plugin/gateway/effort-command.ts +47 -3
  24. package/telegram-plugin/gateway/gateway.ts +1435 -211
  25. package/telegram-plugin/gateway/model-command.ts +94 -8
  26. package/telegram-plugin/gateway/pending-session-command.ts +365 -0
  27. package/telegram-plugin/gateway/permission-timeout.ts +25 -0
  28. package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
  29. package/telegram-plugin/gateway/session-model-file.ts +166 -23
  30. package/telegram-plugin/gateway/stop-command.ts +56 -0
  31. package/telegram-plugin/photo-precheck.ts +201 -0
  32. package/telegram-plugin/quota-watch.ts +141 -2
  33. package/telegram-plugin/registry/subagents-schema.ts +26 -3
  34. package/telegram-plugin/registry/subagents.test.ts +67 -0
  35. package/telegram-plugin/retry-api-call.ts +31 -0
  36. package/telegram-plugin/subagent-watcher.ts +392 -1
  37. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
  38. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
  39. package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
  40. package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
  41. package/telegram-plugin/tests/effort-command.test.ts +59 -2
  42. package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
  43. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
  44. package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
  45. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
  46. package/telegram-plugin/tests/model-command.test.ts +46 -3
  47. package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
  48. package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
  49. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
  50. package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
  51. package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
  52. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
  53. package/telegram-plugin/tests/quota-watch.test.ts +225 -0
  54. package/telegram-plugin/tests/session-model-file.test.ts +101 -2
  55. package/telegram-plugin/tests/stop-command.test.ts +234 -0
  56. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
  57. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
  58. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
  59. package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
  60. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
  61. package/telegram-plugin/welcome-text.ts +4 -3
  62. package/telegram-plugin/worker-activity-feed.ts +27 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Regression test for the #klanker 2026-07-10 gateway crash.
3
+ *
4
+ * A `sendPhoto` that failed with PHOTO_INVALID_DIMENSIONS took the WHOLE
5
+ * gateway down with an `unhandledRejection`, even though the reply-tool
6
+ * call site `await`s the send and `onToolCall` wraps it in try/catch.
7
+ *
8
+ * Root cause was in `chat-lock.ts`'s `run()`: it stores a SECOND promise
9
+ * (`tracked = next.finally(...)`) in the per-key chain map and returns the
10
+ * separate `next` to the caller. The caller handles `next`'s rejection, but
11
+ * `tracked` — which rejects with the same reason — is only ever handled by
12
+ * the NEXT queued call chaining onto it. On the TAIL call of a lane (the
13
+ * common single-reply case) nothing attaches a handler to `tracked`, so its
14
+ * rejection escapes as an `unhandledRejection` and crashes the process.
15
+ *
16
+ * These tests assert:
17
+ * 1. a rejecting TAIL call does NOT emit an `unhandledRejection`, and
18
+ * 2. the caller STILL receives the real error (so error handling / the
19
+ * photo→document fallback still runs).
20
+ */
21
+
22
+ import { describe, it, expect } from 'vitest'
23
+ import { createChatLock } from '../chat-lock.js'
24
+
25
+ /** Run `body`, capturing any process-level unhandledRejection during it. */
26
+ async function captureUnhandledRejections(
27
+ body: () => Promise<void>,
28
+ ): Promise<unknown[]> {
29
+ const seen: unknown[] = []
30
+ const onUnhandled = (reason: unknown) => { seen.push(reason) }
31
+ process.on('unhandledRejection', onUnhandled)
32
+ try {
33
+ await body()
34
+ // Let any orphaned rejections surface: unhandledRejection fires on a
35
+ // later microtask/macrotask tick, so flush both.
36
+ await new Promise((r) => setTimeout(r, 0))
37
+ await Promise.resolve()
38
+ } finally {
39
+ process.off('unhandledRejection', onUnhandled)
40
+ }
41
+ return seen
42
+ }
43
+
44
+ describe('chat-lock run() — tail-call rejection safety', () => {
45
+ it('does NOT emit an unhandledRejection when the tail call rejects', async () => {
46
+ const boom = new Error('PHOTO_INVALID_DIMENSIONS')
47
+ const unhandled = await captureUnhandledRejections(async () => {
48
+ const lock = createChatLock()
49
+ // Single call on this key → it IS the tail. Caller handles the error.
50
+ await expect(lock.run('chat:1', () => Promise.reject(boom))).rejects.toBe(boom)
51
+ })
52
+ expect(unhandled).toEqual([])
53
+ })
54
+
55
+ it('still surfaces the real error to the caller', async () => {
56
+ const lock = createChatLock()
57
+ const boom = new Error('boom')
58
+ await expect(lock.run('k', () => Promise.reject(boom))).rejects.toBe(boom)
59
+ })
60
+
61
+ it('is quiet even when the failing call is the last of a burst', async () => {
62
+ const boom = new Error('PHOTO_INVALID_DIMENSIONS')
63
+ const unhandled = await captureUnhandledRejections(async () => {
64
+ const lock = createChatLock()
65
+ // First call succeeds, second (tail) rejects — mirrors the incident:
66
+ // a document then a photo on the same (chat,thread) lane.
67
+ const okP = lock.run('chat:9', () => Promise.resolve('doc-sent'))
68
+ const failP = lock.run('chat:9', () => Promise.reject(boom))
69
+ await expect(okP).resolves.toBe('doc-sent')
70
+ await expect(failP).rejects.toBe(boom)
71
+ })
72
+ expect(unhandled).toEqual([])
73
+ })
74
+
75
+ it('one failure does not poison later work on the same key', async () => {
76
+ const lock = createChatLock()
77
+ await expect(lock.run('k2', () => Promise.reject(new Error('x')))).rejects.toThrow('x')
78
+ // A subsequent call on the same key must still run and resolve.
79
+ await expect(lock.run('k2', () => Promise.resolve('ok'))).resolves.toBe('ok')
80
+ })
81
+
82
+ it('wrapBot surfaces a bot.api rejection without crashing (tail call)', async () => {
83
+ const boom = new Error('PHOTO_INVALID_DIMENSIONS')
84
+ const unhandled = await captureUnhandledRejections(async () => {
85
+ const lock = createChatLock()
86
+ const bot = {
87
+ api: {
88
+ sendPhoto: (_chatId: string, _file: unknown, _opts?: unknown) =>
89
+ Promise.reject(boom),
90
+ },
91
+ }
92
+ const wrapped = lock.wrapBot(bot)
93
+ await expect(
94
+ (wrapped.api.sendPhoto as (c: string, f: unknown, o?: unknown) => Promise<unknown>)(
95
+ '123', {}, {},
96
+ ),
97
+ ).rejects.toBe(boom)
98
+ })
99
+ expect(unhandled).toEqual([])
100
+ })
101
+ })
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Pins the #3033 bridge crash-breadcrumb contract: uncaughtException /
3
+ * unhandledRejection in the bridge process persist a bounded, greppable
4
+ * line to bridge-crash.log (Claude Code drops MCP stderr after startup,
5
+ * so this file is the only durable trace of why a bridge died).
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest'
9
+ import { mkdtempSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { tmpdir } from 'node:os'
12
+ import { appendCrashBreadcrumb } from '../bridge/crash-breadcrumb.js'
13
+
14
+ function tmpLog(): string {
15
+ return join(mkdtempSync(join(tmpdir(), 'crash-bc-')), 'bridge-crash.log')
16
+ }
17
+
18
+ describe('appendCrashBreadcrumb', () => {
19
+ it('appends a single line with kind, pid and folded stack', () => {
20
+ const log = tmpLog()
21
+ const err = new Error('boom')
22
+ appendCrashBreadcrumb(log, 'uncaughtException', err, new Date('2026-07-11T01:40:59Z'))
23
+ const content = readFileSync(log, 'utf8')
24
+ const lines = content.trimEnd().split('\n')
25
+ expect(lines).toHaveLength(1)
26
+ expect(lines[0]).toContain('2026-07-11T01:40:59')
27
+ expect(lines[0]).toContain('uncaughtException')
28
+ expect(lines[0]).toContain(`pid=${process.pid}`)
29
+ expect(lines[0]).toContain('Error: boom')
30
+ expect(lines[0]).not.toContain('\n')
31
+ })
32
+
33
+ it('handles non-Error rejections and appends across calls', () => {
34
+ const log = tmpLog()
35
+ appendCrashBreadcrumb(log, 'unhandledRejection', 'string reason')
36
+ appendCrashBreadcrumb(log, 'unhandledRejection', { odd: true })
37
+ const lines = readFileSync(log, 'utf8').trimEnd().split('\n')
38
+ expect(lines).toHaveLength(2)
39
+ expect(lines[0]).toContain('string reason')
40
+ })
41
+
42
+ it('rotates once past the size cap instead of growing unbounded', () => {
43
+ const log = tmpLog()
44
+ writeFileSync(log, 'x'.repeat(1024 * 1024 + 1))
45
+ appendCrashBreadcrumb(log, 'uncaughtException', new Error('after rotation'))
46
+ expect(existsSync(`${log}.1`)).toBe(true)
47
+ const content = readFileSync(log, 'utf8')
48
+ expect(content).toContain('after rotation')
49
+ expect(content.length).toBeLessThan(5000)
50
+ })
51
+
52
+ it('never throws when the path is unwritable', () => {
53
+ expect(() =>
54
+ appendCrashBreadcrumb('/nonexistent-dir-xyz/bridge-crash.log', 'uncaughtException', new Error('x')),
55
+ ).not.toThrow()
56
+ })
57
+ })
@@ -107,7 +107,7 @@ describe("effort-command: handler", () => {
107
107
  const { deps } = makeDeps({ getConfiguredEffort: () => "medium" });
108
108
  const r = await handleEffortCommand({ kind: "show" }, deps);
109
109
  expect(r.text).toContain("medium");
110
- expect(r.text).toMatch(/reverts to the configured default/);
110
+ expect(r.text).toMatch(/persists across restarts and deploys/);
111
111
  });
112
112
 
113
113
  it("show falls back to low when effort is unreadable", async () => {
@@ -121,7 +121,7 @@ describe("effort-command: handler", () => {
121
121
  const r = await handleEffortCommand({ kind: "set", level: "high" }, deps);
122
122
  expect(calls).toEqual([{ agent: "carrie", level: "high" }]);
123
123
  expect(r.text).toContain("Set effort level to high");
124
- expect(r.text).toMatch(/reverts to the configured default/);
124
+ expect(r.text).toMatch(/persists across restarts and deploys/);
125
125
  });
126
126
 
127
127
  it("set notes the re-read cost when a confirmation was needed", async () => {
@@ -198,3 +198,60 @@ describe("effort-command: menu + callback", () => {
198
198
  expect(out.selectedEffort).toBeUndefined();
199
199
  });
200
200
  });
201
+
202
+ // ─── #3039: /effort default + durable-override surfaces ─────────────────────
203
+
204
+ describe("effort-command: /effort default (#3039)", () => {
205
+ it("parses `/effort default` as an explicit clear", () => {
206
+ expect(parseEffortCommand("/effort default")).toEqual({ kind: "default" });
207
+ expect(parseEffortCommand("/effort DEFAULT")).toEqual({ kind: "default" });
208
+ });
209
+
210
+ it("default restores the configured level, THEN clears the durable override (order pins the wrapper undo)", async () => {
211
+ const events: string[] = [];
212
+ const { deps } = makeDeps({
213
+ getConfiguredEffort: () => "medium",
214
+ applyEffort: async (_a, level) => {
215
+ events.push(`apply:${level}`);
216
+ return applyOk(level);
217
+ },
218
+ clearSessionEffort: () => events.push("clear"),
219
+ });
220
+ const r = await handleEffortCommand({ kind: "default" }, deps);
221
+ expect(events).toEqual(["apply:medium", "clear"]);
222
+ expect(r.text).toContain("override cleared");
223
+ expect(r.text).toContain("`medium`");
224
+ });
225
+
226
+ it("default still clears the override when the live apply fails (boots on default from now on)", async () => {
227
+ let cleared = false;
228
+ const { deps } = makeDeps({
229
+ applyEffort: async () => applyFail("apply_unverified"),
230
+ clearSessionEffort: () => { cleared = true; },
231
+ });
232
+ const r = await handleEffortCommand({ kind: "default" }, deps);
233
+ expect(cleared).toBe(true);
234
+ expect(r.text).toContain("override cleared");
235
+ // Honest: does not claim the live session switched.
236
+ expect(r.text).toContain("Couldn't switch the live session right now");
237
+ });
238
+
239
+ it("show surfaces an active session override next to the configured default", async () => {
240
+ const { deps } = makeDeps({ getConfiguredEffort: () => "low", getSessionEffort: () => "xhigh" });
241
+ const r = await handleEffortCommand({ kind: "show" }, deps);
242
+ expect(r.text).toContain("session override: `xhigh`");
243
+ });
244
+
245
+ it("menu marks the durable override as the live level", () => {
246
+ const { deps } = makeDeps({ getConfiguredEffort: () => "low", getSessionEffort: () => "max" });
247
+ const menu = buildEffortMenu(deps);
248
+ const marked = menu.keyboard!.flat().find((b) => b.text.startsWith("✅"));
249
+ expect(marked?.text).toBe("✅ max");
250
+ });
251
+
252
+ it("help text advertises /effort default and the sticky contract", async () => {
253
+ const r = await handleEffortCommand({ kind: "help" }, makeDeps().deps);
254
+ expect(r.text).toContain("/effort default");
255
+ expect(r.text).toContain("persists across restarts and deploys");
256
+ });
257
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * #3031 PR 3 — reactive-path message collapse.
3
+ *
4
+ * The model-unavailable card is deliberately sent BEFORE the fallback
5
+ * outcome is known; on a SUCCESSFUL swap the announcement is folded into an
6
+ * EDIT of that card (single evolving card). CRITICAL promise-honesty
7
+ * constraint (2026-06-06→07 incident): every failure / no-op path must still
8
+ * deliver a SEPARATE message — these tests pin that the collapse decision
9
+ * can never eat a failure notice.
10
+ */
11
+
12
+ import { describe, it, expect } from "vitest";
13
+ import {
14
+ createModelUnavailableCardRegistry,
15
+ decideAnnouncementDelivery,
16
+ foldAnnouncementIntoCard,
17
+ CARD_COLLAPSE_MAX_AGE_MS,
18
+ type ModelUnavailableCardRecord,
19
+ type FallbackDeliveryOutcomeKind,
20
+ } from "../fallback-card-collapse.js";
21
+
22
+ const NOW = 1_780_000_000_000;
23
+
24
+ function rec(overrides: Partial<ModelUnavailableCardRecord> = {}): ModelUnavailableCardRecord {
25
+ return {
26
+ messageId: 4242,
27
+ text: "⚠️ Model unavailable — auto-failover in progress",
28
+ atMs: NOW - 5_000,
29
+ promisedFallback: true,
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ describe("createModelUnavailableCardRegistry", () => {
35
+ it("take returns a fresh fallback-promising card exactly once (one edit per card)", () => {
36
+ const reg = createModelUnavailableCardRegistry();
37
+ reg.record("123", rec());
38
+ const first = reg.take("123", NOW);
39
+ expect(first?.messageId).toBe(4242);
40
+ // One-shot: a second take finds nothing (a card absorbs one announcement).
41
+ expect(reg.take("123", NOW)).toBeNull();
42
+ expect(reg.size()).toBe(0);
43
+ });
44
+
45
+ it("a stale card is not editable (belongs to a previous incident) and is cleared", () => {
46
+ const reg = createModelUnavailableCardRegistry();
47
+ reg.record("123", rec({ atMs: NOW - CARD_COLLAPSE_MAX_AGE_MS - 1 }));
48
+ expect(reg.take("123", NOW)).toBeNull();
49
+ expect(reg.size()).toBe(0);
50
+ });
51
+
52
+ it("a card that never promised auto-failover never absorbs the announcement", () => {
53
+ const reg = createModelUnavailableCardRegistry();
54
+ reg.record("123", rec({ promisedFallback: false }));
55
+ expect(reg.take("123", NOW)).toBeNull();
56
+ });
57
+
58
+ it("cards are per-chat: taking one chat's card leaves the other's intact", () => {
59
+ const reg = createModelUnavailableCardRegistry();
60
+ reg.record("123", rec({ messageId: 1 }));
61
+ reg.record("456", rec({ messageId: 2 }));
62
+ expect(reg.take("123", NOW)?.messageId).toBe(1);
63
+ expect(reg.take("456", NOW)?.messageId).toBe(2);
64
+ });
65
+
66
+ it("a newer card replaces the prior record for the same chat", () => {
67
+ const reg = createModelUnavailableCardRegistry();
68
+ reg.record("123", rec({ messageId: 1 }));
69
+ reg.record("123", rec({ messageId: 2 }));
70
+ expect(reg.take("123", NOW)?.messageId).toBe(2);
71
+ });
72
+ });
73
+
74
+ describe("decideAnnouncementDelivery — promise-honesty on every error path", () => {
75
+ it("edits ONLY on a successful swap with a fresh promising card", () => {
76
+ expect(decideAnnouncementDelivery("switched", rec())).toBe("edit");
77
+ });
78
+
79
+ it("sends separately on a successful swap when no card was recorded (e.g. card-less quota_wall_detected trigger)", () => {
80
+ expect(decideAnnouncementDelivery("switched", null)).toBe("send");
81
+ });
82
+
83
+ it("EVERY failure / no-op outcome is a separate send — even with a fresh card on record", () => {
84
+ const failureKinds: FallbackDeliveryOutcomeKind[] = [
85
+ "all-blocked",
86
+ "no-old-active",
87
+ "no-eligible-target",
88
+ "error",
89
+ ];
90
+ for (const kind of failureKinds) {
91
+ expect(decideAnnouncementDelivery(kind, rec()), `kind=${kind}`).toBe("send");
92
+ expect(decideAnnouncementDelivery(kind, null), `kind=${kind} (no card)`).toBe("send");
93
+ }
94
+ });
95
+ });
96
+
97
+ describe("foldAnnouncementIntoCard", () => {
98
+ it("keeps the original card text and appends the announcement (single evolving card)", () => {
99
+ const folded = foldAnnouncementIntoCard("CARD", "ANNOUNCEMENT");
100
+ expect(folded.startsWith("CARD")).toBe(true);
101
+ expect(folded).toContain("ANNOUNCEMENT");
102
+ expect(folded.indexOf("CARD")).toBeLessThan(folded.indexOf("ANNOUNCEMENT"));
103
+ });
104
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Structural pins for the #3018 fixes to the mid-turn ack-queue-apply-confirm
3
+ * wiring in gateway.ts (/model + /effort, #3017).
4
+ *
5
+ * The behaviour lives in un-exported inline closures (the pendingStateReaper
6
+ * interval, enqueueSessionCommand, drainPendingSessionCommand, and the
7
+ * shutdown() handler), so — mirroring gateway-session-model-relaunch.test.ts —
8
+ * we assert on the source structure. The pure contract (per-kind slots,
9
+ * drainCapDecision, shutdownResolutionActions) is unit-tested in
10
+ * pending-session-command.test.ts.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import { readFileSync } from 'node:fs'
15
+ import { fileURLToPath } from 'node:url'
16
+ import { dirname, resolve } from 'node:path'
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url))
19
+ const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
20
+
21
+ describe('gateway: reaper drain-cap defers while a turn is in flight (#3018 finding 1)', () => {
22
+ it('feeds turnInFlightForGate() into drainCapDecision and only forces on the force branch', () => {
23
+ const idx = GATEWAY_SRC.indexOf('pendingCmdDrainCapDecision(')
24
+ expect(idx).toBeGreaterThan(0)
25
+ const win = GATEWAY_SRC.slice(idx, idx + 1200)
26
+ // The decision reads the live turn gate…
27
+ expect(win).toContain('turnInFlightForGate()')
28
+ // …has an explicit defer branch that does NOT drain…
29
+ const deferIdx = win.indexOf("'defer-turn-in-flight'")
30
+ expect(deferIdx).toBeGreaterThan(0)
31
+ const forceIdx = win.indexOf("cmdDecision === 'force'")
32
+ expect(forceIdx).toBeGreaterThan(deferIdx)
33
+ // …and the ONLY drain call in the reaper block sits after the force check.
34
+ const drainIdx = win.indexOf('void drainPendingSessionCommand()')
35
+ expect(drainIdx).toBeGreaterThan(forceIdx)
36
+ expect(win.slice(0, forceIdx)).not.toContain('drainPendingSessionCommand()')
37
+ })
38
+ })
39
+
40
+ describe('gateway: post-enqueue idle kick (#3018 finding 5)', () => {
41
+ it('enqueueSessionCommand drains immediately when the session went idle between the busy check and the enqueue', () => {
42
+ const fnIdx = GATEWAY_SRC.indexOf('function enqueueSessionCommand(')
43
+ expect(fnIdx).toBeGreaterThan(0)
44
+ const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 1500)
45
+ expect(win).toContain('if (!turnInFlightForGate()) void drainPendingSessionCommand()')
46
+ })
47
+ })
48
+
49
+ describe('gateway: shutdown resolves queued ack cards (#3018 finding 3)', () => {
50
+ it('shutdown() empties the slots via shutdownResolutionActions, PERSISTS each typed choice, and edits each card, before the force-exit timer', () => {
51
+ const fnIdx = GATEWAY_SRC.indexOf('async function shutdown(signal: string)')
52
+ expect(fnIdx).toBeGreaterThan(0)
53
+ const win = GATEWAY_SRC.slice(fnIdx, GATEWAY_SRC.indexOf('forceExitTimer', fnIdx))
54
+ const resolveIdx = win.indexOf('pendingCmdShutdownResolutionActions(pendingSessionCommand')
55
+ expect(resolveIdx).toBeGreaterThan(0)
56
+ // #3039: the queued choice is carried across the bounce via the durable
57
+ // carriers, not dropped with a "re-issue" note.
58
+ expect(win.indexOf('persistQueuedCommandForRestart(', resolveIdx)).toBeGreaterThan(resolveIdx)
59
+ expect(win.indexOf('editPendingCommandCard(', resolveIdx)).toBeGreaterThan(resolveIdx)
60
+ // Bounded: raced against a timeout so a wedged Telegram API can't block shutdown.
61
+ expect(win.slice(resolveIdx)).toContain('Promise.race')
62
+ })
63
+ })
64
+
65
+ describe('gateway: drain never confirms a busy refusal and never drops the batch (#3039, #3042 blocker 1)', () => {
66
+ it('drainPendingSessionCommand routes takeAll() through the unit-tested drainTakenCommands with the loss-safe IO', () => {
67
+ const fnIdx = GATEWAY_SRC.indexOf('async function drainPendingSessionCommand(')
68
+ expect(fnIdx).toBeGreaterThan(0)
69
+ const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 3000)
70
+ // Iteration + loss-safety invariants live in pending-session-command.ts
71
+ // (drainTakenCommands, functionally tested); the gateway only supplies IO.
72
+ expect(win).toContain('pendingCmdDrainTaken(pendingSessionCommand.takeAll()')
73
+ expect(win).toContain('turnInFlightForGate()')
74
+ expect(win).toContain('isBusyRefusal: isBusyRefusalText')
75
+ expect(win).toContain('reEnqueue: reEnqueueUnlessSuperseded')
76
+ // The pending-restart branch persists rather than telling the user to re-issue.
77
+ expect(win).toContain('pendingCmdResolveForRestart(cmd')
78
+ expect(win).toContain('persistQueuedCommandForRestart(')
79
+ })
80
+ })
81
+
82
+ describe('gateway: unconfirmed queued model tokens are gated before durable persist (#3042 blocker 2a)', () => {
83
+ it('persistQueuedCommandForRestart refuses offline-unverifiable tokens', () => {
84
+ const fnIdx = GATEWAY_SRC.indexOf('function persistQueuedCommandForRestart(')
85
+ expect(fnIdx).toBeGreaterThan(0)
86
+ const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 2500)
87
+ expect(win).toContain('isOfflineTrustedModelToken(action.arg)')
88
+ expect(win).toContain('NOT saved')
89
+ })
90
+ })
91
+
92
+ describe('gateway: /restart keeps the session-model override (#3039)', () => {
93
+ it('the /restart chat command stamps keep, never revert', () => {
94
+ expect(GATEWAY_SRC).toContain("writeRelaunchModelIntent(smDir, 'keep', 'user: /restart from chat')")
95
+ expect(GATEWAY_SRC).not.toContain("writeRelaunchModelIntent(smDir, 'revert'")
96
+ })
97
+ })
98
+
99
+ describe('gateway: /effort persistence choke point (#3039)', () => {
100
+ it('buildEffortDeps persists a confirmed apply to .session-effort and wires clearSessionEffort', () => {
101
+ const fnIdx = GATEWAY_SRC.indexOf('function buildEffortDeps(')
102
+ expect(fnIdx).toBeGreaterThan(0)
103
+ const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 2500)
104
+ expect(win).toContain('writeSessionEffortFile(')
105
+ expect(win).toContain('clearSessionEffortFile(')
106
+ expect(win).toContain('readSessionEffortFile(')
107
+ })
108
+ })
109
+
110
+ describe('gateway: keep-intent stamp narrowing (#3018 finding 4)', () => {
111
+ it('the shutdown keep-intent stamp carries the gateway-shutdown reason prefix', () => {
112
+ expect(GATEWAY_SRC).toContain(
113
+ "writeRelaunchModelIntent(smDir, 'keep', `${GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX} graceful ${signal} shutdown",
114
+ )
115
+ })
116
+
117
+ it('boot clears a stale gateway-shutdown-stamped intent (gateway-only bounce never runs start.sh)', () => {
118
+ const idx = GATEWAY_SRC.indexOf('clearStaleGatewayShutdownIntent(bootSmDir)')
119
+ expect(idx).toBeGreaterThan(0)
120
+ // The cleanup runs at module top-level (gateway boot), BEFORE the shutdown
121
+ // handler could stamp a fresh one for THIS process's own exit.
122
+ expect(idx).toBeLessThan(GATEWAY_SRC.indexOf('async function shutdown(signal: string)'))
123
+ })
124
+ })
@@ -128,8 +128,14 @@ describe('gateway secret-detect intercept — structural wiring', () => {
128
128
  })
129
129
 
130
130
  it('staging follow-up commands (stash/ignore/rename/forget) are wired', () => {
131
+ // Guarded property: the staging follow-up parser lives INSIDE
132
+ // handleInbound (so it runs on the inbound-text path, gated by the same
133
+ // allowFrom check). The window is a generous bound on handleInbound's
134
+ // pre-staging body, not a precise offset — it has grown before (#3020's
135
+ // stop-keyword block pushed it past the old 30k) and may grow again;
136
+ // bump it when new early-return branches land above the staging block.
131
137
  const handleInboundIdx = src.indexOf('async function handleInbound(')
132
- const tail = src.slice(handleInboundIdx, handleInboundIdx + 30000)
138
+ const tail = src.slice(handleInboundIdx, handleInboundIdx + 50000)
133
139
  expect(tail).toMatch(/\(stash\|ignore\|rename\|forget\)/)
134
140
  expect(tail).toMatch(/secretStaging\.latestForChat\(chat_id\)/)
135
141
  })
@@ -114,14 +114,14 @@ describe('gateway: intent writers on the restart verbs', () => {
114
114
  expect(clearIdx).toBeGreaterThan(markerIdx)
115
115
  })
116
116
 
117
- it('/restart stamps an explicit revert intent (reason honesty) before dispatch', () => {
117
+ it('/restart stamps a KEEP intent before dispatch (#3039: a restart is not "clear my model")', () => {
118
118
  const idx = GATEWAY_SRC.indexOf("stampUserRestartReason('user: /restart from chat')")
119
119
  expect(idx).toBeGreaterThan(0)
120
120
  const win = GATEWAY_SRC.slice(idx, idx + 900)
121
- const revertIdx = win.indexOf("writeRelaunchModelIntent(smDir, 'revert', 'user: /restart from chat')")
121
+ const keepIdx = win.indexOf("writeRelaunchModelIntent(smDir, 'keep', 'user: /restart from chat')")
122
122
  const dispatchIdx = win.indexOf("hostdRequestId('gw-restart')")
123
- expect(revertIdx).toBeGreaterThan(0)
124
- expect(dispatchIdx).toBeGreaterThan(revertIdx)
123
+ expect(keepIdx).toBeGreaterThan(0)
124
+ expect(dispatchIdx).toBeGreaterThan(keepIdx)
125
125
  })
126
126
 
127
127
  it('/new and /reset stamp keep-intent (fresh conversation, same model — contract row 7)', () => {
@@ -137,22 +137,28 @@ describe('gateway: intent writers on the restart verbs', () => {
137
137
 
138
138
  describe('gateway: model-menu callback persists the sticky override', () => {
139
139
  it('a confirmed selection persists the canonical token (selectedModelToken), never the display label', () => {
140
- const idx = GATEWAY_SRC.indexOf('const outcome = await handleModelMenuCallback(data, modelDeps)')
140
+ // Recording extracted into recordModelMenuSideEffects (#3017) — shared by the
141
+ // live dispatcher and the deferred (queued mid-turn) apply so both record
142
+ // identically.
143
+ const idx = GATEWAY_SRC.indexOf('function recordModelMenuSideEffects')
141
144
  expect(idx).toBeGreaterThan(0)
142
- const win = GATEWAY_SRC.slice(idx, idx + 1600)
145
+ const win = GATEWAY_SRC.slice(idx, idx + 2400)
143
146
  expect(win).toContain('outcome.selectedModelToken')
144
147
  expect(win).toMatch(/writeSessionModelFile\(\s*smDir,\s*outcome\.selectedModelToken/)
145
148
  })
146
149
 
147
150
  it('a confirmed "Default" selection CLEARS the sticky file', () => {
148
- const idx = GATEWAY_SRC.indexOf('const outcome = await handleModelMenuCallback(data, modelDeps)')
149
- const win = GATEWAY_SRC.slice(idx, idx + 1800)
151
+ const idx = GATEWAY_SRC.indexOf('function recordModelMenuSideEffects')
152
+ const win = GATEWAY_SRC.slice(idx, idx + 2400)
150
153
  expect(win).toContain('outcome.clearedDefault')
151
154
  expect(win).toContain('clearSessionModelFile(smDir)')
152
155
  })
153
156
 
154
157
  it('the sr-* callback branch calls scheduleModelRelaunch, not inject', () => {
155
- const idx = GATEWAY_SRC.indexOf('if (data.startsWith(MODEL_CALLBACK_SR))')
158
+ // Anchor on the sr-* TARGET dispatcher branch specifically (a mid-turn
159
+ // busy-gate #3017 also matches `if (data.startsWith(MODEL_CALLBACK_SR))`, so
160
+ // anchor on the relaunch call that is unique to the idle apply branch).
161
+ const idx = GATEWAY_SRC.indexOf('const srLabel = escapeHtmlForTg(srFriendlyLabel(srName))')
156
162
  expect(idx).toBeGreaterThan(0)
157
163
  const win = GATEWAY_SRC.slice(idx, idx + 1400)
158
164
  expect(win).toContain('modelDeps.scheduleModelRelaunch(srName')
@@ -172,7 +178,9 @@ describe('gateway: model-menu callback persists the sticky override', () => {
172
178
 
173
179
  describe('gateway: typed /model persists the REQUESTED canonical token', () => {
174
180
  it('persists expandSrAlias(parsed.model), and `/model default` clears file + in-memory override', () => {
175
- const idx = GATEWAY_SRC.indexOf("const requested = parsed.kind === 'set' ? expandSrAlias(parsed.model) : null")
181
+ // Recording extracted into recordTypedModelSwitch (#3017) — shared by the
182
+ // live `bot.command('model')` handler and the deferred (queued mid-turn) apply.
183
+ const idx = GATEWAY_SRC.indexOf('function recordTypedModelSwitch')
176
184
  expect(idx).toBeGreaterThan(0)
177
185
  const win = GATEWAY_SRC.slice(idx, idx + 2400)
178
186
  expect(win).toContain("requested?.toLowerCase() === 'default'")
@@ -184,7 +192,7 @@ describe('gateway: typed /model persists the REQUESTED canonical token', () => {
184
192
  })
185
193
 
186
194
  it('`/model default` file-clear is NOT gated on a positive confirmation (silent-switch path must not resurrect)', () => {
187
- const idx = GATEWAY_SRC.indexOf("const requested = parsed.kind === 'set' ? expandSrAlias(parsed.model) : null")
195
+ const idx = GATEWAY_SRC.indexOf('function recordTypedModelSwitch')
188
196
  const win = GATEWAY_SRC.slice(idx, idx + 2400)
189
197
  // The default branch clears the file unconditionally, and only the
190
198
  // in-memory override change is confirmation-gated inside it.
@@ -20,6 +20,8 @@ import {
20
20
  isValidModelArg,
21
21
  isSrModel,
22
22
  isClaudeModel,
23
+ isBusyRefusalText,
24
+ isOfflineTrustedModelToken,
23
25
  MODEL_ALIASES,
24
26
  type ModelCommandDeps,
25
27
  } from "../gateway/model-command.js";
@@ -201,7 +203,7 @@ describe("handleModelCommand — set", () => {
201
203
  const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
202
204
  expect(calls).toEqual([{ agent: "klanker", command: "/model opus" }]);
203
205
  expect(reply.text).toContain("<pre>⏺ Set model to sonnet</pre>");
204
- expect(reply.text).toContain("Sticky across switchroom-managed relaunches");
206
+ expect(reply.text).toContain("persists across restarts, deploys, and crashes");
205
207
  expect(reply.html).toBe(true);
206
208
  // A verified confirmation records the live model so /status stays honest
207
209
  // (bug 1: the typed path never recorded the switch before).
@@ -769,12 +771,19 @@ describe("buildModelMenu", () => {
769
771
  }
770
772
  });
771
773
 
772
- it("busy agent → no discovery, no keyboard, explanatory text", async () => {
774
+ it("busy agent → no discovery, STATIC keyboard whose taps ride the queue (#3039)", async () => {
773
775
  const { deps, calls } = makeMenuDeps({ isBusy: () => true });
774
776
  const menu = await buildModelMenu(deps);
777
+ // Never drives the picker mid-turn…
775
778
  expect(calls.discover).toBe(0);
776
- expect(menu.keyboard).toBeUndefined();
777
779
  expect(menu.text).toContain("mid-turn");
780
+ // …but no dead-end either: static alias rows are offered so the operator
781
+ // can still lock in a choice (the tap queues at the gateway busy gate).
782
+ expect(menu.keyboard).toBeDefined();
783
+ const data = menu.keyboard!.flat().map(b => b.callback_data);
784
+ expect(data).toContain("mdl:alias:opus");
785
+ expect(data).toContain("mdl:alias:default");
786
+ expect(menu.text).not.toContain("Try again");
778
787
  });
779
788
 
780
789
  it("discovery failure → static v1 fallback with the reason, no keyboard", async () => {
@@ -1317,3 +1326,37 @@ describe("Fable alias callback injects /model fable", () => {
1317
1326
  expect(out.answer).toContain("Invalid");
1318
1327
  });
1319
1328
  });
1329
+
1330
+ // ─── #3039: busy-refusal detection for the queued-command drain ──────────────
1331
+
1332
+ describe("isBusyRefusalText (#3039)", () => {
1333
+ it("matches the typed and menu busy refusals", async () => {
1334
+ const deps = makeDeps({ isBusy: () => true }).deps;
1335
+ const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
1336
+ expect(isBusyRefusalText(reply.text)).toBe(true);
1337
+ });
1338
+
1339
+ it("never matches a genuine confirmation or failure", () => {
1340
+ expect(isBusyRefusalText("⏺ Set model to Opus 4.8")).toBe(false);
1341
+ expect(isBusyRefusalText("✅ `/effort high` — Set effort level to high")).toBe(false);
1342
+ expect(isBusyRefusalText("❌ Switch to opus failed: tmux session not found")).toBe(false);
1343
+ });
1344
+ });
1345
+
1346
+
1347
+ describe("isOfflineTrustedModelToken (#3042 blocker 2a)", () => {
1348
+ it("trusts static Claude aliases and curated sr-* alias names/targets", () => {
1349
+ for (const a of MODEL_ALIASES) expect(isOfflineTrustedModelToken(a)).toBe(true);
1350
+ // Curated sr-* aliases resolve by construction (present in the LiteLLM config).
1351
+ const [alias, target] = Object.entries(SR_MODEL_ALIASES)[0];
1352
+ expect(isOfflineTrustedModelToken(alias)).toBe(true);
1353
+ expect(isOfflineTrustedModelToken(target)).toBe(true);
1354
+ });
1355
+
1356
+ it("refuses hand-typed full ids — shape-valid garbage must never reach a boot carrier unconfirmed", () => {
1357
+ expect(isOfflineTrustedModelToken("claude-nonexistnet-9")).toBe(false);
1358
+ expect(isOfflineTrustedModelToken("claude-opus-4-8")).toBe(false); // real but unverifiable offline
1359
+ expect(isOfflineTrustedModelToken("sr-made-up/model")).toBe(false);
1360
+ expect(isOfflineTrustedModelToken("")).toBe(false);
1361
+ });
1362
+ });