Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MessagesTimeline
{...buildProps()}
isWorking
activeTurnInProgress
activeTurnId={ACTIVITY_ROW_TURN_ID}
activeTurnStartedAt="2026-04-13T12:00:00.000Z"
timelineEntries={buildOverflowingWorkTimelineEntries()}
onOpenAgentsPanel={onOpenAgentsPanel}
turnAgents={{
subagents: [
buildTurnSubagent("agent-1", "running"),
buildTurnSubagent("agent-2", "running"),
],
}}
/>,
);

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
Expand Down
56 changes: 56 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
35 changes: 30 additions & 5 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<LiveActivitySpine
entries={groupedEntries}
liveStartedAt={row.liveStartedAt}
workspaceRoot={workspaceRoot}
/>
<>
{showLiveTracker ? (
<div
data-work-activity-receipt="true"
data-work-activity-live-tracker="true"
style={spineStyle()}
>
<SpineRow node={<SpineNode kind="group" />} connectTop={false} connectBottom={false}>
<ActivityReceipt
entries={groupedEntries}
durationEntries={trackedEntries}
tracker={turnAgentTracker}
isExpanded={false}
onToggle={null}
/>
</SpineRow>
</div>
) : null}
<LiveActivitySpine
entries={groupedEntries}
liveStartedAt={row.liveStartedAt}
workspaceRoot={workspaceRoot}
/>
</>
);
}

Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> } }).data as {
item: Record<string, unknown>;
}
).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. */
Expand Down
39 changes: 26 additions & 13 deletions apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -837,6 +842,7 @@ export function deriveSubagentProgressState(input: {
function toSubagentProgressItem(record: InternalSubagentRecord): SubagentProgressItem {
const {
resolvedModel: _resolvedModel,
nativeSpawn: _nativeSpawn,
liveBodyUpdatedAt: _liveBodyUpdatedAt,
resultActivityId: _resultActivityId,
resultBody: _resultBody,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading