Merge upstream main into feat/subagent-cost-rollup

This commit is contained in:
Igor Velho
2026-08-26 09:28:08 +01:00
135 changed files with 10151 additions and 3897 deletions
@@ -262,7 +262,7 @@ describe('addSelectionToChat', () => {
installSelectionEnvironment({ activeElement: textarea });
expect(addSelectionToChat()).toBe(true);
expect(activeSurfaceCalls).toEqual(['chat']);
expect(activeSurfaceCalls).toEqual([]);
expect(sessionSwitcherCalls).toEqual([false]);
expect(pendingInputCalls).toEqual([{ text: '```md\nselected\n```', mode: 'append' }]);
@@ -290,7 +290,7 @@ describe('addSelectionToChat', () => {
expect(addSelectionToChat()).toBe(false);
expect(pendingInputCalls).toEqual([]);
expect(activeSurfaceCalls).toEqual(['chat']);
expect(activeSurfaceCalls).toEqual([]);
await Promise.resolve();
expect(focusChatInputCalls.length).toBe(1);
@@ -0,0 +1,157 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
// Regression tests for issue #2470: sessions stuck on "loading sessions"
// forever after managed OpenCode connection goes half-open.
//
// The SDK client fetch wrapper must bound read requests so a socket that
// neither resolves nor rejects fails after `requestTimeoutMs`, releasing the
// directory bootstrap concurrency slot. Long-lived streams must be excluded:
// POST (prompt/shell/summarize/command) and the `/event` SSE stream.
type CapturedFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
type RuntimeFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
// `mock(...)` returns a Mock that exposes mockImplementation; keep a typed
// reference so per-test overrides stay type-safe without re-importing.
const runtimeFetchMock = mock<RuntimeFetch>(async () => new Response('', { status: 200 }));
let capturedFetch: CapturedFetch | null = null;
(mock as unknown as { restore?: () => void }).restore?.();
mock.module('@opencode-ai/sdk/v2', () => ({
createOpencodeClient: mock((opts: { fetch: CapturedFetch }) => {
capturedFetch = opts.fetch;
return {};
}),
}));
mock.module('@/contexts/runtimeAPIRegistry', () => ({
getRegisteredRuntimeAPIs: mock(() => null),
}));
mock.module('@/lib/runtime-url', () => ({
getRuntimeUrlResolver: mock(() => ({ api: (path: string) => path })),
}));
mock.module('@/lib/runtime-switch', () => ({
getRuntimeApiBaseUrl: mock(() => ''),
getRuntimeKey: mock(() => 'test-runtime'),
}));
mock.module('@/lib/runtime-fetch', () => ({
runtimeFetch: runtimeFetchMock,
}));
mock.module('@/lib/startupTrace', () => ({
markStartupTrace: mock(() => undefined),
}));
const { createRuntimeOpencodeClient } = await import(
`./client?timeout-final=${Date.now()}`
);
beforeEach(() => {
capturedFetch = null;
runtimeFetchMock.mockImplementation(async () => new Response('', { status: 200 }));
});
describe('createRuntimeOpencodeClient fetch wrapper (#2470)', () => {
test('AbortSignal.timeout fires inside a test environment (sanity)', async () => {
const sig = AbortSignal.timeout(20);
const fired = await new Promise<boolean>((resolve) => {
sig.addEventListener('abort', () => resolve(true));
setTimeout(() => resolve(false), 200);
});
expect(fired).toBe(true);
});
test('createRuntimeOpencodeClient is exported', () => {
expect(typeof createRuntimeOpencodeClient).toBe('function');
});
test('a GET whose socket never settles rejects with normalized "request timed out" error', async () => {
let firedAt = 0;
let calledAt = Date.now();
runtimeFetchMock.mockImplementation(async (_input: string | URL | Request, init?: RequestInit) => {
calledAt = Date.now();
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
firedAt = Date.now();
reject(new DOMException('Aborted', 'AbortError'));
});
setTimeout(
() => reject(new Error('TIMEOUT_TEST_FAIL: signal never fired')),
1000,
);
});
});
createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 });
expect(capturedFetch).not.toBeNull();
const start = Date.now();
await expect(
capturedFetch!('http://opencode.test/api/session'),
).rejects.toThrow(/request timed out/);
const elapsed = Date.now() - start;
expect(firedAt).toBeGreaterThanOrEqual(calledAt);
expect(elapsed).toBeLessThan(500);
});
test('POST requests (long-running prompt/shell/summarize) are NOT timed out', async () => {
runtimeFetchMock.mockImplementation(async () => {
await new Promise((r) => setTimeout(r, 200));
return new Response('ok', { status: 200 });
});
createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 });
expect(capturedFetch).not.toBeNull();
const response = await capturedFetch!(
'http://opencode.test/session/prompt',
{ method: 'POST' },
);
expect(await response.text()).toBe('ok');
});
test('the /event SSE stream is NOT timed out', async () => {
runtimeFetchMock.mockImplementation(async () => {
await new Promise((r) => setTimeout(r, 200));
return new Response('event: ping\n\n', {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
});
});
createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 });
expect(capturedFetch).not.toBeNull();
const response = await capturedFetch!(
'http://opencode.test/api/global/event',
);
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
});
test('caller-provided abort signal still wins (no normalized timeout error)', async () => {
runtimeFetchMock.mockImplementation(async (_input: string | URL | Request, init?: RequestInit) =>
new Promise<Response>((_, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'));
});
}),
);
createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 });
expect(capturedFetch).not.toBeNull();
const callerController = new AbortController();
setTimeout(() => callerController.abort(), 5);
await expect(
capturedFetch!('http://opencode.test/api/session', {
signal: callerController.signal,
}),
).rejects.toThrow('Aborted');
});
});
+78 -2
View File
@@ -200,10 +200,86 @@ const createTimeoutSignal = (timeoutMs: number): { signal: AbortSignal; cleanup:
};
};
const createRuntimeOpencodeClient = (config: { baseUrl: string; directory?: string }): OpencodeClient => {
/**
* Upper bound for non-streaming OpenCode read requests. Without it, a socket
* that neither resolves nor rejects (the half-open state described in #2470)
* keeps the bootstrap concurrency slot busy forever and the UI stays on
* "loading sessions". Long-lived streams (POST prompts, the /event SSE) are
* explicitly excluded in {@link createRuntimeOpencodeClient}.
*/
const OPENCODE_REQUEST_TIMEOUT_MS = 30_000;
const isEventStreamUrl = (input: string | URL | Request): boolean => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
return url.includes('/event');
};
type RuntimeOpencodeClientConfig = {
baseUrl: string;
directory?: string;
/** Read-request timeout in ms. Overridable so tests can use short value. */
requestTimeoutMs?: number;
};
export const createRuntimeOpencodeClient = (config: RuntimeOpencodeClientConfig): OpencodeClient => {
const requestTimeoutMs = config.requestTimeoutMs ?? OPENCODE_REQUEST_TIMEOUT_MS;
return createOpencodeClient({
...config,
fetch: runtimeFetch,
fetch: async (input: string | URL | Request, init?: RequestInit) => {
const method = String(
init?.method ?? (input instanceof Request ? input.method : 'GET'),
).toUpperCase();
if (isEventStreamUrl(input) || method === 'POST') {
return runtimeFetch(input, init);
}
const timeout = createTimeoutSignal(requestTimeoutMs);
const callerSignal = init?.signal;
const supportsAny = typeof AbortSignal !== 'undefined'
&& typeof (AbortSignal as { any?: unknown }).any === 'function';
let signal: AbortSignal;
let detachFallback: (() => void) | null = null;
if (callerSignal && supportsAny) {
signal = (AbortSignal as typeof AbortSignal & { any: (signals: AbortSignal[]) => AbortSignal })
.any([callerSignal, timeout.signal]);
} else if (callerSignal) {
// No AbortSignal.any: compose manually. Silently dropping the timeout
// here would disable the fix on exactly the bootstrap reads it
// targets, since those carry a cancellation signal.
const controller = new AbortController();
const abortFromCaller = () => controller.abort(callerSignal.reason);
const abortFromTimeout = () => controller.abort(timeout.signal.reason);
if (callerSignal.aborted) {
abortFromCaller();
} else if (timeout.signal.aborted) {
abortFromTimeout();
} else {
callerSignal.addEventListener('abort', abortFromCaller, { once: true });
timeout.signal.addEventListener('abort', abortFromTimeout, { once: true });
detachFallback = () => {
callerSignal.removeEventListener('abort', abortFromCaller);
timeout.signal.removeEventListener('abort', abortFromTimeout);
};
}
signal = controller.signal;
} else {
signal = timeout.signal;
}
try {
return await runtimeFetch(input, { ...init, signal });
} catch (error) {
if (timeout.signal.aborted && !callerSignal?.aborted) {
throw new Error(`OpenCode request timed out after ${requestTimeoutMs}ms`);
}
throw error;
} finally {
detachFallback?.();
timeout.cleanup();
}
},
});
};
+130
View File
@@ -548,6 +548,136 @@ describe('updateDesktopSettings', () => {
});
});
test('does not broadcast a stale project selection over a newer pending update', async () => {
const firstSave = deferred<SettingsPayload>();
const savedChanges: Array<Partial<SettingsPayload>> = [];
registerSettingsSave(async (changes) => {
savedChanges.push(changes);
if (savedChanges.length === 1) return firstSave.promise;
return changes as SettingsPayload;
});
const syncedSettings: SettingsPayload[] = [];
const handleSettingsSynced = (event: Event) => {
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
};
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
try {
const firstUpdate = updateDesktopSettings({ activeProjectId: 'project-a' });
await delay(250);
const secondUpdate = updateDesktopSettings({ activeProjectId: 'project-b' });
firstSave.resolve({ activeProjectId: 'project-a' });
await firstUpdate;
expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-b');
await secondUpdate;
} finally {
getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced);
}
});
test('does not broadcast a stale loaded project selection over a newer pending update', async () => {
const loadedSettings = deferred<{ settings: SettingsPayload; source: 'web' | 'vscode' }>();
registerSettingsApi(async (changes) => changes as SettingsPayload, () => loadedSettings.promise);
invalidateSettingsCache();
const syncedSettings: SettingsPayload[] = [];
const handleSettingsSynced = (event: Event) => {
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
};
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
try {
const sync = syncDesktopSettings();
const update = updateDesktopSettings({ activeProjectId: 'project-b' });
loadedSettings.resolve({
settings: {
activeProjectId: 'project-a',
draftStartersCraftGoalAdded: true,
draftStartersScheduleTaskAdded: true,
},
source: 'web',
});
await sync;
expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-b');
await update;
} finally {
getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced);
}
});
test('does not broadcast a stale load after a newer project update has saved', async () => {
const loadedSettings = deferred<{ settings: SettingsPayload; source: 'web' | 'vscode' }>();
registerSettingsApi(async (changes) => changes as SettingsPayload, () => loadedSettings.promise);
invalidateSettingsCache();
const syncedSettings: SettingsPayload[] = [];
const handleSettingsSynced = (event: Event) => {
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
};
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
try {
const sync = syncDesktopSettings();
const update = updateDesktopSettings({ activeProjectId: 'project-b' });
await update;
loadedSettings.resolve({
settings: {
activeProjectId: 'project-a',
draftStartersCraftGoalAdded: true,
draftStartersScheduleTaskAdded: true,
},
source: 'web',
});
await sync;
expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-b');
} finally {
getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced);
}
});
test('preserves only the latest settings values across repeated pending updates', async () => {
const loadedSettings = deferred<{ settings: SettingsPayload; source: 'web' | 'vscode' }>();
registerSettingsApi(async (changes) => changes as SettingsPayload, () => loadedSettings.promise);
invalidateSettingsCache();
const syncedSettings: SettingsPayload[] = [];
const handleSettingsSynced = (event: Event) => {
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
};
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
try {
const sync = syncDesktopSettings();
const updates = Array.from({ length: 100 }, (_, index) => updateDesktopSettings({
activeProjectId: `project-${index}`,
showReasoningTraces: index % 2 === 0,
}));
loadedSettings.resolve({
settings: {
activeProjectId: 'stale-project',
showReasoningTraces: true,
draftStartersCraftGoalAdded: true,
draftStartersScheduleTaskAdded: true,
},
source: 'web',
});
await sync;
expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-99');
expect(syncedSettings.at(-1)?.showReasoningTraces).toBe(false);
await Promise.all(updates);
} finally {
getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced);
}
});
test('applies model selector settings from server settings', async () => {
getWindow();
const settings = {
+133 -48
View File
@@ -1676,6 +1676,62 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
};
type SettingsRuntimeContext = { runtimeKey: string; generation: number };
type SettingsMutation = { revision: number; changes: Partial<DesktopSettings> };
type SettingsOperation = { revision: number };
class SettingsMutationTracker {
private revision = 0;
private mutations: SettingsMutation[] = [];
private operations = new Set<SettingsOperation>();
record(changes: Partial<DesktopSettings>): number {
this.revision += 1;
if (this.operations.size > 0) {
const latest = this.mutations.at(-1);
// A new segment is only needed when an operation started after the last one.
const crossedOperationBoundary = latest
? [...this.operations].some((operation) => operation.revision >= latest.revision)
: true;
if (latest && !crossedOperationBoundary) {
latest.revision = this.revision;
latest.changes = { ...latest.changes, ...changes };
} else {
this.mutations.push({ revision: this.revision, changes });
}
}
return this.revision;
}
begin(revision = this.revision): SettingsOperation {
const operation = { revision };
this.operations.add(operation);
return operation;
}
reconcile(settings: DesktopSettings, operation: SettingsOperation): DesktopSettings {
let reconciled = settings;
for (const mutation of this.mutations) {
if (mutation.revision <= operation.revision) continue;
reconciled = { ...reconciled, ...mutation.changes };
}
return reconciled;
}
finish(operation: SettingsOperation): void {
if (!this.operations.delete(operation)) return;
if (this.operations.size === 0) {
this.mutations = [];
return;
}
const oldestRevision = Math.min(...[...this.operations].map(({ revision }) => revision));
this.mutations = this.mutations.filter((mutation) => mutation.revision > oldestRevision);
}
reset(): void {
this.mutations = [];
this.operations.clear();
}
}
// Short-lived cache + in-flight dedup for settings fetches to avoid repeated GET calls during startup
let _settingsRuntimeGeneration = 0;
@@ -1686,6 +1742,8 @@ let _pendingSettingsContext: SettingsRuntimeContext | null = null;
let _settingsFlushTimer: ReturnType<typeof setTimeout> | null = null;
let _settingsFlushWaiters: Array<() => void> = [];
let _settingsLifecycleInitialized = false;
let _pendingSettingsRevision = 0;
const _settingsMutationTracker = new SettingsMutationTracker();
const SETTINGS_CACHE_TTL = 2_000; // 2 seconds — covers the startup burst
const SETTINGS_DEBOUNCE_MS = 200;
@@ -1714,6 +1772,8 @@ const ensureSettingsRuntimeLifecycle = (): void => {
subscribeRuntimeEndpointChanged((detail) => {
if (detail.runtimeKey === detail.previousRuntimeKey) return;
_settingsRuntimeGeneration += 1;
_settingsMutationTracker.reset();
_pendingSettingsRevision = 0;
_settingsCache = null;
_settingsInflight = null;
});
@@ -1787,6 +1847,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
}
ensureSettingsRuntimeLifecycle();
const context = captureSettingsRuntimeContext();
const operation = _settingsMutationTracker.begin();
const persistApis = [getPersistApi(), useSessionDisplayStore.persist];
@@ -1823,8 +1884,22 @@ export const syncDesktopSettings = async (): Promise<void> => {
// Each step is wrapped in try/catch so a failure in one side-effect (e.g.
// a TypeError from writing to a contextBridge-protected global) doesn't
// prevent server settings from reaching the Zustand store.
const applySettings = async (settings: DesktopSettings) => {
// Local changes sitting in the debounce buffer are not yet tracked as
// mutations (record() only stores while a request is in flight), so a GET
// racing the debounce window would briefly revert them. Reapply the
// pending buffer over every reconciled result.
const overlayPendingChanges = (settings: DesktopSettings): DesktopSettings => {
if (!_pendingSettingsChanges || !_pendingSettingsContext) return settings;
if (!isSettingsRuntimeContextCurrent(_pendingSettingsContext)) return settings;
return { ...settings, ..._pendingSettingsChanges };
};
const applySettings = async (loadedSettings: DesktopSettings) => {
if (!isSettingsRuntimeContextCurrent(context)) return;
let settings = overlayPendingChanges(_settingsMutationTracker.reconcile(loadedSettings, operation));
await waitForHydration();
if (!isSettingsRuntimeContextCurrent(context)) return;
settings = overlayPendingChanges(_settingsMutationTracker.reconcile(loadedSettings, operation));
const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true
|| settings.draftStartersScheduleTaskAdded !== true;
// `autoSaveEnabled` is new to the settings backend. Until the server has a
@@ -1843,8 +1918,6 @@ export const syncDesktopSettings = async (): Promise<void> => {
} catch (error) {
console.warn('persistToLocalStorage failed:', error);
}
await waitForHydration();
if (!isSettingsRuntimeContextCurrent(context)) return;
if (shouldSeedAutoSaveEnabled) {
authoritativeSettings.autoSaveEnabled = useUIStore.getState().autoSaveEnabled;
}
@@ -1907,6 +1980,8 @@ export const syncDesktopSettings = async (): Promise<void> => {
}
} catch (error) {
console.warn('Failed to synchronise settings:', error);
} finally {
_settingsMutationTracker.finish(operation);
}
};
@@ -1914,9 +1989,11 @@ export const syncDesktopSettings = async (): Promise<void> => {
async function _flushSettingsUpdate(): Promise<void> {
const changes = _pendingSettingsChanges;
const context = _pendingSettingsContext;
const revision = _pendingSettingsRevision;
const waiters = _settingsFlushWaiters;
_pendingSettingsChanges = null;
_pendingSettingsContext = null;
_pendingSettingsRevision = 0;
_settingsFlushTimer = null;
_settingsFlushWaiters = [];
try {
@@ -1925,59 +2002,66 @@ async function _flushSettingsUpdate(): Promise<void> {
dispatchSettingsSaveState('saved');
return;
}
const operation = _settingsMutationTracker.begin(revision);
const runtimeSettings = getRuntimeSettingsAPI();
if (runtimeSettings) {
try {
const runtimeSettings = getRuntimeSettingsAPI();
if (runtimeSettings) {
try {
const updated = await runtimeSettings.save(changes);
if (!isSettingsRuntimeContextCurrent(context)) return;
if (updated) {
const reconciled = _settingsMutationTracker.reconcile(updated, operation);
applyDesktopUiPreferences(reconciled);
dispatchSettingsSynced(reconciled);
_settingsCache = null;
}
dispatchSettingsSaveState(updated ? 'saved' : 'error');
return;
} catch (error) {
if (!isSettingsRuntimeContextCurrent(context)) return;
console.warn('Failed to update settings via runtime settings API:', error);
}
}
if (!isSettingsRuntimeContextCurrent(context)) return;
try {
const updated = await runtimeSettings.save(changes);
const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(changes),
});
if (!isSettingsRuntimeContextCurrent(context)) return;
if (!response.ok) {
console.warn('Failed to update shared settings via API:', response.status, response.statusText);
dispatchSettingsSaveState('error');
return;
}
const updated = sanitizeWebSettings(await response.json().catch(() => null));
if (!isSettingsRuntimeContextCurrent(context)) return;
if (updated) {
applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated);
const reconciled = _settingsMutationTracker.reconcile(updated, operation);
applyDesktopUiPreferences(reconciled);
dispatchSettingsSynced(reconciled);
dispatchSettingsSaveState('saved');
// Invalidate GET cache so next read sees the fresh data
_settingsCache = null;
} else {
dispatchSettingsSaveState('error');
}
dispatchSettingsSaveState(updated ? 'saved' : 'error');
return;
} catch (error) {
if (!isSettingsRuntimeContextCurrent(context)) return;
console.warn('Failed to update settings via runtime settings API:', error);
}
}
if (!isSettingsRuntimeContextCurrent(context)) return;
try {
const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(changes),
});
if (!isSettingsRuntimeContextCurrent(context)) return;
if (!response.ok) {
console.warn('Failed to update shared settings via API:', response.status, response.statusText);
dispatchSettingsSaveState('error');
return;
}
const updated = sanitizeWebSettings(await response.json().catch(() => null));
if (!isSettingsRuntimeContextCurrent(context)) return;
if (updated) {
applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated);
dispatchSettingsSaveState('saved');
// Invalidate GET cache so next read sees the fresh data
_settingsCache = null;
} else {
dispatchSettingsSaveState('error');
}
} catch (error) {
if (isSettingsRuntimeContextCurrent(context)) {
console.warn('Failed to update shared settings via API:', error);
dispatchSettingsSaveState('error');
if (isSettingsRuntimeContextCurrent(context)) {
console.warn('Failed to update shared settings via API:', error);
dispatchSettingsSaveState('error');
}
}
} finally {
_settingsMutationTracker.finish(operation);
}
} finally {
waiters.forEach((resolve) => resolve());
@@ -1998,6 +2082,7 @@ export const updateDesktopSettings = async (changes: Partial<DesktopSettings>):
_pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes };
_pendingSettingsContext = context;
_pendingSettingsRevision = _settingsMutationTracker.record(changes);
dispatchSettingsSaveState('saving');
if (_settingsFlushTimer) {