From 472f65bb92ab1d8c98d088a6a00225b6d10758e2 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:33:42 -0400 Subject: [PATCH] Keep the turn activity tracker steady while subagents run Two fixes for the inline subagent activity bar in the conversation: Flicker: a running agent's streamed commentary is a timeline entry that renders nothing, but its timestamp moves with every update. It could split and re-merge the turn's work groups as the agent streamed, and the live group never rendered the activity receipt, so the tracker blinked in and out mid-turn. Live commentary entries now stay out of grouping entirely, and a live work group that owns the turn's tracker renders the receipt above its spine so the bar survives group churn. Missing model/effort: the durable roster seeds a record for every agent before lifecycle rows fold in, so the "only inherit for a brand-new record" guard from #155 never fired and a collab spawn that stated no model lost the parent turn's dispatched selection. Native-spawn omission is now a sticky per-record fact instead: native subAgentActivity spawns still never inherit, while collab and Task spawns inherit the turn's model and effort even when the roster seeded their record first. --- .../chat/MessagesTimeline.browser.tsx | 34 +++++++++++ .../chat/MessagesTimeline.logic.test.ts | 56 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 10 +++- .../src/components/chat/MessagesTimeline.tsx | 35 ++++++++++-- apps/web/src/session-logic.test.ts | 53 ++++++++++++++++++ apps/web/src/session-logic.ts | 39 ++++++++----- 6 files changed, 206 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx index b77786840..7c1e54822 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx @@ -1136,6 +1136,40 @@ describe("MessagesTimeline", () => { } }); + it("keeps the tracker visible while the turn's work group is live", async () => { + const onOpenAgentsPanel = vi.fn(); + // The tail work group renders as the live spine while the turn works. The + // turn's tracker must not blink out with the settled receipt when the live + // group absorbs the turn's earlier steps — it rides above the live steps. + const screen = await renderTimeline( + , + ); + + try { + await expect + .element(page.getByRole("button", { name: "2 subagents. Open the agents panel." })) + .toBeVisible(); + expect(document.querySelector("[data-work-activity-live-tracker='true']")).not.toBeNull(); + expect(document.querySelector("[data-live-activity-strip='true']")).not.toBeNull(); + } finally { + await screen.unmount(); + } + }); + it("keeps the tracker row on a reloaded turn that only delegated, with no count and nothing to expand", async () => { const onOpenAgentsPanel = vi.fn(); // Every entry in the turn is agent lifecycle plumbing, so the conversation diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 28ddae5ba..56eaeaaed 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -316,6 +316,62 @@ describe("deriveMessagesTimelineRows", () => { ]); }); + it("keeps one work group when live agent commentary interleaves with the turn's steps", () => { + const workEntry = (id: string, createdAt: string) => ({ + id, + kind: "work" as const, + createdAt, + entry: { + id, + createdAt, + label: id, + tone: "tool" as const, + turnId: "turn-1" as never, + }, + }); + // The live entry's timestamp moves with every streamed update, so if it + // could split work groups, the receipt would flicker as the agent streams. + const liveEntry = { + id: "subagent-live:turn-1:agent-1", + kind: "subagent-live" as const, + createdAt: "2026-01-01T00:00:05Z", + live: { + id: "subagent-live:turn-1:agent-1", + createdAt: "2026-01-01T00:00:05Z", + turnId: "turn-1" as never, + agentThreadId: "agent-1", + label: "Subagent", + role: null, + objective: null, + body: "Checking the runtime path.", + model: null, + reasoningEffort: null, + }, + }; + + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + workEntry("main-read", "2026-01-01T00:00:00Z"), + liveEntry, + workEntry("main-edit", "2026-01-01T00:00:10Z"), + ], + completionDividerBeforeEntryId: null, + isWorking: true, + activeTurnId: "turn-1" as never, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows).toHaveLength(1); + const [row] = rows; + expect(row?.kind === "work" ? row.groupedEntries.map((entry) => entry.id) : null).toEqual([ + "main-read", + "main-edit", + ]); + expect(row?.kind === "work" ? row.isLive : null).toBe(true); + }); + it("uses the active status label for the live activity row", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [], diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 9face3fd0..4142abc4e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -355,7 +355,8 @@ export function deriveMessagesTimelineRows(input: { // A running agent's streamed commentary never reaches the conversation: // the turn's activity row summarizes what is running, and the rail carries - // the detail. Only the finished agent's receipt lands here. + // the detail. Filtered with the agent-attributed entries above; this arm + // only narrows the type for the message handling below. if (timelineEntry.kind === "subagent-live") { continue; } @@ -486,9 +487,12 @@ function deriveVisibleTimelineEntries(input: { }): TimelineEntry[] { // Agent lifecycle entries stay in this pass: they still count as concrete turn // activity for the provider-lifecycle row's own visibility, and the grouping - // step below is what parks them out of sight. + // step below is what parks them out of sight. Live agent commentary leaves + // here entirely: it renders nothing, and its timestamp moves with every + // streamed update — left in, it would split and re-merge the turn's work + // groups as the agent streams, flickering the activity receipt in and out. const timelineEntries = input.timelineEntries.filter( - (entry) => !isSubagentAttributedEntry(entry), + (entry) => !isSubagentAttributedEntry(entry) && entry.kind !== "subagent-live", ); const visibleByIndex = Array.from({ length: timelineEntries.length }, () => true); let hasLaterProviderLifecycle = false; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 36b512459..afa6da078 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2662,12 +2662,37 @@ const WorkGroupSection = memo(function WorkGroupSection({ } if (isLiveActivity) { + // A turn that delegated keeps its receipt while the group is still live: + // the tracker on it is the conversation's only inline signal that agents + // are running, and it must not blink out whenever the live group happens + // to absorb the turn's earlier steps. The receipt stays collapsed here — + // the live spine below already narrates the recent steps. + const showLiveTracker = turnAgentTracker.summary !== null && onOpenAgentsPanel; return ( - + <> + {showLiveTracker ? ( +
+ } connectTop={false} connectBottom={false}> + + +
+ ) : null} + + ); } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 869e79cfb..b8a85c690 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -373,6 +373,59 @@ describe("deriveThreadSubagentHistory", () => { expect(history[0]?.item.reasoningEffort).toBeNull(); }); + /** The durable roster seeds a record for every agent before the lifecycle + * rows are folded, so the collab spawn is never the record's first sighting. + * A spawn that stated no model still means "the parent turn's settings" — + * the roster seed must not swallow that inheritance. */ + it("inherits the turn's dispatched settings for a roster-seeded collab spawn", () => { + const durable: OrchestrationSubagent = { + id: "codex-child-1", + agentThreadId: "codex-child-1", + parentAgentThreadId: null, + spawnCallId: "call_spawn", + transcriptAgentId: "codex-child-1", + turnId: TurnId.make("turn-1"), + agentPath: "/root/web_tsx_count", + parentAgentPath: "/root", + treeDepth: 0, + nickname: null, + role: null, + objective: null, + status: "running", + requestedModel: null, + resolvedModel: null, + reasoningEffort: null, + modelProvenance: null, + reasoningEffortProvenance: null, + resultBody: null, + resultCreatedAt: null, + createdAt: "2026-08-11T19:34:59.000Z", + updatedAt: "2026-08-11T19:34:59.000Z", + }; + const [turnPreparing, turnStarted, spawn, wait] = codexSpawnActivities(); + const collabSpawn = structuredClone(spawn) as typeof spawn; + const collabItem = ( + (collabSpawn?.payload as { data: { item: Record } }).data as { + item: Record; + } + ).item; + // The same spawn shaped as a collab tool call rather than a native item: + // this omission means "parent settings", not "unknown". + collabItem.type = "collabAgentToolCall"; + + const history = deriveThreadSubagentHistory( + [turnPreparing!, turnStarted!, collabSpawn!, wait!], + [durable], + ); + + expect(history).toHaveLength(1); + expect(history[0]?.item).toMatchObject({ + agentThreadId: "codex-child-1", + model: "gpt-5.6-sol", + reasoningEffort: "high", + }); + }); + /** Claude's `provider.turn.started` does state a model, so the unscoped * dispatch row has to act as a floor rather than be skipped: the effort only * ever appears in the dispatch, and the provider's model still has to win. */ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 74ba7fa39..3ed59a63d 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -768,6 +768,11 @@ interface InternalSubagentRecord extends SubagentProgressItem { * spawn asked for. Sticky: later lifecycle rows only carry the alias * again, and the model has not changed. */ resolvedModel: string | null; + /** Whether a native `subAgentActivity` spawn established this agent. Sticky: + * a native spawn's omitted model and effort mean unknown, so the parent + * turn's dispatched selection must never fill them in — not at spawn, and + * not through the coordination items that follow. */ + nativeSpawn: boolean; liveBodyUpdatedAt: string | null; resultActivityId: string | null; resultBody: string | null; @@ -837,6 +842,7 @@ export function deriveSubagentProgressState(input: { function toSubagentProgressItem(record: InternalSubagentRecord): SubagentProgressItem { const { resolvedModel: _resolvedModel, + nativeSpawn: _nativeSpawn, liveBodyUpdatedAt: _liveBodyUpdatedAt, resultActivityId: _resultActivityId, resultBody: _resultBody, @@ -1154,6 +1160,10 @@ function collectSubagentActivityRecords( status: subagent.status, statusLabel: subagentProgressStatusLabel(subagent.status), resolvedModel: subagent.resolvedModel, + // The roster does not record how the agent was spawned; the lifecycle + // rows below re-flag native spawns before any coordination item could + // inherit on their behalf, since the spawn row precedes them. + nativeSpawn: false, model: subagent.resolvedModel ?? subagent.requestedModel, reasoningEffort: subagent.reasoningEffort, liveBody: null, @@ -1339,18 +1349,14 @@ function collectSubagentActivityRecords( const turnId = activity.turnId ?? previous?.turnId ?? null; // What the spawn asked for always wins; the turn's own selection is the - // floor for providers that only state a model when it was overridden. + // floor for spawns that only state a model when it was overridden. A + // native spawn's omission means unknown, and that fact must survive the + // coordination items (`wait`, `sendInput`, `closeAgent`) that follow, so + // it lives on the record rather than on whichever item arrived first — + // a record seeded from the durable roster still inherits its floor. + const nativeSpawn = previous?.nativeSpawn === true || item.type === "subAgentActivity"; const inherited = - item.type === "subAgentActivity" || turnId === null - ? undefined - : turnModelSelections.get(turnId); - // Inheritance is only a seed for a newly discovered child. Once a native - // spawn has established the record, later coordination items such as - // `wait` must preserve its deliberately omitted model and effort rather - // than filling them from the parent turn. - const previousOrInheritedModel = previous === undefined ? inherited?.model : previous.model; - const previousOrInheritedReasoningEffort = - previous === undefined ? inherited?.reasoningEffort : previous.reasoningEffort; + nativeSpawn || turnId === null ? undefined : turnModelSelections.get(turnId); byAgentId.set(agentId, { id: agentId, @@ -1369,9 +1375,16 @@ function collectSubagentActivityRecords( status, statusLabel: subagentProgressStatusLabel(status), resolvedModel: resolvedModel ?? previous?.resolvedModel ?? null, + nativeSpawn, model: - resolvedModel ?? previous?.resolvedModel ?? model ?? previousOrInheritedModel ?? null, - reasoningEffort: reasoningEffort ?? previousOrInheritedReasoningEffort ?? null, + resolvedModel ?? + previous?.resolvedModel ?? + model ?? + previous?.model ?? + inherited?.model ?? + null, + reasoningEffort: + reasoningEffort ?? previous?.reasoningEffort ?? inherited?.reasoningEffort ?? null, liveBody, liveBodyUpdatedAt, // Claude supplies a dedicated task stream. Codex child work arrives as