switchroom 0.18.7 → 0.18.9

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 (85) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/switchroom.js +905 -758
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/profiles/_base/start.sh.hbs +111 -34
  6. package/skills/switchroom-runtime/SKILL.md +2 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +46273 -44324
  8. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  9. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  10. package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
  11. package/telegram-plugin/gateway/boot-card.ts +27 -0
  12. package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
  13. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  14. package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
  15. package/telegram-plugin/gateway/gateway.ts +1169 -3043
  16. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
  17. package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
  18. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  19. package/telegram-plugin/gateway/model-command.ts +23 -11
  20. package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
  21. package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
  22. package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
  23. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  24. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  25. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  26. package/telegram-plugin/hooks/hooks.json +10 -10
  27. package/telegram-plugin/hooks/run-hook.sh +84 -0
  28. package/telegram-plugin/model-unavailable.ts +26 -0
  29. package/telegram-plugin/pty-partial-handler.ts +39 -0
  30. package/telegram-plugin/render/rich-render.ts +79 -1
  31. package/telegram-plugin/retry-api-call.ts +62 -0
  32. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  33. package/telegram-plugin/silence-poke.ts +14 -0
  34. package/telegram-plugin/stream-controller.ts +156 -38
  35. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  36. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  37. package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
  38. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  39. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  40. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  41. package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
  42. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
  43. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
  44. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  45. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
  46. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
  47. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
  48. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
  49. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
  50. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  51. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
  52. package/telegram-plugin/tests/model-command.test.ts +2 -2
  53. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  54. package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
  55. package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
  56. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
  57. package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
  58. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  59. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  60. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  61. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  62. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  63. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  64. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  65. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  66. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  67. package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
  68. package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
  69. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
  70. package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
  71. package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
  72. package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
  73. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
  74. package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
  75. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
  76. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  77. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  78. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  79. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  80. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  81. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  82. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  83. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  84. package/telegram-plugin/voice-ondemand.ts +25 -1
  85. package/telegram-plugin/voice-send.ts +154 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Regression tests for the chunk-boundary cap bug (fix/rich-render-chunk-boundary-cap).
