From e6c2cf33dfa1dfbc2eac79995765189ee0064dad Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 16 May 2026 11:32:06 -0700 Subject: [PATCH 1/3] test: use canonical session ids in focus flows (cherry picked from commit 035a694989189f95e521a2cab297e9a52eae746b) --- test/e2e/sidebar-click-opens-pane.test.tsx | 2 +- test/unit/server/ws-sdk-session-history-cache.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/e2e/sidebar-click-opens-pane.test.tsx b/test/e2e/sidebar-click-opens-pane.test.tsx index b246f31da..abf8dc8f7 100644 --- a/test/e2e/sidebar-click-opens-pane.test.tsx +++ b/test/e2e/sidebar-click-opens-pane.test.tsx @@ -62,7 +62,7 @@ vi.mock('@/lib/api', async () => { const sessionId = (label: string) => { const chars = Array.from(label).map((ch, idx) => ((ch.charCodeAt(0) + idx) % 16).toString(16)) const hex = chars.join('').padEnd(32, '0').slice(0, 32) - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}` + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}` } function createStore(options: { diff --git a/test/unit/server/ws-sdk-session-history-cache.test.ts b/test/unit/server/ws-sdk-session-history-cache.test.ts index 9d6e46c1d..b7fe32eb9 100644 --- a/test/unit/server/ws-sdk-session-history-cache.test.ts +++ b/test/unit/server/ws-sdk-session-history-cache.test.ts @@ -377,7 +377,7 @@ describe('WsHandler agent history source DI', () => { messages: [], model: 'claude-sonnet-4-20250514', cwd: '/tmp', - resumeSessionId: '01234567-89ab-cdef-0123-456789abcdef', + resumeSessionId: '01234567-89ab-4def-8123-456789abcdef', streamingActive: false, streamingText: '', pendingPermissions: new Map(), @@ -394,7 +394,7 @@ describe('WsHandler agent history source DI', () => { messages: [], model: 'claude-sonnet-4-20250514', cwd: '/tmp', - resumeSessionId: '01234567-89ab-cdef-0123-456789abcdef', + resumeSessionId: '01234567-89ab-4def-8123-456789abcdef', streamingActive: false, streamingText: '', })), @@ -423,12 +423,12 @@ describe('WsHandler agent history source DI', () => { type: 'sdk.create', requestId: 'req-module', cwd: '/tmp', - resumeSessionId: '01234567-89ab-cdef-0123-456789abcdef', + resumeSessionId: '01234567-89ab-4def-8123-456789abcdef', })) await waitForMessage(ws, (d) => d.type === 'sdk.session.snapshot') - expect(moduleLoadSessionHistoryMock).toHaveBeenCalledWith('01234567-89ab-cdef-0123-456789abcdef') + expect(moduleLoadSessionHistoryMock).toHaveBeenCalledWith('01234567-89ab-4def-8123-456789abcdef') ws.close() }) From c57a9c33630984bad62ef200b74e953cdb70927e Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 16 May 2026 21:41:20 -0700 Subject: [PATCH 2/3] fix: preserve opencode resume model identity (cherry picked from commit ec0217804584a87214836464fa03a707f8db124c) --- scripts/run-standard-tests.ts | 2 ++ server/terminal-registry.ts | 4 ++- .../real-session-contract-harness.ts | 18 +++++++++-- .../real/coding-cli-session-contract.test.ts | 4 +-- test/unit/server/run-standard-tests.test.ts | 15 +++++++++ test/unit/server/terminal-registry.test.ts | 32 +++++++++++++++++++ test/unit/vite-config.test.ts | 12 +++++-- vitest.config.ts | 1 + vitest.server.config.ts | 1 + 9 files changed, 82 insertions(+), 7 deletions(-) diff --git a/scripts/run-standard-tests.ts b/scripts/run-standard-tests.ts index db3b31969..fd8af348a 100644 --- a/scripts/run-standard-tests.ts +++ b/scripts/run-standard-tests.ts @@ -108,9 +108,11 @@ function classifySuitePath(token: string): SuiteName | null { normalizedToken.startsWith('test/server/') || normalizedToken.startsWith('test/unit/server/') || normalizedToken.startsWith('test/integration/server/') + || normalizedToken.startsWith('test/integration/real/') || normalizedToken.includes('/test/server/') || normalizedToken.includes('/test/unit/server/') || normalizedToken.includes('/test/integration/server/') + || normalizedToken.includes('/test/integration/real/') || normalizedToken.endsWith('/test/integration/session-repair.test.ts') || normalizedToken.endsWith('/test/integration/session-search-e2e.test.ts') || normalizedToken.endsWith('/test/integration/extension-system.test.ts') diff --git a/server/terminal-registry.ts b/server/terminal-registry.ts index 8e0395bda..e9cee9e63 100644 --- a/server/terminal-registry.ts +++ b/server/terminal-registry.ts @@ -258,7 +258,9 @@ function resolveCodingCliCommand( ) } const effectiveModel = mode === 'opencode' - ? resolveOpencodeLaunchModel(providerSettings?.model, { ...process.env, ...commandEnv }) + ? (resumeSessionId + ? undefined + : resolveOpencodeLaunchModel(providerSettings?.model, { ...process.env, ...commandEnv })) : providerSettings?.model if (effectiveModel && spec.modelArgs) { settingsArgs.push(...spec.modelArgs(effectiveModel)) diff --git a/test/helpers/coding-cli/real-session-contract-harness.ts b/test/helpers/coding-cli/real-session-contract-harness.ts index 1e5644896..0fe9d087d 100644 --- a/test/helpers/coding-cli/real-session-contract-harness.ts +++ b/test/helpers/coding-cli/real-session-contract-harness.ts @@ -213,7 +213,7 @@ async function listFilesRecursive(rootDir: string): Promise { async function waitForHttpJson(url: string, timeoutMs = 30_000): Promise { return waitFor(`HTTP JSON at ${url}`, async () => { try { - const response = await fetch(url) + const response = await fetchWithTimeout(url) if (!response.ok) { return undefined } @@ -224,6 +224,16 @@ async function waitForHttpJson(url: string, timeoutMs = 30_000): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetch(url, { signal: controller.signal }) + } finally { + clearTimeout(timeout) + } +} + function parseJsonLines(text: string): unknown[] { return text .split(/\r?\n/) @@ -1268,13 +1278,17 @@ export async function waitForFileSizeIncrease(filePath: string, previousSize: nu } export async function fetchJson(url: string): Promise { - const response = await fetch(url) + const response = await fetchWithTimeout(url) if (!response.ok) { throw new Error(`Expected a successful response from ${url}, received ${response.status}.`) } return response.json() } +export async function waitForJsonResponse(url: string): Promise { + return waitForHttpJson(url) +} + export async function waitForHttpBusyStatus(url: string, sessionId: string): Promise> { return waitFor(`OpenCode busy status for ${sessionId}`, async () => { const payload = await fetchJson(url).catch(() => undefined) diff --git a/test/integration/real/coding-cli-session-contract.test.ts b/test/integration/real/coding-cli-session-contract.test.ts index 699242022..2caed76ba 100644 --- a/test/integration/real/coding-cli-session-contract.test.ts +++ b/test/integration/real/coding-cli-session-contract.test.ts @@ -10,7 +10,6 @@ import { captureCodexBootstrapEvents, captureCodexResumeBootstrapEvents, extractCodexResumeId, - fetchJson, findClaudeTranscript, findCodexSessionArtifacts, loadCodingCliSessionContractNote, @@ -27,6 +26,7 @@ import { waitForFileSizeIncrease, waitForAnyHttpBusyStatus, waitForHttpHealthy, + waitForJsonResponse, waitForJsonLine, waitForOpencodeDbSession, } from '../../helpers/coding-cli/real-session-contract-harness.js' @@ -451,7 +451,7 @@ describe.sequential('coding cli real provider session contract', () => { healthy: true, version: note.providers.opencode.version, }) - expect(await fetchJson(statusUrl)).toEqual({}) + expect(await waitForJsonResponse(statusUrl)).toEqual({}) const attachedRun = await workspace.spawnProcess( opencodePath, diff --git a/test/unit/server/run-standard-tests.test.ts b/test/unit/server/run-standard-tests.test.ts index 971cdb670..bb9574b25 100644 --- a/test/unit/server/run-standard-tests.test.ts +++ b/test/unit/server/run-standard-tests.test.ts @@ -123,6 +123,21 @@ describe('run-standard-tests', () => { }) }) + it('routes real provider integration paths to the server suite only', () => { + expect(createStandardTestPlan({ + availableParallelism: 32, + ci: false, + forwardedArgs: ['test/integration/real/coding-cli-session-contract.test.ts'], + })).toEqual({ + mode: 'desktop', + stages: [ + [ + { name: 'server', configPath: 'vitest.server.config.ts', maxWorkers: '3', priority: 'background' }, + ], + ], + }) + }) + it('routes electron-targeted paths to the electron suite only', () => { expect(createStandardTestPlan({ availableParallelism: 32, diff --git a/test/unit/server/terminal-registry.test.ts b/test/unit/server/terminal-registry.test.ts index ba2b915df..28b60e41d 100644 --- a/test/unit/server/terminal-registry.test.ts +++ b/test/unit/server/terminal-registry.test.ts @@ -990,6 +990,38 @@ describe('buildSpawnSpec Unix paths', () => { expect(spec.args).toContain('openai/gpt-5-mini') }) + it('does not pass a default OpenCode model when resuming a session', () => { + delete process.env.OPENCODE_CMD + + const spec = buildSpawnSpec('opencode', '/Users/john/project', 'system', 'ses_existing', { + model: 'abc', + opencodeServer: TEST_OPENCODE_SERVER, + }) + + expect(spec.args).toContain('--session') + expect(spec.args).toContain('ses_existing') + expect(spec.args).not.toContain('--model') + expect(spec.args).not.toContain('abc') + }) + + it('does not pass an inferred OpenCode model when resuming a session', () => { + delete process.env.OPENCODE_CMD + delete process.env.GOOGLE_GENERATIVE_AI_API_KEY + delete process.env.GOOGLE_API_KEY + delete process.env.OPENAI_API_KEY + delete process.env.ANTHROPIC_API_KEY + process.env.GEMINI_API_KEY = 'gemini-key' + + const spec = buildSpawnSpec('opencode', '/Users/john/project', 'system', 'ses_existing', { + opencodeServer: TEST_OPENCODE_SERVER, + }) + + expect(spec.args).toContain('--session') + expect(spec.args).toContain('ses_existing') + expect(spec.args).not.toContain('--model') + expect(spec.args).not.toContain('google/gemini-3-pro-preview') + }) + it('defaults OpenCode to a usable Google model and alias env when only GEMINI_API_KEY is set', () => { delete process.env.OPENCODE_CMD delete process.env.GOOGLE_GENERATIVE_AI_API_KEY diff --git a/test/unit/vite-config.test.ts b/test/unit/vite-config.test.ts index ec5c800af..6080e3362 100644 --- a/test/unit/vite-config.test.ts +++ b/test/unit/vite-config.test.ts @@ -150,11 +150,19 @@ describe('vitest config', () => { } }) - it('does not exclude real-provider integration contracts from the default suite', async () => { + it('excludes real-provider integration contracts from the default jsdom suite', async () => { const configModule = await import('../../vitest.config.ts') const config = configModule.default const excluded = config.test?.exclude ?? [] - expect(excluded).not.toContain('test/integration/real/**') + expect(excluded).toContain('test/integration/real/**') + }) + + it('runs real-provider integration contracts in the node server suite', async () => { + const configModule = await import('../../vitest.server.config.ts') + const config = configModule.default + const included = config.test?.include ?? [] + + expect(included).toContain('test/integration/real/**/*.test.ts') }) }) diff --git a/vitest.config.ts b/vitest.config.ts index 6c9a546a4..cf4e92982 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ 'test/integration/session-repair.test.ts', 'test/integration/session-search-e2e.test.ts', 'test/e2e-browser/**', + 'test/integration/real/**', // Electron tests run under vitest.electron.config.ts (node environment) 'test/unit/electron/**', // Electron E2E tests run under Playwright, not Vitest diff --git a/vitest.server.config.ts b/vitest.server.config.ts index 8f065e954..8ca3d23be 100644 --- a/vitest.server.config.ts +++ b/vitest.server.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ 'test/unit/server/**/*.test.ts', 'test/unit/visible-first/**/*.test.ts', 'test/integration/server/**/*.test.ts', + 'test/integration/real/**/*.test.ts', 'test/integration/session-repair.test.ts', 'test/integration/session-search-e2e.test.ts', 'test/integration/extension-system.test.ts', From 15c3f4dc89399d6cb214ea57ac7023062500e42c Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 16 May 2026 23:26:59 -0700 Subject: [PATCH 3/3] fix: hydrate restored opencode tui without replay cap (cherry picked from commit fdcc0bfdd2726d3406fa1e8bc5f03f13d93d5103) --- src/components/TerminalView.tsx | 19 ++++++++++-- .../TerminalView.lifecycle.test.tsx | 29 +++++++++++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index def853dc9..0af23dc0e 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -112,6 +112,12 @@ const DEFAULT_MIN_CONTRAST_RATIO = 1 const MAX_LAST_SENT_VIEWPORT_CACHE_ENTRIES = 200 const TRUNCATED_REPLAY_BYTES = 128 * 1024 +function viewportHydrateReplayOptions(content?: TerminalPaneContent | null): { maxReplayBytes: number } | undefined { + return content?.mode === 'opencode' + ? undefined + : { maxReplayBytes: TRUNCATED_REPLAY_BYTES } +} + type StartupProbeReplayDiscardState = { remainder: string | null buffered: string @@ -1640,7 +1646,10 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) } setIsAttaching(false) } else { - attachTerminal(tid, 'viewport_hydrate', { clearViewportFirst: true, maxReplayBytes: TRUNCATED_REPLAY_BYTES }) + attachTerminal(tid, 'viewport_hydrate', { + clearViewportFirst: true, + ...viewportHydrateReplayOptions(currentContent), + }) } dispatch(consumePaneRefreshRequest({ tabId, paneId, requestId: request.requestId })) @@ -1689,7 +1698,9 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) clearViewportFirst: deferred.pendingIntent === 'viewport_hydrate', suppressNextMatchingResize: true, skipPreAttachFit: true, - ...(deferred.pendingIntent === 'viewport_hydrate' ? { maxReplayBytes: TRUNCATED_REPLAY_BYTES } : {}), + ...(deferred.pendingIntent === 'viewport_hydrate' + ? viewportHydrateReplayOptions(contentRef.current) + : undefined), }) return } @@ -2342,7 +2353,9 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const intent: AttachIntent = deferredAttachStateRef.current.mode === 'live' ? 'keepalive_delta' : 'viewport_hydrate' - attachTerminal(currentTerminalId, intent, intent === 'viewport_hydrate' ? { maxReplayBytes: TRUNCATED_REPLAY_BYTES } : undefined) + attachTerminal(currentTerminalId, intent, intent === 'viewport_hydrate' + ? viewportHydrateReplayOptions(contentRef.current) + : undefined) } } else { deferredAttachStateRef.current = { diff --git a/test/unit/client/components/TerminalView.lifecycle.test.tsx b/test/unit/client/components/TerminalView.lifecycle.test.tsx index 572373112..d6cd81fa8 100644 --- a/test/unit/client/components/TerminalView.lifecycle.test.tsx +++ b/test/unit/client/components/TerminalView.lifecycle.test.tsx @@ -3091,6 +3091,7 @@ describe('TerminalView lifecycle updates', () => { async function renderTerminalHarness(opts?: { status?: 'creating' | 'running' terminalId?: string + mode?: TerminalPaneContent['mode'] hidden?: boolean clearSends?: boolean requestId?: string @@ -3102,12 +3103,13 @@ describe('TerminalView lifecycle updates', () => { const requestId = opts?.requestId ?? 'req-v2-stream' const initialStatus = opts?.status ?? 'running' const terminalId = opts?.terminalId + const mode = opts?.mode ?? 'shell' const paneContent: TerminalPaneContent = { kind: 'terminal', createRequestId: requestId, status: initialStatus, - mode: 'shell', + mode, shell: 'system', ...(terminalId ? { terminalId } : {}), } @@ -3126,9 +3128,9 @@ describe('TerminalView lifecycle updates', () => { tabs: { tabs: [{ id: tabId, - mode: 'shell', + mode, status: initialStatus, - title: 'Shell', + title: mode === 'opencode' ? 'OpenCode' : 'Shell', titleSetByUser: false, createRequestId: requestId, ...(terminalId ? { terminalId } : {}), @@ -3977,6 +3979,27 @@ describe('TerminalView lifecycle updates', () => { })) }) + it('does not cap OpenCode viewport hydration replay for restored running terminals', async () => { + const { terminalId } = await renderTerminalHarness({ + status: 'running', + terminalId: 'term-opencode-restored', + mode: 'opencode', + clearSends: false, + }) + + const attach = wsMocks.send.mock.calls + .map(([msg]) => msg) + .find((msg) => msg?.type === 'terminal.attach' && msg?.terminalId === terminalId) + + expect(attach).toMatchObject({ + type: 'terminal.attach', + terminalId, + intent: 'viewport_hydrate', + sinceSeq: 0, + }) + expect(attach).not.toHaveProperty('maxReplayBytes') + }) + it('revealing a hidden running pane sends a viewport attach with sinceSeq=0', async () => { const { store, tabId, paneId, terminalId, rerender } = await renderTerminalHarness({ status: 'running',