| author | |
| committer | |
| log | 6ecb82dc42c4c721fd2c03c92316ee5361306cdb |
| tree | cc5346a6f5c664c0b670e4a2cf1cb6e2f69e8bfd |
| parent | 521f5b5a5682b18f2d59cf95226fc203cd314240 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>5 files changed, 307 insertions(+), 5 deletions(-)
apps/mobile/src/lib/threadActivity.test.ts+68| ... | ... | @@ -975,6 +975,74 @@ describe("buildThreadFeed", () => { |
| 975 | 975 | ).toBe(true); |
| 976 | 976 | }); |
| 977 | 977 | |
| 978 | it("keeps a settled run's still-running subagents visible while its other work folds", () => { | |
| 979 | const subagent = ( | |
| 980 | id: string, | |
| 981 | updatedAt: string, | |
| 982 | ordinal: number, | |
| 983 | status: OrchestrationV2TurnItem["status"], | |
| 984 | ): OrchestrationV2TurnItem => ({ | |
| 985 | ...base(id, updatedAt, ordinal), | |
| 986 | providerTurnId: ProviderTurnId.make("provider-turn-1"), | |
| 987 | status, | |
| 988 | completedAt: status === "running" ? null : DateTime.makeUnsafe(updatedAt), | |
| 989 | type: "subagent", | |
| 990 | subagentId: NodeId.make(id), | |
| 991 | origin: "app_owned", | |
| 992 | driver: ProviderDriverKind.make("codex"), | |
| 993 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 994 | childThreadId: sourceThreadId, | |
| 995 | prompt: `Inspect ${id}`, | |
| 996 | result: null, | |
| 997 | }); | |
| 998 | const latestRun = { | |
| 999 | runId, | |
| 1000 | status: "completed" as const, | |
| 1001 | startedAt: "2026-06-20T00:00:01.000Z", | |
| 1002 | completedAt: "2026-06-20T00:00:05.000Z", | |
| 1003 | }; | |
| 1004 | const present = (children: ReadonlyArray<OrchestrationV2TurnItem>) => | |
| 1005 | deriveThreadFeedPresentation( | |
| 1006 | buildThreadFeed([ | |
| 1007 | projected(userMessage(), 0), | |
| 1008 | projected(command("2026-06-20T00:00:02.000Z"), 1), | |
| 1009 | ...children.map((child, index) => projected(child, index + 2)), | |
| 1010 | projected(assistantMessage("2026-06-20T00:00:05.000Z"), 5), | |
| 1011 | ]), | |
| 1012 | latestRun, | |
| 1013 | new Set(), | |
| 1014 | ); | |
| 1015 | const visibleSubagentIds = (presented: ReadonlyArray<ThreadFeedEntry>) => | |
| 1016 | presented.flatMap((entry) => | |
| 1017 | entry.type === "activity-group" | |
| 1018 | ? entry.activities.flatMap((activity) => | |
| 1019 | activity.projectedItem.item.type === "subagent" | |
| 1020 | ? [activity.projectedItem.item.id] | |
| 1021 | : [], | |
| 1022 | ) | |
| 1023 | : [], | |
| 1024 | ); | |
| 1025 | ||
| 1026 | const live = present([subagent("item-live", "2026-06-20T00:00:03.000Z", 2, "running")]); | |
| 1027 | expect(live.map((entry) => entry.type)).toEqual([ | |
| 1028 | "message", | |
| 1029 | "run-fold", | |
| 1030 | "activity-group", | |
| 1031 | "message", | |
| 1032 | ]); | |
| 1033 | expect(visibleSubagentIds(live)).toEqual(["item-live"]); | |
| 1034 | ||
| 1035 | // A launch batch stays whole while any member is live. | |
| 1036 | const mixed = present([ | |
| 1037 | subagent("item-done", "2026-06-20T00:00:03.000Z", 2, "completed"), | |
| 1038 | subagent("item-live", "2026-06-20T00:00:04.000Z", 3, "running"), | |
| 1039 | ]); | |
| 1040 | expect(visibleSubagentIds(mixed)).toEqual(["item-done", "item-live"]); | |
| 1041 | ||
| 1042 | const finished = present([subagent("item-done", "2026-06-20T00:00:03.000Z", 2, "completed")]); | |
| 1043 | expect(finished.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]); | |
| 1044 | }); | |
| 1045 | ||
| 978 | 1046 | it("folds settled V2 run work while keeping the terminal assistant message visible", () => { |
| 979 | 1047 | const feed = buildThreadFeed([ |
| 980 | 1048 | projected(userMessage(), 0), |
apps/mobile/src/lib/threadActivity.ts+5-1| ... | ... | @@ -5,6 +5,7 @@ import type { |
| 5 | 5 | } from "@t3tools/client-runtime/state/thread-requests"; |
| 6 | 6 | import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; |
| 7 | 7 | import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; |
| 8 | import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; | |
| 8 | 9 | import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; |
| 9 | 10 | import { |
| 10 | 11 | turnItemHasDetail, |
| ... | ... | @@ -1107,7 +1108,10 @@ function deriveThreadFeedRunFolds( |
| 1107 | 1108 | (activity) => |
| 1108 | 1109 | activity.prominent || |
| 1109 | 1110 | activity.projectedItem.item.type === "notification" || |
| 1110 | activity.projectedItem.item.type === "handoff", | |
| 1111 | activity.projectedItem.item.type === "handoff" || | |
| 1112 | // A child still working outlives its settled launching run; | |
| 1113 | // its group stays visible while any member is live. | |
| 1114 | isLiveSubagentTurnItem(activity.projectedItem.item), | |
| 1111 | 1115 | ) |
| 1112 | 1116 | ), |
| 1113 | 1117 | ) |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+173| ... | ... | @@ -2,10 +2,14 @@ import { ThreadId, type WorktreeSetupSnapshot } from "@t3tools/contracts"; |
| 2 | 2 | import { |
| 3 | 3 | CheckpointRef, |
| 4 | 4 | NodeId, |
| 5 | ProviderDriverKind, | |
| 6 | ProviderInstanceId, | |
| 7 | ProviderTurnId, | |
| 5 | 8 | RunAttemptId, |
| 6 | 9 | TurnItemId, |
| 7 | 10 | RuntimeRequestId, |
| 8 | 11 | type OrchestrationV2ProjectedTurnItem, |
| 12 | type OrchestrationV2TurnItem, | |
| 9 | 13 | } from "@t3tools/contracts"; |
| 10 | 14 | import * as DateTime from "effect/DateTime"; |
| 11 | 15 | import { |
| ... | ... | @@ -4889,3 +4893,172 @@ describe("failed turn transcript", () => { |
| 4889 | 4893 | }, |
| 4890 | 4894 | ); |
| 4891 | 4895 | }); |
| 4896 | ||
| 4897 | describe("live subagents after their parent turn settles", () => { | |
| 4898 | const runId = RunId.make("parent-run"); | |
| 4899 | const threadId = ThreadId.make("parent-thread"); | |
| 4900 | const providerTurnId = ProviderTurnId.make("parent-provider-turn"); | |
| 4901 | const at = (second: number) => | |
| 4902 | DateTime.makeUnsafe(`2026-10-06T10:00:${String(second).padStart(2, "0")}Z`); | |
| 4903 | const base = (id: string, second: number) => ({ | |
| 4904 | id: TurnItemId.make(id), | |
| 4905 | threadId, | |
| 4906 | runId, | |
| 4907 | nodeId: null, | |
| 4908 | providerThreadId: null, | |
| 4909 | providerTurnId, | |
| 4910 | nativeItemRef: null, | |
| 4911 | parentItemId: null, | |
| 4912 | ordinal: second, | |
| 4913 | title: null, | |
| 4914 | startedAt: at(second), | |
| 4915 | completedAt: at(second), | |
| 4916 | updatedAt: at(second), | |
| 4917 | }); | |
| 4918 | const projected = (item: OrchestrationV2TurnItem): OrchestrationV2ProjectedTurnItem => ({ | |
| 4919 | position: item.ordinal, | |
| 4920 | visibility: "local", | |
| 4921 | sourceThreadId: threadId, | |
| 4922 | sourceItemId: item.id, | |
| 4923 | item, | |
| 4924 | }); | |
| 4925 | const child = ( | |
| 4926 | id: string, | |
| 4927 | second: number, | |
| 4928 | status: OrchestrationV2TurnItem["status"], | |
| 4929 | origin: "app_owned" | "provider_native" = "app_owned", | |
| 4930 | ): OrchestrationV2TurnItem => ({ | |
| 4931 | ...base(id, second), | |
| 4932 | status, | |
| 4933 | completedAt: status === "running" ? null : at(second), | |
| 4934 | type: "subagent", | |
| 4935 | subagentId: NodeId.make(id), | |
| 4936 | origin, | |
| 4937 | driver: ProviderDriverKind.make("codex"), | |
| 4938 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 4939 | childThreadId: origin === "app_owned" ? ThreadId.make(`${id}-thread`) : null, | |
| 4940 | prompt: `Inspect ${id}`, | |
| 4941 | result: null, | |
| 4942 | }); | |
| 4943 | const rowsFor = (children: ReadonlyArray<OrchestrationV2TurnItem>) => | |
| 4944 | deriveMessagesTimelineRows({ | |
| 4945 | timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ | |
| 4946 | visibleTurnItems: ( | |
| 4947 | [ | |
| 4948 | { | |
| 4949 | ...base("user", 0), | |
| 4950 | status: "completed", | |
| 4951 | type: "user_message", | |
| 4952 | messageId: MessageId.make("user"), | |
| 4953 | createdBy: "user", | |
| 4954 | creationSource: "web", | |
| 4955 | inputIntent: "turn_start", | |
| 4956 | text: "Delegate the review", | |
| 4957 | attachments: [], | |
| 4958 | }, | |
| 4959 | { | |
| 4960 | ...base("reasoning", 1), | |
| 4961 | status: "completed", | |
| 4962 | type: "reasoning", | |
| 4963 | text: "Two children can split this.", | |
| 4964 | streaming: false, | |
| 4965 | }, | |
| 4966 | { | |
| 4967 | ...base("command", 2), | |
| 4968 | status: "completed", | |
| 4969 | type: "command_execution", | |
| 4970 | input: "pwd", | |
| 4971 | output: "/repo", | |
| 4972 | exitCode: 0, | |
| 4973 | }, | |
| 4974 | ...children, | |
| 4975 | { | |
| 4976 | ...base("assistant", 8), | |
| 4977 | status: "completed", | |
| 4978 | type: "assistant_message", | |
| 4979 | messageId: MessageId.make("assistant"), | |
| 4980 | text: "Both children are running.", | |
| 4981 | streaming: false, | |
| 4982 | }, | |
| 4983 | ] satisfies OrchestrationV2TurnItem[] | |
| 4984 | ).map(projected), | |
| 4985 | optimisticMessages: [], | |
| 4986 | }), | |
| 4987 | latestRun: { | |
| 4988 | runId, | |
| 4989 | status: "completed", | |
| 4990 | startedAt: DateTime.formatIso(at(0)), | |
| 4991 | completedAt: DateTime.formatIso(at(8)), | |
| 4992 | }, | |
| 4993 | isWorking: false, | |
| 4994 | turnDiffSummaries: [], | |
| 4995 | supportsConversationRollback: false, | |
| 4996 | }); | |
| 4997 | ||
| 4998 | it.each(["app_owned", "provider_native"] as const)( | |
| 4999 | "keeps a running %s child visible while the rest of the settled turn folds", | |
| 5000 | (origin) => { | |
| 5001 | const rows = rowsFor([child("child", 3, "running", origin)]); | |
| 5002 | // The reasoning and command still fold; only the live card stays out. | |
| 5003 | expect(rows.map((row) => row.id)).toEqual([ | |
| 5004 | "user", | |
| 5005 | `turn-fold:${runId}`, | |
| 5006 | "child", | |
| 5007 | "assistant", | |
| 5008 | ]); | |
| 5009 | }, | |
| 5010 | ); | |
| 5011 | ||
| 5012 | it.each(["completed", "failed"] as const)("folds a %s child with its settled turn", (status) => { | |
| 5013 | const rows = rowsFor([child("child", 3, status)]); | |
| 5014 | expect(rows.some((row) => row.id === "child")).toBe(false); | |
| 5015 | expect(rows.some((row) => row.kind === "turn-fold")).toBe(true); | |
| 5016 | }); | |
| 5017 | ||
| 5018 | it("keeps a launch batch visible while any member is still running", () => { | |
| 5019 | const rows = rowsFor([child("done", 3, "completed"), child("live", 4, "running")]); | |
| 5020 | expect(rows.map((row) => row.id)).toEqual(["user", `turn-fold:${runId}`, "done", "assistant"]); | |
| 5021 | expect(rows.find((row) => row.id === "done")).toMatchObject({ | |
| 5022 | subagents: [{ item: { id: "done" } }, { item: { id: "live" } }], | |
| 5023 | }); | |
| 5024 | }); | |
| 5025 | ||
| 5026 | it("keeps a running child visible when a steer supersedes its attempt", () => { | |
| 5027 | const attempt = { | |
| 5028 | id: RunAttemptId.make("superseded-attempt"), | |
| 5029 | runId, | |
| 5030 | attemptOrdinal: 1, | |
| 5031 | rootNodeId: NodeId.make("superseded-root"), | |
| 5032 | status: "superseded" as const, | |
| 5033 | }; | |
| 5034 | const entries = deriveTimelineEntriesFromVisibleTurnItems({ | |
| 5035 | visibleTurnItems: ( | |
| 5036 | [ | |
| 5037 | { | |
| 5038 | ...base("command", 2), | |
| 5039 | status: "completed", | |
| 5040 | type: "command_execution", | |
| 5041 | input: "pwd", | |
| 5042 | output: "/repo", | |
| 5043 | exitCode: 0, | |
| 5044 | }, | |
| 5045 | child("child", 3, "running"), | |
| 5046 | ] satisfies OrchestrationV2TurnItem[] | |
| 5047 | ).map(projected), | |
| 5048 | optimisticMessages: [], | |
| 5049 | }).map((entry) => ({ ...entry, attempt })); | |
| 5050 | const rows = deriveMessagesTimelineRows({ | |
| 5051 | timelineEntries: entries, | |
| 5052 | latestRun: { | |
| 5053 | runId, | |
| 5054 | status: "running", | |
| 5055 | startedAt: DateTime.formatIso(at(0)), | |
| 5056 | completedAt: null, | |
| 5057 | }, | |
| 5058 | isWorking: false, | |
| 5059 | turnDiffSummaries: [], | |
| 5060 | supportsConversationRollback: false, | |
| 5061 | }); | |
| 5062 | expect(rows.map((row) => row.id)).toEqual([`attempt-fold:${attempt.id}`, "child"]); | |
| 5063 | }); | |
| 5064 | }); |
apps/web/src/components/chat/MessagesTimeline.logic.ts+51-3| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; |
| 2 | 2 | export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; |
| 3 | import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; | |
| 3 | 4 | import * as Equal from "effect/Equal"; |
| 4 | 5 | import { shallow } from "zustand/vanilla/shallow"; |
| 5 | 6 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; |
| ... | ... | @@ -709,6 +710,7 @@ interface SupersededAttemptFold { |
| 709 | 710 | function deriveSupersededAttemptFolds( |
| 710 | 711 | timelineEntries: ReadonlyArray<TimelineEntry>, |
| 711 | 712 | unfoldedRunIds: ReadonlySet<RunId>, |
| 713 | liveSubagentEntryIds: ReadonlySet<string>, | |
| 712 | 714 | ): ReadonlyMap<string, SupersededAttemptFold> { |
| 713 | 715 | const entriesByAttemptId = new Map<RunAttemptId, TimelineEntry[]>(); |
| 714 | 716 | for (const entry of timelineEntries) { |
| ... | ... | @@ -719,6 +721,8 @@ function deriveSupersededAttemptFolds( |
| 719 | 721 | // A published page stays visible, as it does when its turn folds. |
| 720 | 722 | entry.kind === "html-render" || |
| 721 | 723 | timelineEntryIsPersistentResourceCard(entry) || |
| 724 | // A steer supersedes the attempt but leaves its children running. | |
| 725 | liveSubagentEntryIds.has(entry.id) || | |
| 722 | 726 | (entry.kind === "work" && entry.entry.itemType === "system_notice") |
| 723 | 727 | ) { |
| 724 | 728 | continue; |
| ... | ... | @@ -767,6 +771,43 @@ function deriveUnsettledRunId( |
| 767 | 771 | return isSettled ? null : latestRun.runId; |
| 768 | 772 | } |
| 769 | 773 | |
| 774 | /** | |
| 775 | * Subagent cards that stay out of their turn's folds. A child can keep working | |
| 776 | * after its launching turn settles (or its attempt is superseded), and the | |
| 777 | * waiting footer counts it, so its card stays visible until the child ends. | |
| 778 | * Adjacent cards from one provider turn render as a single grouped row; the | |
| 779 | * whole group stays visible while any member is live, so a launch batch never | |
| 780 | * shows half of its children. | |
| 781 | */ | |
| 782 | function liveSubagentCardEntryIds(entries: ReadonlyArray<TimelineEntry>): ReadonlySet<string> { | |
| 783 | const visible = new Set<string>(); | |
| 784 | let batch: Array<Extract<TimelineEntry, { kind: "event" }>> = []; | |
| 785 | const flush = () => { | |
| 786 | if (batch.length === 0) return; | |
| 787 | if (batch.some((entry) => isLiveSubagentTurnItem(entry.projectedItem.item))) { | |
| 788 | for (const entry of batch) visible.add(entry.id); | |
| 789 | } | |
| 790 | batch = []; | |
| 791 | }; | |
| 792 | for (const entry of entries) { | |
| 793 | if (entry.kind !== "event" || entry.projectedItem.item.type !== "subagent") { | |
| 794 | flush(); | |
| 795 | continue; | |
| 796 | } | |
| 797 | const item = entry.projectedItem.item; | |
| 798 | const previous = batch.at(-1)?.projectedItem.item; | |
| 799 | if ( | |
| 800 | previous !== undefined && | |
| 801 | (previous.runId !== item.runId || previous.providerTurnId !== item.providerTurnId) | |
| 802 | ) { | |
| 803 | flush(); | |
| 804 | } | |
| 805 | batch.push(entry); | |
| 806 | } | |
| 807 | flush(); | |
| 808 | return visible; | |
| 809 | } | |
| 810 | ||
| 770 | 811 | /** `runlessKey` stands in for the run of entries that have none. */ |
| 771 | 812 | function timelineEntryFoldRunId(entry: TimelineEntry, runlessKey: RunId | null): RunId | null { |
| 772 | 813 | if (entry.kind === "work" && entry.entry.itemType === "system_notice") return null; |
| ... | ... | @@ -874,6 +915,7 @@ function deriveTurnFolds(input: { |
| 874 | 915 | unfoldedRunIds: ReadonlySet<RunId>; |
| 875 | 916 | /** Keeps the latest runless response open; V2 work must not reopen imported turns. */ |
| 876 | 917 | runlessWorkActive: boolean; |
| 918 | liveSubagentEntryIds: ReadonlySet<string>; | |
| 877 | 919 | }): ReadonlyMap<string, TurnFold> { |
| 878 | 920 | const interruptedRunIds = new Set<RunId>(); |
| 879 | 921 | for (const entry of input.timelineEntries) { |
| ... | ... | @@ -986,9 +1028,12 @@ function deriveTurnFolds(input: { |
| 986 | 1028 | if (!isCompaction && index > terminalEntryIndex && !isFoldableTrailingActivity) { |
| 987 | 1029 | continue; |
| 988 | 1030 | } |
| 989 | // Linked resources can outlive their launching run and stay visible | |
| 990 | // after the surrounding work folds. | |
| 991 | if (timelineEntryIsPersistentResourceCard(entry)) { | |
| 1031 | // Linked resources and still-working children can outlive their | |
| 1032 | // launching run and stay visible after the surrounding work folds. | |
| 1033 | if ( | |
| 1034 | timelineEntryIsPersistentResourceCard(entry) || | |
| 1035 | input.liveSubagentEntryIds.has(entry.id) | |
| 1036 | ) { | |
| 992 | 1037 | continue; |
| 993 | 1038 | } |
| 994 | 1039 | if (entry.kind === "work" && entry.entry.itemType === "notification") continue; |
| ... | ... | @@ -1242,9 +1287,11 @@ export function deriveMessagesTimelineRows(input: { |
| 1242 | 1287 | const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(timelineEntries); |
| 1243 | 1288 | const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null); |
| 1244 | 1289 | const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null); |
| 1290 | const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries); | |
| 1245 | 1291 | const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds( |
| 1246 | 1292 | timelineEntries, |
| 1247 | 1293 | failedRunIds, |
| 1294 | liveSubagentEntryIds, | |
| 1248 | 1295 | ); |
| 1249 | 1296 | const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({ |
| 1250 | 1297 | timelineEntries: timelineEntries, |
| ... | ... | @@ -1258,6 +1305,7 @@ export function deriveMessagesTimelineRows(input: { |
| 1258 | 1305 | latestRun: input.latestRun ?? null, |
| 1259 | 1306 | unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), |
| 1260 | 1307 | runlessWorkActive, |
| 1308 | liveSubagentEntryIds, | |
| 1261 | 1309 | }); |
| 1262 | 1310 | const collapsedEntryIds = new Set<string>(); |
| 1263 | 1311 | for (const fold of foldsByAnchorEntryId.values()) { |
packages/client-runtime/src/state/subagentRuntime.ts+10-1| ... | ... | @@ -3,7 +3,7 @@ |
| 3 | 3 | * web agent rows render. |
| 4 | 4 | */ |
| 5 | 5 | import * as DateTime from "effect/DateTime"; |
| 6 | import type { OrchestrationV2Subagent } from "@t3tools/contracts"; | |
| 6 | import type { OrchestrationV2Subagent, OrchestrationV2TurnItem } from "@t3tools/contracts"; | |
| 7 | 7 | import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; |
| 8 | 8 | |
| 9 | 9 | export type RuntimeSubagentStatus = |
| ... | ... | @@ -91,6 +91,15 @@ export function isActiveSubagentStatus(status: RuntimeSubagentStatus): boolean { |
| 91 | 91 | return isOrchestrationV2WorkActive(status); |
| 92 | 92 | } |
| 93 | 93 | |
| 94 | /** | |
| 95 | * A subagent card whose child is still working. The card outlives its | |
| 96 | * launching turn: once that turn settles, the waiting footer counts the child, | |
| 97 | * so the card stays out of the turn's fold until the child finishes. | |
| 98 | */ | |
| 99 | export function isLiveSubagentTurnItem(item: OrchestrationV2TurnItem): boolean { | |
| 100 | return item.type === "subagent" && isOrchestrationV2WorkActive(item.status); | |
| 101 | } | |
| 102 | ||
| 94 | 103 | /** |
| 95 | 104 | * Projects orchestration-v2 subagent entities into the runtime shape the web |
| 96 | 105 | * agent rows render. |