3
+ *
4
+ * THE BUG: the outbound safe renderer (`renderSafe`) re-escapes GFM-special
5
+ * characters, which GROWS a body. A raw chunk sized just under the wire cap can
6
+ * escape PAST it. `renderSafe`'s only oversize recourse is to degrade the WHOLE
7
+ * document to `mode:"plain"` (raw source, no rich wrapper) — it never re-splits
8
+ * a multi-block body. The send path then ships that plain body through the
9
+ * 4096-char plain `sendMessage` endpoint, so a ~32k plain body is rejected by
10
+ * Telegram (`message is too long`) and the answer is dropped.
11
+ *
12
+ * `renderOutboundChunks` closes the gap: every returned piece fits its own wire
13
+ * cap (rich `<= maxLen`, plain `<= plainMax`) and is cut only at
14
+ * `splitMarkdownChunks`' safe boundaries (never bisecting a fence / table row).
15
+ */
16
+ import { describe, it, expect } from "vitest";
17
+ import { renderOutboundChunks, PLAIN_TEXT_MAX_CHARS } from "../../render/rich-render.js";
18
+ import { RICH_MESSAGE_MAX_CHARS } from "../../format.js";
19
+
20
+ const ON = { SWITCHROOM_RICH_RENDER: "1" } as NodeJS.ProcessEnv;
21
+ const OFF = {} as NodeJS.ProcessEnv;
22
+
23
+ /** Count fenced-code delimiter lines (```) in a body. A piece that bisects a
24
+ * fenced block has an ODD count. */
25
+ function fenceCount(s: string): number {
26
+ return (s.match(/^```/gm) ?? []).length;
27
+ }
28
+
29
+ describe("renderOutboundChunks", () => {
30
+ it("flag OFF is a single passthrough piece (byte-for-byte)", () => {
31
+ const raw = "**bold** and _italic_ | a | table |";
32
+ const pieces = renderOutboundChunks(raw, OFF);
33
+ expect(pieces).toHaveLength(1);
34
+ expect(pieces[0].text).toBe(raw);
35
+ expect(pieces[0].mode).toBe("markdown");
36
+ });
37
+
38
+ it("flag ON, body that fits is a single piece (common case)", () => {
39
+ const pieces = renderOutboundChunks("just some plain prose", ON);
40
+ expect(pieces).toHaveLength(1);
41
+ expect(pieces[0].text.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);
42
+ });
43
+
44
+ it("REGRESSION: a near-cap escapable body splits into cap-respecting pieces", () => {
45
+ // Prose whose escaping (`_ * |` each gain a leading `\`) grows it past the
46
+ // cap. Use small caps so the test is fast; the invariant is cap-agnostic.
47
+ const maxLen = 200;
48
+ const plainMax = 80;
49
+ const unit = "a_b*c|d ";
50
+ const raw = unit.repeat(40); // 320 raw chars, ~1.5x after escaping
51
+ const pieces = renderOutboundChunks(raw, ON, maxLen, plainMax);
52
+
53
+ // The whole body did NOT fit as one message — it was re-split.
54
+ expect(pieces.length).toBeGreaterThan(1);
55
+ for (const p of pieces) {
56
+ if (p.mode === "plain") {
57
+ // A plain piece rides the plain `sendMessage` endpoint — must fit its cap.
58
+ expect(p.text.length).toBeLessThanOrEqual(plainMax);
59
+ } else {
60
+ expect(p.text.length).toBeLessThanOrEqual(maxLen);
61
+ }
62
+ }
63
+ });
64
+
65
+ it("REGRESSION: never bisects a fenced code block when splitting", () => {
66
+ const maxLen = 300;
67
+ const plainMax = 250; // >= the fence size, so a fence never needs a hard slice.
68
+ // A fenced block big enough that a naive length cut would land inside it,
69
+ // wrapped in prose so the whole document overflows and must be re-split.
70
+ const fence = "```\n" + "code line here\n".repeat(8) + "```";
71
+ const prose = "word ".repeat(40);
72
+ const raw = `${prose}\n\n${fence}\n\n${prose}`;
73
+ const pieces = renderOutboundChunks(raw, ON, maxLen, plainMax);
74
+
75
+ expect(pieces.length).toBeGreaterThan(1);
76
+ for (const p of pieces) {
77
+ // Every emitted piece has BALANCED fence delimiters — no piece opens a
78
+ // fence it doesn't close (which would swallow the next piece's text).
79
+ expect(fenceCount(p.text) % 2).toBe(0);
80
+ const cap = p.mode === "plain" ? plainMax : maxLen;
81
+ expect(p.text.length).toBeLessThanOrEqual(cap);
82
+ }
83
+ });
84
+
85
+ it("REAL-CAP: a ~32k escapable body never yields an over-4096 plain piece", () => {
86
+ // The production failure: raw just under RICH_MESSAGE_MAX_CHARS, escaping
87
+ // pushes the rendered form over it. On the buggy path this became ONE
88
+ // ~32k plain body sent through the 4096 plain endpoint. Here every plain
89
+ // piece is <= 4096 and every rich piece <= 32768.
90
+ const unit = "a_b*c|d ";
91
+ const raw = unit.repeat(Math.floor((RICH_MESSAGE_MAX_CHARS - 20) / unit.length));
92
+ const pieces = renderOutboundChunks(raw, ON);
93
+ for (const p of pieces) {
94
+ const cap = p.mode === "plain" ? PLAIN_TEXT_MAX_CHARS : RICH_MESSAGE_MAX_CHARS;
95
+ expect(p.text.length).toBeLessThanOrEqual(cap);
96
+ }
97
+ }, 30000);
98
+ });
@@ -15,6 +15,8 @@ import {
15
15
  createSwallowingRetryApiCall,
16
16
  retryWithThreadFallback,
17
17
  isHtmlParseRejectError,
18
+ isLocalResourceError,
19
+ LOCAL_RESOURCE_EXHAUSTED,
18
20
  type RetryObserver,
19
21
  } from '../retry-api-call.js'
20
22
  import { errors, makeGrammyError } from './fake-bot-api.js'
@@ -512,3 +514,60 @@ describe('isHtmlParseRejectError', () => {
512
514
  ).toBe(true)
513
515
  })
514
516
  })
517
+
518
+ describe('#2923 — LOCAL resource exhaustion is NOT retried (avoids flood ban)', () => {
519
+ it('classifies ENOSPC / EDQUOT / EIO / ENOMEM by errno code', () => {
520
+ expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'ENOSPC' }))).toBe(true)
521
+ expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'EDQUOT' }))).toBe(true)
522
+ expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'EIO' }))).toBe(true)
523
+ expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'ENOMEM' }))).toBe(true)
524
+ })
525
+
526
+ it('classifies by message when no code is present (incl. EIO, word-boundaried)', () => {
527
+ expect(isLocalResourceError(new Error('ENOSPC: no space left on device, write'))).toBe(true)
528
+ expect(isLocalResourceError(new Error('disk quota exceeded'))).toBe(true)
529
+ expect(isLocalResourceError(new Error('EIO: i/o error, write'))).toBe(true)
530
+ // No false match on a substring (e.g. a word containing the letters).
531
+ expect(isLocalResourceError(new Error('DENOSPCX not a real code'))).toBe(false)
532
+ })
533
+
534
+ it('does NOT classify a remote GrammyError or ordinary error', () => {
535
+ expect(isLocalResourceError(errors.floodWait(10))).toBe(false)
536
+ expect(isLocalResourceError(new Error('fetch failed'))).toBe(false)
537
+ })
538
+
539
+ it('throws LOCAL_RESOURCE_EXHAUSTED immediately without retrying', async () => {
540
+ // Before the fix: an ENOSPC thrown by the send-staging step fell through
541
+ // to the network-retry branch pattern OR was rethrown but only after the
542
+ // caller kept re-driving sends — the storm that tripped the flood ban.
543
+ // Now it must fail FAST on the first attempt with a distinct marker.
544
+ const sleep = vi.fn(async () => {})
545
+ let calls = 0
546
+ const retry = createRetryApiCall({ maxRetries: 3, sleep })
547
+ await expect(
548
+ retry(async () => {
549
+ calls++
550
+ throw Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' })
551
+ }),
552
+ ).rejects.toThrow(LOCAL_RESOURCE_EXHAUSTED)
553
+ expect(calls).toBe(1) // no retry
554
+ expect(sleep).not.toHaveBeenCalled() // no backoff-into-flood
555
+ })
556
+
557
+ it('fires onFloodWait with the retry_after when a 429 is seen', async () => {
558
+ const seen: number[] = []
559
+ const sleep = vi.fn(async () => {})
560
+ let n = 0
561
+ const retry = createRetryApiCall({
562
+ maxRetries: 3,
563
+ sleep,
564
+ onFloodWait: (s) => seen.push(s),
565
+ })
566
+ const out = await retry(async () => {
567
+ if (n++ === 0) throw errors.floodWait(42)
568
+ return 'ok'
569
+ })
570
+ expect(out).toBe('ok')
571
+ expect(seen).toEqual([42])
572
+ })
573
+ })
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { spawnSync } from 'node:child_process'
3
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ /**
9
+ * #2555 — run-hook.sh must tolerate a Node exit-134 (uv_thread_create abort
10
+ * under memory pressure): retry once, and if it still aborts, skip cleanly
11
+ * (exit 0) rather than propagating 134. Real non-134 statuses pass through.
12
+ *
13
+ * We drive the wrapper with a fake command (a tiny sh script) whose exit code
14
+ * is scripted via a counter file, so we exercise the exact control flow
15
+ * without needing a real memory-pressured Node.
16
+ */
17
+ const wrapper = resolve(
18
+ fileURLToPath(new URL('../hooks/run-hook.sh', import.meta.url)),
19
+ )
20
+
21
+ describe('#2555 run-hook.sh exit-134 tolerance', () => {
22
+ let dir: string
23
+ let fake: string
24
+ let counter: string
25
+
26
+ beforeEach(() => {
27
+ dir = mkdtempSync(join(tmpdir(), 'run-hook-'))
28
+ fake = join(dir, 'fake.sh')
29
+ counter = join(dir, 'n')
30
+ writeFileSync(counter, '0')
31
+ })
32
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
33
+
34
+ /** Fake command: exits `codes[attemptIndex]`, records each invocation. */
35
+ function writeFake(codes: number[]): void {
36
+ writeFileSync(
37
+ fake,
38
+ [
39
+ '#!/bin/sh',
40
+ `n=$(cat "${counter}")`,
41
+ `echo "$((n + 1))" > "${counter}"`,
42
+ 'case "$n" in',
43
+ ...codes.map((c, i) => ` ${i}) exit ${c} ;;`),
44
+ ` *) exit ${codes[codes.length - 1]} ;;`,
45
+ 'esac',
46
+ ].join('\n'),
47
+ )
48
+ }
49
+
50
+ const run = (input = '') =>
51
+ spawnSync('sh', [wrapper, 'sh', fake], { encoding: 'utf-8', input })
52
+
53
+ const attempts = () => Number(readFileSync(counter, 'utf-8').trim())
54
+
55
+ it('passes a clean exit 0 through without retrying', () => {
56
+ writeFake([0])
57
+ const r = run()
58
+ expect(r.status).toBe(0)
59
+ expect(attempts()).toBe(1)
60
+ })
61
+
62
+ it('passes a real non-134 failure through unchanged (no retry, not masked)', () => {
63
+ writeFake([2])
64
+ const r = run()
65
+ expect(r.status).toBe(2)
66
+ expect(attempts()).toBe(1)
67
+ })
68
+
69
+ it('retries ONCE on a 134 abort and succeeds on the second attempt', () => {
70
+ writeFake([134, 0])
71
+ const r = run()
72
+ expect(r.status).toBe(0)
73
+ expect(attempts()).toBe(2)
74
+ })
75
+
76
+ it('skips cleanly (exit 0) when it aborts 134 twice — no crash card', () => {
77
+ writeFake([134, 134])
78
+ const r = run()
79
+ expect(r.status).toBe(0) // skipped cleanly, NOT 134
80
+ expect(attempts()).toBe(2)
81
+ expect(r.stderr).toMatch(/skipping hook cleanly/)
82
+ })
83
+
84
+ it('exports a shrunk UV_THREADPOOL_SIZE to the child', () => {
85
+ writeFileSync(fake, `#!/bin/sh\necho "$UV_THREADPOOL_SIZE"\nexit 0\n`)
86
+ const r = run()
87
+ expect(r.stdout.trim()).toBe('1')
88
+ })
89
+
90
+ it('replays the SAME stdin payload on the retry (scanner never sees empty input)', () => {
91
+ // Fake: attempt 0 reads stdin then aborts 134; attempt 1 reads stdin and
92
+ // records it, then exits 0. If stdin were not preserved, the recorded
93
+ // payload on the retry would be empty.
94
+ const seen = join(dir, 'seen-stdin')
95
+ writeFileSync(
96
+ fake,
97
+ [
98
+ '#!/bin/sh',
99
+ `n=$(cat "${counter}")`,
100
+ `echo "$((n + 1))" > "${counter}"`,
101
+ 'data=$(cat)', // drain stdin (the abort-after-read scenario)
102
+ `echo "$data" > "${seen}.$n"`,
103
+ '[ "$n" = "0" ] && exit 134',
104
+ 'exit 0',
105
+ ].join('\n'),
106
+ )
107
+ const r = run('SECRET-PAYLOAD-123')
108
+ expect(r.status).toBe(0)
109
+ expect(attempts()).toBe(2)
110
+ // The RETRY (attempt 1) must have received the full payload, not empty.
111
+ expect(readFileSync(`${seen}.1`, 'utf-8').trim()).toBe('SECRET-PAYLOAD-123')
112
+ })
113
+
114
+ it('FAILS CLOSED (propagates 134) for a security hook that aborts twice', () => {
115
+ // A genuinely broken secret scanner must NOT silently pass — exit 0 after
116
+ // two aborts would be a silent security bypass.
117
+ const secFake = join(dir, 'secret-guard-pretool.mjs')
118
+ writeFileSync(
119
+ secFake,
120
+ [
121
+ '#!/bin/sh',
122
+ `n=$(cat "${counter}")`,
123
+ `echo "$((n + 1))" > "${counter}"`,
124
+ 'exit 134',
125
+ ].join('\n'),
126
+ )
127
+ const r = spawnSync('sh', [wrapper, 'sh', secFake], { encoding: 'utf-8', input: '{}' })
128
+ expect(r.status).toBe(134) // fail closed — NOT skipped
129
+ expect(attempts()).toBe(2)
130
+ expect(r.stderr).toMatch(/FAILING CLOSED/)
131
+ })
132
+ })
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Durable session-model file helpers (session-model-file.ts) — the gateway
3
+ * side of the stickiness contract (reference/rfcs/session-model-stickiness.md).
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
7
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import { tmpdir } from 'node:os'
10
+ import {
11
+ serializeSessionModel,
12
+ parseSessionModel,
13
+ writeSessionModelFile,
14
+ readSessionModelFile,
15
+ readSessionModelFileRaw,
16
+ restoreSessionModelFileRaw,
17
+ clearSessionModelFile,
18
+ writeRelaunchModelIntent,
19
+ clearRelaunchModelIntent,
20
+ readConfiguredDefaultModel,
21
+ intentForRestartReason,
22
+ SESSION_MODEL_FILE,
23
+ RELAUNCH_MODEL_INTENT_FILE,
24
+ CONFIGURED_DEFAULT_MODEL_FILE,
25
+ } from '../gateway/session-model-file.js'
26
+
27
+ let dir: string
28
+ beforeEach(() => {
29
+ dir = mkdtempSync(join(tmpdir(), 'switchroom-sm-file-'))
30
+ })
31
+ afterEach(() => {
32
+ rmSync(dir, { recursive: true, force: true })
33
+ })
34
+
35
+ describe('serialize/parse round-trip', () => {
36
+ it('round-trips a record', () => {
37
+ const rec = { model: 'sr-glm-5', configuredDefaultAtWrite: 'claude-sonnet-5', ts: 1783948123456 }
38
+ expect(parseSessionModel(serializeSessionModel(rec))).toEqual(rec)
39
+ })
40
+
41
+ it('rejects corrupt JSON, missing fields, and non-canonical model tokens', () => {
42
+ expect(parseSessionModel('{broken')).toBeNull()
43
+ expect(parseSessionModel('{"model":"opus"}')).toBeNull()
44
+ expect(parseSessionModel('{"model":"Opus 4.8","configuredDefaultAtWrite":"x","ts":1}')).toBeNull()
45
+ expect(parseSessionModel('{"model":42,"configuredDefaultAtWrite":"x","ts":1}')).toBeNull()
46
+ })
47
+ })
48
+
49
+ describe('writeSessionModelFile — canonical-token guard (review finding 7)', () => {
50
+ it('writes a canonical token with the current default + a fresh ts', () => {
51
+ writeSessionModelFile(dir, 'claude-opus-4-8', 'claude-sonnet-5')
52
+ const rec = readSessionModelFile(dir)!
53
+ expect(rec.model).toBe('claude-opus-4-8')
54
+ expect(rec.configuredDefaultAtWrite).toBe('claude-sonnet-5')
55
+ expect(Math.abs(Date.now() - rec.ts)).toBeLessThan(5000)
56
+ })
57
+
58
+ it('THROWS on a display label — "Opus 4.5" must never be persisted', () => {
59
+ expect(() => writeSessionModelFile(dir, 'Opus 4.5', 'claude-sonnet-5')).toThrow(/non-canonical/)
60
+ expect(existsSync(join(dir, SESSION_MODEL_FILE))).toBe(false)
61
+ })
62
+ })
63
+
64
+ describe('rollback snapshot (scheduleModelRelaunch dispatch failure)', () => {
65
+ it('restores prior content when a file existed', () => {
66
+ writeSessionModelFile(dir, 'claude-opus-4-8', 'claude-sonnet-5')
67
+ const snapshot = readSessionModelFileRaw(dir)
68
+ writeSessionModelFile(dir, 'sr-glm-5', 'claude-sonnet-5')
69
+ restoreSessionModelFileRaw(dir, snapshot)
70
+ expect(readSessionModelFile(dir)!.model).toBe('claude-opus-4-8')
71
+ })
72
+
73
+ it('deletes the file when there was none before', () => {
74
+ const snapshot = readSessionModelFileRaw(dir) // null
75
+ writeSessionModelFile(dir, 'sr-glm-5', 'claude-sonnet-5')
76
+ restoreSessionModelFileRaw(dir, snapshot)
77
+ expect(existsSync(join(dir, SESSION_MODEL_FILE))).toBe(false)
78
+ })
79
+ })
80
+
81
+ describe('relaunch intent', () => {
82
+ it('writes one-line JSON with intent, reason, and embedded ts (the freshness clock)', () => {
83
+ writeRelaunchModelIntent(dir, 'keep', 'user: /new from chat')
84
+ const raw = readFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), 'utf8')
85
+ const parsed = JSON.parse(raw)
86
+ expect(parsed.intent).toBe('keep')
87
+ expect(parsed.reason).toBe('user: /new from chat')
88
+ expect(Math.abs(Date.now() - parsed.ts)).toBeLessThan(5000)
89
+ })
90
+
91
+ it('last-writer-wins and clearable', () => {
92
+ writeRelaunchModelIntent(dir, 'keep', 'a')
93
+ writeRelaunchModelIntent(dir, 'revert', 'b')
94
+ expect(JSON.parse(readFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), 'utf8')).intent).toBe('revert')
95
+ clearRelaunchModelIntent(dir)
96
+ expect(existsSync(join(dir, RELAUNCH_MODEL_INTENT_FILE))).toBe(false)
97
+ })
98
+ })
99
+
100
+ describe('intentForRestartReason — the triggerSelfRestart per-reason table (RFC §3)', () => {
101
+ it.each([
102
+ 'schedule-restart-immediate',
103
+ 'restart-drain-cap-forced',
104
+ 'turn-complete-pending-restart',
105
+ 'fleet-fallback-resume',
106
+ 'sr-to-claude-model-switch',
107
+ ])('switchroom-managed relaunch %s → keep', (reason) => {
108
+ expect(intentForRestartReason(reason)).toBe('keep')
109
+ })
110
+
111
+ it('inline-button-restart (operator-deliberate) → revert', () => {
112
+ expect(intentForRestartReason('inline-button-restart')).toBe('revert')
113
+ })
114
+
115
+ it('unknown gateway reasons default to keep (only gateway code calls triggerSelfRestart; crashes never do)', () => {
116
+ expect(intentForRestartReason('some-future-recovery-path')).toBe('keep')
117
+ })
118
+ })
119
+
120
+ describe('readConfiguredDefaultModel', () => {
121
+ it('reads the trimmed value; null when absent or empty', () => {
122
+ expect(readConfiguredDefaultModel(dir)).toBeNull()
123
+ writeFileSync(join(dir, CONFIGURED_DEFAULT_MODEL_FILE), 'claude-sonnet-5\n')
124
+ expect(readConfiguredDefaultModel(dir)).toBe('claude-sonnet-5')
125
+ writeFileSync(join(dir, CONFIGURED_DEFAULT_MODEL_FILE), '\n')
126
+ expect(readConfiguredDefaultModel(dir)).toBeNull()
127
+ })
128
+
129
+ it('clearSessionModelFile is a safe no-op when absent', () => {
130
+ expect(() => clearSessionModelFile(dir)).not.toThrow()
131
+ })
132
+ })
@@ -170,7 +170,7 @@ describe("slot-banner boot recovery (gateway wiring)", () => {
170
170
  const gw2 = makeGateway(fs, tg);
171
171
  const res = await gw2.bootCleanup();
172
172
 
173
- expect(res).toEqual({ cleared: 1, total: 1 });
173
+ expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
174
174
  expect(tg.pinned.has(`${OWNER}:${msgId}`)).toBe(false); // orphan unpinned
175
175
  expect(loadStatusPins(PATH, fs)).toEqual([]); // store emptied
176
176
  });
