fix(web): hydrate sessionModels and sessionMcpStatus on resume (#2213)
This commit is contained in:
@@ -168,3 +168,64 @@ describe("useExpandableDiff", () => {
|
||||
expect(result.current.metadata).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useExpandableDiff — content-load retries", () => {
|
||||
it("retries a transient WebSocket timeout and succeeds on a later attempt", async () => {
|
||||
// A single 5s WS timeout used to set a terminal error and permanently
|
||||
// disable expansion for this mount (the auto-load effect only fires while
|
||||
// there is no error). The load now retries transient failures, so a brief
|
||||
// backend stall recovers instead of killing expansion.
|
||||
requestFileContentMock
|
||||
.mockRejectedValueOnce(new Error("WebSocket request timed out: workspace.file.get"))
|
||||
.mockResolvedValue({ content: "new", is_binary: false });
|
||||
requestFileContentAtRefMock.mockResolvedValue({ content: "old", is_binary: false });
|
||||
const reparsed = consistentMeta();
|
||||
processFileMock.mockReturnValue(reparsed);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useExpandableDiff({ ...baseProps, fileDiffMetadata: PARTIAL }),
|
||||
);
|
||||
await loadAndSettle(result.current.loadContent);
|
||||
|
||||
expect(requestFileContentMock).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.metadata).toBe(reparsed);
|
||||
expect(result.current.isContentLoaded).toBe(true);
|
||||
});
|
||||
|
||||
it("does not retry a permanent error and falls back to the partial metadata", async () => {
|
||||
// Binary files can never be expanded; retrying would just spin. The load
|
||||
// surfaces the error on the first attempt and keeps the partial metadata.
|
||||
requestFileContentMock.mockRejectedValue(new Error("Cannot expand binary files"));
|
||||
requestFileContentAtRefMock.mockResolvedValue({ content: "old", is_binary: false });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useExpandableDiff({ ...baseProps, fileDiffMetadata: PARTIAL }),
|
||||
);
|
||||
await loadAndSettle(result.current.loadContent);
|
||||
|
||||
expect(requestFileContentMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.error).toBe("Cannot expand binary files");
|
||||
expect(result.current.metadata).toBe(PARTIAL);
|
||||
expect(result.current.canExpand).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces a terminal error after transient retries are exhausted", async () => {
|
||||
// Four attempts total (initial + 3 retries); when every one times out the
|
||||
// load gives up with the error so the caller falls back to partial metadata.
|
||||
requestFileContentMock.mockRejectedValue(
|
||||
new Error("WebSocket request timed out: workspace.file.get"),
|
||||
);
|
||||
requestFileContentAtRefMock.mockResolvedValue({ content: "old", is_binary: false });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useExpandableDiff({ ...baseProps, fileDiffMetadata: PARTIAL }),
|
||||
);
|
||||
await loadAndSettle(result.current.loadContent);
|
||||
|
||||
expect(requestFileContentMock).toHaveBeenCalledTimes(4);
|
||||
expect(result.current.error).toContain("timed out");
|
||||
expect(result.current.metadata).toBe(PARTIAL);
|
||||
expect(result.current.canExpand).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,34 @@ function isFileNotFoundError(error: string): boolean {
|
||||
return /file not found|not found|no such file|does not exist/i.test(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of extra attempts for a transient content-load failure and the base
|
||||
* backoff between them. The two file reads go over the WebSocket, which rejects
|
||||
* with a 5s timeout and no retry of its own; when the backend is briefly slow
|
||||
* (loaded CI runner, reconnect in flight) a single timeout would otherwise set
|
||||
* a terminal error and permanently disable expansion for this mount, since the
|
||||
* auto-load effect only fires while there is no error. Retrying transient
|
||||
* failures here keeps that from happening.
|
||||
*/
|
||||
const CONTENT_LOAD_MAX_RETRIES = 3;
|
||||
const CONTENT_LOAD_RETRY_BASE_MS = 300;
|
||||
|
||||
/**
|
||||
* A transient failure is one worth retrying: the WebSocket client not being
|
||||
* ready yet, or a request that timed out. Permanent failures (binary file,
|
||||
* genuine backend rejections) are surfaced immediately so we fall back to the
|
||||
* partial metadata without spinning.
|
||||
*/
|
||||
function isTransientLoadError(error: string): boolean {
|
||||
return /timed out|websocket client not available|not connected|connection (closed|lost)/i.test(
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a reparsed metadata won't trip @pierre/diffs' iterateOverDiff
|
||||
* trailing-context check (which throws and tears down the renderer). When
|
||||
@@ -114,6 +142,34 @@ async function fetchExpansionContent(
|
||||
return { oldContent, newContent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch expansion content, retrying transient failures with linear backoff.
|
||||
* `isCurrent` lets the caller abort the retry loop when the hook's inputs have
|
||||
* changed (a newer request superseded this one); permanent failures rethrow on
|
||||
* the first attempt so we don't spin on a binary file or a real backend error.
|
||||
*/
|
||||
async function fetchExpansionContentWithRetry(
|
||||
sessionId: string,
|
||||
filePath: string,
|
||||
baseRef: string | undefined,
|
||||
repo: string | undefined,
|
||||
isCurrent: () => boolean,
|
||||
) {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= CONTENT_LOAD_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await fetchExpansionContent(sessionId, filePath, baseRef, repo);
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!isTransientLoadError(msg) || attempt === CONTENT_LOAD_MAX_RETRIES) throw err;
|
||||
await delay(CONTENT_LOAD_RETRY_BASE_MS * (attempt + 1));
|
||||
if (!isCurrent()) throw err;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing expandable diffs with lazy-loaded file content.
|
||||
*
|
||||
@@ -157,7 +213,13 @@ export function useExpandableDiff({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const content = await fetchExpansionContent(sessionId, filePath, baseRef, repo);
|
||||
const content = await fetchExpansionContentWithRetry(
|
||||
sessionId,
|
||||
filePath,
|
||||
baseRef,
|
||||
repo,
|
||||
() => version === requestVersionRef.current,
|
||||
);
|
||||
if (version === requestVersionRef.current) setLoadedContent(content);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to load file content";
|
||||
|
||||
Reference in New Issue
Block a user