From ec4378614046ab58864ff5e640de2b46f6704505 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 12:31:06 -0700 Subject: [PATCH] fix(web): a finished reply replaced by a steer is no longer labeled partial (#17761) Co-authored-by: Claude Opus 5.5 --- .../chat/MessagesTimeline.logic.test.ts | 130 ++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 64 ++++++++- .../components/chat/MessagesTimeline.test.tsx | 4 +- .../src/components/chat/MessagesTimeline.tsx | 2 +- apps/web/src/session-logic.ts | 2 +- 5 files changed, 197 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 8ba1552452a5f64a6bf21c3008c26cbec7d81da8..45613d72aac59b3e4e019b5317e2c720b461da57 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -973,6 +973,7 @@ describe("deriveMessagesTimelineRows", () => { status: "superseded" as const, attemptOrdinal: 0, rootNodeId: "old-root" as never, + completedAt: null, }, } : {}), @@ -3819,6 +3820,7 @@ describe("v2 run and attempt history", () => { attemptOrdinal: 1, rootNodeId: "node-attempt-1" as never, status: "superseded" as const, + completedAt: null, }; const activeAttempt = { id: activeAttemptId, @@ -3826,6 +3828,7 @@ describe("v2 run and attempt history", () => { attemptOrdinal: 2, rootNodeId: "node-attempt-2" as never, status: "running" as const, + completedAt: null, }; const timelineEntries = [ { @@ -3957,6 +3960,131 @@ describe("v2 run and attempt history", () => { "active-assistant-entry", ]); }); + + describe("a steer that supersedes an attempt", () => { + const runId = RunId.make("run-steered"); + const at = (second: number) => `2026-01-01T00:00:${String(second).padStart(2, "0")}Z`; + // The steer stamps the superseded attempt at 0:10. + const supersededAttempt = { + id: RunAttemptId.make("attempt-1"), + runId, + attemptOrdinal: 1, + rootNodeId: NodeId.make("node-attempt-1"), + status: "superseded" as const, + completedAt: DateTime.makeUnsafe(at(10)), + }; + const nextAttempt = { + id: RunAttemptId.make("attempt-2"), + runId, + attemptOrdinal: 2, + rootNodeId: NodeId.make("node-attempt-2"), + status: "completed" as const, + completedAt: DateTime.makeUnsafe(at(14)), + }; + const assistantEntry = ( + id: string, + attempt: typeof supersededAttempt | typeof nextAttempt, + completedSecond: number, + ): TimelineEntry => ({ + id, + kind: "message", + createdAt: at(2), + attempt, + message: { + id: MessageId.make(id), + role: "assistant", + text: `${id} text`, + runId, + createdAt: at(2), + updatedAt: at(completedSecond), + streaming: false, + }, + projectedItem: { + item: { + type: "assistant_message", + status: "completed", + completedAt: DateTime.makeUnsafe(at(completedSecond)), + }, + } as never, + }); + const rowsFor = ( + replyCompletedSecond: number, + runStatus: "running" | "completed" = "completed", + ) => + deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "initial-user-entry", + kind: "message", + createdAt: at(0), + attempt: supersededAttempt, + message: { + id: MessageId.make("initial-user"), + role: "user", + text: "Build it", + runId, + inputIntent: "turn_start", + createdAt: at(0), + updatedAt: at(0), + streaming: false, + }, + }, + { + id: "old-work-entry", + kind: "work", + createdAt: at(1), + attempt: supersededAttempt, + entry: { id: "old-work", createdAt: at(1), runId, label: "Read", tone: "tool" }, + }, + assistantEntry("old-reply-entry", supersededAttempt, replyCompletedSecond), + { + id: "steer-user-entry", + kind: "message", + createdAt: at(10), + attempt: nextAttempt, + message: { + id: MessageId.make("steer-user"), + role: "user", + text: "What are you working on?", + runId, + inputIntent: "steer", + createdAt: at(10), + updatedAt: at(10), + streaming: false, + }, + }, + assistantEntry("new-reply-entry", nextAttempt, 14), + ], + latestRun: { + runId, + status: runStatus, + startedAt: at(0), + completedAt: runStatus === "completed" ? at(14) : null, + }, + isWorking: false, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + it.each(["running", "completed"] as const)( + "keeps a reply that finished before the steer visible while the run is %s", + (runStatus) => { + const rows = rowsFor(4, runStatus); + expect(rows.some((row) => row.kind === "attempt-fold")).toBe(false); + expect(rows.map((row) => row.id)).toContain("old-reply-entry"); + }, + ); + + it("folds a reply the steer cut off as partial output", () => { + const rows = rowsFor(11, "running"); + expect(rows.find((row) => row.kind === "attempt-fold")).toMatchObject({ + attemptId: supersededAttempt.id, + label: "Superseded attempt", + }); + expect(rows.map((row) => row.id)).not.toContain("old-reply-entry"); + }); + }); + it("hides the interruption request while keeping intervening work and the result", () => { const runId = "turn-1" as never; const interruptEvent = (type: "run_interrupt_request" | "run_interrupt_result") => ({ @@ -4962,6 +5090,7 @@ describe("failed turn transcript", () => { attemptOrdinal: 1, rootNodeId: NodeId.make("superseded-root"), status: "superseded" as const, + completedAt: null, }, })), latestRun: { @@ -5160,6 +5289,7 @@ describe("live subagents after their parent turn settles", () => { attemptOrdinal: 1, rootNodeId: NodeId.make("superseded-root"), status: "superseded" as const, + completedAt: null, }; const entries = deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: ( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index b4f879e9a995abf99f6a6eaec73dfe09c54033e2..684b0e641f83a8d01d3fdc05ea966154c4e1e1c9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,6 +1,7 @@ import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; +import * as DateTime from "effect/DateTime"; import * as Equal from "effect/Equal"; import { assistantCitationLabel, @@ -752,7 +753,52 @@ interface SupersededAttemptFold { } /** - * Groups only provider output owned by an explicitly superseded V2 attempt. + * Final replies of superseded attempts that finished before the steer replaced + * them. A steer stamps the attempt's completedAt when it is dispatched, and the + * provider closes a reply it cut off only after that, so a reply whose item + * completed earlier was whole. Item status alone cannot tell: an interrupted + * stream also closes as `completed`. The reply must be the attempt's last + * output; trailing tool or reasoning work means the attempt was still going. + */ +function completeSupersededReplyEntryIds( + timelineEntries: ReadonlyArray, +): ReadonlySet { + const lastOutputByAttemptId = new Map(); + for (const entry of timelineEntries) { + if ( + entry.attempt?.status !== "superseded" || + (entry.kind === "message" && entry.message.role === "user") || + (entry.kind === "work" && + (entry.entry.itemType === "system_notice" || entry.entry.itemType === "notification")) || + (entry.kind !== "message" && entry.kind !== "work") + ) { + continue; + } + lastOutputByAttemptId.set(entry.attempt.id, entry); + } + const replyEntryIds = new Set(); + for (const entry of lastOutputByAttemptId.values()) { + const supersededAt = entry.attempt?.completedAt ?? null; + const item = entry.kind === "message" ? entry.projectedItem?.item : undefined; + if ( + entry.kind === "message" && + !entry.message.streaming && + item?.type === "assistant_message" && + item.status === "completed" && + item.completedAt !== null && + supersededAt !== null && + DateTime.isLessThan(item.completedAt, supersededAt) + ) { + replyEntryIds.add(entry.id); + } + } + return replyEntryIds; +} + +/** + * Groups only provider output owned by an explicitly superseded V2 attempt + * that the steer cut off. An attempt that had already finished its reply is + * left in the timeline like any other turn output. * User messages remain visible because they are inputs to the logical run, * including the steer message that started the replacement attempt. */ @@ -760,11 +806,18 @@ function deriveSupersededAttemptFolds( timelineEntries: ReadonlyArray, unfoldedRunIds: ReadonlySet, liveSubagentEntryIds: ReadonlySet, + completeReplyEntryIds: ReadonlySet, ): ReadonlyMap { + const completeAttemptIds = new Set( + timelineEntries.flatMap((entry) => + entry.attempt !== undefined && completeReplyEntryIds.has(entry.id) ? [entry.attempt.id] : [], + ), + ); const entriesByAttemptId = new Map(); for (const entry of timelineEntries) { if ( entry.attempt?.status !== "superseded" || + completeAttemptIds.has(entry.attempt.id) || unfoldedRunIds.has(entry.attempt.runId) || (entry.kind === "message" && entry.message.role === "user") || // A published page stays visible, as it does when its turn folds. @@ -966,6 +1019,8 @@ function deriveTurnFolds(input: { /** Keeps the latest runless response open; V2 work must not reopen imported turns. */ runlessWorkActive: boolean; liveSubagentEntryIds: ReadonlySet; + /** Finished replies a steer superseded; they answer their prompt, so they stay out. */ + completeSupersededReplyEntryIds: ReadonlySet; }): ReadonlyMap { const interruptedRunIds = new Set(); for (const entry of input.timelineEntries) { @@ -1082,7 +1137,8 @@ function deriveTurnFolds(input: { // launching run and stay visible after the surrounding work folds. if ( timelineEntryIsPersistentResourceCard(entry) || - input.liveSubagentEntryIds.has(entry.id) + input.liveSubagentEntryIds.has(entry.id) || + input.completeSupersededReplyEntryIds.has(entry.id) ) { continue; } @@ -1320,6 +1376,7 @@ function deriveTimelineTurnFolds( unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), runlessWorkActive: input.isWorking && input.runlessWorkActive === true, liveSubagentEntryIds: liveSubagentCardEntryIds(timelineEntries), + completeSupersededReplyEntryIds: completeSupersededReplyEntryIds(timelineEntries), }); } @@ -1365,10 +1422,12 @@ export function deriveMessagesTimelineRows(input: { const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null); const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null); const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries); + const completeSupersededReplyIds = completeSupersededReplyEntryIds(timelineEntries); const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds( timelineEntries, failedRunIds, liveSubagentEntryIds, + completeSupersededReplyIds, ); const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({ timelineEntries: timelineEntries, @@ -1383,6 +1442,7 @@ export function deriveMessagesTimelineRows(input: { unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), runlessWorkActive, liveSubagentEntryIds, + completeSupersededReplyEntryIds: completeSupersededReplyIds, }); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index bb5170390a1aff262824668b8edafaf391bad3fd..199cdfcbdf62dc43130c56c71fee7c242863378a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1471,6 +1471,7 @@ describe("MessagesTimeline", () => { attemptOrdinal: 1, rootNodeId: "node-attempt-1" as never, status: "superseded" as const, + completedAt: null, }; const activeAttempt = { id: "attempt-2" as never, @@ -1478,6 +1479,7 @@ describe("MessagesTimeline", () => { attemptOrdinal: 2, rootNodeId: "node-attempt-2" as never, status: "running" as const, + completedAt: null, }; const markup = renderToStaticMarkup( { expect(markup).toContain('data-superseded-attempt-id="attempt-1"'); expect(markup).toContain('aria-expanded="false"'); expect(markup).toContain("Superseded attempt"); - expect(markup).toContain("Partial output retained"); + expect(markup).toContain("Cut off by a steer"); expect(markup).toContain("Current response remains visible"); expect(markup).not.toContain("Partial response from the old attempt"); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 0518c96afc587802435649dcf3fbc778decb760c..052ebedd9a249745a4a994e7db65edb950905195 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2744,7 +2744,7 @@ function AttemptFoldTimelineRow({ row }: { row: Extract {row.label} - Partial output retained + Cut off by a steer ); } diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 9ce8167251b856961d4545afa504cd54f24bd76a..a1554683a56481fec06908d9ccf7fa815b720d4f 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -119,7 +119,7 @@ export interface LatestProposedPlanState { export type TimelineAttempt = Pick< OrchestrationV2RunAttempt, - "id" | "runId" | "attemptOrdinal" | "rootNodeId" | "status" + "id" | "runId" | "attemptOrdinal" | "rootNodeId" | "status" | "completedAt" >; export type TimelineEntry = ( -- 2.54.0