diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 32034bdb1559..ea8a2e0de438 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -956,6 +956,94 @@ describe("buildThreadFeed", () => { }); }); + it("folds each run of a provider-native subagent thread like a normal turn", () => { + // A Claude subagent's child thread, as projected: no runs, and a user + // prompt for the launch and for a SendMessage resume. + const runless = (item: T, id: string, ordinal: number) => ({ + ...item, + id: TurnItemId.make(id), + runId: null, + ordinal, + }); + const prompt = (id: string, ordinal: number, at: string) => + runless( + { ...userMessage(at), messageId: MessageId.make(id), creationSource: "provider" as const }, + id, + ordinal, + ); + const answer = (id: string, ordinal: number, at: string) => + runless({ ...assistantMessage(at), messageId: MessageId.make(id) }, id, ordinal); + const { exitCode: _exitCode, ...completedCommand } = command("2026-06-20T00:01:17.000Z"); + const feed = (resumeRunning: boolean) => + buildThreadFeed( + [ + prompt("launch", 1, "2026-06-20T00:00:00.000Z"), + runless(command("2026-06-20T00:00:04.000Z"), "launch-ls", 2), + answer("launch-answer", 3, "2026-06-20T00:00:08.000Z"), + prompt("resume", 4, "2026-06-20T00:01:12.000Z"), + resumeRunning + ? runless( + { ...completedCommand, status: "running", completedAt: null, output: "" }, + "resume-ls", + 5, + ) + : runless(command("2026-06-20T00:01:17.000Z"), "resume-ls", 5), + ...(resumeRunning ? [] : [answer("resume-answer", 6, "2026-06-20T00:01:20.000Z")]), + ].map((item, position) => projected(item, position)), + ); + const shape = (entries: ReadonlyArray) => + entries.map((entry) => + entry.type === "run-fold" + ? `fold:${entry.label}` + : entry.type === "message" + ? `${entry.message.role}:${entry.message.id}` + : entry.type, + ); + + const settled = deriveThreadFeedPresentation(feed(false), null, new Set()); + expect(shape(settled)).toEqual([ + "user:launch", + "fold:Worked for 8.0s", + "assistant:launch-answer", + "user:resume", + "fold:Worked for 8.0s", + "assistant:resume-answer", + ]); + const launchFold = settled.find((entry) => entry.type === "run-fold"); + if (launchFold?.type !== "run-fold") throw new Error("Expected the launch fold"); + expect( + shape(deriveThreadFeedPresentation(feed(false), null, new Set([launchFold.runId]))), + ).toEqual([ + "user:launch", + "fold:Worked for 8.0s", + "work-toggle", + "assistant:launch-answer", + "user:resume", + "fold:Worked for 8.0s", + "assistant:resume-answer", + ]); + + // While the resume runs, only the settled launch folds. + expect( + shape( + deriveThreadFeedPresentation( + feed(true), + null, + new Set(), + new Set(), + "2026-06-20T00:01:12.000Z", + true, + ), + ), + ).toEqual([ + "user:launch", + "fold:Worked for 8.0s", + "assistant:launch-answer", + "user:resume", + "work-toggle", + ]); + }); + it("keeps a provider-native subagent's runless tool call live while it works", () => { const startedAt = "2026-06-20T00:00:01.000Z"; const { exitCode: _exitCode, ...completedCommand } = command(); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index ff9b8731b9b5..653331a9dc35 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -41,11 +41,10 @@ import type { OrchestrationV2RunStatus, OrchestrationV2TurnItem, OrchestrationV2UserMessageInputIntent, - RunId, RunAttemptId, ScheduledTaskId, } from "@t3tools/contracts"; -import { ThreadId } from "@t3tools/contracts"; +import { RunId, ThreadId } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; import { computerUseToolTitle } from "@t3tools/shared/toolActivity"; @@ -885,46 +884,38 @@ export function failedFeedRunIds( return failed; } +/** + * A thread without runs (a provider-native subagent) folds each prompt's + * response like a run; `isWorking` keeps its latest response open. + */ function deriveThreadFeedRunFolds( feed: ReadonlyArray, latestRun: ThreadFeedLatestRun | null, + isWorking: boolean, ): ReadonlyMap { const firstAssistantMessageIdByRun = new Map(); const terminalAssistantMessageIdByRun = new Map(); const interruptedRunIds = new Set(); - for (const entry of feed) { - if (entry.type === "message" && entry.message.role === "assistant" && entry.message.runId) { - if (!firstAssistantMessageIdByRun.has(entry.message.runId)) { - firstAssistantMessageIdByRun.set(entry.message.runId, entry.id); - } - terminalAssistantMessageIdByRun.set(entry.message.runId, entry.id); - } - if ( - entry.type === "activity-group" && - entry.runId !== null && - entry.activities.some( - (activity) => activity.projectedItem.item.type === "run_interrupt_result", - ) - ) { - interruptedRunIds.add(entry.runId); - } - } - + const failedRunIds = failedFeedRunIds(feed, latestRun); const groupsByRunId = new Map< RunId, { entries: ThreadFeedEntry[]; startBoundary: string | null } >(); + // Fold state is keyed by run, so each prompt of a runless thread lends its + // response a stable key of its own. + let runlessKey: RunId | null = null; let pendingUserBoundary: string | null = null; for (const entry of feed) { if (entry.type === "message" && entry.message.role === "user") { pendingUserBoundary = entry.message.createdAt; + runlessKey = latestRun === null ? RunId.make(`runless:${entry.id}`) : null; continue; } const runId = entry.type === "message" && entry.message.role === "assistant" - ? entry.message.runId + ? (entry.message.runId ?? runlessKey) : entry.type === "activity-group" - ? entry.runId + ? (entry.runId ?? runlessKey) : null; if (!runId) continue; let group = groupsByRunId.get(runId); @@ -934,14 +925,33 @@ function deriveThreadFeedRunFolds( groupsByRunId.set(runId, group); } group.entries.push(entry); + if (entry.type === "message") { + if (!firstAssistantMessageIdByRun.has(runId)) { + firstAssistantMessageIdByRun.set(runId, entry.id); + } + terminalAssistantMessageIdByRun.set(runId, entry.id); + } + if (entry.type !== "activity-group") continue; + for (const activity of entry.activities) { + const item = activity.projectedItem.item; + if (item.type === "run_interrupt_result") interruptedRunIds.add(runId); + if ( + runId === runlessKey && + item.type === "error" && + item.status === "failed" && + item.parentItemId === null + ) { + failedRunIds.add(runId); + } + } } const activeRunId = unsettledRunId(latestRun); - const failedRunIds = failedFeedRunIds(feed, latestRun); const foldsByAnchorId = new Map(); for (const [runId, group] of groupsByRunId) { if ( runId === activeRunId || + (isWorking && runId === runlessKey) || interruptedRunIds.has(runId) || failedRunIds.has(runId) || group.entries.some((entry) => entry.type === "message" && entry.message.streaming) @@ -1028,9 +1038,9 @@ export function deriveThreadFeedPresentation( ); const failedRunIds = failedFeedRunIds(sourceFeed, latestRun); const activeTailGroup = sourceFeed.at(-1); - const foldsByAnchorId = deriveThreadFeedRunFolds(sourceFeed, latestRun); const activeRunId = unsettledRunId(latestRun); const isWorking = activeWorkStartedAt !== null && latestRun?.status !== "preparing"; + const foldsByAnchorId = deriveThreadFeedRunFolds(sourceFeed, latestRun, isWorking); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorId.values()) { if (!expandedRunIds.has(fold.runId)) { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index c9e0ac872742..245a745a898b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1904,6 +1904,168 @@ describe("deriveMessagesTimelineRows", () => { }); }); + it("folds each run of a provider-native subagent thread like a normal turn", () => { + // A Claude subagent's child thread, as projected: no runs, one runless + // root turn, and a user prompt for the launch and for a SendMessage resume. + const threadId = ThreadId.make("subagent-child"); + const rootNodeId = NodeId.make("task-root"); + const at = (second: number) => + DateTime.makeUnsafe(new Date(Date.UTC(2026, 8, 25, 22, 51, second)).toISOString()); + const base = (id: string, ordinal: number, second: number, endSecond = second) => ({ + id: TurnItemId.make(id), + threadId, + runId: null, + nodeId: rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed" as const, + title: null, + startedAt: at(second), + completedAt: at(endSecond), + updatedAt: at(endSecond), + }); + const prompt = (id: string, ordinal: number, second: number) => ({ + ...base(id, ordinal, second), + type: "user_message" as const, + messageId: MessageId.make(id), + text: `Prompt ${id}`, + attachments: [], + inputIntent: "turn_start" as const, + createdBy: "agent" as const, + creationSource: "provider" as const, + }); + const answer = (id: string, ordinal: number, second: number) => ({ + ...base(id, ordinal, second), + type: "assistant_message" as const, + messageId: MessageId.make(id), + text: `Answer ${id}`, + streaming: false, + }); + type ResumeState = "running" | "completed" | "failed"; + const items = (resume: ResumeState) => + [ + prompt("launch", 1, 0), + { ...base("launch-ls", 2, 4), type: "command_execution" as const, input: "ls src" }, + { + ...base("launch-thinking", 3, 8), + type: "reasoning" as const, + title: "Thinking", + text: "Not there.", + streaming: false, + }, + answer("launch-answer", 4, 8), + prompt("resume", 5, 72), + { + ...base("resume-ls", 6, 77), + type: "command_execution" as const, + input: "ls src", + status: resume === "running" ? ("running" as const) : ("completed" as const), + completedAt: resume === "running" ? null : at(77), + }, + ...(resume === "failed" + ? [ + { + ...base("resume-error", 7, 80), + type: "error" as const, + status: "failed" as const, + failure: { + class: "provider_error" as const, + message: "Subagent failed", + code: null, + retryable: null, + }, + }, + ] + : []), + ...(resume === "running" ? [] : [answer("resume-answer", 8, 80)]), + ].map((item, position) => ({ + position, + visibility: "local" as const, + sourceThreadId: threadId, + sourceItemId: item.id, + item, + })); + const rows = (input: { + resume: ResumeState; + working: boolean; + expandedRunIds?: ReadonlySet; + }) => + deriveMessagesTimelineRows({ + timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: items(input.resume), + optimisticMessages: [], + }), + latestRun: null, + isWorking: input.working, + runlessWorkActive: input.working, + ...(input.expandedRunIds === undefined ? {} : { expandedRunIds: input.expandedRunIds }), + activeTurnStartedAt: input.working ? DateTime.formatIso(at(72)) : null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + const shape = (timeline: ReadonlyArray) => + timeline.map((row) => + row.kind === "turn-fold" + ? `fold:${row.label}` + : row.kind === "message" + ? `${row.message.role}:${row.message.id}` + : row.kind, + ); + + // Settled: each run folds its work, keeping its prompt and final answer. + const settled = rows({ resume: "completed", working: false }); + expect(shape(settled)).toEqual([ + "user:launch", + "fold:Worked for 8.0s", + "assistant:launch-answer", + "user:resume", + "fold:Worked for 8.0s", + "assistant:resume-answer", + ]); + + // Each fold opens on its own. + const launchFold = settled.find((row) => row.kind === "turn-fold"); + if (launchFold?.kind !== "turn-fold") throw new Error("Expected the launch fold"); + const expanded = rows({ + resume: "completed", + working: false, + expandedRunIds: new Set([launchFold.runId]), + }); + expect(shape(expanded)).toEqual([ + "user:launch", + "fold:Worked for 8.0s", + "work-toggle", + "assistant:launch-answer", + "user:resume", + "fold:Worked for 8.0s", + "assistant:resume-answer", + ]); + + // While the resume runs, only the settled launch folds. + expect(shape(rows({ resume: "running", working: true }))).toEqual([ + "user:launch", + "fold:Worked for 8.0s", + "assistant:launch-answer", + "user:resume", + "working", + "work-live", + ]); + + // A failed run stays open, as on a normal thread. + expect(shape(rows({ resume: "failed", working: false }))).toEqual([ + "user:launch", + "fold:Worked for 8.0s", + "assistant:launch-answer", + "user:resume", + "work", + "work", + "assistant:resume-answer", + ]); + }); + it("shows a provider-native subagent's runless tools as live work while it works", () => { const entries = (commandStatus: "inProgress" | "completed") => [ { @@ -1954,9 +2116,9 @@ describe("deriveMessagesTimelineRows", () => { active: true, }); - // Once the subagent settles, the same entries read as finished history. + // Once the subagent settles, the same entries fold as finished history. const settled = rows({ commandStatus: "completed", working: false }); - expect(settled.map((row) => row.kind)).toEqual(["message", "work"]); + expect(settled.map((row) => row.kind)).toEqual(["message", "turn-fold"]); }); it("does not treat runless entries as live work on a thread with runs", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 29d86b2fe9ea..d079a0501d2c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -39,7 +39,7 @@ import { type WorktreeSetupSnapshot, type OrchestrationV2ProjectedTurnItem, type RunAttemptId, - type RunId, + RunId, } from "@t3tools/contracts"; import type { ThreadRunSummary } from "@t3tools/client-runtime/state/shell"; import { @@ -647,19 +647,20 @@ function deriveUnsettledRunId( return isSettled ? null : latestRun.runId; } -function timelineEntryFoldRunId(entry: TimelineEntry): RunId | null { +/** `runlessKey` stands in for the run of entries that have none. */ +function timelineEntryFoldRunId(entry: TimelineEntry, runlessKey: RunId | null): RunId | null { if (entry.kind === "work" && entry.entry.itemType === "system_notice") return null; if (entry.kind === "message" && entry.message.role === "assistant") { - return entry.message.runId ?? null; + return entry.message.runId ?? runlessKey; } if (entry.kind === "work") { - return entry.entry.runId ?? null; + return entry.entry.runId ?? runlessKey; } if ( entry.kind === "event" && (timelineEntryIsPersistentResourceCard(entry) || entry.projectedItem.item.type === "subagent") ) { - return entry.projectedItem.item.runId; + return entry.projectedItem.item.runId ?? runlessKey; } return null; } @@ -714,6 +715,18 @@ function deriveActiveVisualResponseRunIds(input: { return runIds; } +function timelineEntryFailedItem(entry: TimelineEntry) { + const item = + entry.kind === "event" + ? entry.projectedItem.item + : entry.kind === "work" + ? entry.entry.projectedItem?.item + : null; + return item?.type === "error" && item.status === "failed" && item.parentItemId === null + ? item + : null; +} + function failedTimelineRunIds( entries: ReadonlyArray, latestRun: TimelineLatestRun | null, @@ -721,19 +734,8 @@ function failedTimelineRunIds( const failed = new Set(); if (latestRun?.status === "failed") failed.add(latestRun.runId); for (const entry of entries) { - const item = - entry.kind === "event" - ? entry.projectedItem.item - : entry.kind === "work" - ? entry.entry.projectedItem?.item - : null; - if ( - item?.type === "error" && - item.status === "failed" && - item.parentItemId === null && - item.runId !== null - ) - failed.add(item.runId); + const runId = timelineEntryFailedItem(entry)?.runId; + if (runId) failed.add(runId); } return failed; } @@ -741,13 +743,15 @@ function failedTimelineRunIds( /** * Settled turns fold activity before their terminal assistant message behind * a "Worked for ..." row. Ordinary trailing work joins the fold, while failures - * and work still in progress stay visible. + * and work still in progress stay visible. A thread without runs (a + * provider-native subagent) folds each prompt's response the same way. */ function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; terminalAssistantMessageIds: ReadonlySet; latestRun: TimelineLatestRun | null; unfoldedRunIds: ReadonlySet; + isWorking: boolean; }): ReadonlyMap { const interruptedRunIds = new Set(); for (const entry of input.timelineEntries) { @@ -775,7 +779,11 @@ function deriveTurnFolds(input: { anchorEntryId: string; } const groupsByRunId = new Map(); + const runlessFailedKeys = new Set(); + // Fold state is keyed by run, so each prompt of a runless thread lends its + // response a stable key of its own. + let runlessKey: RunId | null = null; let pendingBoundary: { createdAt: string; anchorEntryId: string } | null = null; for (const [index, entry] of input.timelineEntries.entries()) { if (timelineEntryStartsResponse(entry)) { @@ -783,12 +791,16 @@ function deriveTurnFolds(input: { pendingBoundary = nextEntry ? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id } : null; + runlessKey = input.latestRun === null ? RunId.make(`runless:${entry.id}`) : null; continue; } - const runId = timelineEntryFoldRunId(entry); + const runId = timelineEntryFoldRunId(entry, runlessKey); if (!runId) { continue; } + if (runId === runlessKey && timelineEntryFailedItem(entry) !== null) { + runlessFailedKeys.add(runId); + } let group = groupsByRunId.get(runId); if (!group) { group = { @@ -817,7 +829,12 @@ function deriveTurnFolds(input: { const foldsByAnchorEntryId = new Map(); for (const [runId, group] of groupsByRunId) { - if (input.unfoldedRunIds.has(runId) || interruptedRunIds.has(runId)) { + if ( + input.unfoldedRunIds.has(runId) || + interruptedRunIds.has(runId) || + runlessFailedKeys.has(runId) || + (input.isWorking && runId === runlessKey) + ) { continue; } if (group.hasStreamingMessage) { @@ -1086,6 +1103,7 @@ export function deriveMessagesTimelineRows(input: { terminalAssistantMessageIds, latestRun: input.latestRun ?? null, unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), + isWorking: input.isWorking, }); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) {