| author | |
| committer | |
| log | ec4378614046ab58864ff5e640de2b46f6704505 |
| tree | 8fbb9762890764e2e05db49bcc6f41c6d5f2fa1c |
| parent | f82796a16abb707c1e2c41301ad8d6e3153668c5 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>5 files changed, 197 insertions(+), 5 deletions(-)
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+130| ... | ... | @@ -973,6 +973,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 973 | 973 | status: "superseded" as const, |
| 974 | 974 | attemptOrdinal: 0, |
| 975 | 975 | rootNodeId: "old-root" as never, |
| 976 | completedAt: null, | |
| 976 | 977 | }, |
| 977 | 978 | } |
| 978 | 979 | : {}), |
| ... | ... | @@ -3819,6 +3820,7 @@ describe("v2 run and attempt history", () => { |
| 3819 | 3820 | attemptOrdinal: 1, |
| 3820 | 3821 | rootNodeId: "node-attempt-1" as never, |
| 3821 | 3822 | status: "superseded" as const, |
| 3823 | completedAt: null, | |
| 3822 | 3824 | }; |
| 3823 | 3825 | const activeAttempt = { |
| 3824 | 3826 | id: activeAttemptId, |
| ... | ... | @@ -3826,6 +3828,7 @@ describe("v2 run and attempt history", () => { |
| 3826 | 3828 | attemptOrdinal: 2, |
| 3827 | 3829 | rootNodeId: "node-attempt-2" as never, |
| 3828 | 3830 | status: "running" as const, |
| 3831 | completedAt: null, | |
| 3829 | 3832 | }; |
| 3830 | 3833 | const timelineEntries = [ |
| 3831 | 3834 | { |
| ... | ... | @@ -3957,6 +3960,131 @@ describe("v2 run and attempt history", () => { |
| 3957 | 3960 | "active-assistant-entry", |
| 3958 | 3961 | ]); |
| 3959 | 3962 | }); |
| 3963 | ||
| 3964 | describe("a steer that supersedes an attempt", () => { | |
| 3965 | const runId = RunId.make("run-steered"); | |
| 3966 | const at = (second: number) => `2026-01-01T00:00:${String(second).padStart(2, "0")}Z`; | |
| 3967 | // The steer stamps the superseded attempt at 0:10. | |
| 3968 | const supersededAttempt = { | |
| 3969 | id: RunAttemptId.make("attempt-1"), | |
| 3970 | runId, | |
| 3971 | attemptOrdinal: 1, | |
| 3972 | rootNodeId: NodeId.make("node-attempt-1"), | |
| 3973 | status: "superseded" as const, | |
| 3974 | completedAt: DateTime.makeUnsafe(at(10)), | |
| 3975 | }; | |
| 3976 | const nextAttempt = { | |
| 3977 | id: RunAttemptId.make("attempt-2"), | |
| 3978 | runId, | |
| 3979 | attemptOrdinal: 2, | |
| 3980 | rootNodeId: NodeId.make("node-attempt-2"), | |
| 3981 | status: "completed" as const, | |
| 3982 | completedAt: DateTime.makeUnsafe(at(14)), | |
| 3983 | }; | |
| 3984 | const assistantEntry = ( | |
| 3985 | id: string, | |
| 3986 | attempt: typeof supersededAttempt | typeof nextAttempt, | |
| 3987 | completedSecond: number, | |
| 3988 | ): TimelineEntry => ({ | |
| 3989 | id, | |
| 3990 | kind: "message", | |
| 3991 | createdAt: at(2), | |
| 3992 | attempt, | |
| 3993 | message: { | |
| 3994 | id: MessageId.make(id), | |
| 3995 | role: "assistant", | |
| 3996 | text: `${id} text`, | |
| 3997 | runId, | |
| 3998 | createdAt: at(2), | |
| 3999 | updatedAt: at(completedSecond), | |
| 4000 | streaming: false, | |
| 4001 | }, | |
| 4002 | projectedItem: { | |
| 4003 | item: { | |
| 4004 | type: "assistant_message", | |
| 4005 | status: "completed", | |
| 4006 | completedAt: DateTime.makeUnsafe(at(completedSecond)), | |
| 4007 | }, | |
| 4008 | } as never, | |
| 4009 | }); | |
| 4010 | const rowsFor = ( | |
| 4011 | replyCompletedSecond: number, | |
| 4012 | runStatus: "running" | "completed" = "completed", | |
| 4013 | ) => | |
| 4014 | deriveMessagesTimelineRows({ | |
| 4015 | timelineEntries: [ | |
| 4016 | { | |
| 4017 | id: "initial-user-entry", | |
| 4018 | kind: "message", | |
| 4019 | createdAt: at(0), | |
| 4020 | attempt: supersededAttempt, | |
| 4021 | message: { | |
| 4022 | id: MessageId.make("initial-user"), | |
| 4023 | role: "user", | |
| 4024 | text: "Build it", | |
| 4025 | runId, | |
| 4026 | inputIntent: "turn_start", | |
| 4027 | createdAt: at(0), | |
| 4028 | updatedAt: at(0), | |
| 4029 | streaming: false, | |
| 4030 | }, | |
| 4031 | }, | |
| 4032 | { | |
| 4033 | id: "old-work-entry", | |
| 4034 | kind: "work", | |
| 4035 | createdAt: at(1), | |
| 4036 | attempt: supersededAttempt, | |
| 4037 | entry: { id: "old-work", createdAt: at(1), runId, label: "Read", tone: "tool" }, | |
| 4038 | }, | |
| 4039 | assistantEntry("old-reply-entry", supersededAttempt, replyCompletedSecond), | |
| 4040 | { | |
| 4041 | id: "steer-user-entry", | |
| 4042 | kind: "message", | |
| 4043 | createdAt: at(10), | |
| 4044 | attempt: nextAttempt, | |
| 4045 | message: { | |
| 4046 | id: MessageId.make("steer-user"), | |
| 4047 | role: "user", | |
| 4048 | text: "What are you working on?", | |
| 4049 | runId, | |
| 4050 | inputIntent: "steer", | |
| 4051 | createdAt: at(10), | |
| 4052 | updatedAt: at(10), | |
| 4053 | streaming: false, | |
| 4054 | }, | |
| 4055 | }, | |
| 4056 | assistantEntry("new-reply-entry", nextAttempt, 14), | |
| 4057 | ], | |
| 4058 | latestRun: { | |
| 4059 | runId, | |
| 4060 | status: runStatus, | |
| 4061 | startedAt: at(0), | |
| 4062 | completedAt: runStatus === "completed" ? at(14) : null, | |
| 4063 | }, | |
| 4064 | isWorking: false, | |
| 4065 | turnDiffSummaries: [], | |
| 4066 | supportsConversationRollback: false, | |
| 4067 | }); | |
| 4068 | ||
| 4069 | it.each(["running", "completed"] as const)( | |
| 4070 | "keeps a reply that finished before the steer visible while the run is %s", | |
| 4071 | (runStatus) => { | |
| 4072 | const rows = rowsFor(4, runStatus); | |
| 4073 | expect(rows.some((row) => row.kind === "attempt-fold")).toBe(false); | |
| 4074 | expect(rows.map((row) => row.id)).toContain("old-reply-entry"); | |
| 4075 | }, | |
| 4076 | ); | |
| 4077 | ||
| 4078 | it("folds a reply the steer cut off as partial output", () => { | |
| 4079 | const rows = rowsFor(11, "running"); | |
| 4080 | expect(rows.find((row) => row.kind === "attempt-fold")).toMatchObject({ | |
| 4081 | attemptId: supersededAttempt.id, | |
| 4082 | label: "Superseded attempt", | |
| 4083 | }); | |
| 4084 | expect(rows.map((row) => row.id)).not.toContain("old-reply-entry"); | |
| 4085 | }); | |
| 4086 | }); | |
| 4087 | ||
| 3960 | 4088 | it("hides the interruption request while keeping intervening work and the result", () => { |
| 3961 | 4089 | const runId = "turn-1" as never; |
| 3962 | 4090 | const interruptEvent = (type: "run_interrupt_request" | "run_interrupt_result") => ({ |
| ... | ... | @@ -4962,6 +5090,7 @@ describe("failed turn transcript", () => { |
| 4962 | 5090 | attemptOrdinal: 1, |
| 4963 | 5091 | rootNodeId: NodeId.make("superseded-root"), |
| 4964 | 5092 | status: "superseded" as const, |
| 5093 | completedAt: null, | |
| 4965 | 5094 | }, |
| 4966 | 5095 | })), |
| 4967 | 5096 | latestRun: { |
| ... | ... | @@ -5160,6 +5289,7 @@ describe("live subagents after their parent turn settles", () => { |
| 5160 | 5289 | attemptOrdinal: 1, |
| 5161 | 5290 | rootNodeId: NodeId.make("superseded-root"), |
| 5162 | 5291 | status: "superseded" as const, |
| 5292 | completedAt: null, | |
| 5163 | 5293 | }; |
| 5164 | 5294 | const entries = deriveTimelineEntriesFromVisibleTurnItems({ |
| 5165 | 5295 | visibleTurnItems: ( |
apps/web/src/components/chat/MessagesTimeline.logic.ts+62-2| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; |
| 2 | 2 | export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; |
| 3 | 3 | import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 4 | import * as DateTime from "effect/DateTime"; | |
| 4 | 5 | import * as Equal from "effect/Equal"; |
| 5 | 6 | import { |
| 6 | 7 | assistantCitationLabel, |
| ... | ... | @@ -752,7 +753,52 @@ interface SupersededAttemptFold { |
| 752 | 753 | } |
| 753 | 754 | |
| 754 | 755 | /** |
| 755 | * Groups only provider output owned by an explicitly superseded V2 attempt. | |
| 756 | * Final replies of superseded attempts that finished before the steer replaced | |
| 757 | * them. A steer stamps the attempt's completedAt when it is dispatched, and the | |
| 758 | * provider closes a reply it cut off only after that, so a reply whose item | |
| 759 | * completed earlier was whole. Item status alone cannot tell: an interrupted | |
| 760 | * stream also closes as `completed`. The reply must be the attempt's last | |
| 761 | * output; trailing tool or reasoning work means the attempt was still going. | |
| 762 | */ | |
| 763 | function completeSupersededReplyEntryIds( | |
| 764 | timelineEntries: ReadonlyArray<TimelineEntry>, | |
| 765 | ): ReadonlySet<string> { | |
| 766 | const lastOutputByAttemptId = new Map<RunAttemptId, TimelineEntry>(); | |
| 767 | for (const entry of timelineEntries) { | |
| 768 | if ( | |
| 769 | entry.attempt?.status !== "superseded" || | |
| 770 | (entry.kind === "message" && entry.message.role === "user") || | |
| 771 | (entry.kind === "work" && | |
| 772 | (entry.entry.itemType === "system_notice" || entry.entry.itemType === "notification")) || | |
| 773 | (entry.kind !== "message" && entry.kind !== "work") | |
| 774 | ) { | |
| 775 | continue; | |
| 776 | } | |
| 777 | lastOutputByAttemptId.set(entry.attempt.id, entry); | |
| 778 | } | |
| 779 | const replyEntryIds = new Set<string>(); | |
| 780 | for (const entry of lastOutputByAttemptId.values()) { | |
| 781 | const supersededAt = entry.attempt?.completedAt ?? null; | |
| 782 | const item = entry.kind === "message" ? entry.projectedItem?.item : undefined; | |
| 783 | if ( | |
| 784 | entry.kind === "message" && | |
| 785 | !entry.message.streaming && | |
| 786 | item?.type === "assistant_message" && | |
| 787 | item.status === "completed" && | |
| 788 | item.completedAt !== null && | |
| 789 | supersededAt !== null && | |
| 790 | DateTime.isLessThan(item.completedAt, supersededAt) | |
| 791 | ) { | |
| 792 | replyEntryIds.add(entry.id); | |
| 793 | } | |
| 794 | } | |
| 795 | return replyEntryIds; | |
| 796 | } | |
| 797 | ||
| 798 | /** | |
| 799 | * Groups only provider output owned by an explicitly superseded V2 attempt | |
| 800 | * that the steer cut off. An attempt that had already finished its reply is | |
| 801 | * left in the timeline like any other turn output. | |
| 756 | 802 | * User messages remain visible because they are inputs to the logical run, |
| 757 | 803 | * including the steer message that started the replacement attempt. |
| 758 | 804 | */ |
| ... | ... | @@ -760,11 +806,18 @@ function deriveSupersededAttemptFolds( |
| 760 | 806 | timelineEntries: ReadonlyArray<TimelineEntry>, |
| 761 | 807 | unfoldedRunIds: ReadonlySet<RunId>, |
| 762 | 808 | liveSubagentEntryIds: ReadonlySet<string>, |
| 809 | completeReplyEntryIds: ReadonlySet<string>, | |
| 763 | 810 | ): ReadonlyMap<string, SupersededAttemptFold> { |
| 811 | const completeAttemptIds = new Set( | |
| 812 | timelineEntries.flatMap((entry) => | |
| 813 | entry.attempt !== undefined && completeReplyEntryIds.has(entry.id) ? [entry.attempt.id] : [], | |
| 814 | ), | |
| 815 | ); | |
| 764 | 816 | const entriesByAttemptId = new Map<RunAttemptId, TimelineEntry[]>(); |
| 765 | 817 | for (const entry of timelineEntries) { |
| 766 | 818 | if ( |
| 767 | 819 | entry.attempt?.status !== "superseded" || |
| 820 | completeAttemptIds.has(entry.attempt.id) || | |
| 768 | 821 | unfoldedRunIds.has(entry.attempt.runId) || |
| 769 | 822 | (entry.kind === "message" && entry.message.role === "user") || |
| 770 | 823 | // A published page stays visible, as it does when its turn folds. |
| ... | ... | @@ -966,6 +1019,8 @@ function deriveTurnFolds(input: { |
| 966 | 1019 | /** Keeps the latest runless response open; V2 work must not reopen imported turns. */ |
| 967 | 1020 | runlessWorkActive: boolean; |
| 968 | 1021 | liveSubagentEntryIds: ReadonlySet<string>; |
| 1022 | /** Finished replies a steer superseded; they answer their prompt, so they stay out. */ | |
| 1023 | completeSupersededReplyEntryIds: ReadonlySet<string>; | |
| 969 | 1024 | }): ReadonlyMap<string, TurnFold> { |
| 970 | 1025 | const interruptedRunIds = new Set<RunId>(); |
| 971 | 1026 | for (const entry of input.timelineEntries) { |
| ... | ... | @@ -1082,7 +1137,8 @@ function deriveTurnFolds(input: { |
| 1082 | 1137 | // launching run and stay visible after the surrounding work folds. |
| 1083 | 1138 | if ( |
| 1084 | 1139 | timelineEntryIsPersistentResourceCard(entry) || |
| 1085 | input.liveSubagentEntryIds.has(entry.id) | |
| 1140 | input.liveSubagentEntryIds.has(entry.id) || | |
| 1141 | input.completeSupersededReplyEntryIds.has(entry.id) | |
| 1086 | 1142 | ) { |
| 1087 | 1143 | continue; |
| 1088 | 1144 | } |
| ... | ... | @@ -1320,6 +1376,7 @@ function deriveTimelineTurnFolds( |
| 1320 | 1376 | unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), |
| 1321 | 1377 | runlessWorkActive: input.isWorking && input.runlessWorkActive === true, |
| 1322 | 1378 | liveSubagentEntryIds: liveSubagentCardEntryIds(timelineEntries), |
| 1379 | completeSupersededReplyEntryIds: completeSupersededReplyEntryIds(timelineEntries), | |
| 1323 | 1380 | }); |
| 1324 | 1381 | } |
| 1325 | 1382 | |
| ... | ... | @@ -1365,10 +1422,12 @@ export function deriveMessagesTimelineRows(input: { |
| 1365 | 1422 | const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null); |
| 1366 | 1423 | const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null); |
| 1367 | 1424 | const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries); |
| 1425 | const completeSupersededReplyIds = completeSupersededReplyEntryIds(timelineEntries); | |
| 1368 | 1426 | const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds( |
| 1369 | 1427 | timelineEntries, |
| 1370 | 1428 | failedRunIds, |
| 1371 | 1429 | liveSubagentEntryIds, |
| 1430 | completeSupersededReplyIds, | |
| 1372 | 1431 | ); |
| 1373 | 1432 | const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({ |
| 1374 | 1433 | timelineEntries: timelineEntries, |
| ... | ... | @@ -1383,6 +1442,7 @@ export function deriveMessagesTimelineRows(input: { |
| 1383 | 1442 | unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), |
| 1384 | 1443 | runlessWorkActive, |
| 1385 | 1444 | liveSubagentEntryIds, |
| 1445 | completeSupersededReplyEntryIds: completeSupersededReplyIds, | |
| 1386 | 1446 | }); |
| 1387 | 1447 | const collapsedEntryIds = new Set<string>(); |
| 1388 | 1448 | for (const fold of foldsByAnchorEntryId.values()) { |
apps/web/src/components/chat/MessagesTimeline.test.tsx+3-1| ... | ... | @@ -1471,6 +1471,7 @@ describe("MessagesTimeline", () => { |
| 1471 | 1471 | attemptOrdinal: 1, |
| 1472 | 1472 | rootNodeId: "node-attempt-1" as never, |
| 1473 | 1473 | status: "superseded" as const, |
| 1474 | completedAt: null, | |
| 1474 | 1475 | }; |
| 1475 | 1476 | const activeAttempt = { |
| 1476 | 1477 | id: "attempt-2" as never, |
| ... | ... | @@ -1478,6 +1479,7 @@ describe("MessagesTimeline", () => { |
| 1478 | 1479 | attemptOrdinal: 2, |
| 1479 | 1480 | rootNodeId: "node-attempt-2" as never, |
| 1480 | 1481 | status: "running" as const, |
| 1482 | completedAt: null, | |
| 1481 | 1483 | }; |
| 1482 | 1484 | const markup = renderToStaticMarkup( |
| 1483 | 1485 | <MessagesTimeline |
| ... | ... | @@ -1526,7 +1528,7 @@ describe("MessagesTimeline", () => { |
| 1526 | 1528 | expect(markup).toContain('data-superseded-attempt-id="attempt-1"'); |
| 1527 | 1529 | expect(markup).toContain('aria-expanded="false"'); |
| 1528 | 1530 | expect(markup).toContain("Superseded attempt"); |
| 1529 | expect(markup).toContain("Partial output retained"); | |
| 1531 | expect(markup).toContain("Cut off by a steer"); | |
| 1530 | 1532 | expect(markup).toContain("Current response remains visible"); |
| 1531 | 1533 | expect(markup).not.toContain("Partial response from the old attempt"); |
| 1532 | 1534 | }); |
apps/web/src/components/chat/MessagesTimeline.tsx+1-1| ... | ... | @@ -2744,7 +2744,7 @@ function AttemptFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "at |
| 2744 | 2744 | icon={row.expanded ? ChevronDown : ChevronRight} |
| 2745 | 2745 | /> |
| 2746 | 2746 | <span className="text-xs font-medium text-foreground/80">{row.label}</span> |
| 2747 | <span className="text-2xs text-muted-foreground">Partial output retained</span> | |
| 2747 | <span className="text-2xs text-muted-foreground">Cut off by a steer</span> | |
| 2748 | 2748 | </button> |
| 2749 | 2749 | ); |
| 2750 | 2750 | } |
apps/web/src/session-logic.ts+1-1| ... | ... | @@ -119,7 +119,7 @@ export interface LatestProposedPlanState { |
| 119 | 119 | |
| 120 | 120 | export type TimelineAttempt = Pick< |
| 121 | 121 | OrchestrationV2RunAttempt, |
| 122 | "id" | "runId" | "attemptOrdinal" | "rootNodeId" | "status" | |
| 122 | "id" | "runId" | "attemptOrdinal" | "rootNodeId" | "status" | "completedAt" | |
| 123 | 123 | >; |
| 124 | 124 | |
| 125 | 125 | export type TimelineEntry = ( |