@@ -220,7 +220,7 @@ describe("slot-banner boot recovery (gateway wiring)", () => {
220
220
  // Fresh boot recovers it from the pending record.
221
221
  const gw2 = makeGateway(fs, tg);
222
222
  const res = await gw2.bootCleanup();
223
- expect(res).toEqual({ cleared: 1, total: 1 });
223
+ expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
224
224
  expect(tg.pinned.has(`${OWNER}:${rec[0].messageId}`)).toBe(false);
225
225
  expect(loadStatusPins(PATH, fs)).toEqual([]);
226
226
  });
@@ -241,6 +241,6 @@ describe("slot-banner boot recovery (gateway wiring)", () => {
241
241
  expect(loadStatusPins(PATH, fs)).toEqual([]);
242
242
 
243
243
  const gw2 = makeGateway(fs, tg);
244
- expect(await gw2.bootCleanup()).toEqual({ cleared: 0, total: 0 });
244
+ expect(await gw2.bootCleanup()).toEqual({ cleared: 0, retained: 0, kept: 0, total: 0 });
245
245
  });
246
246
  });
@@ -141,7 +141,7 @@ describe("status-pin boot recovery (gateway wiring)", () => {
141
141
  const gw2 = makeGateway(fs, tg);
142
142
  const res = await gw2.bootCleanup();
143
143
 
144
- expect(res).toEqual({ cleared: 1, total: 1 });
144
+ expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
145
145
  expect(tg.pinned.has("-100123:715")).toBe(false); // orphan unpinned
146
146
  expect(loadStatusPins(PATH, fs)).toEqual([]); // store emptied
147
147
  });
