mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
perf(ui): batch chat stream and outbox work into one frame (#127826)
* perf(ui): coalesce chat progress renders * perf(ui): bound outbox projection and retries * fix(ui): invalidate retained outbox projections * fix(ui): invalidate cleared outbox state * test(ui): identify outbox storage event source
This commit is contained in:
committed by
GitHub
parent
844e781ca4
commit
b099741805
@@ -801,7 +801,7 @@ suite.define(() => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(new StorageEvent("storage", { key }));
|
||||
window.dispatchEvent(new StorageEvent("storage", { key, storageArea: sessionStorage }));
|
||||
}, firstKey);
|
||||
await page
|
||||
.locator(
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { StoredComposerSession } from "./outbox-store-codec.ts";
|
||||
import {
|
||||
applyStoredChatOutboxScope,
|
||||
hasKnownSessionDefaults,
|
||||
readStoredOutboxStore,
|
||||
readProjectedOutboxStore,
|
||||
resolveComposerStorageScope,
|
||||
resolveStoredComposerSession,
|
||||
resolveStoredChatOutboxScope,
|
||||
@@ -36,7 +36,7 @@ function listStoredComposerRows(
|
||||
}
|
||||
try {
|
||||
const target = storageTargetForGateway(state.settings?.gatewayUrl);
|
||||
const store = readStoredOutboxStore(storage, target);
|
||||
const store = readProjectedOutboxStore(storage, target);
|
||||
let migrated = false;
|
||||
const selectedAgentId = resolveUiKnownSelectedGlobalAgentId(state);
|
||||
const defaultAgentId = hasKnownSessionDefaults(state)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
summarizeStoredChatOutboxes,
|
||||
} from "./outbox-store-projection.ts";
|
||||
import {
|
||||
readProjectedOutboxStore,
|
||||
resolveStoredChatOutboxScope,
|
||||
retireStoredComposerDrafts,
|
||||
storedChatOutboxScopeKey,
|
||||
@@ -23,6 +24,115 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("stored outbox summaries", () => {
|
||||
it("normalizes an unchanged projection once and refreshes after an external write", () => {
|
||||
const unsubscribe = subscribeStoredChatOutboxChanges(() => undefined);
|
||||
const gatewayUrl = "ws://gateway.test/control";
|
||||
const storageKey = `openclaw.control.chatComposer.v2:${encodeURIComponent(gatewayUrl)}`;
|
||||
sessionStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ version: 2, gatewayOwner: gatewayUrl, sessions: {} }),
|
||||
);
|
||||
const target = {
|
||||
gatewayOwner: gatewayUrl,
|
||||
key: storageKey,
|
||||
legacyKey: "unused",
|
||||
legacyOwnerIsUnambiguous: true,
|
||||
};
|
||||
const first = readProjectedOutboxStore(sessionStorage, target);
|
||||
expect(readProjectedOutboxStore(sessionStorage, target)).toBe(first);
|
||||
|
||||
sessionStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
gatewayOwner: gatewayUrl,
|
||||
sessions: { "main\u0000agent:main": { draft: "new", updatedAt: 1 } },
|
||||
}),
|
||||
);
|
||||
const storageEvent = new StorageEvent("storage", { key: storageKey });
|
||||
Object.defineProperty(storageEvent, "storageArea", { value: sessionStorage });
|
||||
window.dispatchEvent(storageEvent);
|
||||
expect(readProjectedOutboxStore(sessionStorage, target)).not.toBe(first);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("refreshes a retained legacy projection after an external write", () => {
|
||||
const unsubscribe = subscribeStoredChatOutboxChanges(() => undefined);
|
||||
const gatewayUrl = "ws://gateway.test/control";
|
||||
const legacyKey = `openclaw.control.chatComposer.v1:${encodeURIComponent(gatewayUrl)}`;
|
||||
const stored = (ids: string[]) =>
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
sessions: {
|
||||
"thread\u0000agent:main": {
|
||||
queue: ids.map((id, createdAt) => ({ id, text: id, createdAt })),
|
||||
updatedAt: ids.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
sessionStorage.setItem(legacyKey, stored(["first"]));
|
||||
vi.spyOn(sessionStorage, "setItem").mockImplementationOnce(() => {
|
||||
throw new DOMException("quota exceeded", "QuotaExceededError");
|
||||
});
|
||||
const state = { settings: { gatewayUrl } };
|
||||
expect(summarizeStoredChatOutboxes(state).total).toBe(1);
|
||||
|
||||
sessionStorage.setItem(legacyKey, stored(["first", "second"]));
|
||||
const storageEvent = new StorageEvent("storage", { key: legacyKey });
|
||||
Object.defineProperty(storageEvent, "storageArea", { value: sessionStorage });
|
||||
window.dispatchEvent(storageEvent);
|
||||
|
||||
const refreshedTotal = summarizeStoredChatOutboxes(state).total;
|
||||
unsubscribe();
|
||||
expect(refreshedTotal).toBe(2);
|
||||
});
|
||||
|
||||
it("clears every retained projection after an external storage clear", () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribeStoredChatOutboxChanges(listener);
|
||||
const gatewayUrls = ["ws://first.test/control", "ws://second.test/control"];
|
||||
for (const gatewayUrl of gatewayUrls) {
|
||||
sessionStorage.setItem(
|
||||
`openclaw.control.chatComposer.v2:${encodeURIComponent(gatewayUrl)}`,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
gatewayOwner: gatewayUrl,
|
||||
mainAlias: { key: "workspace", agentId: "work" },
|
||||
sessions: {
|
||||
"thread\u0000agent:main": {
|
||||
queue: [{ id: gatewayUrl, text: gatewayUrl, createdAt: 1 }],
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(summarizeStoredChatOutboxes({ settings: { gatewayUrl } }).total).toBe(1);
|
||||
expect(
|
||||
resolveStoredChatOutboxScope(
|
||||
{ settings: { gatewayUrl }, agentsList: null, hello: null },
|
||||
"workspace",
|
||||
),
|
||||
).toEqual({ sessionKey: "global", agentId: "work" });
|
||||
}
|
||||
|
||||
sessionStorage.clear();
|
||||
const storageEvent = new StorageEvent("storage", { key: null });
|
||||
Object.defineProperty(storageEvent, "storageArea", { value: sessionStorage });
|
||||
window.dispatchEvent(storageEvent);
|
||||
unsubscribe();
|
||||
|
||||
for (const gatewayUrl of gatewayUrls) {
|
||||
expect(
|
||||
resolveStoredChatOutboxScope(
|
||||
{ settings: { gatewayUrl }, agentsList: null, hello: null },
|
||||
"workspace",
|
||||
),
|
||||
).toEqual({ sessionKey: "workspace" });
|
||||
expect(summarizeStoredChatOutboxes({ settings: { gatewayUrl } }).total).toBe(0);
|
||||
}
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps the exact aliased scope when sessionStorage retirement fails", () => {
|
||||
const gatewayUrl = "ws://gateway.test/control";
|
||||
const storageKey = `openclaw.control.chatComposer.v2:${encodeURIComponent(gatewayUrl)}`;
|
||||
|
||||
@@ -85,7 +85,9 @@ const storedMainAliasByStorage = new WeakMap<
|
||||
Storage,
|
||||
Map<string, StoredComposerMainAlias | null>
|
||||
>();
|
||||
|
||||
// Projection reads share one normalized snapshot until a canonical write or
|
||||
// browser storage event invalidates it; mutation paths still reread for CAS.
|
||||
const projectedStoreByStorage = new WeakMap<Storage, Map<string, StoredComposerState>>();
|
||||
export function subscribeStoredChatOutboxChanges(listener: () => void): () => void {
|
||||
storedChatOutboxChangeListeners.add(listener);
|
||||
if (!storageChangeListenerInstalled && typeof window !== "undefined") {
|
||||
@@ -116,10 +118,22 @@ export function notifyStoredChatOutboxChanges(): void {
|
||||
}
|
||||
|
||||
function handleStoredChatOutboxStorageChange(event: StorageEvent): void {
|
||||
if (event.key === null && event.storageArea) {
|
||||
storedMainAliasByStorage.get(event.storageArea)?.clear();
|
||||
projectedStoreByStorage.get(event.storageArea)?.clear();
|
||||
notifyStoredChatOutboxChanges();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.key?.startsWith(STORAGE_KEY_PREFIX) ||
|
||||
event.key?.startsWith(LEGACY_STORAGE_KEY_PREFIX)
|
||||
) {
|
||||
if (event.storageArea) {
|
||||
const projectedKey = event.key.startsWith(LEGACY_STORAGE_KEY_PREFIX)
|
||||
? `${STORAGE_KEY_PREFIX}${event.key.slice(LEGACY_STORAGE_KEY_PREFIX.length)}`
|
||||
: event.key;
|
||||
projectedStoreByStorage.get(event.storageArea)?.delete(projectedKey);
|
||||
}
|
||||
notifyStoredChatOutboxChanges();
|
||||
}
|
||||
}
|
||||
@@ -527,11 +541,28 @@ export function readStoredOutboxStore(
|
||||
return { version: 2, gatewayOwner: target.gatewayOwner, sessions: {} };
|
||||
}
|
||||
|
||||
export function readProjectedOutboxStore(
|
||||
storage: Storage,
|
||||
target: ComposerStorageTarget,
|
||||
): StoredComposerState {
|
||||
const byKey = projectedStoreByStorage.get(storage);
|
||||
const cached = byKey?.get(target.key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const store = readStoredOutboxStore(storage, target);
|
||||
const nextByKey = byKey ?? new Map();
|
||||
nextByKey.set(target.key, store);
|
||||
projectedStoreByStorage.set(storage, nextByKey);
|
||||
return store;
|
||||
}
|
||||
|
||||
export function writeStoredOutboxStore(
|
||||
storage: Storage,
|
||||
target: ComposerStorageTarget,
|
||||
store: StoredComposerState,
|
||||
): void {
|
||||
projectedStoreByStorage.get(storage)?.delete(target.key);
|
||||
const entries = Object.entries(store.sessions);
|
||||
const outboxes = entries.filter(([, session]) => session.queue?.length);
|
||||
if (outboxes.length > MAX_STORED_SESSIONS) {
|
||||
@@ -588,11 +619,7 @@ export function writeStoredOutboxStore(
|
||||
export function retireStoredComposerDrafts(
|
||||
state: ChatComposerScope,
|
||||
targets: readonly StoredComposerRetirementTarget[],
|
||||
): {
|
||||
gatewayOwner: string;
|
||||
retirements: StoredComposerRetirement[];
|
||||
storageFailed: boolean;
|
||||
} {
|
||||
) {
|
||||
const storageTarget = storageTargetForGateway(state.settings?.gatewayUrl);
|
||||
if (targets.length === 0) {
|
||||
return { gatewayOwner: storageTarget.gatewayOwner, retirements: [], storageFailed: false };
|
||||
|
||||
@@ -18,6 +18,12 @@ import {
|
||||
} from "./chat-commands.ts";
|
||||
import { loadChatHistory, type ChatHistoryResult } from "./chat-history.ts";
|
||||
import { chatOutboxOwner } from "./chat-outbox-owner.ts";
|
||||
import {
|
||||
consumeChatOutboxRetry,
|
||||
retryableGatewayDelayMs,
|
||||
scheduleChatOutboxRetry,
|
||||
settleChatOutboxRetry,
|
||||
} from "./chat-outbox-retry.ts";
|
||||
import {
|
||||
anyChatOutboxPaneMatches,
|
||||
excludeComposerAttachments,
|
||||
@@ -78,12 +84,9 @@ export type ChatOutboxDrainDependencies = {
|
||||
) => void;
|
||||
};
|
||||
|
||||
type StoredChatOutboxDrainResult = "blocked" | "empty";
|
||||
type StoredChatOutboxDrainLane = {
|
||||
freshAdmissions: Set<string>;
|
||||
host: ChatHost;
|
||||
// A pre-request cancellation can remove its row; retain the direct outcome so
|
||||
// the submitter never mistakes absence for a successful transport handoff.
|
||||
outcomes: Map<string, QueuedChatSendResult>;
|
||||
pendingOptions: Map<string, QueuedChatSendOptions>;
|
||||
promise: Promise<void>;
|
||||
@@ -92,13 +95,9 @@ type StoredChatOutboxDrainLane = {
|
||||
|
||||
type StoredChatOutboxClientState = {
|
||||
lanes: Map<string, StoredChatOutboxDrainLane>;
|
||||
retryTimers: Map<string, ReturnType<typeof setTimeout>>;
|
||||
};
|
||||
|
||||
const STORED_OUTBOX_CONFIRMATION_GRACE_MS = 5_000;
|
||||
const STORED_OUTBOX_RETRY_DEFAULT_MS = 500;
|
||||
const STORED_OUTBOX_RETRY_MIN_MS = 100;
|
||||
const STORED_OUTBOX_RETRY_MAX_MS = 30_000;
|
||||
export const UNCONFIRMED_CHAT_SEND_ERROR =
|
||||
"Delivery could not be confirmed after reconnect. Check the conversation before retrying.";
|
||||
const UNCERTAIN_CLEAR_SUCCESSOR_ERROR =
|
||||
@@ -107,24 +106,11 @@ const UNCERTAIN_CLEAR_SUCCESSOR_ERROR =
|
||||
const storedChatOutboxClients = new WeakMap<GatewayBrowserClient, StoredChatOutboxClientState>();
|
||||
|
||||
function getStoredChatOutboxClientState(client: GatewayBrowserClient): StoredChatOutboxClientState {
|
||||
const existing = storedChatOutboxClients.get(client);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const created: StoredChatOutboxClientState = {
|
||||
const state = storedChatOutboxClients.get(client) ?? {
|
||||
lanes: new Map(),
|
||||
retryTimers: new Map(),
|
||||
};
|
||||
storedChatOutboxClients.set(client, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
export function retryableGatewayDelayMs(err: unknown): number | null {
|
||||
if (!(err instanceof GatewayRequestError) || !err.retryable) {
|
||||
return null;
|
||||
}
|
||||
const requested = err.retryAfterMs ?? STORED_OUTBOX_RETRY_DEFAULT_MS;
|
||||
return Math.min(Math.max(requested, STORED_OUTBOX_RETRY_MIN_MS), STORED_OUTBOX_RETRY_MAX_MS);
|
||||
storedChatOutboxClients.set(client, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
export function scheduleStoredChatOutboxRetry(
|
||||
@@ -132,24 +118,16 @@ export function scheduleStoredChatOutboxRetry(
|
||||
scope: StoredChatOutboxScope,
|
||||
delayMs: number,
|
||||
dependencies: ChatOutboxDrainDependencies,
|
||||
suppressGenericWake = true,
|
||||
) {
|
||||
const client = host.client;
|
||||
if (!host.connected || !client) {
|
||||
return;
|
||||
}
|
||||
const connectionEpoch = host.connectionEpoch;
|
||||
const timers = getStoredChatOutboxClientState(client).retryTimers;
|
||||
const key = storedChatOutboxScopeKey(scope);
|
||||
if (timers.has(key)) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
timers.delete(key);
|
||||
if (host.connected && host.client === client && host.connectionEpoch === connectionEpoch) {
|
||||
void scheduleStoredChatOutboxDrain(host, scope, dependencies);
|
||||
}
|
||||
}, delayMs);
|
||||
timers.set(key, timer);
|
||||
scheduleChatOutboxRetry(
|
||||
host,
|
||||
key,
|
||||
delayMs,
|
||||
(owner) => void scheduleStoredChatOutboxDrain(owner, scope, dependencies),
|
||||
suppressGenericWake,
|
||||
);
|
||||
}
|
||||
|
||||
function readStoredChatOutbox(
|
||||
@@ -340,6 +318,7 @@ async function reconcileStoredChatOutboxHead(
|
||||
outbox,
|
||||
Math.min(STORED_OUTBOX_RETRY_DEFAULT_MS, deadlineMs - now),
|
||||
dependencies,
|
||||
false,
|
||||
);
|
||||
return "blocked";
|
||||
}
|
||||
@@ -367,7 +346,7 @@ async function drainStoredChatOutbox(
|
||||
lane: StoredChatOutboxDrainLane,
|
||||
scope: StoredChatOutboxScope,
|
||||
dependencies: ChatOutboxDrainDependencies,
|
||||
): Promise<StoredChatOutboxDrainResult> {
|
||||
): Promise<"blocked" | "empty"> {
|
||||
while (true) {
|
||||
const host = lane.host;
|
||||
if (!host.connected || !host.client) {
|
||||
@@ -643,16 +622,14 @@ export async function scheduleStoredChatOutboxDrain(
|
||||
return undefined;
|
||||
}
|
||||
const key = storedChatOutboxScopeKey(scope);
|
||||
const { lanes, retryTimers } = getStoredChatOutboxClientState(client);
|
||||
const retryTimer = retryTimers.get(key);
|
||||
if (retryTimer !== undefined) {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimers.delete(key);
|
||||
const { lanes } = getStoredChatOutboxClientState(client);
|
||||
const candidateOwnsScope = visibleSessionMatches(host, scope.sessionKey, scope.agentId);
|
||||
if (consumeChatOutboxRetry(host, key, candidateOwnsScope, itemId)) {
|
||||
return undefined;
|
||||
}
|
||||
// Drain ownership follows the live client, never a disconnected pending RPC.
|
||||
const existing = lanes.get(key);
|
||||
if (existing) {
|
||||
const candidateOwnsScope = visibleSessionMatches(host, scope.sessionKey, scope.agentId);
|
||||
// Keep a connected visible owner for local commands across split-pane reruns.
|
||||
if (
|
||||
!existing.host.connected ||
|
||||
@@ -691,6 +668,7 @@ export async function scheduleStoredChatOutboxDrain(
|
||||
})();
|
||||
try {
|
||||
await lane.promise;
|
||||
settleChatOutboxRetry(client, key);
|
||||
return itemId ? lane.outcomes.get(itemId) : undefined;
|
||||
} finally {
|
||||
if (lanes.get(key) === lane) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { computeBackoff } from "@openclaw/retry";
|
||||
import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { ChatHost } from "./chat-send-contract.ts";
|
||||
|
||||
type RetryTimer = {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
suppressGenericWake: boolean;
|
||||
connectionEpoch: number | undefined;
|
||||
host: ChatHost;
|
||||
};
|
||||
|
||||
type RetryState = {
|
||||
attempts: Map<string, number>;
|
||||
timers: Map<string, RetryTimer>;
|
||||
};
|
||||
|
||||
const RETRY_DEFAULT_MS = 500;
|
||||
const RETRY_MAX_MS = 30_000;
|
||||
const retryStates = new WeakMap<GatewayBrowserClient, RetryState>();
|
||||
|
||||
function retryState(client: GatewayBrowserClient): RetryState {
|
||||
const state = retryStates.get(client) ?? { attempts: new Map(), timers: new Map() };
|
||||
retryStates.set(client, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function retryOwnerStale(client: GatewayBrowserClient, retry: RetryTimer): boolean {
|
||||
return (
|
||||
!retry.host.connected ||
|
||||
retry.host.client !== client ||
|
||||
retry.connectionEpoch !== retry.host.connectionEpoch
|
||||
);
|
||||
}
|
||||
|
||||
export function retryableGatewayDelayMs(err: unknown): number | null {
|
||||
if (!(err instanceof GatewayRequestError) || !err.retryable) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(Math.max(err.retryAfterMs ?? RETRY_DEFAULT_MS, 100), RETRY_MAX_MS);
|
||||
}
|
||||
|
||||
export function scheduleChatOutboxRetry(
|
||||
host: ChatHost,
|
||||
key: string,
|
||||
delayMs: number,
|
||||
wake: (host: ChatHost) => void,
|
||||
suppressGenericWake: boolean,
|
||||
): void {
|
||||
const client = host.client;
|
||||
if (!host.connected || !client) {
|
||||
return;
|
||||
}
|
||||
const state = retryState(client);
|
||||
if (state.timers.has(key)) {
|
||||
return;
|
||||
}
|
||||
const attempt = (state.attempts.get(key) ?? 0) + 1;
|
||||
const retryDelayMs = suppressGenericWake
|
||||
? computeBackoff({ initialMs: delayMs, maxMs: RETRY_MAX_MS, factor: 2, jitter: 0 }, attempt)
|
||||
: delayMs;
|
||||
if (suppressGenericWake) {
|
||||
state.attempts.set(key, attempt);
|
||||
} else {
|
||||
state.attempts.delete(key);
|
||||
}
|
||||
const retry: RetryTimer = {
|
||||
timer: setTimeout(() => {
|
||||
state.timers.delete(key);
|
||||
if (!retryOwnerStale(client, retry)) {
|
||||
wake(retry.host);
|
||||
}
|
||||
}, retryDelayMs),
|
||||
suppressGenericWake,
|
||||
connectionEpoch: host.connectionEpoch,
|
||||
host,
|
||||
};
|
||||
state.timers.set(key, retry);
|
||||
}
|
||||
|
||||
export function consumeChatOutboxRetry(
|
||||
host: ChatHost,
|
||||
key: string,
|
||||
candidateOwnsScope: boolean,
|
||||
itemId?: string,
|
||||
): boolean {
|
||||
const client = host.client;
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
const state = retryState(client);
|
||||
const retry = state.timers.get(key);
|
||||
if (!retry) {
|
||||
return false;
|
||||
}
|
||||
const ownerStale = retryOwnerStale(client, retry);
|
||||
if (!ownerStale && candidateOwnsScope) {
|
||||
retry.connectionEpoch = host.connectionEpoch;
|
||||
retry.host = host;
|
||||
}
|
||||
if (!itemId && !ownerStale && retry.suppressGenericWake) {
|
||||
return true;
|
||||
}
|
||||
clearTimeout(retry.timer);
|
||||
state.timers.delete(key);
|
||||
if (itemId || ownerStale) {
|
||||
state.attempts.delete(key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function settleChatOutboxRetry(client: GatewayBrowserClient, key: string): void {
|
||||
const state = retryState(client);
|
||||
if (!state.timers.has(key)) {
|
||||
state.attempts.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import { readChatResetTargetAccess } from "./chat-commands.ts";
|
||||
import { loadChatBranches, loadChatHistory } from "./chat-history.ts";
|
||||
import {
|
||||
flushStoredChatOutbox,
|
||||
retryableGatewayDelayMs,
|
||||
scheduleStoredChatOutboxDrain as scheduleOutboxDrain,
|
||||
scheduleStoredChatOutboxRetry,
|
||||
UNCONFIRMED_CHAT_SEND_ERROR,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
type QueuedChatSendResult,
|
||||
type QueuedChatStorageMode,
|
||||
} from "./chat-outbox-drain.ts";
|
||||
import { retryableGatewayDelayMs } from "./chat-outbox-retry.ts";
|
||||
import {
|
||||
excludeComposerAttachments,
|
||||
readQueuedMessageById,
|
||||
|
||||
@@ -6745,6 +6745,8 @@ describe("handleSendChat", () => {
|
||||
|
||||
expect(historyAttempts).toBe(1);
|
||||
expect(sendAttempts).toBe(0);
|
||||
await Promise.all(Array.from({ length: 20 }, () => retryReconnectableQueuedChatSends(host)));
|
||||
expect(historyAttempts).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
// Same fire-and-forget retry hand-off as the send-rejection case above.
|
||||
await waitForFast(() => {
|
||||
@@ -6757,6 +6759,92 @@ describe("handleSendChat", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("retries after reconnecting with the same Gateway client", async () => {
|
||||
let sendAttempts = 0;
|
||||
const host = makeChatHost({
|
||||
requestHandlers: {
|
||||
"chat.history": {
|
||||
messages: [],
|
||||
sessionInfo: row("agent:main", { hasActiveRun: false, status: "done" }),
|
||||
},
|
||||
"chat.send": (params: unknown) => {
|
||||
sendAttempts += 1;
|
||||
if (sendAttempts === 1) {
|
||||
throw new GatewayRequestError({
|
||||
code: "UNAVAILABLE",
|
||||
message: "Gateway is temporarily busy",
|
||||
retryable: true,
|
||||
retryAfterMs: 100,
|
||||
});
|
||||
}
|
||||
const payload = requireRecord(params, "reconnected send payload");
|
||||
return { runId: payload.idempotencyKey, status: "ok" };
|
||||
},
|
||||
},
|
||||
chatMessage: "retry after reconnecting",
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await handleSendChat(host);
|
||||
expect(sendAttempts).toBe(1);
|
||||
|
||||
host.connectionEpoch += 1;
|
||||
await retryReconnectableQueuedChatSends(host);
|
||||
|
||||
expect(sendAttempts).toBe(2);
|
||||
expect(listStoredChatOutboxes(host)).toStrictEqual([]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("transfers an active retry backoff to a sibling pane without bypassing it", async () => {
|
||||
let historyAttempts = 0;
|
||||
const owner = makeChatHost({
|
||||
connectionEpoch: 1,
|
||||
requestHandlers: {
|
||||
"chat.history": () => {
|
||||
historyAttempts += 1;
|
||||
throw new GatewayRequestError({
|
||||
code: "UNAVAILABLE",
|
||||
message: "History is temporarily unavailable",
|
||||
retryable: true,
|
||||
retryAfterMs: 100,
|
||||
});
|
||||
},
|
||||
},
|
||||
chatQueue: [
|
||||
{
|
||||
id: "shared-retry",
|
||||
text: "wait for backoff",
|
||||
createdAt: 1,
|
||||
sendRunId: "shared-retry-run",
|
||||
sendState: "waiting-reconnect",
|
||||
sessionKey: "agent:main",
|
||||
},
|
||||
],
|
||||
});
|
||||
admitHostQueueItems(owner);
|
||||
const sibling = makeChatHost({
|
||||
client: owner.client,
|
||||
connectionEpoch: 2,
|
||||
chatQueue: owner.chatQueue,
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await retryReconnectableQueuedChatSends(owner);
|
||||
owner.sessionKey = "agent:main:other";
|
||||
await retryReconnectableQueuedChatSends(sibling);
|
||||
expect(historyAttempts).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await waitForFast(() => expect(historyAttempts).toBe(2));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("persists queueable local commands entered while disconnected", async () => {
|
||||
executeSlashCommandMock.mockResolvedValueOnce({ content: "Thinking level set." });
|
||||
const request = makeRequestMock({
|
||||
|
||||
@@ -501,13 +501,13 @@ export function handlePageGatewayEvent(
|
||||
}
|
||||
if (event.event === "agent" || event.event === "session.tool") {
|
||||
if (handleAgentEvent(state as never, event.payload as never)) {
|
||||
requestChatPageUpdate(state);
|
||||
requestChatPageUpdate(state, "animation-frame");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.event === "session.operation") {
|
||||
handleSessionOperationEvent(state as never, event.payload as never);
|
||||
requestChatPageUpdate(state);
|
||||
requestChatPageUpdate(state, "animation-frame");
|
||||
return;
|
||||
}
|
||||
if (event.event === "chat.send_timing") {
|
||||
@@ -517,13 +517,13 @@ export function handlePageGatewayEvent(
|
||||
if (event.event === "session.message") {
|
||||
handleSessionMessageEvent(state, event.payload, isPresented);
|
||||
void resumeStoredChatOutboxes(state);
|
||||
requestChatPageUpdate(state);
|
||||
requestChatPageUpdate(state, "animation-frame");
|
||||
return;
|
||||
}
|
||||
if (event.event === "sessions.changed") {
|
||||
handleSessionsChangedEvent(state, event.payload, isPresented);
|
||||
void resumeStoredChatOutboxes(state);
|
||||
requestChatPageUpdate(state);
|
||||
requestChatPageUpdate(state, "animation-frame");
|
||||
return;
|
||||
}
|
||||
if (event.event === "task") {
|
||||
|
||||
@@ -908,7 +908,12 @@ describe("canonical session message recovery", () => {
|
||||
expect(state.chatStream).toBe("Current partial reply");
|
||||
});
|
||||
|
||||
it("renders distinct live peers immediately and coalesces their stale history", async () => {
|
||||
it("coalesces distinct live peers into one frame and their stale history into one load", async () => {
|
||||
let renderFrame: FrameRequestCallback | undefined;
|
||||
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
renderFrame = callback;
|
||||
return 1;
|
||||
});
|
||||
let resolveHistory!: (result: {
|
||||
messages: unknown[];
|
||||
sessionId: string;
|
||||
@@ -945,8 +950,10 @@ describe("canonical session message recovery", () => {
|
||||
});
|
||||
|
||||
expect(state.chatMessages).toHaveLength(index + 1);
|
||||
expect(state.requestUpdate).toHaveBeenCalledTimes(index + 1);
|
||||
expect(state.requestUpdate).not.toHaveBeenCalled();
|
||||
}
|
||||
renderFrame?.(0);
|
||||
expect(state.requestUpdate).toHaveBeenCalledOnce();
|
||||
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
resolveHistory({
|
||||
@@ -1746,7 +1753,10 @@ describe("ChatStateController render lifecycle", () => {
|
||||
expect(state.waitingApprovalStatuses.size).toBe(0);
|
||||
});
|
||||
|
||||
it("skips no-op assistant invalidation while tool changes render immediately", () => {
|
||||
it("skips no-op assistant invalidation while tool changes render on the next frame", () => {
|
||||
const requestAnimationFrame = vi
|
||||
.spyOn(globalThis, "requestAnimationFrame")
|
||||
.mockImplementation(() => 1);
|
||||
const requestUpdate = vi.fn();
|
||||
const state = createStreamEventState({
|
||||
requestUpdate,
|
||||
@@ -1768,7 +1778,9 @@ describe("ChatStateController render lifecycle", () => {
|
||||
expect(requestUpdate).not.toHaveBeenCalled();
|
||||
|
||||
emitAgent(2, "tool", { phase: "start", name: "read", toolCallId: "tool-1" });
|
||||
expect(requestUpdate).toHaveBeenCalledOnce();
|
||||
emitAgent(3, "tool", { phase: "update", name: "read", toolCallId: "tool-1" });
|
||||
expect(requestAnimationFrame).toHaveBeenCalledOnce();
|
||||
expect(requestUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("coalesces stream invalidations into one animation frame", () => {
|
||||
@@ -1814,9 +1826,11 @@ describe("ChatStateController render lifecycle", () => {
|
||||
event: "session.operation",
|
||||
payload: {},
|
||||
});
|
||||
expect(frames.size).toBe(1);
|
||||
expect(requestUpdate).toHaveBeenCalledOnce();
|
||||
staleFrame?.(0);
|
||||
|
||||
expect(cancelFrame).toHaveBeenCalledWith(2);
|
||||
expect(cancelFrame).not.toHaveBeenCalledWith(2);
|
||||
expect(requestUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(state.chatStreamRenderFrame).toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user