* perf(vscode): gate API readiness and coalesce duplicate startup reads Bring the VS Code bridge runtime to parity with the web startup optimizations (PR #1650), which were web/desktop-only. waitForApiUrl now hands out the OpenCode API URL only once the manager reports 'connected', instead of as soon as getApiUrl() exposes server.url. The URL is available the moment the process is spawned — before waitForReady confirms it can serve and during a workspace-switch restart (stale port) — so URL-presence alone let the bridge forward to a not-yet-ready OpenCode and surface 502s. Gating on connected status mirrors the web proxy's isOpenCodeReady hold. Also fail fast on 'error' status so a missing CLI doesn't burn the full 30s timeout. Coalesce concurrent identical GET reads (config/path/agents/agent/ project/command) at the bridge proxy so the single OpenCode process serves them once. On cold start the webview's sync bootstrap and config store fire these reads in parallel with no shared dedup; this is the extension-host analog of the runtimeFetch coalescer. Shared reads carry no AbortController so one caller's abort can't strand the others, and the entry clears as soon as it settles (never serves stale). * perf(vscode): fade the startup splash once mounted + connected, not on live fetch The webview's initial-loading overlay held until a successful live /api/config/providers AND /api/agent fetch completed. After the cache hydration work those live reads are the slowest cold-start tail — the UI underneath already paints pickers and the sidebar from cache and refreshes in the background — so gating the splash on them kept it spinning long after the app was usable. Fade the overlay as soon as the UI is mounted and OpenCode is connected. Per-widget loaders convey any remaining background refresh, matching how web/desktop (which have no such splash) already behave. Removes the now -obsolete bootstrapProvidersReady/AgentsReady/Failed tracking and recordBootstrapFetch. Connection error/disconnected splash messages are unchanged. * fix(vscode): include captured OpenCode output in spawn-timeout error When the managed OpenCode server fails to emit its 'listening' line within the start timeout, the error discarded everything the process printed to stdout/stderr — so the status report showed a bare 'Timeout waiting for server to start' with no clue whether the process hung, crashed silently, or printed a config/auth error. The exit path already includes the output; the timeout path now does too (or notes that nothing was printed). * feat(vscode): workspace-grouped session list with working folders, pinning, and archived toggle Replace the flat multi-workspace session list with the grouped project view, using each open VS Code workspace folder as a header (no per-worktree subgroups). This restores native folder and pin support, which the flat list silently dropped, and fixes the clipped left padding on session rows. - Group sessions strictly by open workspace; funnel all non-archived sessions into the workspace's group so they no longer fall into the archived bucket. - Keep the project/group/folder + buttons but make them open a draft in the correct workspace and navigate to chat; hide the project actions (...) menu, which isn't relevant in VS Code. - Force the minimal single-line row layout (the second metadata row is redundant under workspace headers) and drop the per-row tooltip. - Add a show/hide archived toggle next to the archive-all control, since the VS Code header has no display-mode menu. - Size the hover action reveal so the timestamp clears the row buttons.
132 lines
5.0 KiB
TypeScript
132 lines
5.0 KiB
TypeScript
import { describe, test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import type { BridgeContext } from './bridge';
|
|
import { handleProxyBridgeMessage } from './bridge-proxy-runtime';
|
|
|
|
const deps = {
|
|
tryHandleLocalFsProxy: async () => null,
|
|
buildUnavailableApiResponse: () => ({ status: 503, headers: {}, bodyText: '' }),
|
|
sanitizeForwardHeaders: (input: Record<string, string> | undefined) => input ?? {},
|
|
collectHeaders: (headers: Headers) => {
|
|
const result: Record<string, string> = {};
|
|
headers.forEach((value, key) => {
|
|
result[key] = value;
|
|
});
|
|
return result;
|
|
},
|
|
base64EncodeUtf8: (text: string) => Buffer.from(text, 'utf8').toString('base64'),
|
|
};
|
|
|
|
const ctx = {
|
|
manager: {
|
|
getStatus: () => 'connected',
|
|
getApiUrl: () => 'http://127.0.0.1:3902',
|
|
getOpenCodeAuthHeaders: () => ({}),
|
|
onStatusChange: (cb: (status: string) => void) => {
|
|
cb('connected');
|
|
return { dispose: () => {} };
|
|
},
|
|
},
|
|
} as unknown as BridgeContext;
|
|
|
|
describe('VS Code API proxy aborts', () => {
|
|
test('aborts non-SSE api:proxy fetches by bridge request id', async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
let capturedSignal: AbortSignal | undefined;
|
|
|
|
try {
|
|
globalThis.fetch = (async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
|
capturedSignal = init?.signal ?? undefined;
|
|
return new Promise<Response>((_resolve, reject) => {
|
|
capturedSignal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true });
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const pending = handleProxyBridgeMessage(
|
|
{ id: 'req_1', type: 'api:proxy', payload: { method: 'POST', path: '/session/abc/prompt_async', bodyBase64: Buffer.from('{}').toString('base64') } },
|
|
ctx,
|
|
deps,
|
|
);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
assert.equal(capturedSignal?.aborted, false);
|
|
|
|
await handleProxyBridgeMessage({ id: 'abort_req_1', type: 'api:proxy:abort', payload: { requestID: 'req_1' } }, ctx, deps);
|
|
assert.equal(capturedSignal?.aborted, true);
|
|
|
|
const response = await pending;
|
|
assert.equal(response?.success, true);
|
|
assert.equal((response?.data as { status?: number }).status, 502);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('VS Code API proxy read coalescing', () => {
|
|
test('shares one upstream fetch across concurrent identical GET reads', async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
let fetchCount = 0;
|
|
let release: () => void = () => {};
|
|
|
|
try {
|
|
globalThis.fetch = (async () => {
|
|
fetchCount += 1;
|
|
await new Promise<void>((resolve) => { release = resolve; });
|
|
return new Response('{"ok":true}', { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}) as typeof fetch;
|
|
|
|
const first = handleProxyBridgeMessage(
|
|
{ id: 'r1', type: 'api:proxy', payload: { method: 'GET', path: '/config?directory=/x' } },
|
|
ctx,
|
|
deps,
|
|
);
|
|
const second = handleProxyBridgeMessage(
|
|
{ id: 'r2', type: 'api:proxy', payload: { method: 'GET', path: '/config?directory=/x' } },
|
|
ctx,
|
|
deps,
|
|
);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
release();
|
|
|
|
const [a, b] = await Promise.all([first, second]);
|
|
assert.equal(fetchCount, 1);
|
|
assert.equal((a?.data as { bodyText?: string }).bodyText, '{"ok":true}');
|
|
assert.equal((b?.data as { bodyText?: string }).bodyText, '{"ok":true}');
|
|
assert.notStrictEqual((a?.data as { headers: unknown }).headers, (b?.data as { headers: unknown }).headers);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test('does not coalesce POST writes or non-allowlisted reads', async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
let fetchCount = 0;
|
|
|
|
try {
|
|
globalThis.fetch = (async () =>
|
|
new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } })) as typeof fetch;
|
|
|
|
await Promise.all([
|
|
handleProxyBridgeMessage({ id: 'w1', type: 'api:proxy', payload: { method: 'GET', path: '/session?directory=/x' } }, ctx, deps),
|
|
handleProxyBridgeMessage({ id: 'w2', type: 'api:proxy', payload: { method: 'GET', path: '/session?directory=/x' } }, ctx, deps),
|
|
]);
|
|
assert.equal(fetchCount, 0); // sanity: counter only bumps in the slow mock above
|
|
|
|
globalThis.fetch = (async () => {
|
|
fetchCount += 1;
|
|
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}) as typeof fetch;
|
|
|
|
await Promise.all([
|
|
handleProxyBridgeMessage({ id: 's1', type: 'api:proxy', payload: { method: 'GET', path: '/session?directory=/x' } }, ctx, deps),
|
|
handleProxyBridgeMessage({ id: 's2', type: 'api:proxy', payload: { method: 'GET', path: '/session?directory=/x' } }, ctx, deps),
|
|
]);
|
|
assert.equal(fetchCount, 2); // /session is not in the read allowlist
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
});
|