mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 10:01:34 +00:00
fix: scope failed command recovery
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof setLastActiveSessionKey>[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));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<ChatHost, "settings"> & {
|
||||
chatAvatarSource?: string | null;
|
||||
chatAvatarStatus?: "none" | "local" | "remote" | "data" | null;
|
||||
chatAvatarReason?: string | null;
|
||||
chatComposerFallbackByScope: Record<string, ChatComposerMemoryFallback>;
|
||||
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<Awaited<ReturnType<ExecuteSlashCommand>>>();
|
||||
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<Awaited<ReturnType<ExecuteSlashCommand>>>();
|
||||
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<Awaited<ReturnType<ExecuteSlashCommand>>>();
|
||||
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({
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
export {
|
||||
cancelPendingSendBeforeRequest,
|
||||
pendingComposerRestorePlan,
|
||||
} from "./chat-send-composer.ts";
|
||||
export { cancelPendingSendBeforeRequest } from "./chat-send-composer.ts";
|
||||
export {
|
||||
chatOutboxDrainDependencies,
|
||||
sendChatMessageNow,
|
||||
|
||||
@@ -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<ChatPageHost, "chatComposerFallbackByScope">,
|
||||
ownership: ChatComposerMemoryFallbackOwnership,
|
||||
): boolean {
|
||||
return Object.values(state.chatComposerFallbackByScope).some(
|
||||
(fallback) => fallback.sequence === ownership.sequence,
|
||||
);
|
||||
}
|
||||
|
||||
export function clearChatComposerMemoryFallback(
|
||||
state: Pick<ChatPageHost, "chatComposerFallbackByScope">,
|
||||
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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user