switchroom 0.18.6 → 0.18.8

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 (116) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +1 -0
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +1172 -812
  6. package/dist/host-control/main.js +2 -1
  7. package/dist/vault/approvals/kernel-server.js +1 -0
  8. package/dist/vault/broker/server.js +1 -0
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +146 -50
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-runtime/SKILL.md +2 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  15. package/telegram-plugin/dist/gateway/gateway.js +2965 -862
  16. package/telegram-plugin/dist/server.js +24 -0
  17. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  18. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  19. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  20. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  21. package/telegram-plugin/gateway/boot-card.ts +27 -0
  22. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  23. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  24. package/telegram-plugin/gateway/gateway.ts +1618 -198
  25. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  26. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  27. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  28. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  29. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  30. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  31. package/telegram-plugin/gateway/model-command.ts +227 -54
  32. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  33. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  34. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  35. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  36. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  37. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  38. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  39. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  40. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  41. package/telegram-plugin/hooks/hooks.json +10 -10
  42. package/telegram-plugin/hooks/run-hook.sh +84 -0
  43. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  44. package/telegram-plugin/model-label.ts +69 -0
  45. package/telegram-plugin/model-unavailable.ts +26 -0
  46. package/telegram-plugin/operator-events.ts +24 -0
  47. package/telegram-plugin/permission-diff.ts +128 -0
  48. package/telegram-plugin/pty-partial-handler.ts +39 -0
  49. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  50. package/telegram-plugin/registry/subagents.test.ts +90 -0
  51. package/telegram-plugin/render/rich-render.ts +79 -1
  52. package/telegram-plugin/retry-api-call.ts +62 -0
  53. package/telegram-plugin/session-tail.ts +28 -0
  54. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  55. package/telegram-plugin/silence-poke.ts +14 -0
  56. package/telegram-plugin/silent-end.ts +49 -4
  57. package/telegram-plugin/stream-controller.ts +156 -38
  58. package/telegram-plugin/subagent-watcher.ts +222 -37
  59. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  60. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  61. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  62. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  63. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  64. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  65. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  66. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  67. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  68. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  69. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
  70. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  71. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  72. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  73. package/telegram-plugin/tests/model-command.test.ts +203 -43
  74. package/telegram-plugin/tests/model-label.test.ts +64 -0
  75. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  76. package/telegram-plugin/tests/operator-events.test.ts +1 -0
  77. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  78. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  79. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  80. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  81. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  82. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  83. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  84. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  85. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  86. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  87. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  88. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  89. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  90. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  92. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  93. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  94. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  96. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  97. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  98. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  99. package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
  100. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  101. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  102. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  103. package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
  104. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  105. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  106. package/telegram-plugin/tool-activity-summary.ts +22 -2
  107. package/telegram-plugin/typing-wrap.ts +72 -25
  108. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  109. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  110. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  111. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  112. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  113. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  114. package/telegram-plugin/voice-ondemand.ts +25 -1
  115. package/telegram-plugin/voice-send.ts +154 -0
  116. package/telegram-plugin/worker-activity-feed.ts +9 -0
