switchroom 0.14.14 → 0.14.16
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/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +448 -162
- package/telegram-plugin/gateway/gateway.ts +144 -8
- package/telegram-plugin/reaction-defer.ts +98 -0
- package/telegram-plugin/status-reactions.ts +31 -1
- package/telegram-plugin/subagent-watcher.ts +13 -0
- package/telegram-plugin/tests/reaction-defer.test.ts +187 -0
- package/telegram-plugin/tests/status-reactions.test.ts +79 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +256 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-activity-feed-dm.test.ts +125 -0
- package/telegram-plugin/worker-activity-feed.ts +314 -0
|
@@ -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
|
+
}
|