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,308 @@
1
+ /**
2
+ * Telegram file_id reuse for the on-demand '🔊 Listen' send path.
3
+ *
4
+ * gateway.ts is a 25k-line module with import-time side effects, so the send
5
+ * decision core lives in ../voice-send.ts (imported by the gateway). These
6
+ * tests drive the real orchestrator + cache primitive with a mocked bot api,
7
+ * proving the observable contract the gateway wires up:
8
+ *
9
+ * (a) FIRST tap (no stored id) uploads via InputFile and captures the
10
+ * returned file_id onto the cache entry;
11
+ * (b) SECOND tap sends BY the file_id string — no disk read, no InputFile;
12
+ * (c) a file_id send REJECTED as stale (400) falls back to a re-upload and
13
+ * refreshes the stored id, so a dead id never permanently breaks Listen.
14
+ */
15
+
16
+ import { describe, it, expect } from 'bun:test'
17
+ import {
18
+ sendVoiceReusingFileId,
19
+ extractVoiceFileId,
20
+ isInvalidTelegramFileIdError,
21
+ isHttp400Error,
22
+ type SentVoiceMessage,
23
+ } from '../voice-send.js'
24
+ import { VoiceOnDemandCache } from '../voice-ondemand.js'
25
+
26
+ /** A minimal grammy-shaped 400 error (duck-typed the way the gateway sees it). */
27
+ function grammy400(description: string): Error {
28
+ return Object.assign(new Error(description), { error_code: 400, description })
29
+ }
30
+
31
+ /** Sent-message stub echoing a file_id, as Telegram returns from sendVoice. */
32
+ function sentWithFileId(fileId: string): SentVoiceMessage {
33
+ return { voice: { file_id: fileId } }
34
+ }
35
+
36
+ const OGG = new Uint8Array([0x4f, 0x67, 0x67, 0x53]) // OggS magic
37
+
38
+ describe('sendVoiceReusingFileId — file_id reuse contract', () => {
39
+ it('(a) first tap: no stored id → uploads via InputFile, captures the id', async () => {
40
+ const calls: string[] = []
41
+ let byFileId = 0
42
+ let uploaded = 0
43
+ let captured: string | null = null
44
+
45
+ const res = await sendVoiceReusingFileId({
46
+ fileId: null,
47
+ sendByFileId: async () => {
48
+ byFileId++
49
+ return null
50
+ },
51
+ loadAudio: async () => {
52
+ calls.push('loadAudio')
53
+ return OGG
54
+ },
55
+ sendByUpload: async (audio) => {
56
+ uploaded++
57
+ expect(audio).toBe(OGG)
58
+ return sentWithFileId('FILE_ID_1')
59
+ },
60
+ onFileId: (id) => {
61
+ captured = id
62
+ },
63
+ })
64
+
65
+ expect(res).toEqual({ ok: true, path: 'upload', refreshed: false })
66
+ expect(byFileId).toBe(0) // never tried the id path — none stored
67
+ expect(uploaded).toBe(1) // exactly one upload
68
+ expect(calls).toEqual(['loadAudio']) // audio WAS loaded on the first tap
69
+ expect(captured).toBe('FILE_ID_1') // id captured for reuse
70
+ })
71
+
72
+ it('(b) second tap: stored id → sends by file_id string, no disk read, no InputFile', async () => {
73
+ let loadAudioCalls = 0
74
+ let uploadCalls = 0
75
+ let sentId: string | null = null
76
+
77
+ const res = await sendVoiceReusingFileId({
78
+ fileId: 'FILE_ID_1',
79
+ sendByFileId: async (fid) => {
80
+ sentId = fid
81
+ return sentWithFileId('FILE_ID_1') // Telegram echoes the same id
82
+ },
83
+ loadAudio: async () => {
84
+ loadAudioCalls++ // MUST NOT happen — proves no disk read
85
+ return OGG
86
+ },
87
+ sendByUpload: async () => {
88
+ uploadCalls++ // MUST NOT happen — proves no re-upload
89
+ return null
90
+ },
91
+ onFileId: () => {},
92
+ })
93
+
94
+ expect(res).toEqual({ ok: true, path: 'file_id', refreshed: false })
95
+ expect(sentId).toBe('FILE_ID_1') // sent BY the string id
96
+ expect(loadAudioCalls).toBe(0) // no disk read
97
+ expect(uploadCalls).toBe(0) // no InputFile upload
98
+ })
99
+
100
+ it('(c) stale id (400): falls back to re-upload and refreshes the stored id', async () => {
101
+ let byFileId = 0
102
+ let uploaded = 0
103
+ let refreshedTo: string | null = null
104
+
105
+ const res = await sendVoiceReusingFileId({
106
+ fileId: 'STALE_ID',
107
+ sendByFileId: async () => {
108
+ byFileId++
109
+ throw grammy400('Bad Request: wrong file identifier/HTTP URL specified')
110
+ },
111
+ loadAudio: async () => OGG,
112
+ sendByUpload: async () => {
113
+ uploaded++
114
+ return sentWithFileId('FRESH_ID')
115
+ },
116
+ onFileId: (id) => {
117
+ refreshedTo = id
118
+ },
119
+ })
120
+
121
+ expect(res).toEqual({ ok: true, path: 'upload', refreshed: true })
122
+ expect(byFileId).toBe(1) // tried the stale id once
123
+ expect(uploaded).toBe(1) // then re-uploaded
124
+ expect(refreshedTo).toBe('FRESH_ID') // stored id refreshed, not left stale
125
+ })
126
+
127
+ it('a NON-stale send error is a real failure — no silent re-upload', async () => {
128
+ let uploaded = 0
129
+ const res = await sendVoiceReusingFileId({
130
+ fileId: 'FILE_ID_1',
131
+ sendByFileId: async () => {
132
+ throw Object.assign(new Error('forbidden'), { error_code: 403 })
133
+ },
134
+ loadAudio: async () => OGG,
135
+ sendByUpload: async () => {
136
+ uploaded++
137
+ return null
138
+ },
139
+ onFileId: () => {},
140
+ })
141
+ expect(res.ok).toBe(false)
142
+ expect(uploaded).toBe(0) // did NOT paper over a 403 with an upload
143
+ })
144
+
145
+ it('(C1) UNKNOWN-wording 400: re-uploads + refreshes id even outside the stale-id substrings', async () => {
146
+ // A 400 whose text is NOT in isInvalidTelegramFileIdError's substring set.
147
+ // Pre-tweak (substring-gated) this classified as send-failed and never
148
+ // refreshed the id; post-tweak ANY 400 recovers via re-upload.
149
+ let byFileId = 0
150
+ let uploaded = 0
151
+ let refreshedTo: string | null = null
152
+
153
+ const err400 = grammy400('Bad Request: some brand-new file rejection wording')
154
+ expect(isInvalidTelegramFileIdError(err400)).toBe(false) // guard: outside old substrings
155
+ expect(isHttp400Error(err400)).toBe(true) // but IS a 400 → now re-uploadable
156
+
157
+ const res = await sendVoiceReusingFileId({
158
+ fileId: 'STALE_ID',
159
+ sendByFileId: async () => {
160
+ byFileId++
161
+ throw err400
162
+ },
163
+ loadAudio: async () => OGG,
164
+ sendByUpload: async () => {
165
+ uploaded++
166
+ return sentWithFileId('FRESH_ID')
167
+ },
168
+ onFileId: (id) => {
169
+ refreshedTo = id
170
+ },
171
+ })
172
+
173
+ expect(res).toEqual({ ok: true, path: 'upload', refreshed: true })
174
+ expect(byFileId).toBe(1) // tried the id once
175
+ expect(uploaded).toBe(1) // then re-uploaded on the unknown 400
176
+ expect(refreshedTo).toBe('FRESH_ID') // stored id refreshed, not left stale
177
+ })
178
+
179
+ it('(C1) a network error (no error_code) still does NOT re-upload', async () => {
180
+ let uploaded = 0
181
+ const res = await sendVoiceReusingFileId({
182
+ fileId: 'FILE_ID_1',
183
+ sendByFileId: async () => {
184
+ throw new Error('ECONNRESET') // no error_code → not a 400
185
+ },
186
+ loadAudio: async () => OGG,
187
+ sendByUpload: async () => {
188
+ uploaded++
189
+ return null
190
+ },
191
+ onFileId: () => {},
192
+ })
193
+ expect(res.ok).toBe(false)
194
+ expect(uploaded).toBe(0) // transient network error is a real failure, not a re-upload
195
+ })
196
+
197
+ it('(C1) re-upload after a 400 ALSO fails → returns failure, no second re-upload', async () => {
198
+ let byFileId = 0
199
+ let uploaded = 0
200
+ let refreshed: string | null = null
201
+
202
+ const res = await sendVoiceReusingFileId({
203
+ fileId: 'STALE_ID',
204
+ sendByFileId: async () => {
205
+ byFileId++
206
+ throw grammy400('Bad Request: totally unfamiliar 400 wording')
207
+ },
208
+ loadAudio: async () => OGG,
209
+ sendByUpload: async () => {
210
+ uploaded++
211
+ throw grammy400('Bad Request: upload also rejected') // recovery send fails too
212
+ },
213
+ onFileId: (id) => {
214
+ refreshed = id
215
+ },
216
+ })
217
+
218
+ expect(res.ok).toBe(false)
219
+ if (!res.ok) expect(res.reason).toBe('send-failed')
220
+ expect(byFileId).toBe(1) // id tried once
221
+ expect(uploaded).toBe(1) // re-uploaded exactly once — NO second attempt / loop
222
+ expect(refreshed).toBeNull() // failed upload never refreshed the id
223
+ })
224
+
225
+ it('no-audio on the upload path returns { ok:false, reason:"no-audio" }', async () => {
226
+ const res = await sendVoiceReusingFileId({
227
+ fileId: null,
228
+ sendByFileId: async () => null,
229
+ loadAudio: async () => null, // swept + synth unavailable
230
+ sendByUpload: async () => {
231
+ throw new Error('should not be called')
232
+ },
233
+ onFileId: () => {},
234
+ })
235
+ expect(res).toEqual({ ok: false, reason: 'no-audio' })
236
+ })
237
+ })
238
+
239
+ describe('extractVoiceFileId / isInvalidTelegramFileIdError', () => {
240
+ it('extracts a present file_id, ignores missing/empty', () => {
241
+ expect(extractVoiceFileId({ voice: { file_id: 'abc' } })).toBe('abc')
242
+ expect(extractVoiceFileId({ voice: {} })).toBeUndefined()
243
+ expect(extractVoiceFileId({ voice: { file_id: '' } })).toBeUndefined()
244
+ expect(extractVoiceFileId(null)).toBeUndefined()
245
+ expect(extractVoiceFileId(undefined)).toBeUndefined()
246
+ })
247
+
248
+ it('classifies a 400 wrong-file-identifier as stale, others as not', () => {
249
+ expect(
250
+ isInvalidTelegramFileIdError(grammy400('Bad Request: wrong file identifier/HTTP URL specified')),
251
+ ).toBe(true)
252
+ expect(isInvalidTelegramFileIdError(grammy400('Bad Request: wrong remote file identifier'))).toBe(
253
+ true,
254
+ )
255
+ // Not a file_id problem — genuine failures the caller must surface.
256
+ expect(isInvalidTelegramFileIdError(grammy400('Bad Request: chat not found'))).toBe(false)
257
+ expect(
258
+ isInvalidTelegramFileIdError(Object.assign(new Error('forbidden'), { error_code: 403 })),
259
+ ).toBe(false)
260
+ expect(isInvalidTelegramFileIdError(new Error('network'))).toBe(false)
261
+ expect(isInvalidTelegramFileIdError(null)).toBe(false)
262
+ })
263
+
264
+ it('isHttp400Error: true only for error_code 400, wording-agnostic', () => {
265
+ expect(isHttp400Error(grammy400('any wording at all'))).toBe(true)
266
+ expect(isHttp400Error(grammy400('Bad Request: chat not found'))).toBe(true) // 400 regardless of text
267
+ expect(isHttp400Error(Object.assign(new Error('x'), { error_code: 403 }))).toBe(false)
268
+ expect(isHttp400Error(Object.assign(new Error('x'), { error_code: 500 }))).toBe(false)
269
+ expect(isHttp400Error(new Error('network'))).toBe(false) // no error_code
270
+ expect(isHttp400Error(null)).toBe(false)
271
+ expect(isHttp400Error(undefined)).toBe(false)
272
+ })
273
+ })
274
+
275
+ describe('VoiceOnDemandCache.setTelegramFileId — capture + sweep drop', () => {
276
+ it('stores an id on a live entry and returns it via get()', () => {
277
+ const cache = new VoiceOnDemandCache()
278
+ cache.put('tok', { text: 'hi', speed: 1.1 })
279
+ cache.setTelegramFileId('tok', 'FILE_ID_1')
280
+ expect(cache.get('tok')?.telegramFileId).toBe('FILE_ID_1')
281
+ })
282
+
283
+ it('ignores an empty id and unknown/evicted tokens', () => {
284
+ const cache = new VoiceOnDemandCache()
285
+ cache.put('tok', { text: 'hi', speed: 1.1 })
286
+ cache.setTelegramFileId('tok', '') // ignored
287
+ expect(cache.get('tok')?.telegramFileId).toBeUndefined()
288
+ cache.setTelegramFileId('missing', 'X') // no-op, no throw
289
+ expect(cache.get('missing')).toBeNull()
290
+ })
291
+
292
+ it('drops the stored id when the 7-day sweep prunes the entry — no resurrection', () => {
293
+ const cache = new VoiceOnDemandCache()
294
+ cache.put('tok', { text: 'hi', speed: 1.1 })
295
+ cache.setTelegramFileId('tok', 'FILE_ID_1')
296
+ cache.prune(['tok']) // sweep deleted the on-disk file for this token
297
+ expect(cache.get('tok')).toBeNull() // entry AND its file_id are gone
298
+ })
299
+
300
+ it('does not store an id on an already-expired entry', () => {
301
+ let t = 1000
302
+ const cache = new VoiceOnDemandCache({ ttlMs: 100, now: () => t })
303
+ cache.put('tok', { text: 'hi', speed: 1.1 })
304
+ t = 2000 // past TTL
305
+ cache.setTelegramFileId('tok', 'FILE_ID_1') // no-op on expired entry
306
+ expect(cache.get('tok')).toBeNull()
307
+ })
308
+ })
@@ -86,6 +86,17 @@ describe('renderWorkerActivity', () => {
86
86
  expect(out).not.toContain('<code>')
87
87
  })
