switchroom 0.18.6 → 0.18.7

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 (71) 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 +269 -56
  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 +35 -16
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +1937 -580
  15. package/telegram-plugin/dist/server.js +24 -0
  16. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  17. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  18. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  19. package/telegram-plugin/gateway/gateway.ts +1071 -130
  20. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  21. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  22. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  23. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  24. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  25. package/telegram-plugin/gateway/model-command.ts +212 -51
  26. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  27. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  28. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  29. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  30. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  31. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  32. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  33. package/telegram-plugin/model-label.ts +69 -0
  34. package/telegram-plugin/operator-events.ts +24 -0
  35. package/telegram-plugin/permission-diff.ts +128 -0
  36. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  37. package/telegram-plugin/registry/subagents.test.ts +90 -0
  38. package/telegram-plugin/session-tail.ts +28 -0
  39. package/telegram-plugin/silent-end.ts +49 -4
  40. package/telegram-plugin/subagent-watcher.ts +222 -37
  41. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  42. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  43. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  44. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  45. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
  46. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  47. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  48. package/telegram-plugin/tests/model-command.test.ts +202 -42
  49. package/telegram-plugin/tests/model-label.test.ts +64 -0
  50. package/telegram-plugin/tests/operator-events.test.ts +1 -0
  51. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  52. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  53. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  54. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  55. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  56. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  57. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  58. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  59. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  60. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  61. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  62. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  63. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  64. package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
  65. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  66. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  67. package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
  68. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  69. package/telegram-plugin/tool-activity-summary.ts +22 -2
  70. package/telegram-plugin/typing-wrap.ts +72 -25
  71. package/telegram-plugin/worker-activity-feed.ts +9 -0
@@ -18,7 +18,13 @@ import {
18
18
  humanizeElapsed,
19
19
  buildResumeInterruptedInbound,
20
20
  buildResumeWatchdogReportInbound,
21
+ buildResumeDeferredReportInbound,
22
+ isResumeSyntheticTurn,
21
23
  selectResumeBuilder,
24
+ decideBootResumeKind,
25
+ RESUME_SYNTHETIC_PROMPT_PREFIX,
26
+ renderInterruptedSubagentsBlock,
27
+ type InterruptedSubagent,
22
28
  } from '../gateway/resume-inbound-builder.js'
23
29
  import type { Turn, TurnEndedVia } from '../registry/turns-schema.js'
24
30
 
@@ -158,6 +164,155 @@ describe('buildResumeInterruptedInbound', () => {
158
164
  })
159
165
  })
160
166
 
