From 9ea58aae0cfece29a8ca0b696bc5e197e550a69b Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Tue, 14 Jul 2026 04:00:53 -0700 Subject: [PATCH] fix(server): stop OpenCode daemon crash on session close, fix #2014 (#2027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): patch OpenCode SDK unhandled rejection on stream abort The SDK's SSE abort handler calls reader.cancel() without handling its rejection. When Paseo aborts the event stream during normal session close, that promise can reject and crash the daemon with an unhandled rejection outside any Paseo-owned code path. Patches both copies the SDK ships (dist/gen and dist/v2/gen, the latter being what Paseo actually imports). Extends postinstall-patches.mjs to support patch-package running from a non-root cwd, since @opencode-ai/sdk lives in packages/server's own node_modules rather than the hoisted root. * fix(server): stop assuming OpenCode's default agent is "build" OpenCode users can rename or delete any agent, including the built-in "build"/"plan" defaults. Paseo was injecting a hardcoded "build" agent in several places whenever no mode was explicitly requested: - The protocol manifest declared defaultModeId: "build" for opencode, which AgentManager.normalizeConfig applied to every session (including internal metadata-generation sessions) before any provider-level logic ran. - OpenCode's own normalizeOpenCodeModeId defaulted an empty/"default" modeId to "build" rather than omitting the agent field. - Unattended child creates (e.g. subagents spawned by an unattended parent) forced modeId: "build" to express unattendedness, even though that's already carried by the auto_accept feature. Now an unset mode stays unset end-to-end: the "agent" field is omitted from OpenCode prompt/command calls entirely, letting OpenCode fall back to its own configured default agent instead of Paseo guessing one that may not exist. * fix(server): validate agent create mode on the WebSocket session path resolveAndValidateCreateAgentMode already rejects modes unknown to a provider's discovered mode list, but it was only wired into the MCP create path. App-created agents (create_agent_request) skipped it entirely, so a client's remembered mode preference — which can go stale when a user renames or deletes OpenCode agents — sailed through to the provider and failed mid-turn with an opaque error instead of a clear rejection at creation time. resolveSessionCreateAgent now calls providerSnapshotManager.resolveCreateConfig, matching the MCP path, so an invalid mode now throws 'Invalid mode ... Available modes: ...' immediately on create. * chore: address review feedback on create-agent and postinstall script - Document the cleanup-ordering constraint in resolveSessionCreateAgent: mode validation runs after buildSessionConfig (cwd isn't known until that completes), so a thrown validation error leaves any worktree/workspace buildSessionConfig created for the caller to clean up. session.ts already handles this for the worktree path; the directory-only workspace path has a pre-existing gap, not introduced by this validation. - Log spawn errors from patch-package in postinstall-patches.mjs (e.g. ENOENT if it's missing from PATH) instead of failing silently. * fix(server): don't fabricate OpenCode modes when discovery finds none fetchModesFromClient and mergeOpenCodeModes fell back to DEFAULT_MODES ([build, plan]) whenever OpenCode discovery returned nothing — e.g. for a freshly-created worktree whose OpenCode server hasn't loaded the project config yet. That fabricated list let create-time mode validation accept a stale 'plan' preference that the provider then rejects at prompt time (Agent not found: 'plan'). Return an empty list instead: OpenCode users can rename or delete any agent, so any hardcoded fallback risks validating a mode that doesn't exist. DEFAULT_MODES is retained only for description enrichment and sort ordering. * fix(app): reconcile stale selected mode against discovered modes on create The mode picker displays modeOptions[0] when the stored modeId isn't in the discovered list (e.g. a globally-remembered 'plan' that a workspace's OpenCode config no longer defines), but the create request still submitted the raw stored modeId. Result: UI showed 'Build' while the request sent 'plan', which the daemon then rejected. Reconcile the selected mode against the discovered mode ids when building the create config (both the workspace draft composer and the workspace-setup dialog), so the submitted mode matches what the picker shows — falling back to the first available mode when the stored one is absent. Mirrors the existing resolveEffectiveModel reconciliation for models. * test(server): fix opencode mode tests after fallback change + rebase - Replace 'available modes include build and plan' (which relied on the removed [build, plan] discovery-failure fallback) with two tests: one asserting discovered agents map to modes, one asserting empty discovery yields no fabricated modes. - Restore messageId fields in the four provider-subagent timeline assertions. These were accidentally dropped during rebase conflict resolution; the #2013 subagent code legitimately emits messageId, so the expectations must include it. * test(app): fix e2e seed helper using invalid opencode mode createIdleAgent seeded an OpenCode agent with modeId 'bypassPermissions' — a Claude mode OpenCode never had. This worked before only because the session create path silently coerced unknown modes to 'build'. Now that create-time mode validation rejects modes the provider doesn't define, use 'build' + auto_accept (OpenCode's unattended full-access equivalent), matching the rewind-flow helper. Fixes the e2e failures across archive-tab, command-center-host, settings-toggle-tab-regression, workspace-agent-tab-rename, workspace-pane-remount, workspace-navigation-regression, and worktree-restore specs, which all seed via this helper. --- packages/app/e2e/helpers/archive-tab.ts | 7 +- .../src/components/workspace-setup-dialog.tsx | 13 ++- .../app/src/composer/draft/workspace-tab.tsx | 38 +++++--- packages/protocol/src/provider-manifest.ts | 5 +- .../server/agent/create-agent/create.test.ts | 90 ++++++++++++++++++- .../src/server/agent/create-agent/create.ts | 32 ++++++- .../opencode-agent.full-access.test.ts | 17 ++-- .../agent/providers/opencode-agent.test.ts | 36 +++++++- .../server/agent/providers/opencode-agent.ts | 39 +++++--- patches/@opencode-ai+sdk+1.14.46.patch | 38 ++++++++ scripts/postinstall-patches.mjs | 83 +++++++++++------ 11 files changed, 328 insertions(+), 70 deletions(-) create mode 100644 patches/@opencode-ai+sdk+1.14.46.patch diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index 768c6c6bf..05ae4d42f 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -40,6 +40,7 @@ export interface IdleAgentSeedClient { provider: string; model: string; modeId: string; + featureValues?: Record; cwd: string; workspaceId: string; title: string; @@ -58,7 +59,11 @@ export async function createIdleAgent( const created = await client.createAgent({ provider: "opencode", model: "opencode/gpt-5-nano", - modeId: "bypassPermissions", + // OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with + // auto_accept for unattended full access — mode validation now rejects modes + // the provider doesn't define. + modeId: "build", + featureValues: { auto_accept: true }, cwd: input.cwd, workspaceId: input.workspaceId, title: input.title, diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 0a539a439..5dd113688 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -132,13 +132,20 @@ function buildCreateAgentOptions({ workspaceId: string; provider: CreateAgentRequestOptions["provider"]; }): CreateAgentRequestOptions { + // Reconcile the selected mode against the discovered modes. The mode picker + // shows modeOptions[0] when the stored mode isn't in the list (e.g. a stale + // globally-remembered mode this workspace's provider config no longer + // defines), so the submitted mode must match that display rather than send a + // stale mode the provider would reject. + const modeOptionIds = composerState.modeOptions.map((mode) => mode.id); + const reconciledMode = modeOptionIds.includes(composerState.selectedMode) + ? composerState.selectedMode + : (modeOptionIds[0] ?? ""); return { provider, cwd: workspaceDirectory, workspaceId, - ...(composerState.modeOptions.length > 0 && composerState.selectedMode !== "" - ? { modeId: composerState.selectedMode } - : {}), + ...(reconciledMode !== "" ? { modeId: reconciledMode } : {}), ...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}), ...(composerState.effectiveThinkingOptionId ? { thinkingOptionId: composerState.effectiveThinkingOptionId } diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 1a0338f75..4398448cc 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -87,32 +87,46 @@ function resolveAutoSubmitConfig( }; } +// Reconcile the form's selected mode against the currently discovered modes. +// The mode picker displays modeOptions[0] when the stored mode isn't in the +// list (e.g. a globally-remembered "plan" that this workspace's OpenCode config +// no longer defines), so the submitted mode must match that display — otherwise +// we'd send a stale mode the provider rejects while the UI showed a valid one. +function reconcileSelectedMode(modeOptionIds: readonly string[], selectedMode: string): string { + if (modeOptionIds.length === 0) { + return ""; + } + return modeOptionIds.includes(selectedMode) ? selectedMode : (modeOptionIds[0] ?? ""); +} + function resolveDraftModeIdOverride(input: { autoSubmitConfig: AutoSubmitConfig | null; - modeOptionsCount: number; + modeOptionIds: readonly string[]; selectedMode: string; }): { modeId: string } | Record { - const { autoSubmitConfig, modeOptionsCount, selectedMode } = input; + const { autoSubmitConfig, modeOptionIds, selectedMode } = input; if (autoSubmitConfig?.modeId) { return { modeId: autoSubmitConfig.modeId }; } - if (modeOptionsCount > 0 && selectedMode !== "") { - return { modeId: selectedMode }; + const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode); + if (reconciled !== "") { + return { modeId: reconciled }; } return {}; } function resolveDraftModeId(input: { autoSubmitConfig: AutoSubmitConfig | null; - modeOptionsCount: number; + modeOptionIds: readonly string[]; selectedMode: string; }): string | null { - const { autoSubmitConfig, modeOptionsCount, selectedMode } = input; + const { autoSubmitConfig, modeOptionIds, selectedMode } = input; if (autoSubmitConfig?.modeId !== undefined) { return autoSubmitConfig.modeId; } - if (modeOptionsCount > 0 && selectedMode !== "") { - return selectedMode; + const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode); + if (reconciled !== "") { + return reconciled; } return null; } @@ -130,7 +144,7 @@ async function submitDraftCreateRequest(input: { composerState: { selectedProvider: string | null; selectedMode: string; - modeOptions: unknown[]; + modeOptions: readonly { id: string }[]; effectiveModelId: string | null; effectiveThinkingOptionId: string | null; featureValues: Record | undefined; @@ -163,7 +177,7 @@ async function submitDraftCreateRequest(input: { } const modeIdOverride = resolveDraftModeIdOverride({ autoSubmitConfig, - modeOptionsCount: composerState.modeOptions.length, + modeOptionIds: composerState.modeOptions.map((mode) => mode.id), selectedMode: composerState.selectedMode, }); const config = buildWorkspaceDraftAgentConfig({ @@ -202,7 +216,7 @@ function buildDraftAgentSnapshot(input: { composerState: { effectiveModelId: string | null; effectiveThinkingOptionId: string | null; - modeOptions: unknown[]; + modeOptions: readonly { id: string }[]; selectedMode: string; selectedProvider: string | null; agentControls: { features?: Agent["features"] }; @@ -217,7 +231,7 @@ function buildDraftAgentSnapshot(input: { autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || null); const modeId = resolveDraftModeId({ autoSubmitConfig, - modeOptionsCount: composerState.modeOptions.length, + modeOptionIds: composerState.modeOptions.map((mode) => mode.id), selectedMode: composerState.selectedMode, }); const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider; diff --git a/packages/protocol/src/provider-manifest.ts b/packages/protocol/src/provider-manifest.ts index 35af5b1d9..b66f18f47 100644 --- a/packages/protocol/src/provider-manifest.ts +++ b/packages/protocol/src/provider-manifest.ts @@ -198,7 +198,10 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ id: "opencode", label: "OpenCode", description: "Open-source coding assistant with multi-provider model support", - defaultModeId: "build", + // No static default: OpenCode users can rename or delete any agent, + // including "build". Leaving this unset means the daemon and OpenCode + // itself decide (see normalizeOpenCodeModeId in opencode-agent.ts). + defaultModeId: null, modes: OPENCODE_MODES, voice: { enabled: true, diff --git a/packages/server/src/server/agent/create-agent/create.test.ts b/packages/server/src/server/agent/create-agent/create.test.ts index 2c264501f..5b51fe521 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -58,9 +58,7 @@ test("session create forwards clientMessageId to the initial prompt run options" } as unknown as Parameters[0]["agentManager"], agentStorage: {} as Parameters[0]["agentStorage"], logger: createTestLogger(), - providerSnapshotManager: {} as Parameters< - typeof createAgentCommand - >[0]["providerSnapshotManager"], + providerSnapshotManager: createProviderSnapshotManagerStub().manager, }; await createAgentCommand(dependencies, { @@ -80,6 +78,92 @@ test("session create forwards clientMessageId to the initial prompt run options" }); }); +test("session create validates the requested mode against the provider's modes", async () => { + const snapshot = { + id: "agent-1", + provider: "opencode", + cwd: "/tmp/paseo-create-test", + runtimeInfo: null, + } as ManagedAgent; + const createAgent = vi.fn(async () => snapshot); + const stub = createProviderSnapshotManagerStub(); + stub.resolveCreateConfig.mockRejectedValue( + new Error("Invalid mode 'plan' for provider 'opencode'. Available modes: build, myplan"), + ); + const dependencies: Parameters[0] = { + agentManager: { + createAgent, + } as unknown as Parameters[0]["agentManager"], + agentStorage: {} as Parameters[0]["agentStorage"], + logger: createTestLogger(), + providerSnapshotManager: stub.manager, + }; + + await expect( + createAgentCommand(dependencies, { + kind: "session", + config: { provider: "opencode", cwd: "/tmp/paseo-create-test", modeId: "plan" }, + workspaceId: "ws-create-test", + labels: {}, + provisionalTitle: null, + firstAgentContext: { attachments: [] }, + buildSessionConfig: async (config) => ({ sessionConfig: config }), + }), + ).rejects.toThrow("Invalid mode 'plan'"); + + expect(stub.resolveCreateConfig).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "opencode", + cwd: "/tmp/paseo-create-test", + requestedMode: "plan", + }), + ); + expect(createAgent).not.toHaveBeenCalled(); +}); + +test("session create applies the resolved mode from the provider create config", async () => { + const snapshot = { + id: "agent-1", + provider: "opencode", + cwd: "/tmp/paseo-create-test", + runtimeInfo: null, + } as ManagedAgent; + const createAgent = vi.fn(async () => snapshot); + const stub = createProviderSnapshotManagerStub(); + stub.resolveCreateConfig.mockResolvedValue({ + modeId: "build", + featureValues: { auto_accept: true }, + }); + const dependencies: Parameters[0] = { + agentManager: { + createAgent, + getAgent: vi.fn(() => snapshot), + } as unknown as Parameters[0]["agentManager"], + agentStorage: {} as Parameters[0]["agentStorage"], + logger: createTestLogger(), + providerSnapshotManager: stub.manager, + }; + + await createAgentCommand(dependencies, { + kind: "session", + config: { provider: "opencode", cwd: "/tmp/paseo-create-test", modeId: "build" }, + workspaceId: "ws-create-test", + labels: {}, + provisionalTitle: null, + firstAgentContext: { attachments: [] }, + buildSessionConfig: async (config) => ({ sessionConfig: config }), + }); + + expect(createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + modeId: "build", + featureValues: { auto_accept: true }, + }), + undefined, + expect.anything(), + ); +}); + test("mcp create accepts provider-only internal input and leaves model undefined", async () => { const snapshot = { id: "agent-1", diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 62b06bfcd..60c4d2df6 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -217,12 +217,42 @@ async function resolveSessionCreateAgent( input: CreateAgentFromSessionInput, ): Promise { const trimmedPrompt = input.initialPrompt?.trim(); - const { sessionConfig, setupContinuation, createdWorkspaceId } = await input.buildSessionConfig( + const { + sessionConfig: builtSessionConfig, + setupContinuation, + createdWorkspaceId, + } = await input.buildSessionConfig( input.config, input.git, input.worktreeName, input.firstAgentContext, ); + // Validate the requested mode against the provider's modes for the resolved + // cwd. The app remembers mode preferences globally, so a saved mode can be + // stale for a workspace whose provider config no longer defines it — reject + // it here instead of letting the provider fail mid-turn. + // + // This runs after buildSessionConfig, which may already have created a + // worktree and/or workspace record — cwd (required to resolve modes) is + // only known once that step completes. If validation throws, any + // worktree/workspace buildSessionConfig created is the caller's + // responsibility to clean up (session.ts's handleCreateAgentRequest does + // this for the worktree path via cleanupCreatedWorktreeAfterFailedAgentCreate; + // this is a pre-existing gap for directory-only workspace creates, not + // introduced by this validation). + const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({ + cwd: builtSessionConfig.cwd, + provider: builtSessionConfig.provider, + requestedMode: builtSessionConfig.modeId, + featureValues: builtSessionConfig.featureValues, + parent: null, + unattended: false, + }); + const sessionConfig: AgentSessionConfig = { + ...builtSessionConfig, + modeId: resolvedCreateConfig.modeId, + featureValues: resolvedCreateConfig.featureValues, + }; const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments); const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0; const clientMessageId = normalizeClientMessageId(input.clientMessageId); diff --git a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts index 0605ad02d..d1a5de6f8 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts @@ -90,7 +90,7 @@ describe("OpenCode auto_accept feature", () => { expect(modes.map((mode) => mode.id)).toEqual(["build", "paseo-custom"]); }); - test("falls back to default OpenCode modes when discovery returns no modes", async () => { + test("returns no modes when discovery finds none, rather than fabricating defaults", async () => { const { runtime } = mockOpenCodeClient({ agents: [] }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { @@ -103,7 +103,9 @@ describe("OpenCode auto_accept feature", () => { force: false, }); - expect(modes.map((mode) => mode.id)).toEqual(["build", "plan"]); + // OpenCode users can rename/delete any agent, so a hardcoded fallback could + // validate a mode that doesn't exist. Empty is the honest answer. + expect(modes).toEqual([]); }); test("lists auto accept as a provider feature", async () => { @@ -179,9 +181,12 @@ describe("OpenCode auto_accept feature", () => { ).toEqual({ modeId: "build", featureValues: { auto_accept: true } }); }); - test("inherits unattended callers as build plus auto accept", () => { + test("inherits unattended callers as auto accept with OpenCode's default agent", () => { const client = new OpenCodeAgentClient(createTestLogger()); + // Unattendedness is carried by auto_accept, not by a specific agent. The + // mode stays unset so OpenCode picks its own default agent — `build` may + // not exist in the user's OpenCode config. expect( client.resolveCreateConfig({ provider: "opencode", @@ -198,10 +203,10 @@ describe("OpenCode auto_accept feature", () => { { id: "plan", label: "Plan" }, ], }), - ).toEqual({ modeId: "build", featureValues: { auto_accept: true } }); + ).toEqual({ modeId: undefined, featureValues: { auto_accept: true } }); }); - test("defaults unattended creation without a parent to build plus auto accept", () => { + test("defaults unattended creation without a parent to auto accept with OpenCode's default agent", () => { const client = new OpenCodeAgentClient(createTestLogger()); expect( @@ -216,7 +221,7 @@ describe("OpenCode auto_accept feature", () => { { id: "plan", label: "Plan" }, ], }), - ).toEqual({ modeId: "build", featureValues: { auto_accept: true } }); + ).toEqual({ modeId: undefined, featureValues: { auto_accept: true } }); }); test("preserves the selected OpenCode agent when inheriting auto accept from an OpenCode parent", () => { diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index d3b0e179f..2cccfcbee 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -238,9 +238,11 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { sessionID: "session-1", directory: cwd, model: { providerID: "opencode", modelID: "big-pickle" }, - agent: "build", }), ]); + // No modeId configured → no agent field: OpenCode must fall back to its + // own default agent instead of Paseo assuming any particular agent exists. + expect(openCodeClient.calls.sessionPromptAsync[0]).not.toHaveProperty("agent"); await session.close(); rmSync(cwd, { recursive: true, force: true }); @@ -512,10 +514,17 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { expect(maxActiveProviderListCalls).toBeLessThanOrEqual(4); }); - test("available modes include build and plan", async () => { + test("available modes reflect the agents OpenCode discovers", async () => { const cwd = tmpCwd(); const runtime = new TestOpenCodeHarness(); - runtime.enqueueClient(new TestOpenCodeClient()); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.appAgentsResponse = { + data: [ + { name: "build", mode: "primary" }, + { name: "plan", mode: "primary" }, + ], + }; + runtime.enqueueClient(openCodeClient); const client = new OpenCodeAgentClient(logger, undefined, { serverManager: runtime, createClient: runtime.createClient, @@ -531,6 +540,27 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { rmSync(cwd, { recursive: true, force: true }); }, 60_000); + test("available modes are empty when OpenCode discovers no agents", async () => { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + // Default TestOpenCodeClient returns no agents. Discovery failure/empty + // must not fabricate modes — OpenCode users can rename/delete any agent, + // so a hardcoded fallback could validate a mode that doesn't exist. + runtime.enqueueClient(new TestOpenCodeClient()); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession(buildConfig(cwd)); + + const modes = await session.getAvailableModes(); + + expect(modes).toEqual([]); + + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }, 60_000); + test("custom agents defined in opencode.json appear in available modes", async () => { const cwd = tmpCwd(); const runtime = new TestOpenCodeHarness(); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 35aaa6c53..b70fdfa76 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -180,7 +180,10 @@ function resolveOpenCodeCreateConfig( : input.featureValues; if (inheritsUnattended && requestedMode === undefined) { - return { modeId: OPENCODE_BUILD_MODE_ID, featureValues }; + // Unattendedness for OpenCode is carried by auto_accept (set above), not + // by any particular agent. Leave the mode unset so OpenCode uses its own + // default agent — `build` may not exist in the user's OpenCode config. + return { modeId: undefined, featureValues }; } const resolved = resolveDefaultAgentCreateConfig({ @@ -570,16 +573,23 @@ function matchesHydratedFingerprint( return hydratedFingerprint === JSON.stringify(value); } -function normalizeOpenCodeModeId(modeId: string | null | undefined): string { +// `null` = no explicit mode. The `agent` field is then omitted from OpenCode +// prompt/command calls so OpenCode falls back to its own configured default +// agent — never assume any particular agent (even `build`) exists, since +// OpenCode users can define or delete agents at will. +function normalizeOpenCodeModeId(modeId: string | null | undefined): string | null { const trimmed = typeof modeId === "string" ? modeId.trim() : ""; if (!trimmed || trimmed === "default") { - return OPENCODE_BUILD_MODE_ID; + return null; } return trimmed; } -function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): string { +function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): string | undefined { const normalizedModeId = normalizeOpenCodeModeId(modeId); + if (normalizedModeId === null) { + return undefined; + } return normalizedModeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID ? OPENCODE_BUILD_MODE_ID : normalizedModeId; @@ -636,11 +646,10 @@ function mergeOpenCodeModes(discoveredModes: AgentMode[]): AgentMode[] { ); // When discovery returns results, trust them exactly — don't inject hardcoded // defaults that the user may have intentionally disabled in their OpenCode config. - // Fall back to DEFAULT_MODES only when discovery produced nothing. - if (filtered.length > 0) { - return sortOpenCodeModes(filtered); - } - return sortOpenCodeModes([...DEFAULT_MODES]); + // When discovery produced nothing, return empty rather than fabricating modes: + // OpenCode users can rename or delete any agent, so a hardcoded fallback can + // validate a mode that does not actually exist (failing later at prompt time). + return sortOpenCodeModes(filtered); } function sortOpenCodeModes(modes: AgentMode[]): AgentMode[] { @@ -1152,7 +1161,7 @@ function resolveOpenCodePersistedSessionModeId( messages: ReadonlyArray, ): string | undefined { const agent = session.agent ?? messages.map(readOpenCodeMessageAgent).find(Boolean); - return agent ? normalizeOpenCodeModeId(agent) : undefined; + return agent ? (normalizeOpenCodeModeId(agent) ?? undefined) : undefined; } function readOpenCodeMessageAgent(message: OpenCodeSessionMessage): string | undefined { @@ -1635,7 +1644,11 @@ export class OpenCodeAgentClient implements AgentClient { ); if (response.error || !response.data) { - return DEFAULT_MODES; + // Discovery failed — return an empty list rather than fabricating + // modes. OpenCode users can rename or delete any agent (including + // "build"/"plan"), so a hardcoded fallback can validate a mode that + // does not actually exist, which then fails at prompt time. + return []; } const discovered = response.data.filter(isSelectableOpenCodeAgent).map(mapOpenCodeAgentToMode); @@ -2846,7 +2859,7 @@ class OpenCodeAgentSession implements AgentSession { private readonly sessionId: string; private readonly logger: Logger; private readonly modelContextWindowsByModelKey: ReadonlyMap; - private currentMode: string = "default"; + private currentMode: string | null = null; private autoAcceptEnabled = false; private pendingPermissions = new Map(); private abortController: AbortController | null = null; @@ -3771,7 +3784,7 @@ class OpenCodeAgentSession implements AgentSession { } this.currentMode = normalizedModeId; - this.config.modeId = normalizedModeId; + this.config.modeId = normalizedModeId ?? undefined; } async setFeature(featureId: string, value: unknown): Promise { diff --git a/patches/@opencode-ai+sdk+1.14.46.patch b/patches/@opencode-ai+sdk+1.14.46.patch new file mode 100644 index 000000000..65ce69009 --- /dev/null +++ b/patches/@opencode-ai+sdk+1.14.46.patch @@ -0,0 +1,38 @@ +diff --git a/node_modules/@opencode-ai/sdk/dist/gen/core/serverSentEvents.gen.js b/node_modules/@opencode-ai/sdk/dist/gen/core/serverSentEvents.gen.js +index 0000000..0000000 100644 +--- a/node_modules/@opencode-ai/sdk/dist/gen/core/serverSentEvents.gen.js ++++ b/node_modules/@opencode-ai/sdk/dist/gen/core/serverSentEvents.gen.js +@@ -26,7 +26,13 @@ + let buffer = ""; + const abortHandler = () => { + try { +- void reader.cancel(); ++ // reader.cancel() returns a promise that can reject (e.g. it races ++ // with fetch's own abort teardown of the same stream). Swallow that ++ // rejection explicitly — otherwise it surfaces as an unhandled ++ // promise rejection in the host process. ++ void reader.cancel().catch(() => { ++ // noop ++ }); + } + catch { + // noop +diff --git a/node_modules/@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.js b/node_modules/@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.js +index 0000000..0000000 100644 +--- a/node_modules/@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.js ++++ b/node_modules/@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.js +@@ -40,7 +40,13 @@ + let buffer = ""; + const abortHandler = () => { + try { +- reader.cancel(); ++ // reader.cancel() returns a promise that can reject (e.g. it races ++ // with fetch's own abort teardown of the same stream). Swallow that ++ // rejection explicitly — otherwise it surfaces as an unhandled ++ // promise rejection in the host process. ++ void reader.cancel().catch(() => { ++ // noop ++ }); + } + catch { + // noop diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 882b8be3a..781294b6d 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -1,9 +1,12 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; import { spawnSync } from "node:child_process"; -import { join } from "node:path"; +import { join, relative } from "node:path"; // In CI we often install a single workspace (e.g. server/relay/website). Only apply patches // when the patched dependency is actually present. +// `cwd` is where patch-package must run from. Packages that npm does not hoist to the +// workspace root live in their workspace's own node_modules, and patch-package resolves +// the patch's node_modules/... paths relative to its working directory. const patchedPackages = [ { nodeModulesPath: "node_modules/react-native-markdown-display", @@ -17,44 +20,70 @@ const patchedPackages = [ nodeModulesPath: "node_modules/react-native-gesture-handler", patchPrefix: "react-native-gesture-handler+", }, + { + nodeModulesPath: "packages/server/node_modules/@opencode-ai/sdk", + patchPrefix: "@opencode-ai+sdk+", + cwd: "packages/server", + }, ]; -const installedPatchPrefixes = patchedPackages - .filter(({ nodeModulesPath }) => existsSync(nodeModulesPath)) - .map(({ patchPrefix }) => patchPrefix); +const installedPackages = patchedPackages.filter(({ nodeModulesPath }) => + existsSync(nodeModulesPath), +); -if (!existsSync("patches") || installedPatchPrefixes.length === 0) { +if (!existsSync("patches") || installedPackages.length === 0) { process.exit(0); } -const patchFilesToApply = readdirSync("patches").filter( - (file) => - file.endsWith(".patch") && - installedPatchPrefixes.some((patchPrefix) => file.startsWith(patchPrefix)), -); +const patchFiles = readdirSync("patches").filter((file) => file.endsWith(".patch")); -if (patchFilesToApply.length === 0) { +// Group patch files by the directory patch-package must run from. +const patchFilesByCwd = new Map(); +for (const { patchPrefix, cwd = "." } of installedPackages) { + const files = patchFiles.filter((file) => file.startsWith(patchPrefix)); + if (files.length === 0) { + continue; + } + const group = patchFilesByCwd.get(cwd) ?? []; + group.push(...files); + patchFilesByCwd.set(cwd, group); +} + +if (patchFilesByCwd.size === 0) { process.exit(0); } const isWindows = process.platform === "win32"; const cmd = isWindows ? "patch-package.cmd" : "patch-package"; -const tempPatchDir = join(".tmp", `postinstall-patches-${process.pid}`); -mkdirSync(tempPatchDir, { recursive: true }); -for (const patchFile of patchFilesToApply) { - copyFileSync(join("patches", patchFile), join(tempPatchDir, patchFile)); +let groupIndex = 0; +for (const [cwd, files] of patchFilesByCwd) { + groupIndex += 1; + const tempPatchDir = join(".tmp", `postinstall-patches-${process.pid}-${groupIndex}`); + + mkdirSync(tempPatchDir, { recursive: true }); + for (const patchFile of files) { + copyFileSync(join("patches", patchFile), join(tempPatchDir, patchFile)); + } + + let result; + try { + result = spawnSync(cmd, ["--patch-dir", relative(cwd, tempPatchDir)], { + cwd, + shell: isWindows, + stdio: "inherit", + windowsHide: true, + }); + } finally { + rmSync(tempPatchDir, { recursive: true, force: true }); + } + + if (result.error) { + console.error("postinstall-patches: patch-package failed to spawn:", result.error.message); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } } -let result; -try { - result = spawnSync(cmd, ["--patch-dir", tempPatchDir], { - shell: isWindows, - stdio: "inherit", - windowsHide: true, - }); -} finally { - rmSync(tempPatchDir, { recursive: true, force: true }); -} - -process.exit(result.status ?? 1); +process.exit(0);