From 88297dc1a42dd4f3938eb838db3dd64861304eff Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 19 Jun 2026 11:56:13 -0700 Subject: [PATCH 1/2] test: repro freshopencode first-send reload loss --- test/e2e-browser/fixtures/fake-opencode.cjs | 52 +++++ ...shopencode-first-send-reload-repro.spec.ts | 194 ++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 test/e2e-browser/specs/freshopencode-first-send-reload-repro.spec.ts diff --git a/test/e2e-browser/fixtures/fake-opencode.cjs b/test/e2e-browser/fixtures/fake-opencode.cjs index 1218f8717..7859d8d4c 100644 --- a/test/e2e-browser/fixtures/fake-opencode.cjs +++ b/test/e2e-browser/fixtures/fake-opencode.cjs @@ -469,6 +469,58 @@ const server = http.createServer((req, res) => { } if (url.pathname === '/session') { + if (req.method === 'POST') { + appendAudit({ + event: 'session_create_requested', + rootSessionId, + childSessionId, + }) + if (process.env.FAKE_OPENCODE_HANG_SESSION_CREATE === '1') { + req.on('close', () => { + appendAudit({ + event: 'session_create_request_closed', + rootSessionId, + childSessionId, + }) + }) + return + } + let bodyText = '' + req.setEncoding('utf8') + req.on('data', (chunk) => { + bodyText += chunk + }) + req.on('end', () => { + const input = parseJsonText(bodyText) || {} + const now = Date.now() + const sessionId = `ses_http_${now}_${process.pid}` + const directory = typeof input.directory === 'string' && input.directory.length > 0 + ? input.directory + : serverProjectDirectory() + const title = typeof input.title === 'string' && input.title.length > 0 + ? input.title + : `Freshopencode ${sessionId}` + const db = openDatabase() + try { + ensureSchema(db) + insertSession(db, { + sessionId, + projectId: 'proj-http', + parentId: typeof input.parentID === 'string' ? input.parentID : null, + slug: sessionId, + directory, + title, + createdAt: now, + updatedAt: now, + }) + } finally { + db.close() + } + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ id: sessionId, directory, title })) + }) + return + } res.writeHead(200, { 'content-type': 'application/json' }) res.end(JSON.stringify([ { id: rootSessionId, title: `Root ${rootSessionId}` }, diff --git a/test/e2e-browser/specs/freshopencode-first-send-reload-repro.spec.ts b/test/e2e-browser/specs/freshopencode-first-send-reload-repro.spec.ts new file mode 100644 index 000000000..d15d93850 --- /dev/null +++ b/test/e2e-browser/specs/freshopencode-first-send-reload-repro.spec.ts @@ -0,0 +1,194 @@ +import { expect, test, type Page } from '@playwright/test' +import fsp from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { openPanePicker } from '../helpers/pane-picker.js' +import { TestHarness } from '../helpers/test-harness.js' +import { TestServer } from '../helpers/test-server.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const fakeOpencodeSource = path.resolve(__dirname, '../fixtures/fake-opencode.cjs') + +type FakeAuditEvent = { + event?: string + prompt?: string +} + +type FreshOpencodePaneState = { + sessionId?: string + status?: string + sessionRef?: { provider?: string; sessionId?: string } +} + +async function installFakeOpencode(binDir: string): Promise { + await fsp.mkdir(binDir, { recursive: true }) + const target = path.join(binDir, 'opencode') + await fsp.copyFile(fakeOpencodeSource, target) + await fsp.chmod(target, 0o755) +} + +function createSetupHome(sharedOpencodeDataDir: string) { + return async (homeDir: string): Promise => { + const xdgShare = path.join(homeDir, '.local', 'share') + const opencodeLink = path.join(xdgShare, 'opencode') + const freshellDir = path.join(homeDir, '.freshell') + await fsp.mkdir(xdgShare, { recursive: true }) + await fsp.mkdir(freshellDir, { recursive: true }) + await fsp.mkdir(sharedOpencodeDataDir, { recursive: true }) + await fsp.rm(opencodeLink, { recursive: true, force: true }).catch(() => {}) + await fsp.symlink(sharedOpencodeDataDir, opencodeLink, 'dir') + await fsp.writeFile(path.join(freshellDir, 'config.json'), JSON.stringify({ + version: 1, + settings: { + codingCli: { + enabledProviders: ['opencode'], + providers: { opencode: {} }, + }, + freshAgent: { enabled: true }, + }, + }, null, 2)) + } +} + +function createServerOptions(input: { + binDir: string + auditLogPath: string + logsDir: string + sharedOpencodeDataDir: string +}) { + return { + setupHome: createSetupHome(input.sharedOpencodeDataDir), + env: { + PATH: `${input.binDir}${path.delimiter}${process.env.PATH ?? ''}`, + FAKE_OPENCODE_AUDIT_LOG: input.auditLogPath, + FAKE_OPENCODE_HANG_SESSION_CREATE: '1', + FRESHELL_LOG_DIR: input.logsDir, + }, + } +} + +async function readAuditEvents(auditLogPath: string): Promise { + try { + const text = await fsp.readFile(auditLogPath, 'utf8') + return text.split('\n').filter(Boolean).map((line) => JSON.parse(line) as FakeAuditEvent) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error + } +} + +async function enableFreshOpencode(page: Page): Promise { + await page.evaluate(() => { + const harness = window.__FRESHELL_TEST_HARNESS__ + harness?.dispatch({ + type: 'connection/setAvailableClis', + payload: { opencode: true }, + }) + harness?.dispatch({ + type: 'settings/previewServerSettingsPatch', + payload: { + codingCli: { enabledProviders: ['opencode'] }, + freshAgent: { enabled: true }, + }, + }) + }) +} + +async function createFreshopencodePane(page: Page, cwd: string): Promise { + const picker = await openPanePicker(page) + await picker.getByRole('button', { name: /^Freshopencode$/i }).click({ force: true }) + const directoryInput = page.getByLabel(/^Starting directory for Freshopencode$/i) + await expect(directoryInput).toBeVisible({ timeout: 15_000 }) + await directoryInput.fill(cwd) + await directoryInput.press('Enter') + await expect(page.locator('[data-context="fresh-agent"]').last()).toBeVisible({ timeout: 15_000 }) +} + +async function getFreshOpencodePaneState(page: Page): Promise { + return page.evaluate(() => { + const state = window.__FRESHELL_TEST_HARNESS__?.getState() + const activeTabId = state?.tabs?.activeTabId + const findFreshOpencode = (node: any): any => { + if (!node) return undefined + if (node.type === 'leaf' && node.content?.kind === 'fresh-agent' && node.content.provider === 'opencode') { + return node.content + } + if (node.type === 'split') return findFreshOpencode(node.children?.[0]) ?? findFreshOpencode(node.children?.[1]) + return undefined + } + return findFreshOpencode(state?.panes?.layouts?.[activeTabId]) ?? {} + }) +} + +async function sendFreshAgentPrompt(page: Page, prompt: string): Promise { + const textbox = page.getByRole('textbox', { name: 'Chat message input' }) + await expect(textbox).toBeVisible({ timeout: 15_000 }) + await expect(textbox).not.toBeDisabled({ timeout: 15_000 }) + await textbox.fill(prompt) + await page.getByRole('button', { name: 'Send' }).click() +} + +test.describe('Freshopencode first-send reload regression', () => { + test.setTimeout(90_000) + + test('keeps a submitted first prompt visible across reload while session materialization is pending', async ({ page }) => { + const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-freshopencode-repro-')) + const binDir = path.join(sharedRoot, 'bin') + const logsDir = path.join(sharedRoot, 'logs') + const auditLogPath = path.join(sharedRoot, 'fake-opencode-audit.jsonl') + const sharedOpencodeDataDir = path.join(sharedRoot, 'opencode-data') + const cwd = path.join(sharedRoot, 'project') + const prompt = `playwright repro prompt ${Date.now()}` + await fsp.mkdir(cwd, { recursive: true }) + await installFakeOpencode(binDir) + + const server = new TestServer(createServerOptions({ + binDir, + auditLogPath, + logsDir, + sharedOpencodeDataDir, + })) + + try { + const info = await server.start() + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await enableFreshOpencode(page) + await createFreshopencodePane(page, cwd) + + await expect.poll(async () => getFreshOpencodePaneState(page), { timeout: 15_000 }).toMatchObject({ + sessionId: expect.stringMatching(/^freshopencode-/), + }) + await page.evaluate(() => { + window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'persist/flushNow' }) + }) + + await sendFreshAgentPrompt(page, prompt) + const transcript = page.locator('[data-context="fresh-agent-transcript"]') + await expect(transcript.getByText(prompt, { exact: true })).toBeVisible({ timeout: 5_000 }) + await expect.poll(async () => { + const events = await readAuditEvents(auditLogPath) + return events.some((event) => event.event === 'session_create_requested') + }, { timeout: 15_000 }).toBe(true) + + const duringSend = await getFreshOpencodePaneState(page) + expect(duringSend.sessionId).toMatch(/^freshopencode-/) + expect(duringSend.status).not.toBe('running') + expect(duringSend.sessionRef?.sessionId).toMatch(/^freshopencode-/) + + await page.reload() + await harness.waitForHarness() + await harness.waitForConnection() + const afterReloadTranscript = page.locator('[data-context="fresh-agent-transcript"]') + + await expect(afterReloadTranscript.getByText(prompt, { exact: true })).toBeVisible({ timeout: 5_000 }) + } finally { + await server.stop().catch(() => {}) + await fsp.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) + } + }) +}) From 489c29189d8f38146b3987e28836103e8d52b0f1 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 19 Jun 2026 12:21:07 -0700 Subject: [PATCH 2/2] fix: keep freshopencode first send visible across reload --- .../fresh-agent/adapters/opencode/adapter.ts | 53 ++++++------ .../adapters/opencode/serve-manager.ts | 85 +++++++++++++++++-- src/components/fresh-agent/FreshAgentView.tsx | 48 +++++++++-- src/store/paneTypes.ts | 22 +++++ src/store/panesSlice.ts | 9 +- ...shopencode-first-send-reload-repro.spec.ts | 2 +- .../fresh-agent/FreshAgentView.test.tsx | 58 +++++++++++++ .../opencode-serve-adapter.test.ts | 52 ++++++++++++ .../opencode-serve-manager.test.ts | 19 +++++ 9 files changed, 305 insertions(+), 43 deletions(-) diff --git a/server/fresh-agent/adapters/opencode/adapter.ts b/server/fresh-agent/adapters/opencode/adapter.ts index 72b2fa1d2..cdababce6 100644 --- a/server/fresh-agent/adapters/opencode/adapter.ts +++ b/server/fresh-agent/adapters/opencode/adapter.ts @@ -151,6 +151,11 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen }) } + function emitStatus(state: OpencodeSessionState, status: 'running' | 'idle'): void { + state.status = status + state.events.emit('event', { type: 'sdk.session.snapshot', sessionId: state.placeholderId, status }) + } + async function materializeOrSend(state: OpencodeSessionState, text: string, settings?: Partial): Promise { const normalized = settings ? normalizeOpencodeInput({ requestId: state.placeholderId, sessionType: 'freshopencode', provider: 'opencode', ...settings } as FreshAgentCreateRequest) @@ -159,40 +164,37 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen const effort = normalized?.effort ?? state.effort const effectiveCwd = normalized?.cwd ?? state.cwd - if (!state.realSessionId) { - const session = await serveManager.createSession({ title: undefined, ...(effectiveCwd ? { directory: effectiveCwd } : {}) }) - state.realSessionId = session.id - if (typeof session.directory === 'string' && session.directory.length > 0) state.cwd = session.directory - else if (effectiveCwd) state.cwd = effectiveCwd - remember(state) - bindServeStream(state) - } - - const realId = state.realSessionId! - state.status = 'running' - state.events.emit('event', { type: 'sdk.session.snapshot', sessionId: state.placeholderId, status: 'running' }) - const idle = serveManager.onceIdle(realId, turnTimeoutMs) - // If promptAsync fails and we leave via the catch(), `idle` may still - // reject later on its timeout timer. Attach a no-op handler now so that - // later rejection cannot become an unhandled rejection. - void idle.catch(() => {}) + emitStatus(state, 'running') try { + if (!state.realSessionId) { + const session = await serveManager.createSession({ title: undefined, ...(effectiveCwd ? { directory: effectiveCwd } : {}) }) + state.realSessionId = session.id + if (typeof session.directory === 'string' && session.directory.length > 0) state.cwd = session.directory + else if (effectiveCwd) state.cwd = effectiveCwd + remember(state) + bindServeStream(state) + } + + const realId = state.realSessionId! + const idle = serveManager.onceIdle(realId, turnTimeoutMs) + // If promptAsync fails and we leave via the catch(), `idle` may still + // reject later on its timeout timer. Attach a no-op handler now so that + // later rejection cannot become an unhandled rejection. + void idle.catch(() => {}) await promptAsyncForState(state, realId, { parts: [{ type: 'text', text }], ...(splitOpencodeModel(modelStr) ? { model: splitOpencodeModel(modelStr)! } : {}), ...(effort ? { variant: effort } : {}), }) await idle + state.model = modelStr ?? state.model + state.effort = effort + emitStatus(state, 'idle') + return sendResult(state.realSessionId) } catch (error) { - state.status = 'idle' - state.events.emit('event', { type: 'sdk.session.snapshot', sessionId: state.placeholderId, status: 'idle' }) + emitStatus(state, 'idle') throw error } - state.model = modelStr ?? state.model - state.effort = effort - state.status = 'idle' - state.events.emit('event', { type: 'sdk.session.snapshot', sessionId: state.placeholderId, status: 'idle' }) - return sendResult(state.realSessionId) } async function assembleExport( @@ -316,8 +318,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen async interrupt(sessionId) { const state = requireState(sessionId) await abortForState(state).catch((err) => log.warn({ err }, 'abort failed')) - state.status = 'idle' - state.events.emit('event', { type: 'sdk.session.snapshot', sessionId: state.placeholderId, status: 'idle' }) + emitStatus(state, 'idle') }, async compact(sessionId, input) { diff --git a/server/fresh-agent/adapters/opencode/serve-manager.ts b/server/fresh-agent/adapters/opencode/serve-manager.ts index 783b358e4..a43c24356 100644 --- a/server/fresh-agent/adapters/opencode/serve-manager.ts +++ b/server/fresh-agent/adapters/opencode/serve-manager.ts @@ -12,6 +12,7 @@ type OpencodeServeLogger = Pick const OWNERSHIP_ENV = 'FRESHELL_OPENCODE_SIDECAR_ID' const DEFAULT_IDLE_POLL_MS = 500 const REQUIRED_IDLE_STATUS_POLLS = 2 +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000 export type OpencodeServeManagerOptions = { command?: string @@ -24,6 +25,7 @@ export type OpencodeServeManagerOptions = { env?: NodeJS.ProcessEnv idleShutdownMs?: number idlePollMs?: number + requestTimeoutMs?: number } export type OpencodeServeMessage = { info: Record; parts: Array> } @@ -75,6 +77,13 @@ type ServeRoute = { const DEFAULT_CWD_KEY = '' +class OpencodeServeRequestTimeoutError extends Error { + constructor(method: string, requestPath: string, timeoutMs: number) { + super(`opencode serve ${method} ${requestPath} timed out after ${timeoutMs}ms`) + this.name = 'OpencodeServeRequestTimeoutError' + } +} + export class OpencodeServeManager { private readonly command: string private readonly spawnFn: typeof spawn @@ -85,6 +94,7 @@ export class OpencodeServeManager { private readonly env: NodeJS.ProcessEnv private readonly idleShutdownMs: number private readonly idlePollMs: number + private readonly requestTimeoutMs: number private readonly log: OpencodeServeLogger = logger.child({ component: 'opencode-serve-manager' }) /** sessionId → emitter of ParsedServeEvent (and a synthetic 'idle' event). */ private readonly sessionEmitters = new Map() @@ -105,6 +115,7 @@ export class OpencodeServeManager { this.env = options.env ?? process.env this.idleShutdownMs = options.idleShutdownMs ?? 15 * 60_000 this.idlePollMs = options.idlePollMs ?? DEFAULT_IDLE_POLL_MS + this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS } private routeFromCwd(cwd?: string): { cwdKey: string; cwd?: string } { @@ -164,6 +175,55 @@ export class OpencodeServeManager { running.idleTimer.unref?.() } + private discardRunning(route: { cwdKey: string; cwd?: string }, reason: string): void { + const running = this.runningByCwd.get(route.cwdKey) + if (!running) return + this.runningByCwd.delete(route.cwdKey) + this.startPromiseByCwd.delete(route.cwdKey) + this.startAbortByCwd.delete(route.cwdKey) + this.forgetSessionsForCwd(route.cwdKey) + this.clearIdleTimer(running) + try { running.stopEventStream() } catch { /* ignore */ } + this.log.warn({ reason, cwd: route.cwd }, 'discarding opencode serve sidecar') + void killOwnedProcesses(running.child, running.ownershipId, this.log) + } + + private async fetchWithRequestTimeout( + url: string, + requestPath: string, + init: RequestInit | undefined, + ): Promise { + if (this.requestTimeoutMs <= 0) { + return await this.fetchFn(url, init) + } + const controller = new AbortController() + let timedOut = false + const upstreamSignal = init?.signal + const abortFromUpstream = () => controller.abort() + if (upstreamSignal?.aborted) { + controller.abort() + } else { + upstreamSignal?.addEventListener('abort', abortFromUpstream, { once: true }) + } + const timeout = setTimeout(() => { + timedOut = true + controller.abort() + }, this.requestTimeoutMs) + timeout.unref?.() + + try { + return await this.fetchFn(url, { ...init, signal: controller.signal }) + } catch (error) { + if (timedOut) { + throw new OpencodeServeRequestTimeoutError(init?.method ?? 'GET', requestPath, this.requestTimeoutMs) + } + throw error + } finally { + clearTimeout(timeout) + upstreamSignal?.removeEventListener('abort', abortFromUpstream) + } + } + private async withRunning( route: { cwdKey: string; cwd?: string }, fn: (baseUrl: string) => Promise, @@ -322,16 +382,23 @@ export class OpencodeServeManager { const resolved = route.sessionId ? this.routeForSession(route.sessionId, route) : this.routeFromCwd(route.cwd) - return this.withRunning(resolved, async (base) => { - const res = await this.fetchFn(`${base}${requestPath}`, init) - if (!res.ok && res.status !== 204) { - if (res.status === 404 && init?.notFoundValue !== undefined) return init.notFoundValue - const text = await res.text().catch(() => '') - throw new Error(`opencode serve ${init?.method ?? 'GET'} ${requestPath} → ${res.status} ${text}`) + try { + return await this.withRunning(resolved, async (base) => { + const res = await this.fetchWithRequestTimeout(`${base}${requestPath}`, requestPath, init) + if (!res.ok && res.status !== 204) { + if (res.status === 404 && init?.notFoundValue !== undefined) return init.notFoundValue + const text = await res.text().catch(() => '') + throw new Error(`opencode serve ${init?.method ?? 'GET'} ${requestPath} → ${res.status} ${text}`) + } + if (res.status === 204) return undefined as T + return (await res.json()) as T + }) + } catch (error) { + if (error instanceof OpencodeServeRequestTimeoutError) { + this.discardRunning(resolved, 'request_timeout') } - if (res.status === 204) return undefined as T - return (await res.json()) as T - }) + throw error + } } private async getSessionStatusMap(route: ServeRoute = {}, init?: RequestInit): Promise { diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index 7b798be12..089da9fbd 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -72,6 +72,12 @@ type LocalEcho = { submittedTurnId?: string } +function sameLocalEcho(a: LocalEcho | null | undefined, b: LocalEcho | null | undefined): boolean { + return (a?.requestId ?? null) === (b?.requestId ?? null) + && (a?.text ?? null) === (b?.text ?? null) + && (a?.submittedTurnId ?? null) === (b?.submittedTurnId ?? null) +} + type PendingSendMetadata = { cwd?: string checkpointId?: string @@ -414,7 +420,7 @@ export function FreshAgentView({ // Optimistic echo of the just-sent user message: the transcript renders // snapshot turns only, which left a 2-10s blank gap after send // (live-test finding). Cleared when a snapshot containing the turn lands. - const [localEcho, setLocalEcho] = useState(null) + const [localEcho, setLocalEchoState] = useState(() => paneContent.pendingLocalEcho ?? null) const localEchoRef = useRef(null) localEchoRef.current = localEcho const pendingSendMetadataRef = useRef>(new Map()) @@ -431,6 +437,25 @@ export function FreshAgentView({ const paneContentRef = useRef(paneContent) const composerRef = useRef(null) paneContentRef.current = paneContent + const setLocalEcho = useCallback((next: LocalEcho | null) => { + setLocalEchoState(next) + const current = paneContentRef.current + if (sameLocalEcho(current.pendingLocalEcho, next)) return + dispatch(mergePaneContent({ + tabId, + paneId, + updates: { pendingLocalEcho: next ?? undefined }, + })) + }, [dispatch, paneId, tabId]) + useEffect(() => { + const next = paneContent.pendingLocalEcho ?? null + if (sameLocalEcho(localEchoRef.current, next)) return + setLocalEchoState(next) + }, [ + paneContent.pendingLocalEcho?.requestId, + paneContent.pendingLocalEcho?.submittedTurnId, + paneContent.pendingLocalEcho?.text, + ]) const restoreTimeoutRef = useRef(null) const createSentRef = useRef(false) // Session-scoped "always allow" tool names; reset with the pane, never persisted. @@ -682,9 +707,10 @@ export function FreshAgentView({ restoreError: undefined, createError: undefined, status: 'creating', + pendingLocalEcho: undefined, }, })) - }, [commitSnapshot, dispatch, paneId, sendFreshAgentMessage, tabId]) + }, [commitSnapshot, dispatch, paneId, sendFreshAgentMessage, setLocalEcho, tabId]) const sendFork = useCallback((atTurnId?: string) => { const current = paneContentRef.current @@ -1026,9 +1052,11 @@ export function FreshAgentView({ commitSnapshot(displaySnapshot) setSnapshotAutoTitleIdentity(snapshotIdentity) const echo = localEchoRef.current + const landedEcho = echo + ? localEchoLanded(displaySnapshot.turns, echo, pendingSendMetadataRef.current.get(echo.requestId)) + : false if (echo) { - const pending = pendingSendMetadataRef.current.get(echo.requestId) - if (localEchoLanded(displaySnapshot.turns, echo, pending)) setLocalEcho(null) + if (landedEcho) setLocalEcho(null) } const fresh = paneContentRef.current const nextStatus = (resolved.status as FreshAgentPaneContent['status']) ?? fresh.status @@ -1059,6 +1087,7 @@ export function FreshAgentView({ sessionRef: nextSessionRef, status: nextStatus, resumeSessionId: nextResumeSessionId, + pendingLocalEcho: landedEcho ? undefined : fresh.pendingLocalEcho, }, })) }) @@ -1288,6 +1317,7 @@ export function FreshAgentView({ firstMessage: text, })) } + const nextLocalEcho: LocalEcho = { text, requestId } sendFreshAgentMessage({ type: 'freshAgent.send', requestId, @@ -1303,7 +1333,15 @@ export function FreshAgentView({ ...(getEffectiveFreshAgentEffort(current) ? { effort: getEffectiveFreshAgentEffort(current) } : {}), }, }) - setLocalEcho({ text, requestId }) + setLocalEchoState(nextLocalEcho) + dispatch(mergePaneContent({ + tabId, + paneId, + updates: { + ...(current.provider === 'opencode' ? { status: 'running' } : {}), + pendingLocalEcho: nextLocalEcho, + }, + })) }, [dispatch, paneId, recordPendingSendMetadata, sendFreshAgentMessage, snapshotConfirmsNoUserTurns, tabId]) // Flush queued messages when the turn ends. One flush per status change is diff --git a/src/store/paneTypes.ts b/src/store/paneTypes.ts index b93b44ae7..e8849b0db 100644 --- a/src/store/paneTypes.ts +++ b/src/store/paneTypes.ts @@ -123,6 +123,26 @@ export type FreshAgentCreateError = { retryable?: boolean } +export type FreshAgentPendingLocalEcho = { + requestId: string + text: string + submittedTurnId?: string +} + +export function normalizeFreshAgentPendingLocalEcho(value: unknown): FreshAgentPendingLocalEcho | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + const record = value as Record + if (typeof record.requestId !== 'string' || record.requestId.length === 0) return undefined + if (typeof record.text !== 'string' || record.text.length === 0) return undefined + return { + requestId: record.requestId, + text: record.text, + ...(typeof record.submittedTurnId === 'string' && record.submittedTurnId.length > 0 + ? { submittedTurnId: record.submittedTurnId } + : {}), + } +} + export type FreshAgentPaneContent = { kind: 'fresh-agent' sessionType: FreshAgentSessionType @@ -150,6 +170,8 @@ export type FreshAgentPaneContent = { showThinking?: boolean showTools?: boolean showTimecodes?: boolean + /** Persisted optimistic user turn that has not yet appeared in a durable provider snapshot. */ + pendingLocalEcho?: FreshAgentPendingLocalEcho } /** diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index 34df0d826..3f8393c97 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -3,6 +3,7 @@ import { nanoid } from 'nanoid' import { normalizeFreshAgentEffortOverride, normalizeFreshAgentModelSelection, + normalizeFreshAgentPendingLocalEcho, type LivePaneContentInput, type PanesState, type PaneContent, @@ -96,6 +97,8 @@ function normalizePaneContent( const rawFreshAgent = input as Record const existingRestoreError = readRestoreError(rawFreshAgent.restoreError) const style = normalizeFreshAgentStyleOverride((input as { style?: unknown }).style) + const pendingLocalEcho = normalizeFreshAgentPendingLocalEcho(rawFreshAgent.pendingLocalEcho) + const status = input.status || (pendingLocalEcho ? 'running' : 'creating') if (existingRestoreError) { return { kind: 'fresh-agent', @@ -103,7 +106,7 @@ function normalizePaneContent( provider: input.provider, sessionId: input.sessionId, createRequestId: input.createRequestId || nanoid(), - status: input.status || 'creating', + status, ...(existingRestoreError.reason === 'invalid_legacy_restore_target' ? {} : { resumeSessionId: input.resumeSessionId }), @@ -125,6 +128,7 @@ function normalizePaneContent( showThinking: typeof input.showThinking === 'boolean' ? input.showThinking : undefined, showTools: typeof input.showTools === 'boolean' ? input.showTools : undefined, showTimecodes: typeof input.showTimecodes === 'boolean' ? input.showTimecodes : undefined, + ...(pendingLocalEcho ? { pendingLocalEcho } : {}), } } @@ -145,7 +149,7 @@ function normalizePaneContent( provider: input.provider, sessionId: input.sessionId, createRequestId: input.createRequestId || nanoid(), - status: input.status || 'creating', + status, ...(typeof input.resumeSessionId === 'string' ? { resumeSessionId: input.resumeSessionId } : {}), ...(sessionRef ? { sessionRef } : {}), serverInstanceId: typeof input.serverInstanceId === 'string' ? input.serverInstanceId : undefined, @@ -166,6 +170,7 @@ function normalizePaneContent( showThinking: typeof input.showThinking === 'boolean' ? input.showThinking : undefined, showTools: typeof input.showTools === 'boolean' ? input.showTools : undefined, showTimecodes: typeof input.showTimecodes === 'boolean' ? input.showTimecodes : undefined, + ...(pendingLocalEcho ? { pendingLocalEcho } : {}), } } if (input.kind === 'extension') { diff --git a/test/e2e-browser/specs/freshopencode-first-send-reload-repro.spec.ts b/test/e2e-browser/specs/freshopencode-first-send-reload-repro.spec.ts index d15d93850..f03f662dd 100644 --- a/test/e2e-browser/specs/freshopencode-first-send-reload-repro.spec.ts +++ b/test/e2e-browser/specs/freshopencode-first-send-reload-repro.spec.ts @@ -177,7 +177,7 @@ test.describe('Freshopencode first-send reload regression', () => { const duringSend = await getFreshOpencodePaneState(page) expect(duringSend.sessionId).toMatch(/^freshopencode-/) - expect(duringSend.status).not.toBe('running') + expect(duringSend.status).toBe('running') expect(duringSend.sessionRef?.sessionId).toMatch(/^freshopencode-/) await page.reload() diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index 761734179..bcdaf397e 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -1340,6 +1340,64 @@ describe('FreshAgentView', () => { expect(transcriptTurns.at(-1)).toHaveTextContent('Done.') }) + it('persists a pending local echo so a remounted pane keeps the submitted prompt visible', async () => { + const store = createStore() + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ + status: 'idle', + summary: 'empty', + capabilities: { send: true, interrupt: true, fork: true }, + turns: [], + }) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshopencode', + provider: 'opencode', + createRequestId: 'req-pending-echo', + sessionId: 'freshopencode-pending-echo', + status: 'idle', + }, + })) + + const rendered = render( + + + , + ) + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: 'Chat message input' })).not.toBeDisabled() + }) + fireEvent.change(screen.getByRole('textbox', { name: 'Chat message input' }), { + target: { value: 'Do not disappear on reload' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + + const send = sentFreshAgentMessages('freshAgent.send').at(-1) + const requestId = String(send?.requestId) + await waitFor(() => { + expect(getFreshAgentPaneContent(store)).toMatchObject({ + status: 'running', + pendingLocalEcho: { + requestId, + text: 'Do not disappear on reload', + }, + }) + }) + expect(screen.getByText('Do not disappear on reload')).toBeInTheDocument() + + rendered.unmount() + render( + + + , + ) + + expect(screen.getByText('Do not disappear on reload')).toBeInTheDocument() + }) + it('does not transmit stale Freshopencode permissionMode on create or send', async () => { const creatingStore = createStore() creatingStore.dispatch(initLayout({ diff --git a/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts b/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts index a29a7d771..4aed08d33 100644 --- a/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts +++ b/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts @@ -4,6 +4,16 @@ import { createOpencodeFreshAgentAdapter } from '../../../../server/fresh-agent/ type FakeManager = ReturnType +function createDeferred() { + let resolve!: (value: T) => void + let reject!: (error?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + function makeFakeManager() { const sessionEmitters = new Map() const emitterFor = (id: string) => { @@ -90,6 +100,48 @@ describe('OpenCode serve adapter: create + send', () => { expect(events).toContainEqual({ type: 'sdk.session.snapshot', sessionId: 'freshopencode-req-3', status: 'idle' }) }) + it('emits running before first-send session materialization resolves', async () => { + const manager = makeFakeManager() + const createSession = createDeferred<{ id: string; directory?: string; title?: string }>() + manager.createSession.mockReturnValueOnce(createSession.promise) + const adapter = makeAdapter(manager) + await adapter.create({ requestId: 'slow-create', sessionType: 'freshopencode', provider: 'opencode' }) + + const events: unknown[] = [] + adapter.subscribe?.('freshopencode-slow-create', (e) => events.push(e)) + const send = adapter.send?.('freshopencode-slow-create', { text: 'go' }) + + await Promise.resolve() + expect(events).toContainEqual({ + type: 'sdk.session.snapshot', + sessionId: 'freshopencode-slow-create', + status: 'running', + }) + expect(manager.promptAsync).not.toHaveBeenCalled() + + createSession.resolve({ id: 'ses_real_1', title: 'T' }) + await expect(send).resolves.toEqual({ + sessionId: 'ses_real_1', + sessionRef: { provider: 'opencode', sessionId: 'ses_real_1' }, + }) + }) + + it('returns to idle when first-send session materialization fails', async () => { + const manager = makeFakeManager() + manager.createSession.mockRejectedValueOnce(new Error('session create timed out')) + const adapter = makeAdapter(manager) + await adapter.create({ requestId: 'create-fails', sessionType: 'freshopencode', provider: 'opencode' }) + + const events: unknown[] = [] + adapter.subscribe?.('freshopencode-create-fails', (e) => events.push(e)) + + await expect(adapter.send?.('freshopencode-create-fails', { text: 'go' })).rejects.toThrow('session create timed out') + expect(events).toEqual(expect.arrayContaining([ + { type: 'sdk.session.snapshot', sessionId: 'freshopencode-create-fails', status: 'running' }, + { type: 'sdk.session.snapshot', sessionId: 'freshopencode-create-fails', status: 'idle' }, + ])) + }) + it('passes the effective cwd to createSession on first materialization', async () => { const manager = makeFakeManager() const adapter = makeAdapter(manager) diff --git a/test/unit/server/fresh-agent/opencode-serve-manager.test.ts b/test/unit/server/fresh-agent/opencode-serve-manager.test.ts index f01242da2..102f8f87a 100644 --- a/test/unit/server/fresh-agent/opencode-serve-manager.test.ts +++ b/test/unit/server/fresh-agent/opencode-serve-manager.test.ts @@ -260,6 +260,25 @@ describe('OpencodeServeManager HTTP client', () => { await expect(manager.getMessage('ses_x', 'broken')).rejects.toThrow(/opencode serve GET .*\/message\/broken → 500/) }) + it('aborts and fails hung JSON requests instead of waiting forever', async () => { + let sessionSignal: AbortSignal | undefined + const fetchFn = vi.fn(async (url: string, init: any) => { + if (url.endsWith('/global/health')) return jsonResponse({ healthy: true }) + if (url.endsWith('/session') && init?.method === 'POST') { + sessionSignal = init.signal + return await new Promise((_, reject) => { + init.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) + }) + } + return jsonResponse({}) + }) + const { manager, child } = makeManager({ fetchFn: fetchFn as any, requestTimeoutMs: 5 }) + + await expect(manager.createSession()).rejects.toThrow('opencode serve POST /session timed out after 5ms') + expect(sessionSignal?.aborted).toBe(true) + expect(child.kill).toHaveBeenCalled() + }) + it('posts summarize requests to a cwd sidecar learned from getSession', async () => { const calls: Array<{ url: string; init: any }> = [] const childDefault = fakeChild()