88
88
 
89
+ it('renders the friendly live model on the worker metrics line', () => {
90
+ const out = renderWorkerActivity(view({ model: 'claude-sonnet-5' }))
91
+ expect(out).toContain('_10s · 3 tools · sonnet 5_')
92
+ })
93
+
94
+ it('omits the model tag when the worker model is unknown', () => {
95
+ const out = renderWorkerActivity(view())
96
+ expect(out).toContain('_10s · 3 tools_')
97
+ expect(out).not.toContain('· sonnet')
98
+ })
99
+
89
100
  it('shows a "starting…" line when no step has run yet', () => {
90
101
  const out = renderWorkerActivity(view({ lastTool: null, latestSummary: '' }))
91
102
  expect(out).toContain('🛠 **Worker**')
@@ -17,6 +17,7 @@ function makeSub(over: Partial<Subagent>): Subagent {
17
17
  result_summary: null,
18
18
  jsonl_agent_id: 'a37ad7639ae61476c',
19
19
  parent_agent_id: null,
20
+ model: null,
20
21
  ...over,
21
22
  }
22
23
  }
@@ -61,6 +62,85 @@ describe('resolveWorkerFeedDispatch (#2002 regression pin)', () => {
61
62
  // flip a foreground turn into a background one.
62
63
  expect(resolveWorkerFeedDispatch(null, '').isBackground).toBe(false)
63
64
  })
65
+
66
+ it('surfaces the persisted registry model as feedModel (first-paint fallback)', () => {
67
+ const sub = makeSub({ background: true, model: 'claude-opus-4-8' })
68
+ expect(resolveWorkerFeedDispatch(sub, 'sub-agent').feedModel).toBe('claude-opus-4-8')
69
+ })
70
+
71
+ it('feedModel is null when the row is missing or carries no model', () => {
72
+ expect(resolveWorkerFeedDispatch(null, 'sub-agent').feedModel).toBeNull()
73
+ expect(resolveWorkerFeedDispatch(makeSub({ model: null }), 'sub-agent').feedModel).toBeNull()
74
+ })
75
+ })
76
+
77
+ describe('gateway onFinish — fix #1: resultText-driven handback fallback (model)', () => {
78
+ // Mirrors the exact fallback gateway.ts's onFinish handler applies on top
79
+ // of `resolveWorkerFeedDispatch`'s own result (gateway.ts, onFinish
80
+ // handler, right after computing `dispatch`):
81
+ //
82
+ // let isBackground = dispatch.isBackground
83
+ // if (!dispatch.hasRow && entryBackground == null && resultText.trim().length > 0) {
84
+ // isBackground = true
85
+ // }
86
+ //
87
+ // This is the worst case of the #1 bug: the DB row was never linked
88
+ // (unlinked `jsonl_agent_id`) AND the watcher's own cached
89
+ // `entry.background` was also never observed (both `resolveWorkerFeedDispatch`
90
+ // fallbacks came up empty). A finished worker with actual narrative
91
+ // result text is far more likely a dropped background handback than a
92
+ // legitimate foreground no-op, so this degrades to background rather
93
+ // than silently discarding the result.
94
+ function applyOnFinishBackgroundFallback(
95
+ dispatch: { isBackground: boolean; hasRow: boolean },
96
+ entryBackground: boolean | undefined,
97
+ resultText: string,
98
+ ): boolean {
99
+ let isBackground = dispatch.isBackground
100
+ if (!dispatch.hasRow && entryBackground == null && resultText.trim().length > 0) {
101
+ isBackground = true
102
+ }
103
+ return isBackground
104
+ }
105
+
106
+ it('meta-missing path: no row, no entry.background, non-empty resultText → routes a handback', () => {
107
+ const dispatch = resolveWorkerFeedDispatch(null, 'sub-agent', undefined)
108
+ const result = applyOnFinishBackgroundFallback(dispatch, undefined, 'Here is what I found...')
109
+ expect(result).toBe(true)
110
+ })
111
+
112
+ it('no-fuzzy-candidate path: no row, no entry.background, non-empty resultText → routes a handback', () => {
113
+ // Same shape as above — the ambiguous-fuzzy-match-refused case (fix #3)
114
+ // also leaves jsonl_agent_id unlinked, landing in the same no-row state.
115
+ const dispatch = resolveWorkerFeedDispatch(null, 'sub-agent', undefined)
116
+ const result = applyOnFinishBackgroundFallback(dispatch, undefined, 'Task complete: 3 files changed.')
117
+ expect(result).toBe(true)
118
+ })
119
+
120
+ it('empty resultText with no row/no entry.background does NOT escalate (true foreground no-op preserved)', () => {
121
+ const dispatch = resolveWorkerFeedDispatch(null, 'sub-agent', undefined)
122
+ const result = applyOnFinishBackgroundFallback(dispatch, undefined, ' ')
123
+ expect(result).toBe(false)
124
+ })
125
+
126
+ it('entry.background=false (watcher DID observe it as foreground) is trusted over resultText', () => {
127
+ const dispatch = resolveWorkerFeedDispatch(null, 'sub-agent', false)
128
+ const result = applyOnFinishBackgroundFallback(dispatch, false, 'Some prose result anyway.')
129
+ expect(result).toBe(false)
130
+ })
131
+
132
+ it('entry.background=true (watcher DID observe it) is trusted directly, no resultText fallback needed', () => {
133
+ const dispatch = resolveWorkerFeedDispatch(null, 'sub-agent', true)
134
+ const result = applyOnFinishBackgroundFallback(dispatch, true, '')
135
+ expect(result).toBe(true)
136
+ })
137
+
138
+ it('a present DB row is authoritative — the resultText fallback never overrides a real foreground row', () => {
139
+ const sub = makeSub({ background: false })
140
+ const dispatch = resolveWorkerFeedDispatch(sub, 'sub-agent', undefined)
141
+ const result = applyOnFinishBackgroundFallback(dispatch, undefined, 'Long narrative result...')
142
+ expect(result).toBe(false)
143
+ })
64
144
  })