@@ -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
+ })
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Pins the /status session-model freshness contract (#2982 + live-model PR):
3
+ * the FRESHEST observation wins between the transcript's `message.model` and
4
+ * the /model override, arbitrated by a shared monotonic sequence. This is the
5
+ * "model display must never be stale" invariant:
6
+ *
7
+ * - override set AFTER the last transcript observation (idle-time /model
8
+ * switch, no assistant line yet) → /status shows the override;
9
+ * - a new assistant line after that → the transcript wins again.
10
+ */
11
+ import { describe, it, expect } from 'vitest'
12
+ import { createSessionModelSource } from '../gateway/session-model-source.js'
13
+
14
+ describe('createSessionModelSource — freshest observation wins', () => {
15
+ it('returns null when neither source has reported', () => {
16
+ const s = createSessionModelSource()
17
+ expect(s.resolve()).toBeNull()
18
+ expect(s.getOverride()).toBeNull()
19
+ })
20
+
21
+ it('transcript-only → transcript', () => {
22
+ const s = createSessionModelSource()
23
+ s.noteTranscriptModel('claude-opus-4-8')
24
+ expect(s.resolve()).toEqual({ model: 'claude-opus-4-8', source: 'transcript' })
25
+ })
26
+
27
+ it('override-only → override (fresh boot before the first assistant line)', () => {
28
+ const s = createSessionModelSource()
29
+ s.setOverride('sr-glm-5')
30
+ expect(s.resolve()).toEqual({ model: 'sr-glm-5', source: 'override' })
31
+ })
32
+
33
+ it('idle-after-switch window: an override set AFTER the last transcript line wins', () => {
34
+ // The #2982 regression this pins: /model switch while idle — the
35
+ // transcript still holds the OLD model, the override holds the NEW one.
36
+ const s = createSessionModelSource()
37
+ s.noteTranscriptModel('claude-opus-4-8') // old model's last assistant line
38
+ s.setOverride('Sonnet 5') // confirmed switch, no assistant line yet
39
+ expect(s.resolve()).toEqual({ model: 'Sonnet 5', source: 'override' })
40
+ })
41
+
42
+ it('a NEW assistant line after the switch reclaims the transcript as the source', () => {
43
+ const s = createSessionModelSource()
44
+ s.noteTranscriptModel('claude-opus-4-8')
45
+ s.setOverride('Sonnet 5')
46
+ s.noteTranscriptModel('claude-sonnet-5') // first line under the new model
47
+ expect(s.resolve()).toEqual({ model: 'claude-sonnet-5', source: 'transcript' })
48
+ })
49
+
50
+ it('clearing the override (null) falls back to the transcript', () => {
51
+ const s = createSessionModelSource()
52
+ s.noteTranscriptModel('claude-opus-4-8')
53
+ s.setOverride('sr-glm-5')
54
+ s.setOverride(null)
55
+ expect(s.getOverride()).toBeNull()
56
+ expect(s.resolve()).toEqual({ model: 'claude-opus-4-8', source: 'transcript' })
57
+ })
58
+
59
+ it('getOverride reports the override independent of freshness', () => {
60
+ const s = createSessionModelSource()
61
+ s.setOverride('sr-glm-5')
62
+ s.noteTranscriptModel('claude-opus-4-8') // transcript is now fresher
63
+ expect(s.resolve()?.source).toBe('transcript')
64
+ // ...but the override record itself is still readable (menu "session" marker).
65
+ expect(s.getOverride()).toBe('sr-glm-5')
66
+ })
67
+ })
@@ -345,6 +345,42 @@ describe('projectTranscriptLine', () => {
345
345
  messageId: '103',
346
346
  })
347
347
  })
348
+
349
+ // ─── Live model capture (message.model) ──────────────────────────────
350
+ it('emits a model event (first) from message.model on an assistant line', () => {
351
+ const line = JSON.stringify({
352
+ type: 'assistant',
353
+ message: {
354
+ model: 'claude-opus-4-8',
355
+ content: [{ type: 'tool_use', name: 'Bash', id: 'toolu_01', input: {} }],
356
+ },
357
+ })
358
+ // Model event is emitted BEFORE the content events so a same-batch render
359
+ // already reflects the current model.
360
+ expect(projectTranscriptLine(line)).toEqual([
361
+ { kind: 'model', model: 'claude-opus-4-8' },
362
+ { kind: 'tool_use', toolName: 'Bash', toolUseId: 'toolu_01', input: {} },
363
+ ])
364
+ })
365
+
366
+ it('skips a synthetic model sentinel (keeps no model event)', () => {
367
+ const line = JSON.stringify({
368
+ type: 'assistant',
369
+ message: {
370
+ model: '<synthetic>',
371
+ content: [{ type: 'thinking', thinking: '...' }],
372
+ },
373
+ })
374
+ expect(projectTranscriptLine(line)).toEqual([{ kind: 'thinking' }])
375
+ })
376
+
377
+ it('omits the model event when message.model is absent', () => {
378
+ const line = JSON.stringify({
379
+ type: 'assistant',
380
+ message: { content: [{ type: 'thinking', thinking: '...' }] },
381
+ })
382
+ expect(projectTranscriptLine(line)).toEqual([{ kind: 'thinking' }])
383
+ })
348
384
  })
349
385
 
350
386
  // ─── Bug 1 regression: per-file cursor state survives re-attachment ────
@@ -490,6 +526,34 @@ describe('projectSubagentLine', () => {
490
526
  ])
491
527
  })
492
528
 
