diff --git a/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts index 060273eaa723..99309c9e2f91 100644 --- a/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts @@ -30,7 +30,11 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; -import { ProviderAdapterRequestError, ProviderValidationError } from "../../provider/Errors.ts"; +import { + ProviderAdapterRequestError, + ProviderAdapterValidationError, + ProviderValidationError, +} from "../../provider/Errors.ts"; import { ProviderService, type ProviderServiceShape, @@ -80,6 +84,7 @@ type MutableState = { failRestoreAfterEffect: boolean; failCompletionAfterCommit: boolean; terminalRollbackFailure: boolean; + unavailableRetainedBoundary: boolean; interruptAcknowledgementHangs: boolean; readonly order: string[]; readonly interruptedTurnIds: Array; @@ -119,6 +124,7 @@ function makeState(providerSendState: ProjectionTurnRetraction["providerSendStat failRestoreAfterEffect: false, failCompletionAfterCommit: false, terminalRollbackFailure: false, + unavailableRetainedBoundary: false, interruptAcknowledgementHangs: false, order: [], interruptedTurnIds: [], @@ -333,6 +339,16 @@ async function startHarness( }, }), rollbackConversation: () => unsupported(), + validateRollbackConversationTo: ({ retainedTurnCount }) => + state.unavailableRetainedBoundary + ? Effect.fail( + new ProviderAdapterValidationError({ + provider: "claudeAgent", + operation: "rollbackThreadTo", + issue: `Provider history has 3 turns, below retained boundary ${retainedTurnCount}.`, + }), + ) + : Effect.void, rollbackConversationTo: ({ retainedTurnCount, targetTurnId }) => Effect.gen(function* () { state.order.push("rollback"); @@ -734,6 +750,36 @@ it("marks terminal provider rollback failure with the correlated activity shape" await stopHarness(harness); }); +it("silently rejects an unavailable retained boundary before interrupting the turn", async () => { + const state = makeState("claimed"); + state.unavailableRetainedBoundary = true; + const harness = await startHarness(state); + + expect(state.interruptedTurnIds).toEqual([]); + expect(state.order).toEqual([]); + expect(state.sessionStatus).toBe("running"); + expect(state.row.status).toBe("failed"); + expect( + state.dispatched.find( + (command) => + command.type === "thread.activity.append" && + command.activity.kind === "turn.retract.failed", + ), + ).toMatchObject({ + type: "thread.activity.append", + activity: { + payload: { + requestId: REQUEST_ID, + stage: "provider-rollback", + retryable: false, + silent: true, + }, + }, + }); + + await stopHarness(harness); +}); + it.layer(NodeServices.layer)("first-message completion integration", (it) => { it.effect("produces reverted and deleted atomically through the WO4a decider", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts b/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts index 117447449619..beb5a07b58ea 100644 --- a/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts +++ b/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts @@ -58,6 +58,7 @@ type StageFailure = { readonly stage: RetractionStage; readonly retryable: boolean; readonly detail: string; + readonly silent?: boolean; }; const terminalProviderErrorSchemas = [ @@ -75,6 +76,12 @@ const isTerminalProviderError = (error: unknown): boolean => const failureDetail = (error: unknown): string => error instanceof Error ? error.message : String(error); +const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); +const isUnavailableRetainedBoundary = (error: unknown): boolean => + isProviderAdapterValidationError(error) && + error.operation === "rollbackThreadTo" && + /^Provider history has \d+ turns, below retained boundary \d+\.$/.test(error.issue); + export class TurnRetractionRetryTicks extends Context.Reference>( "t3/orchestration/Layers/TurnRetractionReactor/TurnRetractionRetryTicks", { @@ -193,6 +200,7 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { stage: failure.stage, retryable: failure.retryable, detail: failure.detail, + ...(failure.silent ? { silent: true } : {}), }, turnId: row.targetTurnId, createdAt, @@ -462,6 +470,23 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { return; } + if (providerService.validateRollbackConversationTo) { + yield* providerService + .validateRollbackConversationTo({ + threadId: row.threadId, + retainedTurnCount: row.baselineTurnCount, + targetTurnId, + }) + .pipe( + Effect.mapError((error) => ({ + stage: "provider-rollback" as const, + retryable: !isTerminalProviderError(error), + detail: failureDetail(error), + ...(isUnavailableRetainedBoundary(error) ? { silent: true } : {}), + })), + ); + } + const nowMillis = DateTime.toEpochMillis(yield* DateTime.now); const priorAttempt = readInterruptAttempt(row.requestId, targetTurnId); const retryCadenceMillis = Duration.toMillis(interruptRetryCadence); @@ -531,6 +556,7 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { stage: "provider-rollback" as const, retryable: !isTerminalProviderError(error), detail: failureDetail(error), + ...(isUnavailableRetainedBoundary(error) ? { silent: true } : {}), })), ); yield* restoreFilesystem(row, false); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 99893919557d..d4bd64bc3b93 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3921,6 +3921,14 @@ describe("ClaudeAdapterLive", () => { const repeated = yield* adapter.rollbackThreadTo(session.threadId, 1); assert.equal(repeated.turns.length, 1); + assert.isDefined(adapter.validateRollbackThreadTo); + const validation = yield* adapter.validateRollbackThreadTo!(session.threadId, 2).pipe( + Effect.result, + ); + assert.equal(validation._tag, "Failure"); + const afterValidation = yield* adapter.readThread(session.threadId); + assert.equal(afterValidation.turns.length, 1); + const shorterThanTarget = yield* adapter .rollbackThreadTo(session.threadId, 2) .pipe(Effect.result); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 539f16e99fea..beceaf1d32bd 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -4765,9 +4765,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, ); - const rollbackThreadTo: NonNullable = Effect.fn( - "rollbackThreadTo", - )(function* (threadId, retainedTurnCount) { + const validateRollbackBoundary = Effect.fn("validateClaudeRollbackBoundary")(function* ( + threadId: ThreadId, + retainedTurnCount: number, + ) { const context = yield* requireSession(threadId); if (!Number.isInteger(retainedTurnCount) || retainedTurnCount < 0) { return yield* new ProviderAdapterValidationError({ @@ -4784,6 +4785,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( issue: `Provider history has ${lifetimeTurnCount} turns, below retained boundary ${retainedTurnCount}.`, }); } + }); + + const validateRollbackThreadTo: NonNullable = + validateRollbackBoundary; + + const rollbackThreadTo: NonNullable = Effect.fn( + "rollbackThreadTo", + )(function* (threadId, retainedTurnCount) { + yield* validateRollbackBoundary(threadId, retainedTurnCount); + const context = yield* requireSession(threadId); + const lifetimeTurnCount = context.sessionBaseTurnCount + context.turns.length; const delta = lifetimeTurnCount - retainedTurnCount; const sessionLocalTurnCount = context.turns.length; const nextLength = sessionLocalTurnCount - Math.min(delta, sessionLocalTurnCount); @@ -4896,6 +4908,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( interruptTurn, readThread, rollbackThread, + validateRollbackThreadTo, rollbackThreadTo, respondToRequest, respondToUserInput, diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 609aadca143f..4e807541f483 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -1140,6 +1140,28 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); }); + const validateRollbackConversationTo: NonNullable< + ProviderServiceMethod<"validateRollbackConversationTo"> + > = Effect.fn("validateRollbackConversationTo")(function* (rawInput) { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.validateRollbackConversationTo", + schema: ProviderRollbackConversationToInput, + payload: rawInput, + }); + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.validateRollbackConversationTo", + allowRecovery: true, + }); + if (routed.adapter.validateRollbackThreadTo !== undefined) { + yield* routed.adapter.validateRollbackThreadTo( + routed.threadId, + input.retainedTurnCount, + input.targetTurnId, + ); + } + }); + const runStopAll = Effect.fn("runStopAll")(function* () { const threadIds = yield* directory.listThreadIds(); const currentAdapters = yield* getAdapterEntries; @@ -1211,6 +1233,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( getCapabilities, getInstanceInfo, rollbackConversation, + validateRollbackConversationTo, rollbackConversationTo, // Each access creates a fresh PubSub subscription so that multiple // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 489615be5735..293d24559792 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -126,6 +126,17 @@ export interface ProviderAdapterShape { targetTurnId?: TurnId, ) => Effect.Effect; + /** + * Validate an absolute rollback boundary without changing provider state. + * Adapters that track history relative to an opaque resume cursor use this + * to reject an unavailable boundary before a live turn is interrupted. + */ + readonly validateRollbackThreadTo?: ( + threadId: ThreadId, + retainedTurnCount: number, + targetTurnId?: TurnId, + ) => Effect.Effect; + /** * Stop all sessions owned by this adapter. */ diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 71400712473e..7092b83a39b7 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -116,6 +116,13 @@ export interface ProviderServiceShape { readonly targetTurnId?: TurnId; }) => Effect.Effect; + /** Validate an absolute rollback boundary without mutating provider state. */ + readonly validateRollbackConversationTo?: (input: { + readonly threadId: ThreadId; + readonly retainedTurnCount: number; + readonly targetTurnId?: TurnId; + }) => Effect.Effect; + /** * Canonical provider runtime event stream. * diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 622762d9e59e..c0431bf521ae 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -263,7 +263,7 @@ import { } from "./chat/chatEscapeTrigger"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { shouldRenderEmptyThreadHero } from "./chat/emptyThreadHero"; -import { findCorrelatedRetractionFailure } from "./chat/lastUserMessageRecovery"; +import { findCorrelatedRetractionFailureInfo } from "./chat/lastUserMessageRecovery"; import { deriveEffectiveSessionPresentation, usePendingRetractionForThread, @@ -1540,13 +1540,14 @@ function ChatViewContent(props: ChatViewProps) { // depend on which route is mounted. const isServerThread = activeServerThread !== null; const activeThread = activeServerThread ?? localDraftThread; - const retractionFailureDetail = + const retractionFailure = activeServerThread?.turnRetraction?.status === "failed" - ? findCorrelatedRetractionFailure( + ? findCorrelatedRetractionFailureInfo( activeServerThread.activities, activeServerThread.turnRetraction.requestId, ) : null; + const retractionFailureDetail = retractionFailure?.silent ? null : retractionFailure?.detail; const threadError = isServerThread ? (localServerError ?? retractionFailureDetail ?? diff --git a/apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts b/apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts index 49dfba980f26..b88b2bb4bf6c 100644 --- a/apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts +++ b/apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts @@ -16,6 +16,8 @@ import { resolveRetractionRecoverySignal, } from "./RetractionRecoveryHandoff"; import { + applyOptimisticRetractionRecoveryToThread, + rememberOptimisticRetractionComposer, snapshotLastUserMessageRecovery, useRetractionRecoveryStore, } from "./lastUserMessageRecovery"; @@ -216,6 +218,70 @@ describe("retraction recovery handoff", () => { expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull(); }); + it("silently restores the pre-Esc composer for an ignored boundary rejection", async () => { + useComposerDraftStore.getState().setPrompt(sourceThreadRef, "existing draft"); + rememberOptimisticRetractionComposer({ requestId, sourceThreadRef }); + const recovery = await seedRecovery(); + useRetractionRecoveryStore.getState().setOptimisticDestination(requestId, "thread"); + applyOptimisticRetractionRecoveryToThread({ + sourceThreadRef, + bundle: { + prompt: "preserve this message", + images: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6", + }, + runtimeMode: "full-access", + interactionMode: "default", + envMode: "worktree", + baseBranch: "main", + startFromOrigin: true, + }, + }); + expect(useComposerDraftStore.getState().getComposerDraft(sourceThreadRef)?.prompt).toBe( + "existing draft\n\npreserve this message", + ); + + const signal = resolveRetractionRecoverySignal({ + recovery, + liveCompletion: null, + projectedRetraction: { + requestId, + messageId, + targetTurnId: null, + firstUserMessage: false, + status: "failed", + completedAt: null, + }, + activities: [ + { + id: "ignored-failure" as never, + tone: "error", + kind: "turn.retract.failed", + summary: "Message retract failed", + payload: { requestId, detail: "boundary unavailable", silent: true }, + turnId: null, + createdAt: "2026-08-11T12:00:00.100Z", + }, + ], + threadStatus: "live", + threadDetailExists: true, + shellSnapshotReady: true, + sourceThreadInShell: true, + nowMs: Date.parse(createdAt) + 100, + }); + + expect(signal).toEqual({ kind: "ignored" }); + expect(signal && applyRetractionRecoverySignal({ recovery, signal, navigate: vi.fn() })).toBe( + "thread-restored", + ); + expect(useComposerDraftStore.getState().getComposerDraft(sourceThreadRef)?.prompt).toBe( + "existing draft", + ); + expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull(); + }); + it("surfaces the recovery draft when failure activity outlives the source thread", async () => { const recovery = await seedRecovery(); const navigate = vi.fn(); diff --git a/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx b/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx index 2d30cea1ef7c..e149672db3a3 100644 --- a/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx +++ b/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx @@ -16,11 +16,12 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import { type FirstMessageRetractionCompletion, discardRetractionRecovery, - findCorrelatedRetractionFailure, + findCorrelatedRetractionFailureInfo, handoffCompletedFirstMessageRetraction, handoffCompletedMidThreadRetraction, type PendingRetractionRecovery, restoreRetractionRecoveryToThread, + restoreOptimisticRetractionComposer, surfaceRetractionRecoveryDraft, useRetractionRecoveryStore, } from "./lastUserMessageRecovery"; @@ -36,6 +37,7 @@ type RetractionProjection = Pick< export type RetractionRecoverySignal = | { kind: "completed"; completion: FirstMessageRetractionCompletion } | { kind: "failed"; detail: string; sourceThreadExists: boolean } + | { kind: "ignored" } | { kind: "source-thread-gone" } | { kind: "stale" } | null; @@ -90,7 +92,7 @@ export function resolveRetractionRecoverySignal(input: { sourceThreadInShell: boolean; nowMs: number; }): RetractionRecoverySignal { - const activityFailure = findCorrelatedRetractionFailure( + const activityFailure = findCorrelatedRetractionFailureInfo( input.activities, input.recovery.requestId, ); @@ -100,10 +102,13 @@ export function resolveRetractionRecoverySignal(input: { const sourceThreadExists = input.threadStatus !== "deleted" && (input.shellSnapshotReady ? input.sourceThreadInShell : input.threadDetailExists); + if (activityFailure?.silent) { + return { kind: "ignored" }; + } if (projectedFailure || activityFailure !== null) { return { kind: "failed", - detail: activityFailure ?? "The server could not retract this message.", + detail: activityFailure?.detail ?? "The server could not retract this message.", sourceThreadExists, }; } @@ -142,6 +147,11 @@ export function applyRetractionRecoverySignal(input: { }) => unknown; }): "draft-surfaced" | "thread-restored" | null { const optimisticDestination = input.recovery.optimisticDestination; + if (input.signal.kind === "ignored") { + restoreOptimisticRetractionComposer(input.recovery.requestId); + discardRetractionRecovery({ requestId: input.recovery.requestId }); + return "thread-restored"; + } if (optimisticDestination && input.signal.kind !== "stale") { if ( input.signal.kind === "completed" && diff --git a/apps/web/src/components/chat/lastUserMessageRecovery.ts b/apps/web/src/components/chat/lastUserMessageRecovery.ts index 9d28c97a5b13..1d151ff46861 100644 --- a/apps/web/src/components/chat/lastUserMessageRecovery.ts +++ b/apps/web/src/components/chat/lastUserMessageRecovery.ts @@ -11,11 +11,13 @@ import type { TurnId, } from "@t3tools/contracts"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { type ComposerImageAttachment, + type ComposerThreadDraftState, type DraftId, type DraftThreadEnvMode, type PersistedComposerImageAttachment, @@ -27,6 +29,41 @@ import { mergePoppedPrompt } from "./lastUserMessagePop"; const RETRACTION_RECOVERY_STORAGE_KEY = "t3code:thread-retraction-recoveries:v1"; +const optimisticComposerSnapshots = new Map< + string, + { readonly threadKey: string; readonly draft: ComposerThreadDraftState | null } +>(); + +function cloneComposerDraft( + draft: ComposerThreadDraftState | null, +): ComposerThreadDraftState | null { + return draft + ? { + ...draft, + images: [...draft.images], + nonPersistedImageIds: [...draft.nonPersistedImageIds], + persistedAttachments: [...draft.persistedAttachments], + terminalContexts: [...draft.terminalContexts], + elementContexts: [...draft.elementContexts], + previewAnnotations: [...draft.previewAnnotations], + reviewComments: [...draft.reviewComments], + modelSelectionByProvider: { ...draft.modelSelectionByProvider }, + } + : null; +} + +export function rememberOptimisticRetractionComposer(input: { + requestId: CommandId; + sourceThreadRef: ScopedThreadRef; +}): void { + optimisticComposerSnapshots.set(input.requestId, { + threadKey: scopedThreadKey(input.sourceThreadRef), + draft: cloneComposerDraft( + useComposerDraftStore.getState().getComposerDraft(input.sourceThreadRef), + ), + }); +} + export interface PendingRetractionRecovery { requestId: CommandId; messageId: MessageId; @@ -296,6 +333,7 @@ export function discardRetractionRecovery(input: { if (!input.preserveDraft) { useComposerDraftStore.getState().clearDraftThread(recovery.draftId); } + optimisticComposerSnapshots.delete(input.requestId); useRetractionRecoveryStore.getState().forget(input.requestId); return true; } @@ -306,17 +344,60 @@ export interface AppliedRetractionRecovery { unrestoredImageNames: string[]; } -export function findCorrelatedRetractionFailure( +export function restoreOptimisticRetractionComposer( + requestId: CommandId, +): AppliedRetractionRecovery | null { + const snapshot = optimisticComposerSnapshots.get(requestId); + if (!snapshot) return null; + optimisticComposerSnapshots.delete(requestId); + useComposerDraftStore.setState((state) => { + if (snapshot.draft) { + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [snapshot.threadKey]: cloneComposerDraft(snapshot.draft)!, + }, + }; + } + const { [snapshot.threadKey]: _removed, ...draftsByThreadKey } = state.draftsByThreadKey; + return { draftsByThreadKey }; + }); + return { + prompt: snapshot.draft?.prompt ?? "", + images: snapshot.draft?.images ?? [], + unrestoredImageNames: [], + }; +} + +export interface CorrelatedRetractionFailure { + detail: string; + silent: boolean; +} + +export function findCorrelatedRetractionFailureInfo( activities: ReadonlyArray, requestId: CommandId, -): string | null { +): CorrelatedRetractionFailure | null { const activity = activities.findLast((entry) => { if (entry.kind !== "turn.retract.failed" || typeof entry.payload !== "object") return false; return (entry.payload as { requestId?: unknown } | null)?.requestId === requestId; }); if (!activity) return null; - const detail = (activity.payload as { detail?: unknown } | null)?.detail; - return typeof detail === "string" && detail.trim().length > 0 ? detail : activity.summary; + const payload = activity.payload as { detail?: unknown; silent?: unknown } | null; + return { + detail: + typeof payload?.detail === "string" && payload.detail.trim().length > 0 + ? payload.detail + : activity.summary, + silent: payload?.silent === true, + }; +} + +export function findCorrelatedRetractionFailure( + activities: ReadonlyArray, + requestId: CommandId, +): string | null { + return findCorrelatedRetractionFailureInfo(activities, requestId)?.detail ?? null; } export function restoreRetractionRecoveryToThread(input: { @@ -372,6 +453,7 @@ export function restoreRetractionRecoveryToThread(input: { store.setRuntimeMode(input.sourceThreadRef, recoveredDraft.runtimeMode); store.setInteractionMode(input.sourceThreadRef, recoveredDraft.interactionMode); store.clearDraftThread(recovery.draftId); + optimisticComposerSnapshots.delete(input.requestId); useRetractionRecoveryStore.getState().forget(input.requestId); return { @@ -460,6 +542,7 @@ export function surfaceRetractionRecoveryDraft(input: { hidden: false, }); if (!input.retainRecovery) { + optimisticComposerSnapshots.delete(input.requestId); useRetractionRecoveryStore.getState().forget(input.requestId); } if (input.navigate) { diff --git a/apps/web/src/components/chat/useLastUserMessageRetraction.ts b/apps/web/src/components/chat/useLastUserMessageRetraction.ts index 83edd380f9a9..af8cd65f5216 100644 --- a/apps/web/src/components/chat/useLastUserMessageRetraction.ts +++ b/apps/web/src/components/chat/useLastUserMessageRetraction.ts @@ -31,10 +31,12 @@ import { appendImagesToOptimisticRetractionRecovery, applyOptimisticRetractionRecoveryToThread, discardRetractionRecovery, - findCorrelatedRetractionFailure, + findCorrelatedRetractionFailureInfo, handoffCompletedMidThreadRetraction, type PendingRetractionRecovery, restoreRetractionRecoveryToThread, + restoreOptimisticRetractionComposer, + rememberOptimisticRetractionComposer, snapshotLastUserMessageRecovery, surfaceRetractionRecoveryDraft, useRetractionRecoveryStore, @@ -142,6 +144,19 @@ export function useLastUserMessageRetraction(input: { [applyRestoredComposer, onOptimisticRetractionFailed, setThreadError], ); + const ignorePendingRetraction = useCallback( + (recovery: PendingRetractionRecovery) => { + const restored = restoreOptimisticRetractionComposer(recovery.requestId); + discardRetractionRecovery({ requestId: recovery.requestId }); + if (restored) applyRestoredComposer(restored); + onOptimisticRetractionFailed({ + requestId: recovery.requestId, + messageId: recovery.messageId, + }); + }, + [applyRestoredComposer, onOptimisticRetractionFailed], + ); + const dispatchesRef = useRef(new Set()); const recoveryPreparationRef = useRef(false); const dispatchPendingRetraction = useCallback( @@ -222,7 +237,7 @@ export function useLastUserMessageRetraction(input: { useEffect(() => { if (!pendingRecovery || !activeThread) return; - const activityFailure = findCorrelatedRetractionFailure( + const activityFailure = findCorrelatedRetractionFailureInfo( activeThread.activities, pendingRecovery.requestId, ); @@ -230,11 +245,15 @@ export function useLastUserMessageRetraction(input: { activeThread.turnRetraction?.status === "failed" && activeThread.turnRetraction.requestId === pendingRecovery.requestId; if (!projectedFailure && activityFailure === null) return; + if (activityFailure?.silent) { + ignorePendingRetraction(pendingRecovery); + return; + } failPendingRetraction( pendingRecovery, - activityFailure ?? "The server could not retract this message.", + activityFailure?.detail ?? "The server could not retract this message.", ); - }, [activeThread, failPendingRetraction, pendingRecovery]); + }, [activeThread, failPendingRetraction, ignorePendingRetraction, pendingRecovery]); return useCallback(async () => { if ( @@ -265,6 +284,7 @@ export function useLastUserMessageRetraction(input: { startFromOrigin, }; const draftId = newDraftId(); + rememberOptimisticRetractionComposer({ requestId, sourceThreadRef }); const snapshotPromise = beginOptimisticRetraction({ restoreComposer: () => { const restored = applyOptimisticRetractionRecoveryToThread({ diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index fa2264a621f4..e29bf4ef04f0 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -771,6 +771,30 @@ describe("deriveWorkLogEntries", () => { expect(entries.map((entry) => entry.id)).toEqual(["task-progress", "task-complete"]); }); + it("omits silent turn-retraction failures but keeps ordinary failures", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "silent-retraction-failure", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.retract.failed", + summary: "Message retract failed", + tone: "error", + payload: { silent: true }, + }), + makeActivity({ + id: "visible-retraction-failure", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "turn.retract.failed", + summary: "Message retract failed", + tone: "error", + }), + ]; + + expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual([ + "visible-retraction-failure", + ]); + }); + it("uses payload summary as label for task entries when available", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 325ea47cacc7..e4fa788dd505 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -748,6 +748,13 @@ export function deriveWorkLogEntries( const ordered = [...activities].toSorted(compareActivitiesByOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { + if ( + activity.kind === "turn.retract.failed" && + typeof activity.payload === "object" && + (activity.payload as { silent?: unknown } | null)?.silent === true + ) { + continue; + } if (activity.kind === "tool.started") continue; // Agent task.started rows are CTA seeds: they carry the true spawn turn, // which is the batch key (completions of background subagents arrive