mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: preserve fallback attachment packages across remounts
This commit is contained in:
@@ -59,12 +59,15 @@ describe("chat attachment route handoff", () => {
|
||||
paneId: "p1",
|
||||
scopeKey: "agent:main:one",
|
||||
attachments: staged,
|
||||
fallbacks: {},
|
||||
});
|
||||
|
||||
const consumed = handoff.consume({ owner, paneId: "p1", scopeKey: "agent:main:one" });
|
||||
expect(consumed).toEqual(staged);
|
||||
expect(consumed).not.toBe(staged);
|
||||
expect(consumed?.every((attachment, index) => attachment === staged[index])).toBe(true);
|
||||
expect(consumed?.attachments).toEqual(staged);
|
||||
expect(consumed?.attachments).not.toBe(staged);
|
||||
expect(consumed?.attachments.every((attachment, index) => attachment === staged[index])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(handoff.consume({ owner, paneId: "p1", scopeKey: "agent:main:one" })).toBeNull();
|
||||
for (const attachment of ordinary) {
|
||||
expect(getChatAttachmentDataUrl(attachment)).not.toBeNull();
|
||||
@@ -89,6 +92,7 @@ describe("chat attachment route handoff", () => {
|
||||
paneId: "p1",
|
||||
scopeKey: "agent:main:one",
|
||||
attachments: [annotation],
|
||||
fallbacks: {},
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -108,10 +112,16 @@ describe("chat attachment route handoff", () => {
|
||||
const oversized = Array.from({ length: 33 }, (_, index) =>
|
||||
storedAttachment(`oversized-${index}`, "image/png", false),
|
||||
);
|
||||
handoff.prepare({ owner, paneId: "oversized", scopeKey: "oversized", attachments: oversized });
|
||||
expect(handoff.consume({ owner, paneId: "oversized", scopeKey: "oversized" })).toEqual(
|
||||
oversized,
|
||||
);
|
||||
handoff.prepare({
|
||||
owner,
|
||||
paneId: "oversized",
|
||||
scopeKey: "oversized",
|
||||
attachments: oversized,
|
||||
fallbacks: {},
|
||||
});
|
||||
expect(
|
||||
handoff.consume({ owner, paneId: "oversized", scopeKey: "oversized" })?.attachments,
|
||||
).toEqual(oversized);
|
||||
expect(getChatAttachmentDataUrl(oversized[32]!)).not.toBeNull();
|
||||
|
||||
const annotations = Array.from({ length: 33 }, (_, index) =>
|
||||
@@ -123,6 +133,7 @@ describe("chat attachment route handoff", () => {
|
||||
paneId: `p${index}`,
|
||||
scopeKey: `scope-${index}`,
|
||||
attachments: [annotation],
|
||||
fallbacks: {},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -144,6 +155,7 @@ describe("chat attachment route handoff", () => {
|
||||
paneId: "p1",
|
||||
scopeKey: "agent:main:one",
|
||||
attachments: [annotation],
|
||||
fallbacks: {},
|
||||
});
|
||||
|
||||
expect(getChatAttachmentDataUrl(annotation)).toBeNull();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChatAttachment } from "../lib/chat/chat-types.ts";
|
||||
import type { ChatAttachment, ChatComposerMemoryFallback } from "../lib/chat/chat-types.ts";
|
||||
import { releaseChatAttachmentPayloads } from "../pages/chat/attachment-payload-store.ts";
|
||||
import type { ApplicationChatAttachmentHandoff } from "./context.ts";
|
||||
|
||||
@@ -10,6 +10,7 @@ type PendingChatAttachmentHandoff = {
|
||||
owner: NonNullable<Parameters<ApplicationChatAttachmentHandoff["prepare"]>[0]["owner"]>;
|
||||
scopeKey: string;
|
||||
attachments: ChatAttachment[];
|
||||
fallbacks: Record<string, ChatComposerMemoryFallback>;
|
||||
};
|
||||
|
||||
export function createChatAttachmentHandoff(): ApplicationChatAttachmentHandoff {
|
||||
@@ -18,6 +19,18 @@ export function createChatAttachmentHandoff(): ApplicationChatAttachmentHandoff
|
||||
|
||||
const release = (attachments: readonly ChatAttachment[] = []) =>
|
||||
releaseChatAttachmentPayloads(attachments);
|
||||
const releaseHandoff = (handoff: PendingChatAttachmentHandoff | undefined) => {
|
||||
if (!handoff) {
|
||||
return;
|
||||
}
|
||||
const byId = new Map(handoff.attachments.map((attachment) => [attachment.id, attachment]));
|
||||
for (const fallback of Object.values(handoff.fallbacks)) {
|
||||
for (const attachment of fallback.attachments) {
|
||||
byId.set(attachment.id, attachment);
|
||||
}
|
||||
}
|
||||
release([...byId.values()]);
|
||||
};
|
||||
const take = (paneId: string) => {
|
||||
const handoff = pending.get(paneId);
|
||||
if (handoff) {
|
||||
@@ -27,26 +40,39 @@ export function createChatAttachmentHandoff(): ApplicationChatAttachmentHandoff
|
||||
};
|
||||
|
||||
return {
|
||||
prepare: ({ owner, paneId, scopeKey, attachments }) => {
|
||||
prepare: ({ owner, paneId, scopeKey, attachments, fallbacks }) => {
|
||||
const previous = take(paneId);
|
||||
if (attachments.length === 0) {
|
||||
release(previous?.attachments);
|
||||
const fallbackEntries = Object.entries(fallbacks);
|
||||
if (attachments.length === 0 && fallbackEntries.length === 0) {
|
||||
releaseHandoff(previous);
|
||||
return;
|
||||
}
|
||||
const retainedIds = new Set(attachments.map((attachment) => attachment.id));
|
||||
release(previous?.attachments.filter((attachment) => !retainedIds.has(attachment.id)));
|
||||
releaseHandoff(previous);
|
||||
if (!owner || disposed) {
|
||||
release(attachments);
|
||||
for (const fallback of Object.values(fallbacks)) {
|
||||
release(fallback.attachments);
|
||||
}
|
||||
return;
|
||||
}
|
||||
pending.set(paneId, { owner, scopeKey, attachments: [...attachments] });
|
||||
pending.set(paneId, {
|
||||
owner,
|
||||
scopeKey,
|
||||
attachments: [...attachments],
|
||||
fallbacks: Object.fromEntries(
|
||||
fallbackEntries.map(([key, fallback]) => [
|
||||
key,
|
||||
{ ...fallback, attachments: [...fallback.attachments] },
|
||||
]),
|
||||
),
|
||||
});
|
||||
// Route handoffs normally consume immediately. Bounds make abandoned
|
||||
// split panes release their packages instead of leaking for the tab lifetime.
|
||||
for (const oldestPaneId of pending.keys()) {
|
||||
if (pending.size <= MAX_PENDING_CHAT_ATTACHMENT_ENTRIES) {
|
||||
break;
|
||||
}
|
||||
release(take(oldestPaneId)?.attachments);
|
||||
releaseHandoff(take(oldestPaneId));
|
||||
}
|
||||
},
|
||||
consume: ({ owner, paneId, scopeKey }) => {
|
||||
@@ -54,16 +80,16 @@ export function createChatAttachmentHandoff(): ApplicationChatAttachmentHandoff
|
||||
// Reusing a pane id with another session or Gateway is terminal for the
|
||||
// old owner; keeping it would allow a later remount to recover stale evidence.
|
||||
if (match?.owner === owner && match.scopeKey === scopeKey) {
|
||||
return match.attachments;
|
||||
return { attachments: match.attachments, fallbacks: match.fallbacks };
|
||||
}
|
||||
release(match?.attachments);
|
||||
releaseHandoff(match);
|
||||
return null;
|
||||
},
|
||||
clearPane: (paneId) => release(take(paneId)?.attachments),
|
||||
clearPane: (paneId) => releaseHandoff(take(paneId)),
|
||||
dispose: () => {
|
||||
disposed = true;
|
||||
for (const handoff of pending.values()) {
|
||||
release(handoff.attachments);
|
||||
releaseHandoff(handoff);
|
||||
}
|
||||
pending.clear();
|
||||
},
|
||||
|
||||
+11
-3
@@ -4,7 +4,7 @@ import type { RouteId } from "../app-route-paths.ts";
|
||||
import type { AgentIdentityCapability } from "../lib/agents/identity.ts";
|
||||
import type { AgentCapability } from "../lib/agents/index.ts";
|
||||
import type { ChannelCapability } from "../lib/channels/index.ts";
|
||||
import type { ChatAttachment } from "../lib/chat/chat-types.ts";
|
||||
import type { ChatAttachment, ChatComposerMemoryFallback } from "../lib/chat/chat-types.ts";
|
||||
import type { RuntimeConfigCapability } from "../lib/config/index.ts";
|
||||
import type { SessionCapability } from "../lib/sessions/index.ts";
|
||||
import type { WorkboardCapability } from "../lib/workboard/capability.ts";
|
||||
@@ -80,8 +80,16 @@ type ChatAttachmentHandoffKey = {
|
||||
};
|
||||
|
||||
export type ApplicationChatAttachmentHandoff = {
|
||||
prepare(handoff: ChatAttachmentHandoffKey & { attachments: readonly ChatAttachment[] }): void;
|
||||
consume(handoff: ChatAttachmentHandoffKey): ChatAttachment[] | null;
|
||||
prepare(
|
||||
handoff: ChatAttachmentHandoffKey & {
|
||||
attachments: readonly ChatAttachment[];
|
||||
fallbacks: Readonly<Record<string, ChatComposerMemoryFallback>>;
|
||||
},
|
||||
): void;
|
||||
consume(handoff: ChatAttachmentHandoffKey): {
|
||||
attachments: ChatAttachment[];
|
||||
fallbacks: Record<string, ChatComposerMemoryFallback>;
|
||||
} | null;
|
||||
clearPane(paneId: string): void;
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
@@ -24,6 +24,19 @@ export type ChatAttachment = {
|
||||
browserAnnotation?: BrowserAnnotationAttachment;
|
||||
};
|
||||
|
||||
export type ChatComposerDraftRetry = {
|
||||
expectedDraftRevision: number;
|
||||
draftRevision: number;
|
||||
};
|
||||
|
||||
export type ChatComposerMemoryFallback = {
|
||||
message: string;
|
||||
attachments: ChatAttachment[];
|
||||
storageFailed: boolean;
|
||||
draftRetry?: ChatComposerDraftRetry;
|
||||
sequence: number;
|
||||
};
|
||||
|
||||
export type ChatQueueSkillWorkshopRevision = {
|
||||
proposalId: string;
|
||||
agentId?: string;
|
||||
|
||||
@@ -29,13 +29,6 @@ function releaseAttachments(
|
||||
}
|
||||
}
|
||||
|
||||
function releaseFallbackAttachments(state: ChatPageHost, retainedIds = new Set<string>()): void {
|
||||
const releasedIds = new Set<string>();
|
||||
for (const fallback of Object.values(state.chatComposerFallbackByScope)) {
|
||||
releaseAttachments(fallback.attachments, retainedIds, releasedIds);
|
||||
}
|
||||
}
|
||||
|
||||
export function restorePaneStagedAttachments(
|
||||
context: ApplicationContext,
|
||||
paneId: string,
|
||||
@@ -49,8 +42,12 @@ export function restorePaneStagedAttachments(
|
||||
const currentIds = new Set(state.chatAttachments.map((attachment) => attachment.id));
|
||||
state.chatAttachments = [
|
||||
...state.chatAttachments,
|
||||
...restored.filter((attachment) => !currentIds.has(attachment.id)),
|
||||
...restored.attachments.filter((attachment) => !currentIds.has(attachment.id)),
|
||||
];
|
||||
state.chatComposerFallbackByScope = {
|
||||
...restored.fallbacks,
|
||||
...state.chatComposerFallbackByScope,
|
||||
};
|
||||
}
|
||||
|
||||
export function preparePaneStagedAttachments(
|
||||
@@ -63,8 +60,8 @@ export function preparePaneStagedAttachments(
|
||||
context.chatAttachmentHandoff.prepare({
|
||||
...handoffKey(paneId, state, owner),
|
||||
attachments,
|
||||
fallbacks: state.chatComposerFallbackByScope,
|
||||
});
|
||||
releaseFallbackAttachments(state, new Set(attachments.map((attachment) => attachment.id)));
|
||||
}
|
||||
|
||||
export function discardStateStagedAttachments(state: ChatPageHost | undefined): void {
|
||||
|
||||
@@ -108,17 +108,24 @@ describe("staged attachment composer adoption", () => {
|
||||
pane.disconnectedCallback();
|
||||
|
||||
expect(getChatAttachmentDataUrl(shared)).not.toBeNull();
|
||||
expect(getChatAttachmentDataUrl(fallback)).toBeNull();
|
||||
expect(getChatAttachmentDataUrl(fallback)).not.toBeNull();
|
||||
expect(getChatAttachmentDataUrl(ordinary)).not.toBeNull();
|
||||
const transferred = pane.context.chatAttachmentHandoff.consume({
|
||||
owner,
|
||||
paneId: pane.paneId,
|
||||
scopeKey,
|
||||
});
|
||||
expect(transferred).toEqual([shared, ordinary]);
|
||||
expect(transferred?.[0]).toBe(shared);
|
||||
expect(transferred?.[1]).toBe(ordinary);
|
||||
expect(transferred?.attachments).toEqual([shared, ordinary]);
|
||||
expect(transferred?.attachments[0]).toBe(shared);
|
||||
expect(transferred?.attachments[1]).toBe(ordinary);
|
||||
expect(transferred?.fallbacks.fallback).toMatchObject({
|
||||
message: "",
|
||||
sequence: 1,
|
||||
storageFailed: false,
|
||||
});
|
||||
expect(transferred?.fallbacks.fallback?.attachments).toEqual([shared, fallback, ordinary]);
|
||||
releaseChatAttachmentPayload(shared.id);
|
||||
releaseChatAttachmentPayload(fallback.id);
|
||||
releaseChatAttachmentPayload(ordinary.id);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import type { UiSettings } from "../../app/settings.ts";
|
||||
import type { ImageLightboxItem } from "../../components/image-lightbox.ts";
|
||||
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
|
||||
import type { ChatComposerMemoryFallback } from "../../lib/chat/chat-types.ts";
|
||||
import type { EmbedSandboxMode } from "../../lib/chat/tool-display.ts";
|
||||
import type { ChatState } from "./chat-history.ts";
|
||||
import type { ChatRealtimeState } from "./chat-realtime.ts";
|
||||
@@ -18,7 +18,6 @@ import type { ChatProps } from "./chat-view.ts";
|
||||
import type { BackgroundTasksHost } from "./components/chat-background-tasks.ts";
|
||||
import type { SessionWorkspaceHost } from "./components/chat-session-workspace.ts";
|
||||
import type { SidebarContent } from "./components/chat-sidebar.ts";
|
||||
import type { ChatComposerDraftRetry } from "./composer-persistence.ts";
|
||||
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "./input-history.ts";
|
||||
import type { RenderLifecycle } from "./render-lifecycle.ts";
|
||||
import type { PendingChatAbort } from "./run-lifecycle.ts";
|
||||
@@ -32,13 +31,7 @@ import type {
|
||||
WaitingApprovalStatus,
|
||||
} from "./tool-stream.ts";
|
||||
|
||||
export type ChatComposerMemoryFallback = {
|
||||
message: string;
|
||||
attachments: ChatAttachment[];
|
||||
storageFailed: boolean;
|
||||
draftRetry?: ChatComposerDraftRetry;
|
||||
sequence: number;
|
||||
};
|
||||
export type { ChatComposerMemoryFallback } from "../../lib/chat/chat-types.ts";
|
||||
|
||||
export type ChatPageHost = ChatHost &
|
||||
ChatState &
|
||||
|
||||
@@ -4478,11 +4478,12 @@ describe("chat attachment picker", () => {
|
||||
paneId: "p1",
|
||||
scopeKey: "agent:main:one",
|
||||
attachments,
|
||||
fallbacks: {},
|
||||
});
|
||||
attachments = expectDefined(
|
||||
handoff.consume({ owner, paneId: "p1", scopeKey: "agent:main:one" }),
|
||||
"restored attachments",
|
||||
);
|
||||
).attachments;
|
||||
|
||||
expect(attachments).toHaveLength(1);
|
||||
expect(attachments[0]).toBe(original);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts";
|
||||
import type {
|
||||
ChatAttachment,
|
||||
ChatComposerDraftRetry,
|
||||
ChatQueueItem,
|
||||
} from "../../lib/chat/chat-types.ts";
|
||||
import {
|
||||
INTERRUPTED_SETTINGS_WAIT_ERROR,
|
||||
MAX_STORED_QUEUE_ITEMS,
|
||||
@@ -66,10 +70,7 @@ type RestoreOptions = {
|
||||
sessionKey?: string;
|
||||
};
|
||||
|
||||
export type ChatComposerDraftRetry = {
|
||||
expectedDraftRevision: number;
|
||||
draftRevision: number;
|
||||
};
|
||||
export type { ChatComposerDraftRetry } from "../../lib/chat/chat-types.ts";
|
||||
|
||||
type ChatComposerPersistStatus = "persisted" | "conflict" | "storage-failed";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user