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", () => {
973973 status: "superseded" as const,
974974 attemptOrdinal: 0,
975975 rootNodeId: "old-root" as never,
976 completedAt: null,
976977 },
977978 }
978979 : {}),
......@@ -3819,6 +3820,7 @@ describe("v2 run and attempt history", () => {
38193820 attemptOrdinal: 1,
38203821 rootNodeId: "node-attempt-1" as never,
38213822 status: "superseded" as const,
3823 completedAt: null,
38223824 };
38233825 const activeAttempt = {
38243826 id: activeAttemptId,
......@@ -3826,6 +3828,7 @@ describe("v2 run and attempt history", () => {
38263828 attemptOrdinal: 2,
38273829 rootNodeId: "node-attempt-2" as never,
38283830 status: "running" as const,
3831 completedAt: null,
38293832 };
38303833 const timelineEntries = [
38313834 {
......@@ -3957,6 +3960,131 @@ describe("v2 run and attempt history", () => {
39573960 "active-assistant-entry",
39583961 ]);
39593962 });
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
39604088 it("hides the interruption request while keeping intervening work and the result", () => {
39614089 const runId = "turn-1" as never;
39624090 const interruptEvent = (type: "run_interrupt_request" | "run_interrupt_result") => ({
......@@ -4962,6 +5090,7 @@ describe("failed turn transcript", () => {
49625090 attemptOrdinal: 1,
49635091 rootNodeId: NodeId.make("superseded-root"),
49645092 status: "superseded" as const,
5093 completedAt: null,
49655094 },
49665095 })),
49675096 latestRun: {
......@@ -5160,6 +5289,7 @@ describe("live subagents after their parent turn settles", () => {
51605289 attemptOrdinal: 1,
51615290 rootNodeId: NodeId.make("superseded-root"),
51625291 status: "superseded" as const,
5292 completedAt: null,
51635293 };
51645294 const entries = deriveTimelineEntriesFromVisibleTurnItems({
51655295 visibleTurnItems: (
apps/web/src/components/chat/MessagesTimeline.logic.ts+62-2
......@@ -1,6 +1,7 @@
11import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
22export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
33import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime";
4import * as DateTime from "effect/DateTime";
45import * as Equal from "effect/Equal";
56import {
67 assistantCitationLabel,
......@@ -752,7 +753,52 @@ interface SupersededAttemptFold {
752753}
753754
754755/**
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.
756802 * User messages remain visible because they are inputs to the logical run,
757803 * including the steer message that started the replacement attempt.
758804 */
......@@ -760,11 +806,18 @@ function deriveSupersededAttemptFolds(
760806 timelineEntries: ReadonlyArray<TimelineEntry>,
761807 unfoldedRunIds: ReadonlySet<RunId>,
762808 liveSubagentEntryIds: ReadonlySet<string>,
809 completeReplyEntryIds: ReadonlySet<string>,
763810): 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 );
764816 const entriesByAttemptId = new Map<RunAttemptId, TimelineEntry[]>();
765817 for (const entry of timelineEntries) {
766818 if (
767819 entry.attempt?.status !== "superseded" ||
820 completeAttemptIds.has(entry.attempt.id) ||
768821 unfoldedRunIds.has(entry.attempt.runId) ||
769822 (entry.kind === "message" && entry.message.role === "user") ||
770823 // A published page stays visible, as it does when its turn folds.
......@@ -966,6 +1019,8 @@ function deriveTurnFolds(input: {
9661019 /** Keeps the latest runless response open; V2 work must not reopen imported turns. */
9671020 runlessWorkActive: boolean;
9681021 liveSubagentEntryIds: ReadonlySet<string>;
1022 /** Finished replies a steer superseded; they answer their prompt, so they stay out. */
1023 completeSupersededReplyEntryIds: ReadonlySet<string>;
9691024}): ReadonlyMap<string, TurnFold> {
9701025 const interruptedRunIds = new Set<RunId>();
9711026 for (const entry of input.timelineEntries) {
......@@ -1082,7 +1137,8 @@ function deriveTurnFolds(input: {
10821137 // launching run and stay visible after the surrounding work folds.
10831138 if (
10841139 timelineEntryIsPersistentResourceCard(entry) ||
1085 input.liveSubagentEntryIds.has(entry.id)
1140 input.liveSubagentEntryIds.has(entry.id) ||
1141 input.completeSupersededReplyEntryIds.has(entry.id)
10861142 ) {
10871143 continue;
10881144 }
......@@ -1320,6 +1376,7 @@ function deriveTimelineTurnFolds(
13201376 unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]),
13211377 runlessWorkActive: input.isWorking && input.runlessWorkActive === true,
13221378 liveSubagentEntryIds: liveSubagentCardEntryIds(timelineEntries),
1379 completeSupersededReplyEntryIds: completeSupersededReplyEntryIds(timelineEntries),
13231380 });
13241381}
13251382
......@@ -1365,10 +1422,12 @@ export function deriveMessagesTimelineRows(input: {
13651422 const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null);
13661423 const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null);
13671424 const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries);
1425 const completeSupersededReplyIds = completeSupersededReplyEntryIds(timelineEntries);
13681426 const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds(
13691427 timelineEntries,
13701428 failedRunIds,
13711429 liveSubagentEntryIds,
1430 completeSupersededReplyIds,
13721431 );
13731432 const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({
13741433 timelineEntries: timelineEntries,
......@@ -1383,6 +1442,7 @@ export function deriveMessagesTimelineRows(input: {
13831442 unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]),
13841443 runlessWorkActive,
13851444 liveSubagentEntryIds,
1445 completeSupersededReplyEntryIds: completeSupersededReplyIds,
13861446 });
13871447 const collapsedEntryIds = new Set<string>();
13881448 for (const fold of foldsByAnchorEntryId.values()) {
apps/web/src/components/chat/MessagesTimeline.test.tsx+3-1
......@@ -1471,6 +1471,7 @@ describe("MessagesTimeline", () => {
14711471 attemptOrdinal: 1,
14721472 rootNodeId: "node-attempt-1" as never,
14731473 status: "superseded" as const,
1474 completedAt: null,
14741475 };
14751476 const activeAttempt = {
14761477 id: "attempt-2" as never,
......@@ -1478,6 +1479,7 @@ describe("MessagesTimeline", () => {
14781479 attemptOrdinal: 2,
14791480 rootNodeId: "node-attempt-2" as never,
14801481 status: "running" as const,
1482 completedAt: null,
14811483 };
14821484 const markup = renderToStaticMarkup(
14831485 <MessagesTimeline
......@@ -1526,7 +1528,7 @@ describe("MessagesTimeline", () => {
15261528 expect(markup).toContain('data-superseded-attempt-id="attempt-1"');
15271529 expect(markup).toContain('aria-expanded="false"');
15281530 expect(markup).toContain("Superseded attempt");
1529 expect(markup).toContain("Partial output retained");
1531 expect(markup).toContain("Cut off by a steer");
15301532 expect(markup).toContain("Current response remains visible");
15311533 expect(markup).not.toContain("Partial response from the old attempt");
15321534 });
apps/web/src/components/chat/MessagesTimeline.tsx+1-1
......@@ -2744,7 +2744,7 @@ function AttemptFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "at
27442744 icon={row.expanded ? ChevronDown : ChevronRight}
27452745 />
27462746 <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>
27482748 </button>
27492749 );
27502750}
apps/web/src/session-logic.ts+1-1
......@@ -119,7 +119,7 @@ export interface LatestProposedPlanState {
119119
120120export type TimelineAttempt = Pick<
121121 OrchestrationV2RunAttempt,
122 "id" | "runId" | "attemptOrdinal" | "rootNodeId" | "status"
122 "id" | "runId" | "attemptOrdinal" | "rootNodeId" | "status" | "completedAt"
123123>;
124124
125125export type TimelineEntry = (