diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 913107ea2cb1..a6f340b00046 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -35,4 +35,11 @@ jobs: uses: ./.github/actions/setup-bun - name: Build Storybook + # ubuntu-latest gives less RAM than the Blacksmith runner this workflow + # used to run on, and V8's auto-detected default heap isn't enough for + # this build (OOM/SIGABRT within ~90s of starting, before any large + # bundle content is even produced — not a build that grew, one that + # never had headroom on this runner size). run: bun --cwd packages/storybook build + env: + NODE_OPTIONS: "--max-old-space-size=6144" diff --git a/packages/opencode/src/server/routes/team.ts b/packages/opencode/src/server/routes/team.ts index 14a401df0459..50f699ef9ead 100644 --- a/packages/opencode/src/server/routes/team.ts +++ b/packages/opencode/src/server/routes/team.ts @@ -341,17 +341,32 @@ export const TeamRoutes = lazy(() => ) .post( "/runs/:runID/pause", - describeRoute({ summary: "Pause a Team run", operationId: "team.pauseRun", responses: { 200: { description: "Paused", content: { "application/json": { schema: resolver(RunControlSchema) } } }, 409: { description: "Run is not active", content: { "application/json": { schema: resolver(ErrorSchema) } } } } }), + describeRoute({ + summary: "Pause a Team run", + description: "Suspend an active Team run so its workers stop making progress until resumed.", + operationId: "team.pauseRun", + responses: { 200: { description: "Paused", content: { "application/json": { schema: resolver(RunControlSchema) } } }, 409: { description: "Run is not active", content: { "application/json": { schema: resolver(ErrorSchema) } } } }, + }), (c) => controlRun(c, "pause"), ) .post( "/runs/:runID/resume", - describeRoute({ summary: "Resume a Team run", operationId: "team.resumeRun", responses: { 200: { description: "Running", content: { "application/json": { schema: resolver(RunControlSchema) } } }, 409: { description: "Run is not active", content: { "application/json": { schema: resolver(ErrorSchema) } } } } }), + describeRoute({ + summary: "Resume a Team run", + description: "Resume a previously paused Team run so its workers continue making progress.", + operationId: "team.resumeRun", + responses: { 200: { description: "Running", content: { "application/json": { schema: resolver(RunControlSchema) } } }, 409: { description: "Run is not active", content: { "application/json": { schema: resolver(ErrorSchema) } } } }, + }), (c) => controlRun(c, "resume"), ) .post( "/runs/:runID/cancel", - describeRoute({ summary: "Cancel a Team run", operationId: "team.cancelRun", responses: { 200: { description: "Cancelled", content: { "application/json": { schema: resolver(RunControlSchema) } } }, 409: { description: "Run is not active", content: { "application/json": { schema: resolver(ErrorSchema) } } } } }), + describeRoute({ + summary: "Cancel a Team run", + description: "Permanently stop an active or paused Team run; it cannot be resumed afterward.", + operationId: "team.cancelRun", + responses: { 200: { description: "Cancelled", content: { "application/json": { schema: resolver(RunControlSchema) } } }, 409: { description: "Run is not active", content: { "application/json": { schema: resolver(ErrorSchema) } } } }, + }), (c) => controlRun(c, "cancel"), ) .get( diff --git a/packages/opencode/test/collective/provider-discovery.regression.test.ts b/packages/opencode/test/collective/provider-discovery.regression.test.ts index 1054d15b022a..9156ea6ddb71 100644 --- a/packages/opencode/test/collective/provider-discovery.regression.test.ts +++ b/packages/opencode/test/collective/provider-discovery.regression.test.ts @@ -70,6 +70,17 @@ import * as AuthMod from "../../src/auth" import * as RealFsPromises from "node:fs/promises" import * as RealChildProcess from "node:child_process" +// ESM namespace imports are live bindings: once mock.module() replaces what +// "node:fs/promises" resolves to, RealFsPromises.readFile reflects the mock +// too — including from inside the mock's own fallback branch, which would +// call itself forever. Spreading into a plain object HERE, before any test +// in this file has mocked anything, freezes real function values that later +// mock.module() calls cannot retroactively change. +const originalFsPromises = { ...RealFsPromises } +const originalChildProcess = { ...RealChildProcess } +const originalProviderMod = { ...ProviderMod } +const originalAuthMod = { ...AuthMod } + // -------------------------------------------------------------------------------------- // Mocking harness — mirrors the proven, already-passing pattern used by // test/multi-model/provider-discovery.integration.test.ts (B02/B03 @@ -89,25 +100,57 @@ const buildProvider = ( models: Record, ): ProviderInfo => ({ id, env: envVars, models }) +// Spread the real namespace (captured before any mocking, via the static +// imports below) rather than replacing it outright. `Provider`/`Auth` also +// carry an Effect `Service` tag consumed by unrelated singleton layers +// (src/config/config.ts, src/share/share-next.ts) that memoize their build +// via a process-wide MemoMap (src/effect/run-service.ts). If that memoized +// build ever ran while a partial replacement object (missing `.Service`) was +// live in the module registry, the resulting `undefined` gets cached for the +// rest of the test process — cascading into hundreds of unrelated failures +// in later files, regardless of resetMocks() running correctly afterward. const mockProviderList = (list: Record) => { - mock.module("../../src/provider/provider", () => ({ Provider: { list: async () => list } })) + mock.module("../../src/provider/provider", () => ({ + ...originalProviderMod, + Provider: { ...originalProviderMod.Provider, list: async () => list }, + })) } const mockAuthAll = (entries: Record) => { - mock.module("../../src/auth", () => ({ Auth: { all: async () => entries } })) + mock.module("../../src/auth", () => ({ ...originalAuthMod, Auth: { ...originalAuthMod.Auth, all: async () => entries } })) } +// Path-aware and spreads the real module (see the Provider/Auth mocks above +// for why): a bare readFile replacement ignores its path argument, so ANY +// unrelated file read anywhere in the process — even from a completely +// different test file — gets this fixture's content back if it happens to +// run while this mock is live (observed: test/session/llm.test.ts's fixture +// loader failing to JSON.parse "not valid json {{{"). Scoping to the actual +// auth file names and falling back to the real readFile for everything else +// bounds the damage regardless of any afterEach/reset timing. const mockCredentialFile = (content: string | null) => { mock.module("node:fs/promises", () => ({ - readFile: async () => { - if (content === null) throw new Error("ENOENT: no such file") - return content + ...originalFsPromises, + readFile: async (path: Parameters[0], ...rest: unknown[]) => { + const p = String(path) + // "~/.claude/.credentials.json" is the ONLY path this test simulates + // (src/multi-model/provider-discovery.ts's anthropic credential-file + // extractor). Do NOT also match "auth.json": the SAME source file's + // openai extractor reads "~/.codex/auth.json" — a real, unrelated file + // that may genuinely exist on a dev machine — and src/auth/index.ts's + // own storage file is already covered by mockAuthAll, not this mock. + if (p.endsWith(".credentials.json")) { + if (content === null) throw new Error("ENOENT: no such file") + return content + } + return (originalFsPromises.readFile as (...args: unknown[]) => Promise)(path, ...rest) }, })) } const mockCliAuth = (succeeds: boolean) => { mock.module("node:child_process", () => ({ + ...originalChildProcess, execFileSync: () => { if (!succeeds) throw new Error("ENOENT: no such binary") return Buffer.from("") @@ -115,11 +158,16 @@ const mockCliAuth = (succeeds: boolean) => { })) } +// Restore from the frozen snapshots, not the live ProviderMod/AuthMod/ +// RealFsPromises/RealChildProcess bindings — those are live ESM namespace +// views that reflect whatever mock.module() last installed, so passing them +// straight through here would just re-register the CURRENT (possibly still +// mocked) state instead of the pristine original. const resetMocks = () => { - mock.module("../../src/provider/provider", () => ProviderMod) - mock.module("../../src/auth", () => AuthMod) - mock.module("node:fs/promises", () => RealFsPromises) - mock.module("node:child_process", () => RealChildProcess) + mock.module("../../src/provider/provider", () => originalProviderMod) + mock.module("../../src/auth", () => originalAuthMod) + mock.module("node:fs/promises", () => originalFsPromises) + mock.module("node:child_process", () => originalChildProcess) } // Bun caches ES modules; every test gets a cache-busted fresh import of diff --git a/packages/opencode/test/multi-model/provider-discovery.bench.test.ts b/packages/opencode/test/multi-model/provider-discovery.bench.test.ts index 85794c8e2616..f9cd7e7cde0d 100644 --- a/packages/opencode/test/multi-model/provider-discovery.bench.test.ts +++ b/packages/opencode/test/multi-model/provider-discovery.bench.test.ts @@ -27,6 +27,14 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" import * as ProviderMod from "../../src/provider/provider" import * as AuthMod from "../../src/auth" +// ESM namespace imports are live bindings: once mock.module() replaces what +// a specifier resolves to, ProviderMod/AuthMod reflect the mock too. Spread +// into a plain object HERE, before any test in this file mocks anything, so +// the afterEach reset below restores the real thing instead of re-registering +// whatever the last mock happened to leave live. +const originalProviderMod = { ...ProviderMod } +const originalAuthMod = { ...AuthMod } + type ProviderInfo = { id: string name?: string @@ -60,8 +68,8 @@ beforeEach(async () => { }) afterEach(() => { - mock.module("../../src/provider/provider", () => ProviderMod) - mock.module("../../src/auth", () => AuthMod) + mock.module("../../src/provider/provider", () => originalProviderMod) + mock.module("../../src/auth", () => originalAuthMod) }) // --------------------------------------------------------------------------- @@ -137,9 +145,10 @@ afterEach(() => { describe("multi-model/provider-discovery — performance benchmarks", () => { test(`discoverAvailableProviders (small catalogue, 3 providers) — ${N_ITER} iters`, async () => { mock.module("../../src/provider/provider", () => ({ - Provider: { list: async () => SMALL_CATALOGUE }, + ...ProviderMod, + Provider: { ...ProviderMod.Provider, list: async () => SMALL_CATALOGUE }, })) - mock.module("../../src/auth", () => ({ Auth: { all: async () => ({}) } })) + mock.module("../../src/auth", () => ({ ...AuthMod, Auth: { ...AuthMod.Auth, all: async () => ({}) } })) const samples: number[] = [] for (let i = 0; i < N_ITER; i++) { @@ -156,9 +165,10 @@ describe("multi-model/provider-discovery — performance benchmarks", () => { test(`discoverAvailableProviders (medium catalogue, 50 providers) — ${N_ITER} iters`, async () => { const catalogue = buildLargeCatalogue(50) mock.module("../../src/provider/provider", () => ({ - Provider: { list: async () => catalogue }, + ...ProviderMod, + Provider: { ...ProviderMod.Provider, list: async () => catalogue }, })) - mock.module("../../src/auth", () => ({ Auth: { all: async () => ({}) } })) + mock.module("../../src/auth", () => ({ ...AuthMod, Auth: { ...AuthMod.Auth, all: async () => ({}) } })) const samples: number[] = [] for (let i = 0; i < N_ITER; i++) { @@ -174,9 +184,10 @@ describe("multi-model/provider-discovery — performance benchmarks", () => { test(`discoverAvailableProviders (large catalogue, 200 providers) — ${N_ITER} iters`, async () => { const catalogue = buildLargeCatalogue(200) mock.module("../../src/provider/provider", () => ({ - Provider: { list: async () => catalogue }, + ...ProviderMod, + Provider: { ...ProviderMod.Provider, list: async () => catalogue }, })) - mock.module("../../src/auth", () => ({ Auth: { all: async () => ({}) } })) + mock.module("../../src/auth", () => ({ ...AuthMod, Auth: { ...AuthMod.Auth, all: async () => ({}) } })) const samples: number[] = [] for (let i = 0; i < N_ITER; i++) { @@ -236,8 +247,8 @@ describe("multi-model/provider-discovery — performance benchmarks", () => { describe("multi-model/provider-discovery — offline determinism stress", () => { test("1000 iterations of (no env, no auth) produce identical empty-or-InsufficientProvidersError", async () => { - mock.module("../../src/provider/provider", () => ({ Provider: { list: async () => ({}) } })) - mock.module("../../src/auth", () => ({ Auth: { all: async () => ({}) } })) + mock.module("../../src/provider/provider", () => ({ ...ProviderMod, Provider: { ...ProviderMod.Provider, list: async () => ({}) } })) + mock.module("../../src/auth", () => ({ ...AuthMod, Auth: { ...AuthMod.Auth, all: async () => ({}) } })) let failureCount = 0 for (let i = 0; i < 1000; i++) { @@ -249,9 +260,10 @@ describe("multi-model/provider-discovery — offline determinism stress", () => test("1000 iterations of (env-var auth, 3 providers) produce identical provider list", async () => { mock.module("../../src/provider/provider", () => ({ - Provider: { list: async () => SMALL_CATALOGUE }, + ...ProviderMod, + Provider: { ...ProviderMod.Provider, list: async () => SMALL_CATALOGUE }, })) - mock.module("../../src/auth", () => ({ Auth: { all: async () => ({}) } })) + mock.module("../../src/auth", () => ({ ...AuthMod, Auth: { ...AuthMod.Auth, all: async () => ({}) } })) const baseline = await Effect.runPromise(discoverAvailableProviders()) const baselineJSON = JSON.stringify(baseline) diff --git a/packages/opencode/test/multi-model/provider-discovery.integration.test.ts b/packages/opencode/test/multi-model/provider-discovery.integration.test.ts index e1918c6d8fb8..3e9631bd2bbd 100644 --- a/packages/opencode/test/multi-model/provider-discovery.integration.test.ts +++ b/packages/opencode/test/multi-model/provider-discovery.integration.test.ts @@ -29,6 +29,14 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" import * as ProviderMod from "../../src/provider/provider" import * as AuthMod from "../../src/auth" +// ESM namespace imports are live bindings: once mock.module() replaces what +// a specifier resolves to, ProviderMod/AuthMod reflect the mock too. Spread +// into a plain object HERE, before any test in this file mocks anything, so +// resetMocks() below restores the real thing instead of re-registering +// whatever the last mock happened to leave live. +const originalProviderMod = { ...ProviderMod } +const originalAuthMod = { ...AuthMod } + // We capture log output by stubbing console.log temporarily. let logLines: string[] = [] const originalLog = console.log @@ -76,21 +84,27 @@ const buildProvider = ( models, }) +// Spread the real namespace rather than replacing it outright — Provider/Auth +// also carry an Effect `Service` tag consumed by unrelated singleton layers +// that memoize their build process-wide (src/effect/run-service.ts). A bare +// replacement object drops `.Service`, and if that memoized build ever runs +// while this mock is live, the resulting `undefined` gets cached for the rest +// of the test process. See provider-discovery.regression.test.ts for the +// concrete cascade this caused. const mockProviderList = (list: Record) => { mock.module("../../src/provider/provider", () => ({ - Provider: { list: async () => list }, + ...originalProviderMod, + Provider: { ...originalProviderMod.Provider, list: async () => list }, })) } const mockAuthAll = (entries: Record) => { - mock.module("../../src/auth", () => ({ - Auth: { all: async () => entries }, - })) + mock.module("../../src/auth", () => ({ ...originalAuthMod, Auth: { ...originalAuthMod.Auth, all: async () => entries } })) } const resetMocks = () => { - mock.module("../../src/provider/provider", () => ProviderMod) - mock.module("../../src/auth", () => AuthMod) + mock.module("../../src/provider/provider", () => originalProviderMod) + mock.module("../../src/auth", () => originalAuthMod) } let discoverAvailableProviders: typeof import("../../src/multi-model/provider-discovery").discoverAvailableProviders diff --git a/packages/opencode/test/team/worktree-manager.test.ts b/packages/opencode/test/team/worktree-manager.test.ts index 6a3793d9caa4..ed8db96c109e 100644 --- a/packages/opencode/test/team/worktree-manager.test.ts +++ b/packages/opencode/test/team/worktree-manager.test.ts @@ -260,7 +260,12 @@ describe("worktree-manager — listWorktrees", () => { }); test("listWorktrees rejects missing repo_root", () => { - const r = listWorktrees("C:/nonexistent/path/that/does/not/exist"); + // A literal "C:/..." path isn't absolute on POSIX, so this used to only + // exercise the intended PATH_NOT_DIRECTORY/GIT_COMMAND_FAILED codes on + // Windows — on Linux it hit PATH_NOT_ABSOLUTE instead (a different, + // already-covered validation path). tmpdir() is absolute on every platform. + const missing = join(tmpdir(), "opencode-test-nonexistent-repo-root-that-does-not-exist"); + const r = listWorktrees(missing); expect(r.ok).toBe(false); if (!r.ok) expect(["PATH_NOT_DIRECTORY", "GIT_COMMAND_FAILED"]).toContain(r.code); }); diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index ffe8dbf4469d..97fa9c5c5e39 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -4341,6 +4341,8 @@ export class Team extends HeyApiClient { /** * Pause a Team run + * + * Suspend an active Team run so its workers stop making progress until resumed. */ public pauseRun( parameters: { @@ -4371,6 +4373,8 @@ export class Team extends HeyApiClient { /** * Resume a Team run + * + * Resume a previously paused Team run so its workers continue making progress. */ public resumeRun( parameters: { @@ -4401,6 +4405,8 @@ export class Team extends HeyApiClient { /** * Cancel a Team run + * + * Permanently stop an active or paused Team run; it cannot be resumed afterward. */ public cancelRun( parameters: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index f81c57ab7212..c3a7f1804703 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8488,6 +8488,7 @@ } ], "summary": "Pause a Team run", + "description": "Suspend an active Team run so its workers stop making progress until resumed.", "responses": { "200": { "description": "Paused", @@ -8562,6 +8563,7 @@ } ], "summary": "Resume a Team run", + "description": "Resume a previously paused Team run so its workers continue making progress.", "responses": { "200": { "description": "Running", @@ -8636,6 +8638,7 @@ } ], "summary": "Cancel a Team run", + "description": "Permanently stop an active or paused Team run; it cannot be resumed afterward.", "responses": { "200": { "description": "Cancelled", diff --git a/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts b/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts index 04bd2b485c13..bd9ea8b60ed7 100644 --- a/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts +++ b/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts @@ -1,3 +1,7 @@ +// Mirrors src/hooks/use-providers.ts's export of the same name — components +// like team-model-selector.tsx import it directly (not just via useProviders()). +export const popularProviders = ["anthropic"] + const model_id = "claude-3-7-sonnet" const provider = {