From aeabd48414907827d5dfaad6f4fa88eca57db41f Mon Sep 17 00:00:00 2001 From: Shakker Date: Sat, 1 Aug 2026 14:38:25 +0100 Subject: [PATCH] fix: scope failed command recovery --- CHANGELOG.md | 1 + ui/src/pages/chat/chat-send-composer.ts | 128 +++++++++++++++++ ui/src/pages/chat/chat-send-submit.ts | 103 +++++++++----- ui/src/pages/chat/chat-send.test.ts | 181 +++++++++++++++++++++++- ui/src/pages/chat/chat-send.ts | 5 +- ui/src/pages/chat/chat-state-route.ts | 99 ++++++++++++- ui/src/pages/chat/chat-state.test.ts | 123 ++++++++++++++++ 7 files changed, 601 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc79b73317c6..12d5d00278ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **Control UI command recovery:** keep delayed detached and immediate command failures scoped to their submitting session, preserving failed drafts and attachments for that pane without overwriting the active session. Fixes #116846. Thanks @shakkernerd. - **Microsoft Teams message-tool replies:** keep automatic live previews from duplicating a message already delivered to the current Teams conversation, while preserving distinct follow-up text and cross-conversation sends. Fixes #116397. (#116398) Thanks @a-tokyo. - **Buzz plugin packaging:** keep the live QA runner on the shipped QA runner SDK surface and remove the obsolete package shrinkwrap so standalone npm and ClawHub package builds use current host exports and dependency resolutions. Thanks @shakkernerd. - **Control UI sharing connection isolation:** discard stale visibility and membership mutation results after switching gateways or accounts so previous-connection refreshes and errors cannot update the replacement connection. Fixes #116800. Thanks @shakkernerd. diff --git a/ui/src/pages/chat/chat-send-composer.ts b/ui/src/pages/chat/chat-send-composer.ts index 6172419f3806..b3e7ef4dfc4a 100644 --- a/ui/src/pages/chat/chat-send-composer.ts +++ b/ui/src/pages/chat/chat-send-composer.ts @@ -1,10 +1,138 @@ import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts"; +import { visibleSessionMatches } from "../../lib/sessions/index.ts"; import { releaseChatAttachmentPayloads } from "./attachment-payload-store.ts"; import { excludeComposerAttachments, removeVisibleOrScopedQueuedMessageWithoutReleasing, } from "./chat-queue.ts"; import type { ChatHost } from "./chat-send-contract.ts"; +import type { ChatPageHost } from "./chat-state-host.ts"; +import { + captureChatComposerMemoryFallbackOwnership, + clearChatComposerMemoryFallback, + ownsChatComposerMemoryFallback, + retainChatComposerMemoryFallback, + type ChatComposerMemoryFallbackOwnership, +} from "./chat-state-route.ts"; +import type { StoredChatOutboxScope } from "./composer-persistence.ts"; + +export type ChatCommandComposerRecovery = { + attachments: ChatAttachment[]; + client: ChatHost["client"]; + connectionEpoch: ChatHost["connectionEpoch"]; + draft: string; + fallbackOwnership?: ChatComposerMemoryFallbackOwnership; + scope: StoredChatOutboxScope; +}; + +function chatCommandRecoveryHost(host: ChatHost): ChatPageHost | undefined { + return "chatComposerFallbackByScope" in host && + typeof host.chatComposerFallbackByScope === "object" && + host.chatComposerFallbackByScope !== null + ? (host as ChatPageHost) + : undefined; +} + +export function captureChatCommandComposerRecovery( + host: ChatHost, + scope: StoredChatOutboxScope, + draft: string, + attachments: ChatAttachment[], +): ChatCommandComposerRecovery { + const fallbackHost = chatCommandRecoveryHost(host); + return { + attachments, + client: host.client, + connectionEpoch: host.connectionEpoch, + draft, + ...(fallbackHost + ? { + fallbackOwnership: captureChatComposerMemoryFallbackOwnership(fallbackHost, scope, { + message: draft, + attachments, + }), + } + : {}), + scope, + }; +} + +export function submittedCommandConnectionIsCurrent( + host: ChatHost, + recovery: ChatCommandComposerRecovery, +): boolean { + return host.client === recovery.client && host.connectionEpoch === recovery.connectionEpoch; +} + +export function submittedCommandScopeIsVisible( + host: ChatHost, + recovery: ChatCommandComposerRecovery, +): boolean { + return ( + submittedCommandConnectionIsCurrent(host, recovery) && + visibleSessionMatches(host, recovery.scope.sessionKey, recovery.scope.agentId) + ); +} + +export function clearOwnedCommandComposerFallback( + host: ChatHost, + recovery: ChatCommandComposerRecovery, +): boolean { + const fallbackHost = chatCommandRecoveryHost(host); + return fallbackHost + ? clearChatComposerMemoryFallback(fallbackHost, recovery.fallbackOwnership) + : false; +} + +export function commandComposerFallbackRetainsAttachments( + host: ChatHost, + recovery: ChatCommandComposerRecovery, +): boolean { + const fallbackHost = chatCommandRecoveryHost(host); + return Boolean( + recovery.fallbackOwnership && + fallbackHost && + ownsChatComposerMemoryFallback(fallbackHost, recovery.fallbackOwnership), + ); +} + +export function restoreFailedCommandComposer( + host: ChatHost, + recovery: ChatCommandComposerRecovery, +): boolean { + const fallbackHost = chatCommandRecoveryHost(host); + if (!submittedCommandConnectionIsCurrent(host, recovery)) { + return ( + recovery.attachments.length === 0 || commandComposerFallbackRetainsAttachments(host, recovery) + ); + } + if (!submittedCommandScopeIsVisible(host, recovery)) { + if (!fallbackHost) { + return recovery.attachments.length === 0; + } + const ownership = retainChatComposerMemoryFallback(fallbackHost, recovery.scope, { + message: recovery.draft, + attachments: recovery.attachments, + }); + recovery.fallbackOwnership = ownership; + return recovery.attachments.length === 0 || ownership !== undefined; + } + const restorePlan = pendingComposerRestorePlan(host, { + previousAttachments: recovery.attachments, + previousDraft: recovery.draft, + }); + if (restorePlan.willRestoreDraft) { + host.chatMessage = recovery.draft; + } + if (restorePlan.willRestoreAttachments) { + host.chatAttachments = recovery.attachments; + } + const retained = recovery.attachments.length === 0 || restorePlan.willRestoreAttachments; + if (!restorePlan.complete) { + clearOwnedCommandComposerFallback(host, recovery); + } + return retained; +} export function restoreComposerAfterFailedSend( host: ChatHost, diff --git a/ui/src/pages/chat/chat-send-submit.ts b/ui/src/pages/chat/chat-send-submit.ts index 055d44fb8449..57573eb0f4ce 100644 --- a/ui/src/pages/chat/chat-send-submit.ts +++ b/ui/src/pages/chat/chat-send-submit.ts @@ -27,6 +27,15 @@ import { } from "./chat-queue.ts"; import { isTerminalFailureChatSendAck } from "./chat-send-ack.ts"; import { sendChatMessageWithGeneratedRunId, steerSendDependencies } from "./chat-send-actions.ts"; +import { + captureChatCommandComposerRecovery, + clearOwnedCommandComposerFallback, + commandComposerFallbackRetainsAttachments, + restoreFailedCommandComposer, + submittedCommandConnectionIsCurrent, + submittedCommandScopeIsVisible, + type ChatCommandComposerRecovery, +} from "./chat-send-composer.ts"; import type { ChatHost } from "./chat-send-contract.ts"; import { canSendVolatileQueueItem, @@ -40,12 +49,15 @@ import { recordChatSendTiming } from "./chat-send-timing.ts"; import { cancelPendingSendBeforeRequest, chatOutboxDrainDependencies, - pendingComposerRestorePlan, sendChatMessageNow, withChatSubmitGuard, } from "./chat-send.ts"; import { getPendingChatPickerPatch } from "./chat-session.ts"; -import { INTERRUPTED_SETTINGS_WAIT_ERROR, listStoredChatOutboxes } from "./composer-persistence.ts"; +import { + INTERRUPTED_SETTINGS_WAIT_ERROR, + listStoredChatOutboxes, + resolveStoredChatOutboxScope, +} from "./composer-persistence.ts"; import { recordNonTranscriptInputHistory, resetChatInputHistoryNavigation, @@ -167,9 +179,8 @@ async function sendDetachedCommandMessage( host: ChatHost, message: string, opts?: { - previousDraft?: string; attachments?: ChatAttachment[]; - previousAttachments?: ChatAttachment[]; + recovery?: ChatCommandComposerRecovery; runId?: string; }, ) { @@ -180,21 +191,26 @@ async function sendDetachedCommandMessage( { runId: opts?.runId }, ); const ok = ack?.status === "ok" || ack?.status === "started" || ack?.status === "in_flight"; - if (!ok && opts?.previousDraft != null) { - host.chatMessage = opts.previousDraft; + if (!ok && opts?.recovery && !restoreFailedCommandComposer(host, opts.recovery)) { + releaseChatAttachmentPayloads(excludeComposerAttachments(host, opts.attachments)); } - if (!ok && opts?.previousAttachments) { - host.chatAttachments = opts.previousAttachments; - } - if (isTerminalFailureChatSendAck(ack)) { + if ( + isTerminalFailureChatSendAck(ack) && + (!opts?.recovery || submittedCommandScopeIsVisible(host, opts.recovery)) + ) { setChatError(host, formatTerminalChatSendAckError(ack, "detached")); } if (ok) { + if (opts?.recovery && submittedCommandConnectionIsCurrent(host, opts.recovery)) { + clearOwnedCommandComposerFallback(host, opts.recovery); + } setLastActiveSessionKey( host as unknown as Parameters[0], host.sessionKey, ); - releaseChatAttachmentPayloads(excludeComposerAttachments(host, opts?.attachments)); + if (!opts?.recovery || !commandComposerFallbackRetainsAttachments(host, opts.recovery)) { + releaseChatAttachmentPayloads(excludeComposerAttachments(host, opts?.attachments)); + } } return ack; } @@ -287,10 +303,18 @@ export async function handleSendChat( if (messageOverride == null) { recordNonTranscriptInputHistory(host, message); } + const recoveryScope = resolveStoredChatOutboxScope(host, submittedSessionKey); const ack = await sendDetachedCommandMessage(host, message, { - previousDraft: cleared.previousDraft, attachments: hasAttachments ? attachmentsToSend : undefined, - previousAttachments: cleared.previousAttachments, + recovery: + cleared.previousDraft === undefined + ? undefined + : captureChatCommandComposerRecovery( + host, + recoveryScope, + cleared.previousDraft, + cleared.previousAttachments ?? [], + ), }); void ack; }); @@ -370,15 +394,28 @@ export async function handleSendChat( } } let prevDraft = messageOverride == null ? previousDraft : undefined; + let recovery: ChatCommandComposerRecovery | undefined; if (messageOverride == null) { + const recoveryScope = resolveStoredChatOutboxScope(host, submittedSessionKey); recordNonTranscriptInputHistory(host, message); if (waitsForPicker) { - prevDraft = clearSubmittedComposerState( - host, - previousDraft, - attachmentsToSend, - ).previousDraft; + const cleared = clearSubmittedComposerState(host, previousDraft, attachmentsToSend); + prevDraft = cleared.previousDraft; + if (cleared.previousDraft !== undefined) { + recovery = captureChatCommandComposerRecovery( + host, + recoveryScope, + cleared.previousDraft, + cleared.previousAttachments ?? [], + ); + } } else { + recovery = captureChatCommandComposerRecovery( + host, + recoveryScope, + previousDraft, + parsed.command.key === "export-session" ? [] : attachmentsToSend, + ); host.chatMessage = ""; // Export leaves the composer in its current session; /new must clear // attachments before its handoff can capture them under both routes. @@ -400,21 +437,23 @@ export async function handleSendChat( }, ); if (dispatchResult === "failed") { - opts?.onLocalCommandSendRejected?.(); - } - if ( - (dispatchResult === "failed" || dispatchResult === "cancelled") && - messageOverride == null - ) { - const restorePlan = pendingComposerRestorePlan(host, { - previousAttachments: attachmentsToSend, - previousDraft, - }); - if (restorePlan.willRestoreDraft) { - host.chatMessage = previousDraft; + if ( + messageOverride != null || + (recovery && submittedCommandScopeIsVisible(host, recovery)) + ) { + opts?.onLocalCommandSendRejected?.(); } - if (restorePlan.willRestoreAttachments) { - host.chatAttachments = attachmentsToSend; + } + if ((dispatchResult === "failed" || dispatchResult === "cancelled") && recovery) { + if (!restoreFailedCommandComposer(host, recovery)) { + releaseChatAttachmentPayloads(excludeComposerAttachments(host, recovery.attachments)); + } + } else if (dispatchResult === "completed" && recovery) { + if (submittedCommandConnectionIsCurrent(host, recovery)) { + clearOwnedCommandComposerFallback(host, recovery); + } + if (!commandComposerFallbackRetainsAttachments(host, recovery)) { + releaseChatAttachmentPayloads(excludeComposerAttachments(host, recovery.attachments)); } } }; diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index b8e167907703..d7365812018a 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -26,12 +26,14 @@ import { switchChatThinkingLevel, } from "./chat-session.ts"; import { patchChatSessionSettings } from "./chat-settings-patches.ts"; -import type { ChatPageHost } from "./chat-state-host.ts"; +import type { ChatComposerMemoryFallback, ChatPageHost } from "./chat-state-host.ts"; import { admitStoredChatComposerQueueItem, listStoredChatOutboxes, loadChatComposerSnapshot, removeStoredChatComposerQueueItem, + resolveStoredChatOutboxScope, + storedChatOutboxScopeKey, } from "./composer-persistence.ts"; import { getChatSessionProjection, setChatSessionProjection } from "./history-merge.ts"; import { handleChatInputHistoryKey } from "./input-history.ts"; @@ -74,6 +76,7 @@ type TestChatHost = Omit & { chatAvatarSource?: string | null; chatAvatarStatus?: "none" | "local" | "remote" | "data" | null; chatAvatarReason?: string | null; + chatComposerFallbackByScope: Record; sessionsError?: string | null; sessionsResultAgentId?: string | null; sessionsArchivedFilter?: "active" | "archived" | "all"; @@ -367,6 +370,7 @@ function makeHost(overrides?: MakeHostOverrides): TestChatHost | TestChatHostWit chatInputHistoryIndex: -1, chatDraftBeforeHistory: null, chatAttachments: [], + chatComposerFallbackByScope: {}, chatQueue: [], chatRunId: null, chatSending: false, @@ -3007,6 +3011,181 @@ describe("handleSendChat", () => { expect(host.chatMessage).toBe("/approve approval-123 allow-once"); }); + it("keeps a delayed approval failure recoverable in its submitted session", async () => { + const ack = createDeferred<{ runId: string; status: "error" }>(); + const attachment = registerChatAttachmentPayload({ + attachment: { + id: "approval-session-attachment", + mimeType: "text/plain", + fileName: "approval.txt", + }, + dataUrl: "data:text/plain;base64,YXBwcm92YWw=", + file: new File(["approval"], "approval.txt", { type: "text/plain" }), + }); + const host = makeHost({ + requestHandlers: { + "chat.send": () => ack.promise, + }, + chatAttachments: [attachment], + chatMessage: "/approve approval-123 allow-once", + chatRunId: "run-main", + chatStream: "Waiting for approval...", + sessionKey: "agent:main:first", + }); + + const send = handleSendChat(host); + await waitForFast(() => expect(host.request).toHaveBeenCalledOnce()); + host.sessionKey = "agent:main:second"; + host.chatMessage = "second session draft"; + host.lastError = "second session error"; + host.chatError = "second session error"; + + ack.resolve({ runId: "approval-command", status: "error" }); + await send; + + expect(host.chatMessage).toBe("second session draft"); + expect(host.lastError).toBe("second session error"); + expect(host.chatError).toBe("second session error"); + + const fallback = Object.values(host.chatComposerFallbackByScope)[0]; + expect(fallback?.message).toBe("/approve approval-123 allow-once"); + expect(fallback?.attachments).toEqual([expect.objectContaining({ id: attachment.id })]); + expect(getChatAttachmentDataUrl(fallback!.attachments[0]!)).toBe( + "data:text/plain;base64,YXBwcm92YWw=", + ); + }); + + it("keeps a delayed local-command failure recoverable in its submitted session", async () => { + const command = createDeferred>>(); + executeSlashCommandMock.mockImplementationOnce(() => command.promise); + const attachment = registerChatAttachmentPayload({ + attachment: { + id: "redirect-session-attachment", + mimeType: "text/plain", + fileName: "redirect.txt", + }, + dataUrl: "data:text/plain;base64,cmVkaXJlY3Q=", + file: new File(["redirect"], "redirect.txt", { type: "text/plain" }), + }); + const host = makeHost({ + chatAttachments: [attachment], + chatMessage: "/redirect start over", + client: clientWithRequest(vi.fn()), + connectionEpoch: 1, + sessionKey: "agent:main:first", + }); + + const send = handleSendChat(host); + await waitForFast(() => expect(executeSlashCommandMock).toHaveBeenCalledOnce()); + host.sessionKey = "agent:main:second"; + host.chatMessage = "second session draft"; + host.lastError = "second session error"; + host.chatError = "second session error"; + + command.resolve({ content: "Redirect failed.", failed: true }); + await send; + + expect(host.chatMessage).toBe("second session draft"); + expect(host.lastError).toBe("second session error"); + expect(host.chatError).toBe("second session error"); + + const fallback = Object.values(host.chatComposerFallbackByScope)[0]; + expect(fallback?.message).toBe("/redirect start over"); + expect(fallback?.attachments).toEqual([expect.objectContaining({ id: attachment.id })]); + expect(getChatAttachmentDataUrl(fallback!.attachments[0]!)).toBe( + "data:text/plain;base64,cmVkaXJlY3Q=", + ); + }); + + it("does not recover a delayed local command through a replaced connection", async () => { + const command = createDeferred>>(); + executeSlashCommandMock.mockImplementationOnce(() => command.promise); + const attachment = registerChatAttachmentPayload({ + attachment: { + id: "stale-connection-attachment", + mimeType: "text/plain", + }, + dataUrl: "data:text/plain;base64,c3RhbGU=", + file: new File(["stale"], "stale.txt", { type: "text/plain" }), + }); + const originalClient = clientWithRequest(vi.fn()); + const host = makeHost({ + chatAttachments: [attachment], + chatMessage: "/redirect start over", + client: originalClient, + connectionEpoch: 1, + sessionKey: "agent:main:first", + }); + + const send = handleSendChat(host); + await waitForFast(() => expect(executeSlashCommandMock).toHaveBeenCalledOnce()); + host.client = clientWithRequest(vi.fn()); + host.connectionEpoch = 2; + host.chatMessage = "new connection draft"; + + command.resolve({ content: "Redirect failed.", failed: true }); + await send; + + expect(host.chatMessage).toBe("new connection draft"); + expect(host.chatComposerFallbackByScope).toEqual({}); + expect(getChatAttachmentDataUrl(attachment)).toBeNull(); + }); + + it("does not overwrite newer same-session input after a delayed command failure", async () => { + const command = createDeferred>>(); + executeSlashCommandMock.mockImplementationOnce(() => command.promise); + const host = makeHost({ + chatMessage: "/redirect start over", + client: clientWithRequest(vi.fn()), + connectionEpoch: 1, + sessionKey: "agent:main:first", + }); + + const send = handleSendChat(host); + await waitForFast(() => expect(executeSlashCommandMock).toHaveBeenCalledOnce()); + host.chatMessage = "newer draft"; + + command.resolve({ content: "Redirect failed.", failed: true }); + await send; + + expect(host.chatMessage).toBe("newer draft"); + expect(host.chatComposerFallbackByScope).toEqual({}); + }); + + it("clears the owned fallback after a successful local command retry", async () => { + executeSlashCommandMock.mockResolvedValueOnce({ content: "Redirected.", failed: false }); + const attachment = registerChatAttachmentPayload({ + attachment: { + id: "successful-retry-attachment", + mimeType: "text/plain", + }, + dataUrl: "data:text/plain;base64,c3VjY2Vzcw==", + file: new File(["success"], "success.txt", { type: "text/plain" }), + }); + const host = makeHost({ + chatAttachments: [attachment], + chatMessage: "/redirect start over", + client: clientWithRequest(vi.fn()), + connectionEpoch: 1, + sessionKey: "agent:main:first", + }); + const scopeKey = storedChatOutboxScopeKey(resolveStoredChatOutboxScope(host, host.sessionKey)); + host.chatComposerFallbackByScope = { + [scopeKey]: { + message: host.chatMessage, + attachments: [attachment], + storageFailed: false, + sequence: 41, + }, + }; + + await handleSendChat(host); + + expect(host.chatMessage).toBe(""); + expect(host.chatComposerFallbackByScope).toEqual({}); + expect(getChatAttachmentDataUrl(attachment)).toBeNull(); + }); + it("routes /side through the same session companion path", async () => { const openSessionCompanion = vi.fn(); const host = makeHost({ diff --git a/ui/src/pages/chat/chat-send.ts b/ui/src/pages/chat/chat-send.ts index ddfe8daa1508..c6aad6dd319b 100644 --- a/ui/src/pages/chat/chat-send.ts +++ b/ui/src/pages/chat/chat-send.ts @@ -1,7 +1,4 @@ -export { - cancelPendingSendBeforeRequest, - pendingComposerRestorePlan, -} from "./chat-send-composer.ts"; +export { cancelPendingSendBeforeRequest } from "./chat-send-composer.ts"; export { chatOutboxDrainDependencies, sendChatMessageNow, diff --git a/ui/src/pages/chat/chat-state-route.ts b/ui/src/pages/chat/chat-state-route.ts index f764d2f5bb42..fe08dcd9f8ff 100644 --- a/ui/src/pages/chat/chat-state-route.ts +++ b/ui/src/pages/chat/chat-state-route.ts @@ -1,7 +1,7 @@ import { loadLocalAssistantIdentity } from "../../app/assistant-identity.ts"; import { loadSettings, patchSettings } from "../../app/settings.ts"; import { isRenderableControlUiAvatarUrl } from "../../lib/avatar.ts"; -import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; +import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts"; import { scopedAgentParamsForSession, type SessionCapability } from "../../lib/sessions/index.ts"; import { DEFAULT_MAIN_KEY, @@ -56,6 +56,10 @@ type ChatComposerRouteResetResult = { restoredStorageFailure: boolean; }; +export type ChatComposerMemoryFallbackOwnership = { + sequence: number; +}; + export function canCreateChatSession(state: ChatPageHost) { return ( !state.chatLoading && @@ -141,8 +145,9 @@ function restoreChatMessagesForSession( function resolveChatComposerMemoryFallback( state: ChatPageHost, sessionKey: string, + scopeOverride?: StoredChatOutboxScope, ): { fallback?: ChatComposerMemoryFallback; scopeKey: string } { - const scope = resolveStoredChatOutboxScope(state, sessionKey); + const scope = scopeOverride ?? resolveStoredChatOutboxScope(state, sessionKey); const scopeKey = storedChatOutboxScopeKey(scope); const fallback = state.chatComposerFallbackByScope[scopeKey]; const selectedGlobalAgentId = resolveUiKnownSelectedGlobalAgentId(state); @@ -235,6 +240,96 @@ export function saveRouteSessionSettings(state: ChatPageHost, sessionKey: string state.settings = patchSettings({ sessionKey, lastActiveSessionKey: sessionKey }); } +function chatAttachmentsMatch( + left: readonly ChatAttachment[], + right: readonly ChatAttachment[], +): boolean { + return ( + left.length === right.length && + left.every((attachment, index) => attachment.id === right[index]?.id) + ); +} + +export function retainChatComposerMemoryFallback( + state: ChatPageHost, + scope: StoredChatOutboxScope, + composer: { message: string; attachments: ChatAttachment[] }, +): ChatComposerMemoryFallbackOwnership | undefined { + const { fallback: existing, scopeKey } = resolveChatComposerMemoryFallback( + state, + scope.sessionKey, + scope, + ); + const existingMatches = + existing?.message === composer.message && + chatAttachmentsMatch(existing.attachments, composer.attachments); + if (existing && existingMatches) { + return { sequence: existing.sequence }; + } + if ( + existing && + (existing.storageFailed || existing.message.trim() || existing.attachments.length > 0) + ) { + return undefined; + } + const sequence = ++lastChatComposerMemoryFallbackSequence; + state.chatComposerFallbackByScope = { + ...state.chatComposerFallbackByScope, + [scopeKey]: { + message: composer.message, + attachments: [...composer.attachments], + storageFailed: false, + sequence, + }, + }; + return { sequence }; +} + +export function captureChatComposerMemoryFallbackOwnership( + state: ChatPageHost, + scope: StoredChatOutboxScope, + composer: { message: string; attachments: ChatAttachment[] }, +): ChatComposerMemoryFallbackOwnership | undefined { + const { fallback: existing } = resolveChatComposerMemoryFallback(state, scope.sessionKey, scope); + if ( + existing?.message !== composer.message || + !chatAttachmentsMatch(existing.attachments, composer.attachments) + ) { + return undefined; + } + return { sequence: existing.sequence }; +} + +export function ownsChatComposerMemoryFallback( + state: Pick, + ownership: ChatComposerMemoryFallbackOwnership, +): boolean { + return Object.values(state.chatComposerFallbackByScope).some( + (fallback) => fallback.sequence === ownership.sequence, + ); +} + +export function clearChatComposerMemoryFallback( + state: Pick, + ownership: ChatComposerMemoryFallbackOwnership | undefined, +): boolean { + if (!ownership) { + return false; + } + const ownedEntries = Object.entries(state.chatComposerFallbackByScope).filter( + ([, fallback]) => fallback.sequence === ownership.sequence, + ); + if (ownedEntries.length === 0) { + return false; + } + const nextFallbacks = { ...state.chatComposerFallbackByScope }; + for (const [scopeKey] of ownedEntries) { + delete nextFallbacks[scopeKey]; + } + state.chatComposerFallbackByScope = nextFallbacks; + return true; +} + export function resetChatStateForRouteSession( state: ChatPageHost, sessionKey: string, diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index cc5b7db7eb18..f1b9a64f6ba6 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -28,6 +28,8 @@ import { refreshChatModelAuthStatus, } from "./chat-state-refresh.ts"; import { + clearChatComposerMemoryFallback, + retainChatComposerMemoryFallback, resetChatStateForRouteSession, retryChatComposerMemoryFallback, resolveChatAvatarUrl, @@ -1649,6 +1651,127 @@ describe("route composer fallback", () => { expect(resetChatScroll).toHaveBeenCalledTimes(2); }); + it("restores a retained command after leaving and returning to its session", () => { + vi.stubGlobal("sessionStorage", createStorageMock()); + const { state } = createRouteState(""); + state.chatAttachments = []; + const scope = resolveStoredChatOutboxScope(state, state.sessionKey); + resetChatStateForRouteSession(state, "agent:main:second"); + + expect( + retainChatComposerMemoryFallback(state, scope, { + message: "/approve approval-123 allow-once", + attachments: [ + { + id: "approval-command-attachment", + mimeType: "text/plain", + dataUrl: "data:text/plain;base64,YXBwcm92YWw=", + }, + ], + }), + ).toBeDefined(); + + expect(resetChatStateForRouteSession(state, "agent:main:first")).toEqual({ + restoredFallback: true, + restoredStorageFailure: false, + }); + expect(state.chatMessage).toBe("/approve approval-123 allow-once"); + expect(state.chatAttachments).toEqual([ + expect.objectContaining({ id: "approval-command-attachment" }), + ]); + }); + + it("preserves matching storage-failure fallback metadata", () => { + vi.stubGlobal("sessionStorage", createStorageMock()); + const { state } = createRouteState("retry this draft"); + state.chatAttachments = []; + const scope = resolveStoredChatOutboxScope(state, state.sessionKey); + const scopeKey = storedChatOutboxScopeKey(scope); + state.chatComposerFallbackByScope = { + [scopeKey]: { + message: state.chatMessage, + attachments: [], + storageFailed: true, + draftRetry: { expectedDraftRevision: 4, draftRevision: 5 }, + sequence: 42, + }, + }; + + expect( + retainChatComposerMemoryFallback(state, scope, { + message: state.chatMessage, + attachments: [], + }), + ).toEqual({ sequence: 42 }); + expect(state.chatComposerFallbackByScope[scopeKey]).toEqual({ + message: "retry this draft", + attachments: [], + storageFailed: true, + draftRetry: { expectedDraftRevision: 4, draftRevision: 5 }, + sequence: 42, + }); + }); + + it("does not replace a newer alias-equivalent fallback", () => { + vi.stubGlobal("sessionStorage", createStorageMock()); + const { state } = createRouteState(""); + state.assistantAgentId = "work"; + state.agentsList = { + agents: [], + defaultId: "work", + mainKey: "workspace", + scope: "global", + }; + const unresolvedScopeKey = storedChatOutboxScopeKey({ sessionKey: "workspace" }); + state.chatComposerFallbackByScope = { + [unresolvedScopeKey]: { + message: "newer alias draft", + attachments: [], + storageFailed: false, + sequence: 43, + }, + }; + const scope = resolveStoredChatOutboxScope(state, "agent:work:workspace"); + + expect( + retainChatComposerMemoryFallback(state, scope, { + message: "/redirect start over", + attachments: [], + }), + ).toBeUndefined(); + const resolvedScopeKey = storedChatOutboxScopeKey(scope); + expect(state.chatComposerFallbackByScope[unresolvedScopeKey]).toBeUndefined(); + expect(state.chatComposerFallbackByScope[resolvedScopeKey]?.message).toBe("newer alias draft"); + }); + + it("clears only the fallback owned by a completed retry", () => { + const { state } = createRouteState(""); + state.chatComposerFallbackByScope = { + first: { + message: "/redirect start over", + attachments: [], + storageFailed: false, + sequence: 44, + }, + second: { + message: "newer draft", + attachments: [], + storageFailed: false, + sequence: 45, + }, + }; + + expect(clearChatComposerMemoryFallback(state, { sequence: 44 })).toBe(true); + expect(state.chatComposerFallbackByScope).toEqual({ + second: { + message: "newer draft", + attachments: [], + storageFailed: false, + sequence: 45, + }, + }); + }); + it("adopts an unresolved bare-main fallback when the default agent becomes known", () => { vi.stubGlobal("sessionStorage", createStorageMock()); const { state } = createRouteState("unresolved memory draft");