feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture

Forked from OpenCode v1.17.4 with multi-agent system:
- 5 agents: aircoding, scheduler, worker, architect, reviewer
- Deterministic DAG scheduling engine (coordinator_tick)
- Tool whitelists as hard enforcement
- AirCoding validation plugin
- System prompt injection for routing
- V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md
- Design documents in docs/
This commit is contained in:
airlongdian
2026-06-13 21:41:54 +08:00
commit af3016fe27
5757 changed files with 1170017 additions and 0 deletions

View File

@@ -0,0 +1,204 @@
# Test Fixtures Guide
## Temporary Directory Fixture
The `tmpdir` function in `fixture/fixture.ts` creates temporary directories for tests with automatic cleanup.
### Basic Usage
```typescript
import { tmpdir } from "./fixture/fixture"
test("example", async () => {
await using tmp = await tmpdir()
// tmp.path is the temp directory path
// automatically cleaned up when test ends
})
```
### Options
- `git?: boolean` - Initialize a git repo with a root commit
- `config?: Partial<Config.Info>` - Write an `opencode.json` config file
- `init?: (dir: string) => Promise<T>` - Custom setup function, returns value accessible as `tmp.extra`
- `dispose?: (dir: string) => Promise<T>` - Custom cleanup function
### Examples
**Git repository:**
```typescript
await using tmp = await tmpdir({ git: true })
```
**With config file:**
```typescript
await using tmp = await tmpdir({
config: { model: "test/model", username: "testuser" },
})
```
**Custom initialization (returns extra data):**
```typescript
await using tmp = await tmpdir<string>({
init: async (dir) => {
await Bun.write(path.join(dir, "file.txt"), "content")
return "extra data"
},
})
// Access extra data via tmp.extra
console.log(tmp.extra) // "extra data"
```
**With cleanup:**
```typescript
await using tmp = await tmpdir({
init: async (dir) => {
const specialDir = path.join(dir, "special")
await fs.mkdir(specialDir)
return specialDir
},
dispose: async (dir) => {
// Custom cleanup logic
await fs.rm(path.join(dir, "special"), { recursive: true })
},
})
```
### Returned Object
- `path: string` - Absolute path to the temp directory (realpath resolved)
- `extra: T` - Value returned by the `init` function
- `[Symbol.asyncDispose]` - Enables automatic cleanup via `await using`
### Notes
- Directories are created in the system temp folder with prefix `opencode-test-`
- Use `await using` for automatic cleanup when the variable goes out of scope
- Paths are sanitized to strip null bytes (defensive fix for CI environments)
## Testing With Effects
Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect services or Effect-based workflows.
### Core Pattern
```typescript
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(MyService.defaultLayer))
describe("my service", () => {
it.instance("does the thing", () =>
Effect.gen(function* () {
const svc = yield* MyService.Service
const out = yield* svc.run()
expect(out).toEqual("ok")
}),
)
})
```
### `it.effect` vs `it.live`
- Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`.
- Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior.
- Use `it.instance(...)` for live Effect tests that need a scoped temporary directory and instance context.
- Most integration-style tests in this package use `it.live(...)`.
### Effect Fixtures
Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a manual runtime in each test.
- `tmpdirScoped(options?)` creates a scoped temp directory and cleans it up when the Effect scope closes.
- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it runs an Effect with `InstanceRef` provided for `dir`.
- `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup.
- `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server.
Use `it.instance(...)` by default when a test only needs one temp instance. Yield `TestInstance` from `fixture/fixture.ts` when the test needs the temp directory path:
```typescript
import { TestInstance } from "../fixture/fixture"
it.instance("uses the temp directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
expect(test.directory).toContain("opencode-test-")
}),
)
```
Use `provideTmpdirInstance(...)` or `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, needs to switch instance context within one test, or explicitly tests instance disposal/reload lifetime.
### Style
- Define `const it = testEffect(...)` near the top of the file.
- Keep the test body inside `Effect.gen(function* () { ... })`.
- Yield services directly with `yield* MyService.Service` or `yield* MyTool`.
- Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime.
- When a test needs instance-local state, prefer `it.instance(...)` over manual `Instance.provide(...)` inside Promise-style tests.
### Partial Service Stubs
When a test only needs to override one or two methods of a service, prefer `Layer.mock` over a hand-rolled `Layer.succeed(Service, Service.of({ ... }))`. `Layer.mock` lets you supply just the methods that matter — anything else throws an `UnimplementedError` defect if the test accidentally calls it, which is exactly the signal you want.
```typescript
import { Effect, Layer } from "effect"
import { Account } from "@/account/account"
const failingAccountLayer = Layer.mock(Account.Service, {
orgsByAccount: () => Effect.fail(new Account.AccountServiceError({ message: "simulated upstream failure" })),
})
```
This is much shorter than stubbing every method with `Effect.void` / `Effect.succeed(...)` placeholders, and it keeps the test focused on the behaviour under test.
## Synchronizing With Concurrent Work
### The Anti-Pattern
Using `Effect.sleep(N)` or `setTimeout` as a "wait for the forked fiber to be ready" hack races the scheduler. The forked fiber may not have reached the synchronization point within `N` ms on a slow CI host, and the test fails intermittently. See PR #27622 for a concrete flake that fell out of this exact pattern.
### The Fix
Wait on a **published readiness signal**, not wall-clock time. Available affordances:
- `pollWithTimeout(effect, message, duration?)` from `test/lib/effect.ts` — repeatedly run a predicate effect until it returns a non-`undefined` value, with a timeout.
- `awaitWithTimeout(effect, message, duration?)` from `test/lib/effect.ts` — wrap any effect with `Effect.timeoutOrElse` and a custom error message.
- `llm.wait(n)` from `test/lib/llm-server.ts` — wait until the mock LLM has received `n` HTTP calls.
- `SessionStatus.Service` `.get(sessionID)` — observable per-session state (`{ type: "busy" | "idle" | ... }`).
- `BackgroundJob.wait({ id, timeout })` from `src/background/job.ts` — wait for a background job to complete.
- Bus subscriptions — fork `Stream.runForEach(bus.subscribe(Event), ...)` and open a `Latch` inside the callback to signal first-event readiness.
- `Deferred.await(deferred).pipe(Effect.timeoutOrElse(...))` for one-shot signals.
### Example
```ts
// Antipattern — race
yield * prompt.shell({ command: "sleep 30" }).pipe(Effect.forkChild)
yield * Effect.sleep(50)
yield * prompt.cancel(chat.id)
// Fix — wait for a published readiness signal
yield * prompt.shell({ command: "sleep 30" }).pipe(Effect.forkChild)
yield *
pollWithTimeout(
Effect.gen(function* () {
const s = yield* (yield* SessionStatus.Service).get(chat.id)
return s.type === "busy" ? (true as const) : undefined
}),
"session never became busy",
)
yield * prompt.cancel(chat.id)
```
### When Fixed Sleeps Are OK
- Testing debounce or throttle behavior, where the sleep **is** the test.
- Letting real wall-clock advance past a genuine timestamp resolution boundary (e.g. mtime granularity).
- Simulating network latency in race-regression tests that intentionally exercise ordering.

View File

@@ -0,0 +1,169 @@
# Effect Test Migration
Move tests that exercise Effect services out of Promise-land and into the
shared `testEffect` pattern.
This file is guidance, not a live inventory. Before claiming a migration,
search current `dev` for the exact anti-pattern and update any PR notes
with what you actually changed.
## Target Pattern
Every Effect service test should have one local runner near the top:
```ts
const it = testEffect(layer)
```
Use the runner method that matches the behavior:
```ts
it.effect("pure service behavior", () =>
Effect.gen(function* () {
const service = yield* SomeService.Service
expect(yield* service.run()).toEqual("ok")
}),
)
it.instance("instance-local behavior", () =>
Effect.gen(function* () {
const test = yield* TestInstance
expect(test.directory).toContain("opencode-test-")
}),
)
it.live("live filesystem or process behavior", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
// real clock / fs / git / process work
}),
)
```
## Choosing The Runner
- `it.effect(...)` — pure Effect behavior with `TestClock` and
`TestConsole`.
- `it.instance(...)` — service behavior that needs one scoped opencode
instance.
- `it.live(...)` — real time, filesystem mtimes, child processes, git,
locks, servers, watchers, or OS behavior.
Most integration-style tests use `it.live(...)` or `it.instance(...)`.
## Layer Rules
Compose tests from open service layers when a dependency needs replacing.
Do not use a closed `defaultLayer` and then try to override an inner
dependency after it has already been provided.
Prefer small reusable fake boundary layers in `test/fake/*`:
```ts
AuthTest.empty
AccountTest.empty
NpmTest.noop
SkillTest.empty
ProviderTest.fake().layer
```
Use `Layer.mock` for partial service stubs. Missing methods should fail
loudly if the test accidentally calls them.
Do not add generic test-layer builders until repeated local compositions
prove the need.
## Fixture Rules
Use Effect-aware fixtures from `test/fixture/fixture.ts`:
- `TestInstance` inside `it.instance(...)` for the current temp instance.
- `tmpdirScoped(...)` inside `Effect.gen` for extra temp directories.
- `provideInstance(dir)(effect)` when one test needs to switch instance
context.
- `provideTmpdirInstance((dir) => effect, options)` when a live test needs
custom instance setup or multiple instance scopes.
- `disposeAllInstances()` in `afterEach` only for integration tests that
intentionally touch shared instance registries.
Avoid mutable global setup. If a global mutation is unavoidable during a
migration, scope it with acquire/release and treat it as temporary.
Long term, tests should not toggle `process.env`, `Global.Path`, or
mutable flags when behavior can be modeled with services. Prefer layers
such as `RuntimeFlags.layer(...)` or focused fake services.
## Anti-Patterns To Remove
- `test(..., async () => Effect.runPromise(...))`
- local `run(...)`, `load(...)`, `svc(...)`, or `runtime.runPromise(...)`
wrappers that only provide a layer
- `tmpdir()` plus legacy instance provision in Promise test bodies
- custom `ManagedRuntime.make(...)` in test files
- Promise `try/catch` around Effect failures
- `Promise.withResolvers`, `Bun.sleep`, or `setTimeout` for synchronization
when events, `Deferred`, fibers, or deterministic state checks fit
- mutable env/global/flag changes after layers are built
Promise helpers are acceptable at non-Effect boundaries, but yield them from
inside an Effect body with `Effect.promise(...)` rather than making them the
test harness.
## Conversion Recipe
1. Identify the real service under test and whether its open `layer` or
closed `defaultLayer` is appropriate.
2. Build one top-level `layer` with real dependencies where relevant and
fake layers at slow or external boundaries.
3. Replace local Promise wrappers with Effect helpers.
4. Convert `test(..., async () => { ... })` to `it.effect`, `it.instance`,
or `it.live`.
5. Move `await` calls inside `Effect.gen` as `yield*` calls.
6. Replace `await using tmp = await tmpdir(...)` with
`yield* tmpdirScoped(...)` when the temp directory lives inside the
Effect test.
7. Replace Promise failure assertions with `Effect.exit`, `Effect.flip`, or
focused assertion helpers.
8. Preserve concurrency with fibers, `Deferred`, and
`Effect.all(..., { concurrency: "unbounded" })`; do not accidentally
serialize formerly parallel behavior.
9. Run the focused test file and `bun typecheck` from `packages/opencode`.
## Good Examples
Use current examples as patterns, but re-check them before copying because
test migrations are active:
- `test/effect/instance-state.test.ts` — scoped directories, instance
switching, disposal, and concurrency.
- `test/bus/bus-effect.test.ts``Deferred`, streams, scoped fibers.
- `test/agent/plugin-agent-regression.test.ts` — real service layers plus
fake boundary layers.
- `test/account/service.test.ts` — service-level live tests, typed errors,
fake HTTP clients.
## Migration Queue Policy
Do not maintain a long file checklist here. It goes stale quickly.
When looking for the next target, search for current anti-patterns:
```bash
git grep -n "Effect.runPromise\|ManagedRuntime\|Promise.withResolvers\|Bun.sleep\|withTestInstance" -- packages/opencode/test
```
Then choose one file or one small cluster, keep the PR focused, and mention
the focused verification in the PR body.
## Rough Edges To Watch
- Failure assertions against `Exit` / `Cause` can get verbose. Add helpers
only after the same shape repeats across multiple files.
- Some tests still need `Effect.promise(...)` around Node/Bun APIs. Prefer
Effect platform services when the surrounding code already uses them, but
do not block useful migrations on perfect abstraction.
- Layer composition can be noisy when a test needs real service subtrees plus
fake boundaries. Extract small `test/fake/*` layers before inventing
larger builders.
- Concurrency tests can get harder to read after replacing Promise
resolvers. Look for repeated patterns that deserve named helpers.

View File

@@ -0,0 +1,353 @@
import { expect } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { sql } from "drizzle-orm"
import { AccountRepo } from "../../src/account/repo"
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
import { Database } from "@opencode-ai/core/database/database"
import { testEffect } from "../lib/effect"
const truncate = Layer.effectDiscard(
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db.run(sql`DELETE FROM account_state`)
yield* db.run(sql`DELETE FROM account`)
}),
).pipe(Layer.provide(Database.defaultLayer))
const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate))
it.live("list returns empty when no accounts exist", () =>
Effect.gen(function* () {
const accounts = yield* AccountRepo.use.list()
expect(accounts).toEqual([])
}),
)
it.live("active returns none when no accounts exist", () =>
Effect.gen(function* () {
const active = yield* AccountRepo.use.active()
expect(Option.isNone(active)).toBe(true)
}),
)
it.live("persistAccount inserts and getRow retrieves", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: AccessToken.make("at_123"),
refreshToken: RefreshToken.make("rt_456"),
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-1")),
}),
)
const row = yield* AccountRepo.use.getRow(id)
expect(Option.isSome(row)).toBe(true)
const value = Option.getOrThrow(row)
expect(value.id).toBe(AccountID.make("user-1"))
expect(value.email).toBe("test@example.com")
const active = yield* AccountRepo.use.active()
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-1"))
}),
)
it.live("persistAccount normalizes trailing slashes in stored server URLs", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com/",
accessToken: AccessToken.make("at_123"),
refreshToken: RefreshToken.make("rt_456"),
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
)
const row = yield* AccountRepo.use.getRow(id)
const active = yield* AccountRepo.use.active()
const list = yield* AccountRepo.use.list()
expect(Option.getOrThrow(row).url).toBe("https://control.example.com")
expect(Option.getOrThrow(active).url).toBe("https://control.example.com")
expect(list[0]?.url).toBe("https://control.example.com")
}),
)
it.live("persistAccount sets the active account and org", () =>
Effect.gen(function* () {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: id1,
email: "first@example.com",
url: "https://control.example.com",
accessToken: AccessToken.make("at_1"),
refreshToken: RefreshToken.make("rt_1"),
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-1")),
}),
)
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: id2,
email: "second@example.com",
url: "https://control.example.com",
accessToken: AccessToken.make("at_2"),
refreshToken: RefreshToken.make("rt_2"),
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-2")),
}),
)
// Last persisted account is active with its org
const active = yield* AccountRepo.use.active()
expect(Option.isSome(active)).toBe(true)
expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2"))
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
}),
)
it.live("list returns all accounts", () =>
Effect.gen(function* () {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: id1,
email: "a@example.com",
url: "https://control.example.com",
accessToken: AccessToken.make("at_1"),
refreshToken: RefreshToken.make("rt_1"),
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
)
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: id2,
email: "b@example.com",
url: "https://control.example.com",
accessToken: AccessToken.make("at_2"),
refreshToken: RefreshToken.make("rt_2"),
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-1")),
}),
)
const accounts = yield* AccountRepo.use.list()
expect(accounts.length).toBe(2)
expect(accounts.map((a) => a.email).sort()).toEqual(["a@example.com", "b@example.com"])
}),
)
it.live("remove deletes an account", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: AccessToken.make("at_1"),
refreshToken: RefreshToken.make("rt_1"),
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
)
yield* AccountRepo.use.remove(id)
const row = yield* AccountRepo.use.getRow(id)
expect(Option.isNone(row)).toBe(true)
}),
)
it.live("use stores the selected org and marks the account active", () =>
Effect.gen(function* () {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: id1,
email: "first@example.com",
url: "https://control.example.com",
accessToken: AccessToken.make("at_1"),
refreshToken: RefreshToken.make("rt_1"),
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
)
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: id2,
email: "second@example.com",
url: "https://control.example.com",
accessToken: AccessToken.make("at_2"),
refreshToken: RefreshToken.make("rt_2"),
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
)
yield* AccountRepo.Service.use((r) => r.use(id1, Option.some(OrgID.make("org-99"))))
const active1 = yield* AccountRepo.use.active()
expect(Option.getOrThrow(active1).id).toBe(id1)
expect(Option.getOrThrow(active1).active_org_id).toBe(OrgID.make("org-99"))
yield* AccountRepo.Service.use((r) => r.use(id1, Option.none()))
const active2 = yield* AccountRepo.use.active()
expect(Option.getOrThrow(active2).active_org_id).toBeNull()
}),
)
it.live("persistToken updates token fields", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: AccessToken.make("old_token"),
refreshToken: RefreshToken.make("old_refresh"),
expiry: 1000,
orgID: Option.none(),
}),
)
const expiry = Date.now() + 7200_000
yield* AccountRepo.Service.use((r) =>
r.persistToken({
accountID: id,
accessToken: AccessToken.make("new_token"),
refreshToken: RefreshToken.make("new_refresh"),
expiry: Option.some(expiry),
}),
)
const row = yield* AccountRepo.use.getRow(id)
const value = Option.getOrThrow(row)
expect(value.access_token).toBe(AccessToken.make("new_token"))
expect(value.refresh_token).toBe(RefreshToken.make("new_refresh"))
expect(value.token_expiry).toBe(expiry)
}),
)
it.live("persistToken with no expiry sets token_expiry to null", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: AccessToken.make("old_token"),
refreshToken: RefreshToken.make("old_refresh"),
expiry: 1000,
orgID: Option.none(),
}),
)
yield* AccountRepo.Service.use((r) =>
r.persistToken({
accountID: id,
accessToken: AccessToken.make("new_token"),
refreshToken: RefreshToken.make("new_refresh"),
expiry: Option.none(),
}),
)
const row = yield* AccountRepo.use.getRow(id)
expect(Option.getOrThrow(row).token_expiry).toBeNull()
}),
)
it.live("persistAccount upserts on conflict", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: AccessToken.make("at_v1"),
refreshToken: RefreshToken.make("rt_v1"),
expiry: 1000,
orgID: Option.some(OrgID.make("org-1")),
}),
)
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: AccessToken.make("at_v2"),
refreshToken: RefreshToken.make("rt_v2"),
expiry: 2000,
orgID: Option.some(OrgID.make("org-2")),
}),
)
const accounts = yield* AccountRepo.use.list()
expect(accounts.length).toBe(1)
const row = yield* AccountRepo.use.getRow(id)
const value = Option.getOrThrow(row)
expect(value.access_token).toBe(AccessToken.make("at_v2"))
const active = yield* AccountRepo.use.active()
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
}),
)
it.live("remove clears active state when deleting the active account", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: AccessToken.make("at_1"),
refreshToken: RefreshToken.make("rt_1"),
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-1")),
}),
)
yield* AccountRepo.use.remove(id)
const active = yield* AccountRepo.use.active()
expect(Option.isNone(active)).toBe(true)
}),
)
it.live("getRow returns none for nonexistent account", () =>
Effect.gen(function* () {
const row = yield* AccountRepo.Service.use((r) => r.getRow(AccountID.make("nope")))
expect(Option.isNone(row)).toBe(true)
}),
)

View File

@@ -0,0 +1,453 @@
import { expect } from "bun:test"
import { Duration, Effect, Layer, Option, Schema } from "effect"
import { sql } from "drizzle-orm"
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"
import { AccountRepo } from "../../src/account/repo"
import { Account } from "../../src/account/account"
import {
AccessToken,
AccountID,
AccountTransportError,
DeviceCode,
Login,
Org,
OrgID,
RefreshToken,
UserCode,
} from "../../src/account/schema"
import { Database } from "@opencode-ai/core/database/database"
import { testEffect } from "../lib/effect"
const truncate = Layer.effectDiscard(
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db.run(sql`DELETE FROM account_state`)
yield* db.run(sql`DELETE FROM account`)
}),
).pipe(Layer.provide(Database.defaultLayer))
const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate))
const insideEagerRefreshWindow = Duration.toMillis(Duration.minutes(1))
const outsideEagerRefreshWindow = Duration.toMillis(Duration.minutes(10))
const live = (client: HttpClient.HttpClient) =>
Account.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
HttpClientResponse.fromWeb(
req,
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}),
)
const encodeOrg = Schema.encodeSync(Org)
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
const login = () =>
new Login({
code: DeviceCode.make("device-code"),
user: UserCode.make("user-code"),
url: "https://one.example.com/verify",
server: "https://one.example.com",
expiry: Duration.seconds(600),
interval: Duration.seconds(5),
})
const deviceTokenClient = (body: unknown, status = 400) =>
HttpClient.make((req) =>
Effect.succeed(
req.url === "https://one.example.com/auth/device/token" ? json(req, body, status) : json(req, {}, 404),
),
)
const poll = (body: unknown, status = 400) =>
Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
it.live("login normalizes trailing slashes in the provided server URL", () =>
Effect.gen(function* () {
const seen: Array<string> = []
const client = HttpClient.make((req) =>
Effect.gen(function* () {
seen.push(`${req.method} ${req.url}`)
if (req.url === "https://one.example.com/auth/device/code") {
return json(req, {
device_code: "device-code",
user_code: "user-code",
verification_uri_complete: "/device?user_code=user-code",
expires_in: 600,
interval: 5,
})
}
return json(req, {}, 404)
}),
)
const result = yield* Account.use.login("https://one.example.com/").pipe(Effect.provide(live(client)))
expect(seen).toEqual(["POST https://one.example.com/auth/device/code"])
expect(result.server).toBe("https://one.example.com")
expect(result.url).toBe("https://one.example.com/device?user_code=user-code")
}),
)
it.live("login maps transport failures to account transport errors", () =>
Effect.gen(function* () {
const client = HttpClient.make((req) =>
Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({ request: req }),
}),
),
)
const error = yield* Effect.flip(Account.use.login("https://one.example.com").pipe(Effect.provide(live(client))))
expect(error).toBeInstanceOf(AccountTransportError)
if (error instanceof AccountTransportError) {
expect(error.method).toBe("POST")
expect(error.url).toBe("https://one.example.com/auth/device/code")
}
}),
)
it.live("orgsByAccount groups orgs per account", () =>
Effect.gen(function* () {
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: AccountID.make("user-1"),
email: "one@example.com",
url: "https://one.example.com",
accessToken: AccessToken.make("at_1"),
refreshToken: RefreshToken.make("rt_1"),
expiry: Date.now() + outsideEagerRefreshWindow,
orgID: Option.none(),
}),
)
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: AccountID.make("user-2"),
email: "two@example.com",
url: "https://two.example.com",
accessToken: AccessToken.make("at_2"),
refreshToken: RefreshToken.make("rt_2"),
expiry: Date.now() + outsideEagerRefreshWindow,
orgID: Option.none(),
}),
)
const seen: Array<string> = []
const client = HttpClient.make((req) =>
Effect.gen(function* () {
seen.push(`${req.method} ${req.url}`)
if (req.url === "https://one.example.com/api/orgs") {
return json(req, [org("org-1", "One")])
}
if (req.url === "https://two.example.com/api/orgs") {
return json(req, [org("org-2", "Two A"), org("org-3", "Two B")])
}
return json(req, [], 404)
}),
)
const rows = yield* Account.use.orgsByAccount().pipe(Effect.provide(live(client)))
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
[AccountID.make("user-1"), [OrgID.make("org-1")]],
[AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]],
])
expect(seen).toEqual(["GET https://one.example.com/api/orgs", "GET https://two.example.com/api/orgs"])
}),
)
it.live("token refresh persists the new token", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "user@example.com",
url: "https://one.example.com",
accessToken: AccessToken.make("at_old"),
refreshToken: RefreshToken.make("rt_old"),
expiry: Date.now() - 1_000,
orgID: Option.none(),
}),
)
const client = HttpClient.make((req) =>
Effect.succeed(
req.url === "https://one.example.com/auth/device/token"
? json(req, {
access_token: "at_new",
refresh_token: "rt_new",
expires_in: 60,
})
: json(req, {}, 404),
),
)
const token = yield* Account.use.token(id).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(token)).toBeDefined()
expect(String(Option.getOrThrow(token))).toBe("at_new")
const row = yield* AccountRepo.use.getRow(id)
const value = Option.getOrThrow(row)
expect(value.access_token).toBe(AccessToken.make("at_new"))
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
expect(value.token_expiry).toBeGreaterThan(Date.now())
}),
)
it.live("token refreshes before expiry when inside the eager refresh window", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "user@example.com",
url: "https://one.example.com",
accessToken: AccessToken.make("at_old"),
refreshToken: RefreshToken.make("rt_old"),
expiry: Date.now() + insideEagerRefreshWindow,
orgID: Option.none(),
}),
)
let refreshCalls = 0
const client = HttpClient.make((req) =>
Effect.promise(async () => {
if (req.url === "https://one.example.com/auth/device/token") {
refreshCalls += 1
return json(req, {
access_token: "at_new",
refresh_token: "rt_new",
expires_in: 60,
})
}
return json(req, {}, 404)
}),
)
const token = yield* Account.use.token(id).pipe(Effect.provide(live(client)))
expect(String(Option.getOrThrow(token))).toBe("at_new")
expect(refreshCalls).toBe(1)
const row = yield* AccountRepo.use.getRow(id)
const value = Option.getOrThrow(row)
expect(value.access_token).toBe(AccessToken.make("at_new"))
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
}),
)
it.live("concurrent config and token requests coalesce token refresh", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "user@example.com",
url: "https://one.example.com",
accessToken: AccessToken.make("at_old"),
refreshToken: RefreshToken.make("rt_old"),
expiry: Date.now() - 1_000,
orgID: Option.some(OrgID.make("org-9")),
}),
)
let refreshCalls = 0
const client = HttpClient.make((req) =>
Effect.promise(async () => {
if (req.url === "https://one.example.com/auth/device/token") {
refreshCalls += 1
if (refreshCalls === 1) {
await new Promise((resolve) => setTimeout(resolve, 25))
return json(req, {
access_token: "at_new",
refresh_token: "rt_new",
expires_in: 60,
})
}
return json(
req,
{
error: "invalid_grant",
error_description: "refresh token already used",
},
400,
)
}
if (req.url === "https://one.example.com/api/config") {
return json(req, { config: { theme: "light", seats: 5 } })
}
return json(req, {}, 404)
}),
)
const [cfg, token] = yield* Account.Service.use((s) =>
Effect.all([s.config(id, OrgID.make("org-9")), s.token(id)], { concurrency: 2 }),
).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
expect(String(Option.getOrThrow(token))).toBe("at_new")
expect(refreshCalls).toBe(1)
const row = yield* AccountRepo.use.getRow(id)
const value = Option.getOrThrow(row)
expect(value.access_token).toBe(AccessToken.make("at_new"))
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
}),
)
it.live("config sends the selected org header", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id,
email: "user@example.com",
url: "https://one.example.com",
accessToken: AccessToken.make("at_1"),
refreshToken: RefreshToken.make("rt_1"),
expiry: Date.now() + outsideEagerRefreshWindow,
orgID: Option.none(),
}),
)
const seen: { auth?: string; org?: string } = {}
const client = HttpClient.make((req) =>
Effect.gen(function* () {
seen.auth = req.headers.authorization
seen.org = req.headers["x-org-id"]
if (req.url === "https://one.example.com/api/config") {
return json(req, { config: { theme: "light", seats: 5 } })
}
return json(req, {}, 404)
}),
)
const cfg = yield* Account.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
expect(seen).toEqual({
auth: "Bearer at_1",
org: "org-9",
})
}),
)
it.live("poll stores the account and first org on success", () =>
Effect.gen(function* () {
const client = HttpClient.make((req) =>
Effect.succeed(
req.url === "https://one.example.com/auth/device/token"
? json(req, {
access_token: "at_1",
refresh_token: "rt_1",
token_type: "Bearer",
expires_in: 60,
})
: req.url === "https://one.example.com/api/user"
? json(req, { id: "user-1", email: "user@example.com" })
: req.url === "https://one.example.com/api/orgs"
? json(req, [org("org-1", "One")])
: json(req, {}, 404),
),
)
const res = yield* Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
expect(res._tag).toBe("PollSuccess")
if (res._tag === "PollSuccess") {
expect(res.email).toBe("user@example.com")
}
const active = yield* AccountRepo.use.active()
expect(Option.getOrThrow(active)).toEqual(
expect.objectContaining({
id: "user-1",
email: "user@example.com",
active_org_id: "org-1",
}),
)
}),
)
for (const [name, body, expectedTag] of [
[
"pending",
{
error: "authorization_pending",
error_description: "The authorization request is still pending",
},
"PollPending",
],
[
"slow",
{
error: "slow_down",
error_description: "Polling too frequently, please slow down",
},
"PollSlow",
],
[
"denied",
{
error: "access_denied",
error_description: "The authorization request was denied",
},
"PollDenied",
],
[
"expired",
{
error: "expired_token",
error_description: "The device code has expired",
},
"PollExpired",
],
] as const) {
it.live(`poll returns ${name} for ${body.error}`, () =>
Effect.gen(function* () {
const result = yield* poll(body)
expect(result._tag).toBe(expectedTag)
}),
)
}
it.live("poll returns poll error for other OAuth errors", () =>
Effect.gen(function* () {
const result = yield* poll({
error: "server_error",
error_description: "An unexpected error occurred",
})
expect(result._tag).toBe("PollError")
if (result._tag === "PollError") {
expect(String(result.cause)).toContain("server_error")
}
}),
)

View File

@@ -0,0 +1,229 @@
import { describe, expect, test } from "bun:test"
import {
buildConfigOptions,
buildEffortSelectOption,
buildModeSelectOption,
buildModelSelectOption,
formatCurrentModelId,
formatVariantName,
parseModelSelection,
type ConfigOptionProvider,
} from "@/acp/config-option"
const providers: ConfigOptionProvider[] = [
{
id: "anthropic",
name: "Anthropic",
models: {
"claude/sonnet-4": {
id: "claude/sonnet-4",
name: "Claude Sonnet 4",
variants: {
default: {},
high: {},
"very-high": {},
},
},
"claude-haiku": {
id: "claude-haiku",
name: "Claude Haiku",
},
},
},
{
id: "openai",
name: "OpenAI",
models: {
"gpt-5": {
id: "gpt-5",
name: "GPT-5",
variants: {
minimal: {},
low: {},
},
},
},
},
]
describe("acp config options", () => {
test("builds the model select option with ACP verifier category", () => {
expect(
buildModelSelectOption({
providers,
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
currentVariant: "high",
}),
).toEqual({
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "anthropic/claude/sonnet-4",
options: [
{ value: "anthropic/claude-haiku", name: "Anthropic/Claude Haiku" },
{ value: "anthropic/claude/sonnet-4", name: "Anthropic/Claude Sonnet 4" },
{ value: "openai/gpt-5", name: "OpenAI/GPT-5" },
],
})
})
test("includes variant ids in the model option only when requested", () => {
const option = buildModelSelectOption({
providers,
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
currentVariant: "high",
includeVariants: true,
})
expect(option.currentValue).toBe("anthropic/claude/sonnet-4/high")
if (option.type !== "select") throw new Error("expected select option")
expect(option.options).toContainEqual({
value: "anthropic/claude/sonnet-4/high",
name: "Anthropic/Claude Sonnet 4 (High)",
})
expect(option.options).not.toContainEqual({
value: "anthropic/claude/sonnet-4/default",
name: "Anthropic/Claude Sonnet 4 (Default)",
})
})
test("builds effort option from variants and falls back to default when current variant is invalid", () => {
expect(buildEffortSelectOption({ variants: ["low", "default", "high"], currentVariant: "missing" })).toEqual({
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: "default",
options: [
{ value: "low", name: "Low" },
{ value: "default", name: "Default" },
{ value: "high", name: "High" },
],
})
})
test("effort fallback uses the first variant when default is absent", () => {
expect(buildEffortSelectOption({ variants: ["minimal", "low"], currentVariant: "missing" })?.currentValue).toBe(
"minimal",
)
})
test("omits effort option when there are no variants", () => {
expect(buildEffortSelectOption({ variants: [] })).toBeUndefined()
})
test("builds the mode select option with descriptions when present", () => {
expect(
buildModeSelectOption({
currentModeId: "build",
modes: [
{ id: "build", name: "Build", description: "Make code changes" },
{ id: "plan", name: "Plan" },
],
}),
).toEqual({
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: "build",
options: [
{ value: "build", name: "Build", description: "Make code changes" },
{ value: "plan", name: "Plan" },
],
})
})
test("builds full config options with model, effort, and mode in stable order", () => {
const options = buildConfigOptions({
providers,
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
currentVariant: "very-high",
modes: [
{ id: "build", name: "Build" },
{ id: "plan", name: "Plan" },
],
currentModeId: "plan",
})
expect(options.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(options.map((option) => option.category)).toEqual(["model", "thought_level", "mode"])
expect(options[1]?.currentValue).toBe("very-high")
})
test("full config options omit effort for models without variants", () => {
expect(
buildConfigOptions({
providers,
currentModel: { providerID: "anthropic", modelID: "claude-haiku" },
}).map((option) => option.id),
).toEqual(["model"])
})
test("parses provider/model selections", () => {
expect(parseModelSelection("openai/gpt-5", providers)).toEqual({
model: { providerID: "openai", modelID: "gpt-5" },
})
})
test("parses provider/model/variant selections when the base model exposes that variant", () => {
expect(parseModelSelection("openai/gpt-5/low", providers)).toEqual({
model: { providerID: "openai", modelID: "gpt-5" },
variant: "low",
})
})
test("prefers exact slash-containing model ids before treating the tail as a variant", () => {
expect(parseModelSelection("anthropic/claude/sonnet-4", providers)).toEqual({
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
})
})
test("parses trailing variants for slash-containing model ids", () => {
expect(parseModelSelection("anthropic/claude/sonnet-4/high", providers)).toEqual({
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
variant: "high",
})
})
test("keeps unknown trailing segments in the model id when they are not valid variants", () => {
expect(parseModelSelection("anthropic/claude/sonnet-4/missing", providers)).toEqual({
model: { providerID: "anthropic", modelID: "claude/sonnet-4/missing" },
})
})
test("formats current model ids with and without selected variants", () => {
expect(
formatCurrentModelId({
model: { providerID: "openai", modelID: "gpt-5" },
variant: "low",
variants: ["minimal", "low"],
}),
).toBe("openai/gpt-5")
expect(
formatCurrentModelId({
model: { providerID: "openai", modelID: "gpt-5" },
variant: "low",
variants: ["minimal", "low"],
includeVariant: true,
}),
).toBe("openai/gpt-5/low")
})
test("formats current model ids with variant fallback", () => {
expect(
formatCurrentModelId({
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
variant: "missing",
variants: ["default", "high"],
includeVariant: true,
}),
).toBe("anthropic/claude/sonnet-4/default")
})
test("formats variant names for display", () => {
expect(formatVariantName("very_high-effort")).toBe("Very High Effort")
})
})

View File

@@ -0,0 +1,201 @@
import { describe, expect, test } from "bun:test"
import type { ContentBlock } from "@agentclientprotocol/sdk"
import { pathToFileURL } from "node:url"
import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp/content"
describe("acp content conversion", () => {
test("plain text block becomes a text part", () => {
expect(contentBlockToParts({ type: "text", text: "hello" })).toEqual([{ type: "text", text: "hello" }])
})
test("assistant-only text audience becomes synthetic", () => {
expect(
contentBlockToParts({
type: "text",
text: "internal",
annotations: { audience: ["assistant"] },
}),
).toEqual([{ type: "text", text: "internal", synthetic: true }])
})
test("user-only text audience becomes ignored", () => {
expect(
contentBlockToParts({
type: "text",
text: "visible to user",
annotations: { audience: ["user"] },
}),
).toEqual([{ type: "text", text: "visible to user", ignored: true }])
})
test("image block with base64 data becomes a data URL file part", () => {
expect(
contentBlockToParts({
type: "image",
data: "AAAA",
mimeType: "image/png",
uri: "file:///tmp/screenshot.png",
}),
).toEqual([
{
type: "file",
url: "data:image/png;base64,AAAA",
filename: "screenshot.png",
mime: "image/png",
},
])
})
test("image block with http URI becomes a file part", () => {
expect(
contentBlockToParts({
type: "image",
data: "",
mimeType: "image/jpeg",
uri: "http://example.com/assets/photo.jpg",
}),
).toEqual([
{
type: "file",
url: "http://example.com/assets/photo.jpg",
filename: "photo.jpg",
mime: "image/jpeg",
},
])
})
test("resource_link file URL becomes a file part with name and fallback mime", () => {
expect(
contentBlockToParts({
type: "resource_link",
uri: "file:///tmp/notes.txt",
name: "client-notes.txt",
}),
).toEqual([
{
type: "file",
url: "file:///tmp/notes.txt",
filename: "client-notes.txt",
mime: "text/plain",
},
])
})
test("resource_link zed path becomes a file URL part", () => {
expect(
contentBlockToParts({
type: "resource_link",
uri: "zed://workspace?path=/tmp/project/src/app.ts",
name: "app.ts",
mimeType: "text/typescript",
}),
).toEqual([
{
type: "file",
url: pathToFileURL("/tmp/project/src/app.ts").href,
filename: "app.ts",
mime: "text/typescript",
},
])
})
test("resource with text becomes a text part", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "file:///tmp/context.txt",
mimeType: "text/plain",
text: "context",
},
}),
).toEqual([{ type: "text", text: "context" }])
})
test("resource with blob and mimeType becomes a data URL file part", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "file:///tmp/report.pdf",
mimeType: "application/pdf",
blob: "JVBERg==",
},
}),
).toEqual([
{
type: "file",
url: "data:application/pdf;base64,JVBERg==",
filename: "report.pdf",
mime: "application/pdf",
},
])
})
test("data URL resource is preserved as a file part", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "data:text/plain;base64,aGVsbG8=",
mimeType: "text/plain",
blob: "ignored",
},
}),
).toEqual([
{
type: "file",
url: "data:text/plain;base64,aGVsbG8=",
filename: "file",
mime: "text/plain",
},
])
})
test("unsupported blocks are ignored", () => {
expect(promptContentToParts([{ type: "audio", data: "AAAA", mimeType: "audio/wav" }])).toEqual([])
expect(promptContentToParts([{ type: "unknown", text: "skip" } as unknown as ContentBlock])).toEqual([])
})
})
describe("acp replay conversion", () => {
test("replays text audience annotations", () => {
expect(partsToContentChunks([{ type: "text", text: "cached", synthetic: true }])).toEqual([
{
content: {
type: "text",
text: "cached",
annotations: { audience: ["assistant"] },
},
},
])
})
test("replays file and data URL parts as ACP content", () => {
expect(
partsToContentChunks([
{ type: "file", url: "file:///tmp/readme.md", filename: "readme.md", mime: "text/markdown" },
{ type: "file", url: "data:text/plain;base64,aGVsbG8=", filename: "note.txt", mime: "text/plain" },
]),
).toEqual([
{
content: {
type: "resource_link",
uri: "file:///tmp/readme.md",
name: "readme.md",
mimeType: "text/markdown",
},
},
{
content: {
type: "resource",
resource: {
uri: pathToFileURL("note.txt").href,
mimeType: "text/plain",
text: "hello",
},
},
},
])
})
})

View File

@@ -0,0 +1,186 @@
import { describe, expect } from "bun:test"
import { Directory } from "@/acp/directory"
import { Command } from "@/command"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Provider } from "@/provider/provider"
import { Effect, Layer } from "effect"
import { it } from "../lib/effect"
const command = (name: string): Command.Info => ({
name,
source: "command",
template: `run ${name}`,
hints: [],
})
const model = (providerID: ProviderV2.ID, id: string, variants?: Directory.ModelVariants): Provider.Model => ({
id: ModelV2.ID.make(id),
providerID,
api: {
id,
url: "https://example.com",
npm: "@ai-sdk/openai-compatible",
},
name: id,
family: "test",
capabilities: {
temperature: true,
reasoning: Boolean(variants),
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: {
context: 128000,
output: 4096,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
...(variants ? { variants } : {}),
})
const snapshot = (directory: string) => {
const providerID = ProviderV2.ID.make(`provider-${directory}`)
const modelID = ModelV2.ID.make(`model-${directory}`)
const providers = {
[providerID]: {
id: providerID,
name: `Provider ${directory}`,
source: "config",
env: [],
options: {},
models: {
[modelID]: model(providerID, modelID, {
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
}),
[ModelV2.ID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`),
},
},
} satisfies Record<ProviderV2.ID, Provider.Info>
return Directory.build({
directory,
providers,
modes: [
{ id: "build", name: `build-${directory}` },
{ id: "plan", name: `plan-${directory}`, description: "plan first" },
],
defaultModeID: "build",
commands: [command(`init-${directory}`), command(`review-${directory}`)],
defaultModel: { providerID, modelID },
})
}
const fakeLayer = (calls: string[]) =>
Directory.layer.pipe(
Layer.provide(
Layer.succeed(
Directory.Loader,
Directory.Loader.of({
load: (directory) =>
Effect.sync(() => {
calls.push(directory)
return snapshot(directory)
}),
}),
),
),
)
describe("ACP directory snapshot", () => {
it.effect("two concurrent callers share one load", () => {
const calls: string[] = []
return Effect.gen(function* () {
const directory = yield* Directory.Service
const [first, second] = yield* Effect.all([directory.get("alpha"), directory.get("alpha")], {
concurrency: "unbounded",
})
expect(calls).toEqual(["alpha"])
expect(first).toBe(second)
}).pipe(Effect.provide(fakeLayer(calls)))
})
it.effect("warm calls use cached data", () => {
const calls: string[] = []
return Effect.gen(function* () {
const directory = yield* Directory.Service
const first = yield* directory.get("alpha")
const second = yield* directory.get("alpha")
expect(calls).toEqual(["alpha"])
expect(first).toBe(second)
}).pipe(Effect.provide(fakeLayer(calls)))
})
it.effect("different directories get different snapshots", () => {
const calls: string[] = []
return Effect.gen(function* () {
const directory = yield* Directory.Service
const [alpha, beta] = yield* Effect.all([directory.get("alpha"), directory.get("beta")], {
concurrency: "unbounded",
})
expect(calls.toSorted()).toEqual(["alpha", "beta"])
expect(alpha.directory).toBe("alpha")
expect(beta.directory).toBe("beta")
expect(alpha.defaultModel?.providerID).not.toBe(beta.defaultModel?.providerID)
}).pipe(Effect.provide(fakeLayer(calls)))
})
it.effect("model variant lookup works", () =>
Effect.gen(function* () {
const directory = yield* Directory.Service
const alpha = yield* directory.get("alpha")
const model = alpha.defaultModel!
expect(directory.variants(alpha, model)).toEqual({
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
})
expect(directory.variants(alpha, { ...model, modelID: ModelV2.ID.make("missing") })).toBeUndefined()
}).pipe(Effect.provide(fakeLayer([]))),
)
it.effect("commands and modes are included", () =>
Effect.gen(function* () {
const directory = yield* Directory.Service
const alpha = yield* directory.get("alpha")
expect(alpha.availableCommands.map((item) => item.name)).toEqual(["init-alpha", "review-alpha"])
expect(alpha.availableModes).toEqual([
{ id: "build", name: "build-alpha" },
{ id: "plan", name: "plan-alpha", description: "plan first" },
])
expect(alpha.defaultModeID).toBe("build")
}).pipe(Effect.provide(fakeLayer([]))),
)
it.effect("falls back when the default mode is not available", () =>
Effect.sync(() => {
expect(
Directory.build({
directory: "alpha",
providers: {},
modes: [
{ id: "build", name: "Build" },
{ id: "plan", name: "Plan" },
],
defaultModeID: "hidden",
commands: [],
}).defaultModeID,
).toBe("build")
}),
)
})

View File

@@ -0,0 +1,67 @@
import { describe, expect, test } from "bun:test"
import { RequestError } from "@agentclientprotocol/sdk"
import * as ACPError from "../../src/acp/error"
describe("acp.error", () => {
test("maps validation failures to invalid params", () => {
const cases: ACPError.Error[] = [
new ACPError.SessionNotFoundError({ sessionId: "ses_missing" }),
new ACPError.InvalidConfigOptionError({ configId: "temperature" }),
new ACPError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }),
new ACPError.InvalidEffortError({ effort: "extreme" }),
new ACPError.InvalidModeError({ mode: "turbo" }),
]
expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([-32602, -32602, -32602, -32602, -32602])
})
test("includes safe validation details", () => {
expect(ACPError.toRequestError(new ACPError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({
code: -32602,
data: { sessionId: "ses_123" },
})
expect(ACPError.toRequestError(new ACPError.InvalidModelError({ modelId: "gpt-missing" }))).toMatchObject({
code: -32602,
data: { modelId: "gpt-missing" },
})
})
test("maps auth required to the SDK auth error", () => {
const requestError = ACPError.toRequestError(new ACPError.AuthRequiredError({ providerId: "anthropic" }))
expect(requestError).toBeInstanceOf(RequestError)
expect(requestError.code).toBe(-32000)
expect(requestError.message).toBe("Authentication required: provider authentication required")
expect(requestError.data).toEqual({ providerId: "anthropic" })
})
test("maps unsupported operations to method not found", () => {
const requestError = ACPError.toRequestError(new ACPError.UnsupportedOperationError({ method: "session/new" }))
expect(requestError.code).toBe(-32601)
expect(requestError.data).toEqual({ method: "session/new" })
})
test("maps service failures to safe internal errors", () => {
const requestError = ACPError.toRequestError(
new ACPError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }),
)
expect(requestError.code).toBe(-32603)
expect(requestError.message).toBe("Internal error: Provider request failed")
expect(requestError.data).toEqual({ service: "provider" })
})
test("wraps unknown defects without leaking raw details", () => {
const requestError = ACPError.toRequestError(
ACPError.fromUnknownDefect(new Error("stack has sk-ant-secret and oauth refresh token")),
)
const serialized = JSON.stringify(requestError.toErrorResponse())
expect(requestError.code).toBe(-32603)
expect(requestError.message).toBe("Internal error: Internal service failure")
expect(serialized).not.toContain("sk-ant-secret")
expect(serialized).not.toContain("oauth refresh token")
expect(serialized).not.toContain("stack")
})
})

View File

@@ -0,0 +1,743 @@
import { describe, expect, it } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type { Event, Message, OpencodeClient, Part, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
import { Effect, ManagedRuntime } from "effect"
import { ACPEvent } from "@/acp/event"
import * as ACPService from "@/acp/service"
import { Directory } from "@/acp/directory"
import { ACPSession } from "@/acp/session"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
type ToolSessionUpdateParams = SessionUpdateParams & {
update: Extract<SessionUpdateParams["update"], { sessionUpdate: "tool_call" | "tool_call_update" }>
}
type GlobalEventEnvelope = {
payload?: Event
}
type DeltaPartType = Extract<Part, { type: "text" | "reasoning" }>["type"]
const pollUntil = async (
check: () => boolean | Promise<boolean>,
message: string,
opts?: { timeoutMs?: number; intervalMs?: number },
) => {
const started = Date.now()
while (true) {
if (await check()) return
if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message)
await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5))
}
}
function makeSessionService() {
return ManagedRuntime.make(ACPSession.defaultLayer).runSync(
ACPSession.Service.use((service) => Effect.succeed(service)),
)
}
function createEventStream() {
const queue: GlobalEventEnvelope[] = []
const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = []
const state = { closed: false }
const push = (event: GlobalEventEnvelope) => {
const waiter = waiters.shift()
if (waiter) {
waiter(event)
return
}
queue.push(event)
}
const close = () => {
state.closed = true
for (const waiter of waiters.splice(0)) {
waiter(undefined)
}
}
const stream = async function* (signal?: AbortSignal) {
while (true) {
if (signal?.aborted) return
const next = queue.shift()
if (next) {
yield next
continue
}
if (state.closed) return
const value = await new Promise<GlobalEventEnvelope | undefined>((resolve) => {
waiters.push(resolve)
signal?.addEventListener("abort", () => resolve(undefined), { once: true })
})
if (!value) return
yield value
}
}
return { push, close, stream }
}
function createHarness(messages: Record<string, SessionMessageResponse> = {}) {
const updates: SessionUpdateParams[] = []
const calls = {
eventSubscribe: 0,
message: 0,
}
const events = createEventStream()
const sdk = {
global: {
event: (options?: { signal?: AbortSignal }) => {
calls.eventSubscribe++
return Promise.resolve({ stream: events.stream(options?.signal) })
},
},
session: {
message: (input: { messageID: string }) => {
calls.message++
return Promise.resolve({ data: messages[input.messageID] })
},
get: () => Promise.resolve({ data: { id: "ses_loaded" } }),
messages: () => Promise.resolve({ data: [] }),
},
} as unknown as OpencodeClient
const connection = {
sessionUpdate: (params: SessionUpdateParams) => {
updates.push(params)
return Promise.resolve()
},
} satisfies Pick<AgentSideConnection, "sessionUpdate">
const session = makeSessionService()
const subscription = new ACPEvent.Subscription({ sdk, connection, session })
return { calls, connection, events, sdk, session, subscription, updates }
}
function textDelta(sessionID: string, messageID: string, partID: string, delta: string): Event {
return {
id: `evt_${sessionID}_${messageID}_${partID}_${delta}`,
type: "message.part.delta",
properties: {
sessionID,
messageID,
partID,
field: "text",
delta,
},
}
}
function partUpdated(sessionID: string, messageID: string, partID: string, type: DeltaPartType): Event {
return {
id: `evt_${sessionID}_${messageID}_${partID}`,
type: "message.part.updated",
properties: {
sessionID,
time: Date.now(),
part:
type === "text"
? {
id: partID,
sessionID,
messageID,
type: "text",
text: "",
}
: {
id: partID,
sessionID,
messageID,
type: "reasoning",
text: "",
time: { start: Date.now() },
},
},
}
}
function toolUpdated(part: ToolPart): Event {
return {
id: `evt_${part.sessionID}_${part.messageID}_${part.id}_${part.state.status}`,
type: "message.part.updated",
properties: {
sessionID: part.sessionID,
time: Date.now(),
part,
},
}
}
function assistantMessage(sessionID: string, messageID: string, partID: string, type: DeltaPartType) {
return {
info: {
id: messageID,
sessionID,
role: "assistant",
time: { created: Date.now() },
parentID: "msg_parent",
modelID: "model",
providerID: "provider",
mode: "build",
agent: "build",
path: { cwd: "/workspace", root: "/workspace" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [
type === "text"
? {
id: partID,
sessionID,
messageID,
type: "text",
text: "",
}
: {
id: partID,
sessionID,
messageID,
type: "reasoning",
text: "",
time: { start: Date.now() },
},
],
} satisfies SessionMessageResponse
}
function assistantToolMessage(part: ToolPart) {
return {
info: {
id: part.messageID,
sessionID: part.sessionID,
role: "assistant",
time: { created: Date.now() },
parentID: "msg_parent",
modelID: "model",
providerID: "provider",
mode: "build",
agent: "build",
path: { cwd: "/workspace", root: "/workspace" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [part],
} satisfies SessionMessageResponse
}
function runningTool(
sessionID: string,
callID: string,
output?: string,
input: Record<string, unknown> = { cmd: "printf hello" },
) {
return {
id: `part_${callID}`,
sessionID,
messageID: `msg_${callID}`,
type: "tool",
callID,
tool: "bash",
state: {
status: "running",
input,
title: "bash",
...(output !== undefined ? { metadata: { output } } : {}),
time: { start: Date.now() },
},
} satisfies ToolPart
}
function completedTool(
sessionID: string,
callID: string,
output = "done",
attachments: Extract<ToolPart["state"], { status: "completed" }>["attachments"] = [],
options: {
readonly tool?: string
readonly input?: Record<string, unknown>
readonly metadata?: Record<string, unknown>
} = {},
) {
return {
id: `part_${callID}`,
sessionID,
messageID: `msg_${callID}`,
type: "tool",
callID,
tool: options.tool ?? "bash",
state: {
status: "completed",
input: options.input ?? { cmd: "printf done" },
output,
title: "bash",
metadata: options.metadata ?? { exit: 0 },
time: { start: Date.now() - 1, end: Date.now() },
...(attachments.length ? { attachments } : {}),
},
} satisfies ToolPart
}
function errorTool(sessionID: string, callID: string) {
return {
id: `part_${callID}`,
sessionID,
messageID: `msg_${callID}`,
type: "tool",
callID,
tool: "bash",
state: {
status: "error",
input: { cmd: "exit 1" },
error: "failed hard",
metadata: { exit: 1 },
time: { start: Date.now() - 1, end: Date.now() },
},
} satisfies ToolPart
}
function toolUpdates(updates: SessionUpdateParams[]) {
return updates.filter((item): item is ToolSessionUpdateParams => {
return item.update.sessionUpdate === "tool_call" || item.update.sessionUpdate === "tool_call_update"
})
}
async function createKnownSession(
session: ACPSession.Interface,
sessionId: string,
part: { messageId: string; partId: string; partType: Part["type"]; role?: Message["role"] },
) {
await Effect.runPromise(session.create({ id: sessionId, cwd: "/workspace" }))
await Effect.runPromise(
session.recordPartMetadata({
sessionId,
messageId: part.messageId,
partId: part.partId,
partType: part.partType,
role: part.role ?? "assistant",
}),
)
}
describe("acp event routing", () => {
it("routes message.part.delta by sessionID without cross-session pollution", async () => {
const harness = createHarness()
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
await createKnownSession(harness.session, "ses_b", { messageId: "msg_b", partId: "part_b", partType: "text" })
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "hello"))
expect(harness.updates.map((update) => update.sessionId)).toEqual(["ses_b"])
expect(harness.updates[0]?.update.sessionUpdate).toBe("agent_message_chunk")
})
it("keeps interleaved sessions isolated for text and reasoning deltas", async () => {
const harness = createHarness()
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
await createKnownSession(harness.session, "ses_b", {
messageId: "msg_b",
partId: "part_b",
partType: "reasoning",
})
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "A1"))
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "B1"))
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "A2"))
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "B2"))
expect(
harness.updates.filter((update) => update.sessionId === "ses_a").map((update) => update.update.sessionUpdate),
).toEqual(["agent_message_chunk", "agent_message_chunk"])
expect(
harness.updates.filter((update) => update.sessionId === "ses_b").map((update) => update.update.sessionUpdate),
).toEqual(["agent_thought_chunk", "agent_thought_chunk"])
})
it("does not create extra subscriptions on repeated loadSession", async () => {
const harness = createHarness()
let subscription: ACPEvent.Subscription | undefined
const service = ACPService.make({
sdk: harness.sdk,
connection: harness.connection,
directory: {
get: () =>
Effect.succeed(
Directory.build({
directory: "/workspace",
providers: {},
modes: [],
defaultModeID: "build",
commands: [],
}),
),
refresh: () =>
Effect.succeed(
Directory.build({
directory: "/workspace",
providers: {},
modes: [],
defaultModeID: "build",
commands: [],
}),
),
variants: Directory.variants,
},
session: harness.session,
eventSubscription: (started) => {
subscription = started
},
})
await pollUntil(() => harness.calls.eventSubscribe === 1, "event subscription did not start")
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
expect(harness.calls.eventSubscribe).toBe(1)
subscription?.stop()
harness.events.close()
})
it("does not call sdk.session.message repeatedly when metadata is known", async () => {
const harness = createHarness()
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
for (const delta of ["a", "b", "c", "d", "e"]) {
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", delta))
}
expect(harness.calls.message).toBe(0)
expect(harness.updates).toHaveLength(5)
})
it("fetches unknown part metadata once and reuses it for later deltas", async () => {
const harness = createHarness({
msg_a: assistantMessage("ses_a", "msg_a", "part_a", "text"),
})
await Effect.runPromise(harness.session.create({ id: "ses_a", cwd: "/workspace" }))
await harness.subscription.handle(partUpdated("ses_a", "msg_a", "part_a", "text"))
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "a"))
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "b"))
expect(harness.calls.message).toBe(1)
expect(harness.updates).toHaveLength(2)
})
it("replays loaded session messages sequentially and continues after update failures", async () => {
const events = createEventStream()
const updates: SessionUpdateParams[] = []
const connection = {
sessionUpdate: (params: SessionUpdateParams) => {
if (params.update.sessionUpdate === "tool_call" && params.update.toolCallId === "call_slow") {
return new Promise<void>((resolve) => {
setTimeout(() => {
updates.push(params)
resolve()
}, 20)
})
}
if (params.update.sessionUpdate === "tool_call_update" && params.update.toolCallId === "call_slow") {
return Promise.reject(new Error("replay send failed"))
}
updates.push(params)
return Promise.resolve()
},
} satisfies Pick<AgentSideConnection, "sessionUpdate">
let subscription: ACPEvent.Subscription | undefined
const service = ACPService.make({
sdk: {
global: {
event: (options?: { signal?: AbortSignal }) => Promise.resolve({ stream: events.stream(options?.signal) }),
},
session: {
get: () => Promise.resolve({ data: { id: "ses_loaded" } }),
messages: () =>
Promise.resolve({
data: [
assistantToolMessage(completedTool("ses_loaded", "call_slow", "slow")),
assistantToolMessage(completedTool("ses_loaded", "call_after", "after")),
],
}),
},
} as unknown as OpencodeClient,
connection,
directory: {
get: () =>
Effect.succeed(
Directory.build({
directory: "/workspace",
providers: {},
modes: [],
defaultModeID: "build",
commands: [],
}),
),
refresh: () =>
Effect.succeed(
Directory.build({
directory: "/workspace",
providers: {},
modes: [],
defaultModeID: "build",
commands: [],
}),
),
variants: Directory.variants,
},
eventSubscription: (started) => {
subscription = started
},
})
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
expect(toolUpdates(updates).map((item) => item.update.toolCallId)).toEqual([
"call_slow",
"call_after",
"call_after",
])
subscription?.stop()
events.close()
})
it("ignores unknown sessions and live user parts without user_message_chunk duplication", async () => {
const harness = createHarness()
await createKnownSession(harness.session, "ses_user", {
messageId: "msg_user",
partId: "part_user",
partType: "text",
role: "user",
})
await harness.subscription.handle(textDelta("ses_missing", "msg_missing", "part_missing", "ignored"))
await harness.subscription.handle(partUpdated("ses_user", "msg_user", "part_live", "text"))
await harness.subscription.handle(textDelta("ses_user", "msg_user", "part_user", "hello"))
expect(harness.updates).toHaveLength(0)
})
it("emits synthetic pending before the first running tool update", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_tool", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(runningTool("ses_tool", "call_1", "hello")))
expect(toolUpdates(harness.updates).map((item) => item.update.sessionUpdate)).toEqual([
"tool_call",
"tool_call_update",
])
expect(harness.updates[0]?.update).toMatchObject({ status: "pending", toolCallId: "call_1" })
expect(harness.updates[1]?.update).toMatchObject({ status: "in_progress", toolCallId: "call_1" })
})
it("includes available input in the synthetic pending tool call", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_pending_input", cwd: "/workspace" }))
await harness.subscription.handle(
toolUpdated({
id: "part_call_read",
sessionID: "ses_pending_input",
messageID: "msg_call_read",
type: "tool",
callID: "call_read",
tool: "read",
state: {
status: "running",
input: { filePath: "/workspace/file.ts" },
title: "Read file.ts",
time: { start: Date.now() },
},
} satisfies ToolPart),
)
expect(harness.updates[0]?.update).toMatchObject({
sessionUpdate: "tool_call",
toolCallId: "call_read",
status: "pending",
title: "Read file.ts",
kind: "read",
rawInput: { filePath: "/workspace/file.ts" },
locations: [{ path: "/workspace/file.ts" }],
})
})
it("does not emit duplicate synthetic pending after a replayed running tool", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_replay", cwd: "/workspace" }))
await harness.subscription.replayMessage(assistantToolMessage(runningTool("ses_replay", "call_replay", "first")))
await harness.subscription.handle(toolUpdated(runningTool("ses_replay", "call_replay", "second")))
expect(toolUpdates(harness.updates).filter((item) => item.update.sessionUpdate === "tool_call")).toHaveLength(1)
expect(toolUpdates(harness.updates).map((item) => item.update.sessionUpdate)).toEqual([
"tool_call",
"tool_call_update",
"tool_call_update",
])
})
it("dedupes shell output snapshots while still sending status-only running updates", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_shell", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(runningTool("ses_shell", "call_shell", "same")))
await harness.subscription.handle(toolUpdated(runningTool("ses_shell", "call_shell", "same")))
const updates = toolUpdates(harness.updates)
expect(updates).toHaveLength(3)
expect(updates[1]?.update).toMatchObject({
sessionUpdate: "tool_call_update",
content: [{ type: "content", content: { type: "text", text: "same" } }],
})
expect(updates[2]?.update).toMatchObject({ sessionUpdate: "tool_call_update", status: "in_progress" })
expect("content" in updates[2]!.update).toBe(false)
})
it("clears shell snapshot marker when a tool returns to pending", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_pending", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(runningTool("ses_pending", "call_pending", "repeat")))
await harness.subscription.handle(
toolUpdated({
id: "part_call_pending",
sessionID: "ses_pending",
messageID: "msg_call_pending",
type: "tool",
callID: "call_pending",
tool: "bash",
state: {
status: "pending",
input: { cmd: "printf repeat" },
raw: '{"cmd":"printf repeat"}',
},
}),
)
await harness.subscription.handle(toolUpdated(runningTool("ses_pending", "call_pending", "repeat")))
expect(
toolUpdates(harness.updates)
.filter((item) => item.update.sessionUpdate === "tool_call_update")
.map((item) => ("content" in item.update ? item.update.content : undefined)),
).toEqual([
[{ type: "content", content: { type: "text", text: "repeat" } }],
[{ type: "content", content: { type: "text", text: "repeat" } }],
])
})
it("emits completed tool output and rawOutput", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_done", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(completedTool("ses_done", "call_done", "finished")))
expect(harness.updates.at(-1)?.update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "call_done",
status: "completed",
content: [{ type: "content", content: { type: "text", text: "finished" } }],
rawOutput: { output: "finished", metadata: { exit: 0 } },
})
})
it("emits clean read display content and preserves rawOutput", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_read", cwd: "/workspace" }))
const output = [
"<path>/workspace/file.ts</path>",
"<type>file</type>",
"<content>",
"1: import { value } from './value'",
"2: export { value }",
"",
"(End of file - total 2 lines)",
"</content>",
].join("\n")
const metadata = {
display: {
type: "file",
path: "/workspace/file.ts",
text: "import { value } from './value'\nexport { value }",
lineStart: 1,
lineEnd: 2,
totalLines: 2,
truncated: false,
},
}
await harness.subscription.handle(
toolUpdated(
completedTool("ses_read", "call_read", output, [], {
tool: "read",
input: { filePath: "/workspace/file.ts" },
metadata,
}),
),
)
expect(harness.updates.at(-1)?.update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "call_read",
status: "completed",
content: [
{
type: "content",
content: { type: "text", text: "import { value } from './value'\nexport { value }" },
},
],
rawOutput: { output, metadata },
})
})
it("emits error tool output", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_error", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(errorTool("ses_error", "call_error")))
expect(harness.updates.at(-1)?.update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "call_error",
status: "failed",
content: [{ type: "content", content: { type: "text", text: "failed hard" } }],
rawOutput: { error: "failed hard", metadata: { exit: 1 } },
})
})
it("emits image attachments as ACP image content for live and replayed completed tool updates", async () => {
const harness = createHarness()
const image = Buffer.from("image-data").toString("base64")
const attachment = {
id: "file_image",
sessionID: "ses_image",
messageID: "msg_image",
type: "file",
mime: "image/png",
filename: "image.png",
url: `data:image/png;base64,${image}`,
} as const
await Effect.runPromise(harness.session.create({ id: "ses_image", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(completedTool("ses_image", "call_live", "live", [attachment])))
await harness.subscription.replayMessage(
assistantToolMessage(completedTool("ses_image", "call_replayed", "replayed", [attachment])),
)
expect(
toolUpdates(harness.updates)
.filter((item) => item.update.sessionUpdate === "tool_call_update" && item.update.status === "completed")
.map((item) => ("content" in item.update ? item.update.content : [])),
).toEqual([
[
{ type: "content", content: { type: "text", text: "live" } },
{ type: "content", content: { type: "image", mimeType: "image/png", data: image } },
],
[
{ type: "content", content: { type: "text", text: "replayed" } },
{ type: "content", content: { type: "image", mimeType: "image/png", data: image } },
],
])
})
})

View File

@@ -0,0 +1,273 @@
import { describe, expect, it } from "bun:test"
import type {
AgentSideConnection,
RequestPermissionRequest,
RequestPermissionResponse,
SessionUpdate,
} from "@agentclientprotocol/sdk"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { Effect, ManagedRuntime } from "effect"
import { ACPEvent } from "@/acp/event"
import { ACPSession } from "@/acp/session"
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0]
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
const pollUntil = async (
check: () => boolean | Promise<boolean>,
message: string,
opts?: { timeoutMs?: number; intervalMs?: number },
) => {
const started = Date.now()
while (true) {
if (await check()) return
if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message)
await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5))
}
}
function makeSessionService() {
return ManagedRuntime.make(ACPSession.defaultLayer).runSync(
ACPSession.Service.use((service) => Effect.succeed(service)),
)
}
function createHarness(
requestPermission: (params: RequestPermissionRequest) => Promise<RequestPermissionResponse> = () =>
Promise.resolve({ outcome: { outcome: "selected", optionId: "once" } }),
) {
const replies: PermissionReplyParams[] = []
const requests: RequestPermissionRequest[] = []
const updates: SessionUpdateParams[] = []
const session = makeSessionService()
const sdk = {
permission: {
reply: (params: PermissionReplyParams) => {
replies.push(params)
return Promise.resolve({ data: true })
},
},
session: {
message: () => Promise.resolve({ data: undefined }),
},
} as unknown as OpencodeClient
const connection = {
requestPermission: (params: RequestPermissionRequest) => {
requests.push(params)
return requestPermission(params)
},
sessionUpdate: (params: SessionUpdateParams) => {
updates.push(params)
return Promise.resolve()
},
} satisfies Pick<AgentSideConnection, "requestPermission" | "sessionUpdate">
const subscription = new ACPEvent.Subscription({ sdk, connection, session })
return { connection, replies, requests, sdk, session, subscription, updates }
}
async function createSession(session: ACPSession.Interface, sessionId: string, cwd = "/workspace") {
await Effect.runPromise(session.create({ id: sessionId, cwd }))
}
async function createKnownTextPart(
session: ACPSession.Interface,
sessionId: string,
messageId: string,
partId: string,
) {
await Effect.runPromise(
session.recordPartMetadata({
sessionId,
messageId,
partId,
partType: "text",
role: "assistant",
}),
)
}
function permissionAsked(
sessionID: string,
id: string,
input: {
permission?: string
metadata?: Record<string, unknown>
tool?: { messageID: string; callID: string }
} = {},
) {
return {
id: `evt_${id}`,
type: "permission.asked",
properties: {
id,
sessionID,
permission: input.permission ?? "bash",
patterns: ["*"],
metadata: input.metadata ?? { command: "printf hello" },
always: [],
...(input.tool ? { tool: input.tool } : {}),
},
} as PermissionEvent
}
function textDelta(sessionID: string, messageID: string, partID: string, delta: string) {
return {
id: `evt_${sessionID}_${messageID}_${partID}`,
type: "message.part.delta",
properties: {
sessionID,
messageID,
partID,
field: "text",
delta,
},
} as Event
}
function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
return updates
.filter((item) => item.sessionId === sessionId)
.map((item) => item.update)
.filter((update): update is Extract<SessionUpdate, { sessionUpdate: "agent_message_chunk" }> => {
return update.sessionUpdate === "agent_message_chunk"
})
.map((update) => (update.content.type === "text" ? update.content.text : ""))
.join("")
}
describe("acp permissions", () => {
it("sends requestPermission and replies with the selected outcome", async () => {
const harness = createHarness()
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_1", { tool: { messageID: "msg_1", callID: "call_1" } }))
await pollUntil(() => harness.replies.length === 1, "permission was never replied")
expect(harness.requests[0]).toMatchObject({
sessionId: "ses_a",
toolCall: {
toolCallId: "call_1",
status: "pending",
title: "bash",
rawInput: { command: "printf hello" },
kind: "execute",
locations: [],
},
options: [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
],
})
expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }])
})
it("forwards external_directory metadata and locations to requestPermission", async () => {
const harness = createHarness()
await createSession(harness.session, "ses_a")
harness.subscription.handle(
permissionAsked("ses_a", "perm_external", {
permission: "external_directory",
metadata: {
command: "mkdir -p /tmp/outside",
description: "Create external directory",
directories: ["/tmp/outside"],
patterns: ["/tmp/outside/*"],
},
tool: { messageID: "msg_1", callID: "call_1" },
}),
)
await pollUntil(() => harness.replies.length === 1, "external_directory permission was never replied")
expect(harness.requests[0]).toMatchObject({
sessionId: "ses_a",
toolCall: {
toolCallId: "call_1",
status: "pending",
title: "external_directory",
rawInput: {
command: "mkdir -p /tmp/outside",
description: "Create external directory",
directories: ["/tmp/outside"],
patterns: ["/tmp/outside/*"],
},
locations: [{ path: "/tmp/outside" }],
},
})
})
it("rejects non-selected outcomes", async () => {
const harness = createHarness(() => Promise.resolve({ outcome: { outcome: "cancelled" } }))
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_cancelled"))
await pollUntil(() => harness.replies.length === 1, "cancelled permission was never replied")
expect(harness.replies[0]).toMatchObject({ requestID: "perm_cancelled", reply: "reject" })
})
it("rejects when requestPermission fails", async () => {
const harness = createHarness(() => Promise.reject(new Error("client permission UI failed")))
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_failed"))
await pollUntil(() => harness.replies.length === 1, "failed permission was never rejected")
expect(harness.replies[0]).toMatchObject({ requestID: "perm_failed", reply: "reject" })
})
it("does not let a blocked session A permission block session B message updates", async () => {
let releasePermission: (() => void) | undefined
const blocked = new Promise<RequestPermissionResponse>((resolve) => {
releasePermission = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
})
const harness = createHarness(() => blocked)
await createSession(harness.session, "ses_a")
await createSession(harness.session, "ses_b")
await createKnownTextPart(harness.session, "ses_b", "msg_b", "part_b")
harness.subscription.handle(permissionAsked("ses_a", "perm_blocked"))
await pollUntil(() => harness.requests.length === 1, "blocked permission was never requested")
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "session_b_message"))
expect(textFromUpdates(harness.updates, "ses_b")).toBe("session_b_message")
expect(harness.replies).toHaveLength(0)
releasePermission?.()
await pollUntil(() => harness.replies.length === 1, "blocked permission was never replied after release")
})
it("serializes permission requests per session", async () => {
let releaseFirst: (() => void) | undefined
const first = new Promise<RequestPermissionResponse>((resolve) => {
releaseFirst = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
})
const harness = createHarness(() =>
harness.requests.length === 1 ? first : Promise.resolve({ outcome: { outcome: "selected", optionId: "always" } }),
)
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_1"))
harness.subscription.handle(permissionAsked("ses_a", "perm_2"))
await pollUntil(() => harness.requests.length === 1, "first permission was never requested")
expect(harness.requests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
releaseFirst?.()
await pollUntil(() => harness.requests.length === 2, "second permission was not requested after first resolved")
await pollUntil(() => harness.replies.length === 2, "serialized permissions were not both replied")
expect(harness.replies.map((reply) => [reply.requestID, reply.reply])).toEqual([
["perm_1", "once"],
["perm_2", "always"],
])
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
import { describe, expect } from "bun:test"
import type { McpServer } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import * as ACPError from "@/acp/error"
import * as ACPSession from "@/acp/session"
import { testEffect } from "../lib/effect"
const sessionTest = testEffect(ACPSession.defaultLayer)
const model = (providerID: string, modelID: string): ACPSession.SelectedModel => ({
providerID: ProviderV2.ID.make(providerID),
modelID: ModelV2.ID.make(modelID),
})
const mcpServer: McpServer = {
name: "local-tools",
command: "node",
args: ["server.js"],
env: [],
}
describe("acp session state", () => {
sessionTest.effect("creates and retrieves session state", () =>
Effect.gen(function* () {
const createdAt = new Date("2026-05-25T00:00:00.000Z")
const created = yield* ACPSession.Service.use((session) =>
session.create({
id: "ses_1",
cwd: "/workspace",
mcpServers: [mcpServer],
createdAt,
model: model("anthropic", "claude-sonnet"),
variant: "high",
modeId: "build",
}),
)
const loaded = yield* ACPSession.Service.use((session) => session.get("ses_1"))
expect(created).toMatchObject({
id: "ses_1",
cwd: "/workspace",
mcpServers: [mcpServer],
model: model("anthropic", "claude-sonnet"),
variant: "high",
modeId: "build",
})
expect(loaded.createdAt).toEqual(createdAt)
expect(loaded.knownParts.size).toBe(0)
}),
)
sessionTest.effect("fails required lookups with typed SessionNotFound", () =>
Effect.gen(function* () {
const error = yield* ACPSession.Service.use((session) => session.get("ses_missing")).pipe(Effect.flip)
expect(error).toBeInstanceOf(ACPError.SessionNotFoundError)
expect(error.sessionId).toBe("ses_missing")
}),
)
sessionTest.effect("tryGet lets event routing ignore unknown sessions", () =>
Effect.gen(function* () {
const missing = yield* ACPSession.Service.use((session) => session.tryGet("ses_missing"))
const missingPart = yield* ACPSession.Service.use((session) =>
session.tryGetPartMetadata({ sessionId: "ses_missing", messageId: "msg_1", partId: "part_1" }),
)
expect(missing).toBeUndefined()
expect(missingPart).toBeUndefined()
}),
)
sessionTest.effect("updates selected model while preserving session identity and inputs", () =>
Effect.gen(function* () {
yield* ACPSession.Service.use((session) =>
session.create({
id: "ses_model",
cwd: "/workspace",
mcpServers: [mcpServer],
model: model("anthropic", "claude-sonnet"),
variant: "high",
modeId: "build",
}),
)
const updated = yield* ACPSession.Service.use((session) =>
session.setModel("ses_model", model("openai", "gpt-5")),
)
expect(updated.id).toBe("ses_model")
expect(updated.cwd).toBe("/workspace")
expect(updated.mcpServers).toEqual([mcpServer])
expect(updated.model).toEqual(model("openai", "gpt-5"))
expect(updated.variant).toBe("high")
expect(updated.modeId).toBe("build")
}),
)
sessionTest.effect("updates selected variant and mode independently", () =>
Effect.gen(function* () {
yield* ACPSession.Service.use((session) =>
session.load({
id: "ses_config",
cwd: "/workspace",
model: model("anthropic", "claude-sonnet"),
variant: "low",
modeId: "plan",
}),
)
yield* ACPSession.Service.use((session) => session.setVariant("ses_config", "high"))
expect(yield* ACPSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
expect(yield* ACPSession.Service.use((session) => session.getMode("ses_config"))).toBe("plan")
yield* ACPSession.Service.use((session) => session.setMode("ses_config", "build"))
expect(yield* ACPSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
expect(yield* ACPSession.Service.use((session) => session.getMode("ses_config"))).toBe("build")
}),
)
sessionTest.effect("records known message part metadata for delta routing", () =>
Effect.gen(function* () {
yield* ACPSession.Service.use((session) => session.create({ id: "ses_parts", cwd: "/workspace" }))
const metadata = yield* ACPSession.Service.use((session) =>
session.recordPartMetadata({
sessionId: "ses_parts",
messageId: "msg_1",
partId: "part_1",
toolCallId: "tool_1",
metadata: { output: "first chunk" },
}),
)
const routed = yield* ACPSession.Service.use((session) =>
session.getPartMetadata({ sessionId: "ses_parts", messageId: "msg_1", partId: "part_1" }),
)
expect(metadata).toEqual({
messageId: "msg_1",
partId: "part_1",
toolCallId: "tool_1",
metadata: { output: "first chunk" },
})
expect(routed).toEqual(metadata)
}),
)
sessionTest.effect("keeps repeated part ids distinct across messages", () =>
Effect.gen(function* () {
yield* ACPSession.Service.use((session) => session.create({ id: "ses_duplicate_parts", cwd: "/workspace" }))
yield* ACPSession.Service.use((session) =>
session.recordPartMetadata({
sessionId: "ses_duplicate_parts",
messageId: "msg_1",
partId: "part_1",
metadata: { output: "from first message" },
}),
)
yield* ACPSession.Service.use((session) =>
session.recordPartMetadata({
sessionId: "ses_duplicate_parts",
messageId: "msg_2",
partId: "part_1",
metadata: { output: "from second message" },
}),
)
const first = yield* ACPSession.Service.use((session) =>
session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_1", partId: "part_1" }),
)
const second = yield* ACPSession.Service.use((session) =>
session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_2", partId: "part_1" }),
)
expect(first?.metadata).toEqual({ output: "from first message" })
expect(second?.metadata).toEqual({ output: "from second message" })
}),
)
sessionTest.effect("removing a session clears its known part metadata", () =>
Effect.gen(function* () {
yield* ACPSession.Service.use((session) => session.create({ id: "ses_remove", cwd: "/workspace" }))
yield* ACPSession.Service.use((session) =>
session.recordPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }),
)
const removed = yield* ACPSession.Service.use((session) => session.remove("ses_remove"))
const missing = yield* ACPSession.Service.use((session) => session.tryGet("ses_remove"))
const missingPart = yield* ACPSession.Service.use((session) =>
session.tryGetPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }),
)
expect(removed?.knownParts.size).toBe(1)
expect(missing).toBeUndefined()
expect(missingPart).toBeUndefined()
}),
)
})

View File

@@ -0,0 +1,210 @@
import { describe, expect, test } from "bun:test"
import {
completedToolContent,
completedToolRawOutput,
extractImageAttachments,
imageContents,
shellOutputSnapshot,
toLocations,
toToolKind,
} from "../../src/acp/tool"
describe("acp tool conversion", () => {
test("maps OpenCode tool ids to ACP tool kinds", () => {
expect(toToolKind("bash")).toBe("execute")
expect(toToolKind("shell")).toBe("execute")
expect(toToolKind("webfetch")).toBe("fetch")
expect(toToolKind("edit")).toBe("edit")
expect(toToolKind("apply_patch")).toBe("edit")
expect(toToolKind("patch")).toBe("edit")
expect(toToolKind("write")).toBe("edit")
expect(toToolKind("grep")).toBe("search")
expect(toToolKind("glob")).toBe("search")
expect(toToolKind("context7_resolve_library_id")).toBe("search")
expect(toToolKind("context7_get_library_docs")).toBe("search")
expect(toToolKind("read")).toBe("read")
expect(toToolKind("task")).toBe("think")
expect(toToolKind("custom_tool")).toBe("other")
})
test("extracts file locations from tool input", () => {
expect(toLocations("read", { filePath: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
expect(toLocations("edit", { filePath: "/tmp/b.ts" })).toEqual([{ path: "/tmp/b.ts" }])
expect(toLocations("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }])
expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
expect(toLocations("glob", { path: "/repo/test" })).toEqual([{ path: "/repo/test" }])
expect(toLocations("context7_get_library_docs", { path: "/docs" })).toEqual([{ path: "/docs" }])
expect(toLocations("external_directory", { directories: ["/tmp/outside"], patterns: ["/tmp/outside/*"] })).toEqual([
{ path: "/tmp/outside" },
])
expect(toLocations("bash", { filePath: "/tmp/nope.ts", path: "/tmp" })).toEqual([])
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
})
test("builds completed content with text, edit diffs, and image attachments", () => {
const image = Buffer.from("image-data").toString("base64")
expect(
completedToolContent("edit", {
status: "completed",
input: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
output: "edited /tmp/file.ts",
attachments: [
{
type: "file",
mime: "image/png",
filename: "image.png",
url: `data:image/png;base64,${image}`,
},
{
type: "file",
mime: "text/plain",
filename: "note.txt",
url: "data:text/plain;base64,bm90ZQ==",
},
],
}),
).toEqual([
{
type: "content",
content: { type: "text", text: "edited /tmp/file.ts" },
},
{
type: "diff",
path: "/tmp/file.ts",
oldText: "before",
newText: "after",
},
{
type: "content",
content: { type: "image", mimeType: "image/png", data: image },
},
])
})
test("omits edit diffs until old and new text fields exist", () => {
expect(
completedToolContent("write", {
status: "completed",
input: {
filePath: "/tmp/file.ts",
content: "created",
},
output: "wrote /tmp/file.ts",
}),
).toEqual([
{
type: "content",
content: { type: "text", text: "wrote /tmp/file.ts" },
},
])
})
test("uses clean read display text for completed content", () => {
const output = [
"<path>/tmp/file.ts</path>",
"<type>file</type>",
"<content>",
"7: first",
"8: second",
"",
"(End of file - total 8 lines)",
"</content>",
].join("\n")
const state = {
status: "completed" as const,
input: { filePath: "/tmp/file.ts" },
output,
metadata: {
display: {
type: "file",
path: "/tmp/file.ts",
text: "first\nsecond",
lineStart: 7,
lineEnd: 8,
totalLines: 8,
truncated: false,
},
},
}
expect(completedToolContent("read", state)).toEqual([
{
type: "content",
content: { type: "text", text: "first\nsecond" },
},
])
expect(completedToolRawOutput(state)).toEqual({
output,
metadata: state.metadata,
})
})
test("builds completed raw output with optional metadata and attachments", () => {
const attachments = [
{
type: "file",
mime: "image/jpeg",
filename: "photo.jpg",
url: "data:image/jpeg;base64,AAAA",
},
]
expect(
completedToolRawOutput({
status: "completed",
input: {},
output: "done",
metadata: { exit: 0 },
attachments,
}),
).toEqual({
output: "done",
metadata: { exit: 0 },
attachments,
})
expect(
completedToolRawOutput({
status: "completed",
input: {},
output: "done",
}),
).toEqual({ output: "done" })
})
test("extracts image attachments only from data URLs", () => {
const attachments = [
{
mime: "image/webp",
url: "data:image/webp;charset=utf-8;base64,AAAA",
},
{
mime: "image/png",
url: "https://example.com/image.png",
},
{
mime: "text/plain",
url: "data:text/plain;base64,BBBB",
},
]
expect(extractImageAttachments(attachments)).toEqual([{ mimeType: "image/webp", data: "AAAA" }])
expect(imageContents(attachments)).toEqual([
{
type: "content",
content: { type: "image", mimeType: "image/webp", data: "AAAA" },
},
])
})
test("reads shell output snapshot from string metadata output", () => {
expect(shellOutputSnapshot({ metadata: { output: "line 1\nline 2" } })).toBe("line 1\nline 2")
expect(shellOutputSnapshot({ metadata: { output: 42 } })).toBeUndefined()
expect(shellOutputSnapshot({ metadata: undefined })).toBeUndefined()
})
})

View File

@@ -0,0 +1,315 @@
import { describe, expect, test } from "bun:test"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { UsageService } from "@/acp/usage"
import { Provider } from "@/provider/provider"
import { Effect, Layer } from "effect"
import { it } from "../lib/effect"
const assistant = (
input: Partial<UsageService.AssistantMessage> & Pick<UsageService.AssistantMessage, "cost">,
): UsageService.SessionMessage => ({
info: {
role: "assistant",
providerID: "anthropic",
modelID: "claude-sonnet",
tokens: {
input: 10,
output: 20,
reasoning: 0,
cache: { read: 0, write: 0 },
},
...input,
},
})
const user = (): UsageService.SessionMessage => ({
info: { role: "user" },
})
const assistantWithoutProvider = (): UsageService.SessionMessage => ({
info: {
role: "assistant",
modelID: "claude-sonnet",
cost: 1,
tokens: {
input: 10,
output: 20,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
})
const model = (providerID: ProviderV2.ID, modelID: ModelV2.ID, context: number): Provider.Model => ({
id: modelID,
providerID,
api: {
id: modelID,
url: "https://example.com",
npm: "@ai-sdk/openai-compatible",
},
name: modelID,
family: "test",
capabilities: {
temperature: true,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: {
context,
output: 4096,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
})
const providers = (context = 128_000): Record<ProviderV2.ID, Provider.Info> => {
const providerID = ProviderV2.ID.make("anthropic")
const modelID = ModelV2.ID.make("claude-sonnet")
return {
[providerID]: {
id: providerID,
name: "Anthropic",
source: "config",
env: [],
options: {},
models: {
[modelID]: model(providerID, modelID, context),
},
},
}
}
const fakeLayer = (input: {
readonly messages?: Effect.Effect<readonly UsageService.SessionMessage[], unknown>
readonly providers?: (directory: string) => Effect.Effect<Record<ProviderV2.ID, Provider.Info>, unknown>
}) =>
UsageService.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.succeed(
UsageService.MessageLoader,
UsageService.MessageLoader.of({
messages: () => input.messages ?? Effect.succeed([]),
}),
),
Layer.succeed(
UsageService.ContextLimitLoader,
UsageService.ContextLimitLoader.of({
providers: input.providers ?? (() => Effect.succeed(providers())),
}),
),
),
),
)
const connection = (updates: SessionNotification[]) => ({
sessionUpdate(params: SessionNotification) {
updates.push(params)
return Promise.resolve()
},
})
describe("acp usage", () => {
test("builds ACP Usage from assistant token shape", () => {
expect(
UsageService.buildUsage({
cost: 0.02,
tokens: {
input: 100,
output: 40,
reasoning: 7,
cache: { read: 11, write: 13 },
},
}),
).toEqual({
inputTokens: 100,
outputTokens: 40,
thoughtTokens: 7,
cachedReadTokens: 11,
cachedWriteTokens: 13,
totalTokens: 171,
})
})
test("omits optional token fields when they are zero", () => {
expect(
UsageService.buildUsage({
cost: 0,
tokens: {
input: 3,
output: 4,
reasoning: 0,
cache: { read: 0, write: 0 },
},
}),
).toEqual({
inputTokens: 3,
outputTokens: 4,
totalTokens: 7,
})
})
test("finds the latest assistant message", () => {
expect(
UsageService.latestAssistantMessage([assistant({ cost: 1, modelID: "older" }), user(), assistant({ cost: 2 })]),
).toMatchObject({ cost: 2 })
})
test("calculates total session cost from assistant messages", () => {
expect(UsageService.totalSessionCost([assistant({ cost: 1.25 }), user(), assistant({ cost: 2.5 })])).toBe(3.75)
})
it.effect("loads context limits from providers and caches by directory/provider/model", () => {
const calls: string[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
const first = yield* usage.contextLimit({
directory: "/workspace",
providerID: ProviderV2.ID.make("anthropic"),
modelID: ModelV2.ID.make("claude-sonnet"),
})
const second = yield* usage.contextLimit({
directory: "/workspace",
providerID: ProviderV2.ID.make("anthropic"),
modelID: ModelV2.ID.make("claude-sonnet"),
})
expect(first).toBe(200_000)
expect(second).toBe(200_000)
expect(calls).toEqual(["/workspace"])
}).pipe(
Effect.provide(
fakeLayer({
providers: (directory) =>
Effect.sync(() => {
calls.push(directory)
return providers(200_000)
}),
}),
),
)
})
it.effect("sends ACP usage_update with context size and cumulative assistant cost", () => {
const updates: SessionNotification[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
yield* usage.sendUpdate({
connection: connection(updates),
sessionID: "ses_1",
directory: "/workspace",
})
expect(updates).toEqual([
{
sessionId: "ses_1",
update: {
sessionUpdate: "usage_update",
used: 15,
size: 128_000,
cost: { amount: 3, currency: "USD" },
},
},
])
}).pipe(
Effect.provide(
fakeLayer({
messages: Effect.succeed([
assistant({ cost: 1 }),
assistant({
cost: 2,
tokens: {
input: 10,
output: 20,
reasoning: 0,
cache: { read: 5, write: 0 },
},
}),
]),
}),
),
)
})
it.effect("skips usage update when messages cannot be fetched", () => {
const updates: SessionNotification[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
yield* usage.sendUpdate({
connection: connection(updates),
sessionID: "ses_1",
directory: "/workspace",
})
expect(updates).toEqual([])
}).pipe(Effect.provide(fakeLayer({ messages: Effect.fail(new Error("boom")) })))
})
it.effect("skips usage update when no assistant message exists", () => {
const updates: SessionNotification[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
yield* usage.sendUpdate({
connection: connection(updates),
sessionID: "ses_1",
directory: "/workspace",
})
expect(updates).toEqual([])
}).pipe(Effect.provide(fakeLayer({ messages: Effect.succeed([user()]) })))
})
it.effect("skips usage update when assistant message has no provider or model", () => {
const updates: SessionNotification[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
yield* usage.sendUpdate({
connection: connection(updates),
sessionID: "ses_1",
directory: "/workspace",
})
expect(updates).toEqual([])
}).pipe(
Effect.provide(
fakeLayer({
messages: Effect.succeed([assistantWithoutProvider()]),
}),
),
)
})
it.effect("skips usage update when context size is unknown", () => {
const updates: SessionNotification[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
yield* usage.sendUpdate({
connection: connection(updates),
sessionID: "ses_1",
directory: "/workspace",
})
expect(updates).toEqual([])
}).pipe(
Effect.provide(
fakeLayer({
messages: Effect.succeed([assistant({ cost: 1, providerID: "missing" })]),
}),
),
)
})
})

View File

@@ -0,0 +1,760 @@
import { afterEach, expect } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import path from "path"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Agent } from "../../src/agent/agent"
import { Auth } from "../../src/auth"
import { Config } from "../../src/config/config"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Global } from "@opencode-ai/core/global"
import { Permission } from "../../src/permission"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Plugin } from "../../src/plugin"
import { Provider } from "../../src/provider/provider"
import { Skill } from "../../src/skill"
import { Truncate } from "../../src/tool/truncate"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
const agentLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Agent.layer.pipe(
Layer.provide(Plugin.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(RuntimeFlags.layer(flags)),
)
const it = testEffect(agentLayer())
// Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionV1.Action | undefined {
if (!agent) return undefined
return Permission.evaluate(permission, "*", agent.permission).action
}
function load<A>(fn: (svc: Agent.Interface) => Effect.Effect<A>) {
return Agent.Service.use(fn)
}
const expectDefaultAgentError = Effect.fn("AgentTest.expectDefaultAgentError")(function* (message: string) {
const exit = yield* load((svc) => svc.defaultAgent()).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain(message)
})
afterEach(async () => {
await disposeAllInstances()
})
it.instance("returns default native agents when no config", () =>
Effect.gen(function* () {
const agents = yield* load((svc) => svc.list())
const names = agents.map((a) => a.name)
expect(names).toContain("build")
expect(names).toContain("plan")
expect(names).toContain("general")
expect(names).toContain("explore")
expect(names).toContain("compaction")
expect(names).toContain("title")
expect(names).toContain("summary")
}),
)
it.instance("build agent has correct default properties", () =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
expect(build).toBeDefined()
expect(build?.mode).toBe("primary")
expect(build?.native).toBe(true)
expect(evalPerm(build, "edit")).toBe("allow")
expect(evalPerm(build, "bash")).toBe("allow")
}),
)
it.instance("plan agent denies edits except .opencode/plans/*", () =>
Effect.gen(function* () {
const plan = yield* load((svc) => svc.get("plan"))
expect(plan).toBeDefined()
// Wildcard is denied
expect(evalPerm(plan, "edit")).toBe("deny")
// But specific path is allowed
expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
}),
)
it.instance("plan agent denies the general subagent by default", () =>
Effect.gen(function* () {
const plan = yield* load((svc) => svc.get("plan"))
expect(plan).toBeDefined()
expect(Permission.evaluate("task", "general", plan!.permission).action).toBe("deny")
expect(Permission.evaluate("task", "explore", plan!.permission).action).toBe("allow")
expect(Permission.evaluate("task", "custom", plan!.permission).action).toBe("allow")
}),
)
it.instance(
"user permission can allow the general subagent from plan mode",
() =>
Effect.gen(function* () {
const plan = yield* load((svc) => svc.get("plan"))
expect(plan).toBeDefined()
expect(Permission.evaluate("task", "general", plan!.permission).action).toBe("allow")
}),
{
config: {
permission: {
task: {
general: "allow",
},
},
},
},
)
it.instance("explore agent denies edit and write", () =>
Effect.gen(function* () {
const explore = yield* load((svc) => svc.get("explore"))
expect(explore).toBeDefined()
expect(explore?.mode).toBe("subagent")
expect(evalPerm(explore, "edit")).toBe("deny")
expect(evalPerm(explore, "write")).toBe("deny")
expect(evalPerm(explore, "todowrite")).toBe("deny")
}),
)
it.instance("explore agent asks for external directories and allows whitelisted external paths", () =>
Effect.gen(function* () {
const explore = yield* load((svc) => svc.get("explore"))
expect(explore).toBeDefined()
expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
expect(
Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action,
).toBe("allow")
}),
)
it.instance(
"reference config does not create subagents",
() =>
Effect.gen(function* () {
const agents = yield* load((svc) => svc.list())
const names = agents.map((agent) => agent.name)
expect(names).not.toContain("effect")
expect(names).not.toContain("effectFull")
expect(names).not.toContain("localdocs")
expect(names).not.toContain("localdocsFull")
}),
{
config: {
references: {
effect: "github.com/effect/effect-smol",
effectFull: {
repository: "Effect-TS/effect",
branch: "main",
},
localdocs: "../docs",
localdocsFull: {
path: "../local-docs",
},
},
},
},
)
it.instance("general agent denies todo tools", () =>
Effect.gen(function* () {
const general = yield* load((svc) => svc.get("general"))
expect(general).toBeDefined()
expect(general?.mode).toBe("subagent")
expect(general?.hidden).toBeUndefined()
expect(evalPerm(general, "todowrite")).toBe("deny")
}),
)
it.instance("compaction agent denies all permissions", () =>
Effect.gen(function* () {
const compaction = yield* load((svc) => svc.get("compaction"))
expect(compaction).toBeDefined()
expect(compaction?.hidden).toBe(true)
expect(evalPerm(compaction, "bash")).toBe("deny")
expect(evalPerm(compaction, "edit")).toBe("deny")
expect(evalPerm(compaction, "read")).toBe("deny")
}),
)
it.instance(
"custom agent from config creates new agent",
() =>
Effect.gen(function* () {
const custom = yield* load((svc) => svc.get("my_custom_agent"))
expect(custom).toBeDefined()
expect(String(custom?.model?.providerID)).toBe("openai")
expect(String(custom?.model?.modelID)).toBe("gpt-4")
expect(custom?.description).toBe("My custom agent")
expect(custom?.temperature).toBe(0.5)
expect(custom?.topP).toBe(0.9)
expect(custom?.native).toBe(false)
expect(custom?.mode).toBe("all")
}),
{
config: {
agent: {
my_custom_agent: {
model: "openai/gpt-4",
description: "My custom agent",
temperature: 0.5,
top_p: 0.9,
},
},
},
},
)
it.instance(
"custom agent config overrides native agent properties",
() =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
expect(build).toBeDefined()
expect(String(build?.model?.providerID)).toBe("anthropic")
expect(String(build?.model?.modelID)).toBe("claude-3")
expect(build?.description).toBe("Custom build agent")
expect(build?.temperature).toBe(0.7)
expect(build?.color).toBe("#FF0000")
expect(build?.native).toBe(true)
}),
{
config: {
agent: {
build: {
model: "anthropic/claude-3",
description: "Custom build agent",
temperature: 0.7,
color: "#FF0000",
},
},
},
},
)
it.instance(
"agent disable removes agent from list",
() =>
Effect.gen(function* () {
const explore = yield* load((svc) => svc.get("explore"))
expect(explore).toBeUndefined()
const agents = yield* load((svc) => svc.list())
const names = agents.map((a) => a.name)
expect(names).not.toContain("explore")
}),
{
config: {
agent: {
explore: { disable: true },
},
},
},
)
it.instance(
"agent permission config merges with defaults",
() =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
expect(build).toBeDefined()
// Specific pattern is denied
expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
// Edit still allowed
expect(evalPerm(build, "edit")).toBe("allow")
}),
{
config: {
agent: {
build: {
permission: {
bash: {
"rm -rf *": "deny",
},
},
},
},
},
},
)
it.instance(
"global permission config applies to all agents",
() =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
expect(build).toBeDefined()
expect(evalPerm(build, "bash")).toBe("deny")
}),
{
config: {
permission: {
bash: "deny",
},
},
},
)
it.instance(
"agent steps/maxSteps config sets steps property",
() =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
const plan = yield* load((svc) => svc.get("plan"))
expect(build?.steps).toBe(50)
expect(plan?.steps).toBe(100)
}),
{
config: {
agent: {
build: { steps: 50 },
plan: { maxSteps: 100 },
},
},
},
)
it.instance(
"agent mode can be overridden",
() =>
Effect.gen(function* () {
const explore = yield* load((svc) => svc.get("explore"))
expect(explore?.mode).toBe("primary")
}),
{
config: {
agent: {
explore: { mode: "primary" },
},
},
},
)
it.instance(
"agent name can be overridden",
() =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
expect(build?.name).toBe("Builder")
}),
{
config: {
agent: {
build: { name: "Builder" },
},
},
},
)
it.instance(
"agent prompt can be set from config",
() =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
expect(build?.prompt).toBe("Custom system prompt")
}),
{
config: {
agent: {
build: { prompt: "Custom system prompt" },
},
},
},
)
it.instance(
"unknown agent properties are placed into options",
() =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
expect(build?.options.random_property).toBe("hello")
expect(build?.options.another_random).toBe(123)
}),
{
config: {
agent: {
build: {
random_property: "hello",
another_random: 123,
},
},
},
},
)
it.instance(
"agent options merge correctly",
() =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
expect(build?.options.custom_option).toBe(true)
expect(build?.options.another_option).toBe("value")
}),
{
config: {
agent: {
build: {
options: {
custom_option: true,
another_option: "value",
},
},
},
},
},
)
it.instance(
"multiple custom agents can be defined",
() =>
Effect.gen(function* () {
const agentA = yield* load((svc) => svc.get("agent_a"))
const agentB = yield* load((svc) => svc.get("agent_b"))
expect(agentA?.description).toBe("Agent A")
expect(agentA?.mode).toBe("subagent")
expect(agentB?.description).toBe("Agent B")
expect(agentB?.mode).toBe("primary")
}),
{
config: {
agent: {
agent_a: {
description: "Agent A",
mode: "subagent",
},
agent_b: {
description: "Agent B",
mode: "primary",
},
},
},
},
)
it.instance(
"Agent.list keeps the default agent first and sorts the rest by name",
() =>
Effect.gen(function* () {
const names = (yield* load((svc) => svc.list())).map((a) => a.name)
expect(names[0]).toBe("plan")
expect(names.slice(1)).toEqual(names.slice(1).toSorted((a, b) => a.localeCompare(b)))
}),
{
config: {
default_agent: "plan",
agent: {
zebra: {
description: "Zebra",
mode: "subagent",
},
alpha: {
description: "Alpha",
mode: "subagent",
},
},
},
},
)
it.instance("Agent.get returns undefined for non-existent agent", () =>
Effect.gen(function* () {
const nonExistent = yield* load((svc) => svc.get("does_not_exist"))
expect(nonExistent).toBeUndefined()
}),
)
it.instance("default permission includes doom_loop and external_directory as ask", () =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
expect(evalPerm(build, "doom_loop")).toBe("ask")
expect(evalPerm(build, "external_directory")).toBe("ask")
}),
)
it.instance("webfetch is allowed by default", () =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
expect(evalPerm(build, "webfetch")).toBe("allow")
}),
)
it.instance(
"legacy tools config converts to permissions",
() =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
expect(evalPerm(build, "bash")).toBe("deny")
expect(evalPerm(build, "read")).toBe("deny")
}),
{
config: {
agent: {
build: {
tools: {
bash: false,
read: false,
},
},
},
},
},
)
it.instance(
"legacy tools config maps write/edit/patch to edit permission",
() =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
expect(evalPerm(build, "edit")).toBe("deny")
}),
{
config: {
agent: {
build: {
tools: {
write: false,
},
},
},
},
},
)
it.instance(
"Truncate.GLOB is allowed even when user denies external_directory globally",
() =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
}),
{
config: {
permission: {
external_directory: "deny",
},
},
},
)
it.instance("global tmp directory children are allowed for external_directory", () =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
expect(
Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action,
).toBe("allow")
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("ask")
}),
)
it.instance(
"Truncate.GLOB is allowed even when user denies external_directory per-agent",
() =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
}),
{
config: {
agent: {
build: {
permission: {
external_directory: "deny",
},
},
},
},
},
)
it.instance(
"explicit Truncate.GLOB deny is respected",
() =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
}),
{
config: {
permission: {
external_directory: {
"*": "deny",
[Truncate.GLOB]: "deny",
},
},
},
},
)
it.instance(
"skill directories are allowed for external_directory",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const skillDir = path.join(test.directory, ".opencode", "skill", "perm-skill")
yield* Effect.promise(() =>
Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: perm-skill
description: Permission skill.
---
# Permission Skill
`,
),
)
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = test.directory
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
process.env.OPENCODE_TEST_HOME = home
}),
)
const build = yield* load((svc) => svc.get("build"))
const target = path.join(skillDir, "reference", "notes.md")
expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow")
}),
{ git: true },
)
it.instance(
"project reference directories are allowed for external_directory",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const build = yield* load((svc) => svc.get("build"))
const target = path.resolve(test.directory, "../docs/reference/notes.md")
expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow")
}),
{
git: true,
config: {
references: {
docs: "../docs",
},
},
},
)
it.instance("defaultAgent returns build when no default_agent config", () =>
Effect.gen(function* () {
const agent = yield* load((svc) => svc.defaultAgent())
expect(agent).toBe("build")
}),
)
it.instance("defaultInfo returns resolved build agent when no default_agent config", () =>
Effect.gen(function* () {
const agent = yield* load((svc) => svc.defaultInfo())
expect(agent.name).toBe("build")
expect(agent.mode).toBe("primary")
}),
)
it.instance(
"defaultAgent respects default_agent config set to plan",
() =>
Effect.gen(function* () {
const agent = yield* load((svc) => svc.defaultAgent())
expect(agent).toBe("plan")
}),
{
config: {
default_agent: "plan",
},
},
)
it.instance(
"defaultAgent respects default_agent config set to custom agent with mode all",
() =>
Effect.gen(function* () {
const agent = yield* load((svc) => svc.defaultAgent())
expect(agent).toBe("my_custom")
}),
{
config: {
default_agent: "my_custom",
agent: {
my_custom: {
description: "My custom agent",
},
},
},
},
)
it.instance(
"defaultAgent throws when default_agent points to subagent",
() => expectDefaultAgentError('default agent "explore" is a subagent'),
{
config: {
default_agent: "explore",
},
},
)
it.instance(
"defaultAgent throws when default_agent points to hidden agent",
() => expectDefaultAgentError('default agent "compaction" is hidden'),
{
config: {
default_agent: "compaction",
},
},
)
it.instance(
"defaultAgent throws when default_agent points to non-existent agent",
() => expectDefaultAgentError('default agent "does_not_exist" not found'),
{
config: {
default_agent: "does_not_exist",
},
},
)
it.instance(
"defaultAgent returns plan when build is disabled and default_agent not set",
() =>
Effect.gen(function* () {
const agent = yield* load((svc) => svc.defaultAgent())
// build is disabled, so it should return plan (next primary agent)
expect(agent).toBe("plan")
}),
{
config: {
agent: {
build: { disable: true },
},
},
},
)
it.instance(
"defaultAgent throws when all primary agents are disabled",
() => expectDefaultAgentError("no primary visible agent found"),
{
config: {
agent: {
build: { disable: true },
plan: { disable: true },
},
},
},
)

View File

@@ -0,0 +1,159 @@
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { expect } from "bun:test"
import { Effect } from "effect"
import { Agent } from "../../src/agent/agent"
import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions"
import { Permission } from "../../src/permission"
import { testEffect } from "../lib/effect"
const it = testEffect(Agent.defaultLayer)
function testAgent(input: {
name: string
mode: Agent.Info["mode"]
permission: Parameters<typeof Permission.fromConfig>[0]
}) {
return {
name: input.name,
mode: input.mode,
permission: Permission.fromConfig(input.permission),
options: {},
} satisfies Agent.Info
}
// `deriveSubagentSessionPermission` is imported from production. The test
// exercises the actual helper that task.ts uses to build the subagent's
// session permission, so any regression in that helper trips this test.
it.instance("subagent permissions take precedence over parent agent restrictions", () =>
Effect.gen(function* () {
const planAgent = yield* Agent.use.get("plan")
const generalAgent = yield* Agent.use.get("general")
expect(planAgent).toBeDefined()
expect(generalAgent).toBeDefined()
// Sanity: the plan agent itself blocks edit. (Note: `write` and
// `apply_patch` route through the `edit` permission at the runtime
// tool layer — see Permission.disabled / EDIT_TOOLS.)
expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
const parentSessionPermission: PermissionV1.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
subagent: generalAgent!,
})
// Mirror the runtime evaluation in session/prompt.ts (~line 410, 639):
// ruleset: Permission.merge(agent.permission, session.permission ?? [])
const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission)
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).not.toBe("deny")
expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual(new Set())
}),
)
it.instance("subagent's own read-only restriction remains effective", () =>
Effect.gen(function* () {
const explore = yield* Agent.use.get("explore")
expect(explore).toBeDefined()
const parentSessionPermission: PermissionV1.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
subagent: explore!,
})
const effective = Permission.merge(explore!.permission, subagentSessionPermission)
expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny")
}),
)
it.instance(
"custom subagent can explicitly enable edits denied to its parent agent",
() =>
Effect.gen(function* () {
const planAgent = yield* Agent.use.get("plan")
const my = yield* Agent.use.get("my_subagent")
expect(planAgent).toBeDefined()
expect(my).toBeDefined()
const parentSessionPermission: PermissionV1.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
subagent: my!,
})
const effective = Permission.merge(my!.permission, subagentSessionPermission)
expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("allow")
expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual(new Set())
}),
{
config: {
agent: {
my_subagent: {
description: "A user-defined subagent",
mode: "subagent",
permission: {
edit: "allow",
},
},
},
},
},
)
it.effect("subagent self permissions are preserved", () =>
Effect.sync(() => {
const executor = testAgent({
name: "executor",
mode: "subagent",
permission: {
"*": "deny",
read: "allow",
bash: "allow",
task: {
"*": "deny",
worker: "allow",
},
edit: "allow",
},
})
const effective = Permission.merge(
executor.permission,
deriveSubagentSessionPermission({
parentSessionPermission: [],
subagent: executor,
}),
)
expect(Permission.evaluate("read", "README.md", effective).action).toBe("allow")
expect(Permission.evaluate("bash", "git status", effective).action).toBe("allow")
expect(Permission.evaluate("task", "worker", effective).action).toBe("allow")
expect(Permission.evaluate("task", "other", effective).action).toBe("deny")
expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual(new Set())
}),
)
it.effect("subagent inherits parent session deny rules as hard runtime ceilings", () =>
Effect.sync(() => {
const executor = testAgent({
name: "executor",
mode: "subagent",
permission: {
bash: "allow",
},
})
const effective = Permission.merge(
executor.permission,
deriveSubagentSessionPermission({
parentSessionPermission: Permission.fromConfig({ bash: "deny" }),
subagent: executor,
}),
)
expect(Permission.evaluate("bash", "git status", effective).action).toBe("deny")
}),
)

View File

@@ -0,0 +1,64 @@
import { expect } from "bun:test"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import path from "path"
import { pathToFileURL } from "url"
import { Agent } from "../../src/agent/agent"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Config } from "../../src/config/config"
import { Env } from "../../src/env"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Plugin } from "../../src/plugin"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { NpmTest } from "../fake/npm"
import { ProviderTest } from "../fake/provider"
import { SkillTest } from "../fake/skill"
import { testEffect } from "../lib/effect"
import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants"
// `it.instance` skips InstanceBootstrap so LSP / MCP don't spin up — those
// services hang during scope teardown on Windows and aren't needed
// to verify plugin → config hook → Agent.list.
const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href
const provider = ProviderTest.fake()
const configLayer = Config.layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(AuthTest.empty),
Layer.provide(AccountTest.empty),
Layer.provide(NpmTest.noop),
Layer.provide(FetchHttpClient.layer),
)
const pluginLayer = Plugin.layer.pipe(
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(configLayer),
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
)
const agentLayer = Agent.layer.pipe(
Layer.provide(configLayer),
Layer.provide(AuthTest.empty),
Layer.provide(SkillTest.empty),
Layer.provide(provider.layer),
Layer.provide(pluginLayer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
)
const it = testEffect(Layer.mergeAll(agentLayer, pluginLayer))
it.instance(
"plugin-registered agents appear in Agent.list",
() =>
Effect.gen(function* () {
yield* Plugin.Service.use((p) => p.init())
const agents = yield* Agent.use.list()
const added = agents.find((agent) => agent.name === PLUGIN_AGENT.name)
expect(added?.description).toBe(PLUGIN_AGENT.description)
expect(added?.mode).toBe(PLUGIN_AGENT.mode)
}),
{ config: { plugin: [pluginUrl] } },
)

View File

@@ -0,0 +1,77 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Auth } from "../../src/auth"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
const node = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(Auth.defaultLayer, node))
describe("Auth", () => {
it.instance("set normalizes trailing slashes in keys", () =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("https://example.com/", {
type: "wellknown",
key: "TOKEN",
token: "abc",
})
const data = yield* auth.all()
expect(data["https://example.com"]).toBeDefined()
expect(data["https://example.com/"]).toBeUndefined()
}),
)
it.instance("set cleans up pre-existing trailing-slash entry", () =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("https://example.com/", {
type: "wellknown",
key: "TOKEN",
token: "old",
})
yield* auth.set("https://example.com", {
type: "wellknown",
key: "TOKEN",
token: "new",
})
const data = yield* auth.all()
const keys = Object.keys(data).filter((key) => key.includes("example.com"))
expect(keys).toEqual(["https://example.com"])
const entry = data["https://example.com"]!
expect(entry.type).toBe("wellknown")
if (entry.type === "wellknown") expect(entry.token).toBe("new")
}),
)
it.instance("remove deletes both trailing-slash and normalized keys", () =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("https://example.com", {
type: "wellknown",
key: "TOKEN",
token: "abc",
})
yield* auth.remove("https://example.com/")
const data = yield* auth.all()
expect(data["https://example.com"]).toBeUndefined()
expect(data["https://example.com/"]).toBeUndefined()
}),
)
it.instance("set and remove are no-ops on keys without trailing slashes", () =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("anthropic", {
type: "api",
key: "sk-test",
})
const data = yield* auth.all()
expect(data["anthropic"]).toBeDefined()
yield* auth.remove("anthropic")
const after = yield* auth.all()
expect(after["anthropic"]).toBeUndefined()
}),
)
})

View File

@@ -0,0 +1,243 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect } from "effect"
import { BackgroundJob } from "@/background/job"
import { testEffect } from "../lib/effect"
const it = testEffect(BackgroundJob.defaultLayer)
describe("background.job", () => {
it.instance("tracks started jobs through completion", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
title: "test job",
run: Deferred.await(latch).pipe(Effect.as("done")),
})
expect(job.id.startsWith("job_")).toBe(true)
expect(job.status).toBe("running")
expect(job.title).toBe("test job")
yield* Deferred.succeed(latch, undefined)
const done = yield* jobs.wait({ id: job.id })
expect(done.timedOut).toBe(false)
expect(done.info?.status).toBe("completed")
expect(done.info?.output).toBe("done")
expect((yield* jobs.list()).map((item) => item.id)).toEqual([job.id])
}),
)
it.instance("returns a running snapshot when wait times out", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const job = yield* jobs.start({
type: "test",
run: Effect.never,
})
const result = yield* jobs.wait({ id: job.id, timeout: 1 })
expect(result.timedOut).toBe(true)
expect(result.info?.status).toBe("running")
}),
)
it.instance("deduplicates concurrent starts for a running id", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const started = yield* Deferred.make<void>()
const id = "job_test"
const [first, second] = yield* Effect.all(
[
jobs.start({
id,
type: "test",
run: Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
}),
jobs.start({
id,
type: "test",
run: Effect.fail(new Error("duplicate started")),
}),
],
{ concurrency: "unbounded" },
)
yield* Deferred.await(started)
expect(first.id).toBe(id)
expect(second.id).toBe(id)
expect(first.status).toBe("running")
expect(second.status).toBe("running")
expect((yield* jobs.list()).map((item) => item.id)).toEqual([id])
yield* jobs.cancel(id)
}),
)
it.instance("waits for extensions before completing a running job", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const first = yield* Deferred.make<void>()
const second = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
run: Deferred.await(first).pipe(Effect.as("first")),
})
expect(yield* jobs.extend({ id: job.id, run: Deferred.await(second).pipe(Effect.as("second")) })).toBe(true)
yield* Deferred.succeed(first, undefined)
expect((yield* jobs.get(job.id))?.status).toBe("running")
yield* Deferred.succeed(second, undefined)
const done = yield* jobs.wait({ id: job.id })
expect(done.info?.status).toBe("completed")
expect(done.info?.output).toBe("second")
}),
)
it.instance("runs extensions after earlier work completes", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const first = yield* Deferred.make<void>()
const order: string[] = []
const job = yield* jobs.start({
type: "test",
run: Effect.sync(() => order.push("start")).pipe(Effect.andThen(Deferred.await(first)), Effect.as("first")),
})
expect(
yield* jobs.extend({
id: job.id,
run: Effect.sync(() => order.push("extend")).pipe(Effect.as("second")),
}),
).toBe(true)
yield* Effect.yieldNow
expect(order).toEqual(["start"])
yield* Deferred.succeed(first, undefined)
expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("second")
expect(order).toEqual(["start", "extend"])
}),
)
it.instance("rejects extensions after a job completes", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const job = yield* jobs.start({ type: "test", run: Effect.succeed("done") })
yield* jobs.wait({ id: job.id })
expect(yield* jobs.extend({ id: job.id, run: Effect.succeed("late") })).toBe(false)
expect((yield* jobs.get(job.id))?.output).toBe("done")
}),
)
it.instance("records failed jobs", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const job = yield* jobs.start({
type: "test",
run: Effect.fail(new Error("boom")),
})
const result = yield* jobs.wait({ id: job.id })
expect(result.info?.status).toBe("error")
expect(result.info?.error).toBe("boom")
}),
)
it.instance("ignores stale settlements after restarting a failed job", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const fail = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const id = "job_test"
yield* jobs.start({
id,
type: "test",
run: Deferred.await(fail).pipe(Effect.andThen(Effect.fail(new Error("boom")))),
})
yield* jobs.extend({
id,
run: Effect.never.pipe(
Effect.ensuring(Deferred.succeed(interrupted, undefined).pipe(Effect.andThen(Deferred.await(release)))),
),
})
yield* Deferred.succeed(fail, undefined)
expect((yield* jobs.wait({ id })).info?.status).toBe("error")
yield* Deferred.await(interrupted)
yield* jobs.start({ id, type: "test", run: Effect.never })
yield* Deferred.succeed(release, undefined)
yield* Effect.yieldNow
expect((yield* jobs.get(id))?.status).toBe("running")
yield* jobs.cancel(id)
}),
)
it.instance("can cancel running jobs", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const interrupted = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
})
yield* jobs.extend({
id: job.id,
run: Effect.never,
})
const cancelled = yield* jobs.cancel(job.id)
expect(cancelled?.status).toBe("cancelled")
yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second"))
expect((yield* jobs.get(job.id))?.status).toBe("cancelled")
}),
)
it.instance("promotes running jobs without interrupting them", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const latch = yield* Deferred.make<void>()
const promoted = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
metadata: { parentSessionId: "parent" },
onPromote: Deferred.succeed(promoted, undefined).pipe(Effect.asVoid),
run: Deferred.await(latch).pipe(Effect.as("done")),
})
const info = yield* jobs.promote(job.id)
expect(info?.status).toBe("running")
expect(info?.metadata?.background).toBe(true)
yield* Deferred.await(promoted)
expect((yield* jobs.get(job.id))?.status).toBe("running")
yield* Deferred.succeed(latch, undefined)
expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("done")
}),
)
it.instance("returns immutable snapshots", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const job = yield* jobs.start({
type: "test",
metadata: { value: "initial" },
run: Effect.succeed("done"),
})
if (job.metadata) job.metadata.value = "changed"
expect((yield* jobs.get(job.id))?.metadata?.value).toBe("initial")
}),
)
})

View File

@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test"
import stripAnsi from "strip-ansi"
import { defaultConsoleUrl, formatAccountLabel, formatOrgLine } from "../../src/cli/cmd/account"
describe("console account display", () => {
test("uses console.opencode.ai as the default login URL", () => {
expect(defaultConsoleUrl).toBe("https://console.opencode.ai")
})
test("includes the account url in account labels", () => {
expect(stripAnsi(formatAccountLabel({ email: "one@example.com", url: "https://one.example.com" }, false))).toBe(
"one@example.com https://one.example.com",
)
})
test("includes the active marker in account labels", () => {
expect(stripAnsi(formatAccountLabel({ email: "one@example.com", url: "https://one.example.com" }, true))).toBe(
"one@example.com https://one.example.com (active)",
)
})
test("includes the account url in org rows", () => {
expect(
stripAnsi(
formatOrgLine({ email: "one@example.com", url: "https://one.example.com" }, { id: "org-1", name: "One" }, true),
),
).toBe(" ● One one@example.com https://one.example.com org-1")
})
})

View File

@@ -0,0 +1,97 @@
import { expect } from "bun:test"
import type { SessionConfigOption, SessionConfigSelectOption } from "@agentclientprotocol/sdk"
import { Duration, Effect } from "effect"
import type { AcpHandle } from "../../lib/cli-process"
type JsonRpcRequest = {
readonly jsonrpc: "2.0"
readonly id: number
readonly method: string
readonly params?: unknown
}
type JsonRpcResponse<T = unknown> = {
readonly jsonrpc: "2.0"
readonly id: number
readonly result?: T
readonly error?: unknown
}
type JsonRpcNotification<T = unknown> = {
readonly jsonrpc: "2.0"
readonly method: string
readonly params?: T
}
export type AcpClient = {
readonly request: <T>(method: string, params?: unknown) => Effect.Effect<JsonRpcResponse<T>, unknown>
readonly receive: Effect.Effect<unknown>
readonly waitForNotification: <T>(
method: string,
predicate: (params: T) => boolean,
timeoutMs?: number,
) => Effect.Effect<JsonRpcNotification<T>, unknown>
}
export function createAcpClient(acp: AcpHandle): AcpClient {
const state = { nextId: 1 }
const request = <T>(method: string, params?: unknown) =>
Effect.gen(function* () {
const id = state.nextId++
const message: JsonRpcRequest =
params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params }
yield* acp.send(message)
while (true) {
const received = yield* acp.receive.pipe(Effect.timeout(Duration.seconds(15)))
if (isJsonRpcResponse<T>(received) && received.id === id) return received
}
})
const waitForNotification = <T>(method: string, predicate: (params: T) => boolean, timeoutMs = 15_000) =>
Effect.gen(function* () {
while (true) {
const received = yield* acp.receive.pipe(Effect.timeout(Duration.millis(timeoutMs)))
if (!isJsonRpcNotification<T>(received)) continue
if (received.method === method && predicate(received.params as T)) return received
}
})
return {
request,
receive: acp.receive,
waitForNotification,
}
}
export function expectOk<T>(response: JsonRpcResponse<T>) {
expect(response.error).toBeUndefined()
expect(response.result).toBeDefined()
return response.result as T
}
export function selectConfigOption(options: SessionConfigOption[] | null | undefined, id: string) {
return options?.find(
(option): option is Extract<SessionConfigOption, { type: "select" }> =>
option.id === id && option.type === "select",
)
}
export function firstAlternateValue(option: Extract<SessionConfigOption, { type: "select" }>) {
return flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
}
export function flattenSelectOptions(option: Extract<SessionConfigOption, { type: "select" }>) {
return option.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options))
}
function isJsonRpcResponse<T>(input: unknown): input is JsonRpcResponse<T> {
if (!input || typeof input !== "object") return false
return "id" in input && "jsonrpc" in input
}
function isJsonRpcNotification<T>(input: unknown): input is JsonRpcNotification<T> {
if (!input || typeof input !== "object") return false
return "method" in input && !("id" in input) && "jsonrpc" in input
}

View File

@@ -0,0 +1,103 @@
import { describe, expect } from "bun:test"
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { expectOk, flattenSelectOptions, selectConfigOption } from "./acp-test-client"
import {
createAcpClient,
expectAlternateValue,
expectSelectOption,
initialize,
newSession,
verifierConfig,
} from "./helpers"
describe("opencode acp config option subprocess", () => {
cliIt.live(
'model option is listed with category "model"',
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const model = expectSelectOption((yield* newSession(acp, home)).configOptions, "model")
expect(model.category).toBe("model")
expect(model.currentValue).toBe("test/test-model")
expect(flattenSelectOptions(model).length).toBeGreaterThanOrEqual(2)
}),
60_000,
)
cliIt.live(
"model switch updates currentValue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const model = expectSelectOption(session.configOptions, "model")
const nextModel = flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value
expect(nextModel).toBe("test/second-model")
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: nextModel,
}),
)
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
}),
60_000,
)
cliIt.live(
'effort option is listed with category "thought_level" when selected model supports variants',
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const effort = expectSelectOption((yield* newSession(acp, home)).configOptions, "effort")
expect(effort.category).toBe("thought_level")
expect(effort.currentValue).toBe("low")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"])
}),
60_000,
)
cliIt.live(
"effort switch updates currentValue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const nextEffort = expectAlternateValue(expectSelectOption(session.configOptions, "effort"))
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: nextEffort,
}),
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
}),
60_000,
)
})

View File

@@ -0,0 +1,96 @@
import { expect } from "bun:test"
import type { InitializeResponse, NewSessionResponse, SessionConfigOption } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import type { CliFixture } from "../../lib/cli-process"
import { testProviderConfig } from "../../lib/test-provider"
import {
createAcpClient as createJsonRpcAcpClient,
expectOk,
flattenSelectOptions,
selectConfigOption,
type AcpClient,
} from "./acp-test-client"
export function createAcpClient(input: Pick<CliFixture, "opencode">, env?: Record<string, string>) {
return Effect.gen(function* () {
return createJsonRpcAcpClient(yield* input.opencode.acp(env ? { env } : undefined))
})
}
export function initialize(acp: AcpClient) {
return Effect.gen(function* () {
return expectOk(
yield* acp.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: { _meta: { "terminal-auth": true } },
clientInfo: { name: "opencode-local-acp", version: "0.1.0" },
}),
)
})
}
export function newSession(acp: AcpClient, cwd: string) {
return Effect.gen(function* () {
return expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd, mcpServers: [] }))
})
}
export function verifierConfig(llmUrl: string, skills?: string) {
const config = testProviderConfig(llmUrl)
return {
...config,
model: "test/test-model",
...(skills ? { skills: { paths: [skills] } } : {}),
provider: {
test: {
...config.provider.test,
models: {
"test-model": {
...config.provider.test.models["test-model"],
variants: {
low: {},
high: {},
},
},
"second-model": {
...config.provider.test.models["test-model"],
id: "second-model",
name: "Second Test Model",
variants: {
medium: {},
max: {},
},
},
},
},
},
}
}
export function expectErrorCode(error: unknown, code: number) {
if (!error || typeof error !== "object" || !("code" in error)) {
expect(error).toEqual({ code })
return
}
expect(error.code).toBe(code)
}
export function expectSelectOption(options: SessionConfigOption[] | null | undefined, id: string) {
const option = selectConfigOption(options, id)
expect(option).toBeDefined()
return option!
}
export function expectAlternateValue(option: ReturnType<typeof expectSelectOption>) {
const value = flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
expect(value).toBeDefined()
return value!
}
export const verifierSkill = `---
name: verifier-skill
description: Verifier compatibility skill.
---
# Verifier Skill
`

View File

@@ -0,0 +1,61 @@
import { describe, expect } from "bun:test"
import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { createAcpClient, expectErrorCode, initialize } from "./helpers"
describe("opencode acp initialize/auth subprocess", () => {
cliIt.live(
"initialize responds with capabilities",
({ opencode }) =>
Effect.gen(function* () {
const initialized = yield* initialize(yield* createAcpClient({ opencode }))
expect(initialized.protocolVersion).toBe(1)
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
expect(initialized.agentInfo?.name).toBe("OpenCode")
}),
60_000,
)
cliIt.live(
"auth negotiation is explicit and safe",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpClient({ opencode })
const initialized = yield* initialize(acp)
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined()
expect(yield* acp.request<AuthenticateResponse>("authenticate", { methodId: "opencode-login" })).toMatchObject({
result: {},
})
const rejected = yield* acp.request<AuthenticateResponse>("authenticate", { methodId: "missing-auth-method" })
expectErrorCode(rejected.error, -32602)
expect(JSON.stringify(rejected.error)).not.toContain(process.env.OPENCODE_AUTH_CONTENT ?? "not-present")
}),
60_000,
)
cliIt.live(
"initialize without terminal-auth metadata keeps auth command implicit",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpClient({ opencode })
const initialized = yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
expect(initialized.result?.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.result?.authMethods?.[0]?._meta?.["terminal-auth"]).toBeUndefined()
}),
60_000,
)
})

View File

@@ -0,0 +1,118 @@
import { describe, expect } from "bun:test"
import type {
CloseSessionResponse,
ListSessionsResponse,
LoadSessionResponse,
ResumeSessionResponse,
} from "@agentclientprotocol/sdk"
import { Duration, Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { expectOk, selectConfigOption } from "./acp-test-client"
import { createAcpClient, initialize, newSession, verifierConfig } from "./helpers"
describe("opencode acp lifecycle subprocess", () => {
cliIt.live(
"stdin EOF exits cleanly",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* opencode.acp()
acp.close()
const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(5)))
expect(code).toBe(0)
}),
60_000,
)
cliIt.live(
"close capability and close request",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
const initialized = yield* initialize(acp)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
const session = yield* newSession(acp, home)
expectOk(yield* acp.request<CloseSessionResponse>("session/close", { sessionId: session.sessionId }))
}),
60_000,
)
cliIt.live(
"loadSession capability and load request return session config options",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
const initialized = yield* initialize(acp)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
const session = yield* newSession(acp, home)
const loaded = expectOk(
yield* acp.request<LoadSessionResponse>("session/load", {
cwd: home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
}),
60_000,
)
cliIt.live(
"list request includes a live ACP-created session",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const listed = expectOk(yield* acp.request<ListSessionsResponse>("session/list", { cwd: home }))
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
}),
60_000,
)
cliIt.live(
"resume capability advertisement",
({ opencode }) =>
Effect.gen(function* () {
const initialized = yield* initialize(yield* createAcpClient({ opencode }))
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}),
60_000,
)
cliIt.live(
"resume request returns session config options",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const resumed = expectOk(
yield* acp.request<ResumeSessionResponse>("session/resume", {
cwd: home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
}),
60_000,
)
})

View File

@@ -0,0 +1,97 @@
import { describe, expect } from "bun:test"
import type { PromptResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { writeFile } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { cliIt } from "../../lib/cli-process"
import { expectOk } from "./acp-test-client"
import { createAcpClient, initialize, newSession, verifierConfig } from "./helpers"
const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
describe("opencode acp prompt content subprocess", () => {
cliIt.live(
"accepts embedded text resource image and file resource link prompt content",
({ home, llm, opencode }) =>
Effect.gen(function* () {
yield* Effect.promise(() => writeFile(path.join(home, "README.md"), "# ACP content smoke\n"))
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(promptContentConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
yield* llm.text("embedded resource accepted")
expectOk(
yield* acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this embedded resource." },
{
type: "resource",
resource: { uri: "file:///context.txt", mimeType: "text/plain", text: "embedded context" },
},
],
}),
)
yield* llm.text("image accepted")
expectOk(
yield* acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this image." },
{
type: "image",
mimeType: "image/png",
data: tinyPng,
},
],
}),
)
yield* llm.text("file link accepted")
const linked = expectOk(
yield* acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this linked file." },
{
type: "resource_link",
uri: pathToFileURL(path.join(home, "README.md")).href,
name: "README.md",
mimeType: "text/markdown",
},
],
}),
)
expect(linked.stopReason).toBe("end_turn")
}),
60_000,
)
})
function promptContentConfig(llmUrl: string) {
const config = verifierConfig(llmUrl)
return {
...config,
provider: {
test: {
...config.provider.test,
models: Object.fromEntries(
Object.entries(config.provider.test.models).map(([id, model]) => [
id,
{
...model,
attachment: true,
reasoning: true,
},
]),
),
},
},
}
}

View File

@@ -0,0 +1,38 @@
import { describe, expect } from "bun:test"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { cliIt } from "../../lib/cli-process"
import { createAcpClient, initialize, newSession, verifierConfig, verifierSkill } from "./helpers"
describe("opencode acp skills subprocess", () => {
cliIt.live(
"skill slash command appears through available_commands_update",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const skills = path.join(home, "skills")
yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true }))
yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill))
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const update = yield* acp.waitForNotification<SessionNotification>(
"session/update",
(params) =>
params.sessionId === session.sessionId &&
params.update.sessionUpdate === "available_commands_update" &&
params.update.availableCommands.some(
(command) => command.name === "verifier-skill" && command.description.length > 0,
),
)
expect(update.params?.sessionId).toBe(session.sessionId)
}),
60_000,
)
})

View File

@@ -0,0 +1,484 @@
import { describe, expect, test } from "bun:test"
import type { AudioPlayOptions, AudioSound } from "@opentui/core"
import { createTuiAttention } from "@opencode-ai/tui/attention"
import type { TuiConfig } from "@opencode-ai/tui/config"
type FocusEvent = "focus" | "blur"
type AttentionConfig = Pick<TuiConfig.Resolved, "attention">
class FakeRenderer {
isDestroyed = false
notificationResult = true
notificationThrows = false
notifications: { message: string; title: string | undefined }[] = []
listeners: Record<FocusEvent, Set<() => void>> = {
focus: new Set(),
blur: new Set(),
}
on(event: FocusEvent, listener: () => void) {
this.listeners[event].add(listener)
return this
}
off(event: FocusEvent, listener: () => void) {
this.listeners[event].delete(listener)
return this
}
emit(event: FocusEvent) {
for (const listener of this.listeners[event]) listener()
}
listenerCount(event: FocusEvent) {
return this.listeners[event].size
}
triggerNotification(message: string, title?: string) {
if (this.notificationThrows) throw new Error("notification failed")
this.notifications.push({ message, title })
return this.notificationResult
}
}
class FakeAudioEngine {
loadResult: AudioSound | null = 1
playResult: number | null = 1
loadCalls = 0
playCalls = 0
volumes: (number | undefined)[] = []
loadPaths: string[] = []
rejectLoad = false
rejectPaths = new Set<string>()
async loadSoundFile(path: string) {
this.loadCalls += 1
this.loadPaths.push(path)
if (this.rejectLoad || this.rejectPaths.has(path)) throw new Error("decode failed")
return this.loadResult
}
play(_sound: AudioSound, options?: AudioPlayOptions) {
this.playCalls += 1
this.volumes.push(options?.volume)
return this.playResult
}
}
class FakeKV {
store: Record<string, unknown> = {}
get ready() {
return true
}
get<Value = unknown>(key: string, fallback?: Value) {
return (this.store[key] ?? fallback) as Value
}
set(key: string, value: unknown) {
this.store[key] = value
}
}
function config(attention: Partial<AttentionConfig["attention"]> = {}): AttentionConfig {
return {
attention: {
enabled: true,
notifications: true,
sound: true,
volume: 0.4,
sound_pack: "opencode.default",
sounds: {},
...attention,
},
}
}
describe("createTuiAttention", () => {
test("defaults to sound always and notification blurred", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config(), audio })
expect(await attention.notify({ message: "hello" })).toEqual({
ok: true,
notification: false,
sound: true,
})
expect(renderer.notifications).toHaveLength(0)
expect(audio.playCalls).toBe(1)
})
test("supports blurred-only requests", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config(), audio })
expect(await attention.notify({ message: "unknown", sound: { when: "blurred" } })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "focus_unknown",
})
renderer.emit("focus")
expect(await attention.notify({ message: "focused", sound: { when: "blurred" } })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "focused",
})
renderer.emit("blur")
expect(await attention.notify({ message: "blurred", sound: { when: "blurred" } })).toEqual({
ok: true,
notification: true,
sound: true,
})
expect(audio.playCalls).toBe(1)
})
test("supports focused-only requests", async () => {
const renderer = new FakeRenderer()
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() })
expect(await attention.notify({ message: "unknown", notification: { when: "focused" }, sound: false })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "focus_unknown",
})
renderer.emit("blur")
expect(await attention.notify({ message: "blurred", notification: { when: "focused" }, sound: false })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "blurred",
})
renderer.emit("focus")
expect(await attention.notify({ message: "focused", notification: { when: "focused" }, sound: false })).toEqual({
ok: true,
notification: true,
sound: false,
})
expect(renderer.notifications).toEqual([{ title: "opencode", message: "focused" }])
})
test("notification can deliver while focused when requested", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config(), audio })
renderer.emit("focus")
expect(await attention.notify({ message: "hello", notification: { when: "always" } })).toEqual({
ok: true,
notification: true,
sound: true,
})
expect(audio.playCalls).toBe(1)
expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello" }])
})
test("notifies while blurred", async () => {
const renderer = new FakeRenderer()
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() })
renderer.emit("blur")
expect(await attention.notify({ title: "opencode", message: "hello", sound: false })).toEqual({
ok: true,
notification: true,
sound: false,
})
expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello" }])
})
test("when requested, blurred-only calls do not notify or play sound while focused", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config(), audio })
renderer.emit("focus")
expect(await attention.notify({ message: "hello", sound: { when: "blurred" } })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "focused",
})
expect(renderer.notifications).toHaveLength(0)
expect(audio.loadCalls).toBe(0)
})
test("can play sound always while notification is blurred-only", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config(), audio })
renderer.emit("focus")
expect(
await attention.notify({
message: "hello",
sound: { name: "question" },
}),
).toEqual({
ok: true,
notification: false,
sound: true,
})
expect(renderer.notifications).toHaveLength(0)
expect(audio.playCalls).toBe(1)
renderer.emit("blur")
expect(
await attention.notify({
message: "hello again",
sound: { name: "question" },
}),
).toEqual({
ok: true,
notification: true,
sound: true,
})
expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello again" }])
})
test("can disable notification per call while still playing sound", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config(), audio })
expect(await attention.notify({ message: "hello", notification: false })).toEqual({
ok: true,
notification: false,
sound: true,
})
expect(renderer.notifications).toHaveLength(0)
expect(audio.playCalls).toBe(1)
})
test("skips empty messages and disabled attention", async () => {
const empty = new FakeRenderer()
empty.emit("blur")
const disabled = new FakeRenderer()
disabled.emit("blur")
expect(await createTuiAttention({ renderer: empty, config: config() }).notify({ message: " \n " })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "empty_message",
})
expect(
await createTuiAttention({ renderer: disabled, config: config({ enabled: false }) }).notify({ message: "hello" }),
).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "attention_disabled",
})
})
test("respects notification and sound config independently", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config({ notifications: false }), audio })
renderer.emit("blur")
expect(await attention.notify({ message: "hello", sound: true })).toEqual({
ok: true,
notification: false,
sound: true,
})
expect(renderer.notifications).toHaveLength(0)
expect(audio.playCalls).toBe(1)
const soundDisabledRenderer = new FakeRenderer()
const soundDisabledAudio = new FakeAudioEngine()
const soundDisabled = createTuiAttention({
renderer: soundDisabledRenderer,
config: config({ sound: false }),
audio: soundDisabledAudio,
})
soundDisabledRenderer.emit("blur")
expect(await soundDisabled.notify({ message: "hello", sound: true })).toEqual({
ok: true,
notification: true,
sound: false,
})
expect(soundDisabledAudio.loadCalls).toBe(0)
})
test("loads audio lazily only for eligible sound requests", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config(), audio })
await attention.notify({ message: "unknown", sound: { when: "blurred" } })
expect(audio.loadCalls).toBe(0)
renderer.emit("blur")
expect(await attention.notify({ message: "blurred", sound: { volume: 2 } })).toEqual({
ok: true,
notification: true,
sound: true,
})
expect(audio.loadCalls).toBe(1)
expect(audio.volumes).toEqual([1])
})
test("handles unavailable playback and delegates sound loading", async () => {
const unavailableRenderer = new FakeRenderer()
const unavailableAudio = new FakeAudioEngine()
unavailableAudio.playResult = null
const unavailable = createTuiAttention({ renderer: unavailableRenderer, config: config(), audio: unavailableAudio })
unavailableRenderer.emit("blur")
expect(await unavailable.notify({ message: "hello", sound: true })).toEqual({
ok: true,
notification: true,
sound: false,
})
expect(unavailableAudio.loadCalls).toBe(1)
expect(unavailableAudio.playCalls).toBe(1)
const repeatedRenderer = new FakeRenderer()
const repeatedAudio = new FakeAudioEngine()
const repeated = createTuiAttention({ renderer: repeatedRenderer, config: config(), audio: repeatedAudio })
repeatedRenderer.emit("blur")
await repeated.notify({ message: "one", sound: true })
await repeated.notify({ message: "two", sound: true })
expect(repeatedAudio.loadCalls).toBe(2)
expect(repeatedAudio.playCalls).toBe(2)
})
test("plays named sounds from the active sound pack", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config(), audio })
renderer.emit("blur")
const dispose = attention.soundboard.registerPack({
id: "acme.soft",
name: "Soft Alerts",
sounds: {
question: "/tmp/question.mp3",
},
})
expect(attention.soundboard.activate("acme.soft")).toBe(true)
expect(attention.soundboard.current()).toBe("acme.soft")
expect(attention.soundboard.list()).toContainEqual({
id: "acme.soft",
name: "Soft Alerts",
active: true,
builtin: false,
})
expect(await attention.notify({ message: "question", sound: { name: "question" } })).toEqual({
ok: true,
notification: true,
sound: true,
})
expect(audio.loadPaths).toEqual(["/tmp/question.mp3"])
dispose()
expect(attention.soundboard.current()).toBe("opencode.default")
})
test("uses config sound overrides before active pack sounds and falls back on load failure", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
audio.rejectPaths.add("/tmp/bad-question.mp3")
const attention = createTuiAttention({
renderer,
config: config({ sounds: { question: "/tmp/bad-question.mp3" } }),
audio,
})
renderer.emit("blur")
attention.soundboard.registerPack({
id: "acme.soft",
sounds: {
question: "/tmp/good-question.mp3",
},
})
attention.soundboard.activate("acme.soft")
expect(await attention.notify({ message: "question", sound: { name: "question" } })).toEqual({
ok: true,
notification: true,
sound: true,
})
expect(audio.loadPaths).toEqual(["/tmp/bad-question.mp3", "/tmp/good-question.mp3"])
})
test("persists activated sound pack in KV", () => {
const kv = new FakeKV()
const renderer = new FakeRenderer()
const attention = createTuiAttention({ renderer, config: config(), kv })
attention.soundboard.registerPack({ id: "acme.soft", sounds: { done: "/tmp/done.mp3" } })
expect(attention.soundboard.activate("missing", { persist: true })).toBe(false)
expect(kv.store.attention_sound_pack).toBeUndefined()
expect(attention.soundboard.activate("acme.soft", { persist: true })).toBe(true)
expect(kv.store.attention_sound_pack).toBe("acme.soft")
const next = createTuiAttention({ renderer: new FakeRenderer(), config: config(), kv })
next.soundboard.registerPack({ id: "acme.soft", sounds: { done: "/tmp/done.mp3" } })
expect(next.soundboard.current()).toBe("acme.soft")
})
test("does not throw for notification or sound failures", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
renderer.notificationThrows = true
audio.rejectLoad = true
const attention = createTuiAttention({ renderer, config: config(), audio })
renderer.emit("blur")
expect(await attention.notify({ message: "hello", sound: true })).toEqual({
ok: false,
notification: false,
sound: false,
})
})
test("strips unsafe notification text", async () => {
const renderer = new FakeRenderer()
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() })
renderer.emit("blur")
await attention.notify({
title: "\u001b[31m danger\n title\u0007",
message: "\u001b[32m hello\n world\u0000",
})
expect(renderer.notifications).toEqual([{ title: "danger title", message: "hello world" }])
})
test("disposes renderer listeners", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config(), audio })
renderer.emit("blur")
await attention.notify({ message: "hello", sound: true })
expect(renderer.listenerCount("focus")).toBe(1)
expect(renderer.listenerCount("blur")).toBe(1)
attention.dispose()
renderer.isDestroyed = true
expect(renderer.listenerCount("focus")).toBe(0)
expect(renderer.listenerCount("blur")).toBe(0)
expect(audio.loadCalls).toBe(1)
expect(await attention.notify({ message: "hello" })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "renderer_destroyed",
})
})
})

View File

@@ -0,0 +1,39 @@
import { afterEach, expect } from "bun:test"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect } from "effect"
import { fileURLToPath } from "url"
import { InstanceRef } from "../../src/effect/instance-ref"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(FSUtil.defaultLayer)
afterEach(async () => {
await disposeAllInstances()
})
it.live("effect-cmd.ts does not restore legacy instance ALS", () =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const source = yield* fs.readFileString(fileURLToPath(new URL("../../src/cli/effect-cmd.ts", import.meta.url)))
expect(source).not.toContain("restore(ctx")
}),
)
it.instance(
"InstanceRef remains the handler context across Effect promise awaits",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const ctx = yield* InstanceRef
if (!ctx) throw new Error("InstanceRef not provided")
const directory = yield* Effect.promise(async () => {
await Promise.resolve()
return ctx.directory
})
expect(directory).toBe(test.directory)
}),
{ git: true },
)

View File

@@ -0,0 +1,95 @@
import { describe, expect, test } from "bun:test"
import { AccountTransportError } from "../../src/account/schema"
import { FormatError } from "../../src/cli/error"
import { UI } from "../../src/cli/ui"
describe("cli.error", () => {
test("formats legacy and tagged config errors the same way", () => {
const cases = [
{
tag: "ConfigJsonError",
data: { path: "/tmp/opencode.jsonc", message: "Unexpected token" },
expected: "Config file at /tmp/opencode.jsonc is not valid JSON(C): Unexpected token",
},
{
tag: "ConfigDirectoryTypoError",
data: { path: "/tmp/opencode.jsonc", dir: ".opencode", suggestion: "opencode" },
expected:
'Directory ".opencode" in /tmp/opencode.jsonc is not valid. Rename the directory to "opencode" or remove it. This is a common typo.',
},
{
tag: "ConfigFrontmatterError",
data: { path: "/tmp/AGENTS.md", message: "failed frontmatter" },
expected: "failed frontmatter",
},
{
tag: "ConfigInvalidError",
data: {
path: "/tmp/opencode.jsonc",
message: "schema mismatch",
issues: [{ message: "Expected string", path: ["provider", "id"] }],
},
expected: "Configuration is invalid at /tmp/opencode.jsonc: schema mismatch\n↳ Expected string provider.id",
},
]
for (const item of cases) {
expect(FormatError({ name: item.tag, data: item.data })).toBe(item.expected)
expect(FormatError({ _tag: item.tag, ...item.data })).toBe(item.expected)
}
})
test("preserves multiline JSONC diagnostics for tagged config errors", () => {
const data = {
path: "/tmp/opencode.jsonc",
message:
'\n--- JSONC Input ---\n{\n "model": \n}\n--- Errors ---\nValueExpected at line 3, column 1\n Line 3: }\n ^\n--- End ---',
}
const expected = `Config file at ${data.path} is not valid JSON(C): ${data.message}`
expect(FormatError({ name: "ConfigJsonError", data })).toBe(expected)
expect(FormatError({ _tag: "ConfigJsonError", ...data })).toBe(expected)
})
test("formats account transport errors clearly", () => {
const error = new AccountTransportError({
method: "POST",
url: "https://console.opencode.ai/auth/device/code",
})
const formatted = FormatError(error)
expect(formatted).toContain("Could not reach POST https://console.opencode.ai/auth/device/code.")
expect(formatted).toContain("This failed before the server returned an HTTP response.")
expect(formatted).toContain("Check your network, proxy, or VPN configuration and try again.")
})
test("formats legacy and tagged provider model errors the same way", () => {
const data = {
providerID: "anthropic",
modelID: "claude-sonet-4",
suggestions: ["claude-sonnet-4"],
}
const expected = [
"Model not found: anthropic/claude-sonet-4",
"Did you mean: claude-sonnet-4",
"Try: `opencode models` to list available models",
"Or check your config (opencode.json) provider/model names",
].join("\n")
expect(FormatError({ name: "ProviderModelNotFoundError", data })).toBe(expected)
expect(FormatError({ _tag: "ProviderModelNotFoundError", ...data })).toBe(expected)
})
test("formats legacy and tagged provider init errors the same way", () => {
const data = { providerID: "anthropic" }
const expected = 'Failed to initialize provider "anthropic". Check credentials and configuration.'
expect(FormatError({ name: "ProviderInitError", data })).toBe(expected)
expect(FormatError({ _tag: "ProviderInitError", ...data })).toBe(expected)
})
test("formats cancelled UI errors as empty output", () => {
expect(FormatError(new UI.CancelledError())).toBe("")
})
})

View File

@@ -0,0 +1,199 @@
import { test, expect, describe } from "bun:test"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { extractResponseText, formatPromptTooLargeError } from "../../src/cli/cmd/github"
import type { MessageV2 } from "../../src/session/message-v2"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
// Helper to create minimal valid parts
function createTextPart(text: string): SessionV1.Part {
return {
id: PartID.ascending(),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "text" as const,
text,
}
}
function createReasoningPart(text: string): SessionV1.Part {
return {
id: PartID.ascending(),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "reasoning" as const,
text,
time: { start: 0 },
}
}
function createToolPart(tool: string, title: string, status: "completed" | "running" = "completed"): SessionV1.Part {
if (status === "completed") {
return {
id: PartID.ascending(),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "tool" as const,
callID: "c1",
tool,
state: {
status: "completed",
input: {},
output: "",
title,
metadata: {},
time: { start: 0, end: 1 },
},
}
}
return {
id: PartID.ascending(),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "tool" as const,
callID: "c1",
tool,
state: {
status: "running",
input: {},
time: { start: 0 },
},
}
}
function createStepStartPart(): SessionV1.Part {
return {
id: PartID.ascending(),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "step-start" as const,
}
}
function createStepFinishPart(): SessionV1.Part {
return {
id: PartID.ascending(),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "step-finish" as const,
reason: "done",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
}
describe("extractResponseText", () => {
test("returns text from text part", () => {
const parts = [createTextPart("Hello world")]
expect(extractResponseText(parts)).toBe("Hello world")
})
test("returns last text part when multiple exist", () => {
const parts = [createTextPart("First"), createTextPart("Last")]
expect(extractResponseText(parts)).toBe("Last")
})
test("returns text even when tool parts follow", () => {
const parts = [createTextPart("I'll help with that."), createToolPart("todowrite", "3 todos")]
expect(extractResponseText(parts)).toBe("I'll help with that.")
})
test("returns null for reasoning-only response (signals summary needed)", () => {
const parts = [createReasoningPart("Let me think about this...")]
expect(extractResponseText(parts)).toBeNull()
})
test("returns null for tool-only response (signals summary needed)", () => {
// This is the exact scenario from the bug report - todowrite with no text
const parts = [createToolPart("todowrite", "8 todos")]
expect(extractResponseText(parts)).toBeNull()
})
test("returns null for multiple completed tools", () => {
const parts = [
createToolPart("read", "src/file.ts"),
createToolPart("edit", "src/file.ts"),
createToolPart("bash", "bun test"),
]
expect(extractResponseText(parts)).toBeNull()
})
test("returns null for running tool parts (signals summary needed)", () => {
const parts = [createToolPart("bash", "", "running")]
expect(extractResponseText(parts)).toBeNull()
})
test("throws on empty array", () => {
expect(() => extractResponseText([])).toThrow("no parts returned")
})
test("returns null for step-start only", () => {
const parts = [createStepStartPart()]
expect(extractResponseText(parts)).toBeNull()
})
test("returns null for step-finish only", () => {
const parts = [createStepFinishPart()]
expect(extractResponseText(parts)).toBeNull()
})
test("returns null for step-start and step-finish", () => {
const parts = [createStepStartPart(), createStepFinishPart()]
expect(extractResponseText(parts)).toBeNull()
})
test("returns text from multi-step response", () => {
const parts = [
createStepStartPart(),
createToolPart("read", "src/file.ts"),
createTextPart("Done"),
createStepFinishPart(),
]
expect(extractResponseText(parts)).toBe("Done")
})
test("prefers text over reasoning when both present", () => {
const parts = [createReasoningPart("Internal thinking..."), createTextPart("Final answer")]
expect(extractResponseText(parts)).toBe("Final answer")
})
test("prefers text over tools when both present", () => {
const parts = [createToolPart("read", "src/file.ts"), createTextPart("Here's what I found")]
expect(extractResponseText(parts)).toBe("Here's what I found")
})
})
describe("formatPromptTooLargeError", () => {
test("formats error without files", () => {
const result = formatPromptTooLargeError([])
expect(result).toBe("PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.")
})
test("formats error with files (base64 content)", () => {
// Base64 is ~33% larger than original, so we multiply by 0.75 to get original size
// 400 KB base64 = 300 KB original, 200 KB base64 = 150 KB original
const files = [
{ filename: "screenshot.png", content: "a".repeat(400 * 1024) },
{ filename: "diagram.png", content: "b".repeat(200 * 1024) },
]
const result = formatPromptTooLargeError(files)
expect(result).toStartWith("PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.")
expect(result).toInclude("Files in prompt:")
expect(result).toInclude("screenshot.png (300 KB)")
expect(result).toInclude("diagram.png (150 KB)")
})
test("lists all files when multiple present", () => {
// Base64 sizes: 4KB -> 3KB, 8KB -> 6KB, 12KB -> 9KB
const files = [
{ filename: "img1.png", content: "x".repeat(4 * 1024) },
{ filename: "img2.jpg", content: "y".repeat(8 * 1024) },
{ filename: "img3.gif", content: "z".repeat(12 * 1024) },
]
const result = formatPromptTooLargeError(files)
expect(result).toInclude("img1.png (3 KB)")
expect(result).toInclude("img2.jpg (6 KB)")
expect(result).toInclude("img3.gif (9 KB)")
})
})

View File

@@ -0,0 +1,90 @@
import { test, expect } from "bun:test"
import { parseGitHubRemote } from "../../src/cli/cmd/github"
test("parses https URL with .git suffix", () => {
expect(parseGitHubRemote("https://github.com/sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" })
})
test("parses https URL without .git suffix", () => {
expect(parseGitHubRemote("https://github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" })
})
test("parses git@ URL with .git suffix", () => {
expect(parseGitHubRemote("git@github.com:sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" })
})
test("parses git@ URL without .git suffix", () => {
expect(parseGitHubRemote("git@github.com:sst/opencode")).toEqual({ owner: "sst", repo: "opencode" })
})
test("parses ssh:// URL with .git suffix", () => {
expect(parseGitHubRemote("ssh://git@github.com/sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" })
})
test("parses ssh:// URL without .git suffix", () => {
expect(parseGitHubRemote("ssh://git@github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" })
})
test("parses git protocol URLs from package metadata", () => {
expect(parseGitHubRemote("git://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" })
expect(parseGitHubRemote("git+https://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" })
expect(parseGitHubRemote("git+ssh://git@github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" })
})
test("parses npm-style github shorthand", () => {
expect(parseGitHubRemote("github:facebook/react")).toBeNull()
})
test("parses http URL", () => {
expect(parseGitHubRemote("http://github.com/owner/repo")).toEqual({ owner: "owner", repo: "repo" })
})
test("parses URL with hyphenated owner and repo names", () => {
expect(parseGitHubRemote("https://github.com/my-org/my-repo.git")).toEqual({ owner: "my-org", repo: "my-repo" })
})
test("parses URL with underscores in names", () => {
expect(parseGitHubRemote("git@github.com:my_org/my_repo.git")).toEqual({ owner: "my_org", repo: "my_repo" })
})
test("parses URL with numbers in names", () => {
expect(parseGitHubRemote("https://github.com/org123/repo456")).toEqual({ owner: "org123", repo: "repo456" })
})
test("parses repos with dots in the name", () => {
expect(parseGitHubRemote("https://github.com/socketio/socket.io.git")).toEqual({
owner: "socketio",
repo: "socket.io",
})
expect(parseGitHubRemote("https://github.com/vuejs/vue.js")).toEqual({
owner: "vuejs",
repo: "vue.js",
})
expect(parseGitHubRemote("git@github.com:mrdoob/three.js.git")).toEqual({
owner: "mrdoob",
repo: "three.js",
})
expect(parseGitHubRemote("https://github.com/jashkenas/backbone.git")).toEqual({
owner: "jashkenas",
repo: "backbone",
})
})
test("returns null for non-github URLs", () => {
expect(parseGitHubRemote("https://gitlab.com/owner/repo.git")).toBeNull()
expect(parseGitHubRemote("git@gitlab.com:owner/repo.git")).toBeNull()
expect(parseGitHubRemote("https://bitbucket.org/owner/repo")).toBeNull()
})
test("returns null for invalid URLs", () => {
expect(parseGitHubRemote("not-a-url")).toBeNull()
expect(parseGitHubRemote("")).toBeNull()
expect(parseGitHubRemote("github.com")).toBeNull()
expect(parseGitHubRemote("https://github.com/")).toBeNull()
expect(parseGitHubRemote("https://github.com/owner")).toBeNull()
})
test("returns null for URLs with extra path segments", () => {
expect(parseGitHubRemote("https://github.com/owner/repo/tree/main")).toBeNull()
expect(parseGitHubRemote("https://github.com/owner/repo/blob/main/file.ts")).toBeNull()
})

View File

@@ -0,0 +1,631 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode acp --help 1`] = `
"opencode acp
start ACP (Agent Client Protocol) server
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0)
[boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: opencode.local)
[string] [default: "opencode.local"]
--cors additional domains to allow for CORS [array] [default: []]
--cwd working directory [string] [default: "<HOME>"]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp --help 1`] = `
"opencode mcp
manage MCP (Model Context Protocol) servers
Commands:
opencode mcp add [name] add an MCP server
opencode mcp list list MCP servers and their status [aliases: ls]
opencode mcp auth [name] authenticate with an OAuth-enabled MCP server
opencode mcp logout [name] remove OAuth credentials for an MCP server
opencode mcp debug <name> debug OAuth connection for an MCP server
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode attach --help 1`] = `
"opencode attach <url>
attach to a running opencode server
Positionals:
url http://localhost:4096 [string] [required]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--dir directory to run in [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session when continuing (use with --continue or --session) [boolean]
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string]
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')[string]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode run --help 1`] = `
"opencode run [message..]
run opencode with a message
Positionals:
message message to send [array] [default: []]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--command the command to run, use message for args [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session before continuing (requires --continue or
--session) [boolean]
--share share the session [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--format format: default (formatted) or json (raw JSON events)
[string] [choices: "default", "json"] [default: "default"]
-f, --file file(s) to attach to message [array]
--title title for the session (uses truncated prompt if no value
provided) [string]
--attach attach to a running opencode server (e.g.,
http://localhost:4096) [string]
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD)
[string]
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or
'opencode') [string]
--dir directory to run in, path on remote server if attaching
[string]
--port port for the local server (defaults to random port if no value
provided) [number]
--variant model variant (provider-specific reasoning effort, e.g., high,
max, minimal) [string]
--thinking show thinking blocks [boolean]
--replay replay interactive session history on resume and after resize
(use --no-replay to disable) [boolean] [default: true]
--replay-limit cap visible interactive replay to the newest N messages
[number]
-i, --interactive run in direct interactive split-footer mode
[boolean] [default: false]
--dangerously-skip-permissions auto-approve permissions that are not explicitly denied
(dangerous!) [boolean] [default: false]
--demo enable direct interactive demo slash commands; pass one as the
message to run it immediately [boolean] [default: false]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode debug --help 1`] = `
"opencode debug
debugging and troubleshooting tools
Commands:
opencode debug config show resolved configuration
opencode debug lsp LSP debugging utilities
opencode debug rg ripgrep debugging utilities
opencode debug file file system debugging utilities
opencode debug scrap list all known projects
opencode debug skill list all available skills
opencode debug snapshot snapshot debugging utilities
opencode debug startup print startup timing
opencode debug agent <name> show agent configuration details
opencode debug v2 debug v2 catalog and built-in plugins
opencode debug info show debug information
opencode debug paths show global paths (data, config, cache, state)
opencode debug wait wait indefinitely (for debugging)
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers --help 1`] = `
"opencode providers
manage AI providers and credentials
Commands:
opencode providers list list providers and credentials [aliases: ls]
opencode providers login [url] log in to a provider
opencode providers logout [provider] log out from a configured provider
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode agent --help 1`] = `
"opencode agent
manage agents
Commands:
opencode agent create create a new agent
opencode agent list list all available agents
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode upgrade --help 1`] = `
"opencode upgrade [target]
upgrade opencode to the latest or a specific version
Positionals:
target version to upgrade to, for ex '0.1.48' or 'v0.1.48' [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
-m, --method installation method to use
[string] [choices: "curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode uninstall --help 1`] = `
"opencode uninstall
uninstall opencode and remove all related files
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
-c, --keep-config keep configuration files [boolean] [default: false]
-d, --keep-data keep session data and snapshots [boolean] [default: false]
--dry-run show what would be removed without removing [boolean] [default: false]
-f, --force skip confirmation prompts [boolean] [default: false]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode serve --help 1`] = `
"opencode serve
starts a headless opencode server
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0)
[boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: opencode.local)
[string] [default: "opencode.local"]
--cors additional domains to allow for CORS [array] [default: []]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode web --help 1`] = `
"opencode web
start opencode server and open web interface
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0)
[boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: opencode.local)
[string] [default: "opencode.local"]
--cors additional domains to allow for CORS [array] [default: []]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode models --help 1`] = `
"opencode models [provider]
list all available models
Positionals:
provider provider ID to filter models by [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--verbose use more verbose model output (includes metadata like costs) [boolean]
--refresh refresh the models cache from models.dev [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode stats --help 1`] = `
"opencode stats
show token usage and cost statistics
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--days show stats for the last N days (default: all time) [number]
--tools number of tools to show (default: all) [number]
--models show model statistics (default: hidden). Pass a number to show top N, otherwise
shows all
--project filter by project (default: all projects, empty string: current project)[string]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode export --help 1`] = `
"opencode export [sessionID]
export session data as JSON
Positionals:
sessionID session id to export [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--sanitize redact sensitive transcript and file data [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode import --help 1`] = `
"opencode import <file>
import session data from JSON file or URL
Positionals:
file path to JSON file or share URL [string] [required]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode github --help 1`] = `
"opencode github
manage GitHub agent
Commands:
opencode github install install the GitHub agent
opencode github run run the GitHub agent
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode pr --help 1`] = `
"opencode pr <number>
fetch and checkout a GitHub PR branch, then run opencode
Positionals:
number PR number to checkout [number] [required]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode session --help 1`] = `
"opencode session
manage sessions
Commands:
opencode session list list sessions
opencode session delete <sessionID> delete a session
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode plugin --help 1`] = `
"opencode plugin <module>
install plugin and update config
Positionals:
module npm module name [string] [required]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
-g, --global install in global config [boolean] [default: false]
-f, --force replace existing plugin version [boolean] [default: false]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode db --help 1`] = `
"opencode db
database tools
Commands:
opencode db [query] open an interactive sqlite3 shell or run a query [default]
opencode db path print the database path
Positionals:
query SQL query to execute [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--format Output format [string] [choices: "json", "tsv"] [default: "tsv"]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp list --help 1`] = `
"opencode mcp list
list MCP servers and their status
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp add --help 1`] = `
"opencode mcp add [name]
add an MCP server
Positionals:
name name of the MCP server [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--url URL for a remote MCP server [string]
--env environment variable for a local MCP server (KEY=VALUE) [array]
--header HTTP header for a remote MCP server (KEY=VALUE) [array]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp auth --help 1`] = `
"opencode mcp auth [name]
authenticate with an OAuth-enabled MCP server
Commands:
opencode mcp auth list list OAuth-capable MCP servers and their auth status [aliases: ls]
Positionals:
name name of the MCP server [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp logout --help 1`] = `
"opencode mcp logout [name]
remove OAuth credentials for an MCP server
Positionals:
name name of the MCP server [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers list --help 1`] = `
"opencode providers list
list providers and credentials
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers login --help 1`] = `
"opencode providers login [url]
log in to a provider
Positionals:
url opencode auth provider [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
-p, --provider provider id or name to log in to (skips provider selection) [string]
-m, --method login method label (skips method selection) [string]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers logout --help 1`] = `
"opencode providers logout [provider]
log out from a configured provider
Positionals:
provider provider id or name to log out from [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode agent create --help 1`] = `
"opencode agent create
create a new agent
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--path directory path to generate the agent file [string]
--description what the agent should do [string]
--mode agent mode [string] [choices: "all", "primary", "subagent"]
--permissions, --tools comma-separated list of permissions to allow (default: all).
Available: "bash, read, edit, glob, grep, webfetch, task, todowrite,
websearch, lsp, skill" [string]
-m, --model model to use in the format of provider/model [string]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode agent list --help 1`] = `
"opencode agent list
list all available agents
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode session list --help 1`] = `
"opencode session list
list sessions
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
-n, --max-count limit to N most recent sessions [number]
--format output format [string] [choices: "table", "json"] [default: "table"]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode session delete --help 1`] = `
"opencode session delete <sessionID>
delete a session
Positionals:
sessionID session ID to delete [string] [required]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode github install --help 1`] = `
"opencode github install
install the GitHub agent
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode github run --help 1`] = `
"opencode github run
run the GitHub agent
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--event GitHub mock event to run the agent for [string]
--token GitHub personal access token (github_pat_********) [string]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode db path --help 1`] = `
"opencode db path
print the database path
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;

View File

@@ -0,0 +1,137 @@
// Help-text snapshots for every CLI command + key subcommand. Catches
// accidental flag removals, renames, and reordering in a single sweep —
// any change to the user-visible CLI surface shows up here as a diff.
//
// This is the broad coverage layer that makes the future Effect CLI
// migration (yargs → effect-smol/cli) safe to attempt: if a refactor
// preserves the surface, the snapshots stay green; if it doesn't, the
// diff tells you exactly which command(s) changed.
//
// Snapshots are taken at COLUMNS=120 so wrapping is stable across
// terminal sizes. The default opencode tui command is excluded —
// `opencode --help` includes an ASCII banner that pulls in the install
// version (changes per release), so we'd snapshot a moving target.
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { EOL } from "os"
import { cliIt } from "../../lib/cli-process"
import { normalizeForSnapshot, PATH_SEP } from "../../lib/snapshot"
// Composes `normalizeForSnapshot` (CRLF + tmpdir) with two help-specific
// rules:
//
// 1. The harness's `oc-cli-XXX` subdir under TMPDIR collapses to `<HOME>`.
// `PATH_SEP` matches `/` and `\\` so the rule works on POSIX + Windows.
//
// 2. yargs wraps the `[string] [default: "..."]` clause based on the
// pre-normalized default's character length, so different random home
// path widths produce different leading-whitespace counts (or even
// line-wraps onto a fresh line on Windows). `\s+` matches both forms.
function normalize(text: string): string {
return normalizeForSnapshot(text, {
pathReplacements: [
// Mixed-case [A-Za-z0-9] because node's mkdtemp suffix is mixed-case
// (the harness now uses FileSystem.makeTempDirectoryScoped under the
// hood). A `[a-z0-9]+` regex would leave uppercase chars trailing.
[new RegExp(`<TMPDIR>${PATH_SEP}oc-cli-[A-Za-z0-9]+`, "g"), "<HOME>"],
[/\s+\[string\] \[default: "<HOME>"\]/g, ' [string] [default: "<HOME>"]'],
],
})
}
// Top-level commands. Order matches what `opencode --help` prints today;
// keep it in that order so the snapshot file reads as a table of contents.
// `completion` is intentionally excluded — it's a yargs built-in that emits
// top-level help on `--help` and exits 1; not a real opencode command.
const TOP_LEVEL = [
"acp",
"mcp",
"attach",
"run",
"debug",
"providers", // aliased to `auth`
"agent",
"upgrade",
"uninstall",
"serve",
"web",
"models",
"stats",
"export",
"import",
"github",
"pr",
"session",
"plugin",
"db",
] as const
// Subcommands worth pinning. Not exhaustive — the goal is one snapshot per
// distinct argv shape, not every leaf. Add new entries when a subcommand
// gains user-visible flags that we want to lock in.
const SUBCOMMANDS = [
["mcp", "list"],
["mcp", "add"],
["mcp", "auth"],
["mcp", "logout"],
["providers", "list"],
["providers", "login"],
["providers", "logout"],
["agent", "create"],
["agent", "list"],
["session", "list"],
["session", "delete"],
["github", "install"],
["github", "run"],
["db", "path"],
] as const
// Fixed wrap width so a developer's terminal doesn't affect snapshots.
// yargs honors COLUMNS; CI runners typically default to 80 which produces
// different wraps from a 200-col local terminal.
const SNAPSHOT_ENV = { COLUMNS: "120" }
describe("opencode CLI help-text snapshots", () => {
// Single test, parallel spawns. Each command's help fires under
// `concurrency: 8` — wall-clock stays under ~10s even for ~35 commands,
// versus ~1 minute if we serialized.
cliIt.live(
"every documented command emits stable help text",
({ opencode }) =>
Effect.gen(function* () {
const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV })
expect(topLevel.exitCode).toBe(0)
expect(topLevel.stderr.endsWith(EOL)).toBe(true)
const argvs: Array<readonly string[]> = [...TOP_LEVEL.map((c) => [c] as const), ...SUBCOMMANDS]
// Spawn in parallel, then assert in argv order so snapshot output is
// deterministic and per-command failures don't abort the rest of
// the sweep. `Effect.partition` is the canonical "run all, separate
// failures from successes" primitive — no mutable accumulator needed.
const [failures, results] = yield* Effect.partition(
argvs,
(argv) =>
Effect.gen(function* () {
const result = yield* opencode.spawn([...argv, "--help"], { env: SNAPSHOT_ENV })
if (result.exitCode !== 0) {
return yield* Effect.fail(`opencode ${argv.join(" ")}: exit ${result.exitCode}`)
}
return { argv, result }
}),
{ concurrency: 8 },
)
for (const { argv, result } of results) {
// yargs writes --help to stderr, not stdout. Snapshotting stderr
// means our test catches the help body; stdout for these commands
// is expected to be empty.
expect(normalize(result.stderr)).toMatchSnapshot(`opencode ${argv.join(" ")} --help`)
}
if (failures.length > 0) {
throw new Error(`Help text failed for:\n ${failures.join("\n ")}`)
}
}),
180_000,
)
})

View File

@@ -0,0 +1,54 @@
import { test, expect } from "bun:test"
import {
parseShareUrl,
shouldAttachShareAuthHeaders,
transformShareData,
type ShareData,
} from "../../src/cli/cmd/import"
// parseShareUrl tests
test("parses valid share URLs", () => {
expect(parseShareUrl("https://opncd.ai/share/Jsj3hNIW")).toBe("Jsj3hNIW")
expect(parseShareUrl("https://custom.example.com/share/abc123")).toBe("abc123")
expect(parseShareUrl("http://localhost:3000/share/test_id-123")).toBe("test_id-123")
})
test("rejects invalid URLs", () => {
expect(parseShareUrl("https://opncd.ai/s/Jsj3hNIW")).toBeNull() // legacy format
expect(parseShareUrl("https://opncd.ai/share/")).toBeNull()
expect(parseShareUrl("https://opncd.ai/share/id/extra")).toBeNull()
expect(parseShareUrl("not-a-url")).toBeNull()
})
test("only attaches share auth headers for same-origin URLs", () => {
expect(shouldAttachShareAuthHeaders("https://control.example.com/share/abc", "https://control.example.com")).toBe(
true,
)
expect(shouldAttachShareAuthHeaders("https://other.example.com/share/abc", "https://control.example.com")).toBe(false)
expect(shouldAttachShareAuthHeaders("https://control.example.com:443/share/abc", "https://control.example.com")).toBe(
true,
)
expect(shouldAttachShareAuthHeaders("not-a-url", "https://control.example.com")).toBe(false)
})
// transformShareData tests
test("transforms share data to storage format", () => {
const data: ShareData[] = [
{ type: "session", data: { id: "sess-1", title: "Test" } as any },
{ type: "message", data: { id: "msg-1", sessionID: "sess-1" } as any },
{ type: "part", data: { id: "part-1", messageID: "msg-1" } as any },
{ type: "part", data: { id: "part-2", messageID: "msg-1" } as any },
]
const result = transformShareData(data)!
expect(result.info.id).toBe("sess-1")
expect(result.messages).toHaveLength(1)
expect(result.messages[0].parts).toHaveLength(2)
})
test("returns null for invalid share data", () => {
expect(transformShareData([])).toBeNull()
expect(transformShareData([{ type: "message", data: {} as any }])).toBeNull()
expect(transformShareData([{ type: "session", data: { id: "s" } as any }])).toBeNull() // no messages
})

View File

@@ -0,0 +1,74 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { cliIt } from "../lib/cli-process"
describe("opencode mcp add (non-interactive subprocess)", () => {
cliIt.concurrent(
"adds a remote server with HTTP headers",
({ home, opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn([
"mcp",
"add",
"github",
"--url",
"https://example.com/mcp",
"--header",
"Authorization=Bearer {env:GITHUB_TOKEN}",
"--header",
"X-Option=one=two",
])
opencode.expectExit(result, 0)
const config = yield* Effect.promise(() =>
Bun.file(path.join(home, ".config", "opencode", "opencode.json")).json(),
)
expect(config.mcp.github).toEqual({
type: "remote",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer {env:GITHUB_TOKEN}",
"X-Option": "one=two",
},
})
}),
60_000,
)
cliIt.concurrent(
"adds a local server while preserving argv and environment values",
({ home, opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn([
"mcp",
"add",
"local",
"--env",
"API_KEY=secret",
"--env",
"VALUE=one=two",
"--",
"npx",
"-y",
"@example/server",
"--label",
"two words",
])
opencode.expectExit(result, 0)
const config = yield* Effect.promise(() =>
Bun.file(path.join(home, ".config", "opencode", "opencode.json")).json(),
)
expect(config.mcp.local).toEqual({
type: "local",
command: ["npx", "-y", "@example/server", "--label", "two words"],
environment: {
API_KEY: "secret",
VALUE: "one=two",
},
})
}),
60_000,
)
})

View File

@@ -0,0 +1,120 @@
import { test, expect, describe } from "bun:test"
import { resolvePluginProviders } from "../../src/cli/cmd/providers"
import type { Hooks } from "@opencode-ai/plugin"
function hookWithAuth(provider: string): Hooks {
return {
auth: {
provider,
methods: [],
},
}
}
function hookWithoutAuth(): Hooks {
return {}
}
describe("resolvePluginProviders", () => {
test("returns plugin providers not in models.dev", () => {
const result = resolvePluginProviders({
hooks: [hookWithAuth("portkey")],
existingProviders: {},
disabled: new Set(),
providerNames: {},
})
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
})
test("skips providers already in models.dev", () => {
const result = resolvePluginProviders({
hooks: [hookWithAuth("anthropic")],
existingProviders: { anthropic: {} },
disabled: new Set(),
providerNames: {},
})
expect(result).toEqual([])
})
test("deduplicates across plugins", () => {
const result = resolvePluginProviders({
hooks: [hookWithAuth("portkey"), hookWithAuth("portkey")],
existingProviders: {},
disabled: new Set(),
providerNames: {},
})
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
})
test("respects disabled_providers", () => {
const result = resolvePluginProviders({
hooks: [hookWithAuth("portkey")],
existingProviders: {},
disabled: new Set(["portkey"]),
providerNames: {},
})
expect(result).toEqual([])
})
test("respects enabled_providers when provider is absent", () => {
const result = resolvePluginProviders({
hooks: [hookWithAuth("portkey")],
existingProviders: {},
disabled: new Set(),
enabled: new Set(["anthropic"]),
providerNames: {},
})
expect(result).toEqual([])
})
test("includes provider when in enabled set", () => {
const result = resolvePluginProviders({
hooks: [hookWithAuth("portkey")],
existingProviders: {},
disabled: new Set(),
enabled: new Set(["portkey"]),
providerNames: {},
})
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
})
test("resolves name from providerNames", () => {
const result = resolvePluginProviders({
hooks: [hookWithAuth("portkey")],
existingProviders: {},
disabled: new Set(),
providerNames: { portkey: "Portkey AI" },
})
expect(result).toEqual([{ id: "portkey", name: "Portkey AI" }])
})
test("falls back to id when no name configured", () => {
const result = resolvePluginProviders({
hooks: [hookWithAuth("portkey")],
existingProviders: {},
disabled: new Set(),
providerNames: {},
})
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
})
test("skips hooks without auth", () => {
const result = resolvePluginProviders({
hooks: [hookWithoutAuth(), hookWithAuth("portkey"), hookWithoutAuth()],
existingProviders: {},
disabled: new Set(),
providerNames: {},
})
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
})
test("returns empty for no hooks", () => {
const result = resolvePluginProviders({
hooks: [],
existingProviders: {},
disabled: new Set(),
providerNames: {},
})
expect(result).toEqual([])
})
})

View File

@@ -0,0 +1,536 @@
import { describe, expect, test } from "bun:test"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { entryBody, entryCanStream, entryDone } from "@/cli/cmd/run/entry.body"
import type { StreamCommit, ToolSnapshot } from "@/cli/cmd/run/types"
function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
return input
}
function toolPart(tool: string, state: ToolPart["state"], id = `${tool}-1`, messageID = `msg-${tool}`): ToolPart {
return {
id,
sessionID: "session-1",
messageID,
type: "tool",
callID: `call-${id}`,
tool,
state,
} as ToolPart
}
function toolCommit(input: {
tool: string
state: ToolPart["state"]
phase?: StreamCommit["phase"]
toolState?: StreamCommit["toolState"]
text?: string
id?: string
messageID?: string
}) {
return commit({
kind: "tool",
text: input.text ?? "",
phase: input.phase ?? "final",
source: "tool",
tool: input.tool,
toolState: input.toolState ?? "completed",
part: toolPart(input.tool, input.state, input.id, input.messageID),
})
}
function structured(next: StreamCommit) {
const body = entryBody(next)
expect(body.type).toBe("structured")
if (body.type !== "structured") {
throw new Error("expected structured body")
}
return body.snapshot
}
describe("run entry body", () => {
test("renders assistant, reasoning, and user entries in their display formats", () => {
expect(
entryBody(
commit({
kind: "assistant",
text: "# Title\n\nHello **world**",
phase: "progress",
source: "assistant",
partID: "part-1",
}),
),
).toEqual({
type: "markdown",
content: "# Title\n\nHello **world**",
})
const reasoning = entryBody(
commit({
kind: "reasoning",
text: "Thinking: plan next steps",
phase: "progress",
source: "reasoning",
partID: "reason-1",
}),
)
expect(reasoning).toEqual({
type: "code",
filetype: "markdown",
content: "_Thinking:_ plan next steps",
})
expect(
entryCanStream(
commit({
kind: "reasoning",
text: "Thinking: plan next steps",
phase: "progress",
source: "reasoning",
}),
reasoning,
),
).toBe(true)
expect(
entryBody(
commit({
kind: "user",
text: "Inspect footer tabs",
phase: "start",
source: "system",
}),
),
).toEqual({
type: "text",
content: " Inspect footer tabs",
})
})
for (const item of [
{
name: "keeps completed write tool finals structured",
commit: toolCommit({
tool: "write",
state: {
status: "completed",
input: {
filePath: "src/a.ts",
content: "const x = 1\n",
},
output: "",
title: "",
metadata: {},
time: { start: 1, end: 2 },
},
}),
snapshot: {
kind: "code",
title: "# Wrote src/a.ts",
content: "const x = 1\n",
file: "src/a.ts",
},
},
{
name: "keeps completed edit tool finals structured",
commit: toolCommit({
tool: "edit",
state: {
status: "completed",
input: {
filePath: "src/a.ts",
},
output: "",
title: "",
metadata: {
diff: "@@ -1 +1 @@\n-old\n+new\n",
},
time: { start: 1, end: 2 },
},
}),
snapshot: {
kind: "diff",
items: [
{
title: "# Edited src/a.ts",
diff: "@@ -1 +1 @@\n-old\n+new\n",
file: "src/a.ts",
},
],
},
},
{
name: "keeps completed apply_patch tool finals structured",
commit: toolCommit({
tool: "apply_patch",
state: {
status: "completed",
input: {},
output: "",
title: "",
metadata: {
files: [
{
type: "update",
filePath: "src/a.ts",
relativePath: "src/a.ts",
patch: "@@ -1 +1 @@\n-old\n+new\n",
},
],
},
time: { start: 1, end: 2 },
},
}),
snapshot: {
kind: "diff",
items: [
{
title: "# Patched src/a.ts",
diff: "@@ -1 +1 @@\n-old\n+new\n",
file: "src/a.ts",
deletions: 0,
},
],
},
},
] satisfies Array<{ name: string; commit: StreamCommit; snapshot: ToolSnapshot }>) {
test(item.name, () => {
expect(structured(item.commit)).toEqual(item.snapshot)
})
}
test("keeps running task tool state out of scrollback", () => {
expect(
entryBody(
toolCommit({
tool: "task",
phase: "start",
toolState: "running",
text: "running inspect reducer",
state: {
status: "running",
input: {
description: "Inspect reducer",
subagent_type: "explore",
},
time: { start: 1 },
},
}),
),
).toEqual({
type: "none",
})
})
test("promotes task results to markdown and falls back to structured task summaries", () => {
expect(
entryBody(
toolCommit({
tool: "task",
state: {
status: "completed",
input: {
description: "Inspect reducer",
subagent_type: "explore",
},
title: "",
output: [
'<task id="child-1" state="completed">',
"<task_result>",
"# Findings\n\n- Footer stays live",
"</task_result>",
"</task>",
].join("\n"),
metadata: {
sessionId: "child-1",
},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
type: "markdown",
content: "# Findings\n\n- Footer stays live",
})
expect(
structured(
toolCommit({
tool: "task",
state: {
status: "completed",
input: {
description: "Inspect reducer",
subagent_type: "explore",
},
title: "",
output: ['<task id="child-1" state="completed">', "<task_result>", "", "</task_result>", "</task>"].join(
"\n",
),
metadata: {
sessionId: "child-1",
},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
kind: "task",
title: "# Explore Task",
rows: ["Inspect reducer"],
tail: "",
})
})
test("streams tool progress text and treats completed progress as done", () => {
const body = entryBody(
commit({
kind: "tool",
text: "partial output",
phase: "progress",
source: "tool",
tool: "bash",
partID: "tool-2",
}),
)
expect(body).toEqual({
type: "text",
content: "partial output",
})
expect(
entryCanStream(
commit({
kind: "tool",
text: "partial output",
phase: "progress",
source: "tool",
tool: "bash",
}),
body,
),
).toBe(true)
expect(
entryDone(
commit({
kind: "tool",
text: "output",
phase: "progress",
source: "tool",
tool: "bash",
toolState: "completed",
}),
),
).toBe(true)
})
test("formats completed bash output with a blank line after the command and no trailing blank row", () => {
expect(
entryBody(
toolCommit({
tool: "bash",
phase: "progress",
toolState: "completed",
text: ["/tmp/demo", "git status", "On branch demo", "nothing to commit, working tree clean", ""].join("\n"),
state: {
status: "completed",
input: {
command: "git status",
workdir: "/tmp/demo",
},
output: ["/tmp/demo", "git status", "On branch demo", "nothing to commit, working tree clean", ""].join(
"\n",
),
title: "git status",
metadata: {
exitCode: 0,
},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
type: "text",
content: "\nOn branch demo\nnothing to commit, working tree clean",
})
})
test("renders command-only bash starts without the shell header", () => {
expect(
entryBody(
toolCommit({
tool: "bash",
phase: "start",
toolState: "running",
text: "running shell",
state: {
status: "running",
input: {
command: "ls",
},
time: { start: 1 },
},
}),
),
).toEqual({
type: "text",
content: "$ ls",
})
})
test("renders direct shell commits without a synthetic shell header", () => {
expect(
entryBody(
commit({
kind: "tool",
text: "running shell",
phase: "start",
source: "tool",
tool: "bash",
partID: "shell:call-1",
toolState: "running",
shell: {
callID: "call-1",
command: "pwd",
},
}),
),
).toEqual({
type: "text",
content: "$ pwd",
})
expect(
entryBody(
commit({
kind: "tool",
text: "/tmp/demo\n",
phase: "progress",
source: "tool",
tool: "bash",
partID: "shell:call-1",
toolState: "completed",
shell: {
callID: "call-1",
command: "pwd",
},
}),
),
).toEqual({
type: "text",
content: "\n/tmp/demo",
})
})
test("falls back to patch summary when apply_patch has no visible diff items", () => {
expect(
entryBody(
toolCommit({
tool: "apply_patch",
state: {
status: "completed",
input: {
patchText: "*** Begin Patch\n*** End Patch",
},
output: "",
title: "",
metadata: {
files: [
{
type: "update",
filePath: "src/a.ts",
relativePath: "src/a.ts",
diff: "@@ -1 +1 @@\n-old\n+new\n",
},
],
},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
type: "text",
content: "~ Patched src/a.ts",
})
})
test("suppresses redundant patched rows when apply_patch also created a file", () => {
expect(
entryBody(
toolCommit({
tool: "apply_patch",
state: {
status: "completed",
input: {
patchText: "*** Begin Patch\n*** End Patch",
},
output: "",
title: "",
metadata: {
files: [
{
type: "update",
filePath: "src/a.ts",
relativePath: "src/a.ts",
diff: "@@ -1 +1 @@\n-old\n+new\n",
},
{
type: "add",
filePath: "README-demo.md",
relativePath: "README-demo.md",
},
],
},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
type: "text",
content: "+ Created README-demo.md",
})
})
test("renders glob failures as the raw error under the existing header", () => {
expect(
entryBody(
toolCommit({
tool: "glob",
phase: "final",
toolState: "error",
state: {
status: "error",
input: {
pattern: "**/*tool*",
path: "/tmp/demo/run",
},
error: "No such file or directory: '/tmp/demo/run'",
metadata: {},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
type: "text",
content: "No such file or directory: '/tmp/demo/run'",
})
})
test("renders interrupted assistant finals as text", () => {
expect(
entryBody(
commit({
kind: "assistant",
text: "",
phase: "final",
source: "assistant",
interrupted: true,
partID: "part-1",
}),
),
).toEqual({
type: "text",
content: "assistant interrupted",
})
})
})

View File

@@ -0,0 +1,43 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { FOOTER_MENU_ROWS, createFooterMenuState } from "@/cli/cmd/run/footer.menu"
function mount(count: number, limit = FOOTER_MENU_ROWS) {
let dispose!: () => void
let menu!: ReturnType<typeof createFooterMenuState>
createRoot((nextDispose) => {
dispose = nextDispose
menu = createFooterMenuState({ count: () => count, limit })
return null
})
return { menu, dispose }
}
test("footer menu scrolls before the selected row hits the bottom edge", () => {
const state = mount(20)
try {
Array.from({ length: 6 }).forEach(() => state.menu.move(1))
expect(state.menu.selected()).toBe(6)
expect(state.menu.offset()).toBe(1)
} finally {
state.dispose()
}
})
test("footer menu scrolls before the selected row hits the top edge", () => {
const state = mount(20)
try {
Array.from({ length: 13 }).forEach(() => state.menu.move(1))
Array.from({ length: 4 }).forEach(() => state.menu.move(-1))
expect(state.menu.selected()).toBe(9)
expect(state.menu.offset()).toBe(7)
} finally {
state.dispose()
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test"
import { footerWidthPolicy } from "@/cli/cmd/run/footer.width"
describe("run footer width", () => {
test("preserves shared dialog and statusline breakpoints", () => {
const narrow = footerWidthPolicy(79)
expect(narrow.dialog.narrow).toBe(true)
expect(narrow.statusline.showActivityMeta).toBe(false)
expect(narrow.statusline.showCommandHint).toBe(true)
expect(narrow.statusline.showContextHints).toBe(false)
expect(narrow.statusline.contextHintLimit).toBe(0)
expect(narrow.statusline.showModel).toBe(false)
const command = footerWidthPolicy(65)
expect(command.statusline.showCommandHint).toBe(false)
const commandHint = footerWidthPolicy(66)
expect(commandHint.statusline.showCommandHint).toBe(true)
const compact = footerWidthPolicy(80)
expect(compact.dialog.narrow).toBe(false)
expect(compact.statusline.showActivityMeta).toBe(true)
expect(compact.statusline.showContextHints).toBe(true)
expect(compact.statusline.contextHintLimit).toBe(1)
expect(compact.statusline.showModel).toBe(false)
const model = footerWidthPolicy(120)
expect(model.statusline.contextHintLimit).toBe(2)
expect(model.statusline.showModel).toBe(true)
const spacious = footerWidthPolicy(150)
expect(spacious.statusline.contextHintLimit).toBeUndefined()
expect(spacious.statusline.showModel).toBe(true)
})
})

View File

@@ -0,0 +1,144 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import {
createPermissionBodyState,
permissionAlwaysLines,
permissionCancel,
permissionEscape,
permissionInfo,
permissionReject,
permissionRun,
} from "@/cli/cmd/run/permission.shared"
function req(input: Partial<PermissionRequest> = {}): PermissionRequest {
return {
id: "perm-1",
sessionID: "session-1",
permission: "read",
patterns: [],
metadata: {},
always: [],
...input,
}
}
describe("run permission shared", () => {
test("replies immediately for allow once", () => {
const out = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "once")
expect(out.reply).toEqual({
requestID: "perm-1",
reply: "once",
})
})
test("requires confirmation for allow always", () => {
const next = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "always")
expect(next.state.stage).toBe("always")
expect(next.state.selected).toBe("confirm")
expect(next.reply).toBeUndefined()
expect(permissionRun(next.state, "perm-1", "confirm").reply).toEqual({
requestID: "perm-1",
reply: "always",
})
expect(permissionRun(next.state, "perm-1", "cancel").state).toMatchObject({
stage: "permission",
selected: "always",
})
})
test("builds trimmed reject replies and stage transitions", () => {
const next = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "reject")
expect(next.state.stage).toBe("reject")
const out = permissionReject({ ...next.state, message: " use rg " }, "perm-1")
expect(out).toEqual({
requestID: "perm-1",
reply: "reject",
message: "use rg",
})
expect(permissionCancel(next.state)).toMatchObject({
stage: "permission",
selected: "reject",
})
expect(permissionEscape(createPermissionBodyState("perm-1"))).toMatchObject({
stage: "reject",
selected: "reject",
})
expect(permissionEscape({ ...next.state, stage: "always", selected: "confirm" })).toMatchObject({
stage: "permission",
selected: "always",
})
})
test("maps supported permission types into display info", () => {
expect(
permissionInfo(
req({
permission: "bash",
metadata: {
input: {
command: "git status --short",
},
},
}),
),
).toMatchObject({
title: "Shell command",
lines: ["$ git status --short"],
})
expect(
permissionInfo(
req({
permission: "task",
metadata: {
description: "investigate stream",
subagent_type: "general",
},
}),
),
).toMatchObject({
title: "General Task",
lines: ["◉ investigate stream"],
})
expect(
permissionInfo(
req({
permission: "external_directory",
patterns: ["/tmp/work/**/*.ts", "/tmp/work/**/*.tsx"],
}),
),
).toMatchObject({
title: "Access external directory /tmp/work",
lines: ["- /tmp/work/**/*.ts", "- /tmp/work/**/*.tsx"],
})
expect(permissionInfo(req({ permission: "doom_loop" }))).toMatchObject({
title: "Continue after repeated failures",
})
expect(permissionInfo(req({ permission: "custom_tool" }))).toMatchObject({
title: "Call tool custom_tool",
lines: ["Tool: custom_tool"],
})
})
test("formats always-allow copy for wildcard and explicit patterns", () => {
expect(permissionAlwaysLines(req({ permission: "bash", always: ["*"] }))).toEqual([
"This will allow bash until OpenCode is restarted.",
])
expect(permissionAlwaysLines(req({ always: ["src/**/*.ts", "src/**/*.tsx"] }))).toEqual([
"This will allow the following patterns until OpenCode is restarted.",
"- src/**/*.ts",
"- src/**/*.tsx",
])
})
})

View File

@@ -0,0 +1,101 @@
import { describe, expect, test } from "bun:test"
import { realignEditorPromptParts, resolveEditorSlashValue } from "@/cli/cmd/run/prompt.editor"
import type { RunPromptPart } from "@/cli/cmd/run/types"
describe("run prompt editor helpers", () => {
test("strips the local /editor command from the initial editor text", () => {
expect(resolveEditorSlashValue("/editor")).toBe("")
expect(resolveEditorSlashValue("/editor draft message")).toBe("draft message")
expect(resolveEditorSlashValue("/editor first line\nsecond line")).toBe("first line\nsecond line")
})
test("realigns file and agent parts after external editing", () => {
const filePart = {
type: "file",
mime: "text/plain",
filename: "src/app.ts",
url: "file:///src/app.ts",
source: {
type: "file",
path: "src/app.ts",
text: {
start: 0,
end: 11,
value: "@src/app.ts",
},
},
} satisfies RunPromptPart
const agentPart = {
type: "agent",
name: "helper",
source: {
start: 12,
end: 19,
value: "@helper",
},
} satisfies RunPromptPart
const parts = [filePart, agentPart]
expect(realignEditorPromptParts("Please check @helper before @src/app.ts", parts)).toEqual([
{
...filePart,
source: {
...filePart.source,
text: {
...filePart.source.text,
start: 28,
end: 39,
value: "@src/app.ts",
},
},
},
{
...agentPart,
source: {
start: 13,
end: 20,
value: "@helper",
},
},
])
})
test("drops parts whose virtual text was deleted", () => {
const filePart = {
type: "file",
mime: "text/plain",
filename: "src/app.ts",
url: "file:///src/app.ts",
source: {
type: "file",
path: "src/app.ts",
text: {
start: 0,
end: 11,
value: "@src/app.ts",
},
},
} satisfies RunPromptPart
const agentPart = {
type: "agent",
name: "helper",
source: {
start: 12,
end: 19,
value: "@helper",
},
} satisfies RunPromptPart
const parts = [filePart, agentPart]
expect(realignEditorPromptParts("Only @helper remains", parts)).toEqual([
{
...agentPart,
source: {
start: 5,
end: 12,
value: "@helper",
},
},
])
})
})

View File

@@ -0,0 +1,101 @@
import { describe, expect, test } from "bun:test"
import {
createPromptHistory,
isExitCommand,
isNewCommand,
movePromptHistory,
pushPromptHistory,
} from "@/cli/cmd/run/prompt.shared"
import type { RunPrompt } from "@/cli/cmd/run/types"
function prompt(text: string, parts: RunPrompt["parts"] = []): RunPrompt {
return { text, parts }
}
describe("run prompt shared", () => {
test("filters blank prompts and dedupes consecutive history", () => {
const out = createPromptHistory([prompt(" "), prompt("one"), prompt("one"), prompt("two"), prompt("one")])
expect(out.items.map((item) => item.text)).toEqual(["one", "two", "one"])
expect(out.index).toBeNull()
expect(out.draft).toBe("")
})
test("push ignores blanks and dedupes only the latest item", () => {
const base = createPromptHistory([prompt("one")])
expect(pushPromptHistory(base, prompt(" ")).items.map((item) => item.text)).toEqual(["one"])
expect(pushPromptHistory(base, prompt("one")).items.map((item) => item.text)).toEqual(["one"])
expect(pushPromptHistory(base, prompt("two")).items.map((item) => item.text)).toEqual(["one", "two"])
})
test("moves through history only at input boundaries and restores draft", () => {
const base = createPromptHistory([prompt("one"), prompt("two")])
expect(movePromptHistory(base, -1, "draft", 1)).toEqual({
state: base,
apply: false,
})
const up = movePromptHistory(base, -1, "draft", 0)
expect(up.apply).toBe(true)
expect(up.text).toBe("two")
expect(up.cursor).toBe(0)
expect(up.state.index).toBe(1)
expect(up.state.draft).toBe("draft")
const older = movePromptHistory(up.state, -1, "two", 0)
expect(older.apply).toBe(true)
expect(older.text).toBe("one")
expect(older.cursor).toBe(0)
expect(older.state.index).toBe(0)
const newer = movePromptHistory(older.state, 1, "one", 3)
expect(newer.apply).toBe(true)
expect(newer.text).toBe("two")
expect(newer.cursor).toBe(3)
expect(newer.state.index).toBe(1)
const draft = movePromptHistory(newer.state, 1, "two", 3)
expect(draft.apply).toBe(true)
expect(draft.text).toBe("draft")
expect(draft.cursor).toBe(5)
expect(draft.state.index).toBeNull()
})
test("uses display-width cursors for history restoration", () => {
const base = createPromptHistory([prompt("one"), prompt("中文")])
const latest = movePromptHistory(base, -1, "草稿", 0)
expect(latest.apply).toBe(true)
expect(latest.text).toBe("中文")
expect(latest.cursor).toBe(0)
const older = movePromptHistory(latest.state, -1, "中文", 0)
expect(older.apply).toBe(true)
expect(older.text).toBe("one")
expect(older.cursor).toBe(0)
const newer = movePromptHistory(older.state, 1, "one", Bun.stringWidth("one"))
expect(newer.apply).toBe(true)
expect(newer.text).toBe("中文")
expect(newer.cursor).toBe(Bun.stringWidth("中文"))
const draft = movePromptHistory(newer.state, 1, "中文", Bun.stringWidth("中文"))
expect(draft.apply).toBe(true)
expect(draft.text).toBe("草稿")
expect(draft.cursor).toBe(Bun.stringWidth("草稿"))
})
test("recognizes exit commands", () => {
expect(isExitCommand("/exit")).toBe(true)
expect(isExitCommand(" /Quit ")).toBe(true)
expect(isExitCommand("/quit now")).toBe(false)
})
test("recognizes the new-session command", () => {
expect(isNewCommand("/new")).toBe(true)
expect(isNewCommand(" /NEW ")).toBe(true)
expect(isNewCommand("/new now")).toBe(false)
})
})

View File

@@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import {
createQuestionBodyState,
questionConfirm,
questionReject,
questionSave,
questionSelect,
questionSetSelected,
questionStoreCustom,
questionSubmit,
questionSync,
} from "@/cli/cmd/run/question.shared"
function req(input: Partial<QuestionRequest> = {}): QuestionRequest {
return {
id: "question-1",
sessionID: "session-1",
questions: [
{
question: "Mode?",
header: "Mode",
options: [{ label: "chunked", description: "Incremental output" }],
multiple: false,
},
],
...input,
}
}
describe("run question shared", () => {
test("replies immediately for a single-select question", () => {
const out = questionSelect(createQuestionBodyState("question-1"), req())
expect(out.reply).toEqual({
requestID: "question-1",
answers: [["chunked"]],
})
})
test("advances multi-question flows and submits from confirm", () => {
const ask = req({
questions: [
{
question: "Mode?",
header: "Mode",
options: [{ label: "chunked", description: "Incremental output" }],
multiple: false,
},
{
question: "Output?",
header: "Output",
options: [
{ label: "yes", description: "Show tool output" },
{ label: "no", description: "Hide tool output" },
],
multiple: false,
},
],
})
let state = questionSelect(createQuestionBodyState("question-1"), ask).state
expect(state.tab).toBe(1)
state = questionSetSelected(state, 1)
state = questionSelect(state, ask).state
expect(questionConfirm(ask, state)).toBe(true)
expect(questionSubmit(ask, state)).toEqual({
requestID: "question-1",
answers: [["chunked"], ["no"]],
})
})
test("toggles answers for multiple-choice questions", () => {
const ask = req({
questions: [
{
question: "Tags?",
header: "Tags",
options: [{ label: "bug", description: "Bug fix" }],
multiple: true,
},
],
})
let state = questionSelect(createQuestionBodyState("question-1"), ask).state
expect(state.answers).toEqual([["bug"]])
state = questionSelect(state, ask).state
expect(state.answers).toEqual([[]])
})
test("stores and submits custom answers", () => {
let state = questionSetSelected(createQuestionBodyState("question-1"), 1)
let next = questionSelect(state, req())
expect(next.state.editing).toBe(true)
state = questionStoreCustom(next.state, 0, " custom mode ")
next = questionSave(state, req())
expect(next.reply).toEqual({
requestID: "question-1",
answers: [["custom mode"]],
})
})
test("resets state when the request id changes and builds reject payloads", () => {
const state = questionSetSelected(createQuestionBodyState("question-1"), 1)
expect(questionSync(state, "question-1")).toBe(state)
expect(questionSync(state, "question-2")).toEqual(createQuestionBodyState("question-2"))
expect(questionReject(req())).toEqual({
requestID: "question-1",
})
})
})

View File

@@ -0,0 +1,84 @@
// Subprocess integration tests for `opencode run` (non-interactive mode).
// These exercise the real CLI binary against a TestLLMServer running in the
// same process. See `test/lib/cli-process.ts` for the harness — each test uses
// `opencode.run(message, opts?)` to spawn `bun src/index.ts run ...` with
// `OPENCODE_CONFIG_CONTENT` providing the test provider config inline.
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
describe("opencode run (non-interactive subprocess)", () => {
// Happy path: prompt completes, output reaches stdout, process exits 0.
// If this fails, all the others likely will too — debug here first.
cliIt.concurrent(
"exits 0 and writes the response to stdout on a successful prompt",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.text("hello from the test llm")
const result = yield* opencode.run("say hi")
opencode.expectExit(result, 0)
expect(result.stdout).toContain("hello from the test llm")
}),
60_000,
)
// Regression for #27371: an unknown model used to hang the process forever
// waiting on a session.status === idle event that never arrived. The fix
// makes the SDK call surface an error promptly so the process exits nonzero.
// We assert nonzero exit AND wall-clock under the harness timeout — a hang
// would expire the timeout and produce a different (signal-killed) failure.
cliIt.concurrent(
"exits nonzero promptly when the model is unknown (regression for #27371)",
({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.run("say hi", {
model: "test/nonexistent-model",
timeoutMs: 15_000,
})
expect(result.exitCode).not.toBe(0)
expect(result.durationMs).toBeLessThan(15_000)
}),
30_000,
)
// Locks in the current behavior: when the LLM stream errors mid-response
// (the prompt was accepted, then the upstream provider failed), opencode
// emits a session.error event and the process exits 0 today.
//
// This is debatable — a future cleanup might flip it to exit 1. If you're
// changing this expectation, do it deliberately and say so in the PR.
cliIt.concurrent(
"mid-stream LLM error still exits 0 today (contract lock-in)",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.fail("upstream provider exploded mid-stream")
const result = yield* opencode.run("trigger midstream error", { timeoutMs: 30_000 })
expect(result.exitCode).toBe(0)
}),
60_000,
)
// --format json puts one JSON object per line on stdout for each emitted
// event. Consumers (CI scripts, tooling) parse this stream. Asserts the
// shape so a future event-emit change has to update this expectation.
cliIt.concurrent(
"--format json emits parseable line-delimited JSON to stdout",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.text("structured output")
const result = yield* opencode.run("say hi", { format: "json" })
opencode.expectExit(result, 0)
const events = opencode.parseJsonEvents(result.stdout)
expect(events.length).toBeGreaterThan(0)
for (const evt of events) {
expect(typeof evt.type).toBe("string")
expect(typeof evt.sessionID).toBe("string")
}
// At least one `text` event should appear with the LLM's response.
const text = events.find((e) => e.type === "text")
expect(text).toBeDefined()
}),
60_000,
)
})

View File

@@ -0,0 +1,283 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
import type { Resolved } from "@opencode-ai/tui/config"
import { TuiConfig } from "@/config/tui"
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
function model(id: string, providerID: string, context: number, variants?: Record<string, Record<string, never>>) {
return {
id,
providerID,
api: {
id: providerID,
url: `https://${providerID}.test`,
npm: `@ai-sdk/${providerID}`,
},
name: id,
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context,
output: 8192,
},
status: "active" as const,
options: {},
headers: {},
release_date: "2026-01-01",
variants,
}
}
function config(input?: {
leader?: string
leaderTimeout?: number
diff_style?: "auto" | "stacked"
bindings?: Partial<{
commandList: string[]
variantCycle: string[]
interrupt: string[]
historyPrevious: string[]
historyNext: string[]
inputClear: string[]
inputSubmit: string[]
inputNewline: string[]
}>
}): Resolved {
const bind = input?.bindings
return createTuiResolvedConfig({
diff_style: input?.diff_style,
leader_timeout: input?.leaderTimeout,
keybinds: {
...(input?.leader && { leader: input.leader }),
...(bind?.commandList && { command_list: bind.commandList }),
...(bind?.variantCycle && { variant_cycle: bind.variantCycle }),
...(bind?.interrupt && { session_interrupt: bind.interrupt }),
...(bind?.historyPrevious && { history_previous: bind.historyPrevious }),
...(bind?.historyNext && { history_next: bind.historyNext }),
...(bind?.inputClear && { input_clear: bind.inputClear }),
...(bind?.inputSubmit && { input_submit: bind.inputSubmit }),
...(bind?.inputNewline && { input_newline: bind.inputNewline }),
},
})
}
describe("run runtime boot", () => {
afterEach(() => {
mock.restore()
})
test("reads footer keybinds from resolved keybind config", async () => {
spyOn(TuiConfig, "get").mockResolvedValue(
config({
leader: "ctrl+g",
bindings: {
commandList: ["ctrl+p"],
variantCycle: ["ctrl+t", "alt+t"],
interrupt: ["ctrl+c"],
historyPrevious: ["k"],
historyNext: ["j"],
inputClear: ["ctrl+l"],
inputSubmit: ["ctrl+s"],
inputNewline: ["alt+return"],
},
}),
)
const result = await resolveRunTuiConfig()
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g")
expect(result.leader_timeout).toBe(2000)
expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
expect(result.keybinds.get("variant.cycle").map((item) => item.key)).toEqual(["ctrl+t", "alt+t"])
expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("k")
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("j")
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+l")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("ctrl+s")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("alt+return")
})
test("falls back to default tui keymap config when config load fails", async () => {
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
const result = await resolveRunTuiConfig()
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+x")
expect(result.leader_timeout).toBe(2000)
expect(result.diff_style).toBe("auto")
expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
expect(result.keybinds.get("variant.cycle")?.[0]?.key).toBe("ctrl+t")
expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("escape")
expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("up")
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
})
test("preserves disabled leader from resolved tui config", async () => {
spyOn(TuiConfig, "get").mockResolvedValue(config({ leader: "none" }))
const result = await resolveRunTuiConfig()
expect(result.keybinds.get("leader")).toEqual([])
})
test("reads diff style and falls back to auto", async () => {
spyOn(TuiConfig, "get").mockResolvedValue(config({ diff_style: "stacked" }))
await expect(resolveDiffStyle()).resolves.toBe("stacked")
mock.restore()
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
await expect(resolveDiffStyle()).resolves.toBe("auto")
})
test("prefers configured providers for model selector data", async () => {
const sdk = new OpencodeClient()
const data: {
all: Provider[]
default: Record<string, string>
connected: string[]
} = {
all: [
{
id: "openai",
name: "OpenAI",
source: "api",
env: [],
options: {},
models: {
"gpt-5": model("gpt-5", "openai", 128000, {
high: {},
minimal: {},
}),
},
},
{
id: "anthropic",
name: "Anthropic",
source: "api",
env: [],
options: {},
models: {
sonnet: model("sonnet", "anthropic", 200000),
},
},
],
default: {},
connected: [],
}
const configured = {
providers: [data.all[0]!],
default: {},
}
const list = spyOn(sdk.provider, "list").mockImplementation(() =>
Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
spyOn(sdk.config, "providers").mockImplementation(() =>
Promise.resolve({
data: configured,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
providers: configured.providers,
variants: ["high", "minimal"],
limits: {
"openai/gpt-5": 128000,
},
})
expect(list).not.toHaveBeenCalled()
})
test("falls back to provider list when configured providers are unavailable", async () => {
const sdk = new OpencodeClient()
const data: {
all: Provider[]
default: Record<string, string>
connected: string[]
} = {
all: [
{
id: "openai",
name: "OpenAI",
source: "api",
env: [],
options: {},
models: {
"gpt-5": model("gpt-5", "openai", 128000, {
high: {},
minimal: {},
}),
},
},
{
id: "anthropic",
name: "Anthropic",
source: "api",
env: [],
options: {},
models: {
sonnet: model("sonnet", "anthropic", 200000),
},
},
],
default: {},
connected: [],
}
spyOn(sdk.config, "providers").mockRejectedValue(new Error("boom"))
spyOn(sdk.provider, "list").mockImplementation(() =>
Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
providers: data.all,
variants: ["high", "minimal"],
limits: {
"openai/gpt-5": 128000,
"anthropic/sonnet": 200000,
},
})
})
})

View File

@@ -0,0 +1,481 @@
import { describe, expect, test } from "bun:test"
import { runPromptQueue } from "@/cli/cmd/run/runtime.queue"
import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@/cli/cmd/run/types"
function footer() {
const prompts = new Set<(input: RunPrompt) => void>()
const queuedRemoves = new Set<(messageID: string) => void>()
const closes = new Set<() => void>()
const events: FooterEvent[] = []
const commits: StreamCommit[] = []
let closed = false
const api: FooterApi = {
get isClosed() {
return closed
},
onPrompt(fn) {
prompts.add(fn)
return () => {
prompts.delete(fn)
}
},
onQueuedRemove(fn) {
queuedRemoves.add(fn)
return () => {
queuedRemoves.delete(fn)
}
},
onClose(fn) {
if (closed) {
fn()
return () => {}
}
closes.add(fn)
return () => {
closes.delete(fn)
}
},
event(next) {
events.push(next)
},
append(next) {
commits.push(next)
},
idle() {
return Promise.resolve()
},
close() {
if (closed) {
return
}
closed = true
for (const fn of [...closes]) {
fn()
}
},
destroy() {
api.close()
prompts.clear()
closes.clear()
},
}
return {
api,
events,
commits,
submit(text: string, mode?: RunPrompt["mode"]) {
const next = mode ? { text, parts: [] as RunPrompt["parts"], mode } : { text, parts: [] as RunPrompt["parts"] }
for (const fn of [...prompts]) {
fn(next)
}
},
removeQueued(messageID: string) {
for (const fn of [...queuedRemoves]) fn(messageID)
},
}
}
describe("run runtime queue", () => {
test("ignores empty prompts", async () => {
const ui = footer()
let calls = 0
const task = runPromptQueue({
footer: ui.api,
run: async () => {
calls += 1
},
})
ui.submit(" ")
ui.api.close()
await task
expect(calls).toBe(0)
})
test("treats /exit as a close command", async () => {
const ui = footer()
let calls = 0
const task = runPromptQueue({
footer: ui.api,
run: async () => {
calls += 1
},
})
ui.submit("/exit")
await task
expect(calls).toBe(0)
})
test("treats /new as a local session command", async () => {
const ui = footer()
const seen: string[] = []
let created = 0
const task = runPromptQueue({
footer: ui.api,
onNewSession: async () => {
created += 1
},
run: async (input) => {
seen.push(input.text)
ui.api.close()
},
})
ui.submit("/new")
ui.submit("hello")
await task
expect(created).toBe(1)
expect(seen).toEqual(["hello"])
expect(ui.commits).toEqual([
{
kind: "user",
text: "hello",
phase: "start",
source: "system",
messageID: expect.any(String),
},
])
})
test("shell mode submits /exit as a shell command", async () => {
const ui = footer()
const seen: RunPrompt[] = []
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
seen.push(input)
ui.api.close()
},
})
ui.submit("/exit", "shell")
await task
expect(seen).toEqual([{ text: "/exit", parts: [], mode: "shell" }])
expect(ui.commits).toEqual([])
})
test("shell mode submits /new instead of creating a session", async () => {
const ui = footer()
const seen: RunPrompt[] = []
let created = 0
const task = runPromptQueue({
footer: ui.api,
onNewSession: async () => {
created += 1
},
run: async (input) => {
seen.push(input)
ui.api.close()
},
})
ui.submit("/new", "shell")
await task
expect(created).toBe(0)
expect(seen).toEqual([{ text: "/new", parts: [], mode: "shell" }])
expect(ui.commits).toEqual([])
})
test("shell mode does not append a synthetic user row", async () => {
const ui = footer()
const task = runPromptQueue({
footer: ui.api,
run: async () => {
expect(ui.commits).toEqual([])
ui.api.close()
},
})
ui.submit("ls", "shell")
await task
})
test("shell mode does not emit a turn duration summary", async () => {
const ui = footer()
const task = runPromptQueue({
footer: ui.api,
run: async () => {
ui.api.close()
},
})
ui.submit("ls", "shell")
await task
expect(ui.events.some((event) => event.type === "turn.duration")).toBe(false)
})
test("preserves whitespace for initial input", async () => {
const ui = footer()
const seen: string[] = []
await runPromptQueue({
footer: ui.api,
initialInput: " hello ",
run: async (input) => {
seen.push(input.text)
ui.api.close()
},
})
expect(seen).toEqual([" hello "])
expect(ui.commits).toEqual([
{
kind: "user",
text: " hello ",
phase: "start",
source: "system",
messageID: expect.any(String),
},
])
})
test("passes prompts to onSend", async () => {
const ui = footer()
const seen: string[] = []
await runPromptQueue({
footer: ui.api,
initialInput: " hello ",
onSend: (input) => {
seen.push(input.text)
},
run: async () => {
ui.api.close()
},
})
expect(seen).toEqual([" hello "])
})
test("appends the user row before the turn starts", async () => {
const ui = footer()
await runPromptQueue({
footer: ui.api,
initialInput: "/fmt bash",
run: async () => {
expect(ui.commits).toEqual([
{
kind: "user",
text: "/fmt bash",
phase: "start",
source: "system",
messageID: expect.any(String),
},
])
ui.api.close()
},
})
})
test("runs queued prompts in order", async () => {
const ui = footer()
const seen: string[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
seen.push(input.text)
if (seen.length === 1) {
await gate
return
}
ui.api.close()
},
})
ui.submit("one")
ui.submit("two")
await Promise.resolve()
expect(seen).toEqual(["one"])
wake?.()
await task
expect(seen).toEqual(["one", "two"])
})
test("exposes ordinary in-flight prompts for removal before sending", async () => {
const ui = footer()
const turns: RunPrompt[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
turns.push(input)
await gate
},
})
ui.submit("one")
ui.submit("two")
await Promise.resolve()
await Promise.resolve()
expect(turns.map((item) => item.text)).toEqual(["one"])
expect(turns[0]?.messageID).toEqual(expect.any(String))
expect(ui.commits.map((item) => item.text)).toEqual(["one"])
const first = ui.events.find((item) => item.type === "queued.prompts")
const event = ui.events.findLast((item) => item.type === "queued.prompts")
expect(first?.type === "queued.prompts" ? first.prompts : []).toEqual([])
expect(
first?.type === "queued.prompts" && event?.type === "queued.prompts" ? first.prompts === event.prompts : true,
).toBe(false)
expect(ui.events.findLast((item) => item.type === "queue")).toEqual({ type: "queue", queue: 1 })
expect(event?.type === "queued.prompts" ? event.prompts.map((item) => item.prompt.text) : []).toEqual(["two"])
if (event?.type === "queued.prompts") ui.removeQueued(event.prompts[0]!.messageID)
await Promise.resolve()
wake?.()
ui.api.close()
await task
expect(turns.map((item) => item.text)).toEqual(["one"])
})
test("removing one managed queued prompt preserves the others", async () => {
const ui = footer()
const turns: string[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
turns.push(input.text)
if (input.text === "active") await gate
if (input.text === "queued three") ui.api.close()
},
})
ui.submit("active")
ui.submit("queued one")
ui.submit("queued two")
ui.submit("queued three")
await Promise.resolve()
await Promise.resolve()
const event = ui.events.findLast((item) => item.type === "queued.prompts")
if (event?.type === "queued.prompts") {
const second = event.prompts.find((item) => item.prompt.text === "queued two")
if (second) ui.removeQueued(second.messageID)
}
wake?.()
await task
expect(turns).toEqual(["active", "queued one", "queued three"])
})
test("drains a prompt queued during an in-flight turn", async () => {
const ui = footer()
const seen: string[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
seen.push(input.text)
if (seen.length === 1) {
await gate
return
}
ui.api.close()
},
})
ui.submit("one")
await Promise.resolve()
expect(seen).toEqual(["one"])
wake?.()
await Promise.resolve()
ui.submit("two")
await task
expect(seen).toEqual(["one", "two"])
})
test("close aborts the active run and drops pending queued work", async () => {
const ui = footer()
const seen: string[] = []
let hit = false
const task = runPromptQueue({
footer: ui.api,
run: async (input, signal) => {
seen.push(input.text)
await new Promise<void>((resolve) => {
if (signal.aborted) {
hit = true
resolve()
return
}
signal.addEventListener(
"abort",
() => {
hit = true
resolve()
},
{ once: true },
)
})
},
})
ui.submit("one")
await Promise.resolve()
ui.submit("two")
ui.api.close()
await task
expect(hit).toBe(true)
expect(seen).toEqual(["one"])
})
test("propagates run errors", async () => {
const ui = footer()
const task = runPromptQueue({
footer: ui.api,
run: async () => {
throw new Error("boom")
},
})
ui.submit("one")
await expect(task).rejects.toThrow("boom")
})
})

View File

@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test"
import { Readable } from "node:stream"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "@/cli/cmd/run/runtime.stdin"
function stream(isTTY: boolean) {
return Object.assign(new Readable({ read() {} }), { isTTY }) as NodeJS.ReadStream
}
describe("run interactive stdin", () => {
test("reuses stdin when it is already a tty", () => {
const stdin = stream(true)
const seen: string[] = []
const result = resolveInteractiveStdin(
stdin,
(path) => {
seen.push(path)
return stream(true)
},
"linux",
)
expect(result.stdin).toBe(stdin)
expect(result.cleanup).toBeUndefined()
expect(seen).toEqual([])
})
test("opens the controlling terminal when stdin is piped", () => {
const tty = stream(true)
const seen: string[] = []
const result = resolveInteractiveStdin(
stream(false),
(path) => {
seen.push(path)
return tty
},
"linux",
)
expect(result.stdin).toBe(tty)
expect(seen).toEqual(["/dev/tty"])
result.cleanup?.()
expect(tty.destroyed).toBe(true)
})
test("uses CONIN$ on windows", () => {
const seen: string[] = []
resolveInteractiveStdin(
stream(false),
(path) => {
seen.push(path)
return stream(true)
},
"win32",
)
expect(seen).toEqual(["CONIN$"])
})
test("throws a clear error when no controlling terminal is available", () => {
expect(() =>
resolveInteractiveStdin(
stream(false),
() => {
throw new Error("open failed")
},
"linux",
),
).toThrow(INTERACTIVE_INPUT_ERROR)
})
})

View File

@@ -0,0 +1,238 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2"
import { runInteractiveMode } from "@/cli/cmd/run/runtime"
import type { FooterApi, RunProvider } from "@/cli/cmd/run/types"
type SessionMessage = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["messages"]>>["data"]>[number]
const provider: RunProvider = {
id: "openai",
name: "OpenAI",
source: "api",
env: [],
options: {},
models: {
"gpt-5": {
id: "gpt-5",
providerID: "openai",
api: {
id: "openai",
url: "https://openai.test",
npm: "@ai-sdk/openai",
},
name: "Little Frank",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 128000,
output: 8192,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
},
},
}
const transportProviders: RunProvider[][] = []
function defer<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
function ok<T>(data: T) {
return Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
})
}
function footer(): FooterApi {
let closed = false
const closes = new Set<() => void>()
const notify = () => {
for (const fn of closes) fn()
}
return {
get isClosed() {
return closed
},
onPrompt: () => () => {},
onQueuedRemove: () => () => {},
onClose(fn) {
if (closed) {
fn()
return () => {}
}
closes.add(fn)
return () => {
closes.delete(fn)
}
},
event() {},
append() {},
idle() {
return Promise.resolve()
},
close() {
if (closed) {
return
}
closed = true
notify()
},
destroy() {
if (closed) {
return
}
closed = true
notify()
},
}
}
afterEach(() => {
mock.restore()
transportProviders.length = 0
})
describe("run interactive runtime", () => {
test("waits for provider metadata before eager replay transport bootstrap", async () => {
const providersStarted = defer<void>()
const providers = defer<void>()
const sdk = new OpencodeClient()
spyOn(sdk.config, "providers").mockImplementation(async () => {
providersStarted.resolve()
await providers.promise
return ok({ providers: [provider], default: {} })
})
spyOn(sdk.session, "messages").mockImplementation(() =>
ok([
{
info: {
id: "msg-user-1",
sessionID: "ses-1",
role: "user",
time: {
created: 1,
},
agent: "build",
model: {
providerID: "openai",
modelID: "gpt-5",
variant: undefined,
},
},
parts: [
{
id: "part-user-1",
sessionID: "ses-1",
messageID: "msg-user-1",
type: "text",
text: "hello",
},
],
} satisfies SessionMessage,
]),
)
spyOn(sdk.session, "get").mockRejectedValue(new Error("not needed"))
spyOn(sdk.app, "agents").mockImplementation(() => ok([]))
spyOn(sdk.experimental.resource, "list").mockImplementation(() => ok({}))
spyOn(sdk.command, "list").mockImplementation(() => ok([]))
const task = runInteractiveMode(
{
sdk,
directory: "/tmp",
sessionID: "ses-1",
sessionTitle: "Session",
resume: true,
replay: true,
replayLimit: 100,
agent: "build",
model: {
providerID: "openai",
modelID: "gpt-5",
},
variant: undefined,
files: [],
thinking: true,
backgroundSubagents: false,
},
{
createRuntimeLifecycle: async () => ({
footer: footer(),
onResize: () => () => {},
refreshTheme: () => {},
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}),
streamTransport: Promise.resolve({
createSessionTransport: async (input: { providers?: () => RunProvider[]; footer: FooterApi }) => {
transportProviders.push(input.providers?.() ?? [])
setTimeout(() => {
input.footer.close()
}, 0)
return {
runPromptTurn: async () => {},
selectSubagent: () => {},
replayOnResize: async () => false,
close: async () => {},
}
},
formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
}),
},
)
await providersStarted.promise
expect(transportProviders).toEqual([])
providers.resolve()
await task
expect(transportProviders).toEqual([[provider]])
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,595 @@
import { describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2"
import { createSessionData, flushInterrupted, reduceSessionData } from "@/cli/cmd/run/session-data"
import type { StreamCommit } from "@/cli/cmd/run/types"
function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = true) {
return reduceSessionData({
data,
event: event as Event,
sessionID: "session-1",
thinking,
limits: {},
})
}
function assistant(id: string, extra: Record<string, unknown> = {}) {
return {
type: "message.updated",
properties: {
sessionID: "session-1",
info: {
id,
role: "assistant",
providerID: "openai",
modelID: "gpt-5",
tokens: {
input: 1,
output: 1,
reasoning: 0,
cache: { read: 0, write: 0 },
},
...extra,
},
},
}
}
function user(id: string) {
return {
type: "message.updated",
properties: {
sessionID: "session-1",
info: {
id,
role: "user",
},
},
}
}
function text(input: { id: string; messageID: string; text: string; time?: Record<string, number> }) {
return {
type: "message.part.updated",
properties: {
part: {
id: input.id,
messageID: input.messageID,
sessionID: "session-1",
type: "text",
text: input.text,
...(input.time ? { time: input.time } : {}),
},
},
}
}
function reasoning(input: { id: string; messageID: string; text: string; time?: Record<string, number> }) {
return {
type: "message.part.updated",
properties: {
part: {
id: input.id,
messageID: input.messageID,
sessionID: "session-1",
type: "reasoning",
text: input.text,
...(input.time ? { time: input.time } : {}),
},
},
}
}
function delta(messageID: string, partID: string, value: string) {
return {
type: "message.part.delta",
properties: {
sessionID: "session-1",
messageID,
partID,
field: "text",
delta: value,
},
}
}
function tool(input: { id: string; messageID: string; tool: string; state: Record<string, unknown>; callID?: string }) {
return {
type: "message.part.updated",
properties: {
part: {
id: input.id,
messageID: input.messageID,
sessionID: "session-1",
type: "tool",
tool: input.tool,
...(input.callID ? { callID: input.callID } : {}),
state: input.state,
},
},
}
}
describe("run session data", () => {
test("buffers delayed assistant text until the role is known", () => {
let data = createSessionData()
data = reduce(data, delta("msg-1", "txt-1", "hello")).data
data = reduce(data, assistant("msg-1")).data
const out = reduce(
data,
text({
id: "txt-1",
messageID: "msg-1",
text: "",
time: { end: 1 },
}),
)
expect(out.commits).toEqual([
expect.objectContaining({
kind: "assistant",
text: "hello",
partID: "txt-1",
}),
])
})
test("keeps leading whitespace buffered until real assistant content arrives", () => {
let data = createSessionData()
data = reduce(data, assistant("msg-1")).data
data = reduce(data, text({ id: "txt-1", messageID: "msg-1", text: "", time: { start: 1 } })).data
let out = reduce(data, delta("msg-1", "txt-1", " "))
expect(out.commits).toEqual([])
out = reduce(out.data, delta("msg-1", "txt-1", "Found"))
expect(out.commits).toEqual([
expect.objectContaining({
kind: "assistant",
text: " Found",
}),
])
})
test("drops delayed text once the message resolves to a user role", () => {
let data = createSessionData()
data = reduce(data, text({ id: "txt-user-1", messageID: "msg-user-1", text: "HELLO", time: { end: 1 } })).data
const out = reduce(data, user("msg-user-1"))
expect(out.commits).toEqual([])
expect(out.data.ids.has("txt-user-1")).toBe(true)
})
test("suppresses reasoning commits when thinking is disabled", () => {
const out = reduce(
createSessionData(),
reasoning({
id: "reason-1",
messageID: "msg-1",
text: "hidden",
time: { end: 1 },
}),
false,
)
expect(out.commits).toEqual([])
expect(out.data.ids.has("reason-1")).toBe(true)
})
test("keeps permission precedence over queued questions", () => {
let data = createSessionData()
data = reduce(data, {
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "session-1",
permission: "read",
patterns: ["/tmp/file.txt"],
metadata: {},
always: [],
},
}).data
const ask = reduce(data, {
type: "question.asked",
properties: {
id: "question-1",
sessionID: "session-1",
questions: [
{
question: "Mode?",
header: "Mode",
options: [{ label: "chunked", description: "Incremental output" }],
multiple: false,
},
],
},
})
expect(ask.footer).toEqual({
patch: { status: "awaiting permission" },
view: {
type: "permission",
request: expect.objectContaining({ id: "perm-1" }),
},
})
expect(
reduce(ask.data, {
type: "permission.replied",
properties: {
sessionID: "session-1",
requestID: "perm-1",
reply: "reject",
},
}).footer,
).toEqual({
patch: { status: "awaiting answer" },
view: {
type: "question",
request: expect.objectContaining({ id: "question-1" }),
},
})
})
test("refreshes the active permission view when tool input arrives later", () => {
const data = reduce(createSessionData(), {
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "session-1",
permission: "bash",
patterns: ["src/**/*.ts"],
metadata: {},
always: [],
tool: {
messageID: "msg-1",
callID: "call-1",
},
},
}).data
const out = reduce(
data,
tool({
id: "tool-1",
messageID: "msg-1",
callID: "call-1",
tool: "bash",
state: {
status: "running",
input: {
command: "git status --short",
},
},
}),
)
expect(out.footer).toEqual({
view: {
type: "permission",
request: expect.objectContaining({
id: "perm-1",
metadata: expect.objectContaining({
input: {
command: "git status --short",
},
}),
}),
},
})
})
test("strips bash echo only from the first assistant flush", () => {
let data = createSessionData()
data = reduce(data, assistant("msg-1")).data
data = reduce(
data,
tool({
id: "tool-1",
messageID: "msg-1",
tool: "bash",
state: {
status: "completed",
input: {
command: "printf hi",
},
output: "echoed\n",
time: { start: 1, end: 2 },
},
}),
).data
const first = reduce(
data,
text({
id: "txt-1",
messageID: "msg-1",
text: "echoed\nanswer",
}),
)
expect(first.commits).toEqual([
expect.objectContaining({
kind: "assistant",
text: "answer",
}),
])
expect(reduce(first.data, delta("msg-1", "txt-1", "\nechoed\nagain")).commits).toEqual([
expect.objectContaining({
kind: "assistant",
text: "\nechoed\nagain",
}),
])
})
test("renders direct shell mode from first-class shell events", () => {
let data = createSessionData()
const started = reduce(data, {
type: "session.next.shell.started",
properties: {
sessionID: "session-1",
timestamp: 1,
callID: "call-1",
command: "pwd",
},
})
expect(started.commits).toEqual([
expect.objectContaining({
kind: "tool",
phase: "start",
partID: "shell:call-1",
tool: "bash",
shell: {
callID: "call-1",
command: "pwd",
},
}),
])
data = started.data
const ended = reduce(data, {
type: "session.next.shell.ended",
properties: {
sessionID: "session-1",
timestamp: 2,
callID: "call-1",
output: "/tmp/demo\n",
},
})
expect(ended.commits).toEqual([
expect.objectContaining({
kind: "tool",
phase: "progress",
partID: "shell:call-1",
tool: "bash",
text: "/tmp/demo\n",
toolState: "completed",
shell: {
callID: "call-1",
command: "pwd",
},
}),
])
})
test("suppresses legacy bash part updates once shell events claim the call", () => {
let data = reduce(createSessionData(), {
type: "session.next.shell.started",
properties: {
sessionID: "session-1",
timestamp: 1,
callID: "call-1",
command: "pwd",
},
}).data
expect(
reduce(
data,
tool({
id: "tool-1",
messageID: "msg-1",
callID: "call-1",
tool: "bash",
state: {
status: "running",
input: {
command: "pwd",
},
time: { start: 1 },
},
}),
).commits,
).toEqual([])
data = reduce(data, {
type: "session.next.shell.ended",
properties: {
sessionID: "session-1",
timestamp: 2,
callID: "call-1",
output: "/tmp/demo\n",
},
}).data
expect(
reduce(
data,
tool({
id: "tool-1",
messageID: "msg-1",
callID: "call-1",
tool: "bash",
state: {
status: "completed",
input: {
command: "pwd",
},
output: "/tmp/demo\n",
title: "",
metadata: {
output: "/tmp/demo\n",
description: "",
},
time: { start: 1, end: 2 },
},
}),
).commits,
).toEqual([])
})
test("suppresses shell events when the legacy bash part claimed the call first", () => {
let data = reduce(
createSessionData(),
tool({
id: "tool-1",
messageID: "msg-1",
callID: "call-1",
tool: "bash",
state: {
status: "running",
input: {
command: "pwd",
},
time: { start: 1 },
},
}),
).data
expect(
reduce(data, {
type: "session.next.shell.started",
properties: {
sessionID: "session-1",
timestamp: 1,
callID: "call-1",
command: "pwd",
},
}).commits,
).toEqual([])
data = reduce(
data,
tool({
id: "tool-1",
messageID: "msg-1",
callID: "call-1",
tool: "bash",
state: {
status: "completed",
input: {
command: "pwd",
},
output: "/tmp/demo\n",
title: "",
metadata: {
output: "/tmp/demo\n",
description: "",
},
time: { start: 1, end: 2 },
},
}),
).data
expect(
reduce(data, {
type: "session.next.shell.ended",
properties: {
sessionID: "session-1",
timestamp: 2,
callID: "call-1",
output: "/tmp/demo\n",
},
}).commits,
).toEqual([])
})
test("synthesizes a glob start before an error when the running update is missed", () => {
expect(
reduce(
createSessionData(),
tool({
id: "tool-1",
messageID: "msg-1",
tool: "glob",
state: {
status: "error",
input: {
pattern: "**/*tool*",
path: "/tmp/demo/run",
},
error: "No such file or directory: '/tmp/demo/run'",
},
}),
).commits,
).toEqual([
expect.objectContaining({
kind: "tool",
tool: "glob",
phase: "start",
partID: "tool-1",
text: "running glob",
toolState: "running",
}),
expect.objectContaining({
kind: "tool",
tool: "glob",
phase: "final",
partID: "tool-1",
text: "No such file or directory: '/tmp/demo/run'",
toolState: "error",
toolError: "No such file or directory: '/tmp/demo/run'",
}),
])
})
test("flushInterrupted emits one interrupted final per live part", () => {
const data = reduce(
createSessionData(),
text({
id: "txt-1",
messageID: "msg-1",
text: "unfinished",
}),
).data
const first: StreamCommit[] = []
flushInterrupted(data, first)
expect(first).toEqual([
expect.objectContaining({ kind: "assistant", text: "unfinished", phase: "progress" }),
expect.objectContaining({ kind: "assistant", phase: "final", interrupted: true }),
])
const next: StreamCommit[] = []
flushInterrupted(data, next)
expect(next).toEqual([])
})
test("surfaces session errors as error commits", () => {
const out = reduce(createSessionData(), {
type: "session.error",
properties: {
sessionID: "session-1",
error: {
name: "UnknownError",
data: {
message: "permission denied",
},
},
},
})
expect(out.commits).toEqual([
expect.objectContaining({
kind: "error",
text: "permission denied",
}),
])
})
})

View File

@@ -0,0 +1,692 @@
import { describe, expect, test } from "bun:test"
import { replayLocalRows, replaySession } from "@/cli/cmd/run/session-replay"
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
import type { RunProvider } from "@/cli/cmd/run/types"
function userMessage(id: string, text: string): SessionMessages[number] {
return {
info: {
id,
sessionID: "session-1",
role: "user",
time: {
created: 1,
},
agent: "build",
model: {
providerID: "openai",
modelID: "gpt-5",
},
},
parts: [
{
id: `${id}-text`,
sessionID: "session-1",
messageID: id,
type: "text",
text,
},
],
}
}
function assistantInfo(
id: string,
input: {
parentID?: string
modelID?: string
providerID?: string
time?: { created: number; completed?: number }
} = {},
) {
return {
id,
sessionID: "session-1",
role: "assistant" as const,
time: input.time ?? { created: 2 },
parentID: input.parentID ?? "msg-user-1",
modelID: input.modelID ?? "gpt-5",
providerID: input.providerID ?? "openai",
mode: "chat",
agent: "build",
path: {
cwd: "/tmp",
root: "/tmp",
},
cost: 0,
tokens: {
input: 1,
output: 1,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
}
}
function assistantMessage(
id: string,
text: string,
input: {
parentID?: string
modelID?: string
providerID?: string
time?: { created: number; completed?: number }
} = {},
): SessionMessages[number] {
const time = input.time ?? {
created: 200,
completed: 3000,
}
return {
info: assistantInfo(id, {
...input,
time,
}),
parts: [
{
id: `${id}-text`,
sessionID: "session-1",
messageID: id,
type: "text",
text,
time: {
start: time.created,
end: time.completed,
},
},
],
}
}
const provider = (name: string): RunProvider => ({
id: "openai",
name: "OpenAI",
source: "api",
env: [],
options: {},
models: {
"gpt-5": {
id: "gpt-5",
providerID: "openai",
api: {
id: "openai",
url: "https://openai.test",
npm: "@ai-sdk/openai",
},
name,
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 128000,
output: 8192,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
},
},
})
function runningToolMessage(id: string): SessionMessages[number] {
return {
info: assistantInfo(id),
parts: [
{
id: `${id}-tool`,
sessionID: "session-1",
messageID: id,
type: "tool",
callID: `${id}-call`,
tool: "bash",
state: {
status: "running",
input: {
command: "pwd",
},
time: {
start: 2,
},
},
},
],
}
}
function shellUserMessage(id: string): SessionMessages[number] {
return {
info: {
id,
sessionID: "session-1",
role: "user",
time: {
created: 1,
},
agent: "build",
model: {
providerID: "openai",
modelID: "gpt-5",
},
},
parts: [
{
id: `${id}-text`,
sessionID: "session-1",
messageID: id,
type: "text",
text: "The following tool was executed by the user",
synthetic: true,
},
],
}
}
function shellAssistantMessage(id: string, parentID: string): SessionMessages[number] {
return {
info: assistantInfo(id, {
parentID,
time: {
created: 200,
completed: 3000,
},
}),
parts: [
{
id: `${id}-tool`,
sessionID: "session-1",
messageID: id,
type: "tool",
callID: `${id}-call`,
tool: "bash",
state: {
status: "completed",
input: {
command: "ls",
},
output: "account.ts\n",
title: "",
metadata: {
output: "account.ts\n",
description: "",
},
time: {
start: 200,
end: 3000,
},
},
},
],
}
}
describe("run session replay", () => {
test("replays persisted user, assistant, and turn summary history into scrollback commits", () => {
const out = replaySession({
messages: [
userMessage("msg-user-1", "Hello, whats the weather today?"),
assistantMessage("msg-1", "What city or ZIP code should I check?"),
],
permissions: [],
questions: [],
thinking: true,
limits: {},
})
expect(out.commits).toEqual([
expect.objectContaining({
kind: "user",
text: "Hello, whats the weather today?",
phase: "start",
source: "system",
messageID: "msg-user-1",
}),
expect.objectContaining({
kind: "assistant",
text: "What city or ZIP code should I check?",
phase: "progress",
source: "assistant",
messageID: "msg-1",
}),
expect.objectContaining({
kind: "system",
text: "▣ Build · gpt-5 · 2.8s",
phase: "final",
source: "system",
messageID: "msg-1",
summary: {
agent: "Build",
model: "gpt-5",
duration: "2.8s",
},
}),
])
expect(out.patch).toEqual(
expect.objectContaining({
phase: "idle",
status: "",
}),
)
})
test("uses provider model names for replayed turn summaries when available", () => {
const out = replaySession({
messages: [
userMessage("msg-user-1", "Hello, whats the weather today?"),
assistantMessage("msg-1", "What city or ZIP code should I check?"),
],
permissions: [],
questions: [],
thinking: true,
limits: {},
providers: [provider("Little Frank")],
})
expect(out.commits.at(-1)).toEqual(
expect.objectContaining({
kind: "system",
text: "▣ Build · Little Frank · 2.8s",
summary: {
agent: "Build",
model: "Little Frank",
duration: "2.8s",
},
}),
)
})
test("replays one turn summary for the final assistant in a multi-step turn", () => {
const out = replaySession({
messages: [
userMessage("msg-user-1", "Plan and then answer"),
assistantMessage("msg-step-1", "Working", {
parentID: "msg-user-1",
time: { created: 200, completed: 900 },
}),
assistantMessage("msg-step-2", "Done", {
parentID: "msg-user-1",
time: { created: 1000, completed: 3000 },
}),
],
permissions: [],
questions: [],
thinking: true,
limits: {},
})
expect(out.commits.filter((commit) => commit.summary)).toEqual([
expect.objectContaining({
kind: "system",
text: "▣ Build · gpt-5 · 2.0s",
messageID: "msg-step-2",
}),
])
})
test("keeps the footer in a running state for resumed active tools", () => {
const out = replaySession({
messages: [runningToolMessage("msg-1")],
permissions: [],
questions: [],
thinking: true,
limits: {},
})
expect(out.patch).toEqual(
expect.objectContaining({
phase: "running",
status: "running bash",
}),
)
})
test("does not replay turn summaries for shell-mode commands", () => {
const out = replaySession({
messages: [
shellUserMessage("msg-shell-user-1"),
shellAssistantMessage("msg-shell-assistant-1", "msg-shell-user-1"),
],
permissions: [],
questions: [],
thinking: true,
limits: {},
})
expect(out.commits.some((commit) => commit.summary)).toBe(false)
expect(out.commits).toContainEqual(
expect.objectContaining({
kind: "tool",
text: "account.ts\n",
tool: "bash",
toolState: "completed",
}),
)
})
test("merges failed local rows ahead of later persisted prompts", () => {
const persisted = {
kind: "user",
text: "successful",
phase: "start",
source: "system",
messageID: "msg-user-2",
} as const
const failed = {
kind: "user",
text: "failed",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const error = {
kind: "error",
text: "network unavailable",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
expect(
replayLocalRows([userMessage("msg-user-2", "successful")], [persisted], [{ commit: failed }, { commit: error }]),
).toEqual([failed, error, persisted])
})
test("retains local errors but not duplicate local prompts once a prompt persists", () => {
const persisted = {
kind: "user",
text: "failed after persistence",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const error = {
kind: "error",
text: "connection closed",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
expect(
replayLocalRows(
[userMessage("msg-user-1", "failed after persistence")],
[persisted],
[{ commit: persisted }, { commit: error }],
),
).toEqual([persisted, error])
})
test("keeps a local turn failure below assistant output already visible for that turn", () => {
const first = {
kind: "user",
text: "start",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const answer = {
kind: "assistant",
text: "partial answer",
phase: "progress",
source: "assistant",
messageID: "msg-assistant-1",
} as const
const error = {
kind: "error",
text: "stream failed",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const second = {
kind: "user",
text: "retry",
phase: "start",
source: "system",
messageID: "msg-user-2",
} as const
expect(
replayLocalRows(
[userMessage("msg-user-1", "start"), userMessage("msg-user-2", "retry")],
[first, answer, second],
[
{
commit: error,
after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-assistant-1" },
},
],
),
).toEqual([first, answer, error, second])
})
test("keeps a local failure above assistant output received after the failure", () => {
const first = {
kind: "user",
text: "start",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const error = {
kind: "error",
text: "request failed",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const late = {
kind: "assistant",
text: "late answer",
phase: "progress",
source: "assistant",
messageID: "msg-assistant-1",
} as const
expect(replayLocalRows([userMessage("msg-user-1", "start")], [first, late], [{ commit: error }])).toEqual([
first,
error,
late,
])
})
test("inserts a local failure between persisted output chunks spanning that failure", () => {
const first = {
kind: "user",
text: "start",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const complete = {
kind: "assistant",
text: "before after",
phase: "progress",
source: "assistant",
messageID: "msg-assistant-1",
partID: "part-1",
} as const
const error = {
kind: "error",
text: "stream failed",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
expect(
replayLocalRows(
[userMessage("msg-user-1", "start")],
[first, complete],
[
{
commit: error,
after: {
kind: "assistant",
text: "before ",
phase: "progress",
messageID: "msg-assistant-1",
partID: "part-1",
visible: "before ",
},
},
],
),
).toEqual([first, { ...complete, text: "before " }, error, { ...complete, text: "after" }])
})
test("places an unpersisted failed prompt before live output from that turn", () => {
const prompt = {
kind: "user",
text: "start",
phase: "start",
source: "system",
messageID: "msg-1",
} as const
const answer = {
kind: "assistant",
text: "partial answer",
phase: "progress",
source: "assistant",
messageID: "msg-2",
} as const
const error = {
kind: "error",
text: "stream failed",
phase: "start",
source: "system",
messageID: "msg-1",
} as const
expect(
replayLocalRows(
[],
[answer],
[
{ commit: prompt },
{
commit: error,
after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-2" },
},
],
),
).toEqual([prompt, answer, error])
})
test("anchors a failure after the visible start of a tool that later completes", () => {
const prompt = {
kind: "user",
text: "run ls",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const running = {
kind: "tool",
text: "running bash",
phase: "start",
source: "tool",
messageID: "msg-assistant-1",
partID: "part-tool-1",
toolState: "running",
} as const
const completed = {
kind: "tool",
text: "file.txt",
phase: "final",
source: "tool",
messageID: "msg-assistant-1",
partID: "part-tool-1",
toolState: "completed",
} as const
const error = {
kind: "error",
text: "connection lost",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
expect(
replayLocalRows(
[userMessage("msg-user-1", "run ls")],
[prompt, running, completed],
[
{
commit: error,
after: {
kind: "tool",
text: "running bash",
phase: "start",
messageID: "msg-assistant-1",
partID: "part-tool-1",
toolState: "running",
},
},
],
),
).toEqual([prompt, running, error, completed])
})
test("retains an unpersisted local diagnostic before later persisted prompts", () => {
const first = {
kind: "user",
text: "before",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const error = {
kind: "error",
text: "failed to start new session",
phase: "start",
source: "system",
messageID: "msg-user-2",
} as const
const second = {
kind: "user",
text: "after",
phase: "start",
source: "system",
messageID: "msg-user-3",
} as const
expect(
replayLocalRows(
[userMessage("msg-user-1", "before"), userMessage("msg-user-3", "after")],
[first, second],
[{ commit: error }],
),
).toEqual([first, error, second])
})
})

View File

@@ -0,0 +1,247 @@
import { describe, expect, test } from "bun:test"
import {
createSession,
sessionHistory,
sessionVariant,
type RunSession,
type SessionMessages,
} from "@/cli/cmd/run/session.shared"
type Message = SessionMessages[number]
type Part = Message["parts"][number]
type TextPart = Extract<Part, { type: "text" }>
type AgentPart = Extract<Part, { type: "agent" }>
type FilePart = Extract<Part, { type: "file" }>
const model = {
providerID: "openai",
modelID: "gpt-5",
}
function userMessage(id: string, parts: Message["parts"], variant = "high"): Message {
return {
info: {
id,
sessionID: "session-1",
role: "user",
time: {
created: 1,
},
agent: "build",
model: {
...model,
variant,
},
},
parts,
}
}
function assistantMessage(id: string, parts: Message["parts"]): Message {
return {
info: {
id,
sessionID: "session-1",
role: "assistant",
time: {
created: 1,
},
parentID: "msg-user-1",
modelID: "gpt-5",
providerID: "openai",
mode: "chat",
agent: "build",
path: {
cwd: "/tmp",
root: "/tmp",
},
cost: 0,
tokens: {
input: 1,
output: 1,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
},
parts,
}
}
function textPart(id: string, messageID: string, text: string, input: Partial<TextPart> = {}): TextPart {
return {
id,
sessionID: "session-1",
messageID,
type: "text",
text,
synthetic: input.synthetic,
}
}
function agentPart(id: string, messageID: string, name: string, source?: AgentPart["source"]): AgentPart {
return {
id,
sessionID: "session-1",
messageID,
type: "agent",
name,
source,
}
}
function filePart(id: string, messageID: string, url: string, input: Partial<FilePart> = {}): FilePart {
return {
id,
sessionID: "session-1",
messageID,
type: "file",
mime: input.mime ?? "text/plain",
filename: input.filename,
url,
source: input.source,
}
}
describe("run session shared", () => {
test("builds user prompt text from text, file, and agent parts", () => {
const msgs: SessionMessages = [
assistantMessage("msg-assistant-1", [textPart("txt-assistant-1", "msg-assistant-1", "ignore me")]),
userMessage("msg-user-1", [
textPart("txt-user-1", "msg-user-1", "look @scan"),
textPart("txt-user-2", "msg-user-1", "hidden", { synthetic: true }),
agentPart("agent-user-1", "msg-user-1", "scan", {
start: 5,
end: 10,
value: "@scan",
}),
filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"),
]),
]
const out = createSession(msgs)
expect(out.first).toBe(false)
expect(out.turns).toHaveLength(1)
expect(out.turns[0]?.prompt.text).toBe("look @scan @note.ts")
expect(out.turns[0]?.prompt.parts).toEqual([
{
type: "agent",
name: "scan",
source: {
start: 5,
end: 10,
value: "@scan",
},
},
{
type: "file",
mime: "text/plain",
filename: undefined,
url: "file:///tmp/note.ts",
source: {
type: "file",
path: "file:///tmp/note.ts",
text: {
start: 11,
end: 19,
value: "@note.ts",
},
},
},
])
})
test("reuses existing mentions when file and agent parts have no source", () => {
const out = createSession([
userMessage("msg-user-1", [
textPart("txt-user-1", "msg-user-1", "look @scan @note.ts"),
agentPart("agent-user-1", "msg-user-1", "scan"),
filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"),
]),
])
expect(out.turns[0]?.prompt).toEqual({
text: "look @scan @note.ts",
parts: [
{
type: "agent",
name: "scan",
source: {
start: 5,
end: 10,
value: "@scan",
},
},
{
type: "file",
mime: "text/plain",
filename: undefined,
url: "file:///tmp/note.ts",
source: {
type: "file",
path: "file:///tmp/note.ts",
text: {
start: 11,
end: 19,
value: "@note.ts",
},
},
},
],
})
})
test("dedupes consecutive history entries, drops blanks, and copies prompt parts", () => {
const parts = [
{
type: "agent" as const,
name: "scan",
source: {
start: 0,
end: 5,
value: "@scan",
},
},
]
const session: RunSession = {
first: false,
turns: [
{ prompt: { text: "one", parts }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: "one", parts: structuredClone(parts) }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: " ", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: "two", parts: [] }, provider: "openai", model: "gpt-5", variant: undefined },
],
}
const out = sessionHistory(session)
expect(out.map((item) => item.text)).toEqual(["one", "two"])
expect(out[0]?.parts).toEqual(parts)
expect(out[0]?.parts).not.toBe(parts)
expect(out[0]?.parts[0]).not.toBe(parts[0])
})
test("returns the latest matching variant for the active model", () => {
const session: RunSession = {
first: false,
turns: [
{ prompt: { text: "one", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: "two", parts: [] }, provider: "anthropic", model: "sonnet", variant: "max" },
{ prompt: { text: "three", parts: [] }, provider: "openai", model: "gpt-5", variant: undefined },
],
}
expect(sessionVariant(session, model)).toBeUndefined()
session.turns.push({
prompt: { text: "four", parts: [] },
provider: "openai",
model: "gpt-5",
variant: "minimal",
})
expect(sessionVariant(session, model)).toBe("minimal")
})
})

View File

@@ -0,0 +1,56 @@
import { describe, expect, test } from "bun:test"
import { writeSessionOutput } from "@/cli/cmd/run/stream"
import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types"
function footer() {
const events: FooterEvent[] = []
const commits: StreamCommit[] = []
const api: FooterApi = {
isClosed: false,
onPrompt: () => () => {},
onQueuedRemove: () => () => {},
onClose: () => () => {},
event: (next) => {
events.push(next)
},
append: (next) => {
commits.push(next)
},
idle: () => Promise.resolve(),
close: () => {},
destroy: () => {},
}
return { api, events, commits }
}
describe("run stream bridge", () => {
test("defaults status patches to running phase", () => {
const out = footer()
writeSessionOutput(
{
footer: out.api,
},
{
commits: [],
footer: {
patch: {
status: "assistant responding",
},
},
},
)
expect(out.events).toEqual([
{
type: "stream.patch",
patch: {
phase: "running",
status: "assistant responding",
},
},
])
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,547 @@
import { describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2"
import { entryBody } from "@/cli/cmd/run/entry.body"
import {
bootstrapSubagentCalls,
bootstrapSubagentData,
createSubagentData,
reduceSubagentData,
snapshotSubagentData,
} from "@/cli/cmd/run/subagent-data"
type SessionMessage = Parameters<typeof bootstrapSubagentData>[0]["messages"][number]
type ChildMessage = Parameters<typeof bootstrapSubagentCalls>[0]["messages"][number]
function visible(commits: Array<Parameters<typeof entryBody>[0]>) {
return commits.flatMap((item) => {
const body = entryBody(item)
if (body.type === "none") {
return []
}
if (body.type === "structured") {
if (body.snapshot.kind === "code" || body.snapshot.kind === "task") {
return [body.snapshot.title]
}
if (body.snapshot.kind === "diff") {
return body.snapshot.items.map((item) => item.title)
}
if (body.snapshot.kind === "todo") {
return ["# Todos"]
}
return ["# Questions"]
}
return [body.content]
})
}
function reduce(data: ReturnType<typeof createSubagentData>, event: unknown) {
return reduceSubagentData({
data,
event: event as Event,
sessionID: "parent-1",
thinking: true,
limits: {},
})
}
function taskMessage(sessionID: string, status: "running" | "completed" | "interrupted" = "completed"): SessionMessage {
if (status === "running") {
return {
parts: [
{
id: `part-${sessionID}`,
sessionID: "parent-1",
messageID: `msg-${sessionID}`,
type: "tool",
callID: `call-${sessionID}`,
tool: "task",
state: {
status: "running",
input: {
description: "Scan reducer paths",
subagent_type: "explore",
},
title: "Reducer touchpoints",
metadata: {
sessionId: sessionID,
toolcalls: 4,
},
time: { start: 1 },
},
},
],
}
}
if (status === "interrupted") {
return {
parts: [
{
id: `part-${sessionID}`,
sessionID: "parent-1",
messageID: `msg-${sessionID}`,
type: "tool",
callID: `call-${sessionID}`,
tool: "task",
state: {
status: "error",
input: {
description: "Scan reducer paths",
subagent_type: "explore",
},
error: "Tool execution aborted",
metadata: {
sessionId: sessionID,
toolcalls: 4,
interrupted: true,
},
time: { start: 1, end: 2 },
},
},
],
}
}
return {
parts: [
{
id: `part-${sessionID}`,
sessionID: "parent-1",
messageID: `msg-${sessionID}`,
type: "tool",
callID: `call-${sessionID}`,
tool: "task",
state: {
status: "completed",
input: {
description: "Scan reducer paths",
subagent_type: "explore",
},
output: "",
title: "Reducer touchpoints",
metadata: {
sessionId: sessionID,
toolcalls: 4,
},
time: { start: 1, end: 2 },
},
},
],
}
}
function question(id: string, sessionID: string) {
return {
id,
sessionID,
questions: [
{
question: "Mode?",
header: "Mode",
options: [{ label: "Fast", description: "Quick pass" }],
multiple: false,
},
],
}
}
function childMessage(input: {
messageID: string
sessionID: string
role: "user" | "assistant"
parts: ChildMessage["parts"]
}) {
if (input.role === "user") {
return {
info: {
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: {
created: 1,
},
agent: "test",
model: {
providerID: "openai",
modelID: "gpt-5",
},
},
parts: input.parts,
} satisfies ChildMessage
}
return {
info: {
id: input.messageID,
sessionID: input.sessionID,
role: "assistant",
time: {
created: 2,
completed: 3,
},
parentID: "msg-user-1",
providerID: "openai",
modelID: "gpt-5",
mode: "default",
agent: "explore",
path: {
cwd: "/tmp",
root: "/tmp",
},
cost: 0,
tokens: {
input: 1,
output: 1,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
finish: "stop",
},
parts: input.parts,
} satisfies ChildMessage
}
describe("run subagent data", () => {
test("bootstraps tabs and child blockers from parent task parts", () => {
const data = createSubagentData()
expect(
bootstrapSubagentData({
data,
messages: [taskMessage("child-1")],
children: [{ id: "child-1" }, { id: "child-2" }],
permissions: [
{
id: "perm-1",
sessionID: "child-1",
permission: "read",
patterns: ["src/**/*.ts"],
metadata: {},
always: [],
},
{
id: "perm-2",
sessionID: "other",
permission: "read",
patterns: ["src/**/*.ts"],
metadata: {},
always: [],
},
],
questions: [question("question-1", "child-1"), question("question-2", "other")],
}),
).toBe(true)
const snapshot = snapshotSubagentData(data)
expect(snapshot.tabs).toEqual([
expect.objectContaining({
sessionID: "child-1",
label: "Explore",
description: "Scan reducer paths",
title: "Reducer touchpoints",
status: "completed",
toolCalls: 4,
}),
])
expect(snapshot.details).toEqual({
"child-1": {
sessionID: "child-1",
commits: [],
},
})
expect(snapshot.permissions.map((item) => item.id)).toEqual(["perm-1"])
expect(snapshot.questions.map((item) => item.id)).toEqual(["question-1"])
})
test("marks interrupted task tabs as cancelled during bootstrap", () => {
const data = createSubagentData()
bootstrapSubagentData({
data,
messages: [taskMessage("child-1", "interrupted")],
children: [{ id: "child-1" }],
permissions: [],
questions: [],
})
expect(snapshotSubagentData(data).tabs).toEqual([
expect.objectContaining({
sessionID: "child-1",
status: "cancelled",
}),
])
})
test("captures child activity and blocker metadata in the footer detail state", () => {
const data = createSubagentData()
bootstrapSubagentData({
data,
messages: [taskMessage("child-1", "running")],
children: [{ id: "child-1" }],
permissions: [],
questions: [],
})
reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "txt-user-1",
messageID: "msg-user-1",
sessionID: "child-1",
type: "text",
text: "Inspect footer tabs",
},
},
})
reduce(data, {
type: "message.updated",
properties: {
sessionID: "child-1",
info: {
id: "msg-user-1",
role: "user",
},
},
})
reduce(data, {
type: "message.updated",
properties: {
sessionID: "child-1",
info: {
id: "msg-assistant-1",
role: "assistant",
},
},
})
reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "reason-1",
messageID: "msg-assistant-1",
sessionID: "child-1",
type: "reasoning",
text: "planning next steps",
time: { start: 1 },
},
},
})
reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "tool-1",
messageID: "msg-assistant-1",
sessionID: "child-1",
type: "tool",
callID: "call-1",
tool: "bash",
state: {
status: "running",
input: {
command: "git status --short",
},
time: { start: 1 },
},
},
},
})
reduce(data, {
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "child-1",
permission: "bash",
patterns: ["git status --short"],
metadata: {},
always: [],
tool: {
messageID: "msg-assistant-1",
callID: "call-1",
},
},
})
reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "txt-1",
messageID: "msg-assistant-1",
sessionID: "child-1",
type: "text",
text: "hello",
},
},
})
reduce(data, {
type: "message.part.delta",
properties: {
sessionID: "child-1",
messageID: "msg-assistant-1",
partID: "txt-1",
field: "text",
delta: " world",
},
})
const snapshot = snapshotSubagentData(data)
expect(snapshot.tabs).toEqual([expect.objectContaining({ sessionID: "child-1", status: "running" })])
expect(visible(snapshot.details["child-1"]?.commits ?? [])).toEqual([
" Inspect footer tabs",
"_Thinking:_ planning next steps",
"$ git status --short",
"hello world",
])
expect(snapshot.permissions).toEqual([
expect.objectContaining({
id: "perm-1",
metadata: {
input: {
command: "git status --short",
},
},
}),
])
expect(snapshot.questions).toEqual([])
})
test("replays bootstrapped child session messages into inspector commits", () => {
const data = createSubagentData()
bootstrapSubagentData({
data,
messages: [taskMessage("child-1", "completed")],
children: [{ id: "child-1" }],
permissions: [],
questions: [],
})
expect(
bootstrapSubagentCalls({
data,
sessionID: "child-1",
messages: [
childMessage({
messageID: "msg-user-1",
sessionID: "child-1",
role: "user",
parts: [
{
id: "txt-user-1",
messageID: "msg-user-1",
sessionID: "child-1",
type: "text",
text: "Inspect footer tabs",
time: { start: 1, end: 1 },
},
],
}),
childMessage({
messageID: "msg-assistant-1",
sessionID: "child-1",
role: "assistant",
parts: [
{
id: "reason-1",
messageID: "msg-assistant-1",
sessionID: "child-1",
type: "reasoning",
text: "planning next steps",
time: { start: 2, end: 2 },
},
{
id: "txt-1",
messageID: "msg-assistant-1",
sessionID: "child-1",
type: "text",
text: "hello world",
time: { start: 2, end: 3 },
},
],
}),
],
thinking: true,
limits: {},
}),
).toBe(true)
expect(visible(snapshotSubagentData(data).details["child-1"]?.commits ?? [])).toEqual([
" Inspect footer tabs",
"_Thinking:_ planning next steps",
"hello world",
])
})
test("marks a running tab cancelled when the child session aborts", () => {
const data = createSubagentData()
bootstrapSubagentData({
data,
messages: [taskMessage("child-1", "running")],
children: [{ id: "child-1" }],
permissions: [],
questions: [],
})
reduce(data, {
type: "message.updated",
properties: {
sessionID: "child-1",
info: {
id: "msg-assistant-1",
sessionID: "child-1",
role: "assistant",
time: {
created: 1,
completed: 2,
},
error: {
name: "MessageAbortedError",
data: {
message: "Aborted",
},
},
parentID: "msg-user-1",
providerID: "openai",
modelID: "gpt-5",
mode: "default",
agent: "explore",
path: {
cwd: "/tmp",
root: "/tmp",
},
cost: 0,
tokens: {
input: 1,
output: 1,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
finish: "error",
},
},
})
expect(snapshotSubagentData(data).tabs).toEqual([
expect.objectContaining({
sessionID: "child-1",
status: "cancelled",
}),
])
})
})

View File

@@ -0,0 +1,177 @@
import { expect, test } from "bun:test"
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "@/cli/cmd/run/theme"
const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const
function terminalColors(input: Partial<TerminalColors> = {}): TerminalColors {
return {
palette: Array.from({ length: 256 }, (_, index) => input.palette?.[index] ?? palette[index % palette.length]!),
defaultBackground: input.defaultBackground ?? "#1a1b26",
defaultForeground: input.defaultForeground ?? "#c0caf5",
cursorColor: input.cursorColor ?? "#ff9e64",
mouseForeground: input.mouseForeground ?? null,
mouseBackground: input.mouseBackground ?? null,
tekForeground: input.tekForeground ?? null,
tekBackground: input.tekBackground ?? null,
highlightBackground: input.highlightBackground ?? "#33467c",
highlightForeground: input.highlightForeground ?? "#c0caf5",
}
}
function renderer(
input: {
themeMode?: "dark" | "light"
colors?: TerminalColors
fail?: boolean
} = {},
) {
return {
themeMode: input.themeMode,
getPalette: async () => {
if (input.fail) {
throw new Error("boom")
}
return input.colors ?? terminalColors()
},
} as CliRenderer
}
function expectRgba(color: unknown) {
expect(color).toBeInstanceOf(RGBA)
if (!(color instanceof RGBA)) {
throw new Error("expected RGBA")
}
return color
}
function expectIndexed(color: unknown) {
const rgba = expectRgba(color)
expect(rgba.intent).toBe("indexed")
expect(rgba.slot).toBeLessThan(256)
}
function spread(color: RGBA) {
const [r, g, b] = color.toInts()
return Math.max(r, g, b) - Math.min(r, g, b)
}
test("falls back when palette lookup fails", async () => {
expect(await resolveRunTheme(renderer({ fail: true }))).toBe(RUN_THEME_FALLBACK)
})
test("returns syntax styles and indexed splash colors", async () => {
const theme = await resolveRunTheme(renderer({ themeMode: "dark" }))
try {
expect(theme.block.syntax).toBeDefined()
expect(theme.block.subtleSyntax).toBeDefined()
expect([...theme.block.syntax!.getAllStyles()].length).toBeGreaterThan(0)
expect([...theme.block.subtleSyntax!.getAllStyles()].length).toBeGreaterThan(0)
expectIndexed(theme.splash.left)
expectIndexed(theme.splash.right)
expectIndexed(theme.splash.leftShadow)
expectIndexed(theme.splash.rightShadow)
expectIndexed(theme.block.highlight)
expectIndexed(theme.block.warning)
expectRgba(theme.footer.highlight)
expectRgba(theme.footer.statusAccent)
expectRgba(theme.footer.surface)
expect(expectRgba(theme.footer.statusAccent).toInts()).not.toEqual(expectRgba(theme.footer.status).toInts())
} finally {
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
}
})
test("keeps footer surfaces exact while scrollback stays palette matched", async () => {
const colors = terminalColors({
defaultBackground: "#0f172a",
defaultForeground: "#e2e8f0",
})
const theme = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
const exact = resolveTheme(generateSystem(colors, "dark"), "dark")
try {
expect(expectRgba(theme.footer.selected).toInts()).toEqual(expectRgba(exact.backgroundElement).toInts())
expect(expectRgba(theme.footer.border).toInts()).toEqual(expectRgba(exact.border).toInts())
expect(expectRgba(theme.footer.pane).toInts()).toEqual(expectRgba(exact.backgroundMenu).toInts())
expect(expectRgba(theme.footer.selected).intent).toBe("rgb")
expectIndexed(theme.block.highlight)
expectIndexed(theme.block.warning)
} finally {
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
}
})
test("uses refreshed background brightness when cached renderer mode is stale", async () => {
const colors = terminalColors({
defaultBackground: "#fbf1c7",
defaultForeground: "#3c3836",
})
const stale = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
const light = await resolveRunTheme(renderer({ themeMode: "light", colors }))
try {
expect(expectRgba(stale.footer.surface).toInts()).toEqual(expectRgba(light.footer.surface).toInts())
} finally {
stale.block.syntax?.destroy()
stale.block.subtleSyntax?.destroy()
light.block.syntax?.destroy()
light.block.subtleSyntax?.destroy()
}
})
test("keeps renderer mode when refreshed default background is unavailable", async () => {
const colors = {
...terminalColors(),
defaultBackground: null,
palette: ["#000000", ...terminalColors().palette.slice(1)],
}
const light = await resolveRunTheme(renderer({ themeMode: "light", colors }))
const dark = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
try {
expect(expectRgba(light.footer.surface).toInts()).not.toEqual(expectRgba(dark.footer.surface).toInts())
} finally {
light.block.syntax?.destroy()
light.block.subtleSyntax?.destroy()
dark.block.syntax?.destroy()
dark.block.subtleSyntax?.destroy()
}
})
test("keeps dark surfaces neutral on saturated backgrounds", () => {
const theme = resolveTheme(
generateSystem(
terminalColors({
defaultBackground: "#0000ff",
defaultForeground: "#ffffff",
}),
"dark",
),
"dark",
)
expect(spread(theme.backgroundPanel)).toBeLessThan(10)
expect(spread(theme.backgroundElement)).toBeLessThan(10)
})
test("keeps light surfaces close to neutral on warm backgrounds", () => {
const theme = resolveTheme(
generateSystem(
terminalColors({
defaultBackground: "#fbf1c7",
defaultForeground: "#3c3836",
}),
"light",
),
"light",
)
expect(spread(theme.backgroundPanel)).toBeLessThan(60)
expect(spread(theme.backgroundElement)).toBeLessThan(60)
})

View File

@@ -0,0 +1,217 @@
import path from "path"
import { NodeFileSystem } from "@effect/platform-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { describe, expect, test } from "bun:test"
import { Effect, FileSystem, Layer } from "effect"
import { Global } from "@opencode-ai/core/global"
import {
createVariantRuntime,
cycleVariant,
formatModelLabel,
pickVariant,
resolveVariant,
} from "@/cli/cmd/run/variant.shared"
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
import type { RunProvider } from "@/cli/cmd/run/types"
import { testEffect } from "../../lib/effect"
const model = {
providerID: "openai",
modelID: "gpt-5",
}
const providers: RunProvider[] = [
{
id: "openai",
name: "OpenAI",
source: "api",
env: [],
options: {},
models: {
"gpt-5": {
id: "gpt-5",
providerID: "openai",
api: {
id: "gpt-5",
url: "https://openai.test",
npm: "@ai-sdk/openai",
},
name: "GPT-5",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 128000,
output: 8192,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
},
},
},
]
function userMessage(
id: string,
input: { providerID: string; modelID: string; variant?: string },
): SessionMessages[number] {
return {
info: {
id,
sessionID: "session-1",
role: "user",
time: {
created: 1,
},
agent: "build",
model: input,
},
parts: [],
}
}
const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, NodeFileSystem.layer))
function remap(root: string, file: string) {
if (file === Global.Path.state) {
return root
}
if (file.startsWith(Global.Path.state + path.sep)) {
return path.join(root, path.relative(Global.Path.state, file))
}
return file
}
function remappedFs(root: string) {
return Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
readJson: (file) => fs.readJson(remap(root, file)),
writeJson: (file, data, mode) => fs.writeJson(remap(root, file), data, mode),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
}
describe("run variant shared", () => {
test("prefers cli then session then saved variants", () => {
expect(resolveVariant("max", "high", "low", ["low", "high"])).toBe("max")
expect(resolveVariant(undefined, "high", "low", ["low", "high"])).toBe("high")
expect(resolveVariant(undefined, "missing", "low", ["low", "high"])).toBe("low")
})
test("cycles through variants and back to default", () => {
expect(cycleVariant(undefined, ["low", "high"])).toBe("low")
expect(cycleVariant("low", ["low", "high"])).toBe("high")
expect(cycleVariant("high", ["low", "high"])).toBeUndefined()
expect(cycleVariant(undefined, [])).toBeUndefined()
})
test("formats model labels", () => {
expect(formatModelLabel(model, undefined)).toBe("gpt-5 · openai")
expect(formatModelLabel(model, "high")).toBe("gpt-5 · openai · high")
expect(formatModelLabel(model, undefined, providers)).toBe("GPT-5 · OpenAI")
expect(formatModelLabel(model, "high", providers)).toBe("GPT-5 · OpenAI · high")
})
test("picks the latest matching variant from raw session messages", () => {
const msgs: SessionMessages = [
userMessage("msg-1", { providerID: "openai", modelID: "gpt-5", variant: "high" }),
userMessage("msg-2", { providerID: "anthropic", modelID: "sonnet", variant: "max" }),
userMessage("msg-3", { providerID: "openai", modelID: "gpt-5", variant: "minimal" }),
]
expect(pickVariant(model, msgs)).toBe("minimal")
})
it.live("reads and writes saved variants through a runtime-backed app fs layer", () =>
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const fs = yield* FSUtil.Service
const root = yield* filesys.makeTempDirectoryScoped()
const file = path.join(root, "model.json")
yield* fs.writeJson(file, {
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: {
"openai/gpt-4.1": "low",
},
})
const svc = createVariantRuntime(remappedFs(root))
yield* Effect.promise(() => svc.saveVariant(model, "high"))
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high")
expect(yield* fs.readJson(file)).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: {
"openai/gpt-4.1": "low",
"openai/gpt-5": "high",
},
})
yield* Effect.promise(() => svc.saveVariant(model, undefined))
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBeUndefined()
expect(yield* fs.readJson(file)).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: {
"openai/gpt-4.1": "low",
},
})
}),
)
it.live("repairs malformed saved variant state on the next write", () =>
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const fs = yield* FSUtil.Service
const root = yield* filesys.makeTempDirectoryScoped()
const file = path.join(root, "model.json")
yield* filesys.writeFileString(file, "{")
const svc = createVariantRuntime(remappedFs(root))
yield* Effect.promise(() => svc.saveVariant(model, "high"))
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high")
expect(yield* fs.readJson(file)).toEqual({
variant: {
"openai/gpt-5": "high",
},
})
}),
)
})

View File

@@ -0,0 +1,61 @@
// Subprocess integration tests for `opencode serve`. Spawns the real CLI in
// headless mode and exercises it over HTTP — this is the only test tier that
// catches bugs spanning argv → server boot → routing → instance loading.
//
// `serve` is long-lived: the harness returns a handle (url/port/kill/exited)
// and kills the process when the test scope closes. The OS-assigned port is
// parsed off the "listening on http://..." line.
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import { cliIt } from "../../lib/cli-process"
describe("opencode serve (subprocess)", () => {
// Smoke test: server starts, binds a port, and /global/health responds.
// If this fails, all other serve tests likely will too — debug here first.
cliIt.live(
"starts, binds a port, and serves /global/health",
({ opencode }) =>
Effect.gen(function* () {
const server = yield* opencode.serve()
expect(server.port).toBeGreaterThan(0)
expect(server.url).toMatch(/^http:\/\//)
const client = yield* HttpClient.HttpClient
const res = yield* client.get(`${server.url}/global/health`)
expect(res.status).toBe(200)
// GlobalHealth schema is { success: true, ... } | { success: false, error }.
// We don't lock in further shape here — any 200 with parseable JSON is
// enough proof the routing + auth-bypass + instance loading is alive.
const body = yield* res.json
expect(body).toBeDefined()
}),
60_000,
)
// The scope-close finalizer must actually terminate the child. Without this
// test a regression in the kill path (e.g. a future refactor that forgets
// to wire the finalizer) would leak processes on every test run.
cliIt.live(
"kills the subprocess on scope close",
({ opencode }) =>
Effect.gen(function* () {
// Inner scope so we can observe `.exited` resolving after it closes.
const exitedPromise = yield* Effect.scoped(
Effect.gen(function* () {
const server = yield* opencode.serve()
// Capture the Promise, not the resolved value — scope closes after
// this gen returns, at which point the finalizer kills the child.
return server.exited
}),
)
// After scope close: finalizer fired, process must have exited.
const code = yield* Effect.promise(() => exitedPromise)
// Bun reports the exit code; SIGTERM-killed processes return non-null
// (typically 143 on POSIX). We just require resolution within a sane
// window — anything else means the kill didn't take.
expect(typeof code === "number" || code === null).toBe(true)
}),
60_000,
)
})

View File

@@ -0,0 +1,115 @@
// Tier-A smoke tests for read-only commands. Each test asserts only that the
// command exits 0 and produces *some* output in the isolated harness env.
//
// These are not behavioral tests — they're the cheapest possible signal that
// the dependency-layer wiring (config load, DB init, server boot, provider
// resolution) doesn't crash for the broad class of "no inputs, no side
// effects" commands. A regression in any shared layer (an Effect.fail that
// propagates out of a service constructor, a renamed env var, a broken DB
// migration) will fail one or more of these tests.
//
// If a future change should make one of these commands intentionally fail in
// an empty env, update the assertion + add a note explaining the new contract.
//
// Speed: each test pays ~1.5s for bun startup. 7 tests serialize within this
// file. See script/prebuild-test-cli.ts for an opt-in pre-built binary that
// cuts per-spawn cost when this suite gets bigger.
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
describe("opencode read-only commands (smoke)", () => {
// `mcp list` reads MCP server config and pings each one. With the empty
// OPENCODE_CONFIG_CONTENT={} we provide, no servers should be configured
// and the command should report that cleanly.
cliIt.live(
"mcp list: exits 0",
({ opencode }) =>
Effect.gen(function* () {
const r = yield* opencode.spawn(["mcp", "list"])
opencode.expectExit(r, 0, "mcp list")
}),
60_000,
)
// `providers list` enumerates credentials + env-resolved providers.
// (Not config-injected ones — those don't appear here by design.) The
// Credentials header always renders; the Environment header only renders
// when at least one provider env var is set, which the isolation harness
// deliberately doesn't guarantee. Assert the always-present marker so the
// test passes on a clean CI runner without env-var leakage.
cliIt.live(
"providers list: exits 0 and prints the credentials section",
({ opencode }) =>
Effect.gen(function* () {
const r = yield* opencode.spawn(["providers", "list"])
opencode.expectExit(r, 0, "providers list")
expect(r.stdout).toContain("Credentials")
}),
60_000,
)
// `models` lists models from configured providers. Our test/test-model
// should appear because it's wired into the test provider config.
cliIt.live(
"models: exits 0 and lists the test model",
({ opencode }) =>
Effect.gen(function* () {
const r = yield* opencode.spawn(["models"])
opencode.expectExit(r, 0, "models")
expect(r.stdout).toContain("test/test-model")
}),
60_000,
)
// `agent list` walks the agent config. Empty config means no agents
// configured; the command should still exit 0 with a "no agents" line or
// similar. We don't pin the message — just exit cleanly.
cliIt.live(
"agent list: exits 0",
({ opencode }) =>
Effect.gen(function* () {
const r = yield* opencode.spawn(["agent", "list"])
opencode.expectExit(r, 0, "agent list")
}),
60_000,
)
// `session list` reads the session DB. Fresh OPENCODE_TEST_HOME means
// empty DB. Exit 0 with no sessions.
cliIt.live(
"session list: exits 0",
({ opencode }) =>
Effect.gen(function* () {
const r = yield* opencode.spawn(["session", "list"])
opencode.expectExit(r, 0, "session list")
}),
60_000,
)
// `stats` aggregates token usage from the session DB. Empty DB → all zeros.
cliIt.live(
"stats: exits 0",
({ opencode }) =>
Effect.gen(function* () {
const r = yield* opencode.spawn(["stats"])
opencode.expectExit(r, 0, "stats")
}),
60_000,
)
// `db path` prints the DB file location. Under harness isolation the DB
// resolves to SQLite's `:memory:` (no on-disk pollution between tests);
// in production it'd be a path under OPENCODE_TEST_HOME / XDG_DATA_HOME.
// Accept either form — both prove the resolver ran without crashing.
cliIt.live(
"db path: exits 0 and prints a path or :memory:",
({ opencode }) =>
Effect.gen(function* () {
const r = yield* opencode.spawn(["db", "path"])
opencode.expectExit(r, 0, "db path")
expect(r.stdout.trim()).toMatch(/^(:memory:|[/\\].+\.(db|sqlite|sqlite3))$/i)
}),
60_000,
)
})

View File

@@ -0,0 +1,11 @@
import { describe, expect, test } from "bun:test"
describe("tui attach", () => {
test("loads the TUI integration lazily", async () => {
const source = await Bun.file(new URL("../../../src/cli/cmd/attach.ts", import.meta.url)).text()
expect(source).toContain('await import("../tui/layer")')
expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
expect(source).not.toContain('import("./app")')
})
})

View File

@@ -0,0 +1,379 @@
import { Database } from "bun:sqlite"
import { mkdir, symlink } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { afterEach, expect, spyOn, test } from "bun:test"
import { isZedTerminal, offsetToPosition, resolveZedDbPath, resolveZedSelection } from "@opencode-ai/tui/editor-zed"
import { tmpdir } from "../../fixture/fixture"
const originalZedTerm = process.env.ZED_TERM
const originalTermProgram = process.env.TERM_PROGRAM
afterEach(() => {
if (originalZedTerm === undefined) delete process.env.ZED_TERM
else process.env.ZED_TERM = originalZedTerm
if (originalTermProgram === undefined) delete process.env.TERM_PROGRAM
else process.env.TERM_PROGRAM = originalTermProgram
})
type ZedFixtureOptions = {
workspacePaths?: string | null
itemKind?: string
editor?: boolean
selectionStart?: number | null
selectionEnd?: number | null
selections?: Array<{ start: number | null; end: number | null }>
contents?: string
}
async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) {
const dbPath = path.join(dir, "zed.sqlite")
const filePath = path.join(dir, "file.ts")
const contents = options.contents ?? "one\ntwo\nthree"
await Bun.write(filePath, contents)
const db = new Database(dbPath)
db.run("create table workspaces (workspace_id integer, paths text, timestamp text)")
db.run("create table panes (pane_id integer, workspace_id integer, active integer)")
db.run("create table items (item_id integer, workspace_id integer, pane_id integer, active integer, kind text)")
db.run("create table editors (item_id integer, workspace_id integer, buffer_path text, contents text)")
db.run("create table editor_selections (editor_id integer, workspace_id integer, start integer, end integer)")
db.run("insert into workspaces values (1, ?, ?)", [options.workspacePaths ?? JSON.stringify([dir]), "2026-04-27"])
db.run("insert into panes values (1, 1, 1)")
db.run("insert into items values (1, 1, 1, 1, ?)", [options.itemKind ?? "Editor"])
if (options.editor !== false) {
db.run("insert into editors values (1, 1, ?, ?)", [filePath, contents])
;(
options.selections ?? [
{
start: options.selectionStart === undefined ? 4 : options.selectionStart,
end: options.selectionEnd === undefined ? 7 : options.selectionEnd,
},
]
).forEach((selection) =>
db.run("insert into editor_selections values (1, 1, ?, ?)", [selection.start, selection.end]),
)
}
db.close()
return { dbPath, filePath }
}
function utf8ByteOffset(text: string, offset: number) {
return new TextEncoder().encode(text.slice(0, offset)).length
}
test("offsetToPosition converts Zed offsets to 1-based editor positions", () => {
expect(offsetToPosition("one\ntwo\nthree", 0)).toEqual({ line: 1, character: 1 })
expect(offsetToPosition("one\ntwo\nthree", 4)).toEqual({ line: 2, character: 1 })
expect(offsetToPosition("one\ntwo\nthree", 6)).toEqual({ line: 2, character: 3 })
expect(offsetToPosition("one\ntwo\nthree", 100)).toEqual({ line: 3, character: 6 })
expect(offsetToPosition("Ж\nabc", utf8ByteOffset("Ж\nabc", "Ж\nabc".indexOf("a")))).toEqual({
line: 2,
character: 1,
})
expect(offsetToPosition("😀\nabc", utf8ByteOffset("😀\nabc", "😀\nabc".indexOf("a")))).toEqual({
line: 2,
character: 1,
})
})
test("resolveZedDbPath skips candidates that cannot be stated", async () => {
await using tmp = await tmpdir()
const loop = path.join(tmp.path, "loop")
await symlink(loop, loop)
const home = spyOn(os, "homedir").mockImplementation(() => tmp.path)
const previous = process.env.OPENCODE_ZED_DB
process.env.OPENCODE_ZED_DB = loop
try {
expect(resolveZedDbPath()).toBeUndefined()
} finally {
if (previous === undefined) delete process.env.OPENCODE_ZED_DB
else process.env.OPENCODE_ZED_DB = previous
home.mockRestore()
}
})
test("isZedTerminal only returns true for Zed terminal environments", () => {
delete process.env.ZED_TERM
delete process.env.TERM_PROGRAM
expect(isZedTerminal()).toBeFalse()
process.env.ZED_TERM = "true"
expect(isZedTerminal()).toBeTrue()
process.env.ZED_TERM = "false"
process.env.TERM_PROGRAM = "zed"
expect(isZedTerminal()).toBeTrue()
})
test("resolveZedSelection returns active editor selection", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path)
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
filePath: fixture.filePath,
source: "zed",
ranges: [
{
text: "two",
selection: {
start: { line: 2, character: 1 },
end: { line: 2, character: 4 },
},
},
],
},
})
})
test("resolveZedSelection returns all active editor selections sorted by offset", async () => {
await using tmp = await tmpdir()
const contents = "one\ntwo\nthree\nfour"
const fixture = await writeZedFixture(tmp.path, {
contents,
selections: [
{
start: utf8ByteOffset(contents, contents.indexOf("four")),
end: utf8ByteOffset(contents, contents.indexOf("four") + 4),
},
{
start: utf8ByteOffset(contents, contents.indexOf("two")),
end: utf8ByteOffset(contents, contents.indexOf("two") + 3),
},
],
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
filePath: fixture.filePath,
source: "zed",
ranges: [
{
text: "two",
selection: {
start: { line: 2, character: 1 },
end: { line: 2, character: 4 },
},
},
{
text: "four",
selection: {
start: { line: 4, character: 1 },
end: { line: 4, character: 5 },
},
},
],
},
})
})
test("resolveZedSelection converts Zed UTF-8 byte offsets to string offsets", async () => {
await using tmp = await tmpdir()
const contents = "a\nЖЖЖЖЖЖЖЖЖЖ\nb\nTARGET\nz"
const start = contents.indexOf("TARGET")
const fixture = await writeZedFixture(tmp.path, {
contents,
selectionStart: utf8ByteOffset(contents, start),
selectionEnd: utf8ByteOffset(contents, start + "TARGET".length),
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
filePath: fixture.filePath,
source: "zed",
ranges: [
{
text: "TARGET",
selection: {
start: { line: 4, character: 1 },
end: { line: 4, character: 7 },
},
},
],
},
})
})
test("resolveZedSelection handles non-ASCII text inside the selected range", async () => {
await using tmp = await tmpdir()
const contents = "a\npre\nвыбор\nz"
const start = contents.indexOf("выбор")
const fixture = await writeZedFixture(tmp.path, {
contents,
selectionStart: utf8ByteOffset(contents, start),
selectionEnd: utf8ByteOffset(contents, start + "выбор".length),
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
filePath: fixture.filePath,
source: "zed",
ranges: [
{
text: "выбор",
selection: {
start: { line: 3, character: 1 },
end: { line: 3, character: 6 },
},
},
],
},
})
})
test("resolveZedSelection handles emoji before the selected range", async () => {
await using tmp = await tmpdir()
const contents = "😀\nTARGET\nz"
const start = contents.indexOf("TARGET")
const fixture = await writeZedFixture(tmp.path, {
contents,
selectionStart: utf8ByteOffset(contents, start),
selectionEnd: utf8ByteOffset(contents, start + "TARGET".length),
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
filePath: fixture.filePath,
source: "zed",
ranges: [
{
text: "TARGET",
selection: {
start: { line: 2, character: 1 },
end: { line: 2, character: 7 },
},
},
],
},
})
})
test("resolveZedSelection handles reversed Zed byte offsets", async () => {
await using tmp = await tmpdir()
const contents = "a\nЖЖЖ\nTARGET\nz"
const start = contents.indexOf("TARGET")
const fixture = await writeZedFixture(tmp.path, {
contents,
selectionStart: utf8ByteOffset(contents, start + "TARGET".length),
selectionEnd: utf8ByteOffset(contents, start),
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
filePath: fixture.filePath,
source: "zed",
ranges: [
{
text: "TARGET",
selection: {
start: { line: 3, character: 1 },
end: { line: 3, character: 7 },
},
},
],
},
})
})
test("resolveZedSelection returns empty when no workspace matches", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path, {
workspacePaths: JSON.stringify([path.join(path.dirname(tmp.path), "other-workspace")]),
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" })
})
test("resolveZedSelection matches a Zed workspace that contains the session directory", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path)
expect(await resolveZedSelection(fixture.dbPath, path.join(tmp.path, "packages", "app"))).toEqual({
type: "selection",
selection: {
filePath: fixture.filePath,
source: "zed",
ranges: [
{
text: "two",
selection: {
start: { line: 2, character: 1 },
end: { line: 2, character: 4 },
},
},
],
},
})
})
test("resolveZedSelection prefers the most specific containing Zed workspace", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path)
const child = path.join(tmp.path, "packages")
const childFile = path.join(child, "child.ts")
await mkdir(child, { recursive: true })
await Bun.write(childFile, "child")
const db = new Database(fixture.dbPath)
db.run("insert into workspaces values (2, ?, ?)", [JSON.stringify([child]), "2026-01-01"])
db.run("insert into panes values (2, 2, 1)")
db.run("insert into items values (2, 2, 2, 1, ?)", ["Editor"])
db.run("insert into editors values (2, 2, ?, ?)", [childFile, "child"])
db.run("insert into editor_selections values (2, 2, 0, 5)")
db.close()
expect(await resolveZedSelection(fixture.dbPath, path.join(child, "app"))).toEqual({
type: "selection",
selection: {
filePath: childFile,
source: "zed",
ranges: [
{
text: "child",
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 6 },
},
},
],
},
})
})
test("resolveZedSelection ignores a Zed workspace nested inside the session directory", async () => {
await using tmp = await tmpdir()
const child = path.join(tmp.path, "effect-lab")
await mkdir(child, { recursive: true })
const fixture = await writeZedFixture(child)
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" })
})
test("resolveZedSelection returns unavailable when a Zed terminal is active", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path, { itemKind: "Terminal", editor: false })
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "unavailable" })
})
test("resolveZedSelection returns unavailable when the database cannot be queried", async () => {
await using tmp = await tmpdir()
expect(await resolveZedSelection(path.join(tmp.path, "missing.sqlite"), tmp.path)).toEqual({ type: "unavailable" })
})
test("resolveZedSelection returns unavailable when active selection is missing offsets", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path, { selectionStart: null, selectionEnd: null })
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "unavailable" })
})

View File

@@ -0,0 +1,297 @@
import { mkdir, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { afterEach, expect, spyOn, test } from "bun:test"
import { createRoot } from "solid-js"
import { EditorContextProvider, useEditorContext, type EditorIntegration } from "@opencode-ai/tui/context/editor"
import { tmpdir } from "../../fixture/fixture"
import { FakeWebSocket } from "../../lib/websocket"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { discoverEditorConnection } from "@opencode-ai/tui/editor"
const originalClaudePort = process.env.CLAUDE_CODE_SSE_PORT
const originalOpencodePort = process.env.OPENCODE_EDITOR_SSE_PORT
afterEach(() => {
process.env.CLAUDE_CODE_SSE_PORT = originalClaudePort
process.env.OPENCODE_EDITOR_SSE_PORT = originalOpencodePort
})
function nextTick() {
return new Promise<void>((resolve) => queueMicrotask(resolve))
}
function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
let editor!: ReturnType<typeof useEditorContext>
let dispose!: () => void
createRoot((nextDispose) => {
dispose = nextDispose
const Consumer = () => {
editor = useEditorContext()
return null
}
const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT
return (
<TestTuiContexts cwd={process.cwd()} paths={{ home: os.homedir() }}>
<EditorContextProvider integration={editorService} WebSocketImpl={WebSocketImpl}>
<Consumer />
</EditorContextProvider>
</TestTuiContexts>
)
})
return {
editor,
dispose,
}
}
const editorService: EditorIntegration = {
connection: discoverEditorConnection,
}
function createWebSocketImpl(...sockets: FakeWebSocket[]) {
let index = 0
return class {
constructor(url: string, options?: { headers?: Record<string, string> }) {
const socket = sockets[index]
index += 1
expect(socket).toBeDefined()
expect(url).toBe(socket!.url)
expect(options).toEqual(socket!.options)
return socket as unknown as object
}
} as unknown as typeof WebSocket
}
function sendSelection(socket: FakeWebSocket, filePath: string, text = "foo") {
socket.message(
JSON.stringify({
jsonrpc: "2.0",
method: "selection_changed",
params: {
text,
filePath,
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 4 },
},
},
}),
)
}
function expectedSelection(filePath: string, text = "foo") {
return {
filePath,
source: "websocket" as const,
ranges: [
{
text,
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 4 },
},
},
],
}
}
test("useEditorContext reconnect switches editor server by session directory", async () => {
await using tmp = await tmpdir()
const startupDirectory = path.join(tmp.path, "startup")
const sessionDirectory = path.join(tmp.path, "session")
const ideDirectory = path.join(tmp.path, ".claude", "ide")
await mkdir(startupDirectory, { recursive: true })
await mkdir(sessionDirectory, { recursive: true })
await mkdir(ideDirectory, { recursive: true })
await writeFile(
path.join(ideDirectory, "3001.lock"),
JSON.stringify({
transport: "ws",
workspaceFolders: [startupDirectory],
}),
)
await writeFile(
path.join(ideDirectory, "3002.lock"),
JSON.stringify({
transport: "ws",
workspaceFolders: [sessionDirectory],
}),
)
process.env.CLAUDE_CODE_SSE_PORT = undefined
process.env.OPENCODE_EDITOR_SSE_PORT = undefined
spyOn(process, "cwd").mockImplementation(() => startupDirectory)
spyOn(os, "homedir").mockImplementation(() => tmp.path)
const firstSocket = new FakeWebSocket("ws://127.0.0.1:3001")
const secondSocket = new FakeWebSocket("ws://127.0.0.1:3002")
const mounted = mountEditorContext(createWebSocketImpl(firstSocket, secondSocket))
await nextTick()
expect(firstSocket.closed).toBeFalse()
sendSelection(firstSocket, path.join(startupDirectory, "file.ts"))
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
expect(mounted.editor.labelState()).toBe("pending")
mounted.editor.reconnect(sessionDirectory)
await nextTick()
expect(firstSocket.closed).toBeTrue()
expect(secondSocket.closed).toBeFalse()
expect(mounted.editor.selection()).toBeUndefined()
expect(mounted.editor.labelState()).toBe("none")
mounted.dispose()
})
test("useEditorContext favors configured port over lock files", async () => {
await using tmp = await tmpdir()
const startupDirectory = path.join(tmp.path, "startup")
const ideDirectory = path.join(tmp.path, ".claude", "ide")
await mkdir(startupDirectory, { recursive: true })
await mkdir(ideDirectory, { recursive: true })
await writeFile(
path.join(ideDirectory, "3001.lock"),
JSON.stringify({
transport: "ws",
workspaceFolders: [startupDirectory],
}),
)
process.env.CLAUDE_CODE_SSE_PORT = "4010"
process.env.OPENCODE_EDITOR_SSE_PORT = undefined
spyOn(process, "cwd").mockImplementation(() => startupDirectory)
spyOn(os, "homedir").mockImplementation(() => tmp.path)
const socket = new FakeWebSocket("ws://127.0.0.1:4010")
const mounted = mountEditorContext(createWebSocketImpl(socket))
await nextTick()
expect(socket.closed).toBeFalse()
mounted.dispose()
})
test("useEditorContext clears selection when reconnecting", async () => {
await using tmp = await tmpdir()
const startupDirectory = path.join(tmp.path, "startup")
const ideDirectory = path.join(tmp.path, ".claude", "ide")
await mkdir(startupDirectory, { recursive: true })
await mkdir(ideDirectory, { recursive: true })
await writeFile(
path.join(ideDirectory, "3001.lock"),
JSON.stringify({
transport: "ws",
workspaceFolders: [startupDirectory],
}),
)
process.env.CLAUDE_CODE_SSE_PORT = undefined
process.env.OPENCODE_EDITOR_SSE_PORT = undefined
spyOn(process, "cwd").mockImplementation(() => startupDirectory)
spyOn(os, "homedir").mockImplementation(() => tmp.path)
const socket = new FakeWebSocket("ws://127.0.0.1:3001")
const mounted = mountEditorContext(createWebSocketImpl(socket))
await nextTick()
expect(socket.closed).toBeFalse()
expect(mounted.editor.selection()).toBeUndefined()
expect(mounted.editor.connected()).toBeFalse()
socket.open()
socket.message(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: {
protocolVersion: "2025-11-25",
serverInfo: { name: "test", version: "0.0.0" },
},
}),
)
sendSelection(socket, path.join(startupDirectory, "file.ts"))
expect(mounted.editor.connected()).toBeTrue()
expect(mounted.editor.server()).toEqual({
protocolVersion: "2025-11-25",
serverInfo: { name: "test", version: "0.0.0" },
})
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
expect(mounted.editor.labelState()).toBe("pending")
mounted.editor.markSelectionSent()
expect(mounted.editor.labelState()).toBe("sent")
mounted.editor.reconnect(startupDirectory)
expect(socket.closed).toBeFalse()
expect(mounted.editor.connected()).toBeTrue()
expect(mounted.editor.selection()).toBeUndefined()
expect(mounted.editor.labelState()).toBe("none")
mounted.dispose()
})
test("useEditorContext preserves selection for the next reconnect when requested", async () => {
await using tmp = await tmpdir()
const startupDirectory = path.join(tmp.path, "startup")
const ideDirectory = path.join(tmp.path, ".claude", "ide")
await mkdir(startupDirectory, { recursive: true })
await mkdir(ideDirectory, { recursive: true })
await writeFile(
path.join(ideDirectory, "3001.lock"),
JSON.stringify({
transport: "ws",
workspaceFolders: [startupDirectory],
}),
)
process.env.CLAUDE_CODE_SSE_PORT = undefined
process.env.OPENCODE_EDITOR_SSE_PORT = undefined
spyOn(process, "cwd").mockImplementation(() => startupDirectory)
spyOn(os, "homedir").mockImplementation(() => tmp.path)
const socket = new FakeWebSocket("ws://127.0.0.1:3001")
const mounted = mountEditorContext(createWebSocketImpl(socket))
await nextTick()
sendSelection(socket, path.join(startupDirectory, "file.ts"))
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
mounted.editor.markSelectionSent()
mounted.editor.preserveSelectionFromNewSession()
mounted.editor.reconnect(startupDirectory)
expect(socket.closed).toBeFalse()
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
expect(mounted.editor.labelState()).toBe("sent")
mounted.editor.reconnect(startupDirectory)
expect(mounted.editor.selection()).toBeUndefined()
expect(mounted.editor.labelState()).toBe("none")
mounted.dispose()
})
test("useEditorContext connects with OPENCODE_EDITOR_SSE_PORT", async () => {
await using tmp = await tmpdir()
process.env.CLAUDE_CODE_SSE_PORT = undefined
process.env.OPENCODE_EDITOR_SSE_PORT = "4020"
spyOn(process, "cwd").mockImplementation(() => tmp.path)
const socket = new FakeWebSocket("ws://127.0.0.1:4020")
const mounted = mountEditorContext(createWebSocketImpl(socket))
await nextTick()
expect(socket.closed).toBeFalse()
mounted.dispose()
})

View File

@@ -0,0 +1,110 @@
import { expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("adds tui plugin at runtime from spec", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "add-plugin.ts")
const spec = pathToFileURL(file).href
const marker = path.join(dir, "add.txt")
await Bun.write(
file,
`export default {
id: "demo.add",
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return { spec, marker }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({
api: createTuiPluginApi(),
config,
})
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.add")).toEqual({
id: "demo.add",
source: "file",
spec: tmp.extra.spec,
target: tmp.extra.spec,
enabled: true,
active: true,
})
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("retries runtime add for file plugins after dependency wait", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "retry-plugin")
const spec = pathToFileURL(mod).href
const marker = path.join(dir, "retry-add.txt")
await fs.mkdir(mod, { recursive: true })
return { mod, spec, marker }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => {
await Bun.write(
path.join(tmp.extra.mod, "index.ts"),
`export default {
id: "demo.add.retry",
tui: async () => {
await Bun.write(${JSON.stringify(tmp.extra.marker)}, "called")
},
}
`,
)
})
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({
api: createTuiPluginApi(),
config,
})
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
expect(wait).toHaveBeenCalledTimes(1)
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.add.retry")?.active).toBe(true)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})

View File

@@ -0,0 +1,87 @@
import { expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("installs plugin without loading it", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "install-plugin.ts")
const spec = pathToFileURL(file).href
const marker = path.join(dir, "install.txt")
await Bun.write(
path.join(dir, "package.json"),
JSON.stringify(
{
name: "demo-install-plugin",
type: "module",
exports: {
"./tui": {
import: "./install-plugin.ts",
config: { marker },
},
},
},
null,
2,
),
)
await Bun.write(
file,
`export default {
id: "demo.install",
tui: async (_api, options) => {
if (!options?.marker) return
await Bun.write(options.marker, "loaded")
},
}
`,
)
return { spec, marker }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi({
state: {
path: {
state: path.join(tmp.path, "state.json"),
config: path.join(tmp.path, "tui.json"),
worktree: tmp.path,
directory: tmp.path,
},
},
})
try {
await TuiPluginRuntime.init({ api, config })
const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)
expect(out).toMatchObject({
ok: true,
tui: true,
})
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("loaded")
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})

View File

@@ -0,0 +1,224 @@
import { expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { mockTuiRuntime } from "../../fixture/tui-runtime"
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("runs onDispose callbacks with aborted signal and is idempotent", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
const spec = pathToFileURL(file).href
const marker = path.join(dir, "marker.txt")
await Bun.write(
file,
`export default {
id: "demo.lifecycle",
tui: async (api, options) => {
api.event.on("event.test", () => {})
api.route.register([{ name: "lifecycle.route", render: () => null }])
api.lifecycle.onDispose(async () => {
const prev = await Bun.file(options.marker).text().catch(() => "")
await Bun.write(options.marker, prev + "custom\\n")
})
api.lifecycle.onDispose(async () => {
const prev = await Bun.file(options.marker).text().catch(() => "")
await Bun.write(options.marker, prev + "aborted:" + String(api.lifecycle.signal.aborted) + "\\n")
})
},
}
`,
)
return { spec, marker }
},
})
const { config, restore } = mockTuiRuntime(tmp.path, [[tmp.extra.spec, { marker: tmp.extra.marker }]])
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await TuiPluginRuntime.dispose()
const marker = await fs.readFile(tmp.extra.marker, "utf8")
expect(marker).toContain("custom")
expect(marker).toContain("aborted:true")
// second dispose is a no-op
await TuiPluginRuntime.dispose()
const after = await fs.readFile(tmp.extra.marker, "utf8")
expect(after).toBe(marker)
} finally {
await TuiPluginRuntime.dispose()
restore()
}
})
test("rolls back failed plugin and continues loading next", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const bad = path.join(dir, "bad-plugin.ts")
const good = path.join(dir, "good-plugin.ts")
const badSpec = pathToFileURL(bad).href
const goodSpec = pathToFileURL(good).href
const badMarker = path.join(dir, "bad-cleanup.txt")
const goodMarker = path.join(dir, "good-called.txt")
await Bun.write(
bad,
`export default {
id: "demo.bad",
tui: async (api, options) => {
api.route.register([{ name: "bad.route", render: () => null }])
api.lifecycle.onDispose(async () => {
await Bun.write(options.bad_marker, "cleaned")
})
throw new Error("bad plugin")
},
}
`,
)
await Bun.write(
good,
`export default {
id: "demo.good",
tui: async (_api, options) => {
await Bun.write(options.good_marker, "called")
},
}
`,
)
return { badSpec, goodSpec, badMarker, goodMarker }
},
})
const { config, restore } = mockTuiRuntime(tmp.path, [
[tmp.extra.badSpec, { bad_marker: tmp.extra.badMarker }],
[tmp.extra.goodSpec, { good_marker: tmp.extra.goodMarker }],
])
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
// bad plugin's onDispose ran during rollback
await expect(fs.readFile(tmp.extra.badMarker, "utf8")).resolves.toBe("cleaned")
// good plugin still loaded
await expect(fs.readFile(tmp.extra.goodMarker, "utf8")).resolves.toBe("called")
} finally {
await TuiPluginRuntime.dispose()
restore()
}
})
test("assigns sequential slot ids scoped to plugin", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "slot-plugin.ts")
const spec = pathToFileURL(file).href
const marker = path.join(dir, "slot-setup.txt")
await Bun.write(
file,
`import fs from "fs"
const mark = (label) => {
fs.appendFileSync(${JSON.stringify(marker)}, label + "\\n")
}
export default {
id: "demo.slot",
tui: async (api) => {
const one = api.slots.register({
id: 1,
setup: () => { mark("one") },
slots: { home_logo() { return null } },
})
const two = api.slots.register({
id: 2,
setup: () => { mark("two") },
slots: { home_bottom() { return null } },
})
mark("id:" + one)
mark("id:" + two)
},
}
`,
)
return { spec, marker }
},
})
const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
const err = spyOn(console, "error").mockImplementation(() => {})
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
const marker = await fs.readFile(tmp.extra.marker, "utf8")
expect(marker).toContain("one")
expect(marker).toContain("two")
expect(marker).toContain("id:demo.slot")
expect(marker).toContain("id:demo.slot:1")
// no initialization failures
const hit = err.mock.calls.find(
(item) => typeof item[0] === "string" && item[0].includes("failed to initialize tui plugin"),
)
expect(hit).toBeUndefined()
} finally {
await TuiPluginRuntime.dispose()
err.mockRestore()
restore()
}
})
test(
"times out hanging plugin cleanup on dispose",
async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "timeout-plugin.ts")
const spec = pathToFileURL(file).href
await Bun.write(
file,
`export default {
id: "demo.timeout",
tui: async (api) => {
api.lifecycle.onDispose(() => new Promise(() => {}))
},
}
`,
)
return { spec }
},
})
const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config, disposeTimeoutMs: 25 })
const done = await new Promise<string>((resolve) => {
const timer = setTimeout(() => resolve("timeout"), 500)
void TuiPluginRuntime.dispose().then(() => {
clearTimeout(timer)
resolve("done")
})
})
expect(done).toBe("done")
} finally {
await TuiPluginRuntime.dispose()
restore()
}
},
{ timeout: 15000 },
)

View File

@@ -0,0 +1,485 @@
import { expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/config/tui"
import { Npm } from "@opencode-ai/core/npm"
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("loads npm tui plugin from package ./tui export", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const marker = path.join(dir, "tui-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "acme-plugin",
type: "module",
exports: { ".": "./index.js", "./server": "./server.js", "./tui": "./tui.js" },
}),
)
await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
await Bun.write(path.join(mod, "server.js"), "export default {}\n")
await Bun.write(
path.join(mod, "tui.js"),
`export default {
id: "demo.tui.export",
tui: async (_api, options) => {
if (!options?.marker) return
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return { mod, marker, spec: "acme-plugin@1.0.0" }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_origins: [
{
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
const hit = TuiPluginRuntime.list().find((item) => item.id === "demo.tui.export")
expect(hit?.enabled).toBe(true)
expect(hit?.active).toBe(true)
expect(hit?.source).toBe("npm")
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("does not use npm package exports dot for tui entry", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const marker = path.join(dir, "dot-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "acme-plugin",
type: "module",
exports: { ".": "./index.js" },
}),
)
await Bun.write(
path.join(mod, "index.js"),
`export default {
id: "demo.dot",
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return { mod, marker, spec: "acme-plugin@1.0.0" }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [
{
spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("rejects npm tui export that resolves outside plugin directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const outside = path.join(dir, "outside")
const marker = path.join(dir, "outside-called.txt")
await fs.mkdir(mod, { recursive: true })
await fs.mkdir(outside, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "acme-plugin",
type: "module",
exports: { ".": "./index.js", "./tui": "./escape/tui.js" },
}),
)
await Bun.write(path.join(mod, "index.js"), "export default {}\n")
await Bun.write(
path.join(outside, "tui.js"),
`export default {
id: "demo.outside",
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "outside")
},
}
`,
)
await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
return { mod, marker, spec: "acme-plugin@1.0.0" }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [
{
spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
// plugin code never ran
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
// plugin not listed
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("rejects npm tui plugin that exports server and tui together", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const marker = path.join(dir, "mixed-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "acme-plugin",
type: "module",
exports: { ".": "./index.js", "./tui": "./tui.js" },
}),
)
await Bun.write(path.join(mod, "index.js"), "export default {}\n")
await Bun.write(
path.join(mod, "tui.js"),
`export default {
id: "demo.mixed",
server: async () => ({}),
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return { mod, marker, spec: "acme-plugin@1.0.0" }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [
{
spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("does not use npm package main for tui entry", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const marker = path.join(dir, "main-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "acme-plugin",
type: "module",
main: "./index.js",
}),
)
await Bun.write(
path.join(mod, "index.js"),
`export default {
id: "demo.main",
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return { mod, marker, spec: "acme-plugin@1.0.0" }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [
{
spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
const warn = spyOn(console, "warn").mockImplementation(() => {})
const error = spyOn(console, "error").mockImplementation(() => {})
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
expect(error).not.toHaveBeenCalled()
expect(warn.mock.calls.some((call) => String(call[0]).includes("tui plugin has no entrypoint"))).toBe(true)
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
wait.mockRestore()
warn.mockRestore()
error.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("does not use directory package main for tui entry", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "dir-plugin")
const spec = pathToFileURL(mod).href
const marker = path.join(dir, "dir-main-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "dir-plugin",
type: "module",
main: "./main.js",
}),
)
await Bun.write(
path.join(mod, "main.js"),
`export default {
id: "demo.dir.main",
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return { marker, spec }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [
{
spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("uses directory index fallback for tui when package.json is missing", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "dir-index")
const spec = pathToFileURL(mod).href
const marker = path.join(dir, "dir-index-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "index.ts"),
`export default {
id: "demo.dir.index",
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return { marker, spec }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [
{
spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.dir.index")?.active).toBe(true)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("uses npm package name when tui plugin id is omitted", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const marker = path.join(dir, "name-id-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "acme-plugin",
type: "module",
exports: { ".": "./index.js", "./tui": "./tui.js" },
}),
)
await Bun.write(path.join(mod, "index.js"), "export default {}\n")
await Bun.write(
path.join(mod, "tui.js"),
`export default {
tui: async (_api, options) => {
if (!options?.marker) return
await Bun.write(options.marker, "called")
},
}
`,
)
return { mod, marker, spec: "acme-plugin@1.0.0" }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_origins: [
{
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
expect(TuiPluginRuntime.list().find((item) => item.spec === tmp.extra.spec)?.id).toBe("acme-plugin")
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})

View File

@@ -0,0 +1,72 @@
import { expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("skips external tui plugins in pure mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
const spec = pathToFileURL(file).href
const marker = path.join(dir, "called.txt")
const meta = path.join(dir, "plugin-meta.json")
await Bun.write(
file,
`export default {
id: "demo.pure",
tui: async (_api, options) => {
if (!options?.marker) return
await Bun.write(options.marker, "called")
},
}
`,
)
return { spec, marker, meta }
},
})
const pure = process.env.OPENCODE_PURE
const meta = process.env.OPENCODE_PLUGIN_META_FILE
process.env.OPENCODE_PURE = "1"
process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta
const config = createTuiResolvedConfig({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_origins: [
{
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
if (pure === undefined) {
delete process.env.OPENCODE_PURE
} else {
process.env.OPENCODE_PURE = pure
}
if (meta === undefined) {
delete process.env.OPENCODE_PLUGIN_META_FILE
} else {
process.env.OPENCODE_PLUGIN_META_FILE = meta
}
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,264 @@
import { expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("toggles plugin runtime state by exported id", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "toggle-plugin.ts")
const spec = pathToFileURL(file).href
const marker = path.join(dir, "toggle.txt")
await Bun.write(
file,
`export default {
id: "demo.toggle",
tui: async (api, options) => {
const text = await Bun.file(options.marker).text().catch(() => "")
await Bun.write(options.marker, text + "start\\n")
api.lifecycle.onDispose(async () => {
const next = await Bun.file(options.marker).text().catch(() => "")
await Bun.write(options.marker, next + "stop\\n")
})
},
}
`,
)
return {
spec,
marker,
}
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_enabled: {
"demo.toggle": false,
},
plugin_origins: [
{
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi()
try {
await TuiPluginRuntime.init({ api, config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.toggle")).toEqual({
id: "demo.toggle",
source: "file",
spec: tmp.extra.spec,
target: tmp.extra.spec,
enabled: false,
active: false,
})
await expect(TuiPluginRuntime.activatePlugin("demo.toggle")).resolves.toBe(true)
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("start\n")
expect(api.kv.get("plugin_enabled", {})).toEqual({
"demo.toggle": true,
})
await expect(TuiPluginRuntime.deactivatePlugin("demo.toggle")).resolves.toBe(true)
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("start\nstop\n")
expect(api.kv.get("plugin_enabled", {})).toEqual({
"demo.toggle": false,
})
await expect(TuiPluginRuntime.activatePlugin("missing.id")).resolves.toBe(false)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("deactivating plugin pops pushed mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "mode-plugin.ts")
const spec = pathToFileURL(file).href
await Bun.write(
file,
`export default {
id: "demo.mode",
tui: async (api) => {
api.mode.push("demo.mode")
},
}
`,
)
return { spec }
},
})
const stack: { id: symbol; mode: string }[] = []
let popCount = 0
const api = createTuiPluginApi({
mode: {
current: () => stack.at(-1)?.mode ?? "base",
push(mode) {
const id = Symbol(mode)
let active = true
stack.push({ id, mode })
return () => {
if (!active) return
active = false
popCount += 1
const index = stack.findIndex((item) => item.id === id)
if (index !== -1) stack.splice(index, 1)
}
},
},
})
const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({ api, config })
expect(api.mode.current()).toBe("demo.mode")
expect(popCount).toBe(0)
await expect(TuiPluginRuntime.deactivatePlugin("demo.mode")).resolves.toBe(true)
expect(api.mode.current()).toBe("base")
expect(popCount).toBe(1)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
}
})
test("kv plugin_enabled overrides tui config on startup", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "startup-plugin.ts")
const spec = pathToFileURL(file).href
const marker = path.join(dir, "startup.txt")
await Bun.write(
file,
`export default {
id: "demo.startup",
tui: async (_api, options) => {
await Bun.write(options.marker, "on")
},
}
`,
)
return {
spec,
marker,
}
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_enabled: {
"demo.startup": false,
},
plugin_origins: [
{
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi()
api.kv.set("plugin_enabled", {
"demo.startup": true,
})
try {
await TuiPluginRuntime.init({ api, config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("on")
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.startup")).toEqual({
id: "demo.startup",
source: "file",
spec: tmp.extra.spec,
target: tmp.extra.spec,
enabled: true,
active: true,
})
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("loads disabled-by-default internal plugin inactive and activates on demand", async () => {
await using tmp = await tmpdir()
const config = createTuiResolvedConfig()
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi()
try {
await TuiPluginRuntime.init({ api, config })
expect(TuiPluginRuntime.list().find((item) => item.id === "internal:plugin-manager")).toMatchObject({
enabled: true,
active: true,
})
expect(TuiPluginRuntime.list().find((item) => item.id === "which-key")).toEqual({
id: "which-key",
source: "internal",
spec: "which-key",
target: "which-key",
enabled: false,
active: false,
})
await expect(TuiPluginRuntime.activatePlugin("which-key")).resolves.toBe(true)
expect(TuiPluginRuntime.list().find((item) => item.id === "which-key")).toEqual({
id: "which-key",
source: "internal",
spec: "which-key",
target: "which-key",
enabled: true,
active: true,
})
expect(api.kv.get("plugin_enabled", {})).toEqual({
"which-key": true,
})
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
}
})

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { tmpdir } from "../../fixture/fixture"
import { resolveThreadDirectory } from "../../../src/cli/cmd/tui"
describe("tui thread", () => {
test("loads the TUI integration lazily", async () => {
const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text()
expect(source).toContain('await import("../tui/layer")')
expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
expect(source).not.toContain('import("./app")')
})
async function check(project?: string) {
await using tmp = await tmpdir({ git: true })
const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
const type = process.platform === "win32" ? "junction" : "dir"
try {
await fs.symlink(tmp.path, link, type)
expect(resolveThreadDirectory(project, link, tmp.path)).toBe(tmp.path)
} finally {
await fs.rm(link, { recursive: true, force: true }).catch(() => undefined)
}
}
test("uses the real cwd when PWD points at a symlink", async () => {
await check()
})
test("uses the real cwd after resolving a relative project from PWD", async () => {
await check(".")
})
})

View File

@@ -0,0 +1,47 @@
import { expect } from "bun:test"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Config } from "@/config/config"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Config.defaultLayer, AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer))
it.instance(
"agent color parsed from project config",
() =>
Effect.gen(function* () {
const cfg = yield* Config.use.get()
expect(cfg.agent?.["build"]?.color).toBe("#FFA500")
expect(cfg.agent?.["plan"]?.color).toBe("primary")
}),
{
git: true,
config: {
agent: {
build: { color: "#FFA500" },
plan: { color: "primary" },
},
},
},
)
it.instance(
"Agent.get includes color from config",
() =>
Effect.gen(function* () {
const plan = yield* AgentSvc.use.get("plan")
expect(plan?.color).toBe("#A855F7")
const build = yield* AgentSvc.use.get("build")
expect(build?.color).toBe("accent")
}),
{
git: true,
config: {
agent: {
plan: { color: "#A855F7" },
build: { color: "accent" },
},
},
},
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test"
import { posix } from "path"
import { configEntryNameFromPath } from "@/config/entry-name"
// Use POSIX semantics so the test is deterministic regardless of host OS —
// production code passes paths through `path.relative` on the runtime
// platform, but the helper normalizes via `replaceAll("\\", "/")`, so the
// regression assertion ("the helper returns the bare name") holds on either
// platform as long as we feed it a relative path. Using `posix.relative`
// keeps the intermediate values stable across CI runners.
// The prefixes shipped by config/agent.ts after the relative-path refactor.
const AGENT_PREFIXES = ["agent/", "agents/"]
describe("configEntryNameFromPath", () => {
test("strips an `agents/` prefix and returns the bare name", () => {
expect(configEntryNameFromPath("agents/build.md", AGENT_PREFIXES)).toBe("build")
})
test("strips an `agent/` (singular) prefix", () => {
expect(configEntryNameFromPath("agent/build.md", AGENT_PREFIXES)).toBe("build")
})
test("preserves nested subdirectories in the key", () => {
expect(configEntryNameFromPath("agents/team/build.md", AGENT_PREFIXES)).toBe("team/build")
})
test("normalizes Windows-style backslashes", () => {
expect(configEntryNameFromPath("agents\\team\\build.md", AGENT_PREFIXES)).toBe("team/build")
})
test("falls back to basename when no prefix matches", () => {
expect(configEntryNameFromPath("orphaned.md", AGENT_PREFIXES)).toBe("orphaned")
expect(configEntryNameFromPath("anywhere/orphaned.md", [])).toBe("orphaned")
})
// Regression for #25713: a username (or any parent segment) containing
// `agent` or `agents` used to win the substring match before the real
// `agents/` directory could match, leaking the entire intervening path into
// the agent key (e.g. `.config/opencode/agents/build`). Anchoring at the
// caller via `path.relative(dir, item)` makes this impossible — the relative
// path is always rooted at `agent/` or `agents/`.
test("regression #25713: caller passes relative path; parent /agent/ segment is irrelevant", () => {
const dir = "/home/agent/.config/opencode"
const item = "/home/agent/.config/opencode/agents/build.md"
const relative = posix.relative(dir, item)
expect(relative).toBe("agents/build.md")
expect(configEntryNameFromPath(relative, AGENT_PREFIXES)).toBe("build")
})
test("regression #25713: parent /agents/ segment is irrelevant", () => {
const dir = "/srv/agents/team/.config/opencode"
const item = "/srv/agents/team/.config/opencode/agents/build.md"
const relative = posix.relative(dir, item)
expect(configEntryNameFromPath(relative, AGENT_PREFIXES)).toBe("build")
})
})

View File

@@ -0,0 +1,4 @@
---
---
Content

View File

@@ -0,0 +1,28 @@
---
description: "This is a description wrapped in quotes"
# field: this is a commented out field that should be ignored
occupation: This man has the following occupation: Software Engineer
title: 'Hello World'
name: John "Doe"
family: He has no 'family'
summary: >
This is a summary
url: https://example.com:8080/path?query=value
time: The time is 12:30:00 PM
nested: First: Second: Third: Fourth
quoted_colon: "Already quoted: no change needed"
single_quoted_colon: 'Single quoted: also fine'
mixed: He said "hello: world" and then left
empty:
dollar: Use $' and $& for special patterns
---
Content that should not be parsed:
fake_field: this is not yaml
another: neither is this
time: 10:30:00 AM
url: https://should-not-be-parsed.com:3000
The above lines look like YAML but are just content.

View File

@@ -0,0 +1,11 @@
# Response Formatting Requirements
Always structure your responses using clear markdown formatting:
- By default don't put information into tables for questions (but do put information into tables when creating or updating files)
- Use headings (##, ###) to organise sections, always
- Use bullet points or numbered lists for multiple items
- Use code blocks with language tags for any code
- Use **bold** for key terms and emphasis
- Use tables when comparing options or listing structured data
- Break long responses into logical sections with headings

View File

@@ -0,0 +1 @@
Content

View File

@@ -0,0 +1,13 @@
---
description: General coding and planning agent
mode: subagent
model: synthetic/hf:zai-org/GLM-4.7
tools:
write: true
read: true
edit: true
stuff: >
This is some stuff
---
Strictly follow da rules

View File

@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ConfigLSPV1 } from "@opencode-ai/core/v1/config/lsp"
// The LSP config refinement enforces: any custom (non-builtin) LSP server
// entry must declare an `extensions` array so the client knows which files
// the server should attach to. Builtin server IDs and explicitly disabled
// entries are exempt.
//
// `typescript` is a builtin server id (see src/lsp/server.ts).
describe("ConfigLSPV1.Info refinement", () => {
const decodeEffect = Schema.decodeUnknownSync(ConfigLSPV1.Info)
describe("accepted inputs", () => {
test("true and false pass (top-level toggle)", () => {
expect(decodeEffect(true)).toBe(true)
expect(decodeEffect(false)).toBe(false)
})
test("builtin server with no extensions passes", () => {
const input = { typescript: { command: ["typescript-language-server", "--stdio"] } }
expect(decodeEffect(input)).toEqual(input)
})
test("custom server WITH extensions passes", () => {
const input = {
"my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] },
}
expect(decodeEffect(input)).toEqual(input)
})
test("disabled custom server passes (no extensions needed)", () => {
const input = { "my-lsp": { disabled: true as const } }
expect(decodeEffect(input)).toEqual(input)
})
test("mix of builtin and custom with extensions passes", () => {
const input = {
typescript: { command: ["typescript-language-server", "--stdio"] },
"my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] },
}
expect(decodeEffect(input)).toEqual(input)
})
})
describe("rejected inputs", () => {
const expectedMessage = "For custom LSP servers, 'extensions' array is required."
test("custom server WITHOUT extensions fails via Effect decode", () => {
expect(() => decodeEffect({ "my-lsp": { command: ["my-lsp-bin"] } })).toThrow(expectedMessage)
})
test("custom server with empty extensions array fails (extensions must be non-empty-truthy)", () => {
// Boolean(['']) is true, so a non-empty array of strings is fine.
// Boolean([]) is also true in JS, so empty arrays are accepted by the
// refinement. This test documents current behavior.
const input = { "my-lsp": { command: ["my-lsp-bin"], extensions: [] } }
expect(decodeEffect(input)).toEqual(input)
})
test("custom server without extensions mixed with a valid builtin still fails", () => {
const input = {
typescript: { command: ["typescript-language-server", "--stdio"] },
"my-lsp": { command: ["my-lsp-bin"] },
}
expect(() => decodeEffect(input)).toThrow(expectedMessage)
})
})
})

View File

@@ -0,0 +1,228 @@
import { expect, test, describe } from "bun:test"
import { ConfigMarkdown } from "@/config/markdown"
describe("ConfigMarkdown: normal template", () => {
const template = `This is a @valid/path/to/a/file and it should also match at
the beginning of a line:
@another-valid/path/to/a/file
but this is not:
- Adds a "Co-authored-by:" footer which clarifies which AI agent
helped create this commit, using an appropriate \`noreply@...\`
or \`noreply@anthropic.com\` email address.
We also need to deal with files followed by @commas, ones
with @file-extensions.md, even @multiple.extensions.bak,
hidden directories like @.config/ or files like @.bashrc
and ones at the end of a sentence like @foo.md.
Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
as well as @~/home-files and @~/paths/under/home.txt.
If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
const matches = ConfigMarkdown.files(template)
test("should extract exactly 12 file references", () => {
expect(matches.length).toBe(12)
})
test("should extract valid/path/to/a/file", () => {
expect(matches[0][1]).toBe("valid/path/to/a/file")
})
test("should extract another-valid/path/to/a/file", () => {
expect(matches[1][1]).toBe("another-valid/path/to/a/file")
})
test("should extract paths ignoring comma after", () => {
expect(matches[2][1]).toBe("commas")
})
test("should extract a path with a file extension and comma after", () => {
expect(matches[3][1]).toBe("file-extensions.md")
})
test("should extract a path with multiple dots and comma after", () => {
expect(matches[4][1]).toBe("multiple.extensions.bak")
})
test("should extract hidden directory", () => {
expect(matches[5][1]).toBe(".config/")
})
test("should extract hidden file", () => {
expect(matches[6][1]).toBe(".bashrc")
})
test("should extract a file ignoring period at end of sentence", () => {
expect(matches[7][1]).toBe("foo.md")
})
test("should extract an absolute path with an extension", () => {
expect(matches[8][1]).toBe("/absolute/paths.txt")
})
test("should extract an absolute path without an extension", () => {
expect(matches[9][1]).toBe("/without/extensions")
})
test("should extract an absolute path in home directory", () => {
expect(matches[10][1]).toBe("~/home-files")
})
test("should extract an absolute path under home directory", () => {
expect(matches[11][1]).toBe("~/paths/under/home.txt")
})
test("should not match when preceded by backtick", () => {
const backtickTest = "This `@should/not/match` should be ignored"
const backtickMatches = ConfigMarkdown.files(backtickTest)
expect(backtickMatches.length).toBe(0)
})
test("should not match email addresses", () => {
const emailTest = "Contact user@example.com for help"
const emailMatches = ConfigMarkdown.files(emailTest)
expect(emailMatches.length).toBe(0)
})
})
describe("ConfigMarkdown: frontmatter parsing", async () => {
const parsed = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/frontmatter.md")
test("should parse without throwing", () => {
expect(parsed).toBeDefined()
expect(parsed.data).toBeDefined()
expect(parsed.content).toBeDefined()
})
test("should extract description field", () => {
expect(parsed.data.description).toBe("This is a description wrapped in quotes")
})
test("should extract occupation field with colon in value", () => {
expect(parsed.data.occupation).toBe("This man has the following occupation: Software Engineer")
})
test("should extract title field with single quotes", () => {
expect(parsed.data.title).toBe("Hello World")
})
test("should extract name field with embedded quotes", () => {
expect(parsed.data.name).toBe('John "Doe"')
})
test("should extract family field with embedded single quotes", () => {
expect(parsed.data.family).toBe("He has no 'family'")
})
test("should extract multiline summary field", () => {
expect(parsed.data.summary).toBe("This is a summary\n")
})
test("should not include commented fields in data", () => {
expect(parsed.data.field).toBeUndefined()
})
test("should extract URL with port", () => {
expect(parsed.data.url).toBe("https://example.com:8080/path?query=value")
})
test("should extract time with colons", () => {
expect(parsed.data.time).toBe("The time is 12:30:00 PM")
})
test("should extract value with multiple colons", () => {
expect(parsed.data.nested).toBe("First: Second: Third: Fourth")
})
test("should preserve already double-quoted values with colons", () => {
expect(parsed.data.quoted_colon).toBe("Already quoted: no change needed")
})
test("should preserve already single-quoted values with colons", () => {
expect(parsed.data.single_quoted_colon).toBe("Single quoted: also fine")
})
test("should extract value with quotes and colons mixed", () => {
expect(parsed.data.mixed).toBe('He said "hello: world" and then left')
})
test("should handle empty values", () => {
expect(parsed.data.empty).toBeNull()
})
test("should handle dollar sign replacement patterns literally", () => {
expect(parsed.data.dollar).toBe("Use $' and $& for special patterns")
})
test("should not parse fake yaml from content", () => {
expect(parsed.data.fake_field).toBeUndefined()
expect(parsed.data.another).toBeUndefined()
})
test("should extract content after frontmatter without modification", () => {
expect(parsed.content).toContain("Content that should not be parsed:")
expect(parsed.content).toContain("fake_field: this is not yaml")
expect(parsed.content).toContain("url: https://should-not-be-parsed.com:3000")
})
})
describe("ConfigMarkdown: frontmatter parsing w/ empty frontmatter", async () => {
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/empty-frontmatter.md")
test("should parse without throwing", () => {
expect(result).toBeDefined()
expect(result.data).toEqual({})
expect(result.content.trim()).toBe("Content")
})
})
describe("ConfigMarkdown: frontmatter parsing w/ no frontmatter", async () => {
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/no-frontmatter.md")
test("should parse without throwing", () => {
expect(result).toBeDefined()
expect(result.data).toEqual({})
expect(result.content.trim()).toBe("Content")
})
})
describe("ConfigMarkdown: frontmatter parsing w/ Markdown header", async () => {
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/markdown-header.md")
test("should parse and match", () => {
expect(result).toBeDefined()
expect(result.data).toEqual({})
expect(result.content.trim().replace(/\r\n/g, "\n")).toBe(`# Response Formatting Requirements
Always structure your responses using clear markdown formatting:
- By default don't put information into tables for questions (but do put information into tables when creating or updating files)
- Use headings (##, ###) to organise sections, always
- Use bullet points or numbered lists for multiple items
- Use code blocks with language tags for any code
- Use **bold** for key terms and emphasis
- Use tables when comparing options or listing structured data
- Break long responses into logical sections with headings`)
})
})
describe("ConfigMarkdown: frontmatter has weird model id", async () => {
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/weird-model-id.md")
test("should parse and match", () => {
expect(result).toBeDefined()
expect(result.data["description"]).toEqual("General coding and planning agent")
expect(result.data["mode"]).toEqual("subagent")
expect(result.data["model"]).toEqual("synthetic/hf:zai-org/GLM-4.7")
expect(result.data["tools"]["write"]).toBeTrue()
expect(result.data["tools"]["read"]).toBeTrue()
expect(result.data["stuff"]).toBe("This is some stuff\n")
expect(result.content.trim()).toBe("Strictly follow da rules")
})
})

View File

@@ -0,0 +1,886 @@
import { expect } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { Config } from "@/config/config"
import { ConfigPlugin } from "@/config/plugin"
import { CurrentWorkingDirectory } from "@/config/tui-cwd"
import { TuiConfig } from "../../src/config/tui"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Config.defaultLayer, FSUtil.defaultLayer))
const winIt = process.platform === "win32" ? it.instance : it.instance.skip
const globalConfigFiles = ["opencode.json", "opencode.jsonc", "tui.json", "tui.jsonc"].map((file) =>
path.join(Global.Path.config, file),
)
const cleanState = Effect.gen(function* () {
const fs = yield* FSUtil.Service
delete process.env.OPENCODE_CONFIG
delete process.env.OPENCODE_TUI_CONFIG
yield* Effect.forEach(globalConfigFiles, (file) => fs.remove(file, { force: true }).pipe(Effect.ignore), {
discard: true,
})
})
const withCleanState = <A, E, R>(self: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
cleanState,
() => self,
() => cleanState,
)
const withEnv = <A, E, R>(name: string, value: string | undefined, self: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env[name]
if (value === undefined) delete process.env[name]
else process.env[name] = value
return previous
}),
() => self,
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env[name]
else process.env[name] = previous
}),
)
const withPlatform = <A, E, R>(platform: typeof process.platform, self: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const original = Object.getOwnPropertyDescriptor(process, "platform")
Object.defineProperty(process, "platform", {
...original,
value: platform,
})
return original
}),
() => self,
(original) =>
Effect.sync(() => {
if (original) Object.defineProperty(process, "platform", original)
}),
)
const getTuiConfig = (directory: string) =>
TuiConfig.Service.use((svc) => svc.get()).pipe(
Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))),
)
const getTuiPluginOrigins = (directory: string) =>
TuiConfig.Service.use((svc) => svc.pluginOrigins()).pipe(
Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))),
)
it.instance("keeps server and tui plugin merge semantics aligned", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
const local = path.join(test.directory, ".opencode")
yield* fs.makeDirectory(local, { recursive: true })
yield* fs.writeJson(path.join(Global.Path.config, "opencode.json"), {
plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"],
})
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"],
})
yield* fs.writeJson(path.join(local, "opencode.json"), {
plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
})
yield* fs.writeJson(path.join(local, "tui.json"), {
plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
})
const server = yield* Config.use.get()
const tui = yield* getTuiConfig(test.directory)
const tuiOrigins = yield* getTuiPluginOrigins(test.directory)
const serverPlugins = (server.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
const tuiPlugins = (tui.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
expect(serverPlugins).toEqual(tuiPlugins)
expect(serverPlugins).toContain("shared-plugin@2.0.0")
expect(serverPlugins).not.toContain("shared-plugin@1.0.0")
const serverOrigins = server.plugin_origins ?? []
expect(serverOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(serverPlugins)
expect(tuiOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(tuiPlugins)
expect(serverOrigins.map((item) => item.scope)).toEqual(tuiOrigins.map((item) => item.scope))
}),
),
)
it.instance("loads tui config with the same precedence order as server config paths", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { theme: "global" })
yield* fs.writeJson(path.join(test.directory, "tui.json"), { theme: "project" })
yield* fs.writeWithDirs(
path.join(test.directory, ".opencode", "tui.json"),
JSON.stringify({ theme: "local", diff_style: "stacked" }, null, 2),
)
const config = yield* getTuiConfig(test.directory)
expect(config.theme).toBe("local")
expect(config.diff_style).toBe("stacked")
}),
),
)
it.instance("resolves attention config defaults and overrides", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
expect((yield* getTuiConfig(test.directory)).attention).toEqual({
enabled: false,
notifications: true,
sound: true,
volume: 0.4,
sound_pack: "opencode.default",
sounds: {},
})
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
attention: {
enabled: false,
notifications: false,
sound: false,
volume: 0.7,
sound_pack: "acme.soft",
sounds: {
default: path.join(test.directory, "default.mp3"),
question: pathToFileURL(path.join(test.directory, "question.mp3")).href,
error: "./error.mp3",
subagent_done: "./subagent-done.mp3",
},
},
})
expect((yield* getTuiConfig(test.directory)).attention).toEqual({
enabled: false,
notifications: false,
sound: false,
volume: 0.7,
sound_pack: "acme.soft",
sounds: {
default: path.join(test.directory, "default.mp3"),
question: path.join(test.directory, "question.mp3"),
error: path.join(test.directory, "error.mp3"),
subagent_done: path.join(test.directory, "subagent-done.mp3"),
},
})
}),
),
)
it.instance("migrates tui-specific keys from opencode.json when tui.json does not exist", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
const source = path.join(test.directory, "opencode.json")
yield* fs.writeJson(source, {
theme: "migrated-theme",
tui: { scroll_speed: 5 },
keybinds: { app_exit: "ctrl+q" },
})
const config = yield* getTuiConfig(test.directory)
expect(config.theme).toBe("migrated-theme")
expect(config.scroll_speed).toBe(5)
expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q")
expect(JSON.parse(yield* fs.readFileString(path.join(test.directory, "tui.json")))).toMatchObject({
theme: "migrated-theme",
scroll_speed: 5,
})
const server = JSON.parse(yield* fs.readFileString(source))
expect(server.theme).toBeUndefined()
expect(server.keybinds).toBeUndefined()
expect(server.tui).toBeUndefined()
expect(yield* fs.existsSafe(path.join(test.directory, "opencode.json.tui-migration.bak"))).toBe(true)
expect(yield* fs.existsSafe(path.join(test.directory, "tui.json"))).toBe(true)
}),
),
)
it.instance("migrates project legacy tui keys even when global tui.json already exists", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { theme: "global" })
yield* fs.writeJson(path.join(test.directory, "opencode.json"), {
theme: "project-migrated",
tui: { scroll_speed: 2 },
})
const config = yield* getTuiConfig(test.directory)
expect(config.theme).toBe("project-migrated")
expect(config.scroll_speed).toBe(2)
expect(yield* fs.existsSafe(path.join(test.directory, "tui.json"))).toBe(true)
const server = JSON.parse(yield* fs.readFileString(path.join(test.directory, "opencode.json")))
expect(server.theme).toBeUndefined()
expect(server.tui).toBeUndefined()
}),
),
)
it.instance("drops unknown legacy tui keys during migration", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "opencode.json"), {
theme: "migrated-theme",
tui: { scroll_speed: 2, foo: 1 },
})
const config = yield* getTuiConfig(test.directory)
expect(config.theme).toBe("migrated-theme")
expect(config.scroll_speed).toBe(2)
const migrated = JSON.parse(yield* fs.readFileString(path.join(test.directory, "tui.json")))
expect(migrated.scroll_speed).toBe(2)
expect(migrated.foo).toBeUndefined()
}),
),
)
it.instance("skips migration when opencode.jsonc is syntactically invalid", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeFileString(
path.join(test.directory, "opencode.jsonc"),
`{
"theme": "broken-theme",
"tui": { "scroll_speed": 2 }
"username": "still-broken"
}`,
)
const config = yield* getTuiConfig(test.directory)
expect(config.theme).toBeUndefined()
expect(config.scroll_speed).toBeUndefined()
expect(yield* fs.existsSafe(path.join(test.directory, "tui.json"))).toBe(false)
expect(yield* fs.existsSafe(path.join(test.directory, "opencode.jsonc.tui-migration.bak"))).toBe(false)
const source = yield* fs.readFileString(path.join(test.directory, "opencode.jsonc"))
expect(source).toContain('"theme": "broken-theme"')
expect(source).toContain('"tui": { "scroll_speed": 2 }')
}),
),
)
it.instance("skips migration when tui.json already exists", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "opencode.json"), { theme: "legacy" })
yield* fs.writeJson(path.join(test.directory, "tui.json"), { diff_style: "stacked" })
const config = yield* getTuiConfig(test.directory)
expect(config.diff_style).toBe("stacked")
expect(config.theme).toBeUndefined()
const server = JSON.parse(yield* fs.readFileString(path.join(test.directory, "opencode.json")))
expect(server.theme).toBe("legacy")
expect(yield* fs.existsSafe(path.join(test.directory, "opencode.json.tui-migration.bak"))).toBe(false)
}),
),
)
it.instance("continues loading tui config when legacy source cannot be stripped", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
const source = path.join(test.directory, "opencode.json")
yield* fs.writeJson(source, { theme: "readonly-theme" })
yield* Effect.acquireUseRelease(
fs.chmod(source, 0o444),
() =>
Effect.gen(function* () {
const config = yield* getTuiConfig(test.directory)
expect(config.theme).toBe("readonly-theme")
expect(yield* fs.existsSafe(path.join(test.directory, "tui.json"))).toBe(true)
const server = JSON.parse(yield* fs.readFileString(source))
expect(server.theme).toBe("readonly-theme")
}),
() => fs.chmod(source, 0o644).pipe(Effect.ignore),
)
}),
),
)
it.instance("migration backup preserves JSONC comments", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeFileString(
path.join(test.directory, "opencode.jsonc"),
`{
// top-level comment
"theme": "jsonc-theme",
"tui": {
// nested comment
"scroll_speed": 1.5
}
}`,
)
yield* getTuiConfig(test.directory)
const backup = yield* fs.readFileString(path.join(test.directory, "opencode.jsonc.tui-migration.bak"))
expect(backup).toContain("// top-level comment")
expect(backup).toContain("// nested comment")
expect(backup).toContain('"theme": "jsonc-theme"')
expect(backup).toContain('"scroll_speed": 1.5')
}),
),
)
it.instance("migrates legacy tui keys across multiple opencode.json levels", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
const nested = path.join(test.directory, "apps", "client")
yield* fs.makeDirectory(nested, { recursive: true })
yield* fs.writeJson(path.join(test.directory, "opencode.json"), { theme: "root-theme" })
yield* fs.writeJson(path.join(nested, "opencode.json"), { theme: "nested-theme" })
const config = yield* getTuiConfig(nested)
expect(config.theme).toBe("nested-theme")
expect(yield* fs.existsSafe(path.join(test.directory, "tui.json"))).toBe(true)
expect(yield* fs.existsSafe(path.join(nested, "tui.json"))).toBe(true)
}),
),
)
it.instance("flattens nested tui key inside tui.json", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
theme: "outer",
tui: { scroll_speed: 3, diff_style: "stacked" },
})
const config = yield* getTuiConfig(test.directory)
expect(config.scroll_speed).toBe(3)
expect(config.diff_style).toBe("stacked")
expect(config.theme).toBe("outer")
}),
),
)
it.instance("top-level keys in tui.json take precedence over nested tui key", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
diff_style: "auto",
tui: { diff_style: "stacked", scroll_speed: 2 },
})
const config = yield* getTuiConfig(test.directory)
expect(config.diff_style).toBe("auto")
expect(config.scroll_speed).toBe(2)
}),
),
)
it.instance("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE_CONFIG)", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
const custom = path.join(test.directory, "custom-tui.json")
yield* fs.writeJson(path.join(test.directory, "tui.json"), { theme: "project", diff_style: "auto" })
yield* fs.writeJson(custom, { theme: "custom", diff_style: "stacked" })
yield* withEnv(
"OPENCODE_TUI_CONFIG",
custom,
Effect.gen(function* () {
const config = yield* getTuiConfig(test.directory)
expect(config.theme).toBe("project")
expect(config.diff_style).toBe("auto")
}),
)
}),
),
)
it.instance("merges keybind overrides across precedence layers", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { keybinds: { app_exit: "ctrl+q" } })
yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { theme_list: "ctrl+k" } })
const config = yield* getTuiConfig(test.directory)
expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q")
expect(config.keybinds.get("theme.switch")?.[0]?.key).toBe("ctrl+k")
}),
),
)
it.instance("ignores unknown keybind names without dropping valid overrides from the same file", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
keybinds: {
session_delete: "ctrl+d",
not_a_real_keybind: "ctrl+q",
},
})
const config = yield* getTuiConfig(test.directory)
expect(config.keybinds.get("session.delete")?.[0]?.key).toBe("ctrl+d")
expect(config.keybinds.get("not_a_real_keybind")).toEqual([])
}),
),
)
it.instance("resolves keybind lookup from canonical keybinds", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
keybinds: {
leader: { key: { name: "g", ctrl: true } },
command_list: "alt+p",
which_key_toggle: "alt+k",
editor_open: "ctrl+e",
"prompt.autocomplete.next": "ctrl+j",
"dialog.prompt.submit": "ctrl+s",
"dialog.mcp.toggle": "ctrl+t",
model_favorite_toggle: "ctrl+f",
"dialog.plugins.install": "shift+i",
},
leader_timeout: 1234,
})
const config = yield* getTuiConfig(test.directory)
expect(config.keybinds.get("leader")?.[0]?.key).toEqual({ name: "g", ctrl: true })
expect(config.leader_timeout).toBe(1234)
expect(config.keybinds.get("command.palette.show")?.[0]?.key).toBe("alt+p")
expect(config.keybinds.get("session.new")?.[0]?.key).toBe("<leader>n")
expect(config.keybinds.get("which-key.toggle")?.[0]?.key).toBe("alt+k")
expect(config.keybinds.get("which-key.layout.toggle")?.[0]?.key).toBe("ctrl+alt+shift+k")
expect(config.keybinds.get("which-key.pending.toggle")?.[0]?.key).toBe("ctrl+alt+shift+p")
expect(config.keybinds.get("which-key.group.next")?.[0]?.key).toBe("ctrl+alt+right,ctrl+alt+]")
expect((config.keybinds.get("which-key.toggle")?.[0] as { desc?: unknown } | undefined)?.desc).toBe(
"Toggle which-key panel",
)
expect(config.keybinds.get("prompt.editor")?.[0]?.key).toBe("ctrl+e")
expect(config.keybinds.get("prompt.autocomplete.next")?.[0]?.key).toBe("ctrl+j")
expect(config.keybinds.get("dialog.prompt.submit")?.[0]?.key).toBe("ctrl+s")
expect(config.keybinds.get("dialog.mcp.toggle")?.[0]?.key).toBe("ctrl+t")
expect(config.keybinds.get("model.dialog.favorite")?.[0]?.key).toBe("ctrl+f")
expect(config.keybinds.get("dialog.plugins.install")?.[0]?.key).toBe("shift+i")
expect(
config.keybinds.gather("plugins.dialog", ["dialog.plugins.install"]).map((binding) => binding.cmd),
).toEqual(["dialog.plugins.install"])
}),
),
)
it.instance("keybinds accept OpenTUI binding specs", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
keybinds: {
command_list: [{ key: "alt+p", preventDefault: false }],
editor_open: { key: { name: "e", ctrl: true }, group: "Explicit" },
"prompt.autocomplete.next": false,
plugin_manager: "ctrl+shift+p",
},
})
const config = yield* getTuiConfig(test.directory)
expect(config.keybinds.get("command.palette.show")).toEqual([
{ key: "alt+p", cmd: "command.palette.show", preventDefault: false, desc: "List available commands" },
])
expect(config.keybinds.get("prompt.editor")?.[0]).toMatchObject({
key: { name: "e", ctrl: true },
cmd: "prompt.editor",
group: "Explicit",
})
expect(config.keybinds.get("prompt.autocomplete.next")).toEqual([])
expect(config.keybinds.get("plugins.list")?.[0]?.key).toBe("ctrl+shift+p")
}),
),
)
winIt("defaults Ctrl+Z to input undo on Windows", () =>
withCleanState(
Effect.gen(function* () {
const test = yield* TestInstance
const config = yield* getTuiConfig(test.directory)
expect(config.keybinds.get("terminal.suspend")).toEqual([])
expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z")
}),
),
)
winIt("keeps explicit input undo overrides on Windows", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { input_undo: "ctrl+y" } })
const config = yield* getTuiConfig(test.directory)
expect(config.keybinds.get("terminal.suspend")).toEqual([])
expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y")
}),
),
)
winIt("ignores terminal suspend bindings on Windows", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { terminal_suspend: "alt+z" } })
const config = yield* getTuiConfig(test.directory)
expect(config.keybinds.get("terminal.suspend")).toEqual([])
expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z")
}),
),
)
it.instance("applies Windows keybind defaults", () =>
withCleanState(
withPlatform(
"win32",
Effect.gen(function* () {
const test = yield* TestInstance
const config = yield* getTuiConfig(test.directory)
expect(config.keybinds.get("terminal.suspend")).toEqual([])
expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z")
}),
),
),
)
it.instance("ignores explicit keybind terminal suspend binding on Windows", () =>
withCleanState(
withPlatform(
"win32",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
keybinds: {
terminal_suspend: "alt+z",
},
})
const config = yield* getTuiConfig(test.directory)
expect(config.keybinds.get("terminal.suspend")).toEqual([])
}),
),
),
)
it.instance("keeps explicit configured keybind input undo on Windows", () =>
withCleanState(
withPlatform(
"win32",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
keybinds: {
input_undo: "ctrl+y",
},
})
const config = yield* getTuiConfig(test.directory)
expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y")
}),
),
),
)
it.instance("OPENCODE_TUI_CONFIG provides settings when no project config exists", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
const custom = path.join(test.directory, "custom-tui.json")
yield* fs.writeJson(custom, { theme: "from-env", diff_style: "stacked" })
yield* withEnv(
"OPENCODE_TUI_CONFIG",
custom,
Effect.gen(function* () {
const config = yield* getTuiConfig(test.directory)
expect(config.theme).toBe("from-env")
expect(config.diff_style).toBe("stacked")
}),
)
}),
),
)
it.instance("does not derive tui path from OPENCODE_CONFIG", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
const customDir = path.join(test.directory, "custom")
yield* fs.makeDirectory(customDir, { recursive: true })
yield* fs.writeJson(path.join(customDir, "opencode.json"), { model: "test/model" })
yield* fs.writeJson(path.join(customDir, "tui.json"), { theme: "should-not-load" })
yield* withEnv(
"OPENCODE_CONFIG",
path.join(customDir, "opencode.json"),
Effect.gen(function* () {
const config = yield* getTuiConfig(test.directory)
expect(config.theme).toBeUndefined()
}),
)
}),
),
)
it.instance("applies env and file substitutions in tui.json", () =>
withCleanState(
withEnv(
"TUI_THEME_TEST",
"env-theme",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeFileString(path.join(test.directory, "keybind.txt"), "ctrl+q")
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
theme: "{env:TUI_THEME_TEST}",
keybinds: { app_exit: "{file:keybind.txt}" },
})
const config = yield* getTuiConfig(test.directory)
expect(config.theme).toBe("env-theme")
expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q")
}),
),
),
)
it.instance("applies file substitutions when first identical token is in a commented line", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeFileString(path.join(test.directory, "theme.txt"), "resolved-theme")
yield* fs.writeFileString(
path.join(test.directory, "tui.jsonc"),
`{
// "theme": "{file:theme.txt}",
"theme": "{file:theme.txt}"
}`,
)
const config = yield* getTuiConfig(test.directory)
expect(config.theme).toBe("resolved-theme")
}),
),
)
it.instance("loads .opencode/tui.json", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeWithDirs(
path.join(test.directory, ".opencode", "tui.json"),
JSON.stringify({ diff_style: "stacked" }, null, 2),
)
const config = yield* getTuiConfig(test.directory)
expect(config.diff_style).toBe("stacked")
}),
),
)
it.instance("supports tuple plugin specs with options in tui.json", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
plugin: [["acme-plugin@1.2.3", { enabled: true, label: "demo" }]],
})
const config = yield* getTuiConfig(test.directory)
const origins = yield* getTuiPluginOrigins(test.directory)
expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
expect(origins).toEqual([
{
spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
scope: "local",
source: path.join(test.directory, "tui.json"),
},
])
}),
),
)
it.instance("deduplicates tuple plugin specs by name with higher precedence winning", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
plugin: [["acme-plugin@1.0.0", { source: "global" }]],
})
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
plugin: [
["acme-plugin@2.0.0", { source: "project" }],
["second-plugin@3.0.0", { source: "project" }],
],
})
const config = yield* getTuiConfig(test.directory)
const origins = yield* getTuiPluginOrigins(test.directory)
expect(config.plugin).toEqual([
["acme-plugin@2.0.0", { source: "project" }],
["second-plugin@3.0.0", { source: "project" }],
])
expect(origins).toEqual([
{
spec: ["acme-plugin@2.0.0", { source: "project" }],
scope: "local",
source: path.join(test.directory, "tui.json"),
},
{
spec: ["second-plugin@3.0.0", { source: "project" }],
scope: "local",
source: path.join(test.directory, "tui.json"),
},
])
}),
),
)
it.instance("tracks global and local plugin metadata in merged tui config", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { plugin: ["global-plugin@1.0.0"] })
yield* fs.writeJson(path.join(test.directory, "tui.json"), { plugin: ["local-plugin@2.0.0"] })
const config = yield* getTuiConfig(test.directory)
const origins = yield* getTuiPluginOrigins(test.directory)
expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
expect(origins).toEqual([
{
spec: "global-plugin@1.0.0",
scope: "global",
source: path.join(Global.Path.config, "tui.json"),
},
{
spec: "local-plugin@2.0.0",
scope: "local",
source: path.join(test.directory, "tui.json"),
},
])
}),
),
)
it.instance("merges plugin_enabled flags across config layers", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
plugin_enabled: {
"internal:sidebar-context": false,
"demo.plugin": true,
},
})
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
plugin_enabled: {
"demo.plugin": false,
"local.plugin": true,
},
})
const config = yield* getTuiConfig(test.directory)
expect(config.plugin_enabled).toEqual({
"internal:sidebar-context": false,
"demo.plugin": false,
"local.plugin": true,
})
}),
),
)
it.instance("silently skips malformed tui.json - load failures degrade to {}", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeFileString(path.join(test.directory, "tui.json"), '{ "theme": "broken",')
yield* fs.writeWithDirs(path.join(test.directory, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" }))
const config = yield* getTuiConfig(test.directory)
expect(config.theme).toBe("fallback")
}),
),
)
it.instance("silently skips non-ENOENT read failures (e.g. tui.json is a directory) - fallback layer still loads", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.makeDirectory(path.join(test.directory, "tui.json"), { recursive: true })
yield* fs.writeWithDirs(path.join(test.directory, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" }))
const config = yield* getTuiConfig(test.directory)
expect(config.theme).toBe("fallback")
}),
),
)
it.instance("missing tui.json - silently treated as empty (ENOENT path)", () =>
withCleanState(
Effect.gen(function* () {
const test = yield* TestInstance
const config = yield* getTuiConfig(test.directory)
expect(config).toBeDefined()
expect(config.theme).toBeUndefined()
}),
),
)

View File

@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test"
import { getAdapter, registerAdapter } from "../../src/control-plane/adapters"
import { ProjectV2 } from "@opencode-ai/core/project"
import type { WorkspaceInfo } from "../../src/control-plane/types"
function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInfo {
return {
id: "workspace-test" as WorkspaceInfo["id"],
type,
name: "workspace-test",
branch: null,
directory: null,
extra: null,
projectID,
}
}
function adapter(dir: string) {
return {
name: dir,
description: dir,
configure(input: WorkspaceInfo) {
return input
},
async create() {},
async remove() {},
target() {
return {
type: "local" as const,
directory: dir,
}
},
}
}
describe("control-plane/adapters", () => {
test("isolates custom adapters by project", async () => {
const type = `demo-${Math.random().toString(36).slice(2)}`
const one = ProjectV2.ID.make(`project-${Math.random().toString(36).slice(2)}`)
const two = ProjectV2.ID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdapter(one, type, adapter("/one"))
registerAdapter(two, type, adapter("/two"))
expect(await (await getAdapter(one, type)).target(info(one, type))).toEqual({
type: "local",
directory: "/one",
})
expect(await (await getAdapter(two, type)).target(info(two, type))).toEqual({
type: "local",
directory: "/two",
})
})
test("latest install wins within a project", async () => {
const type = `demo-${Math.random().toString(36).slice(2)}`
const id = ProjectV2.ID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdapter(id, type, adapter("/one"))
expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({
type: "local",
directory: "/one",
})
registerAdapter(id, type, adapter("/two"))
expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({
type: "local",
directory: "/two",
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
import { test } from "bun:test"
import { Context, Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
class A extends Context.Service<A, { readonly value: "a" }>()("test/A") {}
class B extends Context.Service<B, { readonly value: "b" }>()("test/B") {}
class C extends Context.Service<C, { readonly value: "c" }>()("test/C") {}
class LayerError {
readonly _tag = "LayerError"
}
class NotFoundError {
readonly _tag = "NotFoundError"
}
class DiskError {
readonly _tag = "DiskError"
}
class NetworkError {
readonly _tag = "NetworkError"
}
const aImplementation = Layer.succeed(A, A.of({ value: "a" }))
const bImplementation = Layer.effect(
B,
Effect.gen(function* () {
yield* A
return B.of({ value: "b" })
}),
)
const cImplementation = Layer.effect(
C,
Effect.gen(function* () {
yield* A
yield* B
return C.of({ value: "c" })
}),
)
const failingAImplementation = Layer.effect(A, Effect.fail(new LayerError()))
const notFoundAImplementation = Layer.effect(A, Effect.fail(new NotFoundError()))
const diskAImplementation = Layer.effect(A, Effect.fail(new DiskError()))
const networkAImplementation = Layer.effect(A, Effect.fail(new NetworkError()))
const notFoundOrDiskAImplementation = Layer.effect(A, Effect.fail(new NotFoundError() as NotFoundError | DiskError))
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false
type Assert<T extends true> = T
type AProvides = Assert<Equal<Layer.Success<typeof aImplementation>, A>>
type ARequires = Assert<Equal<Layer.Services<typeof aImplementation>, never>>
type BProvides = Assert<Equal<Layer.Success<typeof bImplementation>, B>>
type BRequires = Assert<Equal<Layer.Services<typeof bImplementation>, A>>
type CRequires = Assert<Equal<Layer.Services<typeof cImplementation>, A | B>>
void (0 as unknown as AProvides)
void (0 as unknown as ARequires)
void (0 as unknown as BProvides)
void (0 as unknown as BRequires)
void (0 as unknown as CRequires)
const a = LayerNode.make(aImplementation, [])
const b = LayerNode.make(bImplementation, [a])
const c = LayerNode.make(cImplementation, [a, b])
const failingA = LayerNode.make(failingAImplementation, [])
const bWithFailingA = LayerNode.make(bImplementation, [failingA])
const notFoundA = LayerNode.make(notFoundAImplementation, [])
const diskA = LayerNode.make(diskAImplementation, [])
const networkA = LayerNode.make(networkAImplementation, [])
const notFoundOrDiskA = LayerNode.make(notFoundOrDiskAImplementation, [])
// @ts-expect-error B requires A
LayerNode.make(bImplementation, [])
// @ts-expect-error C requires both A and B
LayerNode.make(cImplementation, [a])
type ANodeProvides = Assert<Equal<typeof a, LayerNode.Node<A, never>>>
type BNodeProvides = Assert<Equal<typeof b, LayerNode.Node<B, never>>>
type CNodeProvides = Assert<Equal<typeof c, LayerNode.Node<C, never>>>
type FailingANodeError = Assert<Equal<typeof failingA, LayerNode.Node<A, LayerError>>>
type DependentNodeError = Assert<Equal<typeof bWithFailingA, LayerNode.Node<B, LayerError>>>
void (0 as unknown as ANodeProvides)
void (0 as unknown as BNodeProvides)
void (0 as unknown as CNodeProvides)
void (0 as unknown as FailingANodeError)
void (0 as unknown as DependentNodeError)
const closed = LayerNode.buildLayer(c)
const closedWithError = LayerNode.buildLayer(bWithFailingA)
type ClosedProvides = Assert<Equal<Layer.Success<typeof closed>, C>>
type ClosedRequires = Assert<Equal<Layer.Services<typeof closed>, never>>
type ClosedError = Assert<Equal<Layer.Error<typeof closedWithError>, LayerError>>
void (0 as unknown as ClosedProvides)
void (0 as unknown as ClosedRequires)
void (0 as unknown as ClosedError)
const replacement = LayerNode.make(Layer.succeed(A, A.of({ value: "a" })), [])
LayerNode.replace(a, Layer.succeed(A, A.of({ value: "a" })))
LayerNode.replace(notFoundOrDiskA, notFoundAImplementation)
LayerNode.replace(notFoundOrDiskA, diskAImplementation)
LayerNode.replaceWithNode(a, replacement)
// @ts-expect-error An override for A must still provide A
LayerNode.replaceWithNode(a, b)
// @ts-expect-error A replacement cannot introduce NetworkError
LayerNode.replace(notFoundOrDiskA, networkAImplementation)
// @ts-expect-error A replacement layer must not have unresolved dependencies
LayerNode.replace(b, bImplementation)
test("type exploration compiles", () => {})

View File

@@ -0,0 +1,204 @@
import { describe, expect, test } from "bun:test"
import { Cause, Context, Effect, Exit, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
const { buildLayer: build, group, replace, replaceWithNode } = LayerNode
const node = LayerNode.make
class Value extends Context.Service<Value, { readonly value: string }>()("test/Value") {}
class Greeting extends Context.Service<Greeting, { readonly text: string }>()("test/Greeting") {}
const value = LayerNode.make(Layer.succeed(Value, Value.of({ value: "production" })), [])
const greetingImplementation = Layer.effect(
Greeting,
Effect.gen(function* () {
return Greeting.of({ text: `hello ${(yield* Value).value}` })
}),
)
const greeting = LayerNode.make(greetingImplementation, [value])
// @ts-expect-error Greeting requires Value
LayerNode.make(greetingImplementation, [])
describe("app graph", () => {
test("creates any selected dependency layer", async () => {
const result = Effect.gen(function* () {
return (yield* Greeting).text
}).pipe(Effect.provide(build(greeting)))
expect(await Effect.runPromise(result)).toBe("hello production")
})
test("applies overrides before dependency materialization", async () => {
const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
const graph = build(greeting, { replacements: [replace(value, replacement)] })
const result = Effect.gen(function* () {
return (yield* Greeting).text
}).pipe(Effect.provide(graph))
expect(await Effect.runPromise(result)).toBe("hello simulation")
})
test("acquires a shared dependency once", async () => {
class Shared extends Context.Service<Shared, { readonly value: string }>()("test/Shared") {}
class Left extends Context.Service<Left, { readonly value: string }>()("test/Left") {}
class Right extends Context.Service<Right, { readonly value: string }>()("test/Right") {}
let acquisitions = 0
const shared = node(
Layer.effect(
Shared,
Effect.sync(() => {
acquisitions++
return Shared.of({ value: "shared" })
}),
),
[],
)
const left = node(
Layer.effect(
Left,
Effect.gen(function* () {
return Left.of({ value: `${(yield* Shared).value}-left` })
}),
),
[shared],
)
const right = node(
Layer.effect(
Right,
Effect.gen(function* () {
return Right.of({ value: `${(yield* Shared).value}-right` })
}),
),
[shared],
)
const result = Effect.gen(function* () {
return [(yield* Left).value, (yield* Right).value]
}).pipe(Effect.provide(build(group([left, right]))))
expect(await Effect.runPromise(result)).toEqual(["shared-left", "shared-right"])
expect(acquisitions).toBe(1)
})
test("applies a replacement to every transitive consumer", async () => {
class Left extends Context.Service<Left, { readonly value: string }>()("test/ReplacementLeft") {}
class Right extends Context.Service<Right, { readonly value: string }>()("test/ReplacementRight") {}
const left = node(
Layer.effect(
Left,
Effect.gen(function* () {
return Left.of({ value: (yield* Value).value })
}),
),
[value],
)
const right = node(
Layer.effect(
Right,
Effect.gen(function* () {
return Right.of({ value: (yield* Value).value })
}),
),
[value],
)
const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
const graph = build(group([left, right]), { replacements: [replace(value, replacement)] })
const result = Effect.gen(function* () {
return [(yield* Left).value, (yield* Right).value]
}).pipe(Effect.provide(graph))
expect(await Effect.runPromise(result)).toEqual(["simulation", "simulation"])
})
test("propagates layer acquisition errors", async () => {
class AcquisitionError {
readonly _tag = "AcquisitionError"
}
const failing = node(Layer.effect(Value, Effect.fail(new AcquisitionError())), [])
const exit = await Effect.runPromiseExit(Effect.provide(Value, build(failing)))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(AcquisitionError)
})
test("groups expose every selected service", async () => {
class Count extends Context.Service<Count, { readonly value: number }>()("test/Count") {}
const count = node(Layer.succeed(Count, Count.of({ value: 3 })), [])
const result = Effect.gen(function* () {
return { text: (yield* Value).value, count: (yield* Count).value }
}).pipe(Effect.provide(build(group([value, count]))))
expect(await Effect.runPromise(result)).toEqual({ text: "production", count: 3 })
})
test("builds an empty group", async () => {
expect(await Effect.runPromise(Effect.succeed("ok").pipe(Effect.provide(build(group([])))))).toBe("ok")
})
test("builds replacements with their own dependencies", async () => {
class ReplacementConfig extends Context.Service<ReplacementConfig, { readonly value: string }>()(
"test/ReplacementConfig",
) {}
const replacementConfig = node(Layer.succeed(ReplacementConfig, ReplacementConfig.of({ value: "replacement" })), [])
const replacement = node(
Layer.effect(
Value,
Effect.gen(function* () {
return Value.of({ value: (yield* ReplacementConfig).value })
}),
),
[replacementConfig],
)
const result = Effect.gen(function* () {
return (yield* Greeting).text
}).pipe(Effect.provide(build(greeting, { replacements: [replaceWithNode(value, replacement)] })))
expect(await Effect.runPromise(result)).toBe("hello replacement")
})
test("does not acquire unreachable replacements", async () => {
let acquisitions = 0
const unreachable = node(Layer.succeed(Value, Value.of({ value: "unreachable" })), [])
const replacement = Layer.effect(
Value,
Effect.sync(() => {
acquisitions++
return Value.of({ value: "replacement" })
}),
)
await Effect.runPromise(
Effect.provide(Greeting, build(greeting, { replacements: [replace(unreachable, replacement)] })),
)
expect(acquisitions).toBe(0)
})
test("rejects a direct cycle", () => {
const cyclic = node(Layer.succeed(Value, Value.of({ value: "cyclic" })), [])
;(cyclic.dependencies as LayerNode.Node<unknown, unknown>[]).push(cyclic)
expect(() => build(cyclic)).toThrow("Cycle detected in app graph: layer#1 -> layer#1")
})
test("rejects an indirect cycle", () => {
const first = node(Layer.succeed(Value, Value.of({ value: "first" })), [])
const second = node(Layer.succeed(Value, Value.of({ value: "second" })), [first])
const third = node(Layer.succeed(Value, Value.of({ value: "third" })), [second])
;(first.dependencies as LayerNode.Node<unknown, unknown>[]).push(third)
expect(() => build(first)).toThrow("Cycle detected in app graph: layer#1 -> layer#2 -> layer#3 -> layer#1")
})
test("rejects a cycle introduced by a replacement", () => {
const replacement = node(Layer.succeed(Value, Value.of({ value: "replacement" })), [])
const consumer = node(greetingImplementation, [value])
;(replacement.dependencies as LayerNode.Node<unknown, unknown>[]).push(consumer)
expect(() => build(consumer, { replacements: [replaceWithNode(value, replacement)] })).toThrow(
"Cycle detected in app graph: layer#1 -> layer#2 -> layer#1",
)
})
})

View File

@@ -0,0 +1,99 @@
import { expect } from "bun:test"
import { Context, Deferred, Effect, Fiber, Layer, Logger } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppLayer } from "../../src/effect/app-runtime"
import { EffectBridge } from "@/effect/bridge"
import { InstanceRef } from "../../src/effect/instance-ref"
import * as Observability from "@opencode-ai/core/observability"
import { attach } from "../../src/effect/run-service"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(CrossSpawnSpawner.defaultLayer)
function check(loggers: ReadonlySet<Logger.Logger<unknown, any>>) {
return {
tracerLogger: loggers.has(Logger.tracerLogger),
size: loggers.size,
}
}
it.live("makeRuntime installs the observability logger", () =>
Effect.gen(function* () {
class Dummy extends Context.Service<Dummy, { readonly current: () => Effect.Effect<ReturnType<typeof check>> }>()(
"@test/Dummy",
) {}
const layer = Layer.effect(
Dummy,
Effect.gen(function* () {
return Dummy.of({
current: () => Effect.map(Effect.service(Logger.CurrentLoggers), check),
})
}),
)
const current = yield* Dummy.use((svc) => svc.current()).pipe(
Effect.provide(Layer.provideMerge(layer, Observability.layer)),
)
expect(current.size).toBeGreaterThan(0)
}),
)
it.live("AppLayer also installs the observability logger", () =>
Effect.gen(function* () {
const current = yield* Effect.map(Effect.service(Logger.CurrentLoggers), check).pipe(Effect.provide(AppLayer))
expect(current.size).toBeGreaterThan(0)
}),
)
it.instance(
"attach preserves InstanceRef from the current fiber context",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const current = yield* attach(
Effect.gen(function* () {
return (yield* InstanceRef)?.directory
}),
)
expect(current).toBe(test.directory)
}),
{ git: true },
)
it.instance(
"EffectBridge preserves logger and instance context across async boundaries",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const bridge = yield* EffectBridge.make()
const started = yield* Deferred.make<void>()
const fiber = yield* Effect.gen(function* () {
yield* Deferred.succeed(started, undefined)
return yield* Effect.promise(() =>
Promise.resolve().then(() =>
bridge.promise(
Effect.gen(function* () {
return {
directory: (yield* InstanceRef)?.directory,
...check(yield* Effect.service(Logger.CurrentLoggers)),
}
}),
),
),
)
}).pipe(Effect.forkScoped)
yield* Deferred.await(started)
const result = yield* Fiber.join(fiber)
expect(result.directory).toBe(test.directory)
expect(result.size).toBeGreaterThan(0)
}).pipe(Effect.provide(Observability.layer)),
{ git: true },
)

View File

@@ -0,0 +1,65 @@
import { describe, expect } from "bun:test"
import { Config, ConfigProvider, Context, Effect, Layer, Option } from "effect"
import { ConfigService } from "../../src/effect/config-service"
import { it } from "../lib/effect"
class TestConfig extends ConfigService.Service<TestConfig>()("@test/ConfigService", {
name: Config.string("NAME"),
token: Config.string("TOKEN").pipe(Config.option),
port: Config.number("PORT").pipe(Config.withDefault(3000)),
}) {}
const fromConfig = (input: Record<string, unknown>) =>
TestConfig.defaultLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input))))
const readConfig = TestConfig.useSync((config) => config)
describe("ConfigService", () => {
it.effect("defaultLayer parses values from the active ConfigProvider", () =>
Effect.gen(function* () {
const config = yield* readConfig.pipe(
Effect.provide(
fromConfig({
NAME: "kit",
TOKEN: "secret",
PORT: "4096",
}),
),
)
expect(config.name).toBe("kit")
expect(config.token).toEqual(Option.some("secret"))
expect(config.port).toBe(4096)
}),
)
it.effect("defaultLayer applies Effect Config defaults", () =>
Effect.gen(function* () {
const config = yield* readConfig.pipe(Effect.provide(fromConfig({ NAME: "kit" })))
expect(config.name).toBe("kit")
expect(config.token).toEqual(Option.none())
expect(config.port).toBe(3000)
}),
)
it.effect("layer provides an already parsed service value", () =>
Effect.gen(function* () {
const config = yield* readConfig.pipe(
Effect.provide(
TestConfig.layer({
name: "direct",
token: Option.some("parsed"),
port: 9000,
}),
),
)
expect(config).toEqual({
name: "direct",
token: Option.some("parsed"),
port: 9000,
} satisfies Context.Service.Shape<typeof TestConfig>)
}),
)
})

View File

@@ -0,0 +1,391 @@
import { expect } from "bun:test"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { $ } from "bun"
import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import {
disposeAllInstancesEffect,
provideInstanceEffect,
reloadInstance,
testInstanceStoreLayer,
tmpdirScoped,
} from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer))
const access = <A, E>(state: InstanceState.InstanceState<A, E>, dir: string) =>
InstanceState.get(state).pipe(provideInstanceEffect(dir))
const tmpdirGitScoped = Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
yield* Effect.promise(() => $`git commit --allow-empty --amend -m ${`root commit ${dir}`}`.cwd(dir).quiet())
return dir
})
it.live("InstanceState caches values per directory", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
let n = 0
const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
const a = yield* access(state, dir)
const b = yield* access(state, dir)
expect(a).toBe(b)
expect(n).toBe(1)
}),
)
it.live("InstanceState isolates directories", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
let n = 0
const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
const a = yield* access(state, one)
const b = yield* access(state, two)
const c = yield* access(state, one)
expect(a).toBe(c)
expect(a).not.toBe(b)
expect(n).toBe(2)
}),
)
it.live("InstanceState invalidates on reload", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const seen: string[] = []
let n = 0
const state = yield* InstanceState.make(() =>
Effect.acquireRelease(
Effect.sync(() => ({ n: ++n })),
(value) =>
Effect.sync(() => {
seen.push(String(value.n))
}),
),
)
const a = yield* access(state, dir)
yield* reloadInstance({ directory: dir })
const b = yield* access(state, dir)
expect(a).not.toBe(b)
expect(seen).toEqual(["1"])
}),
)
it.live("InstanceState invalidates on disposeAll", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
const seen: string[] = []
const state = yield* InstanceState.make((ctx) =>
Effect.acquireRelease(
Effect.sync(() => ({ dir: ctx.directory })),
(value) =>
Effect.sync(() => {
seen.push(value.dir)
}),
),
)
yield* access(state, one)
yield* access(state, two)
yield* disposeAllInstancesEffect
expect(seen.sort()).toEqual([one, two].sort())
}),
)
it.live("InstanceState.get reads the current directory lazily", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/InstanceStateLazy") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
const get = InstanceState.get(state)
return Test.of({
get: Effect.fn("Test.get")(function* () {
return yield* get
}),
})
}),
)
}
yield* Effect.gen(function* () {
const a = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(one))
const b = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(two))
expect(a).toBe(one)
expect(b).toBe(two)
}).pipe(Effect.provide(Test.layer))
}),
)
it.live("InstanceState preserves directory across async boundaries", () =>
Effect.gen(function* () {
const one = yield* tmpdirGitScoped
const two = yield* tmpdirGitScoped
const three = yield* tmpdirGitScoped
interface Api {
readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }>
}
class Test extends Context.Service<Test, Api>()("@test/InstanceStateAsync") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.sync(() => ({
directory: ctx.directory,
worktree: ctx.worktree,
project: ctx.project.id,
})),
)
return Test.of({
get: Effect.fn("Test.get")(function* () {
yield* Effect.sleep(Duration.millis(1))
yield* Effect.sleep(Duration.millis(1))
for (let i = 0; i < 100; i++) {
yield* Effect.yieldNow
}
for (let i = 0; i < 100; i++) {
yield* Effect.promise(() => Promise.resolve())
}
yield* Effect.sleep(Duration.millis(2))
yield* Effect.sleep(Duration.millis(1))
return yield* InstanceState.get(state)
}),
})
}),
)
}
yield* Effect.gen(function* () {
const [a, b, c] = yield* Effect.all(
[one, two, three].map((dir) => Test.use((svc) => svc.get()).pipe(provideInstanceEffect(dir))),
{ concurrency: "unbounded" },
)
expect(a).toEqual({ directory: one, worktree: one, project: a.project })
expect(b).toEqual({ directory: two, worktree: two, project: b.project })
expect(c).toEqual({ directory: three, worktree: three, project: c.project })
expect(a.project).not.toBe(b.project)
expect(a.project).not.toBe(c.project)
expect(b.project).not.toBe(c.project)
}).pipe(Effect.provide(Test.layer))
}),
)
it.live("InstanceState survives high-contention concurrent access", () =>
Effect.gen(function* () {
const dirs = yield* Effect.all(
Array.from({ length: 20 }, () => tmpdirScoped()),
{ concurrency: "unbounded" },
)
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/HighContention") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
return Test.of({
get: Effect.fn("Test.get")(function* () {
for (let i = 0; i < 10; i++) {
yield* Effect.sleep(Duration.millis(Math.random() * 3))
yield* Effect.yieldNow
yield* Effect.promise(() => Promise.resolve())
}
return yield* InstanceState.get(state)
}),
})
}),
)
}
yield* Effect.gen(function* () {
const results = yield* Effect.all(
dirs.map((dir) => Test.use((svc) => svc.get()).pipe(provideInstanceEffect(dir))),
{ concurrency: "unbounded" },
)
expect(results).toEqual(dirs)
}).pipe(Effect.provide(Test.layer))
}),
)
it.live("InstanceState correct after interleaved init and dispose", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/InterleavedDispose") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.gen(function* () {
yield* Effect.sleep(Duration.millis(5))
return ctx.directory
}),
)
return Test.of({
get: Effect.fn("Test.get")(function* () {
return yield* InstanceState.get(state)
}),
})
}),
)
}
yield* Effect.gen(function* () {
const a = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(one))
expect(a).toBe(one)
const [, b] = yield* Effect.all(
[reloadInstance({ directory: one }), Test.use((svc) => svc.get()).pipe(provideInstanceEffect(two))],
{ concurrency: "unbounded" },
)
expect(b).toBe(two)
const c = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(one))
expect(c).toBe(one)
}).pipe(Effect.provide(Test.layer))
}),
)
it.live("InstanceState mutation in one directory does not leak to another", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 })))
const s1 = yield* access(state, one)
s1.count = 42
const s2 = yield* access(state, two)
expect(s2.count).toBe(0)
const s1again = yield* access(state, one)
expect(s1again.count).toBe(42)
expect(s1again).toBe(s1)
}),
)
it.live("InstanceState dedupes concurrent lookups", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
let n = 0
const state = yield* InstanceState.make(() =>
Effect.gen(function* () {
n += 1
yield* Effect.sleep(Duration.millis(10))
return { n }
}),
)
const [a, b] = yield* Effect.all([access(state, dir), access(state, dir)], { concurrency: "unbounded" })
expect(a).toBe(b)
expect(n).toBe(1)
}),
)
it.live("InstanceState survives deferred resume from the same instance context", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
interface Api {
readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/DeferredResume") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
return Test.of({
get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred<void>) {
yield* Deferred.await(gate)
return yield* InstanceState.get(state)
}),
})
}),
)
}
yield* Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstanceEffect(dir), Effect.forkScoped)
yield* Deferred.succeed(gate, undefined).pipe(provideInstanceEffect(dir))
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) expect(exit.value).toBe(dir)
}).pipe(Effect.provide(Test.layer))
}),
)
it.live("InstanceState survives deferred resume outside ALS when InstanceRef is set", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
interface Api {
readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/DeferredResumeOutside") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
return Test.of({
get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred<void>) {
yield* Deferred.await(gate)
return yield* InstanceState.get(state)
}),
})
}),
)
}
yield* Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstanceEffect(dir), Effect.forkScoped)
yield* Deferred.succeed(gate, undefined)
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) expect(exit.value).toBe(dir)
}).pipe(Effect.provide(Test.layer))
}),
)

View File

@@ -0,0 +1,89 @@
import { expect } from "bun:test"
import { Effect, Layer, Context } from "effect"
import { InstanceRef } from "../../src/effect/instance-ref"
import { makeRuntime } from "../../src/effect/run-service"
import { ProjectV2 } from "@opencode-ai/core/project"
import { it } from "../lib/effect"
class Shared extends Context.Service<Shared, { readonly id: number }>()("@test/Shared") {}
const testDirectory = "/tmp/opencode-test"
it.live("makeRuntime shares dependent layers through the shared memo map", () =>
Effect.gen(function* () {
let n = 0
const shared = Layer.effect(
Shared,
Effect.sync(() => {
n += 1
return Shared.of({ id: n })
}),
)
class One extends Context.Service<One, { readonly get: () => Effect.Effect<number> }>()("@test/One") {}
const one = Layer.effect(
One,
Effect.gen(function* () {
const svc = yield* Shared
return One.of({
get: Effect.fn("One.get")(() => Effect.succeed(svc.id)),
})
}),
).pipe(Layer.provide(shared))
class Two extends Context.Service<Two, { readonly get: () => Effect.Effect<number> }>()("@test/Two") {}
const two = Layer.effect(
Two,
Effect.gen(function* () {
const svc = yield* Shared
return Two.of({
get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)),
})
}),
).pipe(Layer.provide(shared))
const { runPromise: runOne } = makeRuntime(One, one)
const { runPromise: runTwo } = makeRuntime(Two, two)
expect(yield* Effect.promise(() => runOne((svc) => svc.get()))).toBe(1)
expect(yield* Effect.promise(() => runTwo((svc) => svc.get()))).toBe(1)
expect(n).toBe(1)
}),
)
it.live("makeRuntime inherits InstanceRef from the current fiber", () =>
Effect.gen(function* () {
class NeedsInstance extends Context.Service<
NeedsInstance,
{ readonly directory: () => Effect.Effect<string | undefined> }
>()("@test/NeedsInstance") {}
const runtime = makeRuntime(
NeedsInstance,
Layer.succeed(
NeedsInstance,
NeedsInstance.of({
directory: () =>
Effect.gen(function* () {
return (yield* InstanceRef)?.directory
}),
}),
),
)
const actual = yield* Effect.promise(() => runtime.runPromise((svc) => svc.directory()))
expect(actual).toBe(testDirectory)
}).pipe(
Effect.provideService(InstanceRef, {
directory: testDirectory,
worktree: testDirectory,
project: {
id: ProjectV2.ID.global,
worktree: testDirectory,
time: { created: 0, updated: 0 },
sandboxes: [],
},
}),
),
)

View File

@@ -0,0 +1,514 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber, Latch, Ref, Scope } from "effect"
import { Runner } from "@/effect/runner"
import { it } from "../lib/effect"
const waitForState = <A, E>(runner: Runner.Runner<A, E>, tag: Runner.State<A, E>["_tag"]) =>
Effect.gen(function* () {
while (runner.state._tag !== tag) yield* Effect.yieldNow
}).pipe(Effect.timeout("1 second"))
describe("Runner", () => {
// --- ensureRunning semantics ---
it.live(
"ensureRunning starts work and returns result",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const result = yield* runner.ensureRunning(Effect.succeed("hello"))
expect(result).toBe("hello")
expect(runner.state._tag).toBe("Idle")
expect(runner.busy).toBe(false)
}),
)
it.live(
"ensureRunning propagates work failures",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string, string>(s)
const exit = yield* runner.ensureRunning(Effect.fail("boom")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
expect(runner.state._tag).toBe("Idle")
}),
)
it.live(
"concurrent callers share the same run",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const calls = yield* Ref.make(0)
const work = Effect.gen(function* () {
yield* Ref.update(calls, (n) => n + 1)
yield* Effect.sleep("10 millis")
return "shared"
})
const [a, b] = yield* Effect.all([runner.ensureRunning(work), runner.ensureRunning(work)], {
concurrency: "unbounded",
})
expect(a).toBe("shared")
expect(b).toBe("shared")
expect(yield* Ref.get(calls)).toBe(1)
}),
)
it.live(
"concurrent callers all receive same error",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string, string>(s)
const work = Effect.gen(function* () {
yield* Effect.sleep("10 millis")
return yield* Effect.fail("boom")
})
const [a, b] = yield* Effect.all(
[runner.ensureRunning(work).pipe(Effect.exit), runner.ensureRunning(work).pipe(Effect.exit)],
{ concurrency: "unbounded" },
)
expect(Exit.isFailure(a)).toBe(true)
expect(Exit.isFailure(b)).toBe(true)
}),
)
it.live(
"ensureRunning can be called again after previous run completes",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
expect(yield* runner.ensureRunning(Effect.succeed("first"))).toBe("first")
expect(yield* runner.ensureRunning(Effect.succeed("second"))).toBe("second")
}),
)
it.live(
"second ensureRunning ignores new work if already running",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const ran = yield* Ref.make<string[]>([])
const first = Effect.gen(function* () {
yield* Ref.update(ran, (a) => [...a, "first"])
yield* Effect.sleep("50 millis")
return "first-result"
})
const second = Effect.gen(function* () {
yield* Ref.update(ran, (a) => [...a, "second"])
return "second-result"
})
const [a, b] = yield* Effect.all([runner.ensureRunning(first), runner.ensureRunning(second)], {
concurrency: "unbounded",
})
expect(a).toBe("first-result")
expect(b).toBe("first-result")
expect(yield* Ref.get(ran)).toEqual(["first"])
}),
)
// --- cancel semantics ---
it.live(
"cancel interrupts running work",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const started = yield* Deferred.make<void>()
const fiber = yield* runner
.ensureRunning(
Effect.gen(function* () {
yield* Deferred.succeed(started, void 0)
return yield* Effect.never.pipe(Effect.as("never"))
}),
)
.pipe(Effect.forkChild)
yield* Deferred.await(started)
expect(runner.busy).toBe(true)
expect(runner.state._tag).toBe("Running")
yield* runner.cancel
expect(runner.busy).toBe(false)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
it.live(
"cancel on idle is a no-op",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
yield* runner.cancel
expect(runner.busy).toBe(false)
}),
)
it.live(
"cancel with onInterrupt resolves callers gracefully",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s, { onInterrupt: Effect.succeed("fallback") })
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild)
yield* waitForState(runner, "Running")
yield* runner.cancel
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) expect(exit.value).toBe("fallback")
}),
)
it.live(
"cancel with queued callers resolves all",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s, { onInterrupt: Effect.succeed("fallback") })
const a = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild)
yield* waitForState(runner, "Running")
const b = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* runner.cancel
const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
expect(Exit.isSuccess(exitA)).toBe(true)
expect(Exit.isSuccess(exitB)).toBe(true)
if (Exit.isSuccess(exitA)) expect(exitA.value).toBe("fallback")
if (Exit.isSuccess(exitB)) expect(exitB.value).toBe("fallback")
}),
)
it.live(
"work can be started after cancel",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild)
yield* waitForState(runner, "Running")
yield* runner.cancel
yield* Fiber.await(fiber)
const result = yield* runner.ensureRunning(Effect.succeed("after-cancel"))
expect(result).toBe("after-cancel")
}),
)
it.live(
"cancel does not deadlock when replacement work starts before interrupted run exits",
Effect.gen(function* () {
const s = yield* Scope.Scope
const hit = yield* Deferred.make<void>()
const hold = yield* Deferred.make<void>()
const done = yield* Deferred.make<void>()
yield* Effect.gen(function* () {
const runner = Runner.make<string>(s)
const first = Effect.never.pipe(
Effect.onInterrupt(() => Deferred.succeed(hit, undefined)),
Effect.ensuring(Deferred.await(hold)),
Effect.as("first"),
)
const a = yield* runner.ensureRunning(first).pipe(Effect.exit, Effect.forkChild)
yield* waitForState(runner, "Running")
const stop = yield* runner.cancel.pipe(Effect.forkChild)
yield* Deferred.await(hit).pipe(Effect.timeout("250 millis"))
const b = yield* runner.ensureRunning(Deferred.await(done).pipe(Effect.as("second"))).pipe(Effect.forkChild)
yield* Effect.yieldNow
expect(runner.busy).toBe(true)
yield* Deferred.succeed(hold, undefined)
const stopExit = yield* Fiber.await(stop).pipe(Effect.timeout("250 millis"))
expect(Exit.isSuccess(stopExit)).toBe(true)
expect(runner.busy).toBe(true)
yield* Deferred.succeed(done, undefined)
expect(yield* Fiber.join(b).pipe(Effect.timeout("250 millis"))).toBe("second")
expect(runner.busy).toBe(false)
const exit = yield* Fiber.join(a)
expect(Exit.isFailure(exit)).toBe(true)
}).pipe(
Effect.ensuring(
Effect.all([Deferred.succeed(hold, undefined), Deferred.succeed(done, undefined)], { discard: true }).pipe(
Effect.ignore,
),
),
)
}),
)
// --- shell semantics ---
it.live(
"shell runs exclusively",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const result = yield* runner.startShell(Effect.succeed("shell-done"))
expect(result).toBe("shell-done")
expect(runner.busy).toBe(false)
}),
)
it.live(
"shell rejects when run is active",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const started = yield* Deferred.make<void>()
const fiber = yield* runner
.ensureRunning(
Effect.gen(function* () {
yield* Deferred.succeed(started, undefined)
return yield* Effect.never.pipe(Effect.as("x"))
}),
)
.pipe(Effect.forkChild)
yield* Deferred.await(started).pipe(Effect.timeout("250 millis"))
yield* Effect.gen(function* () {
while (runner.state._tag !== "Running") yield* Effect.yieldNow
}).pipe(Effect.timeout("250 millis"))
const exit = yield* runner.startShell(Effect.succeed("nope")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
yield* runner.cancel
yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis"))
}),
)
it.live(
"shell rejects when another shell is running",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const gate = yield* Deferred.make<void>()
const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("first"))).pipe(Effect.forkChild)
yield* waitForState(runner, "Shell")
const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Runner.Busy)
yield* Deferred.succeed(gate, undefined)
yield* Fiber.await(sh)
}),
)
it.live(
"cancel interrupts shell",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const gate = yield* Deferred.make<void>()
const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ignored"))).pipe(Effect.forkChild)
yield* waitForState(runner, "Shell")
const stop = yield* runner.cancel.pipe(Effect.forkChild)
const stopExit = yield* Fiber.await(stop).pipe(Effect.timeout("250 millis"))
expect(Exit.isSuccess(stopExit)).toBe(true)
expect(runner.busy).toBe(false)
const shellExit = yield* Fiber.await(sh)
expect(Exit.isFailure(shellExit)).toBe(true)
yield* Deferred.succeed(gate, undefined).pipe(Effect.ignore)
}),
)
it.live(
"cancel does not mask shell defects",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s, { onInterrupt: Effect.succeed("interrupted") })
const ready = yield* Latch.make()
const sh = yield* runner
.startShell(
Effect.gen(function* () {
yield* ready.open
return yield* Effect.never.pipe(Effect.as("ignored"))
}).pipe(Effect.ensuring(Effect.die("boom"))),
ready,
)
.pipe(Effect.forkChild)
yield* ready.await.pipe(Effect.timeout("250 millis"))
yield* runner.cancel
expect(Exit.isFailure(yield* Fiber.await(sh))).toBe(true)
}),
)
// --- shell→run handoff ---
it.live(
"ensureRunning queues behind shell then runs after",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const gate = yield* Deferred.make<void>()
const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell-result"))).pipe(Effect.forkChild)
yield* waitForState(runner, "Shell")
expect(runner.state._tag).toBe("Shell")
const run = yield* runner.ensureRunning(Effect.succeed("run-result")).pipe(Effect.forkChild)
yield* waitForState(runner, "ShellThenRun")
expect(runner.state._tag).toBe("ShellThenRun")
yield* Deferred.succeed(gate, undefined)
yield* Fiber.await(sh)
const exit = yield* Fiber.await(run)
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) expect(exit.value).toBe("run-result")
expect(runner.state._tag).toBe("Idle")
}),
)
it.live(
"multiple ensureRunning callers share the queued run behind shell",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const calls = yield* Ref.make(0)
const gate = yield* Deferred.make<void>()
const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell"))).pipe(Effect.forkChild)
yield* waitForState(runner, "Shell")
const work = Effect.gen(function* () {
yield* Ref.update(calls, (n) => n + 1)
return "run"
})
const a = yield* runner.ensureRunning(work).pipe(Effect.forkChild)
const b = yield* runner.ensureRunning(work).pipe(Effect.forkChild)
yield* waitForState(runner, "ShellThenRun")
yield* Deferred.succeed(gate, undefined)
yield* Fiber.await(sh)
const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
expect(Exit.isSuccess(exitA)).toBe(true)
expect(Exit.isSuccess(exitB)).toBe(true)
expect(yield* Ref.get(calls)).toBe(1)
}),
)
it.live(
"cancel during shell_then_run cancels both",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild)
yield* waitForState(runner, "Shell")
const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild)
yield* waitForState(runner, "ShellThenRun")
expect(runner.state._tag).toBe("ShellThenRun")
yield* runner.cancel
expect(runner.busy).toBe(false)
yield* Fiber.await(sh)
const exit = yield* Fiber.await(run)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
// --- lifecycle callbacks ---
it.live(
"onIdle fires when returning to idle from running",
Effect.gen(function* () {
const s = yield* Scope.Scope
const count = yield* Ref.make(0)
const runner = Runner.make<string>(s, {
onIdle: Ref.update(count, (n) => n + 1),
})
yield* runner.ensureRunning(Effect.succeed("ok"))
expect(yield* Ref.get(count)).toBe(1)
}),
)
it.live(
"onIdle fires on cancel",
Effect.gen(function* () {
const s = yield* Scope.Scope
const count = yield* Ref.make(0)
const runner = Runner.make<string>(s, {
onIdle: Ref.update(count, (n) => n + 1),
})
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild)
yield* waitForState(runner, "Running")
yield* runner.cancel
yield* Fiber.await(fiber)
expect(yield* Ref.get(count)).toBeGreaterThanOrEqual(1)
}),
)
it.live(
"onBusy fires when shell starts",
Effect.gen(function* () {
const s = yield* Scope.Scope
const count = yield* Ref.make(0)
const runner = Runner.make<string>(s, {
onBusy: Ref.update(count, (n) => n + 1),
})
yield* runner.startShell(Effect.succeed("done"))
expect(yield* Ref.get(count)).toBe(1)
}),
)
// --- busy flag ---
it.live(
"busy is true during run",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const gate = yield* Deferred.make<void>()
const fiber = yield* runner.ensureRunning(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild)
yield* waitForState(runner, "Running")
expect(runner.busy).toBe(true)
yield* Deferred.succeed(gate, undefined)
yield* Fiber.await(fiber)
expect(runner.busy).toBe(false)
}),
)
it.live(
"busy is true during shell",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const gate = yield* Deferred.make<void>()
const fiber = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild)
yield* waitForState(runner, "Shell")
expect(runner.busy).toBe(true)
yield* Deferred.succeed(gate, undefined)
yield* Fiber.await(fiber)
expect(runner.busy).toBe(false)
}),
)
})

View File

@@ -0,0 +1,373 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer } from "effect"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { it } from "../lib/effect"
const fromConfig = (input: Record<string, unknown>) =>
RuntimeFlags.defaultLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input))))
const readFlags = RuntimeFlags.Service.useSync((flags) => flags)
describe("RuntimeFlags", () => {
it.effect("defaultLayer defaults autoShare to false", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
expect(flags.autoShare).toBe(false)
}),
)
it.effect("defaultLayer parses plugin flags from the active ConfigProvider", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(
Effect.provide(
fromConfig({
OPENCODE_PURE: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_AUTO_SHARE: "true",
OPENCODE_DISABLE_EMBEDDED_WEB_UI: "true",
OPENCODE_DISABLE_EXTERNAL_SKILLS: "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_EXPERIMENTAL: "true",
OPENCODE_ENABLE_EXA: "true",
OPENCODE_ENABLE_PARALLEL: "true",
OPENCODE_ENABLE_EXPERIMENTAL_MODELS: "true",
OPENCODE_ENABLE_QUESTION_TOOL: "true",
OPENCODE_CLIENT: "desktop",
}),
),
)
expect(flags.pure).toBe(true)
expect(flags.autoShare).toBe(true)
expect(flags.disableDefaultPlugins).toBe(true)
expect(flags.disableEmbeddedWebUi).toBe(true)
expect(flags.disableExternalSkills).toBe(true)
expect(flags.disableLspDownload).toBe(true)
expect(flags.disableClaudeCodePrompt).toBe(false)
expect(flags.enableExa).toBe(true)
expect(flags.enableParallel).toBe(true)
expect(flags.enableExperimentalModels).toBe(true)
expect(flags.enableQuestionTool).toBe(true)
expect(flags.experimentalReferences).toBe(true)
expect(flags.experimentalBackgroundSubagents).toBe(true)
expect(flags.experimentalLspTy).toBe(false)
expect(flags.experimentalLspTool).toBe(true)
expect(flags.experimentalOxfmt).toBe(true)
expect(flags.experimentalPlanMode).toBe(true)
expect(flags.experimentalEventSystem).toBe(true)
expect(flags.experimentalWorkspaces).toBe(true)
expect(flags.experimentalIconDiscovery).toBe(true)
expect(flags.experimentalNativeLlm).toBe(false)
expect(flags.experimentalWebSockets).toBe(false)
expect(flags.client).toBe("desktop")
}),
)
it.effect("defaultLayer parses OPENCODE_EXPERIMENTAL_LSP_TY", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(
Effect.provide(
fromConfig({
OPENCODE_EXPERIMENTAL_LSP_TY: "true",
}),
),
)
expect(flags.experimentalLspTy).toBe(true)
}),
)
it.effect("enables native LLM via dedicated flag only", () =>
Effect.gen(function* () {
const explicit = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_NATIVE_LLM: "true" })))
const umbrella = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL: "true" })))
expect(explicit.experimentalNativeLlm).toBe(true)
expect(umbrella.experimentalNativeLlm).toBe(false)
}),
)
it.effect("enables WebSockets via dedicated flag only", () =>
Effect.gen(function* () {
const explicit = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_WEBSOCKETS: "true" })))
const umbrella = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL: "true" })))
expect(explicit.experimentalWebSockets).toBe(true)
expect(umbrella.experimentalWebSockets).toBe(false)
}),
)
it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(
Effect.provide(RuntimeFlags.layer({ disableDefaultPlugins: true, bashDefaultTimeoutMs: 1_000 })),
)
expect(flags.pure).toBe(false)
expect(flags.autoShare).toBe(false)
expect(flags.disableDefaultPlugins).toBe(true)
expect(flags.disableEmbeddedWebUi).toBe(false)
expect(flags.disableExternalSkills).toBe(false)
expect(flags.disableLspDownload).toBe(false)
expect(flags.disableClaudeCodePrompt).toBe(false)
expect(flags.disableClaudeCodeSkills).toBe(false)
expect(flags.enableExa).toBe(false)
expect(flags.experimentalIconDiscovery).toBe(false)
expect(flags.experimentalOxfmt).toBe(false)
expect(flags.outputTokenMax).toBeUndefined()
expect(flags.bashDefaultTimeoutMs).toBe(1_000)
expect(flags.enableExperimentalModels).toBe(false)
expect(flags.client).toBe("cli")
}),
)
it.effect("experimentalIconDiscovery defaults to false", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
expect(flags.experimentalIconDiscovery).toBe(false)
}),
)
it.effect("disableExternalSkills defaults to false", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
expect(flags.disableExternalSkills).toBe(false)
}),
)
it.effect("disableExternalSkills reads OPENCODE_DISABLE_EXTERNAL_SKILLS", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_EXTERNAL_SKILLS: "true" })))
expect(flags.disableExternalSkills).toBe(true)
}),
)
it.effect("disableLspDownload defaults to false", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
expect(flags.disableLspDownload).toBe(false)
}),
)
it.effect("disableLspDownload reads OPENCODE_DISABLE_LSP_DOWNLOAD", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_LSP_DOWNLOAD: "true" })))
expect(flags.disableLspDownload).toBe(true)
}),
)
it.effect("disableClaudeCodePrompt defaults to false", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
expect(flags.disableClaudeCodePrompt).toBe(false)
}),
)
it.effect("disableClaudeCodePrompt reads OPENCODE_DISABLE_CLAUDE_CODE_PROMPT", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: "true" })))
expect(flags.disableClaudeCodePrompt).toBe(true)
}),
)
it.effect("disableClaudeCodePrompt inherits OPENCODE_DISABLE_CLAUDE_CODE", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_CLAUDE_CODE: "true" })))
expect(flags.disableClaudeCodePrompt).toBe(true)
}),
)
it.effect("experimentalIconDiscovery reads OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true" })))
expect(flags.experimentalIconDiscovery).toBe(true)
}),
)
it.effect("experimentalIconDiscovery inherits OPENCODE_EXPERIMENTAL", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL: "true" })))
expect(flags.experimentalIconDiscovery).toBe(true)
}),
)
it.effect("specific experimental flags override OPENCODE_EXPERIMENTAL", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(
Effect.provide(
fromConfig({
OPENCODE_EXPERIMENTAL: "true",
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "false",
}),
),
)
expect(flags.experimentalIconDiscovery).toBe(false)
}),
)
it.effect("experimentalOxfmt defaults to false", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
expect(flags.experimentalOxfmt).toBe(false)
}),
)
it.effect("experimentalOxfmt is enabled by OPENCODE_EXPERIMENTAL_OXFMT", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(
Effect.provide(
fromConfig({
OPENCODE_EXPERIMENTAL_OXFMT: "true",
}),
),
)
expect(flags.experimentalOxfmt).toBe(true)
}),
)
it.effect("experimentalOxfmt inherits OPENCODE_EXPERIMENTAL", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(
Effect.provide(
fromConfig({
OPENCODE_EXPERIMENTAL: "true",
}),
),
)
expect(flags.experimentalOxfmt).toBe(true)
}),
)
for (const input of [
{ name: "absent", config: {}, expected: undefined },
{
name: "valid positive integer",
config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1234" },
expected: 1234,
},
{
name: "invalid string",
config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "nope" },
expected: undefined,
},
{ name: "zero", config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "0" }, expected: undefined },
{ name: "negative", config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "-1" }, expected: undefined },
{
name: "non-integer",
config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1.5" },
expected: undefined,
},
]) {
it.effect(`parses bashDefaultTimeoutMs from config: ${input.name}`, () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig(input.config)))
expect(flags.bashDefaultTimeoutMs).toBe(input.expected)
}),
)
}
for (const input of [
{ name: "absent", config: {}, expected: undefined },
{
name: "valid positive integer",
config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "1234" },
expected: 1234,
},
{
name: "invalid string",
config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "nope" },
expected: undefined,
},
{ name: "zero", config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "0" }, expected: undefined },
{ name: "negative", config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "-1" }, expected: undefined },
{
name: "non-integer",
config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "1.5" },
expected: undefined,
},
]) {
it.effect(`parses outputTokenMax from config: ${input.name}`, () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig(input.config)))
expect(flags.outputTokenMax).toBe(input.expected)
}),
)
}
it.effect("layer ignores the active ConfigProvider for omitted test overrides", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(
Effect.provide(RuntimeFlags.layer()),
Effect.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_PURE: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_DISABLE_EXTERNAL_SKILLS: "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_EXPERIMENTAL: "true",
OPENCODE_ENABLE_EXA: "true",
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1234",
OPENCODE_CLIENT: "desktop",
}),
),
),
)
expect(flags.pure).toBe(false)
expect(flags.disableDefaultPlugins).toBe(false)
expect(flags.disableEmbeddedWebUi).toBe(false)
expect(flags.disableExternalSkills).toBe(false)
expect(flags.disableLspDownload).toBe(false)
expect(flags.disableClaudeCodePrompt).toBe(false)
expect(flags.disableClaudeCodeSkills).toBe(false)
expect(flags.enableExa).toBe(false)
expect(flags.experimentalIconDiscovery).toBe(false)
expect(flags.experimentalOxfmt).toBe(false)
expect(flags.outputTokenMax).toBeUndefined()
expect(flags.bashDefaultTimeoutMs).toBeUndefined()
expect(flags.client).toBe("cli")
}),
)
it.effect("disableClaudeCodeSkills defaults to false", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
expect(flags.disableClaudeCodeSkills).toBe(false)
}),
)
it.effect("disableClaudeCodeSkills reads OPENCODE_DISABLE_CLAUDE_CODE_SKILLS", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "true" })))
expect(flags.disableClaudeCodeSkills).toBe(true)
}),
)
it.effect("disableClaudeCodeSkills inherits OPENCODE_DISABLE_CLAUDE_CODE", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_CLAUDE_CODE: "true" })))
expect(flags.disableClaudeCodeSkills).toBe(true)
}),
)
})

View File

@@ -0,0 +1,9 @@
import { Effect, Layer, Option } from "effect"
import { Account } from "../../src/account/account"
export const empty = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()),
activeOrg: () => Effect.succeed(Option.none()),
})
export * as AccountTest from "./account"

View File

@@ -0,0 +1,8 @@
import { Effect, Layer } from "effect"
import { Auth } from "../../src/auth"
export const empty = Layer.mock(Auth.Service)({
all: () => Effect.succeed({}),
})
export * as AuthTest from "./auth"

View File

@@ -0,0 +1,8 @@
import { Npm } from "@opencode-ai/core/npm"
import { Effect, Layer } from "effect"
export const noop = Layer.mock(Npm.Service)({
install: () => Effect.void,
})
export * as NpmTest from "./npm"

View File

@@ -0,0 +1,82 @@
import { Effect, Layer } from "effect"
import { Provider } from "@/provider/provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
export namespace ProviderTest {
export function model(override: Partial<Provider.Model> = {}): Provider.Model {
const id = override.id ?? ModelV2.ID.make("gpt-5.2")
const providerID = override.providerID ?? ProviderV2.ID.make("openai")
return {
id,
providerID,
name: "Test Model",
capabilities: {
toolcall: true,
attachment: false,
reasoning: false,
temperature: true,
interleaved: false,
input: { text: true, image: false, audio: false, video: false, pdf: false },
output: { text: true, image: false, audio: false, video: false, pdf: false },
},
api: { id, url: "https://example.com", npm: "@ai-sdk/openai" },
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 200_000, output: 10_000 },
status: "active",
options: {},
headers: {},
release_date: "2025-01-01",
...override,
}
}
export function info(override: Partial<Provider.Info> = {}, mdl = model()): Provider.Info {
const id = override.id ?? mdl.providerID
return {
id,
name: "Test Provider",
source: "config",
env: [],
options: {},
models: { [mdl.id]: mdl },
...override,
}
}
export function fake(override: Partial<Provider.Interface> & { model?: Provider.Model; info?: Provider.Info } = {}) {
const mdl = override.model ?? model()
const row = override.info ?? info({}, mdl)
return {
model: mdl,
info: row,
layer: Layer.succeed(
Provider.Service,
Provider.Service.of({
list: Effect.fn("TestProvider.list")(() => Effect.succeed({ [row.id]: row })),
getProvider: Effect.fn("TestProvider.getProvider")((providerID) => {
if (providerID === row.id) return Effect.succeed(row)
return Effect.die(new Error(`Unknown test provider: ${providerID}`))
}),
getModel: Effect.fn("TestProvider.getModel")((providerID, modelID) => {
if (providerID === row.id && modelID === mdl.id) return Effect.succeed(mdl)
return Effect.die(new Error(`Unknown test model: ${providerID}/${modelID}`))
}),
getLanguage: Effect.fn("TestProvider.getLanguage")(() =>
Effect.die(new Error("ProviderTest.getLanguage not configured")),
),
closest: Effect.fn("TestProvider.closest")((providerID) =>
Effect.succeed(providerID === row.id ? { providerID: row.id, modelID: mdl.id } : undefined),
),
getSmallModel: Effect.fn("TestProvider.getSmallModel")((providerID) =>
Effect.succeed(providerID === row.id ? mdl : undefined),
),
defaultModel: Effect.fn("TestProvider.defaultModel")(() =>
Effect.succeed({ providerID: row.id, modelID: mdl.id }),
),
...override,
}),
),
}
}
}

View File

@@ -0,0 +1,8 @@
import { Effect, Layer } from "effect"
import { Skill } from "../../src/skill"
export const empty = Layer.mock(Skill.Service)({
dirs: () => Effect.succeed([]),
})
export * as SkillTest from "./skill"

View File

@@ -0,0 +1,319 @@
import { describe, test, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { testEffect } from "../lib/effect"
import path from "path"
const live = FSUtil.layer.pipe(Layer.provide(NodeFileSystem.layer))
const { effect: it } = testEffect(live)
describe("FSUtil", () => {
describe("isDir", () => {
it(
"returns true for directories",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
expect(yield* fs.isDir(tmp)).toBe(true)
}),
)
it(
"returns false for files",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "test.txt")
yield* fs.writeFileString(file, "hello")
expect(yield* fs.isDir(file)).toBe(false)
}),
)
it(
"returns false for non-existent paths",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false)
}),
)
})
describe("isFile", () => {
it(
"returns true for files",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "test.txt")
yield* fs.writeFileString(file, "hello")
expect(yield* fs.isFile(file)).toBe(true)
}),
)
it(
"returns false for directories",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
expect(yield* fs.isFile(tmp)).toBe(false)
}),
)
})
describe("readJson / writeJson", () => {
it(
"round-trips JSON data",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "data.json")
const data = { name: "test", count: 42, nested: { ok: true } }
yield* fs.writeJson(file, data)
const result = yield* fs.readJson(file)
expect(result).toEqual(data)
}),
)
})
describe("ensureDir", () => {
it(
"creates nested directories",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const nested = path.join(tmp, "a", "b", "c")
yield* fs.ensureDir(nested)
const info = yield* fs.stat(nested)
expect(info.type).toBe("Directory")
}),
)
it(
"is idempotent",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const dir = path.join(tmp, "existing")
yield* fs.makeDirectory(dir)
yield* fs.ensureDir(dir)
const info = yield* fs.stat(dir)
expect(info.type).toBe("Directory")
}),
)
})
describe("writeWithDirs", () => {
it(
"creates parent directories if missing",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "deep", "nested", "file.txt")
yield* fs.writeWithDirs(file, "hello")
expect(yield* fs.readFileString(file)).toBe("hello")
}),
)
it(
"writes directly when parent exists",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "direct.txt")
yield* fs.writeWithDirs(file, "world")
expect(yield* fs.readFileString(file)).toBe("world")
}),
)
it(
"writes Uint8Array content",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "binary.bin")
const content = new Uint8Array([0x00, 0x01, 0x02, 0x03])
yield* fs.writeWithDirs(file, content)
const result = yield* fs.readFile(file)
expect(new Uint8Array(result)).toEqual(content)
}),
)
})
describe("findUp", () => {
it(
"finds target in start directory",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "target.txt"), "found")
const result = yield* fs.findUp("target.txt", tmp)
expect(result).toEqual([path.join(tmp, "target.txt")])
}),
)
it(
"finds target in parent directories",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "marker"), "root")
const child = path.join(tmp, "a", "b")
yield* fs.makeDirectory(child, { recursive: true })
const result = yield* fs.findUp("marker", child, tmp)
expect(result).toEqual([path.join(tmp, "marker")])
}),
)
it(
"returns empty array when not found",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const result = yield* fs.findUp("nonexistent", tmp, tmp)
expect(result).toEqual([])
}),
)
})
describe("up", () => {
it(
"finds multiple targets walking up",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "a.txt"), "a")
yield* fs.writeFileString(path.join(tmp, "b.txt"), "b")
const child = path.join(tmp, "sub")
yield* fs.makeDirectory(child)
yield* fs.writeFileString(path.join(child, "a.txt"), "a-child")
const result = yield* fs.up({ targets: ["a.txt", "b.txt"], start: child, stop: tmp })
expect(result).toContain(path.join(child, "a.txt"))
expect(result).toContain(path.join(tmp, "a.txt"))
expect(result).toContain(path.join(tmp, "b.txt"))
}),
)
})
describe("glob", () => {
it(
"finds files matching pattern",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "a.ts"), "a")
yield* fs.writeFileString(path.join(tmp, "b.ts"), "b")
yield* fs.writeFileString(path.join(tmp, "c.json"), "c")
const result = yield* fs.glob("*.ts", { cwd: tmp })
expect(result.sort()).toEqual(["a.ts", "b.ts"])
}),
)
it(
"supports absolute paths",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "file.txt"), "hello")
const result = yield* fs.glob("*.txt", { cwd: tmp, absolute: true })
expect(result).toEqual([path.join(tmp, "file.txt")])
}),
)
})
describe("globMatch", () => {
it(
"matches patterns",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
expect(fs.globMatch("*.ts", "foo.ts")).toBe(true)
expect(fs.globMatch("*.ts", "foo.json")).toBe(false)
expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true)
}),
)
})
describe("globUp", () => {
it(
"finds files walking up directories",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "root.md"), "root")
const child = path.join(tmp, "a", "b")
yield* fs.makeDirectory(child, { recursive: true })
yield* fs.writeFileString(path.join(child, "leaf.md"), "leaf")
const result = yield* fs.globUp("*.md", child, tmp)
expect(result).toContain(path.join(child, "leaf.md"))
expect(result).toContain(path.join(tmp, "root.md"))
}),
)
})
describe("built-in passthrough", () => {
it(
"exists works",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "exists.txt")
yield* fs.writeFileString(file, "yes")
expect(yield* fs.exists(file)).toBe(true)
expect(yield* fs.exists(file + ".nope")).toBe(false)
}),
)
it(
"remove works",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "delete-me.txt")
yield* fs.writeFileString(file, "bye")
yield* fs.remove(file)
expect(yield* fs.exists(file)).toBe(false)
}),
)
})
describe("pure helpers", () => {
test("mimeType returns correct types", () => {
expect(FSUtil.mimeType("file.json")).toBe("application/json")
expect(FSUtil.mimeType("image.png")).toBe("image/png")
expect(FSUtil.mimeType("unknown.qzx")).toBe("application/octet-stream")
})
test("contains checks path containment", () => {
expect(FSUtil.contains("/a/b", "/a/b/c")).toBe(true)
expect(FSUtil.contains("/a/b", "/a/c")).toBe(false)
})
test("overlaps detects overlapping paths", () => {
expect(FSUtil.overlaps("/a/b", "/a/b/c")).toBe(true)
expect(FSUtil.overlaps("/a/b/c", "/a/b")).toBe(true)
expect(FSUtil.overlaps("/a", "/b")).toBe(false)
})
})
})

Some files were not shown because too many files have changed in this diff Show More