@@ -177,7 +177,7 @@ describe("status-pin boot recovery (gateway wiring)", () => {
177
177
  // Fresh boot recovers it from the pending record.
178
178
  const gw2 = makeGateway(fs, tg);
179
179
  const res = await gw2.bootCleanup();
180
- expect(res).toEqual({ cleared: 1, total: 1 });
180
+ expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
181
181
  expect(tg.pinned.has("-100123:715")).toBe(false);
182
182
  expect(loadStatusPins(PATH, fs)).toEqual([]);
183
183
  });
@@ -196,7 +196,7 @@ describe("status-pin boot recovery (gateway wiring)", () => {
196
196
  expect(loadStatusPins(PATH, fs)).toEqual([]);
197
197
 
198
198
  const gw2 = makeGateway(fs, tg);
199
- expect(await gw2.bootCleanup()).toEqual({ cleared: 0, total: 0 });
199
+ expect(await gw2.bootCleanup()).toEqual({ cleared: 0, retained: 0, kept: 0, total: 0 });
200
200
  });
201
201
  });
202
202
 
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
+ BOOT_UNPIN_MAX_ATTEMPTS,
3
4
  loadStatusPins,
4
5
  mutateStatusPinRow,
5
6
  persistStatusPins,
@@ -195,12 +196,12 @@ describe("runStatusPinBootCleanup", () => {
195
196
  ["-100123", 715],
196
197
  ["-100999", 42],
197
198
  ]);
