authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 02:21:36-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 02:21:36-07:00
log9a01524f3faca628699cdf65b43ad020b40b86d8
treec17ddfbf42bb7be77cce741408822e6cdf20e29d
parent4cf8243f855da1abc79f5b09c58065c0fb328c0e
signature Signed by PGP key B5690EEEBB952194

fix(clients): a resumed subagent no longer moves the later turn's Worked for row (#19338)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

4 files changed, 281 insertions(+), 13 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+118
...@@ -2066,6 +2066,124 @@ const multiSelectQuestion = {...@@ -2066,6 +2066,124 @@ const multiSelectQuestion = {
2066 multiSelect: true,2066 multiSelect: true,
2067} as const;2067} as const;
20682068
2069describe("subagent continued by a later turn", () => {
2070 // Stored shape from #15891: resuming a subagent moves its item to the
2071 // resuming run, but the item keeps the ordinal from the turn that started it.
2072 const run2 = RunId.make("run-2");
2073 const at = (second: number) => `2026-10-09T10:00:${String(second).padStart(2, "0")}.000Z`;
2074 const item = (id: string, itemRunId: RunId, ordinal: number, second: number) => ({
2075 ...base(id, at(second), ordinal),
2076 runId: itemRunId,
2077 });
2078 const user = (id: string, itemRunId: RunId, ordinal: number, second: number) => ({
2079 ...item(id, itemRunId, ordinal, second),
2080 type: "user_message" as const,
2081 messageId: MessageId.make(id),
2082 createdBy: "user" as const,
2083 creationSource: "mobile" as const,
2084 inputIntent: "turn_start" as const,
2085 text: `Prompt ${id}`,
2086 attachments: [],
2087 });
2088 const assistant = (id: string, itemRunId: RunId, ordinal: number, second: number) => ({
2089 ...item(id, itemRunId, ordinal, second),
2090 type: "assistant_message" as const,
2091 messageId: MessageId.make(id),
2092 text: `Answer ${id}`,
2093 streaming: false,
2094 });
2095 const reasoning = (id: string, itemRunId: RunId, ordinal: number, second: number) => ({
2096 ...item(id, itemRunId, ordinal, second),
2097 type: "reasoning" as const,
2098 text: "Thinking",
2099 streaming: false,
2100 });
2101 const items: OrchestrationV2TurnItem[] = [
2102 user("prompt-1", runId, 1_000_001, 0),
2103 reasoning("reasoning-1", runId, 1_000_002, 1),
2104 {
2105 ...item("subagent", run2, 1_000_003, 2),
2106 type: "subagent",
2107 subagentId: NodeId.make("subagent"),
2108 origin: "provider_native",
2109 driver: ProviderDriverKind.make("claudeAgent"),
2110 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
2111 childThreadId: null,
2112 prompt: "Research the bug",
2113 result: "Found it",
2114 },
2115 assistant("answer-1", runId, 1_000_004, 3),
2116 user("prompt-2", run2, 2_000_001, 30),
2117 reasoning("reasoning-2", run2, 2_000_002, 31),
2118 assistant("answer-2", run2, 2_000_003, 32),
2119 {
2120 ...item("send-message", run2, 2_000_004, 33),
2121 type: "dynamic_tool",
2122 toolName: "SendMessage",
2123 input: { to: "subagent" },
2124 output: "Sent",
2125 },
2126 assistant("closing-2", run2, 2_000_005, 41),
2127 ];
2128
2129 it("keeps each turn's fold between its own prompt and answer", () => {
2130 const presented = deriveThreadFeedPresentation(
2131 buildThreadFeed(items.map((entry, index) => projected(entry, index))),
2132 { runId: run2, status: "completed", startedAt: at(30), completedAt: at(41) },
2133 new Set(),
2134 );
2135 expect(presented.map((entry) => entry.id)).toEqual([
2136 "prompt-1",
2137 `run-fold:${runId}`,
2138 "answer-1",
2139 "prompt-2",
2140 `run-fold:${run2}`,
2141 "answer-2",
2142 "closing-2",
2143 ]);
2144 });
2145
2146 it("keeps a wake run's work open when the user steers it", () => {
2147 const wakeRun = RunId.make("wake-run");
2148 const wakeItems: OrchestrationV2TurnItem[] = [
2149 user("prompt-1", runId, 1_000_001, 0),
2150 reasoning("reasoning-1", runId, 1_000_002, 1),
2151 assistant("answer-1", runId, 1_000_003, 3),
2152 {
2153 ...item("wake", wakeRun, 2_000_001, 30),
2154 type: "notification",
2155 source: { kind: "background_task" },
2156 outcome: "completed",
2157 summary: "Background task finished",
2158 },
2159 {
2160 ...item("wake-command", wakeRun, 2_000_002, 31),
2161 type: "command_execution",
2162 input: "vp test",
2163 output: "ok",
2164 exitCode: 0,
2165 },
2166 { ...user("steer", wakeRun, 2_000_003, 32), inputIntent: "steer" },
2167 ];
2168 const presented = deriveThreadFeedPresentation(
2169 buildThreadFeed(wakeItems.map((entry, index) => projected(entry, index))),
2170 { runId: wakeRun, status: "running", startedAt: at(30), completedAt: null },
2171 new Set(),
2172 new Set(),
2173 at(30),
2174 );
2175 expect(presented.map((entry) => entry.id)).toEqual([
2176 "prompt-1",
2177 `run-fold:${runId}`,
2178 "answer-1",
2179 "local:thread-1:wake",
2180 "work-toggle:work-group:local:thread-1:wake-command",
2181 "steer",
2182 "live-activity-row",
2183 ]);
2184 });
2185});
2186
2069describe("pending user input answers", () => {2187describe("pending user input answers", () => {
2070 it("preserves exact editor text, including a deliberately cleared answer", () => {2188 it("preserves exact editor text, including a deliberately cleared answer", () => {
2071 const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" };2189 const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" };
apps/mobile/src/lib/threadActivity.ts+29-4
...@@ -1060,11 +1060,26 @@ function deriveThreadFeedRunFolds(...@@ -1060,11 +1060,26 @@ function deriveThreadFeedRunFolds(
1060 feed: ReadonlyArray<ThreadFeedEntry>,1060 feed: ReadonlyArray<ThreadFeedEntry>,
1061 latestRun: ThreadFeedLatestRun | null,1061 latestRun: ThreadFeedLatestRun | null,
1062 runlessWorkActive: boolean,1062 runlessWorkActive: boolean,
1063 feedFailedRunIds: ReadonlySet<RunId>,
1063): ReadonlyMap<string, ThreadFeedRunFold> {1064): ReadonlyMap<string, ThreadFeedRunFold> {
1064 const firstAssistantMessageIdByRun = new Map<RunId, string>();1065 const firstAssistantMessageIdByRun = new Map<RunId, string>();
1065 const terminalAssistantMessageIdByRun = new Map<RunId, string>();1066 const terminalAssistantMessageIdByRun = new Map<RunId, string>();
1066 const interruptedRunIds = new Set<RunId>();1067 const interruptedRunIds = new Set<RunId>();
1067 const failedRunIds = failedFeedRunIds(feed, latestRun);1068 const failedRunIds = new Set(feedFailedRunIds);
1069 const promptIndexByRunId = new Map<RunId, number>();
1070 for (const [index, entry] of feed.entries()) {
1071 // A steer joins a run that already started, maybe from a promptless wake.
1072 if (
1073 entry.type !== "message" ||
1074 entry.message.role !== "user" ||
1075 entry.message.inputIntent === "steer" ||
1076 entry.message.inputIntent === "promoted_queued_to_steer"
1077 ) {
1078 continue;
1079 }
1080 const runId = entry.message.runId;
1081 if (runId != null && !promptIndexByRunId.has(runId)) promptIndexByRunId.set(runId, index);
1082 }
1068 const groupsByRunId = new Map<1083 const groupsByRunId = new Map<
1069 RunId,1084 RunId,
1070 { entries: ThreadFeedEntry[]; startBoundary: string | null }1085 { entries: ThreadFeedEntry[]; startBoundary: string | null }
...@@ -1073,14 +1088,16 @@ function deriveThreadFeedRunFolds(...@@ -1073,14 +1088,16 @@ function deriveThreadFeedRunFolds(
1073 // stable key of its own. Decide per prompt, not per thread: a V1 thread's1088 // stable key of its own. Decide per prompt, not per thread: a V1 thread's
1074 // first V2 run must not unfold every imported turn above it.1089 // first V2 run must not unfold every imported turn above it.
1075 let runlessKey: RunId | null = null;1090 let runlessKey: RunId | null = null;
1091 let responseKey: RunId | null = null;
1076 let pendingUserBoundary: string | null = null;1092 let pendingUserBoundary: string | null = null;
1077 for (const entry of feed) {1093 for (const [index, entry] of feed.entries()) {
1078 if (entry.type === "message" && entry.message.role === "user") {1094 if (entry.type === "message" && entry.message.role === "user") {
1079 pendingUserBoundary = entry.message.createdAt;1095 pendingUserBoundary = entry.message.createdAt;
1080 runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null;1096 runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null;
1097 responseKey = entry.message.runId ?? runlessKey;
1081 continue;1098 continue;
1082 }1099 }
1083 const runId =1100 const ownRunId =
1084 entry.type === "message" && entry.message.role === "assistant"1101 entry.type === "message" && entry.message.role === "assistant"
1085 ? (entry.message.runId ?? runlessKey)1102 ? (entry.message.runId ?? runlessKey)
1086 : entry.type === "activity-group" ||1103 : entry.type === "activity-group" ||
...@@ -1088,7 +1105,14 @@ function deriveThreadFeedRunFolds(...@@ -1088,7 +1105,14 @@ function deriveThreadFeedRunFolds(
1088 entry.type === "mcp-app"1105 entry.type === "mcp-app"
1089 ? (entry.runId ?? runlessKey)1106 ? (entry.runId ?? runlessKey)
1090 : null;1107 : null;
1091 if (!runId) continue;1108 if (!ownRunId) continue;
1109 // Resuming a subagent moves its item to the resuming run, but the item
1110 // keeps its place in the turn that started it. It folds with that turn,
1111 // so the later run's fold still opens under its own prompt.
1112 const runId =
1113 responseKey !== null && (promptIndexByRunId.get(ownRunId) ?? -1) > index
1114 ? responseKey
1115 : ownRunId;
1092 let group = groupsByRunId.get(runId);1116 let group = groupsByRunId.get(runId);
1093 if (!group) {1117 if (!group) {
1094 group = { entries: [], startBoundary: pendingUserBoundary };1118 group = { entries: [], startBoundary: pendingUserBoundary };
...@@ -1254,6 +1278,7 @@ export function deriveThreadFeedPresentation(...@@ -1254,6 +1278,7 @@ export function deriveThreadFeedPresentation(
1254 sourceFeed,1278 sourceFeed,
1255 latestRun,1279 latestRun,
1256 isWorking && runlessWorkActive,1280 isWorking && runlessWorkActive,
1281 failedRunIds,
1257 );1282 );
1258 const collapsedEntryIds = new Set<string>();1283 const collapsedEntryIds = new Set<string>();
1259 for (const fold of foldsByAnchorId.values()) {1284 for (const fold of foldsByAnchorId.values()) {
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+110
...@@ -5359,6 +5359,116 @@ describe("live subagents after their parent turn settles", () => {...@@ -5359,6 +5359,116 @@ describe("live subagents after their parent turn settles", () => {
5359 });5359 });
5360});5360});
53615361
5362describe("subagent continued by a later turn", () => {
5363 // Stored shape from #15891: resuming a subagent moves its item to the
5364 // resuming run, but the item keeps the ordinal from the turn that started it.
5365 const threadId = ThreadId.make("parent-thread");
5366 const run1 = RunId.make("run-1");
5367 const run2 = RunId.make("run-2");
5368 const at = (second: number) =>
5369 DateTime.makeUnsafe(`2026-10-09T10:00:${String(second).padStart(2, "0")}Z`);
5370 const base = (id: string, runId: RunId, ordinal: number, second: number) => ({
5371 id: TurnItemId.make(id),
5372 threadId,
5373 runId,
5374 nodeId: null,
5375 providerThreadId: null,
5376 providerTurnId: null,
5377 nativeItemRef: null,
5378 parentItemId: null,
5379 ordinal,
5380 status: "completed" as const,
5381 title: null,
5382 startedAt: at(second),
5383 completedAt: at(second),
5384 updatedAt: at(second),
5385 });
5386 const user = (id: string, runId: RunId, ordinal: number, second: number) => ({
5387 ...base(id, runId, ordinal, second),
5388 type: "user_message" as const,
5389 messageId: MessageId.make(id),
5390 createdBy: "user" as const,
5391 creationSource: "web" as const,
5392 inputIntent: "turn_start" as const,
5393 text: `Prompt ${id}`,
5394 attachments: [],
5395 });
5396 const assistant = (id: string, runId: RunId, ordinal: number, second: number) => ({
5397 ...base(id, runId, ordinal, second),
5398 type: "assistant_message" as const,
5399 messageId: MessageId.make(id),
5400 text: `Answer ${id}`,
5401 streaming: false,
5402 });
5403 const reasoning = (id: string, runId: RunId, ordinal: number, second: number) => ({
5404 ...base(id, runId, ordinal, second),
5405 type: "reasoning" as const,
5406 text: "Thinking",
5407 streaming: false,
5408 });
5409 const items: OrchestrationV2TurnItem[] = [
5410 user("prompt-1", run1, 1_000_001, 0),
5411 reasoning("reasoning-1", run1, 1_000_002, 1),
5412 {
5413 ...base("subagent", run2, 1_000_003, 2),
5414 completedAt: at(40),
5415 updatedAt: at(40),
5416 type: "subagent",
5417 subagentId: NodeId.make("subagent"),
5418 origin: "provider_native",
5419 driver: ProviderDriverKind.make("claudeAgent"),
5420 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
5421 childThreadId: null,
5422 prompt: "Research the bug",
5423 result: "Found it",
5424 },
5425 assistant("answer-1", run1, 1_000_004, 3),
5426 user("prompt-2", run2, 2_000_001, 30),
5427 reasoning("reasoning-2", run2, 2_000_002, 31),
5428 assistant("answer-2", run2, 2_000_003, 32),
5429 {
5430 ...base("send-message", run2, 2_000_004, 33),
5431 type: "dynamic_tool",
5432 toolName: "SendMessage",
5433 input: { to: "subagent" },
5434 output: "Sent",
5435 },
5436 assistant("closing-2", run2, 2_000_005, 41),
5437 ];
5438
5439 it("keeps each turn's fold between its own prompt and answer", () => {
5440 const rows = deriveMessagesTimelineRows({
5441 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
5442 visibleTurnItems: items.map((item) => ({
5443 position: item.ordinal,
5444 visibility: "local",
5445 sourceThreadId: threadId,
5446 sourceItemId: item.id,
5447 item,
5448 })),
5449 optimisticMessages: [],
5450 }),
5451 latestRun: {
5452 runId: run2,
5453 status: "completed",
5454 startedAt: DateTime.formatIso(at(30)),
5455 completedAt: DateTime.formatIso(at(41)),
5456 },
5457 isWorking: false,
5458 turnDiffSummaries: [],
5459 supportsConversationRollback: false,
5460 });
5461 expect(rows.map((row) => row.id)).toEqual([
5462 "prompt-1",
5463 `turn-fold:${run1}`,
5464 "answer-1",
5465 "prompt-2",
5466 `turn-fold:${run2}`,
5467 "closing-2",
5468 ]);
5469 });
5470});
5471
5362describe("shouldCollapseUserMessage", () => {5472describe("shouldCollapseUserMessage", () => {
5363 it("measures a quote chip by its label, not its encoded link", () => {5473 it("measures a quote chip by its label, not its encoded link", () => {
5364 const quote = "A long assistant paragraph that the user quoted. ".repeat(40);5474 const quote = "A long assistant paragraph that the user quoted. ".repeat(40);
apps/web/src/components/chat/MessagesTimeline.logic.ts+24-9
...@@ -942,6 +942,12 @@ function timelineEntryStartsResponse(entry: TimelineEntry): boolean {...@@ -942,6 +942,12 @@ function timelineEntryStartsResponse(entry: TimelineEntry): boolean {
942 );942 );
943}943}
944944
945function responseBoundaryRunId(entry: TimelineEntry): RunId | null {
946 if (entry.kind === "message") return entry.message.runId ?? null;
947 if (entry.kind === "work") return entry.entry.runId ?? null;
948 return null;
949}
950
945/**951/**
946 * A promptless provider restart replaces the native turn without adding a952 * A promptless provider restart replaces the native turn without adding a
947 * user message. Keep every provider turn since the initiating prompt in one953 * user message. Keep every provider turn since the initiating prompt in one
...@@ -1025,7 +1031,12 @@ function deriveTurnFolds(input: {...@@ -1025,7 +1031,12 @@ function deriveTurnFolds(input: {
1025 completeSupersededReplyEntryIds: ReadonlySet<string>;1031 completeSupersededReplyEntryIds: ReadonlySet<string>;
1026}): ReadonlyMap<string, TurnFold> {1032}): ReadonlyMap<string, TurnFold> {
1027 const interruptedRunIds = new Set<RunId>();1033 const interruptedRunIds = new Set<RunId>();
1028 for (const entry of input.timelineEntries) {1034 const promptIndexByRunId = new Map<RunId, number>();
1035 for (const [index, entry] of input.timelineEntries.entries()) {
1036 if (timelineEntryStartsResponse(entry)) {
1037 const runId = responseBoundaryRunId(entry);
1038 if (runId !== null && !promptIndexByRunId.has(runId)) promptIndexByRunId.set(runId, index);
1039 }
1029 if (1040 if (
1030 entry.kind === "event" &&1041 entry.kind === "event" &&
1031 entry.projectedItem.item.runId !== null &&1042 entry.projectedItem.item.runId !== null &&
...@@ -1056,6 +1067,7 @@ function deriveTurnFolds(input: {...@@ -1056,6 +1067,7 @@ function deriveTurnFolds(input: {
1056 // stable key of its own. Decide per prompt, not per thread: a V1 thread's1067 // stable key of its own. Decide per prompt, not per thread: a V1 thread's
1057 // first V2 run must not unfold every imported turn above it.1068 // first V2 run must not unfold every imported turn above it.
1058 let runlessKey: RunId | null = null;1069 let runlessKey: RunId | null = null;
1070 let responseKey: RunId | null = null;
1059 let pendingBoundary: { createdAt: string; anchorEntryId: string } | null = null;1071 let pendingBoundary: { createdAt: string; anchorEntryId: string } | null = null;
1060 for (const [index, entry] of input.timelineEntries.entries()) {1072 for (const [index, entry] of input.timelineEntries.entries()) {
1061 if (timelineEntryStartsResponse(entry)) {1073 if (timelineEntryStartsResponse(entry)) {
...@@ -1063,19 +1075,22 @@ function deriveTurnFolds(input: {...@@ -1063,19 +1075,22 @@ function deriveTurnFolds(input: {
1063 pendingBoundary = nextEntry1075 pendingBoundary = nextEntry
1064 ? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id }1076 ? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id }
1065 : null;1077 : null;
1066 const boundaryRunId =1078 const boundaryRunId = responseBoundaryRunId(entry);
1067 entry.kind === "message"
1068 ? entry.message.runId
1069 : entry.kind === "work"
1070 ? entry.entry.runId
1071 : null;
1072 runlessKey = boundaryRunId == null ? RunId.make(`runless:${entry.id}`) : null;1079 runlessKey = boundaryRunId == null ? RunId.make(`runless:${entry.id}`) : null;
1080 responseKey = boundaryRunId ?? runlessKey;
1073 continue;1081 continue;
1074 }1082 }
1075 const runId = timelineEntryFoldRunId(entry, runlessKey);1083 const ownRunId = timelineEntryFoldRunId(entry, runlessKey);
1076 if (!runId) {1084 if (!ownRunId) {
1077 continue;1085 continue;
1078 }1086 }
1087 // Resuming a subagent moves its item to the resuming run, but the item
1088 // keeps its place in the turn that started it. It folds with that turn,
1089 // so the later run's fold still opens under its own prompt.
1090 const runId =
1091 responseKey !== null && (promptIndexByRunId.get(ownRunId) ?? -1) > index
1092 ? responseKey
1093 : ownRunId;
1079 if (runId === runlessKey && timelineEntryFailedItem(entry) !== null) {1094 if (runId === runlessKey && timelineEntryFailedItem(entry) !== null) {
1080 runlessFailedKeys.add(runId);1095 runlessFailedKeys.add(runId);
1081 }1096 }