167
+ describe('interrupted sub-agent block', () => {
168
+ const twoRunning: InterruptedSubagent[] = [
169
+ { agentType: 'worker', description: 'refactor the auth module and add tests', status: 'running' },
170
+ { agentType: 'researcher', description: 'survey the pricing pages of 5 competitors', status: 'running' },
171
+ ]
172
+
173
+ it('renderInterruptedSubagentsBlock lists each worker with type + prompt', () => {
174
+ const block = renderInterruptedSubagentsBlock(twoRunning)
175
+ expect(block).toContain('2 sub-agents were still')
176
+ expect(block).toContain('did NOT complete')
177
+ expect(block).toContain('[worker]')
178
+ expect(block).toContain('refactor the auth module and add tests')
179
+ expect(block).toContain('[researcher]')
180
+ expect(block).toContain('survey the pricing pages of 5 competitors')
181
+ expect(block).toContain('Re-dispatch the ones still needed')
182
+ })
183
+
184
+ it('returns empty string when there are no in-flight sub-agents', () => {
185
+ expect(renderInterruptedSubagentsBlock(undefined)).toBe('')
186
+ expect(renderInterruptedSubagentsBlock([])).toBe('')
187
+ })
188
+
189
+ it('interrupted-turn inbound contains BOTH running sub-agents', () => {
190
+ const msg = buildResumeInterruptedInbound({ turn: makeTurn(), subagents: twoRunning })
191
+ expect(msg.text).toContain('refactor the auth module and add tests')
192
+ expect(msg.text).toContain('survey the pricing pages of 5 competitors')
193
+ expect(msg.text).toContain('did NOT complete')
194
+ expect(msg.text).toContain('Re-dispatch the ones still needed')
195
+ })
196
+
197
+ it('interrupted-turn inbound is UNCHANGED when no sub-agents were running', () => {
198
+ const withNone = buildResumeInterruptedInbound({ turn: makeTurn(), subagents: [] })
199
+ const bare = buildResumeInterruptedInbound({ turn: makeTurn() })
200
+ expect(withNone.text).toBe(bare.text)
201
+ expect(withNone.text).not.toContain('did NOT complete')
202
+ })
203
+
204
+ it('truncates each dispatch prompt to ~200 chars', () => {
205
+ const long = 'x'.repeat(400)
206
+ const block = renderInterruptedSubagentsBlock([{ agentType: 'worker', description: long }])
207
+ // The 400-char prompt must not survive in full; the entry line is capped.
208
+ expect(block).not.toContain(long)
209
+ expect(block).toContain('…')
210
+ // 200-char cap → the truncated slice (199 chars + ellipsis) is present.
211
+ expect(block).toContain('x'.repeat(199))
212
+ expect(block).not.toContain('x'.repeat(201))
213
+ })
214
+
215
+ it('caps the list at 10 and summarises the remainder', () => {
216
+ const many: InterruptedSubagent[] = Array.from({ length: 14 }, (_, i) => ({
217
+ agentType: 'worker',
218
+ description: `task number ${i + 1}`,
219
+ }))
220
+ const block = renderInterruptedSubagentsBlock(many)
221
+ expect(block).toContain('14 sub-agents were still')
222
+ expect(block).toContain('task number 10')
223
+ expect(block).not.toContain('task number 11')
224
+ expect(block).toContain('…and 4 more.')
225
+ // Exactly 10 numbered entries rendered.
226
+ const numbered = block.match(/^\s+\d+\. \[/gm) ?? []
227
+ expect(numbered.length).toBe(10)
228
+ })
229
+
230
+ it('falls back to a generic label + placeholder when type/prompt are missing', () => {
231
+ const block = renderInterruptedSubagentsBlock([{ agentType: null, description: null }])
232
+ expect(block).toContain('[sub-agent]')
233
+ expect(block).toContain('(no task description recorded)')
234
+ })
235
+
236
+ it('singularises a single killed sub-agent', () => {
237
+ const block = renderInterruptedSubagentsBlock([{ agentType: 'worker', description: 'do X' }])
238
+ expect(block).toContain('1 sub-agent was still')
239
+ expect(block).not.toContain('1 sub-agents')
240
+ })
241
+
242
+ it('codepoint-safe truncation never splits a surrogate pair', () => {
243
+ // 199 ASCII chars, then an astral-plane emoji (2 UTF-16 code units)
244
+ // straddling the cap. A code-unit slice would cut the pair in half and
245
+ // leave a lone surrogate; the codepoint-safe slice must not.
246
+ const desc = 'a'.repeat(199) + '🚀' + 'b'.repeat(50)
247
+ const block = renderInterruptedSubagentsBlock([{ agentType: 'worker', description: desc }])
248
+ expect(block).toContain('…')
249
+ // No lone surrogates anywhere in the rendered block.
250
+ expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(block)).toBe(false)
251
+ expect(block).toBe(block.normalize('NFC')) // sanity: still a well-formed string
252
+ })
253
+
254
+ it('the watchdog report inbound carries the block in DEFERRED form (ask-first contract)', () => {
255
+ const msg = buildResumeWatchdogReportInbound({
256
+ turn: makeTurn({ ended_via: 'timeout' }),
257
+ idleMs: 300_000,
258
+ subagents: twoRunning,
259
+ })
260
+ expect(msg.text).toContain('did NOT complete')
261
+ expect(msg.text).toContain('refactor the auth module and add tests')
262
+ // Deferred wording: re-dispatch is conditional on the user asking to retry…
263
+ expect(msg.text).toContain("If the user asks you to retry, they'll need re-dispatching")
264
+ expect(msg.text).toContain("don't assume their work landed")
265
+ // …and the resume-path imperative must NOT appear — it would contradict
266
+ // the watchdog inbound's "Do NOT silently resume … ask" hang-safety gate.
267
+ expect(msg.text).not.toContain('Re-dispatch the ones still needed')
268
+ expect(msg.text).not.toContain('before declaring the task done')
269
+ })
270
+
271
+ it('the resume-path inbound keeps the assertive imperative (and not the deferred form)', () => {
272
+ const msg = buildResumeInterruptedInbound({ turn: makeTurn(), subagents: twoRunning })
273
+ expect(msg.text).toContain('Re-dispatch the ones still needed before declaring the task done')
274
+ expect(msg.text).not.toContain('If the user asks you to retry')
275
+ })
276
+
277
+ for (const reason of ['loop-guard', 'clean-restart-suppressed'] as const) {
278
+ it(`the deferred report (${reason}) carries the block in DEFERRED form — suppressed resumes still name worker deaths`, () => {
279
+ const msg = buildResumeDeferredReportInbound({
280
+ turn: makeTurn(),
281
+ reason,
282
+ subagents: twoRunning,
283
+ })
284
+ expect(msg.text).toContain('did NOT complete')
285
+ expect(msg.text).toContain('refactor the auth module and add tests')
286
+ expect(msg.text).toContain('survey the pricing pages of 5 competitors')
287
+ // Deferred wording only — never the resume-path imperative, which would
288
+ // contradict this inbound's "Do NOT silently resume … ask" contract.
289
+ expect(msg.text).toContain("If the user asks you to retry, they'll need re-dispatching")
290
+ expect(msg.text).not.toContain('Re-dispatch the ones still needed')
291
+ expect(msg.text).not.toContain('before declaring the task done')
292
+ // Appending the block must not disturb the loop-guard anchor at pos 0.
293
+ expect(msg.text.startsWith(RESUME_SYNTHETIC_PROMPT_PREFIX)).toBe(true)
294
+ })
295
+ }
296
+
297
+ it('deferred report without subagents is unchanged (no block)', () => {
298
+ const withNone = buildResumeDeferredReportInbound({ turn: makeTurn(), reason: 'loop-guard', subagents: [] })
299
+ const bare = buildResumeDeferredReportInbound({ turn: makeTurn(), reason: 'loop-guard' })
300
+ expect(withNone.text).toBe(bare.text)
301
+ expect(withNone.text).not.toContain('did NOT complete')
302
+ })
303
+
304
+ it('appending the block preserves the RESUME_SYNTHETIC_PROMPT_PREFIX anchor on resume + watchdog inbounds too', () => {
305
+ const resume = buildResumeInterruptedInbound({ turn: makeTurn(), subagents: twoRunning })
306
+ expect(resume.text.startsWith(RESUME_SYNTHETIC_PROMPT_PREFIX)).toBe(true)
307
+ const report = buildResumeWatchdogReportInbound({
308
+ turn: makeTurn({ ended_via: 'timeout' }),
309
+ idleMs: 300_000,
310
+ subagents: twoRunning,
311
+ })
312
+ expect(report.text.startsWith(RESUME_SYNTHETIC_PROMPT_PREFIX)).toBe(true)
313
+ })
314
+ })
315
+
161
316
  describe('buildResumeWatchdogReportInbound', () => {
162
317
  it('sets the resume_watchdog_timeout source and idle_ms passthrough', () => {
163
318
  const turn = makeTurn({ ended_via: 'timeout' })
@@ -243,3 +398,134 @@ describe('selectResumeBuilder', () => {
243
398
  expect(selectResumeBuilder('restart', { ageMs: MAX + 1 })).toBe('resume') // needs BOTH to cap
244
399
  })
245
400
  })
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // Loop-guard: RESUME_SYNTHETIC_PROMPT_PREFIX / isResumeSyntheticTurn
404
+ // ---------------------------------------------------------------------------
405
+
406
+ describe('resume synthetic-turn detection (loop-guard anchor)', () => {
407
+ it('EVERY synthetic boot inbound text starts with the machine-stable prefix', () => {
408
+ // The loop-guard keys on this prefix landing in user_prompt_preview, so if
409
+ // a prose edit drops it the chain-cap silently breaks. Pin it here.
410
+ const turn = makeTurn({ user_prompt_preview: 'do the thing' })
411
+ expect(buildResumeInterruptedInbound({ turn }).text.startsWith(RESUME_SYNTHETIC_PROMPT_PREFIX)).toBe(true)
412
+ expect(
413
+ buildResumeWatchdogReportInbound({ turn: makeTurn({ ended_via: 'timeout' }), idleMs: 1000 }).text.startsWith(
414
+ RESUME_SYNTHETIC_PROMPT_PREFIX,
415
+ ),
416
+ ).toBe(true)
417
+ expect(
418
+ buildResumeDeferredReportInbound({ turn, reason: 'loop-guard' }).text.startsWith(
419
+ RESUME_SYNTHETIC_PROMPT_PREFIX,
420
+ ),
421
+ ).toBe(true)
422
+ expect(
423
+ buildResumeDeferredReportInbound({ turn, reason: 'clean-restart-suppressed' }).text.startsWith(
424
+ RESUME_SYNTHETIC_PROMPT_PREFIX,
425
+ ),
426
+ ).toBe(true)
427
+ })
428
+
429
+ it('isResumeSyntheticTurn is true for a turn whose preview is a synthetic prompt', () => {
430
+ const synthetic = buildResumeInterruptedInbound({ turn: makeTurn() })
431
+ // A resume turn stores the first ~200 chars of the synthetic text as its
432
+ // preview (channel wrapper stripped) — simulate that.
433
+ const resumeTurn = makeTurn({ user_prompt_preview: synthetic.text.slice(0, 200) })
434
+ expect(isResumeSyntheticTurn(resumeTurn)).toBe(true)
435
+ })
436
+
437
+ it('isResumeSyntheticTurn is false for real user work and for null preview', () => {
438
+ expect(isResumeSyntheticTurn(makeTurn({ user_prompt_preview: 'refactor the auth module' }))).toBe(false)
439
+ expect(isResumeSyntheticTurn(makeTurn({ user_prompt_preview: null }))).toBe(false)
440
+ })
441
+ })
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // buildResumeDeferredReportInbound
445
+ // ---------------------------------------------------------------------------
446
+
447
+ describe('buildResumeDeferredReportInbound', () => {
448
+ it('emits source=resume_deferred and carries the dedup anchor + reason', () => {
449
+ const turn = makeTurn({ turn_key: 'zz:3' })
450
+ const msg = buildResumeDeferredReportInbound({ turn, reason: 'loop-guard' })
451
+ expect(msg.meta.source).toBe('resume_deferred')
452
+ expect(msg.meta.resume_turn_key).toBe('zz:3')
453
+ expect(msg.meta.defer_reason).toBe('loop-guard')
454
+ })
455
+
456
+ it('loop-guard framing tells the model the chain was capped, not to resume', () => {
457
+ const msg = buildResumeDeferredReportInbound({ turn: makeTurn(), reason: 'loop-guard' })
458
+ expect(msg.text.toLowerCase()).toContain('resume of earlier interrupted work')
459
+ expect(msg.text).toContain('Do NOT silently resume')
460
+ })
461
+
462
+ it('clean-restart-suppressed framing cites boot_resume: never', () => {
463
+ const msg = buildResumeDeferredReportInbound({ turn: makeTurn(), reason: 'clean-restart-suppressed' })
464
+ expect(msg.text).toContain('boot_resume: never')
465
+ expect(msg.text).toContain('Do NOT silently resume')
466
+ })
467
+
468
+ it('routes to the origin thread when the turn carried one', () => {
469
+ const turn = makeTurn({ chat_id: '-100999', thread_id: '77' })
470
+ const msg = buildResumeDeferredReportInbound({ turn, reason: 'loop-guard' })
471
+ expect(msg.chatId).toBe('-100999')
472
+ expect(msg.threadId).toBe(77)
473
+ expect(msg.meta.message_thread_id).toBe('77')
474
+ })
475
+ })
476
+
477
+ // ---------------------------------------------------------------------------
478
+ // decideBootResumeKind — boot-resume precedence + bounded resume chain
479
+ // ---------------------------------------------------------------------------
480
+
481
+ describe('decideBootResumeKind', () => {
482
+ const MAX = 10_800_000 // 3h
483
+
484
+ it('resumes genuinely in-flight work after a deliberate restart (not suppressed)', () => {
485
+ // The core product fix: clean restart mid-turn → resume, not silence.
486
+ const pending = makeTurn({ ended_via: 'restart', user_prompt_preview: 'ship the release' })
487
+ expect(decideBootResumeKind({ pending, suppressed: false, ageMs: 1000, maxAgeMs: MAX })).toBe('resume')
488
+ })
489
+
490
+ it("boot_resume:never (suppressed) → 'defer-suppressed' passive report, never silence", () => {
491
+ const pending = makeTurn({ ended_via: 'restart', user_prompt_preview: 'ship the release' })
492
+ expect(decideBootResumeKind({ pending, suppressed: true, ageMs: 1000, maxAgeMs: MAX })).toBe('defer-suppressed')
493
+ })
494
+
495
+ it('watchdog timeout still reports even when not suppressed', () => {
496
+ const pending = makeTurn({ ended_via: 'timeout', user_prompt_preview: 'ship the release' })
497
+ expect(decideBootResumeKind({ pending, suppressed: false, ageMs: 1000, maxAgeMs: MAX })).toBe('report')
498
+ })
499
+
500
+ it('BOUNDED CHAIN: a restart DURING a resume turn does NOT re-resume — loop-guard fires', () => {
501
+ // Simulate the chain the coordinator flagged:
502
+ // A: real work, interrupted → resume R1 (turn B created from R1's text)
503
+ // restart lands during B → pending is B, whose preview IS the synthetic
504
+ // resume prompt. We must NOT mint a second resume (endless loop risk).
505
+ const r1 = buildResumeInterruptedInbound({ turn: makeTurn({ user_prompt_preview: 'real work' }) })
506
+ const turnB = makeTurn({ ended_via: 'restart', user_prompt_preview: r1.text.slice(0, 200) })
507
+ // Even though NOT suppressed (would normally resume), the loop-guard wins:
508
+ expect(decideBootResumeKind({ pending: turnB, suppressed: false, ageMs: 1000, maxAgeMs: MAX })).toBe('defer-loop')
509
+ })
510
+
511
+ it('BOUNDED CHAIN: a restart during a deferred-report turn stays capped (still defer-loop, never resume)', () => {
512
+ // Depth does not grow: a report-of-report is still a passive report, never
513
+ // a resume — so no unbounded restart→resume→restart chain can form.
514
+ const deferred = buildResumeDeferredReportInbound({ turn: makeTurn(), reason: 'loop-guard' })
515
+ const turnC = makeTurn({ ended_via: 'restart', user_prompt_preview: deferred.text.slice(0, 200) })
516
+ const kind = decideBootResumeKind({ pending: turnC, suppressed: false, ageMs: 1000, maxAgeMs: MAX })
517
+ expect(kind).toBe('defer-loop')
518
+ expect(kind).not.toBe('resume')
519
+ })
520
+
521
+ it('loop-guard takes precedence over suppression too (synthetic turn never resumes or double-reports as resume)', () => {
522
+ const r1 = buildResumeInterruptedInbound({ turn: makeTurn() })
523
+ const turnB = makeTurn({ ended_via: 'restart', user_prompt_preview: r1.text.slice(0, 200) })
524
+ expect(decideBootResumeKind({ pending: turnB, suppressed: true, ageMs: 1000, maxAgeMs: MAX })).toBe('defer-loop')
525
+ })
526
+
527
+ it('stale in-flight work downgrades to report when older than maxAgeMs', () => {
528
+ const pending = makeTurn({ ended_via: 'restart', user_prompt_preview: 'stale work' })
529
+ expect(decideBootResumeKind({ pending, suppressed: false, ageMs: MAX + 1, maxAgeMs: MAX })).toBe('report')
530
+ })
531
+ })
@@ -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({
@@ -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',