switchroom 0.14.15 → 0.14.17

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.
@@ -49413,8 +49413,8 @@ var {
49413
49413
  } = import__.default;
49414
49414
 
49415
49415
  // src/build-info.ts
49416
- var VERSION = "0.14.15";
49417
- var COMMIT_SHA = "e0f95f64";
49416
+ var VERSION = "0.14.17";
49417
+ var COMMIT_SHA = "95c1d475";
49418
49418
 
49419
49419
  // src/cli/agent.ts
49420
49420
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.15",
3
+ "version": "0.14.17",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51140,10 +51140,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51140
51140
  }
51141
51141
 
51142
51142
  // ../src/build-info.ts
51143
- var VERSION = "0.14.15";
51144
- var COMMIT_SHA = "e0f95f64";
51145
- var COMMIT_DATE = "2026-05-30T04:03:13Z";
51146
- var LATEST_PR = 2001;
51143
+ var VERSION = "0.14.17";
51144
+ var COMMIT_SHA = "95c1d475";
51145
+ var COMMIT_DATE = "2026-05-30T05:17:47Z";
51146
+ var LATEST_PR = 2005;
51147
51147
  var COMMITS_AHEAD_OF_TAG = 0;
51148
51148
 
51149
51149
  // gateway/boot-version.ts
@@ -51605,6 +51605,34 @@ function applySubagentsSchema(db2) {
51605
51605
  }
51606
51606
  db2.exec("CREATE INDEX IF NOT EXISTS subagents_jsonl_id ON subagents(jsonl_agent_id)");
51607
51607
  }
51608
+ function mapSubagentRow(row) {
51609
+ return {
51610
+ id: row.id,
51611
+ parent_session_id: row.parent_session_id,
51612
+ parent_turn_key: row.parent_turn_key,
51613
+ agent_type: row.agent_type,
51614
+ description: row.description,
51615
+ background: row.background !== 0,
51616
+ started_at: row.started_at,
51617
+ last_activity_at: row.last_activity_at,
51618
+ ended_at: row.ended_at,
51619
+ status: row.status,
51620
+ result_summary: row.result_summary,
51621
+ jsonl_agent_id: row.jsonl_agent_id
51622
+ };
51623
+ }
51624
+ function getSubagentByJsonlId(db2, jsonlAgentId) {
51625
+ const row = db2.prepare("SELECT * FROM subagents WHERE jsonl_agent_id = ?").get(jsonlAgentId);
51626
+ return row ? mapSubagentRow(row) : null;
51627
+ }
51628
+
51629
+ // gateway/worker-feed-dispatch.ts
51630
+ function resolveWorkerFeedDispatch(sub, watcherDescription) {
51631
+ return {
51632
+ isBackground: sub?.background ?? false,
51633
+ feedDescription: (sub?.description ?? "") || watcherDescription
51634
+ };
51635
+ }
51608
51636
 
51609
51637
  // gateway/resolve-calling-subagent.ts
51610
51638
  function resolveCallingSubagent(opts) {
@@ -61331,18 +61359,7 @@ var didOneTimeSetup = false;
61331
61359
  },
61332
61360
  onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs }) => {
61333
61361
  deferredDoneReactions.promote();
61334
- if (workerFeedEnabled) {
61335
- workerActivityFeed.finish(agentId, {
61336
- description,
61337
- lastTool: null,
61338
- toolCount,
61339
- latestSummary: resultText,
61340
- elapsedMs: durationMs,
61341
- state: outcome === "failed" ? "failed" : "done"
61342
- });
61343
- }
61344
61362
  let fleetChatId = "";
61345
- let isBackground = false;
61346
61363
  try {
61347
61364
  const fleets = progressDriver?.peekAllFleets() ?? [];
61348
61365
  for (const f of fleets) {
@@ -61352,13 +61369,23 @@ var didOneTimeSetup = false;
61352
61369
  }
61353
61370
  }
61354
61371
  } catch {}
61372
+ let dispatch = resolveWorkerFeedDispatch(null, description);
61355
61373
  if (turnsDb != null) {
61356
61374
  try {
61357
- const row = turnsDb.prepare("SELECT background FROM subagents WHERE jsonl_agent_id = ?").get(agentId);
61358
- if (row != null)
61359
- isBackground = row.background === 1;
61375
+ dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description);
61360
61376
  } catch {}
