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.
- package/dist/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +1 -0
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +1172 -812
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +1 -0
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +146 -50
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2965 -862
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1618 -198
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +227 -54
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/operator-events.ts +24 -0
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/subagent-watcher.ts +222 -37
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +203 -43
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/operator-events.test.ts +1 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
- 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,
|