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.
- package/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +49 -23
- package/telegram-plugin/gateway/gateway.ts +34 -27
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +37 -0
- package/telegram-plugin/subagent-watcher.ts +10 -1
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +63 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-activity-feed-dm.test.ts +125 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -49413,8 +49413,8 @@ var {
|
|
|
49413
49413
|
} = import__.default;
|
|
49414
49414
|
|
|
49415
49415
|
// src/build-info.ts
|
|
49416
|
-
var VERSION = "0.14.
|
|
49417
|
-
var COMMIT_SHA = "
|
|
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
|
@@ -51140,10 +51140,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
51140
51140
|
}
|
|
51141
51141
|
|
|
51142
51142
|
// ../src/build-info.ts
|
|
51143
|
-
var VERSION = "0.14.
|
|
51144
|
-
var COMMIT_SHA = "
|
|
51145
|
-
var COMMIT_DATE = "2026-05-
|
|
51146
|
-
var LATEST_PR =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
+
});
|