switchroom 0.14.24 → 0.14.25
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 +38 -13
- package/telegram-plugin/gateway/foreground-nesting.ts +65 -0
- package/telegram-plugin/gateway/gateway.ts +34 -12
- package/telegram-plugin/tests/foreground-nesting.test.ts +98 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +121 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -49420,8 +49420,8 @@ var {
|
|
|
49420
49420
|
} = import__.default;
|
|
49421
49421
|
|
|
49422
49422
|
// src/build-info.ts
|
|
49423
|
-
var VERSION = "0.14.
|
|
49424
|
-
var COMMIT_SHA = "
|
|
49423
|
+
var VERSION = "0.14.25";
|
|
49424
|
+
var COMMIT_SHA = "f75f4f25";
|
|
49425
49425
|
|
|
49426
49426
|
// src/cli/agent.ts
|
|
49427
49427
|
init_source();
|
package/package.json
CHANGED
|
@@ -47505,6 +47505,18 @@ function decideSubagentProgress(input) {
|
|
|
47505
47505
|
return { deliver: true, chatId, bucketIdx, inbound };
|
|
47506
47506
|
}
|
|
47507
47507
|
|
|
47508
|
+
// gateway/foreground-nesting.ts
|
|
47509
|
+
function shouldRenderForegroundProgress(g) {
|
|
47510
|
+
return g.nestingEnabled;
|
|
47511
|
+
}
|
|
47512
|
+
function foregroundFinishAction(i) {
|
|
47513
|
+
if (!i.removed)
|
|
47514
|
+
return "none";
|
|
47515
|
+
if (i.replyCalled && i.remainingForeground === 0)
|
|
47516
|
+
return "handoff-clear";
|
|
47517
|
+
return "recompose";
|
|
47518
|
+
}
|
|
47519
|
+
|
|
47508
47520
|
// gateway/poll-health.ts
|
|
47509
47521
|
var DEFAULT_LOG = (msg) => {
|
|
47510
47522
|
process.stderr.write(msg.endsWith(`
|
|
@@ -51457,10 +51469,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
51457
51469
|
}
|
|
51458
51470
|
|
|
51459
51471
|
// ../src/build-info.ts
|
|
51460
|
-
var VERSION = "0.14.
|
|
51461
|
-
var COMMIT_SHA = "
|
|
51462
|
-
var COMMIT_DATE = "2026-
|
|
51463
|
-
var LATEST_PR =
|
|
51472
|
+
var VERSION = "0.14.25";
|
|
51473
|
+
var COMMIT_SHA = "f75f4f25";
|
|
51474
|
+
var COMMIT_DATE = "2026-06-01T00:05:32Z";
|
|
51475
|
+
var LATEST_PR = 2038;
|
|
51464
51476
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
51465
51477
|
|
|
51466
51478
|
// gateway/boot-version.ts
|
|
@@ -61976,12 +61988,22 @@ var didOneTimeSetup = false;
|
|
|
61976
61988
|
const isBackground = dispatch.isBackground;
|
|
61977
61989
|
if (!isBackground) {
|
|
61978
61990
|
const turn = currentTurn;
|
|
61979
|
-
|
|
61980
|
-
|
|
61981
|
-
|
|
61982
|
-
|
|
61983
|
-
|
|
61984
|
-
|
|
61991
|
+
const removed = turn != null && turn.foregroundSubAgents.delete(agentId);
|
|
61992
|
+
if (turn != null && removed) {
|
|
61993
|
+
const action = foregroundFinishAction({
|
|
61994
|
+
removed,
|
|
61995
|
+
replyCalled: turn.replyCalled,
|
|
61996
|
+
remainingForeground: turn.foregroundSubAgents.size
|
|
61997
|
+
});
|
|
61998
|
+
if (action === "handoff-clear") {
|
|
61999
|
+
clearActivitySummary(turn);
|
|
62000
|
+
} else if (action === "recompose") {
|
|
62001
|
+
const rendered = composeTurnActivity(turn);
|
|
62002
|
+
if (rendered != null) {
|
|
62003
|
+
turn.activityPendingRender = rendered;
|
|
62004
|
+
if (turn.activityInFlight == null) {
|
|
62005
|
+
turn.activityInFlight = drainActivitySummary(turn);
|
|
62006
|
+
}
|
|
61985
62007
|
}
|
|
61986
62008
|
}
|
|
61987
62009
|
}
|
|
@@ -62048,10 +62070,13 @@ var didOneTimeSetup = false;
|
|
|
62048
62070
|
}
|
|
62049
62071
|
const isBackground = dispatch.isBackground;
|
|
62050
62072
|
if (!isBackground) {
|
|
62051
|
-
if (!foregroundNestingEnabled)
|
|
62052
|
-
return;
|
|
62053
62073
|
const turn = currentTurn;
|
|
62054
|
-
if (turn == null
|
|
62074
|
+
if (turn == null)
|
|
62075
|
+
return;
|
|
62076
|
+
if (!shouldRenderForegroundProgress({
|
|
62077
|
+
nestingEnabled: foregroundNestingEnabled,
|
|
62078
|
+
replyCalled: turn.replyCalled
|
|
62079
|
+
}))
|
|
62055
62080
|
return;
|
|
62056
62081
|
const child = latestSummary.trim().slice(0, 120);
|
|
62057
62082
|
if (child.length === 0)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure state-transition helpers for Model A foreground sub-agent activity
|
|
3
|
+
* nesting (#2027). Extracted from gateway.ts so the `replyCalled` gate is
|
|
4
|
+
* unit-testable on its own — the exact seam #2027 shipped without, which let
|
|
5
|
+
* the feature silently no-op for every ack-first turn (reply "On it…" then
|
|
6
|
+
* delegate) and go unnoticed in production. See
|
|
7
|
+
* `tests/foreground-nesting.test.ts`.
|
|
8
|
+
*
|
|
9
|
+
* Subscription-honest: every caller is pure jsonl-tail → render, no model
|
|
10
|
+
* call. These functions decide *whether/what* to render; the gateway owns the
|
|
11
|
+
* `currentTurn` mutation and the Telegram edit.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface ForegroundProgressGate {
|
|
15
|
+
/** `SWITCHROOM_FOREGROUND_SUBAGENT_NESTING !== '0'` — the kill-switch. */
|
|
16
|
+
nestingEnabled: boolean
|
|
17
|
+
/**
|
|
18
|
+
* Whether the parent turn has already emitted a reply. Accepted as input
|
|
19
|
+
* but intentionally NOT a gate: a foreground `Task` is a *blocking* call,
|
|
20
|
+
* so the parent cannot have issued its FINAL answer while the sub-agent is
|
|
21
|
+
* still running — any `replyCalled === true` observed here is therefore an
|
|
22
|
+
* *interim* ack. The pre-fix code bailed on this flag, which is precisely
|
|
23
|
+
* why ack-first turns showed zero live foreground activity.
|
|
24
|
+
*/
|
|
25
|
+
replyCalled: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether a foreground sub-agent progress tick should accumulate its narrative
|
|
30
|
+
* and re-render the activity feed. Gated ONLY by the kill-switch — never by
|
|
31
|
+
* `replyCalled` (see the field doc above).
|
|
32
|
+
*/
|
|
33
|
+
export function shouldRenderForegroundProgress(g: ForegroundProgressGate): boolean {
|
|
34
|
+
return g.nestingEnabled
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ForegroundFinishAction = 'recompose' | 'handoff-clear' | 'none'
|
|
38
|
+
|
|
39
|
+
export interface ForegroundFinishInput {
|
|
40
|
+
/** Was this agentId actually an active foreground sub-agent we tracked? */
|
|
41
|
+
removed: boolean
|
|
42
|
+
/** Has the parent turn already emitted a reply (an interim ack)? */
|
|
43
|
+
replyCalled: boolean
|
|
44
|
+
/** Foreground sub-agents still active AFTER removing the finished one. */
|
|
45
|
+
remainingForeground: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What the gateway should do when a foreground sub-agent finishes:
|
|
50
|
+
*
|
|
51
|
+
* - `'none'` — it wasn't one we were tracking (e.g. background, or a
|
|
52
|
+
* stale boot row); leave the feed alone.
|
|
53
|
+
* - `'handoff-clear'`— it was the LAST foreground sub-agent and the parent
|
|
54
|
+
* has already acked. The re-opened post-ack feed now
|
|
55
|
+
* hands off to the imminent final answer, mirroring the
|
|
56
|
+
* first-reply hand-off. (turn_end is the safety net if
|
|
57
|
+
* this is somehow missed.)
|
|
58
|
+
* - `'recompose'` — collapse the finished sub-agent's block and re-render
|
|
59
|
+
* (pre-ack flow, or other sub-agents still running).
|
|
60
|
+
*/
|
|
61
|
+
export function foregroundFinishAction(i: ForegroundFinishInput): ForegroundFinishAction {
|
|
62
|
+
if (!i.removed) return 'none'
|
|
63
|
+
if (i.replyCalled && i.remainingForeground === 0) return 'handoff-clear'
|
|
64
|
+
return 'recompose'
|
|
65
|
+
}
|
|
@@ -303,6 +303,10 @@ import {
|
|
|
303
303
|
decideSubagentProgress,
|
|
304
304
|
DEFAULT_PROGRESS_INTERVAL_MS,
|
|
305
305
|
} from './subagent-progress-inbound-builder.js'
|
|
306
|
+
import {
|
|
307
|
+
shouldRenderForegroundProgress,
|
|
308
|
+
foregroundFinishAction,
|
|
309
|
+
} from './foreground-nesting.js'
|
|
306
310
|
import { createPollHealthCheck, type PollHealthCheckHandle } from './poll-health.js'
|
|
307
311
|
import type {
|
|
308
312
|
ToolCallMessage,
|
|
@@ -17843,16 +17847,26 @@ void (async () => {
|
|
|
17843
17847
|
// tool result, so there's no handback to deliver. Reaction
|
|
17844
17848
|
// promotion already ran above.
|
|
17845
17849
|
const turn = currentTurn
|
|
17846
|
-
|
|
17847
|
-
|
|
17848
|
-
|
|
17849
|
-
|
|
17850
|
-
|
|
17851
|
-
|
|
17852
|
-
|
|
17853
|
-
|
|
17854
|
-
|
|
17855
|
-
|
|
17850
|
+
const removed = turn != null && turn.foregroundSubAgents.delete(agentId)
|
|
17851
|
+
if (turn != null && removed) {
|
|
17852
|
+
const action = foregroundFinishAction({
|
|
17853
|
+
removed,
|
|
17854
|
+
replyCalled: turn.replyCalled,
|
|
17855
|
+
remainingForeground: turn.foregroundSubAgents.size,
|
|
17856
|
+
})
|
|
17857
|
+
if (action === 'handoff-clear') {
|
|
17858
|
+
// Post-ack: the last foreground sub-agent finished and
|
|
17859
|
+
// the parent will now produce its answer inline. Hand
|
|
17860
|
+
// the re-opened feed off to the answer, mirroring the
|
|
17861
|
+
// first-reply clear (turn_end is the safety net).
|
|
17862
|
+
clearActivitySummary(turn)
|
|
17863
|
+
} else if (action === 'recompose') {
|
|
17864
|
+
const rendered = composeTurnActivity(turn)
|
|
17865
|
+
if (rendered != null) {
|
|
17866
|
+
turn.activityPendingRender = rendered
|
|
17867
|
+
if (turn.activityInFlight == null) {
|
|
17868
|
+
turn.activityInFlight = drainActivitySummary(turn)
|
|
17869
|
+
}
|
|
17856
17870
|
}
|
|
17857
17871
|
}
|
|
17858
17872
|
}
|
|
@@ -17972,9 +17986,17 @@ void (async () => {
|
|
|
17972
17986
|
// activity draft rather than a separate worker message. Pure
|
|
17973
17987
|
// jsonl-tail → render (no model call), inside the
|
|
17974
17988
|
// subscription-honest boundary.
|
|
17975
|
-
if (!foregroundNestingEnabled) return // kill-switch: skip overhead
|
|
17976
17989
|
const turn = currentTurn
|
|
17977
|
-
if (turn == null
|
|
17990
|
+
if (turn == null) return
|
|
17991
|
+
// Render regardless of `replyCalled` — a foreground Task
|
|
17992
|
+
// blocks the parent, so any reply seen while it runs is an
|
|
17993
|
+
// interim ack, never the final answer. Gating on replyCalled
|
|
17994
|
+
// (pre-#2032) made ack-first turns show zero live foreground
|
|
17995
|
+
// activity. Kill-switch lives in the predicate.
|
|
17996
|
+
if (!shouldRenderForegroundProgress({
|
|
17997
|
+
nestingEnabled: foregroundNestingEnabled,
|
|
17998
|
+
replyCalled: turn.replyCalled,
|
|
17999
|
+
})) return
|
|
17978
18000
|
const child = latestSummary.trim().slice(0, 120)
|
|
17979
18001
|
if (child.length === 0) return
|
|
17980
18002
|
let narrative = turn.foregroundSubAgents.get(agentId)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression guard for the Model A foreground sub-agent nesting gate (#2032).
|
|
3
|
+
*
|
|
4
|
+
* #2027 shipped foreground nesting but gated every render path on
|
|
5
|
+
* `turn.replyCalled`. Because the framework's ack-first pattern replies
|
|
6
|
+
* "On it…" FIRST and then delegates, `replyCalled` was already true before any
|
|
7
|
+
* foreground sub-agent ran — so the feature silently produced ZERO live
|
|
8
|
+
* foreground activity for the exact case it was built for, and that went
|
|
9
|
+
* unnoticed because #2027 only tested the pure renderer, never the gate.
|
|
10
|
+
*
|
|
11
|
+
* These tests pin the gate decisions directly so the replyCalled-independence
|
|
12
|
+
* can never regress silently again.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect } from 'vitest'
|
|
15
|
+
import {
|
|
16
|
+
shouldRenderForegroundProgress,
|
|
17
|
+
foregroundFinishAction,
|
|
18
|
+
} from '../gateway/foreground-nesting.js'
|
|
19
|
+
import { renderActivityFeedWithNested } from '../tool-activity-summary.js'
|
|
20
|
+
|
|
21
|
+
describe('shouldRenderForegroundProgress', () => {
|
|
22
|
+
it('renders even after the parent has acked (the #2027 blindspot)', () => {
|
|
23
|
+
// THE regression guard: ack-first sets replyCalled=true before the
|
|
24
|
+
// foreground sub-agent runs. It MUST still render.
|
|
25
|
+
expect(
|
|
26
|
+
shouldRenderForegroundProgress({ nestingEnabled: true, replyCalled: true }),
|
|
27
|
+
).toBe(true)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('renders before any reply (pre-ack flow, unchanged)', () => {
|
|
31
|
+
expect(
|
|
32
|
+
shouldRenderForegroundProgress({ nestingEnabled: true, replyCalled: false }),
|
|
33
|
+
).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('is independent of replyCalled — the flag never flips the outcome', () => {
|
|
37
|
+
const on = shouldRenderForegroundProgress({ nestingEnabled: true, replyCalled: true })
|
|
38
|
+
const off = shouldRenderForegroundProgress({ nestingEnabled: true, replyCalled: false })
|
|
39
|
+
expect(on).toBe(off)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('honours the kill-switch regardless of replyCalled', () => {
|
|
43
|
+
expect(
|
|
44
|
+
shouldRenderForegroundProgress({ nestingEnabled: false, replyCalled: false }),
|
|
45
|
+
).toBe(false)
|
|
46
|
+
expect(
|
|
47
|
+
shouldRenderForegroundProgress({ nestingEnabled: false, replyCalled: true }),
|
|
48
|
+
).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('foregroundFinishAction', () => {
|
|
53
|
+
it('hands off to the answer when the last sub-agent finishes post-ack', () => {
|
|
54
|
+
expect(
|
|
55
|
+
foregroundFinishAction({ removed: true, replyCalled: true, remainingForeground: 0 }),
|
|
56
|
+
).toBe('handoff-clear')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('recomposes when other foreground sub-agents are still running post-ack', () => {
|
|
60
|
+
expect(
|
|
61
|
+
foregroundFinishAction({ removed: true, replyCalled: true, remainingForeground: 1 }),
|
|
62
|
+
).toBe('recompose')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('recomposes pre-ack (original behaviour preserved)', () => {
|
|
66
|
+
expect(
|
|
67
|
+
foregroundFinishAction({ removed: true, replyCalled: false, remainingForeground: 0 }),
|
|
68
|
+
).toBe('recompose')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('does nothing for an agent it was not tracking', () => {
|
|
72
|
+
expect(
|
|
73
|
+
foregroundFinishAction({ removed: false, replyCalled: true, remainingForeground: 0 }),
|
|
74
|
+
).toBe('none')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('end-to-end render shape under ack-first', () => {
|
|
79
|
+
it('produces a live nested block from a post-ack foreground narrative', () => {
|
|
80
|
+
// marko's real scenario: parent acked with no prior steps (empty mirror),
|
|
81
|
+
// then a foreground researcher emits progress. The gate now ALLOWS this
|
|
82
|
+
// render; prove the render is a real, non-empty nested feed with a live
|
|
83
|
+
// bold "→ current" line — i.e. the user would actually see activity.
|
|
84
|
+
const html = renderActivityFeedWithNested(
|
|
85
|
+
[],
|
|
86
|
+
[
|
|
87
|
+
'searching Unsplash CC0 desk',
|
|
88
|
+
'checking license on 4 candidates',
|
|
89
|
+
'ranking by resolution',
|
|
90
|
+
],
|
|
91
|
+
)
|
|
92
|
+
expect(html).not.toBeNull()
|
|
93
|
+
// newest child is the live, bold current step
|
|
94
|
+
expect(html).toContain('<b>→ ranking by resolution</b>')
|
|
95
|
+
// an earlier child is present as a done/italic step
|
|
96
|
+
expect(html).toContain('checking license on 4 candidates')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foreground sub-agent live activity nesting (#2032) — UAT.
|
|
3
|
+
*
|
|
4
|
+
* Background (#2027): a FOREGROUND sub-agent (Agent/Task WITHOUT
|
|
5
|
+
* run_in_background) runs INSIDE the parent turn, blocking it. Its live steps
|
|
6
|
+
* were meant to nest into the parent's activity-summary feed (Model A). But
|
|
7
|
+
* every render path bailed on `turn.replyCalled`, and the framework's
|
|
8
|
+
* ack-first pattern replies "On it…" FIRST — so the sub-agent ran with
|
|
9
|
+
* replyCalled already true and NOTHING ever painted. #2027 only tested the
|
|
10
|
+
* pure renderer, so the regression shipped silently (the "marko's researcher
|
|
11
|
+
* showed no Telegram activity" report). #2032 renders foreground progress
|
|
12
|
+
* regardless of replyCalled.
|
|
13
|
+
*
|
|
14
|
+
* This scenario forces the exact broken shape end-to-end:
|
|
15
|
+
* 1. Prompt the agent to send a quick ack FIRST (sets replyCalled=true),
|
|
16
|
+
* 2. THEN dispatch a FOREGROUND sub-agent (run_in_background:false) that
|
|
17
|
+
* narrates ~10 paced steps so its jsonl keeps ticking under the
|
|
18
|
+
* test-harness 5s stall floor and the feed can paint + edit,
|
|
19
|
+
* 3. then report done.
|
|
20
|
+
*
|
|
21
|
+
* Asserts the load-bearing proof: an activity-summary feed message appears
|
|
22
|
+
* carrying the NESTED foreground marker ("↳") with the sub-agent's narration —
|
|
23
|
+
* i.e. live foreground activity surfaced AFTER the ack. Pre-#2032 this message
|
|
24
|
+
* never existed. Logs every observed body so a human can read the real UX.
|
|
25
|
+
*
|
|
26
|
+
* NOT a draft: the activity-summary feed is a real sendMessage/editMessageText
|
|
27
|
+
* (gateway.ts drainActivitySummary), so mtcute can observe it — unlike the
|
|
28
|
+
* answer-stream draft, which mtcute cannot see.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, expect, it } from "vitest";
|
|
32
|
+
import { spinUp } from "../harness.js";
|
|
33
|
+
|
|
34
|
+
// Same paced-narration discipline as jtbd-worker-activity-feed-dm: each step
|
|
35
|
+
// is its own Bash call with a one-line narration so the sub-agent emits a
|
|
36
|
+
// `sub_agent_text` line every ~2s — under SWITCHROOM_SUBAGENT_STALL_MS=5000,
|
|
37
|
+
// so the watcher never synth-terminates it mid-flight (which would suppress
|
|
38
|
+
// onProgress and the feed would never paint). run_in_background:false makes it
|
|
39
|
+
// FOREGROUND — the whole point.
|
|
40
|
+
const FG_DISPATCH_PROMPT =
|
|
41
|
+
`First, immediately send me a one-line acknowledgement that you're starting ` +
|
|
42
|
+
`(just "On it — running a check now."). Then use the Agent tool with ` +
|
|
43
|
+
`subagent_type "general-purpose" and run_in_background: false (a FOREGROUND ` +
|
|
44
|
+
`sub-agent) with this exact task: "Do eight steps, ONE AT A TIME, k = 1 ` +
|
|
45
|
+
`through 8. Before each step write a brief one-sentence narration of what ` +
|
|
46
|
+
`you are about to do, then run \`sleep 2\` via the Bash tool, then run ` +
|
|
47
|
+
`\`echo step-k\` via the Bash tool (substitute the real number for k). Run ` +
|
|
48
|
+
`every sleep and every echo as its OWN separate Bash call — never batch or ` +
|
|
49
|
+
`chain them with && — and narrate before each so progress surfaces ` +
|
|
50
|
+
`incrementally. Do not stop early; complete all eight steps, then return a ` +
|
|
51
|
+
`one-line summary." Wait for the foreground sub-agent to finish, then send ` +
|
|
52
|
+
`me a brief reply telling me it's done.`;
|
|
53
|
+
|
|
54
|
+
// The nested foreground block renders each child line prefixed with "↳"
|
|
55
|
+
// (NESTED_PREFIX = " ↳ "), newest as a bold "→ …" current step. Telegram
|
|
56
|
+
// strips the bold but keeps the literal ↳ / → glyphs in message text.
|
|
57
|
+
const NESTED_RE = /↳/;
|
|
58
|
+
|
|
59
|
+
describe("uat: foreground sub-agent live activity nesting (#2032)", () => {
|
|
60
|
+
it(
|
|
61
|
+
"surfaces nested foreground activity in the feed AFTER the ack-first reply",
|
|
62
|
+
async () => {
|
|
63
|
+
const sc = await spinUp({ agent: "test-harness" });
|
|
64
|
+
try {
|
|
65
|
+
await sc.sendDM(FG_DISPATCH_PROMPT);
|
|
66
|
+
|
|
67
|
+
// Ack-first reply — establishes replyCalled=true BEFORE the
|
|
68
|
+
// foreground sub-agent runs. This is the condition that broke #2027.
|
|
69
|
+
const ack = await sc.expectMessage(/.+/, {
|
|
70
|
+
from: "bot",
|
|
71
|
+
timeout: 60_000,
|
|
72
|
+
});
|
|
73
|
+
console.log(`[fg-activity UAT] ack-first reply: ${JSON.stringify(ack.text)}`);
|
|
74
|
+
|
|
75
|
+
// The activity-summary feed carrying the NESTED foreground narrative.
|
|
76
|
+
// First paint waits for the sub-agent to dispatch + narrate (~8s
|
|
77
|
+
// first-paint throttle), so give it a generous window. Its presence
|
|
78
|
+
// is the load-bearing proof of the fix: post-ack foreground activity.
|
|
79
|
+
const feed = await sc.expectMessage(NESTED_RE, {
|
|
80
|
+
from: "bot",
|
|
81
|
+
timeout: 90_000,
|
|
82
|
+
});
|
|
83
|
+
console.log(
|
|
84
|
+
`[fg-activity UAT] nested feed paint (id=${feed.messageId}): ${JSON.stringify(feed.text)}`,
|
|
85
|
+
);
|
|
86
|
+
expect(feed.messageId).toBeGreaterThan(0);
|
|
87
|
+
expect(feed.text).toMatch(NESTED_RE);
|
|
88
|
+
|
|
89
|
+
// Live edit: re-fetch the SAME message after the throttle + a few
|
|
90
|
+
// sub-agent steps. Body should change as the nested narrative
|
|
91
|
+
// advances — proving it's a live feed, not a one-shot post. Soft:
|
|
92
|
+
// log either way; the nested-paint above is the load-bearing proof.
|
|
93
|
+
const before = feed.text;
|
|
94
|
+
await new Promise((r) => setTimeout(r, 10_000));
|
|
95
|
+
const mid = await sc.driver.getMessage(sc.botUserId, feed.messageId);
|
|
96
|
+
console.log(
|
|
97
|
+
`[fg-activity UAT] same feed after 10s (id=${feed.messageId}): ${JSON.stringify(mid?.text ?? null)}`,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Final answer — the parent resumes after the foreground sub-agent
|
|
101
|
+
// returns and reports done. The feed hands off (clears) to this
|
|
102
|
+
// answer. Confirms the turn completes cleanly, not wedged.
|
|
103
|
+
const done = await sc.expectMessage(/done|complete|finished|step-8|wrapped/i, {
|
|
104
|
+
from: "bot",
|
|
105
|
+
timeout: 120_000,
|
|
106
|
+
});
|
|
107
|
+
console.log(`[fg-activity UAT] final answer: ${JSON.stringify(done.text)}`);
|
|
108
|
+
expect(done.text.length).toBeGreaterThan(0);
|
|
109
|
+
// Did the nested narrative actually move while in flight?
|
|
110
|
+
if (mid?.text != null) {
|
|
111
|
+
console.log(
|
|
112
|
+
`[fg-activity UAT] body moved in-flight: ${mid.text !== before}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
await sc.tearDown();
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
300_000,
|
|
120
|
+
);
|
|
121
|
+
});
|