authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-10 12:31:06-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 12:31:06-07:00
logec4378614046ab58864ff5e640de2b46f6704505
tree8fbb9762890764e2e05db49bcc6f41c6d5f2fa1c
parentf82796a16abb707c1e2c41301ad8d6e3153668c5
signature Signed by PGP key B5690EEEBB952194

fix(web): a finished reply replaced by a steer is no longer labeled partial (#17761)

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,6 +973,7 @@ describe("deriveMessagesTimelineRows", () => {
973 status: "superseded" as const,973 status: "superseded" as const,
974 attemptOrdinal: 0,974 attemptOrdinal: 0,
975 rootNodeId: "old-root" as never,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,6 +3820,7 @@ describe("v2 run and attempt history", () => {
3819 attemptOrdinal: 1,3820 attemptOrdinal: 1,
3820 rootNodeId: "node-attempt-1" as never,3821 rootNodeId: "node-attempt-1" as never,
3821 status: "superseded" as const,3822 status: "superseded" as const,
3823 completedAt: null,
3822 };3824 };
3823 const activeAttempt = {3825 const activeAttempt = {
3824 id: activeAttemptId,3826 id: activeAttemptId,
...@@ -3826,6 +3828,7 @@ describe("v2 run and attempt history", () => {...@@ -3826,6 +3828,7 @@ describe("v2 run and attempt history", () => {
3826 attemptOrdinal: 2,3828 attemptOrdinal: 2,
3827 rootNodeId: "node-attempt-2" as never,3829 rootNodeId: "node-attempt-2" as never,
3828 status: "running" as const,3830 status: "running" as const,
3831 completedAt: null,
3829 };3832 };
3830 const timelineEntries = [3833 const timelineEntries = [
3831 {3834 {
...@@ -3957,6 +3960,131 @@ describe("v2 run and attempt history", () => {...@@ -3957,6 +3960,131 @@ describe("v2 run and attempt history", () => {
3957 "active-assistant-entry",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 it("hides the interruption request while keeping intervening work and the result", () => {4088 it("hides the interruption request while keeping intervening work and the result", () => {
3961 const runId = "turn-1" as never;4089 const runId = "turn-1" as never;
3962 const interruptEvent = (type: "run_interrupt_request" | "run_interrupt_result") => ({4090 const interruptEvent = (type: "run_interrupt_request" | "run_interrupt_result") => ({
...@@ -4962,6 +5090,7 @@ describe("failed turn transcript", () => {...@@ -4962,6 +5090,7 @@ describe("failed turn transcript", () => {
4962 attemptOrdinal: 1,5090 attemptOrdinal: 1,
4963 rootNodeId: NodeId.make("superseded-root"),5091 rootNodeId: NodeId.make("superseded-root"),
4964 status: "superseded" as const,5092 status: "superseded" as const,
5093 completedAt: null,
4965 },5094 },
4966 })),5095 })),
4967 latestRun: {5096 latestRun: {
...@@ -5160,6 +5289,7 @@ describe("live subagents after their parent turn settles", () => {...@@ -5160,6 +5289,7 @@ describe("live subagents after their parent turn settles", () => {
5160 attemptOrdinal: 1,5289 attemptOrdinal: 1,
5161 rootNodeId: NodeId.make("superseded-root"),5290 rootNodeId: NodeId.make("superseded-root"),
5162 status: "superseded" as const,5291 status: "superseded" as const,
5292 completedAt: null,
5163 };5293 };
5164 const entries = deriveTimelineEntriesFromVisibleTurnItems({5294 const entries = deriveTimelineEntriesFromVisibleTurnItems({
5165 visibleTurnItems: (5295 visibleTurnItems: (
apps/web/src/components/chat/MessagesTimeline.logic.ts+62-2
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";1import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
2export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";2export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
3import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime";3import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime";
4import * as DateTime from "effect/DateTime";
4import * as Equal from "effect/Equal";5import * as Equal from "effect/Equal";
5import {6import {
6 assistantCitationLabel,7 assistantCitationLabel,
...@@ -752,7 +753,52 @@ interface SupersededAttemptFold {...@@ -752,7 +753,52 @@ interface SupersededAttemptFold {
752}753}
753754
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 */
763function 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 * User messages remain visible because they are inputs to the logical run,802 * User messages remain visible because they are inputs to the logical run,
757 * including the steer message that started the replacement attempt.803 * including the steer message that started the replacement attempt.
758 */804 */
...@@ -760,11 +806,18 @@ function deriveSupersededAttemptFolds(...@@ -760,11 +806,18 @@ function deriveSupersededAttemptFolds(
760 timelineEntries: ReadonlyArray<TimelineEntry>,806 timelineEntries: ReadonlyArray<TimelineEntry>,
761 unfoldedRunIds: ReadonlySet<RunId>,807 unfoldedRunIds: ReadonlySet<RunId>,
762 liveSubagentEntryIds: ReadonlySet<string>,808 liveSubagentEntryIds: ReadonlySet<string>,
809 completeReplyEntryIds: ReadonlySet<string>,
763): ReadonlyMap<string, SupersededAttemptFold> {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 const entriesByAttemptId = new Map<RunAttemptId, TimelineEntry[]>();816 const entriesByAttemptId = new Map<RunAttemptId, TimelineEntry[]>();
765 for (const entry of timelineEntries) {817 for (const entry of timelineEntries) {
766 if (818 if (
767 entry.attempt?.status !== "superseded" ||819 entry.attempt?.status !== "superseded" ||
820 completeAttemptIds.has(entry.attempt.id) ||
768 unfoldedRunIds.has(entry.attempt.runId) ||821 unfoldedRunIds.has(entry.attempt.runId) ||
769 (entry.kind === "message" && entry.message.role === "user") ||822 (entry.kind === "message" && entry.message.role === "user") ||
770 // A published page stays visible, as it does when its turn folds.823 // A published page stays visible, as it does when its turn folds.
...@@ -966,6 +1019,8 @@ function deriveTurnFolds(input: {...@@ -966,6 +1019,8 @@ function deriveTurnFolds(input: {
966 /** Keeps the latest runless response open; V2 work must not reopen imported turns. */1019 /** Keeps the latest runless response open; V2 work must not reopen imported turns. */
967 runlessWorkActive: boolean;1020 runlessWorkActive: boolean;
968 liveSubagentEntryIds: ReadonlySet<string>;1021 liveSubagentEntryIds: ReadonlySet<string>;
1022 /** Finished replies a steer superseded; they answer their prompt, so they stay out. */
1023 completeSupersededReplyEntryIds: ReadonlySet<string>;
969}): ReadonlyMap<string, TurnFold> {1024}): ReadonlyMap<string, TurnFold> {
970 const interruptedRunIds = new Set<RunId>();1025 const interruptedRunIds = new Set<RunId>();
971 for (const entry of input.timelineEntries) {1026 for (const entry of input.timelineEntries) {
...@@ -1082,7 +1137,8 @@ function deriveTurnFolds(input: {...@@ -1082,7 +1137,8 @@ function deriveTurnFolds(input: {
1082 // launching run and stay visible after the surrounding work folds.1137 // launching run and stay visible after the surrounding work folds.
1083 if (1138 if (
1084 timelineEntryIsPersistentResourceCard(entry) ||1139 timelineEntryIsPersistentResourceCard(entry) ||
1085 input.liveSubagentEntryIds.has(entry.id)1140 input.liveSubagentEntryIds.has(entry.id) ||
1141 input.completeSupersededReplyEntryIds.has(entry.id)
1086 ) {1142 ) {
1087 continue;1143 continue;
1088 }1144 }
...@@ -1320,6 +1376,7 @@ function deriveTimelineTurnFolds(...@@ -1320,6 +1376,7 @@ function deriveTimelineTurnFolds(
1320 unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]),1376 unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]),
1321 runlessWorkActive: input.isWorking && input.runlessWorkActive === true,1377 runlessWorkActive: input.isWorking && input.runlessWorkActive === true,
1322 liveSubagentEntryIds: liveSubagentCardEntryIds(timelineEntries),1378 liveSubagentEntryIds: liveSubagentCardEntryIds(timelineEntries),
1379 completeSupersededReplyEntryIds: completeSupersededReplyEntryIds(timelineEntries),
1323 });1380 });
1324}1381}
13251382
...@@ -1365,10 +1422,12 @@ export function deriveMessagesTimelineRows(input: {...@@ -1365,10 +1422,12 @@ export function deriveMessagesTimelineRows(input: {
1365 const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null);1422 const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null);
1366 const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null);1423 const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null);
1367 const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries);1424 const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries);
1425 const completeSupersededReplyIds = completeSupersededReplyEntryIds(timelineEntries);
1368 const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds(1426 const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds(
1369 timelineEntries,1427 timelineEntries,
1370 failedRunIds,1428 failedRunIds,
1371 liveSubagentEntryIds,1429 liveSubagentEntryIds,
1430 completeSupersededReplyIds,
1372 );1431 );
1373 const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({1432 const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({
1374 timelineEntries: timelineEntries,1433 timelineEntries: timelineEntries,
...@@ -1383,6 +1442,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -1383,6 +1442,7 @@ export function deriveMessagesTimelineRows(input: {
1383 unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]),1442 unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]),
1384 runlessWorkActive,1443 runlessWorkActive,
1385 liveSubagentEntryIds,1444 liveSubagentEntryIds,
1445 completeSupersededReplyEntryIds: completeSupersededReplyIds,
1386 });1446 });
1387 const collapsedEntryIds = new Set<string>();1447 const collapsedEntryIds = new Set<string>();
1388 for (const fold of foldsByAnchorEntryId.values()) {1448 for (const fold of foldsByAnchorEntryId.values()) {
apps/web/src/components/chat/MessagesTimeline.test.tsx+3-1
...@@ -1471,6 +1471,7 @@ describe("MessagesTimeline", () => {...@@ -1471,6 +1471,7 @@ describe("MessagesTimeline", () => {
1471 attemptOrdinal: 1,1471 attemptOrdinal: 1,
1472 rootNodeId: "node-attempt-1" as never,1472 rootNodeId: "node-attempt-1" as never,
1473 status: "superseded" as const,1473 status: "superseded" as const,
1474 completedAt: null,
1474 };1475 };
1475 const activeAttempt = {1476 const activeAttempt = {
1476 id: "attempt-2" as never,1477 id: "attempt-2" as never,
...@@ -1478,6 +1479,7 @@ describe("MessagesTimeline", () => {...@@ -1478,6 +1479,7 @@ describe("MessagesTimeline", () => {
1478 attemptOrdinal: 2,1479 attemptOrdinal: 2,
1479 rootNodeId: "node-attempt-2" as never,1480 rootNodeId: "node-attempt-2" as never,
1480 status: "running" as const,1481 status: "running" as const,
1482 completedAt: null,
1481 };1483 };
1482 const markup = renderToStaticMarkup(1484 const markup = renderToStaticMarkup(
1483 <MessagesTimeline1485 <MessagesTimeline
...@@ -1526,7 +1528,7 @@ describe("MessagesTimeline", () => {...@@ -1526,7 +1528,7 @@ describe("MessagesTimeline", () => {
1526 expect(markup).toContain('data-superseded-attempt-id="attempt-1"');1528 expect(markup).toContain('data-superseded-attempt-id="attempt-1"');
1527 expect(markup).toContain('aria-expanded="false"');1529 expect(markup).toContain('aria-expanded="false"');
1528 expect(markup).toContain("Superseded attempt");1530 expect(markup).toContain("Superseded attempt");
1529 expect(markup).toContain("Partial output retained");1531 expect(markup).toContain("Cut off by a steer");
1530 expect(markup).toContain("Current response remains visible");1532 expect(markup).toContain("Current response remains visible");
1531 expect(markup).not.toContain("Partial response from the old attempt");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,7 +2744,7 @@ function AttemptFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "at
2744 icon={row.expanded ? ChevronDown : ChevronRight}2744 icon={row.expanded ? ChevronDown : ChevronRight}
2745 />2745 />
2746 <span className="text-xs font-medium text-foreground/80">{row.label}</span>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 </button>2748 </button>
2749 );2749 );
2750}2750}
apps/web/src/session-logic.ts+1-1
...@@ -119,7 +119,7 @@ export interface LatestProposedPlanState {...@@ -119,7 +119,7 @@ export interface LatestProposedPlanState {
119119
120export type TimelineAttempt = Pick<120export type TimelineAttempt = Pick<
121 OrchestrationV2RunAttempt,121 OrchestrationV2RunAttempt,
122 "id" | "runId" | "attemptOrdinal" | "rootNodeId" | "status"122 "id" | "runId" | "attemptOrdinal" | "rootNodeId" | "status" | "completedAt"
123>;123>;
124124
125export type TimelineEntry = (125export type TimelineEntry = (