From 9a01524f3faca628699cdf65b43ad020b40b86d8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 02:21:36 -0700 Subject: [PATCH] fix(clients): a resumed subagent no longer moves the later turn's Worked for row (#19338) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/lib/threadActivity.test.ts | 118 ++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 33 ++++- .../chat/MessagesTimeline.logic.test.ts | 110 ++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 33 +++-- 4 files changed, 281 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 95cf9fdd6ed68b86a8ded82da8f89f873e7c196a..3d2684f128dcb0ea9b8a4d0cd796551202935b56 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2066,6 +2066,124 @@ const multiSelectQuestion = { multiSelect: true, } as const; +describe("subagent continued by a later turn", () => { + // Stored shape from #15891: resuming a subagent moves its item to the + // resuming run, but the item keeps the ordinal from the turn that started it. + const run2 = RunId.make("run-2"); + const at = (second: number) => `2026-10-09T10:00:${String(second).padStart(2, "0")}.000Z`; + const item = (id: string, itemRunId: RunId, ordinal: number, second: number) => ({ + ...base(id, at(second), ordinal), + runId: itemRunId, + }); + const user = (id: string, itemRunId: RunId, ordinal: number, second: number) => ({ + ...item(id, itemRunId, ordinal, second), + type: "user_message" as const, + messageId: MessageId.make(id), + createdBy: "user" as const, + creationSource: "mobile" as const, + inputIntent: "turn_start" as const, + text: `Prompt ${id}`, + attachments: [], + }); + const assistant = (id: string, itemRunId: RunId, ordinal: number, second: number) => ({ + ...item(id, itemRunId, ordinal, second), + type: "assistant_message" as const, + messageId: MessageId.make(id), + text: `Answer ${id}`, + streaming: false, + }); + const reasoning = (id: string, itemRunId: RunId, ordinal: number, second: number) => ({ + ...item(id, itemRunId, ordinal, second), + type: "reasoning" as const, + text: "Thinking", + streaming: false, + }); + const items: OrchestrationV2TurnItem[] = [ + user("prompt-1", runId, 1_000_001, 0), + reasoning("reasoning-1", runId, 1_000_002, 1), + { + ...item("subagent", run2, 1_000_003, 2), + type: "subagent", + subagentId: NodeId.make("subagent"), + origin: "provider_native", + driver: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + childThreadId: null, + prompt: "Research the bug", + result: "Found it", + }, + assistant("answer-1", runId, 1_000_004, 3), + user("prompt-2", run2, 2_000_001, 30), + reasoning("reasoning-2", run2, 2_000_002, 31), + assistant("answer-2", run2, 2_000_003, 32), + { + ...item("send-message", run2, 2_000_004, 33), + type: "dynamic_tool", + toolName: "SendMessage", + input: { to: "subagent" }, + output: "Sent", + }, + assistant("closing-2", run2, 2_000_005, 41), + ]; + + it("keeps each turn's fold between its own prompt and answer", () => { + const presented = deriveThreadFeedPresentation( + buildThreadFeed(items.map((entry, index) => projected(entry, index))), + { runId: run2, status: "completed", startedAt: at(30), completedAt: at(41) }, + new Set(), + ); + expect(presented.map((entry) => entry.id)).toEqual([ + "prompt-1", + `run-fold:${runId}`, + "answer-1", + "prompt-2", + `run-fold:${run2}`, + "answer-2", + "closing-2", + ]); + }); + + it("keeps a wake run's work open when the user steers it", () => { + const wakeRun = RunId.make("wake-run"); + const wakeItems: OrchestrationV2TurnItem[] = [ + user("prompt-1", runId, 1_000_001, 0), + reasoning("reasoning-1", runId, 1_000_002, 1), + assistant("answer-1", runId, 1_000_003, 3), + { + ...item("wake", wakeRun, 2_000_001, 30), + type: "notification", + source: { kind: "background_task" }, + outcome: "completed", + summary: "Background task finished", + }, + { + ...item("wake-command", wakeRun, 2_000_002, 31), + type: "command_execution", + input: "vp test", + output: "ok", + exitCode: 0, + }, + { ...user("steer", wakeRun, 2_000_003, 32), inputIntent: "steer" }, + ]; + const presented = deriveThreadFeedPresentation( + buildThreadFeed(wakeItems.map((entry, index) => projected(entry, index))), + { runId: wakeRun, status: "running", startedAt: at(30), completedAt: null }, + new Set(), + new Set(), + at(30), + ); + expect(presented.map((entry) => entry.id)).toEqual([ + "prompt-1", + `run-fold:${runId}`, + "answer-1", + "local:thread-1:wake", + "work-toggle:work-group:local:thread-1:wake-command", + "steer", + "live-activity-row", + ]); + }); +}); + describe("pending user input answers", () => { it("preserves exact editor text, including a deliberately cleared answer", () => { const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 25d102d16d096ff0fe068bdeebe81d8180eb407d..316732dd46f3488c400d47214eb26978effce255 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1060,11 +1060,26 @@ function deriveThreadFeedRunFolds( feed: ReadonlyArray, latestRun: ThreadFeedLatestRun | null, runlessWorkActive: boolean, + feedFailedRunIds: ReadonlySet, ): ReadonlyMap { const firstAssistantMessageIdByRun = new Map(); const terminalAssistantMessageIdByRun = new Map(); const interruptedRunIds = new Set(); - const failedRunIds = failedFeedRunIds(feed, latestRun); + const failedRunIds = new Set(feedFailedRunIds); + const promptIndexByRunId = new Map(); + for (const [index, entry] of feed.entries()) { + // A steer joins a run that already started, maybe from a promptless wake. + if ( + entry.type !== "message" || + entry.message.role !== "user" || + entry.message.inputIntent === "steer" || + entry.message.inputIntent === "promoted_queued_to_steer" + ) { + continue; + } + const runId = entry.message.runId; + if (runId != null && !promptIndexByRunId.has(runId)) promptIndexByRunId.set(runId, index); + } const groupsByRunId = new Map< RunId, { entries: ThreadFeedEntry[]; startBoundary: string | null } @@ -1073,14 +1088,16 @@ function deriveThreadFeedRunFolds( // stable key of its own. Decide per prompt, not per thread: a V1 thread's // first V2 run must not unfold every imported turn above it. let runlessKey: RunId | null = null; + let responseKey: RunId | null = null; let pendingUserBoundary: string | null = null; - for (const entry of feed) { + for (const [index, entry] of feed.entries()) { if (entry.type === "message" && entry.message.role === "user") { pendingUserBoundary = entry.message.createdAt; runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null; + responseKey = entry.message.runId ?? runlessKey; continue; } - const runId = + const ownRunId = entry.type === "message" && entry.message.role === "assistant" ? (entry.message.runId ?? runlessKey) : entry.type === "activity-group" || @@ -1088,7 +1105,14 @@ function deriveThreadFeedRunFolds( entry.type === "mcp-app" ? (entry.runId ?? runlessKey) : null; - if (!runId) continue; + if (!ownRunId) continue; + // Resuming a subagent moves its item to the resuming run, but the item + // keeps its place in the turn that started it. It folds with that turn, + // so the later run's fold still opens under its own prompt. + const runId = + responseKey !== null && (promptIndexByRunId.get(ownRunId) ?? -1) > index + ? responseKey + : ownRunId; let group = groupsByRunId.get(runId); if (!group) { group = { entries: [], startBoundary: pendingUserBoundary }; @@ -1254,6 +1278,7 @@ export function deriveThreadFeedPresentation( sourceFeed, latestRun, isWorking && runlessWorkActive, + failedRunIds, ); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorId.values()) { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 68930da0252e6d3a2b9d4f439d8c1345c7917811..caaa2d8241a1be8ae6788ac720ba45ea0a8bd4d6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -5359,6 +5359,116 @@ describe("live subagents after their parent turn settles", () => { }); }); +describe("subagent continued by a later turn", () => { + // Stored shape from #15891: resuming a subagent moves its item to the + // resuming run, but the item keeps the ordinal from the turn that started it. + const threadId = ThreadId.make("parent-thread"); + const run1 = RunId.make("run-1"); + const run2 = RunId.make("run-2"); + const at = (second: number) => + DateTime.makeUnsafe(`2026-10-09T10:00:${String(second).padStart(2, "0")}Z`); + const base = (id: string, runId: RunId, ordinal: number, second: number) => ({ + id: TurnItemId.make(id), + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed" as const, + title: null, + startedAt: at(second), + completedAt: at(second), + updatedAt: at(second), + }); + const user = (id: string, runId: RunId, ordinal: number, second: number) => ({ + ...base(id, runId, ordinal, second), + type: "user_message" as const, + messageId: MessageId.make(id), + createdBy: "user" as const, + creationSource: "web" as const, + inputIntent: "turn_start" as const, + text: `Prompt ${id}`, + attachments: [], + }); + const assistant = (id: string, runId: RunId, ordinal: number, second: number) => ({ + ...base(id, runId, ordinal, second), + type: "assistant_message" as const, + messageId: MessageId.make(id), + text: `Answer ${id}`, + streaming: false, + }); + const reasoning = (id: string, runId: RunId, ordinal: number, second: number) => ({ + ...base(id, runId, ordinal, second), + type: "reasoning" as const, + text: "Thinking", + streaming: false, + }); + const items: OrchestrationV2TurnItem[] = [ + user("prompt-1", run1, 1_000_001, 0), + reasoning("reasoning-1", run1, 1_000_002, 1), + { + ...base("subagent", run2, 1_000_003, 2), + completedAt: at(40), + updatedAt: at(40), + type: "subagent", + subagentId: NodeId.make("subagent"), + origin: "provider_native", + driver: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + childThreadId: null, + prompt: "Research the bug", + result: "Found it", + }, + assistant("answer-1", run1, 1_000_004, 3), + user("prompt-2", run2, 2_000_001, 30), + reasoning("reasoning-2", run2, 2_000_002, 31), + assistant("answer-2", run2, 2_000_003, 32), + { + ...base("send-message", run2, 2_000_004, 33), + type: "dynamic_tool", + toolName: "SendMessage", + input: { to: "subagent" }, + output: "Sent", + }, + assistant("closing-2", run2, 2_000_005, 41), + ]; + + it("keeps each turn's fold between its own prompt and answer", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: items.map((item) => ({ + position: item.ordinal, + visibility: "local", + sourceThreadId: threadId, + sourceItemId: item.id, + item, + })), + optimisticMessages: [], + }), + latestRun: { + runId: run2, + status: "completed", + startedAt: DateTime.formatIso(at(30)), + completedAt: DateTime.formatIso(at(41)), + }, + isWorking: false, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.map((row) => row.id)).toEqual([ + "prompt-1", + `turn-fold:${run1}`, + "answer-1", + "prompt-2", + `turn-fold:${run2}`, + "closing-2", + ]); + }); +}); + describe("shouldCollapseUserMessage", () => { it("measures a quote chip by its label, not its encoded link", () => { const quote = "A long assistant paragraph that the user quoted. ".repeat(40); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 17d54c8f74386e4b0fc55ae28f8ffc76d1cd2da0..25f67ffce1c99e50590bfa9705081948febf0586 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -942,6 +942,12 @@ function timelineEntryStartsResponse(entry: TimelineEntry): boolean { ); } +function responseBoundaryRunId(entry: TimelineEntry): RunId | null { + if (entry.kind === "message") return entry.message.runId ?? null; + if (entry.kind === "work") return entry.entry.runId ?? null; + return null; +} + /** * A promptless provider restart replaces the native turn without adding a * user message. Keep every provider turn since the initiating prompt in one @@ -1025,7 +1031,12 @@ function deriveTurnFolds(input: { completeSupersededReplyEntryIds: ReadonlySet; }): ReadonlyMap { const interruptedRunIds = new Set(); - for (const entry of input.timelineEntries) { + const promptIndexByRunId = new Map(); + for (const [index, entry] of input.timelineEntries.entries()) { + if (timelineEntryStartsResponse(entry)) { + const runId = responseBoundaryRunId(entry); + if (runId !== null && !promptIndexByRunId.has(runId)) promptIndexByRunId.set(runId, index); + } if ( entry.kind === "event" && entry.projectedItem.item.runId !== null && @@ -1056,6 +1067,7 @@ function deriveTurnFolds(input: { // stable key of its own. Decide per prompt, not per thread: a V1 thread's // first V2 run must not unfold every imported turn above it. let runlessKey: RunId | null = null; + let responseKey: RunId | null = null; let pendingBoundary: { createdAt: string; anchorEntryId: string } | null = null; for (const [index, entry] of input.timelineEntries.entries()) { if (timelineEntryStartsResponse(entry)) { @@ -1063,19 +1075,22 @@ function deriveTurnFolds(input: { pendingBoundary = nextEntry ? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id } : null; - const boundaryRunId = - entry.kind === "message" - ? entry.message.runId - : entry.kind === "work" - ? entry.entry.runId - : null; + const boundaryRunId = responseBoundaryRunId(entry); runlessKey = boundaryRunId == null ? RunId.make(`runless:${entry.id}`) : null; + responseKey = boundaryRunId ?? runlessKey; continue; } - const runId = timelineEntryFoldRunId(entry, runlessKey); - if (!runId) { + const ownRunId = timelineEntryFoldRunId(entry, runlessKey); + if (!ownRunId) { continue; } + // Resuming a subagent moves its item to the resuming run, but the item + // keeps its place in the turn that started it. It folds with that turn, + // so the later run's fold still opens under its own prompt. + const runId = + responseKey !== null && (promptIndexByRunId.get(ownRunId) ?? -1) > index + ? responseKey + : ownRunId; if (runId === runlessKey && timelineEntryFailedItem(entry) !== null) { runlessFailedKeys.add(runId); } -- 2.54.0