65
145
 
66
146
  // Deterministic PRNG (mulberry32) so a failing case is reproducible from the
@@ -140,6 +220,52 @@ describe('resolveWorkerFeedDispatch — randomized property sweep', () => {
140
220
  })
141
221
  })
142
222
 
223
+ describe('resolveWorkerFeedDispatch — fix #1(+#2): entryBackground fallback', () => {
224
+ it('falls back to entryBackground=true when the registry row is missing (unlinked jsonl_agent_id)', () => {
225
+ // Simulates onFinish firing for a background worker whose DB row was
226
+ // never linked via `jsonl_agent_id` (meta.json missing, or the fuzzy
227
+ // backfill match never fired) — the watcher's own cached
228
+ // `entry.background` (seeded at dispatch time / kept fresh by
229
+ // `readSubTail`'s liveness lookup) is the only signal left.
230
+ const out = resolveWorkerFeedDispatch(null, 'sub-agent', true)
231
+ expect(out.isBackground).toBe(true)
232
+ expect(out.hasRow).toBe(false)
233
+ })
234
+
235
+ it('falls back to entryBackground=false when the row is missing and the entry was foreground', () => {
236
+ const out = resolveWorkerFeedDispatch(null, 'sub-agent', false)
237
+ expect(out.isBackground).toBe(false)
238
+ })
239
+
240
+ it('defaults to false when the row is missing AND entryBackground was never observed (undefined)', () => {
241
+ // Both fallbacks come up empty — worst case, matches pre-fix
242
+ // behaviour (isBackground=false) at the resolver level. The gateway's
243
+ // onFinish handler applies its OWN further fallback on top of this
244
+ // (routing a handback anyway when resultText is non-empty) — that's
245
+ // pinned separately in the gateway-level test, not here.
246
+ const out = resolveWorkerFeedDispatch(null, 'sub-agent', undefined)
247
+ expect(out.isBackground).toBe(false)
248
+ expect(out.hasRow).toBe(false)
249
+ })
250
+
251
+ it('a present registry row always wins over entryBackground, even when they disagree', () => {
252
+ // The DB row is the authoritative source whenever it's actually
253
+ // linked — entryBackground is a pure fallback for the unlinked case,
254
+ // never a second vote.
255
+ const sub = makeSub({ background: true })
256
+ const out = resolveWorkerFeedDispatch(sub, 'sub-agent', false)
257
+ expect(out.isBackground).toBe(true)
258
+ })
259
+
260
+ it('omitting entryBackground entirely preserves prior 2-arg call-site behaviour', () => {
261
+ // #2002-era callers pass only 2 args — the optional 3rd param must not
262
+ // change their observed result.
263
+ expect(resolveWorkerFeedDispatch(null, 'sub-agent')).toEqual(
264
+ resolveWorkerFeedDispatch(null, 'sub-agent', undefined),
265
+ )
266
+ })
267
+ })
268
+
143
269
  describe('resolveWorkerFeedDispatch — nested/row-presence signals (unified progress cards)', () => {
144
270
  it('hasRow=false for a missing registry row (never silently foreground-nest it)', () => {
145
271
  const out = resolveWorkerFeedDispatch(null, 'sub-agent')
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ decideWorkerPinReaps,
4
+ workerAgentIdOfPinKey,
5
+ WORKER_PIN_TTL_MS_DEFAULT,
6
+ type WorkerPinCandidate,
7
+ } from "../gateway/worker-pin-reaper.js";
8
+
9
+ const NOW = 1_750_000_000_000;
10
+ const TTL = WORKER_PIN_TTL_MS_DEFAULT;
11
+
12
+ function pin(over: Partial<WorkerPinCandidate> = {}): WorkerPinCandidate {
13
+ return { pinKey: "wk:agent-1", chatId: "123", pinnedAt: NOW - 60_000, ...over };
14
+ }
15
+
16
+ describe("workerAgentIdOfPinKey", () => {
17
+ it("extracts the agentId from a wk: key", () => {
18
+ expect(workerAgentIdOfPinKey("wk:abc123")).toBe("abc123");
19
+ });
20
+ it("returns null for non-worker keys (fg:/banner:/tool:) and empty ids", () => {
21
+ expect(workerAgentIdOfPinKey("fg:123:5")).toBeNull();
22
+ expect(workerAgentIdOfPinKey("banner:owner")).toBeNull();
23
+ expect(workerAgentIdOfPinKey("tool:123:9")).toBeNull();
24
+ expect(workerAgentIdOfPinKey("wk:")).toBeNull();
25
+ });
26
+ });
27
+
28
+ describe("decideWorkerPinReaps (#3001 mid-session wk: sweep)", () => {
29
+ it("reaps a pin whose worker is terminal, however young the pin is", () => {
30
+ const reaps = decideWorkerPinReaps({
31
+ pins: [pin({ pinnedAt: NOW - 1_000 })],
32
+ statusOf: () => 'terminal' as const,
33
+ ttlMs: TTL,
34
+ now: NOW,
35
+ });
36
+ expect(reaps).toHaveLength(1);
37
+ expect(reaps[0].reason).toBe("terminal");
38
+ expect(reaps[0].pinKey).toBe("wk:agent-1");
39
+ });
40
+
41
+ it("reaps a non-terminal pin once it exceeds the TTL", () => {
42
+ const reaps = decideWorkerPinReaps({
43
+ pins: [pin({ pinnedAt: NOW - TTL })],
44
+ statusOf: () => 'unknown' as const,
45
+ ttlMs: TTL,
46
+ now: NOW,
47
+ });
48
+ expect(reaps).toHaveLength(1);
49
+ expect(reaps[0].reason).toBe("ttl");
50
+ });
51
+
52
+ it("NEVER touches a pin younger than the TTL when the registry cannot vouch (unknown)", () => {
53
+ const reaps = decideWorkerPinReaps({
54
+ pins: [pin({ pinnedAt: NOW - TTL + 1 })],
55
+ statusOf: () => 'unknown' as const,
56
+ ttlMs: TTL,
57
+ now: NOW,
58
+ });
59
+ expect(reaps).toHaveLength(0);
60
+ });
61
+
62
+ it("ignores non-worker keys entirely (fg:/banner:/tool: are not its job)", () => {
63
+ const reaps = decideWorkerPinReaps({
64
+ pins: [
65
+ pin({ pinKey: "fg:123:5", pinnedAt: NOW - 10 * TTL }),
66
+ pin({ pinKey: "banner:owner", pinnedAt: NOW - 10 * TTL }),
67
+ pin({ pinKey: "tool:123:9", pinnedAt: NOW - 10 * TTL }),
68
+ ],
69
+ statusOf: () => 'terminal' as const,
70
+ ttlMs: TTL,
71
+ now: NOW,
72
+ });
73
+ expect(reaps).toHaveLength(0);
74
+ });
75
+
76
+ it("skips a candidate with no chat id (cannot unpin without one)", () => {
77
+ const reaps = decideWorkerPinReaps({
78
+ pins: [pin({ chatId: "", pinnedAt: NOW - 10 * TTL })],
79
+ statusOf: () => 'terminal' as const,
80
+ ttlMs: TTL,
81
+ now: NOW,
82
+ });
83
+ expect(reaps).toHaveLength(0);
84
+ });
85
+
86
+ it("a registry-confirmed RUNNING worker keeps its pin PAST the TTL (no unpin/re-pin churn on healthy long workers)", () => {
87
+ const reaps = decideWorkerPinReaps({
88
+ pins: [pin({ pinnedAt: NOW - 10 * TTL })],
89
+ statusOf: () => 'running' as const,
90
+ ttlMs: TTL,
91
+ now: NOW,
92
+ });
93
+ expect(reaps).toHaveLength(0);
94
+ });
95
+
96
+ it("status is consulted per-agent: terminal reaps young, running exempts old, unknown falls to the TTL gate", () => {
97
+ const seen: string[] = [];
98
+ const reaps = decideWorkerPinReaps({
99
+ pins: [
100
+ pin({ pinKey: "wk:done", pinnedAt: NOW - 1_000 }),
101
+ pin({ pinKey: "wk:running-old", pinnedAt: NOW - TTL - 1 }),
102
+ pin({ pinKey: "wk:unknown-young", pinnedAt: NOW - 1_000 }),
103
+ pin({ pinKey: "wk:unknown-old", pinnedAt: NOW - TTL - 1 }),
104
+ ],
105
+ statusOf: (agentId) => {
106
+ seen.push(agentId);
107
+ if (agentId === "done") return "terminal";
108
+ if (agentId === "running-old") return "running";
109
+ return "unknown";
110
+ },
111
+ ttlMs: TTL,
112
+ now: NOW,
113
+ });
114
+ expect(seen).toEqual(["done", "running-old", "unknown-young", "unknown-old"]);
115
+ expect(reaps.map((r) => [r.pinKey, r.reason])).toEqual([
116
+ ["wk:done", "terminal"],
117
+ ["wk:unknown-old", "ttl"],
118
+ ]);
119
+ });
120
+
121
+ it("registry hiccup ('unknown') never unpins a young pin — the caller's contract on statusOf degrade", () => {
122
+ // The gateway wraps its DB lookup and degrades to 'unknown'. This pins the
123
+ // pure module's side of the contract: unknown + young pin = untouched.
124
+ const reaps = decideWorkerPinReaps({
125
+ pins: [pin()],
126
+ statusOf: () => 'unknown' as const,
127
+ ttlMs: TTL,
128
+ now: NOW,
129
+ });
130
+ expect(reaps).toHaveLength(0);
131
+ });
132
+ });
@@ -91,6 +91,7 @@ import {
91
91
  } from './status-no-truncate.js'
92
92
  import { escapeMarkdown, stripMarkdown, truncate, stackCardLines } from './card-format.js'
93
93
  import { isTelegramSurfaceTool } from './tool-names.js'
94
+ import { formatModelLabel } from './model-label.js'
94
95
 
95
96
  /**
96
97
  * Optional header for the main-session activity card, matching the worker
@@ -106,6 +107,14 @@ export interface SessionActivityHeader {
106
107
  elapsedMs: number
107
108
  toolCount: number
108
109
  state: 'running' | 'done'
110
+ /**
111
+ * Live model in use, as a raw resolved model id (e.g. `claude-opus-4-8`,
112
+ * `sr-glm-5`) sourced from the transcript. Rendered as a short friendly form
113
+ * appended to the metrics line (`… · opus 4.8`). Omitted when unknown — the
114
+ * card never guesses from config. Formatting + sentinel-filtering live in
115
+ * `formatModelLabel` (model-label.ts).
116
+ */
117
+ model?: string
109
118
  }
110
119
 
111
120
  /**
@@ -173,14 +182,19 @@ export function renderActivityHeader(
173
182
  elapsedMs: number,
174
183
  toolCount: number,
175
184
  state: 'running' | 'done' | 'failed',
185
+ model?: string,
176
186
  ): [string, string] {
177
187
  const toolWord = toolCount === 1 ? 'tool' : 'tools'
178
188
  const elapsed = formatFeedElapsed(elapsedMs)
179
189
  const descPart = description.length > 0 ? ` · _${escapeMarkdown(description)}_` : ''
180
190
  const line1 = `${emoji} **${escapeMarkdown(label)}**${descPart}`
191
+ // Subtle live-model tag: joins the existing dot-separated metrics (never a new
192
+ // line). formatModelLabel returns null for absent/sentinel values → no suffix.
193
+ const modelLabel = formatModelLabel(model)
194
+ const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
181
195
  const line2 = state === 'running'
182
- ? `_${elapsed} · ${toolCount} ${toolWord}_`
183
- : `_${state} · ${toolCount} ${toolWord} · ${elapsed}_`
196
+ ? `_${elapsed} · ${toolCount} ${toolWord}${modelPart}_`
197
+ : `_${state} · ${toolCount} ${toolWord} · ${elapsed}${modelPart}_`
184
198
  return [line1, line2]
185
199
  }
186
200
 
@@ -270,6 +284,9 @@ export interface StatusCardHeader {
270
284
  elapsedMs: number
271
285
  toolCount: number
272
286
  state: 'running' | 'done' | 'failed'
287
+ /** Live model id (raw, e.g. `claude-opus-4-8`) — rendered as a short friendly
288
+ * tag on the metrics line via `formatModelLabel`. Omitted when unknown. */
289
+ model?: string
273
290
  }
274
291
 
275
292
  /** Inputs to the unified status-card renderer. */
@@ -321,6 +338,7 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
321
338
  // when the result block is empty. The agent surface never passes
322
339
  // 'failed', so only the worker card is affected.
323
340
  header.state,
341
+ header.model,
324
342
  )
325
343
  : []
326
344
 
@@ -476,6 +494,7 @@ export function renderActivityFeed(
476
494
  elapsedMs: header.elapsedMs,
477
495
  toolCount: header.toolCount,
478
496
  state: header.state,
497
+ model: header.model,
479
498
  }
480
499
  : undefined,
481
500
  steps: lines,
@@ -527,6 +546,7 @@ export function renderActivityFeedWithNested(
527
546
  elapsedMs: header.elapsedMs,
528
547
  toolCount: header.toolCount,
529
548
  state: header.state,
549
+ model: header.model,
530
550
  }
531
551
  : undefined,
532
552
  steps: lines,