mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
fix(ui): release losing fallback payloads on composer adoption (#124391)
Global-scope composer fallback adoption coalesces sibling candidates (bare-global, default-main, qualified-main) into one winner and deletes the losers from chatComposerFallbackByScope — but never released their attachment payload-store entries, unlike every clearChatComposerMemory- Fallback caller. The dropped data URLs and object URLs leaked for the pane's lifetime. Release dropped candidates' payloads at the adoption site, retaining ids still referenced by the live composer or any surviving fallback (the same retention rule the pane-handoff owner uses).
This commit is contained in:
committed by
GitHub
parent
c6ae99bb96
commit
9a3f3b7bf1
@@ -87,6 +87,20 @@ export function releaseChatAttachmentPayloads(attachments: readonly ChatAttachme
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases displaced attachments except ids still referenced by a retained
|
||||
* owner (live composer, surviving fallbacks). Attachments are backups of
|
||||
* composer state, so shared ids across owners are the norm — dropping one
|
||||
* owner must never revoke another owner's payload.
|
||||
*/
|
||||
export function releaseDisplacedChatAttachmentPayloads(
|
||||
displaced: readonly ChatAttachment[],
|
||||
retained: ReadonlyArray<readonly ChatAttachment[]>,
|
||||
): void {
|
||||
const retainedIds = new Set(retained.flat().map((attachment) => attachment.id));
|
||||
releaseChatAttachmentPayloads(displaced.filter((attachment) => !retainedIds.has(attachment.id)));
|
||||
}
|
||||
|
||||
export function generateAttachmentId(): string {
|
||||
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
|
||||
import {
|
||||
getChatAttachmentDataUrl,
|
||||
registerChatAttachmentPayload,
|
||||
} from "./attachment-payload-store.ts";
|
||||
import { retainChatComposerMemoryFallback } from "./chat-composer-memory-fallback.ts";
|
||||
import type { ChatComposerMemoryFallback, ChatPageHost } from "./chat-state-host.ts";
|
||||
import { storedChatOutboxScopeKey } from "./composer-persistence.ts";
|
||||
|
||||
function storedAttachment(id: string, mimeType = "image/png"): ChatAttachment {
|
||||
return registerChatAttachmentPayload({
|
||||
attachment: { id, mimeType },
|
||||
dataUrl: `data:${mimeType};base64,${id}`,
|
||||
file: new File([id], id, { type: mimeType }),
|
||||
});
|
||||
}
|
||||
|
||||
function globalHost(
|
||||
fallbacks: Record<string, ChatComposerMemoryFallback>,
|
||||
attachments: ChatAttachment[] = [],
|
||||
): ChatPageHost {
|
||||
return {
|
||||
agentsList: { defaultId: "main", mainKey: "main" },
|
||||
assistantAgentId: "main",
|
||||
chatAttachments: attachments,
|
||||
chatComposerFallbackByScope: fallbacks,
|
||||
hello: null,
|
||||
sessionKey: "global",
|
||||
settings: { gatewayUrl: "ws://example.test" },
|
||||
} as unknown as ChatPageHost;
|
||||
}
|
||||
|
||||
describe("chat composer memory fallback adoption", () => {
|
||||
it("releases losing sibling fallback payloads when adoption drops them", () => {
|
||||
const losing = storedAttachment("losing-sibling-attachment");
|
||||
const winning = storedAttachment("winning-attachment");
|
||||
const scope = { sessionKey: "global", agentId: "main" } as const;
|
||||
const scopeKey = storedChatOutboxScopeKey(scope);
|
||||
const bareGlobalKey = storedChatOutboxScopeKey({ sessionKey: "global" });
|
||||
const host = globalHost({
|
||||
[bareGlobalKey]: {
|
||||
message: "older sibling draft",
|
||||
attachments: [losing],
|
||||
storageFailed: true,
|
||||
sequence: 1,
|
||||
},
|
||||
[scopeKey]: {
|
||||
message: "newest draft",
|
||||
attachments: [winning],
|
||||
storageFailed: true,
|
||||
sequence: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const ownership = retainChatComposerMemoryFallback(host, scope, {
|
||||
message: "newest draft",
|
||||
attachments: [winning],
|
||||
});
|
||||
|
||||
expect(ownership).toEqual({ sequence: 2 });
|
||||
expect(Object.keys(host.chatComposerFallbackByScope)).toEqual([scopeKey]);
|
||||
// The losing sibling was dropped for good: its payload-store entry must be
|
||||
// released with it, while the adopted fallback's payload stays available.
|
||||
expect(getChatAttachmentDataUrl({ id: losing.id, mimeType: losing.mimeType })).toBeNull();
|
||||
expect(getChatAttachmentDataUrl({ id: winning.id, mimeType: winning.mimeType })).toBe(
|
||||
"data:image/png;base64,winning-attachment",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps payloads shared with the live composer when a sibling is dropped", () => {
|
||||
const shared = storedAttachment("shared-with-composer");
|
||||
const scope = { sessionKey: "global", agentId: "main" } as const;
|
||||
const scopeKey = storedChatOutboxScopeKey(scope);
|
||||
const bareGlobalKey = storedChatOutboxScopeKey({ sessionKey: "global" });
|
||||
const host = globalHost(
|
||||
{
|
||||
[bareGlobalKey]: {
|
||||
message: "older sibling draft",
|
||||
attachments: [shared],
|
||||
storageFailed: true,
|
||||
sequence: 1,
|
||||
},
|
||||
[scopeKey]: {
|
||||
message: "newest draft",
|
||||
attachments: [],
|
||||
storageFailed: true,
|
||||
sequence: 2,
|
||||
},
|
||||
},
|
||||
[shared],
|
||||
);
|
||||
|
||||
retainChatComposerMemoryFallback(host, scope, {
|
||||
message: "newest draft",
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
expect(getChatAttachmentDataUrl({ id: shared.id, mimeType: shared.mimeType })).toBe(
|
||||
"data:image/png;base64,shared-with-composer",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
resolveUiDefaultAgentId,
|
||||
resolveUiKnownSelectedGlobalAgentId,
|
||||
} from "../../lib/sessions/session-key.ts";
|
||||
import { releaseDisplacedChatAttachmentPayloads } from "./attachment-payload-store.ts";
|
||||
import type { ChatComposerMemoryFallback, ChatPageHost } from "./chat-state-host.ts";
|
||||
import {
|
||||
loadChatComposerCommittedDraftRevision,
|
||||
@@ -106,6 +107,13 @@ function resolveChatComposerMemoryFallback(
|
||||
delete nextFallbacks[candidate.scopeKey];
|
||||
}
|
||||
nextFallbacks[scopeKey] = adoptedFallback;
|
||||
// Losing sibling fallbacks are dropped for good here; release their
|
||||
// payload-store entries (like the pane-handoff owner does) or the data URLs
|
||||
// leak for the pane's lifetime.
|
||||
releaseDisplacedChatAttachmentPayloads(
|
||||
candidates.flatMap((candidate) => candidate.fallback.attachments),
|
||||
[state.chatAttachments, ...Object.values(nextFallbacks).map((f) => f.attachments)],
|
||||
);
|
||||
state.chatComposerFallbackByScope = nextFallbacks;
|
||||
return { fallback: adoptedFallback, scopeKey };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
|
||||
import { releaseChatAttachmentPayload } from "./attachment-payload-store.ts";
|
||||
import {
|
||||
releaseChatAttachmentPayload,
|
||||
releaseDisplacedChatAttachmentPayloads,
|
||||
} from "./attachment-payload-store.ts";
|
||||
import type { ChatPageHost } from "./chat-state-host.ts";
|
||||
import { resolveStoredChatOutboxScope, storedChatOutboxScopeKey } from "./composer-persistence.ts";
|
||||
import { panesOf, type ChatSplitLayout, visiblePanesOf } from "./split-layout.ts";
|
||||
@@ -51,13 +54,10 @@ export function restorePaneStagedAttachments(
|
||||
...restored.fallbacks,
|
||||
...state.chatComposerFallbackByScope,
|
||||
};
|
||||
const retainedIds = new Set(state.chatAttachments.map((attachment) => attachment.id));
|
||||
for (const fallback of Object.values(state.chatComposerFallbackByScope)) {
|
||||
for (const attachment of fallback.attachments) {
|
||||
retainedIds.add(attachment.id);
|
||||
}
|
||||
}
|
||||
releaseAttachments(displaced, retainedIds);
|
||||
releaseDisplacedChatAttachmentPayloads(displaced, [
|
||||
state.chatAttachments,
|
||||
...Object.values(state.chatComposerFallbackByScope).map((fallback) => fallback.attachments),
|
||||
]);
|
||||
}
|
||||
|
||||
export function preparePaneStagedAttachments(
|
||||
|
||||
Reference in New Issue
Block a user