61361
61377
  }
61378
+ const isBackground = dispatch.isBackground;
61379
+ if (workerFeedEnabled) {
61380
+ workerActivityFeed.finish(agentId, {
61381
+ description: dispatch.feedDescription,
61382
+ lastTool: null,
61383
+ toolCount,
61384
+ latestSummary: resultText,
61385
+ elapsedMs: durationMs,
61386
+ state: outcome === "failed" ? "failed" : "done"
61387
+ });
61388
+ }
61362
61389
  const decision = decideSubagentHandback({
61363
61390
  handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
61364
61391
  outcome,
@@ -61393,7 +61420,6 @@ var didOneTimeSetup = false;
61393
61420
  },
61394
61421
  onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount }) => {
61395
61422
  let fleetChatId = "";
61396
- let isBackground = false;
61397
61423
  try {
61398
61424
  const fleets = progressDriver?.peekAllFleets() ?? [];
61399
61425
  for (const f of fleets) {
@@ -61403,18 +61429,18 @@ var didOneTimeSetup = false;
61403
61429
  }
61404
61430
  }
61405
61431
  } catch {}
61432
+ let dispatch = resolveWorkerFeedDispatch(null, description);
61406
61433
  if (turnsDb != null) {
61407
61434
  try {
61408
- const row = turnsDb.prepare("SELECT background FROM subagents WHERE jsonl_agent_id = ?").get(agentId);
61409
- if (row != null)
61410
- isBackground = row.background === 1;
61435
+ dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description);
61411
61436
  } catch {}
61412
61437
  }
61438
+ const isBackground = dispatch.isBackground;
61413
61439
  if (!isBackground)
61414
61440
  return;