529
+ it('emits sub_agent_model (first) from message.model on a sub-agent assistant line', () => {
530
+ const st = { hasEmittedStart: true }
531
+ const line = JSON.stringify({
532
+ type: 'assistant',
533
+ message: {
534
+ model: 'sr-glm-5',
535
+ content: [{ type: 'tool_use', id: 'toolu_a', name: 'Read', input: { file_path: '/a' } }],
536
+ },
537
+ })
538
+ const events = projectSubagentLine(line, 'X', st)
539
+ expect(events[0]).toEqual({ kind: 'sub_agent_model', agentId: 'X', model: 'sr-glm-5' })
540
+ expect(events[1].kind).toBe('sub_agent_tool_use')
541
+ })
542
+
543
+ it('skips a synthetic sub-agent model sentinel', () => {
544
+ const st = { hasEmittedStart: true }
545
+ const line = JSON.stringify({
546
+ type: 'assistant',
547
+ message: {
548
+ model: '<synthetic>',
549
+ content: [{ type: 'tool_use', id: 'toolu_a', name: 'Read', input: { file_path: '/a' } }],
550
+ },
551
+ })
552
+ const events = projectSubagentLine(line, 'X', st)
553
+ expect(events.some((e) => e.kind === 'sub_agent_model')).toBe(false)
554
+ expect(events[0].kind).toBe('sub_agent_tool_use')
555
+ })
556
+
493
557
  it('emits sub_agent_tool_use for regular tools; nested Agent fires ONLY nested_spawn', () => {
494
558
  const st = { hasEmittedStart: true }
495
559
  const line = JSON.stringify({
@@ -10,6 +10,7 @@ import {
10
10
  recordSilentTurnEnd,
11
11
  recordUndeliveredTurnEnd,
12
12
  SILENT_END_MAX_RETRIES,
13
+ SILENT_END_STALE_RECORD_MAX_AGE_MS,
13
14
  } from '../silent-end.js'
14
15
  import { isFinalAnswerReply } from '../final-answer-detect.js'
15
16
 
@@ -206,12 +207,56 @@ describe('recordSilentTurnEnd — #1161 exhaustion detection', () => {
206
207
  chatId: 'c', threadId: null, turnKey: 'c:_',
207
208
  retryCount: SILENT_END_MAX_RETRIES, timestamp: 0,
208
209
  }))
209
- const r = recordSilentTurnEnd({ chatId: 'c', threadId: null, turnKey: 'c:_' })
210
+ // Pin `now` alongside the record's timestamp=0 so this exercises the
211
+ // genuine same-turn ladder, not fix #8's age-based staleness bound
212
+ // (which is covered by its own dedicated tests below).
213
+ const r = recordSilentTurnEnd(
214
+ { chatId: 'c', threadId: null, turnKey: 'c:_' },
215
+ { now: () => 0 },
216
+ )
210
217
  expect(r.exhausted).toBe(true)
211
218
  // State cleared so the Stop hook on this final turn allows the stop.
212
219
  expect(readSilentEndState()).toBeNull()
213
220
  })
214
221
 
222
+ it('fix #8: an exhausted record older than the plausible turn lifetime starts a fresh retry budget (no delivery evidence needed)', () => {
223
+ // A crash/interrupt bypassed the gateway's own exhaust-read-and-clear,
224
+ // so a spent (retryCount >= MAX) record from an OLD turn survives on
225
+ // disk. turnKey is the STABLE statusKey(chatId, threadId) — it matches
226
+ // this brand-new dark turn on the same chat/thread even though it
227
+ // belongs to a completely different turn instance.
228
+ const path = join(stateDir, 'silent-end-pending.json')
229
+ writeFileSync(path, JSON.stringify({
230
+ chatId: 'c', threadId: null, turnKey: 'c:_',
231
+ retryCount: SILENT_END_MAX_RETRIES, timestamp: 0,
232
+ }))
233
+ const now = SILENT_END_STALE_RECORD_MAX_AGE_MS + 1000 // just past the age bound
234
+ const r = recordSilentTurnEnd(
235
+ { chatId: 'c', threadId: null, turnKey: 'c:_' },
236
+ { now: () => now },
237
+ )
238
+ // Must run its OWN re-prompt ladder, not immediately fall back.
239
+ expect(r.exhausted).toBe(false)
240
+ expect(readSilentEndState()).toMatchObject({ turnKey: 'c:_', retryCount: 0 })
241
+ })
242
+
243
+ it('fix #8: a genuinely same-turn exhausted record (within the age bound) still reports exhausted — no regression', () => {
244
+ const path = join(stateDir, 'silent-end-pending.json')
245
+ const recentTimestamp = 1_000_000
246
+ writeFileSync(path, JSON.stringify({
247
+ chatId: 'c', threadId: null, turnKey: 'c:_',
248
+ retryCount: SILENT_END_MAX_RETRIES, timestamp: recentTimestamp,
249
+ }))
250
+ // Well within the plausible single-turn retry-ladder window.
251
+ const now = recentTimestamp + 5000
252
+ const r = recordSilentTurnEnd(
253
+ { chatId: 'c', threadId: null, turnKey: 'c:_' },
254
+ { now: () => now },
255
+ )
256
+ expect(r.exhausted).toBe(true)
257
+ expect(readSilentEndState()).toBeNull()
258
+ })
259
+
215
260
  it('treats a capped prior state for a DIFFERENT turn as a fresh silent-end', () => {
216
261
  const path = join(stateDir, 'silent-end-pending.json')
217
262
  writeFileSync(path, JSON.stringify({
@@ -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
  });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Wire-level regression test for the chunk-boundary cap bug
3
+ * (fix/rich-render-chunk-boundary-cap).
4
+ *
5
+ * With `SWITCHROOM_RICH_RENDER` on, a near-cap body full of escapable chars
6
+ * (`_ * |`) makes `renderSafe` degrade the whole document to plain (its escaped
7
+ * rich form exceeds RICH_MESSAGE_MAX_CHARS). BEFORE the fix, the stream
8
+ * controller shipped that ~32k plain body through the plain `sendMessage`
9
+ * endpoint in ONE call — Telegram's plain endpoint caps at 4096, so the send
10
+ * is rejected (`message is too long`) and the streamed answer is dropped.
11
+ *
12
+ * AFTER the fix, `renderOutboundChunks` re-splits at safe boundaries so every
13
+ * emitted send fits its own wire cap: rich pieces <= 32768, plain pieces
14
+ * <= 4096, and no fenced block is bisected.
15
+ */
16
+ import { describe, it, expect, afterEach } from "vitest";
17
+ import { createStreamController } from "../stream-controller.js";
18
+ import { createFakeBotApi } from "./fake-bot-api.js";
19
+ import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
20
+
21
+ const PLAIN_CAP = 4096; // Telegram's legacy plain-text sendMessage cap (format.ts:29).
22
+
23
+ function fenceCount(s: string): number {
24
+ return (s.match(/^```/gm) ?? []).length;
25
+ }
26
+
27
+ describe("stream-controller enforces the wire cap on the post-escape body", () => {
28
+ afterEach(() => {
29
+ delete process.env.SWITCHROOM_RICH_RENDER;
30
+ });
31
+
32
+ it("REGRESSION: near-cap escapable first send never exceeds the plain wire cap", async () => {
33
+ process.env.SWITCHROOM_RICH_RENDER = "1";
34
+ const bot = createFakeBotApi({ startMessageId: 1000 });
35
+ const unit = "a_b*c|d ";
36
+ const body = unit.repeat(Math.floor((RICH_MESSAGE_MAX_CHARS - 20) / unit.length));
37
+
38
+ const stream = createStreamController({
39
+ bot: bot as unknown as Parameters<typeof createStreamController>[0]["bot"],
40
+ chatId: "c1",
41
+ throttleMs: 0,
42
+ });
43
+ await stream.update(body);
44
+ await stream.finalize();
45
+
46
+ expect(bot.state.sent.length).toBeGreaterThan(0);
47
+ for (const s of bot.state.sent) {
48
+ // Every send fits the rich cap...
49
+ expect(s.text.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);
50
+ // ...and a PLAIN send (rich !== true) additionally fits the 4096 plain
51
+ // endpoint cap — the invariant HEAD violated (one ~32k plain send).
52
+ if (!s.rich) expect(s.text.length).toBeLessThanOrEqual(PLAIN_CAP);
53
+ // No send bisects a fenced block.
54
+ expect(fenceCount(s.text) % 2).toBe(0);
55
+ }
56
+ }, 30000);
57
+
58
+ it("BLOCKER: multi-update oversize stream emits tails ONCE, not per edit tick", async () => {
59
+ // Reproduces the duplicate-flood blocker: an oversize body splits into N
60
+ // pieces. The FIRST flush (send path) emits the anchor + (N-1) tail
61
+ // messages. Every SUBSEQUENT throttled flush routes through the EDIT
62
+ // callback. The pre-fix code re-sent all (N-1) tails as brand-new messages
63
+ // on each edit tick, so `sent.length` grew by (N-1) every update. After the
64
+ // fix, tails are parked once and edited in place — `sent.length` is flat.
65
+ process.env.SWITCHROOM_RICH_RENDER = "1";
66
+ const bot = createFakeBotApi({ startMessageId: 3000 });
67
+ const unit = "a_b*c|d ";
68
+ // Near-cap body that degrades to plain and splits into several pieces.
69
+ const base = unit.repeat(Math.floor((RICH_MESSAGE_MAX_CHARS - 20) / unit.length));
70
+ // Distinct short prefix per flush so the HEAD piece actually changes —
71
+ // an unchanged head yields a not-modified edit, which short-circuits before
72
+ // the tail loop and would hide the duplicate-resend bug.
73
+ const bodyFor = (i: number) => `v${i} ${base}`;
74
+
75
+ const stream = createStreamController({
76
+ bot: bot as unknown as Parameters<typeof createStreamController>[0]["bot"],
77
+ chatId: "c1",
78
+ throttleMs: 0,
79
+ });
80
+
81
+ // First flush → send path: anchor + tails, emitted exactly once.
82
+ await stream.update(bodyFor(1));
83
+ const afterFirst = bot.state.sent.length;
84
+ expect(afterFirst).toBeGreaterThan(1); // genuinely multi-piece
85
+
86
+ // Drive several more oversize updates — each routes through the EDIT
87
+ // callback. The count must NOT grow: tails are edited in place, not resent.
88
+ await stream.update(bodyFor(2));
89
+ expect(bot.state.sent.length).toBe(afterFirst);
90
+ await stream.update(bodyFor(3));
91
+ expect(bot.state.sent.length).toBe(afterFirst);
92
+ await stream.update(bodyFor(4));
93
+ expect(bot.state.sent.length).toBe(afterFirst);
94
+ await stream.finalize();
95
+
96
+ // Final: exactly the anchor + tail set, no duplicates across the lifetime.
97
+ expect(bot.state.sent.length).toBe(afterFirst);
98
+ const ids = bot.state.sent.map((s) => s.message_id);
99
+ expect(new Set(ids).size).toBe(ids.length); // no duplicate message ids
100
+
101
+ // Every currently-visible message fits its wire cap and (for plain) 4096.
102
+ for (const s of bot.state.sent) {
103
+ const cur = bot.textOf(s.message_id) ?? "";
104
+ expect(cur.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);
105
+ if (!s.rich) expect(cur.length).toBeLessThanOrEqual(PLAIN_CAP);
106
+ }
107
+ }, 30000);
108
+
109
+ it("flag OFF leaves the single-send path untouched", async () => {
110
+ const bot = createFakeBotApi({ startMessageId: 2000 });
111
+ const stream = createStreamController({
112
+ bot: bot as unknown as Parameters<typeof createStreamController>[0]["bot"],
113
+ chatId: "c1",
114
+ throttleMs: 0,
115
+ });
116
+ await stream.update("**hi** _there_");
117
+ await stream.finalize();
118
+ expect(bot.state.sent).toHaveLength(1);
119
+ expect(bot.state.sent[0].rich).toBe(true);
120
+ expect(bot.state.sent[0].text).toBe("**hi** _there_");
121
+ });
122
+ });
@@ -108,6 +108,45 @@ describe('subagent-tracker-pretool', () => {
108
108
  expect(row!.last_activity_at).toBe(row!.started_at)
109
109
  })
110
110
 
111
+ it('persists tool_input.model as the first-paint model on the row', () => {
112
+ const event = {
113
+ session_id: 'sess-model',
114
+ tool_name: 'Agent',
115
+ tool_use_id: 'toolu_model001',
116
+ tool_input: {
117
+ subagent_type: 'worker',
118
+ description: 'Build with a pinned model',
119
+ run_in_background: true,
120
+ model: 'claude-opus-4-8',
121
+ },
122
+ }
123
+ const result = runHook(PRETOOL_SCRIPT, event)
124
+ expect(result.status).toBe(0)
125
+
126
+ const db = openDb()
127
+ const row = db.prepare('SELECT model FROM subagents WHERE id = ?').get('toolu_model001') as
128
+ | { model: string | null }
129
+ | undefined
130
+ expect(row?.model).toBe('claude-opus-4-8')
131
+ })
132
+
133
+ it('leaves model null when the Agent dispatch carries no model', () => {
134
+ const event = {
135
+ session_id: 'sess-nomodel',
136
+ tool_name: 'Agent',
137
+ tool_use_id: 'toolu_nomodel001',
138
+ tool_input: { subagent_type: 'worker', description: 'no model', run_in_background: false },
139
+ }
140
+ const result = runHook(PRETOOL_SCRIPT, event)
141
+ expect(result.status).toBe(0)
142
+
143
+ const db = openDb()
144
+ const row = db.prepare('SELECT model FROM subagents WHERE id = ?').get('toolu_nomodel001') as
145
+ | { model: string | null }
146
+ | undefined
147
+ expect(row?.model ?? null).toBeNull()
148
+ })
149
+
111
150
  it('does not write a row when tool_name is not Agent', () => {
112
151
  const event = {
113
152
  session_id: 'sess-abc123',