198
- expect(res).toEqual({ cleared: 2, total: 2 });
199
+ expect(res).toEqual({ cleared: 2, retained: 0, kept: 0, total: 2 });
199
200
  // Store empty afterwards → no re-attempt next boot.
200
201
  expect(loadStatusPins(PATH, fs)).toEqual([]);
201
202
  });
202
203
 
203
- it("a failing unpin is non-fatal and the store is still emptied", async () => {
204
+ it("retry-safe (#3001): a failing unpin is non-fatal, RETAINS the row with an attempt counter, and drops only the succeeded one", async () => {
204
205
  const { fs } = memFs();
205
206
  persistStatusPins(PATH, fs, [
206
207
  pin({ pinKey: "fg:c:1", chatId: "-100123", messageId: 5 }),
@@ -216,11 +217,66 @@ describe("runStatusPinBootCleanup", () => {
216
217
  log: () => {},
217
218
  });
218
219
 
219
- // One failed, one succeeded — still non-fatal, store still emptied.
220
- expect(res).toEqual({ cleared: 1, total: 2 });
220
+ // One failed, one succeeded — the failure is retained for a next-boot
221
+ // retry instead of forfeiting the orphan (the pre-#3001 behaviour).
222
+ expect(res).toEqual({ cleared: 1, retained: 1, kept: 0, total: 2 });
223
+ expect(loadStatusPins(PATH, fs)).toEqual([
224
+ { pinKey: "fg:c:1", chatId: "-100123", messageId: 5, attempts: 1 },
225
+ ]);
226
+ });
227
+
228
+ it("retry-safe (#3001): a row is forfeited once its attempts reach BOOT_UNPIN_MAX_ATTEMPTS", async () => {
229
+ const { fs } = memFs();
230
+ persistStatusPins(PATH, fs, [
231
+ pin({
232
+ pinKey: "fg:c:1",
233
+ chatId: "-100123",
234
+ messageId: 5,
235
+ attempts: BOOT_UNPIN_MAX_ATTEMPTS - 1,
236
+ }),
237
+ ]);
238
+ const res = await runStatusPinBootCleanup({
239
+ path: PATH,
240
+ fs,
241
+ unpin: async () => {
242
+ throw new Error("chat gone forever");
243
+ },
244
+ log: () => {},
245
+ });
246
+ // Final attempt failed too — forfeited, not retained: a permanently-
247
+ // undeliverable unpin must not re-fail on every future boot.
248
+ expect(res).toEqual({ cleared: 0, retained: 0, kept: 0, total: 1 });
221
249
  expect(loadStatusPins(PATH, fs)).toEqual([]);
222
250
  });
223
251
 
252
+ it("tool pins (#3001): an UNEXPIRED `tool:` row survives the boot untouched; an EXPIRED one is unpinned and dropped", async () => {
253
+ const { fs } = memFs();
254
+ const now = 1_750_000_000_000;
255
+ persistStatusPins(PATH, fs, [
256
+ pin({ pinKey: "tool:-100123:70", chatId: "-100123", messageId: 70, expiresAt: now + 1 }),
257
+ pin({ pinKey: "tool:-100123:71", chatId: "-100123", messageId: 71, expiresAt: now }),
258
+ pin({ pinKey: "wk:agent-x", chatId: "-100123", messageId: 72 }),
259
+ ]);
260
+ const unpinned: number[] = [];
261
+ const res = await runStatusPinBootCleanup({
262
+ path: PATH,
263
+ fs,
264
+ unpin: async (_c, messageId) => {
265
+ unpinned.push(messageId);
266
+ },
267
+ now,
268
+ log: () => {},
269
+ });
270
+ // The expired tool pin and the work-scoped wk: pin are unpinned; the
271
+ // unexpired tool pin is kept for a future boot (restart ≠ reset for a
272
+ // deliberate agent pin with no "work finished" event).
273
+ expect(unpinned).toEqual([71, 72]);
274
+ expect(res).toEqual({ cleared: 2, retained: 0, kept: 1, total: 3 });
275
+ expect(loadStatusPins(PATH, fs)).toEqual([
276
+ { pinKey: "tool:-100123:70", chatId: "-100123", messageId: 70, expiresAt: now + 1 },
277
+ ]);
278
+ });
279
+
224
280
  it("no-op on a fresh boot with no persisted pins (no unpin calls)", async () => {
225
281
  const { fs } = memFs();
226
282
  let calls = 0;
@@ -232,7 +288,7 @@ describe("runStatusPinBootCleanup", () => {
232
288
  },
233
289
  log: () => {},
234
290
  });
235
- expect(res).toEqual({ cleared: 0, total: 0 });
291
+ expect(res).toEqual({ cleared: 0, retained: 0, kept: 0, total: 0 });
236
292
  expect(calls).toBe(0);
237
293
  });
238
294
 
@@ -254,7 +310,7 @@ describe("runStatusPinBootCleanup", () => {
254
310
  log: () => {},
255
311
  });
256
312
  expect(unpinned).toEqual([["-100777", 314]]);
257
- expect(res).toEqual({ cleared: 1, total: 1 });
313
+ expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
258
314
  expect(loadStatusPins(PATH, fs)).toEqual([]);
259
315
  });
260
316
  });