61415
61441
  if (workerFeedEnabled) {
61416
61442
  workerActivityFeed.update(agentId, fleetChatId || (loadAccess().allowFrom[0] ?? ""), {
61417
- description,
61443
+ description: dispatch.feedDescription,
61418
61444
  lastTool,
61419
61445
  toolCount,
61420
61446
  latestSummary,
@@ -418,7 +418,8 @@ import {
418
418
  findMostRecentInterruptedTurn,
419
419
  findRecentTurnsForChat,
420
420
  } from '../registry/turns-schema.js'
421
- import { applySubagentsSchema } from '../registry/subagents-schema.js'
421
+ import { applySubagentsSchema, getSubagentByJsonlId } from '../registry/subagents-schema.js'
422
+ import { resolveWorkerFeedDispatch, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
422
423
  import { formatIdleFooter } from '../idle-footer.js'
423
424
  import { resolveCallingSubagent } from './resolve-calling-subagent.js'
424
425
 
@@ -17396,28 +17397,12 @@ void (async () => {
17396
17397
  // handback gating below, so it must run before any early
17397
17398
  // return. Cheap no-op when nothing is deferred.
17398
17399
  deferredDoneReactions.promote()
17399
- // #PR2 live worker-feed: force the terminal recap edit on
17400
- // the worker's live message. No-op when no message was ever
17401
- // posted (trivial workers stay silent; handback covers them).
17402
- // 'orphan' is a stale boot row, not a fresh completion — map
17403
- // it to 'done' so an already-posted message still finalizes.
17404
- if (workerFeedEnabled) {
17405
- void workerActivityFeed.finish(agentId, {
17406
- description,
17407
- lastTool: null,
17408
- toolCount,
17409
- latestSummary: resultText,
17410
- elapsedMs: durationMs,
17411
- state: outcome === 'failed' ? 'failed' : 'done',
17412
- })
17413
- }
17414
17400
  // IO: resolve the fleet chat id and the background flag.
17415
17401
  // The DECISION (gating + inbound build) is delegated to
17416
17402
  // the pure `decideSubagentHandback` so it is unit-tested
17417
17403
  // independent of the gateway — see
17418
17404
  // `subagent-handback-decision.test.ts`.
17419
17405
  let fleetChatId = ''
17420
- let isBackground = false
17421
17406
  try {
17422
17407
  const fleets = progressDriver?.peekAllFleets() ?? []
17423
17408
  for (const f of fleets) {
@@ -17430,14 +17415,33 @@ void (async () => {
17430
17415
  // peek failures are non-fatal — fall through to the
17431
17416
  // owner-chat fallback inside decideSubagentHandback.
17432
17417
  }
17418
+ // Background flag + feed header description, both derived from
17419
+ // the registry row via the pure resolveWorkerFeedDispatch
17420
+ // (worker-feed-dispatch.ts, pinned by its test). Best-effort:
17421
+ // a DB hiccup keeps the watcher's generic label rather than
17422
+ // throwing out of the terminal handler.
17423
+ let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description)
17433
17424
  if (turnsDb != null) {
17434
17425
  try {
17435
- const row = turnsDb
17436
- .prepare('SELECT background FROM subagents WHERE jsonl_agent_id = ?')
17437
- .get(agentId) as { background: number } | undefined
17438
- if (row != null) isBackground = row.background === 1
17426
+ dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description)
17439
17427
  } catch { /* best-effort */ }
17440
17428
  }
17429
+ const isBackground = dispatch.isBackground
17430
+ // #PR2 live worker-feed: force the terminal recap edit on
17431
+ // the worker's live message. No-op when no message was ever
17432
+ // posted (trivial workers stay silent; handback covers them).
17433
+ // 'orphan' is a stale boot row, not a fresh completion — map
17434
+ // it to 'done' so an already-posted message still finalizes.
17435
+ if (workerFeedEnabled) {
17436
+ void workerActivityFeed.finish(agentId, {
17437
+ description: dispatch.feedDescription,
17438
+ lastTool: null,
17439
+ toolCount,
17440
+ latestSummary: resultText,
17441
+ elapsedMs: durationMs,
17442
+ state: outcome === 'failed' ? 'failed' : 'done',
17443
+ })
17444
+ }
17441
17445
 
17442
17446
  const decision = decideSubagentHandback({
17443
17447
  handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
@@ -17510,7 +17514,6 @@ void (async () => {
17510
17514
  // lives in the `onFinish` block just above.
17511
17515
  onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount }) => {
17512
17516
  let fleetChatId = ''
17513
- let isBackground = false
17514
17517
  try {
17515
17518
  const fleets = progressDriver?.peekAllFleets() ?? []
17516
17519
  for (const f of fleets) {
@@ -17520,14 +17523,18 @@ void (async () => {
17520
17523
  }
17521
17524
  }
17522
17525
  } catch { /* peek failures non-fatal */ }
17526
+ // The watcher's `description` is its 'sub-agent' default (it
17527
+ // never reassigns it from the worker jsonl). The dispatch-time
17528
+ // task description lives in the registry row — resolveWorkerFeedDispatch
17529
+ // prefers it so the header reads "🔧 Worker · <real task>" not
17530
+ // "· sub-agent" (worker-feed-dispatch.ts, pinned by its test).
17531
+ let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description)
17523
17532
  if (turnsDb != null) {
17524
17533
  try {
17525
- const row = turnsDb
17526
- .prepare('SELECT background FROM subagents WHERE jsonl_agent_id = ?')
17527
- .get(agentId) as { background: number } | undefined
17528
- if (row != null) isBackground = row.background === 1
17534
+ dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description)
17529
17535
  } catch { /* best-effort */ }
17530
17536
  }
17537
+ const isBackground = dispatch.isBackground
17531
17538
  if (!isBackground) return // skip overhead for foreground
17532
17539
 
17533
17540
  // #PR2 live worker-feed: when ON, the worker's live chat
@@ -17541,7 +17548,7 @@ void (async () => {
17541
17548
  agentId,
17542
17549
  fleetChatId || (loadAccess().allowFrom[0] ?? ''),
17543
17550
  {
17544
- description,
17551
+ description: dispatch.feedDescription,
17545
17552
  lastTool,
17546
17553
  toolCount,
17547
17554
  latestSummary,
@@ -0,0 +1,37 @@
1
+ import type { Subagent } from '../registry/subagents-schema.js'
2
+
3
+ export interface WorkerFeedDispatch {
4
+ /** True when the sub-agent was dispatched with `run_in_background: true`. */
5
+ isBackground: boolean
6
+ /**
7
+ * The human-readable task to render in the feed header
8
+ * ("🔧 Worker · <feedDescription>").
9
+ */
10
+ feedDescription: string
11
+ }
12
+
13
+ /**
14
+ * Resolve the two registry-derived inputs the worker-activity feed needs:
15
+ * whether the sub-agent was a background dispatch, and the task description
16
+ * to show in the feed header.
17
+ *
18
+ * The live watcher only carries a generic 'sub-agent' label — it never
19
+ * reassigns `description` from the worker jsonl. The real dispatch-time
20
+ * description lives in the registry `subagents` row (written by the pretool
21
+ * hook from the `Agent(description:)` input). Prefer it; fall back to the
22
+ * watcher's label only when the row is missing or its description is empty.
23
+ *
24
+ * Pure + DB-free so it pins the #2002 behavior under both vitest and bun —
25
+ * see worker-feed-dispatch.test.ts. The gateway must never inline this
26
+ * decision again: a regression here silently reverts the feed header to
27
+ * "· sub-agent".
28
+ */
29
+ export function resolveWorkerFeedDispatch(
30
+ sub: Subagent | null,
31
+ watcherDescription: string,
32
+ ): WorkerFeedDispatch {
33
+ return {
34
+ isBackground: sub?.background ?? false,
35
+ feedDescription: (sub?.description ?? '') || watcherDescription,
36
+ }
37
+ }
@@ -73,7 +73,13 @@ export interface WorkerEntry {
73
73
  readonly agentId: string
74
74
  /** File path of the JSONL. */
75
75
  readonly filePath: string
76
- /** Short description — from the sub-agent's first text/narrative line. */
76
+ /**
77
+ * Generic 'sub-agent' placeholder — the watcher deliberately does NOT
78
+ * reassign this from the worker jsonl (see the init at construction and
79
+ * the "Do NOT overwrite" note in the line-handler). The real dispatch-time
80
+ * task description lives in the registry `subagents` row; the gateway reads
81
+ * it there via resolveWorkerFeedDispatch for the worker-feed header.
82
+ */
77
83
  description: string
78
84
  /** Current lifecycle state. */
79
85
  state: WorkerState
@@ -864,6 +870,9 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
864
870
  const entry: WorkerEntry = {
865
871
  agentId,
866
872
  filePath,
873
+ // Generic placeholder only — never overwritten from the jsonl. The
874
+ // gateway substitutes the real registry description for the worker
875
+ // feed (resolveWorkerFeedDispatch). See the WorkerEntry.description doc.
867
876
  description: 'sub-agent',
868
877
  state: 'running',
869
878
  dispatchedAt: n,
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { Subagent } from '../registry/subagents-schema.js'
3
+ import { resolveWorkerFeedDispatch } from '../gateway/worker-feed-dispatch.js'
4
+
5
+ function makeSub(over: Partial<Subagent>): Subagent {
6
+ return {
7
+ id: 'toolu_01ABC',
8
+ parent_session_id: null,
9
+ parent_turn_key: null,
10
+ agent_type: 'general-purpose',
11
+ description: null,
12
+ background: false,
13
+ started_at: 0,
14
+ last_activity_at: null,
15
+ ended_at: null,
16
+ status: 'running',
17
+ result_summary: null,
18
+ jsonl_agent_id: 'a37ad7639ae61476c',
19
+ ...over,
20
+ }
21
+ }
22
+
23
+ describe('resolveWorkerFeedDispatch (#2002 regression pin)', () => {
24
+ it('uses the real registry description for the feed header, not the watcher label', () => {
25
+ const sub = makeSub({ background: true, description: 'Background ten-step worker' })
26
+ const out = resolveWorkerFeedDispatch(sub, 'sub-agent')
27
+ expect(out.isBackground).toBe(true)
28
+ expect(out.feedDescription).toBe('Background ten-step worker')
29
+ })
30
+
31
+ it('falls back to the watcher label when the registry row is missing', () => {
32
+ const out = resolveWorkerFeedDispatch(null, 'sub-agent')
33
+ expect(out.isBackground).toBe(false)
34
+ expect(out.feedDescription).toBe('sub-agent')
35
+ })
36
+
37
+ it('falls back to the watcher label when the registry description is null', () => {
38
+ const sub = makeSub({ background: true, description: null })
39
+ const out = resolveWorkerFeedDispatch(sub, 'sub-agent')
40
+ expect(out.isBackground).toBe(true)
41
+ expect(out.feedDescription).toBe('sub-agent')
42
+ })
43
+
44
+ it('falls back to the watcher label when the registry description is empty', () => {
45
+ const sub = makeSub({ background: true, description: '' })
46
+ const out = resolveWorkerFeedDispatch(sub, 'sub-agent')
47
+ expect(out.feedDescription).toBe('sub-agent')
48
+ })
49
+
50
+ it('reports a foreground sub-agent as not background', () => {
51
+ const sub = makeSub({ background: false, description: 'inline helper' })
52
+ const out = resolveWorkerFeedDispatch(sub, 'sub-agent')
53
+ expect(out.isBackground).toBe(false)
54
+ // description still resolves — callers gate on isBackground separately.
55
+ expect(out.feedDescription).toBe('inline helper')
56
+ })
57
+
58
+ it('a missing row defaults isBackground false so the feed never fires blind', () => {
59
+ // The gateway gates the feed on isBackground; a registry miss must not
60
+ // flip a foreground turn into a background one.
61
+ expect(resolveWorkerFeedDispatch(null, '').isBackground).toBe(false)
62
+ })
63
+ })
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Live worker-activity feed (#2000) — UAT.
3
+ *
4
+ * A *background* sub-agent decouples from the parent turn; when the turn
5
+ * ends nothing surfaces its ongoing jsonl activity and a long worker
6
+ * reads as silence. The feed (flag `SWITCHROOM_WORKER_ACTIVITY_FEED=1`,
7
+ * set on the test-harness agent for this run) posts ONE regular Telegram
8
+ * message per background worker and edits it in place — current tool +
9
+ * short summary + elapsed — finalizing with a recap on completion.
10
+ *
11
+ * This scenario dispatches a real background worker (~60s of paced
12
+ * sleep/echo work, so it narrates between tools and the feed can paint
13
+ * + edit), then asserts:
14
+ *
15
+ * 1. a worker-feed message appears (🔧 Worker · …), distinct from the
16
+ * parent's ack reply — proving background activity surfaces after
17
+ * the parent turn closed;
18
+ * 2. the message edits in place while work is in flight (body changes
19
+ * across a window) — proving it's live, not a one-shot post;
20
+ * 3. it finalizes to the terminal recap (✅ Worker done · … / N tools).
21
+ *
22
+ * It logs every observed body so a human can read the real rendered UX.
23
+ *
24
+ * Prompt is the deterministic Option-1 dispatch from
25
+ * `bg-sub-agent-dispatch-dm.test.ts` (naming the Agent tool + arg keeps
26
+ * the model from running the sleeps inline via Bash).
27
+ */
28
+
29
+ import { describe, expect, it } from "vitest";
30
+ import { spinUp } from "../harness.js";
31
+
32
+ // The worker must keep its jsonl ticking faster than the *test-harness*
33
+ // stall window (SWITCHROOM_SUBAGENT_STALL_MS=5000 /
34
+ // SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS=10000 in switchroom.yaml — see
35
+ // PR #1110): a worker silent for >15s gets a *synthesized* terminal
36
+ // turn_end mid-flight, which flips the watcher entry to `done` and
37
+ // suppresses every later onProgress (the feed then never paints). Long
38
+ // silent `sleep 20`s tripped exactly that. So we drive ~10 short steps,
39
+ // each its own Bash call with a one-line narration, keeping the gap
40
+ // between jsonl emissions ~2s — well under the 5s stall floor — for
41
+ // ~30-40s total: long enough to clear the 8s first-paint, throttle, and
42
+ // land several in-place edits before the real end_turn.
43
+ const BG_DISPATCH_PROMPT =
44
+ `Use the Agent tool with subagent_type "general-purpose" and ` +
45
+ `run_in_background: true to dispatch a worker with this exact task: ` +
46
+ `"Do ten steps, ONE AT A TIME, k = 1 through 10. Before each step ` +
47
+ `write a brief one-sentence narration of what you are about to do, ` +
48
+ `then run \`sleep 2\` via the Bash tool, then run \`echo step-k\` via ` +
49
+ `the Bash tool (substitute the real number for k). Run every sleep and ` +
50
+ `every echo as its OWN separate Bash call — never batch or chain them ` +
51
+ `with && — and narrate before each so progress surfaces incrementally. ` +
52
+ `Do not stop early; complete all ten steps." After dispatching, send a ` +
53
+ `brief reply saying you've kicked off the background worker so I can ` +
54
+ `watch its progress.`;
55
+
56
+ // The feed header rendered in Telegram: "🔧 Worker · <desc>" (running)
57
+ // or "✅ Worker done · …" / "⚠️ Worker failed · …" (terminal).
58
+ const WORKER_FEED_RE = /🔧\s*Worker|Worker done|Worker failed|⚡/i;
59
+ const WORKER_DONE_RE = /✅\s*Worker done|⚠️\s*Worker failed/i;
60
+
61
+ describe("uat: live worker-activity feed (#2000)", () => {
62
+ it(
63
+ "surfaces a background worker as a live, editing message that finalizes",
64
+ async () => {
65
+ const sc = await spinUp({ agent: "test-harness" });
66
+ try {
67
+ await sc.sendDM(BG_DISPATCH_PROMPT);
68
+
69
+ // Parent ack — some bot reply so we know the parent turn closed.
70
+ const ack = await sc.expectMessage(/.+/, {
71
+ from: "bot",
72
+ timeout: 45_000,
73
+ });
74
+ console.log(`[worker-feed UAT] parent ack: ${JSON.stringify(ack.text)}`);
75
+
76
+ // The worker-feed message. May arrive after the parent ack since
77
+ // first-paint waits for the worker to run ~8s and narrate.
78
+ const feed = await sc.expectMessage(WORKER_FEED_RE, {
79
+ from: "bot",
80
+ timeout: 75_000,
81
+ });
82
+ console.log(
83
+ `[worker-feed UAT] first feed paint (id=${feed.messageId}): ${JSON.stringify(feed.text)}`,
84
+ );
85
+ expect(feed.messageId).toBeGreaterThan(0);
86
+
87
+ // Live edit: snapshot, wait past the throttle + a heartbeat, and
88
+ // re-fetch the SAME message. Body should change as work advances.
89
+ // Soft: a very terse worker might narrate only once; we still
90
+ // require the terminal recap below, which is the load-bearing
91
+ // proof. Log either way so the real cadence is visible.
92
+ const before = feed.text;
93
+ await new Promise((r) => setTimeout(r, 12_000));
94
+ const mid = await sc.driver.getMessage(sc.botUserId, feed.messageId);
95
+ console.log(
96
+ `[worker-feed UAT] after 12s (id=${feed.messageId}): ${JSON.stringify(mid?.text ?? null)}`,
97
+ );
98
+ expect(mid, "worker-feed message vanished mid-flight").not.toBeNull();
99
+
100
+ // Terminal recap — poll the same message until it flips to the
101
+ // done/failed header. Generous budget: ~60s of work + finalize.
102
+ let doneText: string | null = null;
103
+ const deadline = Date.now() + 120_000;
104
+ while (Date.now() < deadline) {
105
+ const m = await sc.driver.getMessage(sc.botUserId, feed.messageId);
106
+ if (m != null && WORKER_DONE_RE.test(m.text)) {
107
+ doneText = m.text;
108
+ break;
109
+ }
110
+ await new Promise((r) => setTimeout(r, 5_000));
111
+ }
112
+ console.log(
113
+ `[worker-feed UAT] terminal (id=${feed.messageId}): ${JSON.stringify(doneText)}`,
114
+ );
115
+ expect(doneText, "worker-feed never reached a terminal recap").not.toBeNull();
116
+ expect(doneText!).toMatch(/tools?|tool ·/i);
117
+ // Did the body actually move between first paint and terminal?
118
+ expect(doneText).not.toBe(before);
119
+ } finally {
120
+ await sc.tearDown();
121
+ }
122
+ },
123
+ 240_000,
124
+ );
125
+ });