Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/storybook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
21 changes: 18 additions & 3 deletions packages/opencode/src/server/routes/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -89,37 +100,74 @@ const buildProvider = (
models: Record<string, { cost?: { input: number; output: number }; status?: string }>,
): 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<string, ProviderInfo>) => {
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<string, unknown>) => {
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<typeof originalFsPromises.readFile>[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<unknown>)(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("")
},
}))
}

// 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
Expand Down
36 changes: 24 additions & 12 deletions packages/opencode/test/multi-model/provider-discovery.bench.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
})

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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++) {
Expand All @@ -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++) {
Expand All @@ -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++) {
Expand Down Expand Up @@ -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++) {
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, ProviderInfo>) => {
mock.module("../../src/provider/provider", () => ({
Provider: { list: async () => list },
...originalProviderMod,
Provider: { ...originalProviderMod.Provider, list: async () => list },
}))
}

const mockAuthAll = (entries: Record<string, AuthEntry>) => {
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
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/test/team/worktree-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
6 changes: 6 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ThrowOnError extends boolean = false>(
parameters: {
Expand Down Expand Up @@ -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<ThrowOnError extends boolean = false>(
parameters: {
Expand Down Expand Up @@ -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<ThrowOnError extends boolean = false>(
parameters: {
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
Loading