| author | |
| committer | |
| log | 9a01524f3faca628699cdf65b43ad020b40b86d8 |
| tree | c17ddfbf42bb7be77cce741408822e6cdf20e29d |
| parent | 4cf8243f855da1abc79f5b09c58065c0fb328c0e |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 2066 | multiSelect: true, |
| 2067 | 2067 | } as const; |
| 2068 | 2068 | |
| 2069 | describe("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 | ||
| 2069 | 2187 | describe("pending user input answers", () => { |
| 2070 | 2188 | it("preserves exact editor text, including a deliberately cleared answer", () => { |
| 2071 | 2189 | const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; |
apps/mobile/src/lib/threadActivity.ts+29-4| ... | ... | @@ -1060,11 +1060,26 @@ function deriveThreadFeedRunFolds( |
| 1060 | 1060 | feed: ReadonlyArray<ThreadFeedEntry>, |
| 1061 | 1061 | latestRun: ThreadFeedLatestRun | null, |
| 1062 | 1062 | runlessWorkActive: boolean, |
| 1063 | feedFailedRunIds: ReadonlySet<RunId>, | |
| 1063 | 1064 | ): ReadonlyMap<string, ThreadFeedRunFold> { |
| 1064 | 1065 | const firstAssistantMessageIdByRun = new Map<RunId, string>(); |
| 1065 | 1066 | const terminalAssistantMessageIdByRun = new Map<RunId, string>(); |
| 1066 | 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 | 1083 | const groupsByRunId = new Map< |
| 1069 | 1084 | RunId, |
| 1070 | 1085 | { entries: ThreadFeedEntry[]; startBoundary: string | null } |
| ... | ... | @@ -1073,14 +1088,16 @@ function deriveThreadFeedRunFolds( |
| 1073 | 1088 | // stable key of its own. Decide per prompt, not per thread: a V1 thread's |
| 1074 | 1089 | // first V2 run must not unfold every imported turn above it. |
| 1075 | 1090 | let runlessKey: RunId | null = null; |
| 1091 | let responseKey: RunId | null = null; | |
| 1076 | 1092 | let pendingUserBoundary: string | null = null; |
| 1077 | for (const entry of feed) { | |
| 1093 | for (const [index, entry] of feed.entries()) { | |
| 1078 | 1094 | if (entry.type === "message" && entry.message.role === "user") { |
| 1079 | 1095 | pendingUserBoundary = entry.message.createdAt; |
| 1080 | 1096 | runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null; |
| 1097 | responseKey = entry.message.runId ?? runlessKey; | |
| 1081 | 1098 | continue; |
| 1082 | 1099 | } |
| 1083 | const runId = | |
| 1100 | const ownRunId = | |
| 1084 | 1101 | entry.type === "message" && entry.message.role === "assistant" |
| 1085 | 1102 | ? (entry.message.runId ?? runlessKey) |
| 1086 | 1103 | : entry.type === "activity-group" || |
| ... | ... | @@ -1088,7 +1105,14 @@ function deriveThreadFeedRunFolds( |
| 1088 | 1105 | entry.type === "mcp-app" |
| 1089 | 1106 | ? (entry.runId ?? runlessKey) |
| 1090 | 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 | 1116 | let group = groupsByRunId.get(runId); |
| 1093 | 1117 | if (!group) { |
| 1094 | 1118 | group = { entries: [], startBoundary: pendingUserBoundary }; |
| ... | ... | @@ -1254,6 +1278,7 @@ export function deriveThreadFeedPresentation( |
| 1254 | 1278 | sourceFeed, |
| 1255 | 1279 | latestRun, |
| 1256 | 1280 | isWorking && runlessWorkActive, |
| 1281 | failedRunIds, | |
| 1257 | 1282 | ); |
| 1258 | 1283 | const collapsedEntryIds = new Set<string>(); |
| 1259 | 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 | 5359 | }); |
| 5360 | 5360 | }); |
| 5361 | 5361 | |
| 5362 | describe("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 | ||
| 5362 | 5472 | describe("shouldCollapseUserMessage", () => { |
| 5363 | 5473 | it("measures a quote chip by its label, not its encoded link", () => { |
| 5364 | 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 | 942 | ); |
| 943 | 943 | } |
| 944 | 944 | |
| 945 | function 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 | 952 | * A promptless provider restart replaces the native turn without adding a |
| 947 | 953 | * user message. Keep every provider turn since the initiating prompt in one |
| ... | ... | @@ -1025,7 +1031,12 @@ function deriveTurnFolds(input: { |
| 1025 | 1031 | completeSupersededReplyEntryIds: ReadonlySet<string>; |
| 1026 | 1032 | }): ReadonlyMap<string, TurnFold> { |
| 1027 | 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 | 1040 | if ( |
| 1030 | 1041 | entry.kind === "event" && |
| 1031 | 1042 | entry.projectedItem.item.runId !== null && |
| ... | ... | @@ -1056,6 +1067,7 @@ function deriveTurnFolds(input: { |
| 1056 | 1067 | // stable key of its own. Decide per prompt, not per thread: a V1 thread's |
| 1057 | 1068 | // first V2 run must not unfold every imported turn above it. |
| 1058 | 1069 | let runlessKey: RunId | null = null; |
| 1070 | let responseKey: RunId | null = null; | |
| 1059 | 1071 | let pendingBoundary: { createdAt: string; anchorEntryId: string } | null = null; |
| 1060 | 1072 | for (const [index, entry] of input.timelineEntries.entries()) { |
| 1061 | 1073 | if (timelineEntryStartsResponse(entry)) { |
| ... | ... | @@ -1063,19 +1075,22 @@ function deriveTurnFolds(input: { |
| 1063 | 1075 | pendingBoundary = nextEntry |
| 1064 | 1076 | ? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id } |
| 1065 | 1077 | : null; |
| 1066 | const boundaryRunId = | |
| 1067 | entry.kind === "message" | |
| 1068 | ? entry.message.runId | |
| 1069 | : entry.kind === "work" | |
| 1070 | ? entry.entry.runId | |
| 1071 | : null; | |
| 1078 | const boundaryRunId = responseBoundaryRunId(entry); | |
| 1072 | 1079 | runlessKey = boundaryRunId == null ? RunId.make(`runless:${entry.id}`) : null; |
| 1080 | responseKey = boundaryRunId ?? runlessKey; | |
| 1073 | 1081 | continue; |
| 1074 | 1082 | } |
| 1075 | const runId = timelineEntryFoldRunId(entry, runlessKey); | |
| 1076 | if (!runId) { | |
| 1083 | const ownRunId = timelineEntryFoldRunId(entry, runlessKey); | |
| 1084 | if (!ownRunId) { | |
| 1077 | 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 | 1094 | if (runId === runlessKey && timelineEntryFailedItem(entry) !== null) { |
| 1080 | 1095 | runlessFailedKeys.add(runId); |
| 1081 | 1096 | } |