switchroom 0.14.14 → 0.14.15

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.
@@ -0,0 +1,256 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ renderWorkerActivity,
4
+ createWorkerActivityFeed,
5
+ type WorkerActivityView,
6
+ type BotApiForWorkerFeed,
7
+ } from '../worker-activity-feed.js'
8
+
9
+ function view(partial: Partial<WorkerActivityView> = {}): WorkerActivityView {
10
+ return {
11
+ description: 'research competitors',
12
+ lastTool: { name: 'Bash', sanitisedArg: 'grep -r pricing' },
13
+ toolCount: 3,
14
+ latestSummary: 'scanning vendor pages',
15
+ elapsedMs: 10_000,
16
+ state: 'running',
17
+ ...partial,
18
+ }
19
+ }
20
+
21
+ interface FakeBot extends BotApiForWorkerFeed {
22
+ sent: Array<{ chatId: string; text: string; opts?: Record<string, unknown> }>
23
+ edits: Array<{ messageId: number; text: string }>
24
+ failNextSendWith?: unknown
25
+ failNextEditWith?: unknown
26
+ }
27
+
28
+ function makeFakeBot(): FakeBot {
29
+ let nextId = 1000
30
+ const fb: FakeBot = {
31
+ sent: [],
32
+ edits: [],
33
+ sendMessage: async (chatId, text, opts) => {
34
+ if (fb.failNextSendWith != null) {
35
+ const e = fb.failNextSendWith
36
+ fb.failNextSendWith = undefined
37
+ throw e
38
+ }
39
+ fb.sent.push({ chatId, text, opts })
40
+ return { message_id: nextId++ }
41
+ },
42
+ editMessageText: async (_chatId, messageId, text) => {
43
+ if (fb.failNextEditWith != null) {
44
+ const e = fb.failNextEditWith
45
+ fb.failNextEditWith = undefined
46
+ throw e
47
+ }
48
+ fb.edits.push({ messageId, text })
49
+ return {}
50
+ },
51
+ }
52
+ return fb
53
+ }
54
+
55
+ // ─── renderWorkerActivity (pure) ─────────────────────────────────────────────
56
+
57
+ describe('renderWorkerActivity', () => {
58
+ it('renders running header + tool activity line + summary', () => {
59
+ const out = renderWorkerActivity(view())
60
+ expect(out).toContain('🔧 <b>Worker</b> · <i>research competitors</i>')
61
+ expect(out).toContain('⚡ <code>Bash</code> grep -r pricing')
62
+ expect(out).toContain('(3 tools · ')
63
+ expect(out).toContain('↳ <i>scanning vendor pages</i>')
64
+ })
65
+
66
+ it('shows a "starting…" line when no tool has run yet', () => {
67
+ const out = renderWorkerActivity(view({ lastTool: null, latestSummary: '' }))
68
+ expect(out).toContain('🔧 <b>Worker</b>')
69
+ expect(out).toContain('starting…')
70
+ expect(out).not.toContain('⚡')
71
+ })
72
+
73
+ it('omits the summary line when latestSummary is blank', () => {
74
+ const out = renderWorkerActivity(view({ latestSummary: ' ' }))
75
+ expect(out).not.toContain('↳')
76
+ })
77
+
78
+ it('uses singular "tool" for a single tool call', () => {
79
+ const out = renderWorkerActivity(view({ toolCount: 1 }))
80
+ expect(out).toContain('(1 tool · ')
81
+ })
82
+
83
+ it('renders a done terminal recap', () => {
84
+ const out = renderWorkerActivity(view({ state: 'done', toolCount: 5 }))
85
+ expect(out).toContain('✅ <b>Worker done</b> · <i>research competitors</i>')
86
+ expect(out).toContain('5 tools · ')
87
+ expect(out).not.toContain('⚡')
88
+ })
89
+
90
+ it('renders a failed terminal recap', () => {
91
+ const out = renderWorkerActivity(view({ state: 'failed' }))
92
+ expect(out).toContain('⚠️ <b>Worker failed</b>')
93
+ })
94
+
95
+ it('escapes HTML in description, tool, arg, and summary', () => {
96
+ const out = renderWorkerActivity(
97
+ view({
98
+ description: 'a <b>bold</b> task',
99
+ lastTool: { name: 'Ba<sh', sanitisedArg: 'x & y' },
100
+ latestSummary: 'a > b',
101
+ }),
102
+ )
103
+ expect(out).toContain('a &lt;b&gt;bold&lt;/b&gt; task')
104
+ expect(out).toContain('Ba&lt;sh')
105
+ expect(out).toContain('x &amp; y')
106
+ expect(out).toContain('a &gt; b')
107
+ })
108
+ })
109
+
110
+ // ─── createWorkerActivityFeed (lifecycle) ────────────────────────────────────
111
+
112
+ describe('createWorkerActivityFeed', () => {
113
+ it('holds first paint until the worker has run firstPaintMinMs', async () => {
114
+ const bot = makeFakeBot()
115
+ let clock = 0
116
+ const feed = createWorkerActivityFeed({
117
+ bot,
118
+ now: () => clock,
119
+ firstPaintMinMs: 8000,
120
+ })
121
+ clock = 5000
122
+ await feed.update('w1', 'chat', view({ elapsedMs: 5000 }))
123
+ expect(bot.sent).toHaveLength(0)
124
+ expect(feed.has('w1')).toBe(false)
125
+
126
+ clock = 9000
127
+ await feed.update('w1', 'chat', view({ elapsedMs: 9000 }))
128
+ expect(bot.sent).toHaveLength(1)
129
+ expect(bot.sent[0].chatId).toBe('chat')
130
+ expect(bot.sent[0].opts?.parse_mode).toBe('HTML')
131
+ expect(feed.has('w1')).toBe(true)
132
+ })
133
+
134
+ it('dedups an identical body (no edit)', async () => {
135
+ const bot = makeFakeBot()
136
+ let clock = 10_000
137
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
138
+ await feed.update('w1', 'chat', view())
139
+ expect(bot.sent).toHaveLength(1)
140
+ clock = 20_000
141
+ await feed.update('w1', 'chat', view()) // same body
142
+ expect(bot.edits).toHaveLength(0)
143
+ })
144
+
145
+ it('throttles edits inside minEditIntervalMs but lets them through after', async () => {
146
+ const bot = makeFakeBot()
147
+ let clock = 10_000
148
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 2500 })
149
+ await feed.update('w1', 'chat', view({ toolCount: 1 }))
150
+ expect(bot.sent).toHaveLength(1)
151
+
152
+ clock = 11_000 // +1000 < 2500
153
+ await feed.update('w1', 'chat', view({ toolCount: 2 }))
154
+ expect(bot.edits).toHaveLength(0)
155
+
156
+ clock = 13_000 // +3000 since last edit > 2500
157
+ await feed.update('w1', 'chat', view({ toolCount: 3 }))
158
+ expect(bot.edits).toHaveLength(1)
159
+ expect(bot.edits[0].text).toContain('(3 tools · ')
160
+ })
161
+
162
+ it('forces a terminal edit on finish, skipping the throttle', async () => {
163
+ const bot = makeFakeBot()
164
+ let clock = 10_000
165
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 9_999_999 })
166
+ await feed.update('w1', 'chat', view())
167
+ expect(bot.sent).toHaveLength(1)
168
+
169
+ clock = 10_500 // well within the throttle window
170
+ await feed.finish('w1', view({ state: 'done', toolCount: 5 }))
171
+ expect(bot.edits).toHaveLength(1)
172
+ expect(bot.edits[0].text).toContain('✅ <b>Worker done</b>')
173
+ // finish forgets the worker.
174
+ expect(feed.has('w1')).toBe(false)
175
+ expect(feed.size).toBe(0)
176
+ })
177
+
178
+ it('finish is a no-op when no message was ever posted', async () => {
179
+ const bot = makeFakeBot()
180
+ let clock = 0
181
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, firstPaintMinMs: 8000 })
182
+ clock = 2000
183
+ await feed.update('w1', 'chat', view({ elapsedMs: 2000 })) // too short to paint
184
+ expect(bot.sent).toHaveLength(0)
185
+ await feed.finish('w1', view({ state: 'done' }))
186
+ expect(bot.edits).toHaveLength(0)
187
+ expect(bot.sent).toHaveLength(0)
188
+ })
189
+
190
+ it('drop forgets a worker without editing', async () => {
191
+ const bot = makeFakeBot()
192
+ let clock = 10_000
193
+ const feed = createWorkerActivityFeed({ bot, now: () => clock })
194
+ await feed.update('w1', 'chat', view())
195
+ expect(feed.has('w1')).toBe(true)
196
+ feed.drop('w1')
197
+ expect(feed.has('w1')).toBe(false)
198
+ expect(feed.size).toBe(0)
199
+ await feed.finish('w1', view({ state: 'done' }))
200
+ expect(bot.edits).toHaveLength(0)
201
+ })
202
+
203
+ it('honours a 429 cooldown before retrying the first paint', async () => {
204
+ const bot = makeFakeBot()
205
+ let clock = 10_000
206
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, firstPaintMinMs: 0 })
207
+ bot.failNextSendWith = { error_code: 429, parameters: { retry_after: 2 } }
208
+ await feed.update('w1', 'chat', view())
209
+ expect(bot.sent).toHaveLength(0) // failed send
210
+
211
+ clock = 11_000 // still inside cooldown (10_000 + 2000 + 500 jitter = 12_500)
212
+ await feed.update('w1', 'chat', view())
213
+ expect(bot.sent).toHaveLength(0)
214
+
215
+ clock = 13_000 // past cooldown
216
+ await feed.update('w1', 'chat', view())
217
+ expect(bot.sent).toHaveLength(1)
218
+ })
219
+
220
+ it('re-posts after a stale-message edit failure', async () => {
221
+ const bot = makeFakeBot()
222
+ let clock = 10_000
223
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
224
+ await feed.update('w1', 'chat', view({ toolCount: 1 }))
225
+ expect(bot.sent).toHaveLength(1)
226
+
227
+ clock = 20_000
228
+ bot.failNextEditWith = new Error('Bad Request: message to edit not found')
229
+ await feed.update('w1', 'chat', view({ toolCount: 2 }))
230
+ expect(bot.edits).toHaveLength(0) // edit threw
231
+ expect(feed.has('w1')).toBe(false) // messageId reset
232
+
233
+ clock = 30_000
234
+ await feed.update('w1', 'chat', view({ toolCount: 3 }))
235
+ expect(bot.sent).toHaveLength(2) // re-posted
236
+ expect(feed.has('w1')).toBe(true)
237
+ })
238
+
239
+ it('skips entirely when chatId is empty (owner DM unconfigured)', async () => {
240
+ const bot = makeFakeBot()
241
+ let clock = 10_000
242
+ const feed = createWorkerActivityFeed({ bot, now: () => clock })
243
+ await feed.update('w1', '', view())
244
+ expect(bot.sent).toHaveLength(0)
245
+ expect(feed.has('w1')).toBe(false)
246
+ expect(feed.size).toBe(0)
247
+ })
248
+
249
+ it('forwards threadId as message_thread_id on send', async () => {
250
+ const bot = makeFakeBot()
251
+ let clock = 10_000
252
+ const feed = createWorkerActivityFeed({ bot, now: () => clock })
253
+ await feed.update('w1', 'chat', view(), 42)
254
+ expect(bot.sent[0].opts?.message_thread_id).toBe(42)
255
+ })
256
+ })
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Live worker-activity feed — a regular chat message that edits in place
3
+ * while a *background* sub-agent (Agent/Task `run_in_background: true`)
4
+ * runs, then finalizes when the worker completes.
5
+ *
6
+ * Why this exists: the pinned progress card was deleted (#1126) and the
7
+ * "Chat is the artifact" principle bars re-adding card chrome. But a
8
+ * background worker decouples from the parent turn — when the parent's
9
+ * turn ends, nothing surfaces the worker's ongoing jsonl activity, so a
10
+ * long worker reads as silence (the exact gap an operator hit watching a
11
+ * dispatched worker go quiet). This module surfaces that activity the
12
+ * same way the main agent's live answer does: a normal Telegram message
13
+ * that grows/edits as work happens — indistinguishable from "the agent
14
+ * is typing live", not a status widget.
15
+ *
16
+ * Pure render (`renderWorkerActivity`) + an injected bot API
17
+ * (`BotApiForWorkerFeed`), mirroring `issues-card.ts` so the gateway
18
+ * reuses the same wiring. The manager (`createWorkerActivityFeed`) owns
19
+ * one edit-in-place message per worker, keyed by jsonl agent id, with:
20
+ * - a first-paint delay so trivial sub-second workers never post a
21
+ * message (their result still lands via the handback reply),
22
+ * - a proactive min-edit-interval throttle (worker jsonl ticks ~1/s;
23
+ * Telegram rate-limits edits) plus body-dedup,
24
+ * - per-worker serialization so two rapid ticks can't double-send,
25
+ * - 429 cooldown + message_id drift resilience (re-post on stale edit),
26
+ * - a forced terminal edit on `finish` regardless of throttle.
27
+ *
28
+ * The feed is gated to BACKGROUND workers and lives behind the
29
+ * `SWITCHROOM_WORKER_ACTIVITY_FEED` flag — see the gateway wiring. The
30
+ * watcher already drives the cues (it polls the worker jsonl directly,
31
+ * so it keeps firing after the parent turn ends), which is why the feed
32
+ * is fed from watcher callbacks rather than the bridge event stream.
33
+ */
34
+
35
+ import { escapeHtml, formatDuration, truncate } from './card-format.js'
36
+
37
+ export type WorkerActivityState = 'running' | 'done' | 'failed'
38
+
39
+ /** The render-relevant snapshot of a worker at one instant. */
40
+ export interface WorkerActivityView {
41
+ /** Dispatch-time task description (stable across the worker's life). */
42
+ description: string
43
+ /** Most recent tool the worker invoked, with a pre-sanitised arg. */
44
+ lastTool: { name: string; sanitisedArg: string } | null
45
+ /** Number of tool calls observed so far. */
46
+ toolCount: number
47
+ /** The worker's latest narrative line, if any (already capped upstream). */
48
+ latestSummary: string
49
+ /** Wall-clock since dispatch, ms. */
50
+ elapsedMs: number
51
+ state: WorkerActivityState
52
+ }
53
+
54
+ export interface BotApiForWorkerFeed {
55
+ sendMessage(
56
+ chatId: string,
57
+ text: string,
58
+ opts?: Record<string, unknown>,
59
+ ): Promise<{ message_id: number }>
60
+ editMessageText(
61
+ chatId: string,
62
+ messageId: number,
63
+ text: string,
64
+ opts?: Record<string, unknown>,
65
+ ): Promise<unknown>
66
+ }
67
+
68
+ const DESC_MAX = 80
69
+ const TOOL_ARG_MAX = 64
70
+ const SUMMARY_MAX = 100
71
+
72
+ /**
73
+ * Render the worker-activity message body as Telegram HTML.
74
+ *
75
+ * Layout (running):
76
+ * 🔧 <b>Worker</b> · <i>{description}</i>
77
+ * ⚡ <code>{tool}</code> {arg} <i>({n} tools · {elapsed})</i>
78
+ * ↳ <i>{latest summary}</i>
79
+ *
80
+ * Terminal collapses the activity line to a tool-count + duration recap:
81
+ * ✅ <b>Worker done</b> · <i>{description}</i>
82
+ * <i>{n} tools · {elapsed}</i>
83
+ */
84
+ export function renderWorkerActivity(v: WorkerActivityView): string {
85
+ const desc = truncate(v.description.trim() || 'background task', DESC_MAX)
86
+ const elapsed = formatDuration(v.elapsedMs)
87
+ const toolWord = v.toolCount === 1 ? 'tool' : 'tools'
88
+
89
+ if (v.state === 'done' || v.state === 'failed') {
90
+ const head =
91
+ v.state === 'done'
92
+ ? `✅ <b>Worker done</b> · <i>${escapeHtml(desc)}</i>`
93
+ : `⚠️ <b>Worker failed</b> · <i>${escapeHtml(desc)}</i>`
94
+ return `${head}\n<i>${v.toolCount} ${toolWord} · ${elapsed}</i>`
95
+ }
96
+
97
+ const header = `🔧 <b>Worker</b> · <i>${escapeHtml(desc)}</i>`
98
+
99
+ let activity: string
100
+ if (v.lastTool != null) {
101
+ const arg = v.lastTool.sanitisedArg.trim()
102
+ const argPart = arg.length > 0 ? ` ${escapeHtml(truncate(arg, TOOL_ARG_MAX))}` : ''
103
+ activity = `⚡ <code>${escapeHtml(v.lastTool.name)}</code>${argPart} <i>(${v.toolCount} ${toolWord} · ${elapsed})</i>`
104
+ } else {
105
+ activity = `<i>starting… (${elapsed})</i>`
106
+ }
107
+
108
+ const summary = v.latestSummary.trim()
109
+ const lines = [header, activity]
110
+ if (summary.length > 0) {
111
+ lines.push(` ↳ <i>${escapeHtml(truncate(summary, SUMMARY_MAX))}</i>`)
112
+ }
113
+ return lines.join('\n')
114
+ }
115
+
116
+ export interface WorkerActivityFeedOpts {
117
+ bot: BotApiForWorkerFeed
118
+ /** `Date.now` override for tests. */
119
+ now?: () => number
120
+ /**
121
+ * Minimum ms between in-place edits to one worker's message. Worker
122
+ * jsonl ticks roughly once per second; without this we'd burn through
123
+ * Telegram's edit budget. Default 2500ms. First paint and the terminal
124
+ * `finish` edit bypass it.
125
+ */
126
+ minEditIntervalMs?: number
127
+ /**
128
+ * A worker must have been running at least this long before its first
129
+ * message is posted. Sub-second / trivial workers never surface a live
130
+ * message — their result still reaches the user via the handback
131
+ * reply, so a message would be pure noise. Default 8000ms.
132
+ */
133
+ firstPaintMinMs?: number
134
+ /** stderr-style log sink. Defaults to noop. */
135
+ log?: (msg: string) => void
136
+ }
137
+
138
+ interface WorkerHandle {
139
+ chatId: string
140
+ threadId?: number
141
+ messageId: number | null
142
+ lastBody: string | null
143
+ lastEditAt: number
144
+ cooldownUntil: number
145
+ /** Per-worker serialization chain so ticks can't interleave sends. */
146
+ chain: Promise<void>
147
+ }
148
+
149
+ const COOLDOWN_JITTER_MS = 500
150
+
151
+ function extractRetryAfterSecs(err: unknown): number | null {
152
+ if (err == null || typeof err !== 'object') return null
153
+ const e = err as { error_code?: unknown; parameters?: { retry_after?: unknown } }
154
+ if (e.error_code !== 429) return null
155
+ const ra = e.parameters?.retry_after
156
+ if (typeof ra === 'number' && Number.isFinite(ra) && ra > 0) return ra
157
+ return null
158
+ }
159
+
160
+ /**
161
+ * Manager owning one live message per background worker. Keyed by jsonl
162
+ * agent id. The gateway calls `update` on each watcher activity cue and
163
+ * `finish` on terminal; `drop` discards a worker's state without a final
164
+ * edit (error / supersession paths).
165
+ */
166
+ export interface WorkerActivityFeed {
167
+ /** True if a message is currently posted for this worker. */
168
+ has(agentId: string): boolean
169
+ /** Push a running-state cue. Returns the serialized op for tests. */
170
+ update(
171
+ agentId: string,
172
+ chatId: string,
173
+ view: WorkerActivityView,
174
+ threadId?: number,
175
+ ): Promise<void>
176
+ /** Force the terminal recap edit. No-op if no message was ever posted. */
177
+ finish(agentId: string, view: WorkerActivityView): Promise<void>
178
+ /** Forget a worker's state without editing (e.g. error path). */
179
+ drop(agentId: string): void
180
+ /** Number of tracked workers (test/inspection hook). */
181
+ readonly size: number
182
+ }
183
+
184
+ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerActivityFeed {
185
+ const log = opts.log ?? (() => {})
186
+ const nowFn = opts.now ?? Date.now
187
+ const minEditInterval = opts.minEditIntervalMs ?? 2500
188
+ const firstPaintMin = opts.firstPaintMinMs ?? 8000
189
+ const handles = new Map<string, WorkerHandle>()
190
+
191
+ function sendOptsFor(h: WorkerHandle): Record<string, unknown> {
192
+ return {
193
+ parse_mode: 'HTML',
194
+ disable_web_page_preview: true,
195
+ ...(h.threadId != null ? { message_thread_id: h.threadId } : {}),
196
+ }
197
+ }
198
+
199
+ function noteRateLimited(h: WorkerHandle, err: unknown, label: string): void {
200
+ const retryAfter = extractRetryAfterSecs(err)
201
+ if (retryAfter == null) return
202
+ h.cooldownUntil = nowFn() + retryAfter * 1000 + COOLDOWN_JITTER_MS
203
+ log(`worker-feed: ${label} 429 — backing off ${retryAfter}s`)
204
+ }
205
+
206
+ async function doUpdate(h: WorkerHandle, view: WorkerActivityView): Promise<void> {
207
+ if (nowFn() < h.cooldownUntil) return
208
+ const body = renderWorkerActivity(view)
209
+
210
+ // First paint: hold off until the worker has run long enough to be
211
+ // worth a message; trivial workers stay silent (handback covers them).
212
+ if (h.messageId == null) {
213
+ if (view.elapsedMs < firstPaintMin) return
214
+ try {
215
+ const sent = await opts.bot.sendMessage(h.chatId, body, sendOptsFor(h))
216
+ h.messageId = sent.message_id
217
+ h.lastBody = body
218
+ h.lastEditAt = nowFn()
219
+ } catch (err) {
220
+ noteRateLimited(h, err, 'send')
221
+ log(`worker-feed: send failed: ${(err as Error).message}`)
222
+ }
223
+ return
224
+ }
225
+
226
+ // Dedup + proactive throttle.
227
+ if (body === h.lastBody) return
228
+ if (nowFn() - h.lastEditAt < minEditInterval) return
229
+
230
+ try {
231
+ await opts.bot.editMessageText(h.chatId, h.messageId, body, sendOptsFor(h))
232
+ h.lastBody = body
233
+ h.lastEditAt = nowFn()
234
+ } catch (err) {
235
+ noteRateLimited(h, err, 'edit')
236
+ // Stale message_id (manually deleted / edit window gone). Re-post
237
+ // on the next tick rather than now, so we don't double-down inside
238
+ // a cooldown.
239
+ log(`worker-feed: edit failed, will re-post: ${(err as Error).message}`)
240
+ h.messageId = null
241
+ h.lastBody = null
242
+ }
243
+ }
244
+
245
+ async function doFinish(h: WorkerHandle, view: WorkerActivityView): Promise<void> {
246
+ // No message ever posted → nothing to finalize. The worker's result
247
+ // reaches the user via the handback reply; a bare "done" recap with
248
+ // no preceding activity would be noise.
249
+ if (h.messageId == null) return
250
+ if (nowFn() < h.cooldownUntil) {
251
+ // Honour the flood-wait; a terminal edit isn't worth a ban. The
252
+ // message is left at its last running render — stale but harmless.
253
+ return
254
+ }
255
+ const body = renderWorkerActivity(view)
256
+ if (body === h.lastBody) return
257
+ try {
258
+ await opts.bot.editMessageText(h.chatId, h.messageId, body, sendOptsFor(h))
259
+ h.lastBody = body
260
+ h.lastEditAt = nowFn()
261
+ } catch (err) {
262
+ noteRateLimited(h, err, 'finish')
263
+ log(`worker-feed: finish edit failed: ${(err as Error).message}`)
264
+ }
265
+ }
266
+
267
+ return {
268
+ has(agentId) {
269
+ return handles.get(agentId)?.messageId != null
270
+ },
271
+ get size() {
272
+ return handles.size
273
+ },
274
+ update(agentId, chatId, view, threadId) {
275
+ // No chat to post to (owner DM unconfigured) — don't create a
276
+ // handle that would retry a failing send('') every tick.
277
+ if (chatId.length === 0) return Promise.resolve()
278
+ let h = handles.get(agentId)
279
+ if (h == null) {
280
+ h = {
281
+ chatId,
282
+ threadId,
283
+ messageId: null,
284
+ lastBody: null,
285
+ lastEditAt: 0,
286
+ cooldownUntil: 0,
287
+ chain: Promise.resolve(),
288
+ }
289
+ handles.set(agentId, h)
290
+ }
291
+ const handle = h
292
+ handle.chain = handle.chain.then(() => doUpdate(handle, view)).catch((err) => {
293
+ log(`worker-feed: update chain error ${agentId}: ${(err as Error).message}`)
294
+ })
295
+ return handle.chain
296
+ },
297
+ finish(agentId, view) {
298
+ const h = handles.get(agentId)
299
+ if (h == null) return Promise.resolve()
300
+ h.chain = h.chain
301
+ .then(() => doFinish(h, view))
302
+ .catch((err) => {
303
+ log(`worker-feed: finish chain error ${agentId}: ${(err as Error).message}`)
304
+ })
305
+ .finally(() => {
306
+ handles.delete(agentId)
307
+ })
308
+ return h.chain
309
+ },
310
+ drop(agentId) {
311
+ handles.delete(agentId)
312
+ },
313
+ }
314
+ }