diff --git a/.changeset/calm-services-start.md b/.changeset/calm-services-start.md index ccdd17b6b0..94b57738d8 100644 --- a/.changeset/calm-services-start.md +++ b/.changeset/calm-services-start.md @@ -1,5 +1,7 @@ --- "@opencode-ai/client": patch +"@opencode-ai/protocol": patch +"@opencode-ai/cli": patch --- -Reuse a same-version background service when a repeated health probe succeeds instead of replacing an endpoint another client may already be using. +Expose background-service lifecycle status, preserve one process-held owner through startup and failure, reconnect TUIs without activating replacement, and stop exact service instances gracefully. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 534a8d78a7..9b2e87fc7a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,6 +70,14 @@ jobs: env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} + - name: Verify compiled service lifecycle + if: always() + timeout-minutes: 10 + working-directory: packages/cli + run: | + bun run script/build.ts --single --skip-install + bun run script/service-smoke.ts + - name: Check generated client if: runner.os == 'Linux' working-directory: packages/client diff --git a/bun.lock b/bun.lock index fd03c58e7d..87354a2d82 100644 --- a/bun.lock +++ b/bun.lock @@ -126,6 +126,7 @@ "uqr": "0.1.3", }, "devDependencies": { + "@opencode-ai/protocol": "workspace:*", "@opencode-ai/script": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", @@ -167,6 +168,7 @@ "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", + "typescript": "catalog:", }, }, "packages/console/app": { @@ -337,7 +339,7 @@ "@opencode-ai/codemode": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", - "@opencode-ai/llm": "workspace:*", + "@opencode-ai/ai": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/schema": "workspace:*", "@openrouter/ai-sdk-provider": "2.9.0", @@ -560,8 +562,8 @@ "@typescript/native-preview": "catalog:", }, }, - "packages/llm": { - "name": "@opencode-ai/llm", + "packages/ai": { + "name": "@opencode-ai/ai", "version": "1.17.20", "dependencies": { "@opencode-ai/schema": "workspace:*", @@ -569,6 +571,7 @@ "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", "effect": "catalog:", + "google-auth-library": "10.5.0", }, "devDependencies": { "@clack/prompts": "1.0.0-alpha.1", @@ -621,7 +624,7 @@ "@opencode-ai/cli": "workspace:*", "@opencode-ai/client": "workspace:*", "@opencode-ai/codemode": "workspace:*", - "@opencode-ai/llm": "workspace:*", + "@opencode-ai/ai": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/protocol": "workspace:*", "@opencode-ai/schema": "workspace:*", @@ -717,6 +720,7 @@ "version": "1.17.20", "dependencies": { "@ai-sdk/provider": "3.0.8", + "@opencode-ai/ai": "workspace:*", "@opencode-ai/client": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -879,10 +883,10 @@ "name": "@opencode-ai/simulation", "version": "1.17.13", "dependencies": { - "@fontsource/adwaita-mono": "5.2.1", + "@fontsource/commit-mono": "5.2.5", "@napi-rs/canvas": "1.0.2", "@opencode-ai/core": "workspace:*", - "@opencode-ai/llm": "workspace:*", + "@opencode-ai/ai": "workspace:*", "@opentui/core": "catalog:", "effect": "catalog:", }, @@ -1764,7 +1768,7 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - "@fontsource/adwaita-mono": ["@fontsource/adwaita-mono@5.2.1", "", {}, "sha512-6+Q1UIvklJ9REijs6kv7YlRNt6yktRj0iW8H69YIugdD9P2h3eIX1AB8/9ICMfpVyVeywlsrCXg82y/LfRrjyg=="], + "@fontsource/commit-mono": ["@fontsource/commit-mono@5.2.5", "", {}, "sha512-htX8yQWtiPt5L1Hzh4sirvfUJT2+KYiquDB/Q2sY2tWQYplpBUOD5zHnIM3k36Hnm4V+JIIqA/wmwupSQ68WjA=="], "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.5", "", {}, "sha512-G09N3GfuT9qj3Ax2FDZvKqZttzM3v+cco2l8uXamhKyXLdmlaUDH5o88/C3vtTHj2oT7yRKsvxz9F+BXbWKMYA=="], @@ -2202,7 +2206,7 @@ "@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"], - "@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"], + "@opencode-ai/ai": ["@opencode-ai/ai@workspace:packages/ai"], "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], @@ -6860,9 +6864,9 @@ "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], - "@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], + "@opencode-ai/ai/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], - "@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + "@opencode-ai/ai/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], "@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], diff --git a/docs/design/service-lifecycle.md b/docs/design/service-lifecycle.md new file mode 100644 index 0000000000..e32a6cb441 --- /dev/null +++ b/docs/design/service-lifecycle.md @@ -0,0 +1,685 @@ +# Service Lifecycle: Election, Restart, and Reconnect + +Status: in progress + +Incident: [#36688](https://github.com/anomalyco/opencode/issues/36688) + +## Summary + +The managed V2 service keeps its current update policy: the background updater +may install a new package, but only a freshly launched TUI activates that update +after finding an older running service. Existing TUIs never replace a service; +they only reconnect. + +The restart path changes in three places: + +1. A process-held OS lock, not the HTTP port or registration file, elects + exactly one server owner for its lifetime. +2. The elected process binds and registers a minimal lifecycle surface before + it initializes the application, so clients can distinguish a slow winner + from an absent server. +3. TUIs rediscover and reconnect indefinitely. Transport loss is never a + terminal error by itself. + +Several clients may spawn small contenders during a restart. This is safe and +intentional: one contender acquires the lock and initializes, while every loser +exits before expensive server boot. The design does not require clients to +agree on a single initiator. + +This proposal does not introduce a supervisor process, warm candidate server, +protocol negotiation, idle background restart, or general execution-recovery +framework. + +## Architecture at a Glance + +```text + ╭───────────────────╮ + │ CLI ServiceConfig │ + ╰─────────┬─────────╯ + │ + ▼ + ╭──────────────────────╮ + │ CLI ServerConnection │ + ╰───────────┬──────────╯ + ╭──────────────────╰───────────────────╮ + ▼ ▼ +╭──────────────────────────╮ ╭─────────────────────────╮ +│ Client Service lifecycle │ │ CLI runPromiseWith seam │ +╰─────────────┬────────────╯ ╰─────────────┬───────────╯ + ╰─────╮ │ + ▼ ▼ + ╭────────────────────────────╮ ╭─────────────╮ + │ Background service process │ │ TUI / Solid │ + ╰──────────────┬─────────────╯ ╰──────┬──────╯ + │ │ + ╰────────────◀────────────────────╯ + ╭───────────────────────╮ + │ Server HTTP transport │ + ╰───────────┬───────────╯ + │ + ▼ + ╭──────────────────╮ + │ Core application │ + ╰──────────────────╯ +``` + +| Owner | Responsibility | +| ------------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| `packages/client/src/effect/service.ts` | Effect-native discovery, start, and stop lifecycle operations | +| `packages/cli/src/services/service-config.ts` | CLI registration path, installed version, and daemon command | +| `packages/cli/src/services/server-connection.ts` | Resolve an endpoint and, only for the shared service, grouped reconnect and restart Effects | +| `packages/cli/src/server-process.ts` | Daemon election, registration, and server process boot | +| `packages/server/src/process.ts` | HTTP lifecycle shell and application transport | +| `packages/core` | Application behavior behind the transport | +| CLI default handler | Convert lifecycle Effects with the outer `FileSystem` context and pass grouped Promise capabilities | +| `packages/tui` Solid client context | Own event-stream reconnect, endpoint replacement, status, and user-triggered restart UI | + +## Implementation Status + +| Area | State | +| ------------------------- | --------------------------------------------------------------------- | +| Lifetime ownership | Implemented on this branch with a scoped OS lock | +| Contender behavior | Implemented; losers exit before the server module is imported | +| Registration repair | Implemented; the owner reasserts deleted or corrupt discovery | +| Channel isolation | Implemented with no-clobber migration for legacy preview discovery | +| Client startup waiting | Implemented; slow winners are not killed and waiting is indefinite | +| Lifecycle shell | Implemented; the owner binds and registers before application boot | +| Failed-state latching | Implemented; deterministic boot failure stays bound and actionable | +| Recovery diagnostics | Implemented; the TUI shows status instead of transport internals | +| Cross-platform validation | macOS runtime verified; Linux and Windows run in the unit-test matrix | + +## Context + +The V2 CLI runs a shared managed service that owns Sessions, location graphs, +plugins, permissions, and tool execution. The service updater can replace the +installed package while the current process continues running the old image. +A later TUI launch then detects the version mismatch and replaces the service. + +Incident #36688 showed four failures in that replacement path: + +- Multiple TUIs spawned heavyweight server contenders. +- A winner remained unobservable while it cold-booted, so another wave treated + it as absent and displaced it. +- A fresh TUI exhausted its reconnect budget and crashed with an unhandled + transport defect. +- A losing contender remained alive and consumed about 1 GB of RSS. + +The `origin/v2` baseline serializes service startup with `EffectFlock`. A +contender acquires a three-second heartbeat lease, checks whether another +service became discoverable, and only the winner crosses the application-boot +boundary. This already prevents simultaneous heavy boots and makes startup +losers exit. + +The lease is released immediately after registration, however, so it is not +lifetime ownership. Registration then reverts to last-writer-wins authority: a +deleted or corrupt registration can admit a second boot, a displaced server +terminates itself through its 10-second registration self-check, and a stalled +lease holder can be displaced after the three-second service staleness timeout. + +`Flock` and `EffectFlock` live in `packages/core/src/util` and are also used for +config writes, MCP auth, npm installs, and repository caching. Despite the +name, the primitive is an atomic-mkdir lease with heartbeat and staleness +takeover, not an OS-held lock. It remains appropriate for bounded critical +sections, including today's startup fence, but is not lifetime service +ownership. + +The current implementation also mixes three different concepts: + +- **Ownership:** which process is allowed to be the managed server. +- **Discovery:** where clients can reach that process. +- **Lifecycle:** whether that process is starting, ready, stopping, or failed. + +This design gives each concept one authority. + +```definitions +[ + { + "term": "Owner", + "definition": "The one process holding the process-held OS service lock." + }, + { + "term": "Contender", + "definition": "A small serve process attempting to acquire the service lock. It must not initialize the application before winning." + }, + { + "term": "Registration", + "definition": "An atomic discovery record containing the elected owner's identity and endpoint. Registration never grants ownership." + }, + { + "term": "Lifecycle shell", + "definition": "The minimal HTTP surface bound by the elected process before application initialization. It serves health and retryable startup responses." + }, + { + "term": "Application", + "definition": "The full server routes and global or location-scoped modules used for normal OpenCode work." + } +] +``` + +## Goals + +- At most one process initializes and serves the managed application. +- Losing contenders exit before database, route, plugin, MCP, or location boot. +- A slow winner becomes observable before expensive initialization. +- Existing and freshly launched TUIs survive retryable service unavailability. +- Reconnect follows service state instead of displaying retry counts or raw + transport failures. +- Version-mismatch replacement remains triggered by a fresh TUI launch. +- A stale or malformed registration cannot create a second owner. +- An unresponsive owner is never killed automatically by an arbitrary TUI. +- Every spawned contender has a bounded path to ownership or exit. + +## Non-goals + +- Restarting automatically when a background update finds an idle window. +- Running old and candidate application servers concurrently. +- Adding a permanent steward, proxy, or supervisor process. +- Zero-downtime worker handoff or automatic rollback. +- Application protocol negotiation or automatic TUI self-restart. +- General hard-crash recovery for active Sessions. +- Defining recovery semantics for provider attempts, tools, shells, sub-agents, + permissions, questions, or background jobs. +- Automatically killing a frozen owner. +- Bounding concurrent location cold boots after clients reconnect. +- Multi-machine or clustered service placement. + +## Invariants + +1. **The service lock is ownership.** Exactly one process may hold the OS lock + for one installation channel and service profile. +2. **Ownership precedes boot.** A contender performs no expensive application + initialization before it acquires the lock. +3. **Ownership lasts for the process lifetime.** The owner holds an open lock + handle until the managed server exits. The OS releases it on process death + without a cleanup callback. +4. **The port is transport, not election.** The owner may select a dynamic port + after acquiring the lock. +5. **Registration is discovery, not election.** Deleting, corrupting, or + replacing registration does not invalidate a live owner's lock. +6. **Only a fresh launch enforces package version.** Existing TUIs reconnect to + the current owner without initiating version replacement. +7. **Transport loss is retryable.** It never terminates a TUI without a separate + diagnosed, non-retryable cause. +8. **Clients do not kill an unresponsive owner automatically.** Destructive + recovery requires the explicit `service restart` command. +9. **Lifecycle does not promise execution semantics.** Graceful replacement + invokes Session suspension and resumption hooks, but tool-level continuity + belongs to a separate design. + +## System Model + +```text +╭───────────────────────╮ ╭──────────────────────────────╮ +│ Fresh or existing TUI │ │ Process-held OS service lock │ +╰───────────┬───────────╯ ╰───────────────┬──────────────╯ + ╰─────┬ normal requests observe ───────────────────────╮ │ + │ discover │ ├──╯ authorizes one owner + ▼ │ ▼ + ╭───────────────────╮ │ ╭─────────────────╮ + │ Registration file │ │ │ Lifecycle shell │ + ╰───────────────────╯ │ ╰────────┬────────╯ + │ │ + ├────────────────────────╯ + ▼ + ╭──────────────────────╮ + │ OpenCode application │ + ╰──────────────────────╯ +``` + +The lifecycle shell and application run in the same process. The distinction is +initialization order and responsibility, not process topology. + +## Service Status + +The server reports one small status value: + +```typescript +type ServiceStatus = + | { + type: "starting" + } + | { + type: "ready" + } + | { + type: "stopping" + targetVersion?: string + } + | { + type: "failed" + message: string + action: string + } +``` + +The client adds only the discovery states needed by callers: + +```typescript +type Status = { type: "missing" } | { type: "unreachable" } | { type: "unresponsive" } | ServiceStatus +``` + +The health response retains the existing fields for old clients and adds the +status discriminant: + +```typescript +type ServiceHealth = { + healthy: true + version: string + pid: number + instanceID: string + status: ServiceStatus +} +``` + +`healthy: true` means the registered lifecycle shell is responding and its +identity matches registration. New clients use `status.type === "ready"` as +the application-readiness signal. + +During `starting` or `stopping`, application requests are not held in memory. +They receive an immediate retryable response: + +```http +HTTP/1.1 503 Service Unavailable +Retry-After: 1 +Content-Type: application/json + +{"code":"service_starting"} +``` + +`stopping` uses `service_stopping`. A failed application boot uses +`service_failed` and includes a safe diagnostic message. + +A failed owner remains bound and keeps holding the service lock. Exiting on +failure would let every waiting client's `ensureRunning` loop elect a new +contender that repeats the same heavy failing boot, so staying bound turns a +deterministic boot failure into one observable `failed` state instead of a +client-driven respawn loop. Recovery still works: a fresh launch observes the +failed instance through the stop path, and explicit `service restart` replaces +it. + +## Registration Contract + +Registration contains only discovery identity: + +```typescript +type ServiceRegistration = { + schema: 1 + instanceID: string + version: string + url: string + pid: number +} +``` + +Authentication continues to use the existing private service credential +storage. The registration schema does not change that policy. + +The owner writes registration only after the lifecycle shell has bound: + +1. Bind the lifecycle shell. +2. Write a temporary registration file with mode `0600`. +3. Atomically rename it over the old registration. +4. Serve lifecycle health as `starting`. + +On shutdown, the owner removes registration only if the current file still has +its `instanceID`. An old finalizer can never remove a successor's registration. + +While running, the owner periodically asserts its registration. Because the +lock guarantees exactly one live owner, any registration that does not name the +owner is stale or corrupt, and the owner rewrites it. A deleted or clobbered +registration therefore heals within one assertion interval instead of leaving +clients waiting on absent discovery. This inverts today's self-check loop, +which terminates the displaced process instead of repairing discovery. + +Legacy registration shapes are decoded by a compatibility adapter. The new +domain type does not make fields optional to represent old formats. + +## Election + +This design promotes today's startup fence into lifetime ownership. +Last-writer-wins registration is replaced by a process-held OS lock that is +acquired before any expensive boot work and held for the entire service +lifetime. + +A heartbeat-and-staleness lease, including the existing `Flock` utility, is not +sufficient for service ownership: the service configures a three-second stale +timeout, after which its lock can be broken and recreated. An event-loop stall, +a suspended machine, or a debugger pause can therefore make a live owner appear +stale and allow a contender to displace it. Service ownership requires a +process-held OS lock: `flock` on Unix and an exclusively bound named pipe on +Windows. It cannot be broken because a heartbeat exceeded a timeout. Process +death releases the lock through the OS. + +Neither Bun nor Node exposes `flock` directly, the existing `Flock` utility is +an mkdir-plus-heartbeat lease rather than an OS-held lock, and the common +lockfile packages are staleness-based leases as well. The platform layer uses +`bun:ffi` to call `flock` on POSIX and Node's named-pipe server support on +Windows, where Bun FFI is not available on every shipped architecture. It lives +alongside the existing utility in `packages/core/src/util`. This primitive is +the foundation of the design, so the delivery sequence spikes it first. + +```text +Contender Lock Lifecycle Application + │ │ │ │ + ├─ try acquire ───▶ │ │ + │ │ │ │ + ╭─ alt: lock held ────────────────────────────────────────────────╮ + │ │ │ │ │ │ + │ ◀─ busy ──────────┤ │ │ │ + │ │ │ │ │ │ + │ ├─────────╮ │ │ │ │ + │ │ exit │ │ │ │ │ + │ ◀─────────╯ │ │ │ │ + │ │ │ │ │ │ + ├─ else: lock acquired ───────────────────────────────────────────┤ + │ │ │ │ │ │ + │ ◀─ owner ─────────┤ │ │ │ + │ │ │ │ │ │ + │ ├─ bind, register, starting ────────▶ │ │ + │ │ │ │ │ │ + │ ├─ initialize ──────────────────────────────────────────────▶ │ + │ │ │ │ │ │ + │╭─ alt: boot succeeds ──────────────────────────────────────────╮│ + ││ │ │ │ │ ││ + ││ │ │ ◀─ ready ───────────────┤ ││ + ││ │ │ │ │ ││ + │├─ else: boot fails ────────────────────────────────────────────┤│ + ││ │ │ │ │ ││ + ││ │ │ ◀─ failed, stay bound ──┤ ││ + ││ │ │ │ │ ││ + │╰───────────────────────────────────────────────────────────────╯│ + │ │ │ │ │ │ + ╰─────────────────────────────────────────────────────────────────╯ + │ │ │ │ +``` + +Lock acquisition by a contender is nonblocking or tightly bounded. A loser +must exit before constructing application routes or importing startup-heavy +modules. + +Several clients may spawn contenders concurrently. The design guarantees one +heavy winner, not one process spawn. If the winner crashes during startup, the +OS releases the lock and a later client retry starts another election. + +The lock is scoped by installation channel and service profile. Local, preview, +and stable installations cannot displace one another. + +## Update Activation + +Background update behavior remains unchanged: + +1. The running service checks for an update. +2. The updater installs the package in the background. +3. The running process continues using its existing process image. +4. No idle check or automatic restart occurs. + +A fresh TUI launch activates the installed update: + +1. Read registration and authenticate the responding service. +2. If its package version matches the fresh client, attach normally. +3. If the version differs, request graceful stop of that exact registered + instance using the existing authenticated stop path. +4. Re-check instance identity before every signal or escalation in that path. +5. Wait for the old process to exit and release the service lock. +6. Call `ensureRunning` until a compatible service becomes ready. + +Concurrent fresh launchers may all observe the same old instance. Stopping that +exact instance must be idempotent. Once registration names a different instance, +a stale launcher stops signaling and returns to discovery. + +No durable restart-transition record is introduced. The initiating fresh TUI +already knows the source and target versions and can display its update +preflight. Existing TUIs may display `Updating...` if they observed `stopping`; +otherwise `Waiting for background service...` is the honest fallback. + +## Fresh Launch Versus Reconnect + +Fresh launch and reconnect deliberately have different version policies: + +```typescript +type ManagedConnection = + | { + type: "launch" + requiredVersion: string + } + | { + type: "reconnect" + } +``` + +- `launch` requires the installed package version and may activate replacement. +- `reconnect` accepts the current owner and never activates replacement. + +This preserves today's permissive reconnect behavior. Explicit application +protocol negotiation and automatic TUI re-exec remain follow-ups. + +## Client Reconnect + +Fresh and existing TUIs use the same status loop after startup: + +1. Read registration on every attempt. Do not retry a stale URL indefinitely. +2. If registration is absent, call `ensureRunning` and continue waiting. +3. If registration is unreachable, call `ensureRunning`. A live owner prevents + contenders from acquiring the lock; a dead owner does not. +4. If status is `starting` or `stopping`, wait. +5. If status is `failed`, show its actionable message. +6. If status is `ready`, rebuild HTTP and event-stream clients for the new + endpoint and perform authoritative state reconciliation. + +Retry cadence is internal policy. Retry counts are telemetry, not user-facing +state. The TUI waits until the service is ready or the user exits. + +Transport failures are handled at the TUI run boundary. A raw client transport +error or Effect defect must not escape to the terminal. Hard exit is reserved +for diagnosed causes such as invalid local configuration, failed authentication, +or a foreign process occupying an explicitly configured port. + +The UI derives text from status: + +| Status | User-facing state | +| ------------------------ | ----------------------------------- | +| No registration | `Starting background service...` | +| Registration unreachable | `Waiting for background service...` | +| `starting` | `Starting OpenCode vX...` | +| `stopping` | `Updating to vX...` | +| `failed` | Actionable failure message | +| `ready` | Normal TUI | + +## Graceful Session Continuity + +Version-mismatch replacement uses the existing graceful Session suspension and +resumption hooks: + +1. The old server snapshots active Session IDs during graceful teardown. +2. The successor schedules those Sessions for continuation. +3. The runner reloads durable Session history before continuing. + +This lifecycle design does not define what an interrupted physical provider +attempt or tool invocation means. It does not promise that external side effects +did not occur, replay the exact interrupted tool, preserve an in-memory form, or +recover process-local background work. + +Those concerns require a separate execution-continuity design covering tools, +shells, sub-agents, permissions, questions, provider attempts, and hard-crash +recovery. + +## Unresponsive Owner + +An unreachable registration does not prove that the owner is dead. A contender +attempts the service lock: + +- If the lock is free, the contender starts a replacement. +- If the lock is held, the contender exits and the client keeps waiting. + +After a bounded diagnostic threshold, the client may show: + +```text +The background service owns the service lock but is not responding. +Run `opencode service restart` to recover it. +``` + +Only explicit `service restart` may perform destructive recovery. It verifies +the complete registration and process instance before signaling, waits for +graceful exit, re-checks identity before escalation, and refuses to kill a +process it cannot positively identify. + +Automatic frozen-owner recovery is deferred. + +## Failure Walkthroughs + +### Update with open TUIs + +1. The old service installs vNext but keeps running. +2. A fresh vNext TUI finds the healthy vOld service and requests graceful stop. +3. The old service reports `stopping`, suspends active Sessions, and exits. +4. Open TUIs enter their indefinite status loops. +5. One or more clients spawn contenders. +6. One contender acquires the service lock. Losers exit before heavy boot. +7. The winner binds and registers the lifecycle shell as `starting`. +8. Clients stop spawning and wait on the observable winner. +9. The winner initializes the application and reports `ready`. +10. TUIs rebuild clients, reconcile state, and resume. + +### Server crashes while ready + +1. The endpoint becomes unreachable and registration may remain stale. +2. Clients call `ensureRunning`. +3. Process death has released the service lock. +4. One contender wins, replaces registration, and starts normally. +5. Detailed active-execution recovery is outside this design. + +### Winner crashes during startup + +1. Clients observed `starting` and remain alive. +2. Process death releases the service lock. +3. A later reconnect attempt starts another election. +4. One new contender wins; all other contenders exit. + +### Registration is deleted while the owner is healthy + +1. Clients may call `ensureRunning` because discovery is absent. +2. Every contender fails to acquire the owner's lock and exits. +3. No second application initializes. +4. The owner's next registration assertion republishes discovery. + +### Owner is alive but unresponsive + +1. Health fails, but the process still holds the service lock. +2. Contenders fail lock acquisition and exit. +3. Clients wait and eventually show explicit recovery guidance. +4. No TUI kills the owner automatically. + +## TDD Verification + +Implementation should proceed test-first with real subprocesses and real locks. +Mocks cannot establish process death, lock release, loser cleanup, or port +behavior. + +### Election tests + +| Scenario | Required result | +| ----------------------------------------------------- | ------------------------------------------------------- | +| Ten contenders start simultaneously | Exactly one crosses the application-boot boundary | +| Winner pauses after lock acquisition | No loser initializes or remains alive | +| Winner event loop pauses beyond the old stale timeout | Ownership is not displaced | +| Winner crashes before bind | Lock releases; a later attempt wins | +| Winner crashes after bind but before registration | Lock releases; a later attempt replaces stale discovery | +| Registration is deleted while owner runs | No second owner initializes | +| Registration is malformed | Lock still prevents a second owner | +| Registration names a dead PID | New contender can acquire the released lock | +| Two installation channels start | Each elects an independent owner | +| Explicit configured port is foreign-owned | Fail diagnostically; do not kill the foreign process | + +The fixture records a marker immediately before application initialization. The +tests assert that only one process writes that marker and that every loser exits +within a bounded interval. The harness should also assert that a loser's peak +RSS stays an order of magnitude below an application boot, since import weight +was the observed incident cost. + +### Lifecycle tests + +| Scenario | Required result | +| ----------------------------------------------- | ---------------------------------------------------------------- | +| Winner owns lock but application boot is paused | Health reports `starting` | +| Application request arrives during startup | Immediate retryable `503` | +| Application becomes ready | Status changes once from `starting` to `ready` | +| Graceful replacement begins | Status reports `stopping` before disconnect | +| Application initialization fails | Actionable `failed` status; owner stays bound and holds the lock | +| Registration is deleted while owner runs | Owner republishes it within one assertion interval | +| Owner exits | Registration is removed only if it still names that owner | + +### Update tests + +| Scenario | Required result | +| -------------------------------------- | -------------------------------------------------------- | +| Background update installs vNext | Running vOld service does not restart | +| Fresh vNext launch finds vOld | Exact old instance stops; vNext eventually becomes ready | +| Two fresh vNext launches race | One heavy successor; both clients attach | +| Existing vOld TUI reconnects to vNext | It never requests replacement | +| Stale launcher observes a new instance | It does not signal the new instance | + +### Reconnect tests + +| Scenario | Required result | +| --------------------------------------------------- | -------------------------------------------------- | +| Endpoint disappears and changes port | TUI rediscovers and rebuilds clients | +| Service remains unavailable beyond old retry budget | TUI remains alive | +| Event stream reconnects | Client performs authoritative state reconciliation | +| Transport returns an unexpected defect | TUI formats it; no raw stack escapes | +| Owner remains unresponsive | TUI waits and shows explicit restart guidance | + +## Delivery Sequence + +1. **Spike the lock primitive.** Prove a nonblocking, process-held OS lock + under Bun on macOS, Linux, and Windows (`bun:ffi` to `flock` on POSIX and a + named pipe on Windows), including release on hard kill and behavior across + containers and network filesystems used in CI. +2. **Expand the subprocess test harness.** Begin from the baseline + two-contender test and cover ten contenders, lock release on crash, a paused + winner, deleted or corrupt registration, and bounded loser exit before + changing ownership. +3. **Contain client failure.** Make transport loss nonterminal, rediscover on + every cycle, and format unexpected failures at the TUI boundary. +4. **Promote the startup fence to process-held ownership.** Preserve the + existing pre-boot acquisition seam, replace its lease with the OS lock, hold + it until process exit, and invert the registration self-check from + self-termination to reassertion. +5. **Bind the lifecycle shell first.** Publish registration and `starting`, + return retryable `503` for application requests, then initialize the app. + The health contract change is public API: regenerate clients from + `packages/client` with `bun run generate`. +6. **Codify launch versus reconnect.** Fresh launch enforces installed version; + reconnect never activates replacement. +7. **Integrate graceful replacement.** Preserve current background-install and + fresh-launch activation behavior while invoking Session continuity hooks. +8. **Harden explicit recovery.** Verify exact process identity during explicit + `service restart`; never automatically kill an unresponsive owner. +9. **Run the full multi-process suite.** Include repeated restart cycles and + assert that no contender or child process remains afterward. + +## Acceptance Criteria + +- Ten concurrent restart observers produce one application initialization. +- No losing contender survives or builds a location graph. +- A 30-second application boot remains continuously observable as `starting`. +- A TUI remains alive through a service outage longer than the previous retry + budget. +- A service endpoint change does not require restarting an existing TUI. +- Background installation alone does not restart the service. +- A fresh mismatched TUI eventually attaches to the installed service version. +- Existing reconnecting TUIs never replace the current owner. +- Registration corruption cannot produce two owners. +- A deleted registration heals without restarting the owner or any client. +- An unresponsive owner is not killed without an explicit recovery command. +- Raw transport defects never escape to the terminal. + +## Follow-ups + +- Idle background update activation with an admission fence. +- Application protocol compatibility and automatic local TUI re-exec. +- Durable execution recovery for provider attempts and tools. +- Shell, sub-agent, permission, question, and background-job continuity. +- Automatic recovery for a positively identified frozen owner. +- Cold-boot concurrency limits and interaction-prioritized location loading. +- A steward or socket-handoff architecture if zero-downtime replacement becomes + a real requirement. diff --git a/packages/llm/AGENTS.md b/packages/ai/AGENTS.md similarity index 98% rename from packages/llm/AGENTS.md rename to packages/ai/AGENTS.md index 1d920258df..997615ce0d 100644 --- a/packages/llm/AGENTS.md +++ b/packages/ai/AGENTS.md @@ -1,4 +1,4 @@ -# LLM Package Guide +# AI Package Guide ## Effect @@ -115,10 +115,10 @@ Keep provider facades small and explicit: ### Provider Package Entrypoints -Catalog-selected native providers use package-like export paths from `@opencode-ai/llm`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays. +Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays. ```ts -import { model } from "@opencode-ai/llm/providers/openai/responses" +import { model } from "@opencode-ai/ai/providers/openai/responses" const selected = model("gpt-5", { apiKey, @@ -133,7 +133,7 @@ Do not expose `Route` in provider package settings. Route composition stays an i ### Folder layout ``` -packages/llm/src/ +packages/ai/src/ schema/ canonical Schema model, split by concern ids.ts branded IDs, literal types, ProviderMetadata options.ts Generation/Provider/Http options, Limits, Model, cache policy @@ -143,7 +143,7 @@ packages/llm/src/ index.ts barrel llm.ts request constructors and convenience helpers route/ - index.ts @opencode-ai/llm/route advanced barrel + index.ts @opencode-ai/ai/route advanced barrel client.ts Route.make + LLMClient.prepare/stream/generate executor.ts RequestExecutor service + transport error mapping protocol.ts Protocol type + Protocol.make diff --git a/packages/llm/DESIGN.md b/packages/ai/DESIGN.md similarity index 99% rename from packages/llm/DESIGN.md rename to packages/ai/DESIGN.md index 22e76969a4..2e73360300 100644 --- a/packages/llm/DESIGN.md +++ b/packages/ai/DESIGN.md @@ -1,7 +1,7 @@ # AI Library Design -> Discussion draft. This document describes the intended replacement for the -> current private `@opencode-ai/llm` API. Names and exact TypeScript signatures +> Discussion draft. This document describes an intended clean-break redesign of +> the current private `@opencode-ai/ai` API. Names and exact TypeScript signatures > are illustrative until implementation, but the domain boundaries and defaults > are deliberate. @@ -1074,7 +1074,6 @@ The redesign intentionally removes or changes these current concepts: | Current | Proposed | | --------------------------------------- | ----------------------------------------------------------- | -| `@opencode-ai/llm` | `@opencode-ai/ai` | | Mandatory `LLM.request({ model, ... })` | Inline calls or model-free portable requests | | `LLM.generate` means one turn | `LLM.generate` means complete run | | `LLMClient.generate/stream` | `LLM.generateTurn/streamTurn` for one turn | diff --git a/packages/llm/README.md b/packages/ai/README.md similarity index 77% rename from packages/llm/README.md rename to packages/ai/README.md index 3a3aa0c389..e88213e342 100644 --- a/packages/llm/README.md +++ b/packages/ai/README.md @@ -1,11 +1,11 @@ -# @opencode-ai/llm +# @opencode-ai/ai Schema-first LLM core for opencode. One typed request, response, event, and tool language; provider quirks live in adapters, not in calling code. ```ts import { Effect } from "effect" -import { LLM, LLMClient } from "@opencode-ai/llm" -import { OpenAI } from "@opencode-ai/llm/providers" +import { LLM, LLMClient } from "@opencode-ai/ai" +import { OpenAI } from "@opencode-ai/ai/providers" const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini") @@ -95,7 +95,7 @@ Normalized cache usage is read back into `response.usage.cacheReadInputTokens` a Provider facades configure endpoint/auth/deployment details first, then expose model selectors that take only a model or deployment id. The selected model carries the executable route value used at runtime. ```ts -import { OpenAI, CloudflareAIGateway } from "@opencode-ai/llm/providers" +import { OpenAI, CloudflareAIGateway } from "@opencode-ai/ai/providers" const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini") const gateway = CloudflareAIGateway.configure({ @@ -104,14 +104,14 @@ const gateway = CloudflareAIGateway.configure({ }).model("workers-ai/@cf/meta/llama-3.1-8b-instruct") ``` -Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible Chat helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc. and a generic Responses entrypoint. +Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint. ### Package-like entrypoints -Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/llm` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays. +Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays. ```ts -import { model } from "@opencode-ai/llm/providers/openai/responses" +import { model } from "@opencode-ai/ai/providers/openai/responses" const selected = model("gpt-5", { apiKey: process.env.OPENAI_API_KEY, @@ -123,11 +123,30 @@ const selected = model("gpt-5", { OpenAI Chat and OpenAI Responses are separate semantic entrypoints: -- `@opencode-ai/llm/providers/openai/chat` -- `@opencode-ai/llm/providers/openai/responses` -- `@opencode-ai/llm/providers/openai-compatible/responses` +- `@opencode-ai/ai/providers/openai/chat` +- `@opencode-ai/ai/providers/openai/responses` +- `@opencode-ai/ai/providers/openai-compatible/responses` +- `@opencode-ai/ai/providers/anthropic-compatible` +- `@opencode-ai/ai/providers/google-vertex` +- `@opencode-ai/ai/providers/google-vertex/anthropic` -Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; compatible Responses is separate at `providers/openai-compatible/responses`. Anthropic, Google Gemini, and Amazon Bedrock expose their single native API through their existing provider paths. +Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; compatible Responses is separate at `providers/openai-compatible/responses`. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths. + +Vertex Gemini and Vertex Anthropic are separate products with separate entrypoints. Both accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. + +Tuned Vertex Gemini deployments use model ids shaped like `endpoints/1234567890` and require OAuth or ADC; Vertex express-mode API keys support publisher models only. + +```ts +import { model } from "@opencode-ai/ai/providers/google-vertex" + +model("gemini-3.5-flash", { project: "my-project", location: "global" }) +``` + +```ts +import { model } from "@opencode-ai/ai/providers/google-vertex/anthropic" + +model("claude-sonnet-4-6", { project: "my-project", location: "global" }) +``` Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path. diff --git a/packages/llm/STATUS.md b/packages/ai/STATUS.md similarity index 67% rename from packages/llm/STATUS.md rename to packages/ai/STATUS.md index 11d0025d20..f3a95c1a7b 100644 --- a/packages/llm/STATUS.md +++ b/packages/ai/STATUS.md @@ -1,15 +1,15 @@ # LLM Provider Parity Status -Last reviewed: 2026-07-08 +Last reviewed: 2026-07-15 -This file tracks the gap between the native `@opencode-ai/llm` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths. +This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths. ## Existing Status Sources | File | What it tracks | Limitation | | ------------------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `packages/llm/DESIGN.md` | Future clean-break API proposal, currently named `@opencode-ai/ai` in the draft. | Not a provider parity tracker. | -| `packages/llm/example/call-sites.md` | Route/value/provider-facade migration checklist and call-site sketches. | Architecture migration only; not AI SDK package parity. | +| `packages/ai/DESIGN.md` | Future clean-break API proposal for `@opencode-ai/ai`. | Not a provider parity tracker. | +| `packages/ai/example/call-sites.md` | Route/value/provider-facade migration checklist and call-site sketches. | Architecture migration only; not AI SDK package parity. | ## Current Implementation Snapshot @@ -20,8 +20,11 @@ This file tracks the gap between the native `@opencode-ai/llm` package and the A | OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. | | OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. | | OpenAI-compatible Responses | `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the OpenAI Responses wire protocol. | No named family profiles or recorded deployment coverage yet. | +| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base. | No named compatible family profiles or recorded deployment coverage yet. | | Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. | | Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. | +| Vertex Gemini | `src/protocols/google-vertex-gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. | +| Vertex Anthropic Messages | `src/protocols/google-vertex-anthropic.ts`, `src/providers/google-vertex-anthropic.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. | | Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. | | Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. | | Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. | @@ -39,7 +42,7 @@ This file tracks the gap between the native `@opencode-ai/llm` package and the A | `aisdk:@ai-sdk/anthropic` | `AnthropicMessages.route` | | `aisdk:@ai-sdk/openai-compatible` with explicit URL | `OpenAICompatibleChat.route` | -Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` when the V2 native runner tries to resolve it. This includes `@ai-sdk/google`, `@ai-sdk/google-vertex`, `@ai-sdk/google-vertex/anthropic`, `@ai-sdk/azure`, `@ai-sdk/amazon-bedrock`, and `@ai-sdk/amazon-bedrock/mantle`. +Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently fall back through the AI SDK loader in the production runner. The dependency-free resolver seam rejects them with `SessionRunnerModel.UnsupportedPackageError`; they are not native route mappings yet. ## AI SDK Package Parity Matrix @@ -49,8 +52,10 @@ Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` wh | `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat and Responses | Partial / usable | Decide per-family namespace/profile behavior and runner API selection for providers that support Responses versus Chat only. | | `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. | | `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. | -| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Missing | Implement Vertex endpoint derivation, ADC/OAuth auth, project/location/env resolution, OpenAI-compatible Vertex endpoint handling, and runner/catalog mapping. | -| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Missing | Implement Vertex Anthropic endpoint/auth selection, regional endpoint behavior, and compatibility with Anthropic Messages lowering/parsing. | +| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. | +| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. | +| `@ai-sdk/google-vertex/maas` | Vertex MaaS OpenAI-compatible namespace/facade | Missing | Decide native Chat/Responses selection, endpoint derivation, auth, and catalog mapping. | +| `@ai-sdk/google-vertex/xai` | Vertex xAI OpenAI-compatible namespace/facade | Missing | Decide whether this composes the generic compatible bases or the xAI facade, then add endpoint/auth mapping and tests. | | `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. | | `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. | | `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Missing | Decide native Mantle shape, likely separate from Converse because it uses OpenAI-compatible Chat/Responses semantics over Bedrock. Add package mapping and tests. | @@ -60,38 +65,42 @@ Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` wh 1. Runner support is narrower than the LLM package. The package has native provider facades for Google, Azure, and Bedrock, but the V2 Session runner only maps OpenAI, Anthropic, and explicit OpenAI-compatible Chat from `aisdk` catalog metadata. 2. OpenAI-compatible Responses is available as a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it. 3. Bedrock native auth is not AI SDK parity. The AI SDK plugin uses the default AWS provider chain, profile, container credentials, and Bedrock bearer token env behavior. Native Bedrock currently expects explicit credentials or bearer auth on the facade. -4. Vertex is not implemented natively. Google Gemini Developer API exists, but Vertex Gemini and Vertex Anthropic are separate auth/endpoint products and should be separate namespaces/facades. +4. Vertex Gemini and Vertex Anthropic now have native package entrypoints, but the core runner does not map catalog metadata to them yet and recorded provider coverage is still missing. 5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review. 6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage. 7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed. -8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Missing native API boundaries remain for Vertex Gemini, Vertex Anthropic Messages, and Bedrock Mantle. +8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Missing native boundaries remain for Vertex MaaS, Vertex xAI, and Bedrock Mantle. 9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults. ## Native Namespace Shape These are implementation/API slices, not separate npm packages. -| API slice | Package-like entrypoint | Purpose | -| --------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- | -| OpenAI Chat | `@opencode-ai/llm/providers/openai/chat` | OpenAI `/chat/completions` semantics. | -| OpenAI Responses | `@opencode-ai/llm/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. | -| OpenAI-compatible Chat | `@opencode-ai/llm/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. | -| OpenAI-compatible Responses | `@opencode-ai/llm/providers/openai-compatible/responses` | Generic OpenAI-compatible `/responses`. | -| Anthropic Messages | `@opencode-ai/llm/providers/anthropic` | Anthropic Messages API. | -| Gemini Developer API | `@opencode-ai/llm/providers/google` | Google AI Studio Gemini API. | -| Vertex Gemini | Missing | Vertex Gemini API. | -| Vertex Anthropic Messages | Missing | Vertex-hosted Anthropic Messages API. | -| Bedrock Converse | `@opencode-ai/llm/providers/amazon-bedrock` | AWS Bedrock Converse API. | -| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. | -| Azure OpenAI Chat | `@opencode-ai/llm/providers/azure/chat` | Azure specialization of OpenAI Chat. | -| Azure OpenAI Responses | `@opencode-ai/llm/providers/azure/responses` | Azure specialization of OpenAI Responses. | +| API slice | Package-like entrypoint | Purpose | +| ----------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- | +| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. | +| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. | +| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. | +| OpenAI-compatible Responses | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic OpenAI-compatible `/responses`. | +| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. | +| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. | +| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. | +| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex` | Vertex Gemini API. | +| Vertex Anthropic Messages | `@opencode-ai/ai/providers/google-vertex/anthropic` | Vertex-hosted Anthropic Messages API. | +| Vertex MaaS | Missing | Vertex OpenAI-compatible MaaS APIs. | +| Vertex xAI | Missing | Vertex-hosted xAI APIs. | +| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. | +| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. | +| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. | +| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. | ## Suggested Next Work Slices 1. Add native runner/catalog mappings for `@ai-sdk/azure`, `@ai-sdk/google`, and `@ai-sdk/amazon-bedrock` where the existing native facades are already close. 2. Add API-aware runner/catalog selection between OpenAI-compatible Chat and Responses. 3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling. -4. Add Vertex Gemini and Vertex Anthropic native facades with ADC/OAuth auth and project/location endpoint derivation. -5. Add Bedrock Mantle as a separate OpenAI-compatible Bedrock namespace after deciding whether it uses Chat, Responses, or both by model. -6. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples. -7. Add recorded provider tests for Azure, Vertex Gemini, Vertex Anthropic, Bedrock credential-chain behavior, and Mantle before making native runtime the default for those packages. +4. Add runner/catalog mappings and recorded scenarios for the native Vertex Gemini and Vertex Anthropic entrypoints. +5. Add native Vertex MaaS and Vertex xAI entrypoints by composing the compatible bases and shared Vertex auth/endpoint setup. +6. Add Bedrock Mantle as a separate OpenAI-compatible Bedrock namespace after deciding whether it uses Chat, Responses, or both by model. +7. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples. +8. Add recorded provider tests for Azure, Vertex Gemini, Vertex Anthropic, Bedrock credential-chain behavior, and Mantle before making native runtime the default for those packages. diff --git a/packages/llm/example/call-sites.md b/packages/ai/example/call-sites.md similarity index 97% rename from packages/llm/example/call-sites.md rename to packages/ai/example/call-sites.md index 7c5a411cba..84a9a9e4be 100644 --- a/packages/llm/example/call-sites.md +++ b/packages/ai/example/call-sites.md @@ -7,7 +7,7 @@ values directly. ## Conversation Summary Kit and Aidan want provider-specific LLM behavior to move out of opencode's AI -SDK transform path and into `packages/llm` where possible. The goal is not a big +SDK transform path and into `packages/ai` where possible. The goal is not a big generic transform layer; the goal is small composable route definitions backed by recorded golden tests. @@ -355,11 +355,26 @@ The package-like OpenAI Responses entrypoint instead keeps transport scoped to Responses settings while preserving the same `model(...)` contract: ```ts -import { model } from "@opencode-ai/llm/providers/openai/responses" +import { model } from "@opencode-ai/ai/providers/openai/responses" model("gpt-4o", { apiKey, transport: "websocket" }) ``` +Vertex keeps Gemini and Anthropic Messages as separate package-like entrypoints, +while sharing project/location resolution and ADC authentication internally: + +```ts +import { model } from "@opencode-ai/ai/providers/google-vertex" + +model("gemini-3.5-flash", { project, location: "global" }) +``` + +```ts +import { model } from "@opencode-ai/ai/providers/google-vertex/anthropic" + +model("claude-sonnet-4-6", { project, location: "global" }) +``` + The client should not require a different public layer just because a selected route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime capabilities available; routes that do not need WebSocket simply never touch it. diff --git a/packages/llm/example/tutorial.ts b/packages/ai/example/tutorial.ts similarity index 97% rename from packages/llm/example/tutorial.ts rename to packages/ai/example/tutorial.ts index fddc345966..b109ef6230 100644 --- a/packages/llm/example/tutorial.ts +++ b/packages/ai/example/tutorial.ts @@ -1,12 +1,12 @@ import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect" -import { LLM, LLMClient, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/llm" -import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route" -import { OpenAI } from "@opencode-ai/llm/providers" +import { LLM, LLMClient, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai" +import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/ai/route" +import { OpenAI } from "@opencode-ai/ai/providers" /** * A runnable walkthrough of the LLM package use-site API. * - * Run from `packages/llm` with an OpenAI key in the environment: + * Run from `packages/ai` with an OpenAI key in the environment: * * OPENAI_API_KEY=... bun example/tutorial.ts * diff --git a/packages/ai/package.json b/packages/ai/package.json new file mode 100644 index 0000000000..36c3e22673 --- /dev/null +++ b/packages/ai/package.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "version": "1.17.20", + "name": "@opencode-ai/ai", + "type": "module", + "license": "MIT", + "scripts": { + "setup:recording-env": "bun run script/setup-recording-env.ts", + "test": "bun test --timeout 30000 --only-failures", + "typecheck": "tsgo --noEmit", + "build": "tsc -p tsconfig.build.json" + }, + "files": [ + "dist" + ], + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts" + }, + "devDependencies": { + "@clack/prompts": "1.0.0-alpha.1", + "@effect/platform-node": "catalog:", + "@opencode-ai/http-recorder": "workspace:*", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:" + }, + "dependencies": { + "@smithy/eventstream-codec": "4.2.14", + "@smithy/util-utf8": "4.2.2", + "@opencode-ai/schema": "workspace:*", + "aws4fetch": "1.0.20", + "effect": "catalog:", + "google-auth-library": "10.5.0" + } +} diff --git a/packages/ai/script/publish.ts b/packages/ai/script/publish.ts new file mode 100644 index 0000000000..f03dd084b0 --- /dev/null +++ b/packages/ai/script/publish.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env bun +import { Script } from "@opencode-ai/script" +import { $ } from "bun" +import { fileURLToPath } from "url" + +const dir = fileURLToPath(new URL("..", import.meta.url)) +process.chdir(dir) + +async function published(name: string, version: string) { + return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 +} + +await $`bun run build` +const originalText = await Bun.file("package.json").text() +const pkg = JSON.parse(originalText) as { + name: string + version: string + exports: Record +} +if (await published(pkg.name, pkg.version)) { + console.log(`already published ${pkg.name}@${pkg.version}`) +} else { + for (const [key, value] of Object.entries(pkg.exports)) { + const file = value.replace("./src/", "./dist/").replace(".ts", "") + // @ts-ignore + pkg.exports[key] = { + import: file + ".js", + types: file + ".d.ts", + } + } + await Bun.write("package.json", JSON.stringify(pkg, null, 2)) + try { + await $`bun pm pack` + await $`npm publish *.tgz --tag ${Script.channel} --access public` + } finally { + await Bun.write("package.json", originalText) + } +} diff --git a/packages/llm/script/recording-cost-report.ts b/packages/ai/script/recording-cost-report.ts similarity index 100% rename from packages/llm/script/recording-cost-report.ts rename to packages/ai/script/recording-cost-report.ts diff --git a/packages/llm/script/setup-recording-env.ts b/packages/ai/script/setup-recording-env.ts similarity index 100% rename from packages/llm/script/setup-recording-env.ts rename to packages/ai/script/setup-recording-env.ts diff --git a/packages/llm/src/cache-policy.ts b/packages/ai/src/cache-policy.ts similarity index 100% rename from packages/llm/src/cache-policy.ts rename to packages/ai/src/cache-policy.ts diff --git a/packages/llm/src/index.ts b/packages/ai/src/index.ts similarity index 97% rename from packages/llm/src/index.ts rename to packages/ai/src/index.ts index 83edee7155..273861b26f 100644 --- a/packages/llm/src/index.ts +++ b/packages/ai/src/index.ts @@ -18,7 +18,7 @@ export type { AnyTool, ExecutableTool, ExecutableTools, - Tool as ToolShape, + Definition as ToolShape, ToolExecute, ToolExecuteContext, ToolModelOutputInput, diff --git a/packages/llm/src/llm.ts b/packages/ai/src/llm.ts similarity index 100% rename from packages/llm/src/llm.ts rename to packages/ai/src/llm.ts diff --git a/packages/ai/src/protocols.ts b/packages/ai/src/protocols.ts new file mode 100644 index 0000000000..96aa6ee43d --- /dev/null +++ b/packages/ai/src/protocols.ts @@ -0,0 +1 @@ +export * from "./protocols/index" diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts similarity index 99% rename from packages/llm/src/protocols/anthropic-messages.ts rename to packages/ai/src/protocols/anthropic-messages.ts index d61ae5c7bd..b2d8fa70dc 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -182,7 +182,7 @@ const AnthropicBodyFields = { thinking: Schema.optional(AnthropicThinking), output_config: Schema.optional(AnthropicOutputConfig), } -const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields) +export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields) export type AnthropicMessagesBody = Schema.Schema.Type const AnthropicUsage = Schema.Struct({ diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/ai/src/protocols/bedrock-converse.ts similarity index 100% rename from packages/llm/src/protocols/bedrock-converse.ts rename to packages/ai/src/protocols/bedrock-converse.ts diff --git a/packages/llm/src/protocols/bedrock-event-stream.ts b/packages/ai/src/protocols/bedrock-event-stream.ts similarity index 96% rename from packages/llm/src/protocols/bedrock-event-stream.ts rename to packages/ai/src/protocols/bedrock-event-stream.ts index d07d7de475..0312ea7d57 100644 --- a/packages/llm/src/protocols/bedrock-event-stream.ts +++ b/packages/ai/src/protocols/bedrock-event-stream.ts @@ -1,7 +1,7 @@ import { EventStreamCodec } from "@smithy/eventstream-codec" import { fromUtf8, toUtf8 } from "@smithy/util-utf8" import { Effect, Stream } from "effect" -import type { Framing } from "../route/framing" +import { Framing } from "../route/framing" import { ProviderShared } from "./shared" // Bedrock streams responses using the AWS event stream binary protocol — each @@ -79,7 +79,7 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A * under its `:event-type` header so the chunk schema can match the JSON * payload directly. */ -export const framing = (route: string): Framing => ({ +export const framing = (route: string): Framing.Definition => ({ id: "aws-event-stream", frame: (bytes) => bytes.pipe(Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route))), }) diff --git a/packages/llm/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts similarity index 100% rename from packages/llm/src/protocols/gemini.ts rename to packages/ai/src/protocols/gemini.ts diff --git a/packages/ai/src/protocols/google-vertex-anthropic.ts b/packages/ai/src/protocols/google-vertex-anthropic.ts new file mode 100644 index 0000000000..e387ccd665 --- /dev/null +++ b/packages/ai/src/protocols/google-vertex-anthropic.ts @@ -0,0 +1,42 @@ +import { Effect, Schema, Struct } from "effect" +import { AnthropicMessages } from "./anthropic-messages" +import { Auth } from "../route/auth" +import { Route } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import { Protocol } from "../route/protocol" + +const VERSION = "vertex-2023-10-16" as const + +export const GoogleVertexAnthropicBody = Schema.Struct({ + ...Struct.omit(AnthropicMessages.AnthropicMessagesBody.fields, ["model"]), + anthropic_version: Schema.Literal(VERSION), +}) +export type GoogleVertexAnthropicBody = Schema.Schema.Type + +export const protocol = Protocol.make({ + id: "google-vertex-anthropic", + body: { + schema: GoogleVertexAnthropicBody, + from: (request) => + AnthropicMessages.protocol.body.from(request).pipe( + Effect.map((body) => ({ + ...Struct.omit(body, ["model"]), + anthropic_version: VERSION, + })), + ), + }, + stream: AnthropicMessages.protocol.stream, +}) + +export const route = Route.make({ + id: "google-vertex-anthropic", + provider: "google-vertex-anthropic", + providerMetadataKey: "anthropic", + protocol, + endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`), + auth: Auth.none, + framing: Framing.sse, +}) + +export * as GoogleVertexAnthropic from "./google-vertex-anthropic" diff --git a/packages/ai/src/protocols/google-vertex-gemini.ts b/packages/ai/src/protocols/google-vertex-gemini.ts new file mode 100644 index 0000000000..3cf00f50c8 --- /dev/null +++ b/packages/ai/src/protocols/google-vertex-gemini.ts @@ -0,0 +1,20 @@ +import { Gemini } from "./gemini" +import { Auth } from "../route/auth" +import { Route } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" + +export const route = Route.make({ + id: "google-vertex-gemini", + provider: "google-vertex", + providerMetadataKey: "google", + protocol: Gemini.protocol, + endpoint: Endpoint.path(({ request }) => { + const model = String(request.model.id) + return `/${model.startsWith("endpoints/") ? model : `models/${model}`}:streamGenerateContent?alt=sse` + }), + auth: Auth.none, + framing: Framing.sse, +}) + +export * as GoogleVertexGemini from "./google-vertex-gemini" diff --git a/packages/llm/src/protocols/index.ts b/packages/ai/src/protocols/index.ts similarity index 75% rename from packages/llm/src/protocols/index.ts rename to packages/ai/src/protocols/index.ts index d00d517a09..8c1f332946 100644 --- a/packages/llm/src/protocols/index.ts +++ b/packages/ai/src/protocols/index.ts @@ -1,4 +1,6 @@ export * as AnthropicMessages from "./anthropic-messages" +export * as GoogleVertexAnthropic from "./google-vertex-anthropic" +export * as GoogleVertexGemini from "./google-vertex-gemini" export * as BedrockConverse from "./bedrock-converse" export * as Gemini from "./gemini" export * as OpenAIChat from "./openai-chat" diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts similarity index 99% rename from packages/llm/src/protocols/openai-chat.ts rename to packages/ai/src/protocols/openai-chat.ts index cba67ce0a9..c74ab7fd64 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -160,7 +160,7 @@ export const OpenAIChatEvent = Schema.Struct({ export type OpenAIChatEvent = Schema.Schema.Type type OpenAIChatRequestMessage = LLMRequest["messages"][number] -interface ParserState { +export interface ParserState { readonly tools: ToolStream.State readonly toolCallEvents: ReadonlyArray readonly usage?: Usage diff --git a/packages/llm/src/protocols/openai-compatible-chat.ts b/packages/ai/src/protocols/openai-compatible-chat.ts similarity index 100% rename from packages/llm/src/protocols/openai-compatible-chat.ts rename to packages/ai/src/protocols/openai-compatible-chat.ts diff --git a/packages/llm/src/protocols/openai-compatible-responses.ts b/packages/ai/src/protocols/openai-compatible-responses.ts similarity index 100% rename from packages/llm/src/protocols/openai-compatible-responses.ts rename to packages/ai/src/protocols/openai-compatible-responses.ts diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts similarity index 100% rename from packages/llm/src/protocols/openai-responses.ts rename to packages/ai/src/protocols/openai-responses.ts diff --git a/packages/llm/src/protocols/shared.ts b/packages/ai/src/protocols/shared.ts similarity index 100% rename from packages/llm/src/protocols/shared.ts rename to packages/ai/src/protocols/shared.ts diff --git a/packages/llm/src/protocols/utils/bedrock-auth.ts b/packages/ai/src/protocols/utils/bedrock-auth.ts similarity index 100% rename from packages/llm/src/protocols/utils/bedrock-auth.ts rename to packages/ai/src/protocols/utils/bedrock-auth.ts diff --git a/packages/llm/src/protocols/utils/bedrock-cache.ts b/packages/ai/src/protocols/utils/bedrock-cache.ts similarity index 100% rename from packages/llm/src/protocols/utils/bedrock-cache.ts rename to packages/ai/src/protocols/utils/bedrock-cache.ts diff --git a/packages/llm/src/protocols/utils/bedrock-media.ts b/packages/ai/src/protocols/utils/bedrock-media.ts similarity index 100% rename from packages/llm/src/protocols/utils/bedrock-media.ts rename to packages/ai/src/protocols/utils/bedrock-media.ts diff --git a/packages/llm/src/protocols/utils/cache.ts b/packages/ai/src/protocols/utils/cache.ts similarity index 100% rename from packages/llm/src/protocols/utils/cache.ts rename to packages/ai/src/protocols/utils/cache.ts diff --git a/packages/llm/src/protocols/utils/gemini-tool-schema.ts b/packages/ai/src/protocols/utils/gemini-tool-schema.ts similarity index 100% rename from packages/llm/src/protocols/utils/gemini-tool-schema.ts rename to packages/ai/src/protocols/utils/gemini-tool-schema.ts diff --git a/packages/llm/src/protocols/utils/lifecycle.ts b/packages/ai/src/protocols/utils/lifecycle.ts similarity index 100% rename from packages/llm/src/protocols/utils/lifecycle.ts rename to packages/ai/src/protocols/utils/lifecycle.ts diff --git a/packages/llm/src/protocols/utils/openai-options.ts b/packages/ai/src/protocols/utils/openai-options.ts similarity index 100% rename from packages/llm/src/protocols/utils/openai-options.ts rename to packages/ai/src/protocols/utils/openai-options.ts diff --git a/packages/llm/src/protocols/utils/tool-schema.ts b/packages/ai/src/protocols/utils/tool-schema.ts similarity index 100% rename from packages/llm/src/protocols/utils/tool-schema.ts rename to packages/ai/src/protocols/utils/tool-schema.ts diff --git a/packages/llm/src/protocols/utils/tool-stream.ts b/packages/ai/src/protocols/utils/tool-stream.ts similarity index 100% rename from packages/llm/src/protocols/utils/tool-stream.ts rename to packages/ai/src/protocols/utils/tool-stream.ts diff --git a/packages/llm/src/provider-error.ts b/packages/ai/src/provider-error.ts similarity index 100% rename from packages/llm/src/provider-error.ts rename to packages/ai/src/provider-error.ts diff --git a/packages/llm/src/provider-package.ts b/packages/ai/src/provider-package.ts similarity index 100% rename from packages/llm/src/provider-package.ts rename to packages/ai/src/provider-package.ts diff --git a/packages/llm/src/provider.ts b/packages/ai/src/provider.ts similarity index 100% rename from packages/llm/src/provider.ts rename to packages/ai/src/provider.ts diff --git a/packages/ai/src/providers.ts b/packages/ai/src/providers.ts new file mode 100644 index 0000000000..e43654f054 --- /dev/null +++ b/packages/ai/src/providers.ts @@ -0,0 +1 @@ +export * from "./providers/index" diff --git a/packages/llm/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts similarity index 100% rename from packages/llm/src/providers/amazon-bedrock.ts rename to packages/ai/src/providers/amazon-bedrock.ts diff --git a/packages/ai/src/providers/anthropic-compatible.ts b/packages/ai/src/providers/anthropic-compatible.ts new file mode 100644 index 0000000000..7578d63075 --- /dev/null +++ b/packages/ai/src/providers/anthropic-compatible.ts @@ -0,0 +1,67 @@ +import type { ProviderPackage } from "../provider-package" +import { AnthropicMessages } from "../protocols/anthropic-messages" +import { Auth } from "../route/auth" +import type { ProviderAuthOption } from "../route/auth-options" +import type { RouteDefaultsInput } from "../route/client" +import { ProviderID, type ModelID } from "../schema" + +export const id = ProviderID.make("anthropic-compatible") + +export type Config = RouteDefaultsInput & + ProviderAuthOption<"optional"> & { + readonly provider?: string + readonly baseURL: string + } + +export type Settings = ProviderPackage.Settings & + ( + | { readonly apiKey?: string; readonly authToken?: never } + | { readonly apiKey?: never; readonly authToken?: string } + ) & { + readonly baseURL: string + readonly provider?: string + } + +export const routes = [AnthropicMessages.route] + +const auth = (input: ProviderAuthOption<"optional">) => { + if ("auth" in input && input.auth) return input.auth + return Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key")) +} + +export const configure = (input: Config) => { + if (!input.baseURL) throw new Error("Anthropic-compatible providers require a baseURL") + const provider = input.provider ?? "anthropic-compatible" + const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input + const route = AnthropicMessages.route.with({ + ...rest, + provider, + endpoint: { baseURL }, + auth: auth(input), + }) + return { + id: ProviderID.make(provider), + model: (modelID: string | ModelID) => route.model({ id: modelID }), + configure, + } +} + +export const provider = { + id, + configure, +} + +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { + if (settings.apiKey !== undefined && settings.authToken !== undefined) + throw new Error("Anthropic-compatible apiKey cannot be combined with authToken") + return configure({ + ...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }), + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + provider: settings.provider, + }).model(modelID) +} + +export * as AnthropicCompatible from "./anthropic-compatible" diff --git a/packages/llm/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts similarity index 63% rename from packages/llm/src/providers/anthropic.ts rename to packages/ai/src/providers/anthropic.ts index 84684e7d83..d317d49e1c 100644 --- a/packages/llm/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -3,7 +3,8 @@ import { Auth } from "../route/auth" import type { ProviderAuthOption } from "../route/auth-options" import type { ProviderPackage } from "../provider-package" import { ProviderID, type ModelID } from "../schema" -import * as AnthropicMessages from "../protocols/anthropic-messages" +import { AnthropicMessages } from "../protocols/anthropic-messages" +import { AnthropicCompatible } from "./anthropic-compatible" export const id = ProviderID.make("anthropic") @@ -11,11 +12,13 @@ export const routes = [AnthropicMessages.route] export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string } -export interface Settings extends ProviderPackage.Settings { - readonly apiKey?: string - readonly authToken?: string - readonly baseURL?: string -} +export type Settings = ProviderPackage.Settings & + ( + | { readonly apiKey?: string; readonly authToken?: never } + | { readonly apiKey?: never; readonly authToken?: string } + ) & { + readonly baseURL?: string + } const auth = (options: ProviderAuthOption<"optional">) => { if ("auth" in options && options.auth) return options.auth @@ -24,26 +27,30 @@ const auth = (options: ProviderAuthOption<"optional">) => { .pipe(Auth.header("x-api-key")) } -const configuredRoute = (input: Config) => { - const { apiKey: _, auth: _auth, baseURL, ...rest } = input - return AnthropicMessages.route.with({ ...rest, endpoint: { baseURL }, auth: auth(input) }) -} - export const configure = (input: Config = {}) => { - const route = configuredRoute(input) + const { apiKey: _, auth: _auth, baseURL, ...rest } = input + const compatible = AnthropicCompatible.configure({ + ...rest, + auth: auth(input), + baseURL: baseURL ?? AnthropicMessages.DEFAULT_BASE_URL, + provider: id, + }) return { id, - model: (modelID: string | ModelID) => route.model({ id: modelID }), + model: (modelID: string | ModelID) => compatible.model(modelID), configure, } } export const provider = configure() -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => - configure({ +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { + if (settings.apiKey !== undefined && settings.authToken !== undefined) + throw new Error("Anthropic apiKey cannot be combined with authToken") + return configure({ ...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }), baseURL: settings.baseURL, headers: settings.headers === undefined ? undefined : { ...settings.headers }, http: settings.body === undefined ? undefined : { body: { ...settings.body } }, limits: settings.limits, }).model(modelID) +} diff --git a/packages/llm/src/providers/azure.ts b/packages/ai/src/providers/azure.ts similarity index 100% rename from packages/llm/src/providers/azure.ts rename to packages/ai/src/providers/azure.ts diff --git a/packages/llm/src/providers/azure/chat.ts b/packages/ai/src/providers/azure/chat.ts similarity index 100% rename from packages/llm/src/providers/azure/chat.ts rename to packages/ai/src/providers/azure/chat.ts diff --git a/packages/llm/src/providers/azure/responses.ts b/packages/ai/src/providers/azure/responses.ts similarity index 100% rename from packages/llm/src/providers/azure/responses.ts rename to packages/ai/src/providers/azure/responses.ts diff --git a/packages/llm/src/providers/cloudflare.ts b/packages/ai/src/providers/cloudflare.ts similarity index 100% rename from packages/llm/src/providers/cloudflare.ts rename to packages/ai/src/providers/cloudflare.ts diff --git a/packages/llm/src/providers/github-copilot.ts b/packages/ai/src/providers/github-copilot.ts similarity index 100% rename from packages/llm/src/providers/github-copilot.ts rename to packages/ai/src/providers/github-copilot.ts diff --git a/packages/ai/src/providers/google-vertex-anthropic.ts b/packages/ai/src/providers/google-vertex-anthropic.ts new file mode 100644 index 0000000000..73502c4aa7 --- /dev/null +++ b/packages/ai/src/providers/google-vertex-anthropic.ts @@ -0,0 +1,77 @@ +import type { ProviderPackage } from "../provider-package" +import { GoogleVertexAnthropic } from "../protocols/google-vertex-anthropic" +import type { RouteDefaultsInput } from "../route/client" +import { ProviderID, type ModelID, type ProviderOptions } from "../schema" +import { GoogleVertexShared } from "./google-vertex-shared" + +export const id = ProviderID.make("google-vertex-anthropic") + +export type Config = RouteDefaultsInput & + GoogleVertexShared.OAuthOptions & { + readonly baseURL?: string + readonly location?: string + readonly project?: string + } + +export interface Settings extends ProviderPackage.Settings { + readonly accessToken?: string + readonly apiKey?: never + readonly baseURL?: string + readonly location?: string + readonly project?: string + readonly providerOptions?: ProviderOptions +} + +export const routes = [GoogleVertexAnthropic.route] + +const configuredRoute = (input: Config) => { + if ("apiKey" in input && input.apiKey !== undefined) + throw new Error("Google Vertex Anthropic does not support API keys") + const { + accessToken: _accessToken, + auth: _auth, + baseURL, + location: inputLocation, + project: inputProject, + ...rest + } = input + const location = GoogleVertexShared.location(inputLocation, "global") + const project = GoogleVertexShared.project(inputProject) + return GoogleVertexAnthropic.route.with({ + ...rest, + endpoint: { + baseURL: + baseURL ?? + `https://${GoogleVertexShared.host(location)}/v1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}/publishers/anthropic/models`, + }, + auth: GoogleVertexShared.oauth(input, project), + }) +} + +export const configure = (input: Config = {}) => { + const route = configuredRoute(input) + return { + id, + model: (modelID: string | ModelID) => route.model({ id: modelID }), + configure, + } +} + +export const provider = { + id, + configure, +} + +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { + if (settings.apiKey !== undefined) throw new Error("Google Vertex Anthropic does not support API keys") + return configure({ + accessToken: settings.accessToken, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + location: settings.location, + project: settings.project, + providerOptions: settings.providerOptions, + }).model(modelID) +} diff --git a/packages/ai/src/providers/google-vertex-shared.ts b/packages/ai/src/providers/google-vertex-shared.ts new file mode 100644 index 0000000000..cd8a4168e6 --- /dev/null +++ b/packages/ai/src/providers/google-vertex-shared.ts @@ -0,0 +1,77 @@ +import type { AnyAuthClient } from "google-auth-library" +import { Effect, Redacted } from "effect" +import { Auth, MissingCredentialError } from "../route/auth" + +const SCOPE = "https://www.googleapis.com/auth/cloud-platform" + +export type OAuthOptions = + | { readonly accessToken?: string; readonly auth?: never } + | { readonly accessToken?: never; readonly auth?: Auth.Definition } + +export type ApiKeyOptions = + | (OAuthOptions & { readonly apiKey?: never }) + | { readonly accessToken?: never; readonly apiKey?: string; readonly auth?: never } + +export const project = (value?: string) => + value ?? + process.env.GOOGLE_VERTEX_PROJECT ?? + process.env.GOOGLE_CLOUD_PROJECT ?? + process.env.GCP_PROJECT ?? + process.env.GCLOUD_PROJECT + +export const location = (value: string | undefined, fallback: string) => + value ?? + process.env.GOOGLE_VERTEX_LOCATION ?? + process.env.GOOGLE_CLOUD_LOCATION ?? + process.env.VERTEX_LOCATION ?? + fallback + +export const host = (location: string) => { + if (location === "global") return "aiplatform.googleapis.com" + // Jurisdictional multi-regions use Regional Endpoint Platform domains. + if (location === "eu" || location === "us") return `aiplatform.${location}.rep.googleapis.com` + return `${location}-aiplatform.googleapis.com` +} + +export const requireProject = (value: string | undefined) => { + if (value) return value + throw new Error("Google Vertex requires a project when baseURL is not configured") +} + +export const apiKey = (input: ApiKeyOptions) => { + if (input.apiKey !== undefined && (input.accessToken !== undefined || input.auth !== undefined)) + throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth") + if (input.accessToken !== undefined || input.auth !== undefined) return undefined + return input.apiKey ?? process.env.GOOGLE_VERTEX_API_KEY +} + +const adc = (project?: string) => { + let client: Promise | undefined + const loadClient = () => { + if (client) return client + client = import("google-auth-library").then(({ GoogleAuth }) => + new GoogleAuth({ projectId: project, scopes: [SCOPE] }).getClient(), + ) + return client + } + return Auth.effect( + Effect.tryPromise({ + try: async () => { + const token = await (await loadClient()).getAccessToken() + if (!token.token) throw new Error("Google ADC returned an empty access token") + return Redacted.make(token.token) + }, + catch: () => new MissingCredentialError("Google Application Default Credentials"), + }), + ).bearer() +} + +export const oauth = (input: OAuthOptions, project?: string) => { + if (input.accessToken !== undefined && input.auth !== undefined) + throw new Error("Google Vertex accessToken cannot be combined with auth") + if (input.auth) return input.auth + if (input.accessToken !== undefined) return Auth.bearer(input.accessToken) + return adc(project) +} + +export * as GoogleVertexShared from "./google-vertex-shared" diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts new file mode 100644 index 0000000000..cd019d6e05 --- /dev/null +++ b/packages/ai/src/providers/google-vertex.ts @@ -0,0 +1,83 @@ +import type { ProviderPackage } from "../provider-package" +import { GoogleVertexGemini } from "../protocols/google-vertex-gemini" +import { Auth } from "../route/auth" +import type { RouteDefaultsInput } from "../route/client" +import { ProviderID, type ModelID, type ProviderOptions } from "../schema" +import { GoogleVertexShared } from "./google-vertex-shared" + +export const id = ProviderID.make("google-vertex") + +export type Config = RouteDefaultsInput & + GoogleVertexShared.ApiKeyOptions & { + readonly baseURL?: string + readonly location?: string + readonly project?: string + } + +export type Settings = ProviderPackage.Settings & + ( + | { readonly accessToken?: string; readonly apiKey?: never } + | { readonly accessToken?: never; readonly apiKey?: string } + ) & { + readonly baseURL?: string + readonly location?: string + readonly project?: string + readonly providerOptions?: ProviderOptions + } + +export const routes = [GoogleVertexGemini.route] + +const configuredRoute = (input: Config, modelID: string | ModelID) => { + const { + accessToken: _accessToken, + apiKey: _apiKey, + auth: _auth, + baseURL, + location: inputLocation, + project: inputProject, + ...rest + } = input + const apiKey = GoogleVertexShared.apiKey(input) + const endpointModel = String(modelID).startsWith("endpoints/") + if (apiKey !== undefined && endpointModel) + throw new Error("Google Vertex tuned models do not support Express Mode API keys") + const location = GoogleVertexShared.location(inputLocation, "us-central1") + const project = GoogleVertexShared.project(inputProject) + const endpoint = + baseURL ?? + (apiKey + ? "https://aiplatform.googleapis.com/v1/publishers/google" + : `https://${GoogleVertexShared.host(location)}/v1beta1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}${endpointModel ? "" : "/publishers/google"}`) + return GoogleVertexGemini.route.with({ + ...rest, + endpoint: { baseURL: endpoint }, + auth: apiKey === undefined ? GoogleVertexShared.oauth(input, project) : Auth.header("x-goog-api-key", apiKey), + }) +} + +export const configure = (input: Config = {}) => { + return { + id, + model: (modelID: string | ModelID) => configuredRoute(input, modelID).model({ id: modelID }), + configure, + } +} + +export const provider = { + id, + configure, +} +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { + if (settings.apiKey !== undefined && settings.accessToken !== undefined) + throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth") + return configure({ + ...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }), + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + location: settings.location, + project: settings.project, + providerOptions: settings.providerOptions, + }).model(modelID) +} diff --git a/packages/ai/src/providers/google-vertex/anthropic.ts b/packages/ai/src/providers/google-vertex/anthropic.ts new file mode 100644 index 0000000000..3bdbf6ff44 --- /dev/null +++ b/packages/ai/src/providers/google-vertex/anthropic.ts @@ -0,0 +1,2 @@ +export { model } from "../google-vertex-anthropic" +export type { Settings } from "../google-vertex-anthropic" diff --git a/packages/llm/src/providers/google.ts b/packages/ai/src/providers/google.ts similarity index 100% rename from packages/llm/src/providers/google.ts rename to packages/ai/src/providers/google.ts diff --git a/packages/llm/src/providers/index.ts b/packages/ai/src/providers/index.ts similarity index 76% rename from packages/llm/src/providers/index.ts rename to packages/ai/src/providers/index.ts index 042e825a51..023fb03669 100644 --- a/packages/llm/src/providers/index.ts +++ b/packages/ai/src/providers/index.ts @@ -1,10 +1,13 @@ export * as Anthropic from "./anthropic" +export * as AnthropicCompatible from "./anthropic-compatible" export * as AmazonBedrock from "./amazon-bedrock" export * as Azure from "./azure" export * as Cloudflare from "./cloudflare" export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare" export * as GitHubCopilot from "./github-copilot" export * as Google from "./google" +export * as GoogleVertex from "./google-vertex" +export * as GoogleVertexAnthropic from "./google-vertex-anthropic" export * as OpenAI from "./openai" export * as OpenAICompatible from "./openai-compatible" export * as OpenAICompatibleResponses from "./openai-compatible-responses" diff --git a/packages/llm/src/providers/openai-compatible-profile.ts b/packages/ai/src/providers/openai-compatible-profile.ts similarity index 100% rename from packages/llm/src/providers/openai-compatible-profile.ts rename to packages/ai/src/providers/openai-compatible-profile.ts diff --git a/packages/llm/src/providers/openai-compatible-responses.ts b/packages/ai/src/providers/openai-compatible-responses.ts similarity index 100% rename from packages/llm/src/providers/openai-compatible-responses.ts rename to packages/ai/src/providers/openai-compatible-responses.ts diff --git a/packages/llm/src/providers/openai-compatible.ts b/packages/ai/src/providers/openai-compatible.ts similarity index 100% rename from packages/llm/src/providers/openai-compatible.ts rename to packages/ai/src/providers/openai-compatible.ts diff --git a/packages/ai/src/providers/openai-compatible/responses.ts b/packages/ai/src/providers/openai-compatible/responses.ts new file mode 100644 index 0000000000..03404ca68b --- /dev/null +++ b/packages/ai/src/providers/openai-compatible/responses.ts @@ -0,0 +1 @@ +export * from "../openai-compatible-responses" diff --git a/packages/llm/src/providers/openai-options.ts b/packages/ai/src/providers/openai-options.ts similarity index 100% rename from packages/llm/src/providers/openai-options.ts rename to packages/ai/src/providers/openai-options.ts diff --git a/packages/llm/src/providers/openai.ts b/packages/ai/src/providers/openai.ts similarity index 100% rename from packages/llm/src/providers/openai.ts rename to packages/ai/src/providers/openai.ts diff --git a/packages/llm/src/providers/openai/chat.ts b/packages/ai/src/providers/openai/chat.ts similarity index 100% rename from packages/llm/src/providers/openai/chat.ts rename to packages/ai/src/providers/openai/chat.ts diff --git a/packages/llm/src/providers/openai/responses.ts b/packages/ai/src/providers/openai/responses.ts similarity index 100% rename from packages/llm/src/providers/openai/responses.ts rename to packages/ai/src/providers/openai/responses.ts diff --git a/packages/llm/src/providers/openrouter.ts b/packages/ai/src/providers/openrouter.ts similarity index 100% rename from packages/llm/src/providers/openrouter.ts rename to packages/ai/src/providers/openrouter.ts diff --git a/packages/llm/src/providers/xai.ts b/packages/ai/src/providers/xai.ts similarity index 100% rename from packages/llm/src/providers/xai.ts rename to packages/ai/src/providers/xai.ts diff --git a/packages/ai/src/route.ts b/packages/ai/src/route.ts new file mode 100644 index 0000000000..e76b133a7a --- /dev/null +++ b/packages/ai/src/route.ts @@ -0,0 +1 @@ +export * from "./route/index" diff --git a/packages/llm/src/route/auth-options.ts b/packages/ai/src/route/auth-options.ts similarity index 91% rename from packages/llm/src/route/auth-options.ts rename to packages/ai/src/route/auth-options.ts index 7e40aa12a2..957ae9b311 100644 --- a/packages/llm/src/route/auth-options.ts +++ b/packages/ai/src/route/auth-options.ts @@ -4,7 +4,7 @@ import { Auth } from "./auth" export type ApiKeyMode = "optional" | "required" export type AuthOverride = { - readonly auth: Auth + readonly auth: Auth.Definition readonly apiKey?: never } @@ -44,7 +44,10 @@ export type AtLeastOne = { * override, otherwise resolve `apiKey` (option > config var) and apply it as * a bearer token. */ -export const bearer = (options: ProviderAuthOption<"optional">, envVar: string | ReadonlyArray): Auth => { +export const bearer = ( + options: ProviderAuthOption<"optional">, + envVar: string | ReadonlyArray, +): Auth.Definition => { if ("auth" in options && options.auth) return options.auth return (Array.isArray(envVar) ? envVar : [envVar]) .reduce( diff --git a/packages/llm/src/route/auth.ts b/packages/ai/src/route/auth.ts similarity index 90% rename from packages/llm/src/route/auth.ts rename to packages/ai/src/route/auth.ts index 32871c0454..9bbbb2b84c 100644 --- a/packages/llm/src/route/auth.ts +++ b/packages/ai/src/route/auth.ts @@ -25,19 +25,19 @@ export interface AuthInput { export interface Credential { readonly load: Effect.Effect readonly orElse: (that: Credential) => Credential - readonly bearer: () => Auth - readonly header: (name: string) => Auth + readonly bearer: () => Definition + readonly header: (name: string) => Definition readonly pipe: (f: (self: Credential) => A) => A } -export interface Auth { +export interface Definition { readonly apply: (input: AuthInput) => Effect.Effect - readonly andThen: (that: Auth) => Auth - readonly orElse: (that: Auth) => Auth - readonly pipe: (f: (self: Auth) => A) => A + readonly andThen: (that: Definition) => Definition + readonly orElse: (that: Definition) => Definition + readonly pipe: (f: (self: Definition) => A) => A } -export const isAuth = (input: unknown): input is Auth => +export const isAuth = (input: unknown): input is Definition => typeof input === "object" && input !== null && "apply" in input && typeof input.apply === "function" const credential = (load: Effect.Effect): Credential => { @@ -51,8 +51,8 @@ const credential = (load: Effect.Effect): Cr return self } -const auth = (apply: Auth["apply"]): Auth => { - const self: Auth = { +const auth = (apply: Definition["apply"]): Definition => { + const self: Definition = { apply, andThen: (that) => auth((input) => apply(input).pipe(Effect.flatMap((headers) => that.apply({ ...input, headers })))), @@ -109,15 +109,15 @@ const credentialInput = (source: Secret | Credential) => ? credentialFromSecret(source, "value") : source -export function bearer(source: Secret | Credential): Auth +export function bearer(source: Secret | Credential): Definition export function bearer(source: Secret | Credential) { return credentialInput(source).bearer() } export const apiKey = bearer -export function header(name: string): (source: Secret | Credential) => Auth -export function header(name: string, source: Secret | Credential): Auth +export function header(name: string): (source: Secret | Credential) => Definition +export function header(name: string, source: Secret | Credential): Definition export function header(name: string, source?: Secret | Credential) { if (source === undefined) { return (next: Secret | Credential) => credentialInput(next).header(name) @@ -125,8 +125,8 @@ export function header(name: string, source?: Secret | Credential) { return credentialInput(source).header(name) } -export function bearerHeader(name: string): (source: Secret | Credential) => Auth -export function bearerHeader(name: string, source: Secret | Credential): Auth +export function bearerHeader(name: string): (source: Secret | Credential) => Definition +export function bearerHeader(name: string, source: Secret | Credential): Definition export function bearerHeader(name: string, source?: Secret | Credential) { const render = (input: Secret | Credential) => fromCredential(credentialInput(input), (secret) => ({ [name]: `Bearer ${secret}` })) @@ -149,7 +149,7 @@ const toLLMError = (error: AuthError): LLMError => { } export const toEffect = - (input: Auth) => + (input: Definition) => (authInput: AuthInput): Effect.Effect => input.apply(authInput).pipe(Effect.mapError(toLLMError)) diff --git a/packages/llm/src/route/client.ts b/packages/ai/src/route/client.ts similarity index 97% rename from packages/llm/src/route/client.ts rename to packages/ai/src/route/client.ts index 2ef0db0222..067292329b 100644 --- a/packages/llm/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -1,9 +1,9 @@ import { Cause, Context, Effect, Layer, Schema, Stream } from "effect" import * as Option from "effect/Option" -import { Auth, type Auth as AuthDef } from "./auth" +import { Auth } from "./auth" import { Endpoint, type EndpointPatch } from "./endpoint" import { RequestExecutor } from "./executor" -import type { Framing } from "./framing" +import { Framing } from "./framing" import { HttpTransport } from "./transport" import type { Transport, TransportRuntime } from "./transport" import { WebSocketExecutor } from "./transport" @@ -40,8 +40,8 @@ export interface Route { /** ProviderMetadata namespace emitted and consumed by this route. */ readonly providerMetadataKey?: string readonly protocol: ProtocolID - readonly endpoint: Endpoint - readonly auth: AuthDef + readonly endpoint: Endpoint.Definition + readonly auth: Auth.Definition readonly transport: Transport readonly defaults: RouteDefaults readonly body: RouteBody @@ -86,7 +86,7 @@ export interface RouteDefaultsInput { export interface RoutePatch extends RouteDefaultsInput { readonly id?: string readonly provider?: string | ProviderID - readonly auth?: AuthDef + readonly auth?: Auth.Definition readonly transport?: Transport readonly endpoint?: EndpointPatch } @@ -122,7 +122,7 @@ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefault } } -const endpointBaseURL = (endpoint: Endpoint) => +const endpointBaseURL = (endpoint: Endpoint.Definition) => typeof endpoint.baseURL === "string" ? endpoint.baseURL : undefined const mergeHeaders = (...items: ReadonlyArray | undefined>) => { @@ -192,11 +192,11 @@ export interface MakeInput { /** Semantic API contract — owns body construction, body schema, and parsing. */ readonly protocol: Protocol /** Where the request is sent. */ - readonly endpoint: Endpoint + readonly endpoint: Endpoint.Definition /** Per-request transport auth. Provider facades override this via `route.with(...)`. */ - readonly auth?: AuthDef + readonly auth?: Auth.Definition /** Stream framing — bytes -> frames before `protocol.stream.event` decoding. */ - readonly framing: Framing + readonly framing: Framing.Definition /** Static / per-request headers added before `auth` runs. */ readonly headers?: (input: { readonly request: LLMRequest }) => Record /** Route/request defaults used when compiling requests for this route. */ @@ -213,9 +213,9 @@ export interface MakeTransportInput { /** Semantic API contract — owns body construction, body schema, and parsing. */ readonly protocol: Protocol /** Where the request is sent. */ - readonly endpoint: Endpoint + readonly endpoint: Endpoint.Definition /** Per-request transport auth. Provider facades override this via `route.with(...)`. */ - readonly auth?: AuthDef + readonly auth?: Auth.Definition /** Static / per-request headers added before `auth` runs. */ readonly headers?: (input: { readonly request: LLMRequest }) => Record /** Runnable transport route. */ diff --git a/packages/llm/src/route/endpoint.ts b/packages/ai/src/route/endpoint.ts similarity index 78% rename from packages/llm/src/route/endpoint.ts rename to packages/ai/src/route/endpoint.ts index accbe53243..2b2077907d 100644 --- a/packages/llm/src/route/endpoint.ts +++ b/packages/ai/src/route/endpoint.ts @@ -19,21 +19,24 @@ export type EndpointPart = string | ((input: EndpointInput) => strin * URL embeds the model id, region, or another body field (e.g. Bedrock, * Gemini). */ -export interface Endpoint { +export interface Definition { readonly baseURL?: string readonly path: EndpointPart readonly query?: Record } -export type EndpointPatch = Partial> +export type EndpointPatch = Partial> /** Construct an `Endpoint` from a path string or path function. */ -export const path = (value: EndpointPart, options: Omit, "path"> = {}): Endpoint => ({ +export const path = ( + value: EndpointPart, + options: Omit, "path"> = {}, +): Definition => ({ ...options, path: value, }) -export const merge = (base: Endpoint, patch: EndpointPatch): Endpoint => ({ +export const merge = (base: Definition, patch: EndpointPatch): Definition => ({ ...base, ...patch, baseURL: patch.baseURL ?? base.baseURL, @@ -44,7 +47,7 @@ export const merge = (base: Endpoint, patch: EndpointPatch): E const renderPart = (part: EndpointPart, input: EndpointInput) => typeof part === "function" ? part(input) : part -export const render = (endpoint: Endpoint, input: EndpointInput) => { +export const render = (endpoint: Definition, input: EndpointInput) => { const url = new URL(`${ProviderShared.trimBaseUrl(endpoint.baseURL ?? "")}${renderPart(endpoint.path, input)}`) for (const [key, value] of Object.entries(endpoint.query ?? {})) url.searchParams.set(key, value) return url diff --git a/packages/llm/src/route/executor.ts b/packages/ai/src/route/executor.ts similarity index 100% rename from packages/llm/src/route/executor.ts rename to packages/ai/src/route/executor.ts diff --git a/packages/llm/src/route/framing.ts b/packages/ai/src/route/framing.ts similarity index 88% rename from packages/llm/src/route/framing.ts rename to packages/ai/src/route/framing.ts index ef4855817d..f4ec86cbf9 100644 --- a/packages/llm/src/route/framing.ts +++ b/packages/ai/src/route/framing.ts @@ -16,12 +16,12 @@ import type { LLMError } from "../schema" * The frame type is opaque to this layer; the protocol's `decode` step turns * a frame into a typed chunk. */ -export interface Framing { +export interface Definition { readonly id: string readonly frame: (bytes: Stream.Stream) => Stream.Stream } /** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */ -export const sse: Framing = { id: "sse", frame: ProviderShared.sseFraming } +export const sse: Definition = { id: "sse", frame: ProviderShared.sseFraming } export * as Framing from "./framing" diff --git a/packages/llm/src/route/index.ts b/packages/ai/src/route/index.ts similarity index 78% rename from packages/llm/src/route/index.ts rename to packages/ai/src/route/index.ts index 48f4b7bc33..70db881ea4 100644 --- a/packages/llm/src/route/index.ts +++ b/packages/ai/src/route/index.ts @@ -17,9 +17,9 @@ export { Framing } from "./framing" export { Protocol } from "./protocol" export { HttpTransport, WebSocketExecutor, WebSocketTransport } from "./transport" export * as Transport from "./transport" -export type { Auth as AuthShape, AuthInput, Credential, CredentialError } from "./auth" +export type { Definition as AuthShape, AuthInput, Credential, CredentialError } from "./auth" export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options" -export type { Endpoint as EndpointFn, EndpointInput } from "./endpoint" -export type { Framing as FramingDef } from "./framing" +export type { Definition as EndpointFn, EndpointInput } from "./endpoint" +export type { Definition as FramingDef } from "./framing" export type { Protocol as ProtocolDef } from "./protocol" export type { Transport as TransportDef, TransportRuntime } from "./transport" diff --git a/packages/llm/src/route/protocol.ts b/packages/ai/src/route/protocol.ts similarity index 100% rename from packages/llm/src/route/protocol.ts rename to packages/ai/src/route/protocol.ts diff --git a/packages/llm/src/route/transport/http.ts b/packages/ai/src/route/transport/http.ts similarity index 96% rename from packages/llm/src/route/transport/http.ts rename to packages/ai/src/route/transport/http.ts index acc52c6ea1..785e450a5e 100644 --- a/packages/llm/src/route/transport/http.ts +++ b/packages/ai/src/route/transport/http.ts @@ -2,7 +2,7 @@ import { Effect, Stream } from "effect" import { Headers, HttpClientRequest } from "effect/unstable/http" import { Auth } from "../auth" import { render as renderEndpoint } from "../endpoint" -import { Framing, type Framing as FramingDef } from "../framing" +import { Framing } from "../framing" import type { Transport, TransportPrepareInput } from "./index" import * as ProviderShared from "../../protocols/shared" import { mergeJsonRecords, type LLMRequest } from "../../schema" @@ -18,7 +18,7 @@ export interface JsonRequestParts { export interface HttpPrepared { readonly request: HttpClientRequest.HttpClientRequest - readonly framing: FramingDef + readonly framing: Framing.Definition } const applyQuery = (url: string, query: Record | undefined) => { @@ -29,6 +29,7 @@ const applyQuery = (url: string, query: Record | undefined) => { } const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([ + "anthropic_version", "content", "contents", "frequencyPenalty", @@ -106,7 +107,7 @@ export const jsonRequestParts = (input: JsonRequestInput) => }) export interface HttpJsonInput<_Body, Frame> { - readonly framing: FramingDef + readonly framing: Framing.Definition } export type HttpJsonPatch = Partial> diff --git a/packages/llm/src/route/transport/index.ts b/packages/ai/src/route/transport/index.ts similarity index 87% rename from packages/llm/src/route/transport/index.ts rename to packages/ai/src/route/transport/index.ts index fde9d6c415..cf8fef1d08 100644 --- a/packages/llm/src/route/transport/index.ts +++ b/packages/ai/src/route/transport/index.ts @@ -1,6 +1,6 @@ import type { Effect, Stream } from "effect" -import type { Endpoint } from "../endpoint" -import type { Auth } from "../auth" +import { Endpoint } from "../endpoint" +import { Auth } from "../auth" import type { Interface as RequestExecutorInterface } from "../executor" import type { Interface as WebSocketExecutorInterface } from "./websocket" import type { LLMError, LLMRequest } from "../../schema" @@ -23,8 +23,8 @@ export interface Transport { export interface TransportPrepareInput { readonly body: Body readonly request: LLMRequest - readonly endpoint: Endpoint - readonly auth: Auth + readonly endpoint: Endpoint.Definition + readonly auth: Auth.Definition readonly encodeBody: (body: Body) => string readonly headers?: (input: { readonly request: LLMRequest }) => Record } diff --git a/packages/llm/src/route/transport/websocket.ts b/packages/ai/src/route/transport/websocket.ts similarity index 100% rename from packages/llm/src/route/transport/websocket.ts rename to packages/ai/src/route/transport/websocket.ts diff --git a/packages/llm/src/schema/errors.ts b/packages/ai/src/schema/errors.ts similarity index 100% rename from packages/llm/src/schema/errors.ts rename to packages/ai/src/schema/errors.ts diff --git a/packages/llm/src/schema/events.ts b/packages/ai/src/schema/events.ts similarity index 100% rename from packages/llm/src/schema/events.ts rename to packages/ai/src/schema/events.ts diff --git a/packages/llm/src/schema/ids.ts b/packages/ai/src/schema/ids.ts similarity index 100% rename from packages/llm/src/schema/ids.ts rename to packages/ai/src/schema/ids.ts diff --git a/packages/llm/src/schema/index.ts b/packages/ai/src/schema/index.ts similarity index 100% rename from packages/llm/src/schema/index.ts rename to packages/ai/src/schema/index.ts diff --git a/packages/llm/src/schema/messages.ts b/packages/ai/src/schema/messages.ts similarity index 100% rename from packages/llm/src/schema/messages.ts rename to packages/ai/src/schema/messages.ts diff --git a/packages/llm/src/schema/options.ts b/packages/ai/src/schema/options.ts similarity index 100% rename from packages/llm/src/schema/options.ts rename to packages/ai/src/schema/options.ts diff --git a/packages/llm/src/tool-runtime.ts b/packages/ai/src/tool-runtime.ts similarity index 100% rename from packages/llm/src/tool-runtime.ts rename to packages/ai/src/tool-runtime.ts diff --git a/packages/llm/src/tool.ts b/packages/ai/src/tool.ts similarity index 97% rename from packages/llm/src/tool.ts rename to packages/ai/src/tool.ts index 11ed9854ca..62bd0df82f 100644 --- a/packages/llm/src/tool.ts +++ b/packages/ai/src/tool.ts @@ -45,7 +45,7 @@ export type ToolToModelOutput, Success extend * Internally each tool also carries memoized codecs and a precomputed * `ToolDefinition` so callers do not rebuild them per invocation. */ -export interface Tool, Success extends ToolSchema> { +export interface Definition, Success extends ToolSchema> { readonly description: string readonly parameters: Parameters readonly success: Success @@ -68,9 +68,9 @@ export interface Tool, Success extends ToolSc readonly _definition: ToolDefinitionClass } -export type AnyTool = Tool +export type AnyTool = Definition -export type ExecutableTool, Success extends ToolSchema> = Tool< +export type ExecutableTool, Success extends ToolSchema> = Definition< Parameters, Success > & { @@ -145,7 +145,7 @@ export function make, Success extends ToolSch readonly execute?: undefined readonly toModelOutput?: ToolToModelOutput readonly toStructuredOutput?: (output: Success["Encoded"]) => unknown -}): Tool +}): Definition export function make(config: { readonly description: string readonly jsonSchema: JsonSchema.JsonSchema diff --git a/packages/llm/src/utils/record.ts b/packages/ai/src/utils/record.ts similarity index 100% rename from packages/llm/src/utils/record.ts rename to packages/ai/src/utils/record.ts diff --git a/packages/llm/sst-env.d.ts b/packages/ai/sst-env.d.ts similarity index 100% rename from packages/llm/sst-env.d.ts rename to packages/ai/sst-env.d.ts diff --git a/packages/llm/test/adapter.test.ts b/packages/ai/test/adapter.test.ts similarity index 100% rename from packages/llm/test/adapter.test.ts rename to packages/ai/test/adapter.test.ts diff --git a/packages/llm/test/auth-options.types.ts b/packages/ai/test/auth-options.types.ts similarity index 75% rename from packages/llm/test/auth-options.types.ts rename to packages/ai/test/auth-options.types.ts index 18f9508c3c..ff376cd5a2 100644 --- a/packages/llm/test/auth-options.types.ts +++ b/packages/ai/test/auth-options.types.ts @@ -5,10 +5,13 @@ import { Auth as RuntimeAuth } from "../src/route/auth" import * as OpenAIChat from "../src/protocols/openai-chat" import * as AmazonBedrock from "../src/providers/amazon-bedrock" import * as Anthropic from "../src/providers/anthropic" +import * as AnthropicCompatible from "../src/providers/anthropic-compatible" import * as Azure from "../src/providers/azure" import * as Cloudflare from "../src/providers/cloudflare" import * as GitHubCopilot from "../src/providers/github-copilot" import * as Google from "../src/providers/google" +import * as GoogleVertex from "../src/providers/google-vertex" +import * as GoogleVertexAnthropic from "../src/providers/google-vertex-anthropic" import * as OpenAI from "../src/providers/openai" import * as OpenAICompatible from "../src/providers/openai-compatible" import * as OpenRouter from "../src/providers/openrouter" @@ -135,11 +138,57 @@ Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: RuntimeAu Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku") // @ts-expect-error Anthropic model selectors only accept model ids. Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {}) +// @ts-expect-error Anthropic package settings accept only one auth source. +Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" }) + +AnthropicCompatible.configure({ + apiKey: "messages-key", + baseURL: "https://messages.example.com/v1", + provider: "example", +}).model("compatible-model") +// @ts-expect-error Anthropic-compatible providers require a base URL. +AnthropicCompatible.configure({ apiKey: "messages-key" }) +// @ts-expect-error Anthropic-compatible model selectors only accept model ids. +AnthropicCompatible.configure({ baseURL: "https://messages.example.com/v1" }).model("compatible-model", {}) +// @ts-expect-error Anthropic-compatible package settings accept only one auth source. +AnthropicCompatible.model("compatible-model", { + apiKey: "messages-key", + authToken: "messages-token", + baseURL: "https://messages.example.com/v1", +}) Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash") // @ts-expect-error Google model selectors only accept model ids. Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash", {}) +GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash") +GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash") +GoogleVertex.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash") +// @ts-expect-error Vertex Gemini model selectors only accept model ids. +GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {}) +// @ts-expect-error Vertex Gemini config accepts only one auth source. +GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", project: "project" }) +// @ts-expect-error Vertex Gemini package settings accept only one auth source. +GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" }) + +GoogleVertexAnthropic.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6") +// @ts-expect-error Vertex Anthropic package settings do not accept API keys. +GoogleVertexAnthropic.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" }) +GoogleVertexAnthropic.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model( + "claude-sonnet-4-6", +) +GoogleVertexAnthropic.configure({ accessToken: "vertex-token", project: "project" }).model( + "claude-sonnet-4-6", + // @ts-expect-error Vertex Anthropic model selectors only accept model ids. + {}, +) +GoogleVertexAnthropic.configure({ + accessToken: "vertex-token", + // @ts-expect-error Vertex Anthropic config accepts only one auth source. + auth: RuntimeAuth.bearer("vertex-token"), + project: "project", +}) + AmazonBedrock.configure({ apiKey: "bedrock-key" }).model("anthropic.claude") // @ts-expect-error Bedrock model selectors only accept model ids. AmazonBedrock.configure({ apiKey: "bedrock-key" }).model("anthropic.claude", {}) diff --git a/packages/llm/test/auth.test.ts b/packages/ai/test/auth.test.ts similarity index 100% rename from packages/llm/test/auth.test.ts rename to packages/ai/test/auth.test.ts diff --git a/packages/llm/test/cache-policy.test.ts b/packages/ai/test/cache-policy.test.ts similarity index 100% rename from packages/llm/test/cache-policy.test.ts rename to packages/ai/test/cache-policy.test.ts diff --git a/packages/llm/test/continuation-scenarios.ts b/packages/ai/test/continuation-scenarios.ts similarity index 100% rename from packages/llm/test/continuation-scenarios.ts rename to packages/ai/test/continuation-scenarios.ts diff --git a/packages/llm/test/endpoint.test.ts b/packages/ai/test/endpoint.test.ts similarity index 100% rename from packages/llm/test/endpoint.test.ts rename to packages/ai/test/endpoint.test.ts diff --git a/packages/llm/test/executor.test.ts b/packages/ai/test/executor.test.ts similarity index 100% rename from packages/llm/test/executor.test.ts rename to packages/ai/test/executor.test.ts diff --git a/packages/llm/test/exports.test.ts b/packages/ai/test/exports.test.ts similarity index 88% rename from packages/llm/test/exports.test.ts rename to packages/ai/test/exports.test.ts index 0ac6eb9bf2..c4e32d6601 100644 --- a/packages/llm/test/exports.test.ts +++ b/packages/ai/test/exports.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" -import { LLM, LLMClient, Provider } from "@opencode-ai/llm" -import { Route, Protocol } from "@opencode-ai/llm/route" -import { Provider as ProviderSubpath } from "@opencode-ai/llm/provider" +import { LLM, LLMClient, Provider } from "@opencode-ai/ai" +import { Route, Protocol } from "@opencode-ai/ai/route" +import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider" import { CloudflareAIGateway, CloudflareWorkersAI, @@ -9,15 +9,15 @@ import { OpenAICompatible, OpenRouter, XAI, -} from "@opencode-ai/llm/providers" -import * as GitHubCopilot from "@opencode-ai/llm/providers/github-copilot" +} from "@opencode-ai/ai/providers" +import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot" import { OpenAIChat, OpenAICompatibleChat, OpenAICompatibleResponses, OpenAIResponses, -} from "@opencode-ai/llm/protocols" -import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" +} from "@opencode-ai/ai/protocols" +import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages" describe("public exports", () => { test("root exposes app-facing runtime APIs", () => { @@ -34,7 +34,7 @@ describe("public exports", () => { }) test("provider barrels expose user-facing facades", async () => { - const { OpenAICompatibleResponses } = await import("@opencode-ai/llm/providers") + const { OpenAICompatibleResponses } = await import("@opencode-ai/ai/providers") expect(OpenAI.model).toBeFunction() expect(OpenAI.provider.responses).toBe(OpenAI.responses) diff --git a/packages/llm/test/fixtures/media/restroom.png b/packages/ai/test/fixtures/media/restroom.png similarity index 100% rename from packages/llm/test/fixtures/media/restroom.png rename to packages/ai/test/fixtures/media/restroom.png diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json b/packages/ai/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json similarity index 100% rename from packages/llm/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json rename to packages/ai/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json b/packages/ai/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json similarity index 100% rename from packages/llm/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json rename to packages/ai/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-image-tool-result.json b/packages/ai/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-image-tool-result.json similarity index 100% rename from packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-image-tool-result.json rename to packages/ai/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-image-tool-result.json diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json b/packages/ai/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json similarity index 100% rename from packages/llm/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json rename to packages/ai/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json b/packages/ai/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json similarity index 100% rename from packages/llm/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json rename to packages/ai/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/streams-text.json b/packages/ai/test/fixtures/recordings/anthropic-messages/streams-text.json similarity index 100% rename from packages/llm/test/fixtures/recordings/anthropic-messages/streams-text.json rename to packages/ai/test/fixtures/recordings/anthropic-messages/streams-text.json diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/streams-tool-call.json b/packages/ai/test/fixtures/recordings/anthropic-messages/streams-tool-call.json similarity index 100% rename from packages/llm/test/fixtures/recordings/anthropic-messages/streams-tool-call.json rename to packages/ai/test/fixtures/recordings/anthropic-messages/streams-tool-call.json diff --git a/packages/llm/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json b/packages/ai/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json similarity index 100% rename from packages/llm/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json rename to packages/ai/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json diff --git a/packages/llm/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json b/packages/ai/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json similarity index 100% rename from packages/llm/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json rename to packages/ai/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json diff --git a/packages/llm/test/fixtures/recordings/bedrock-converse/streams-text.json b/packages/ai/test/fixtures/recordings/bedrock-converse/streams-text.json similarity index 100% rename from packages/llm/test/fixtures/recordings/bedrock-converse/streams-text.json rename to packages/ai/test/fixtures/recordings/bedrock-converse/streams-text.json diff --git a/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json b/packages/ai/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json similarity index 100% rename from packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json rename to packages/ai/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json diff --git a/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json b/packages/ai/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json similarity index 100% rename from packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json rename to packages/ai/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json diff --git a/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json b/packages/ai/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json similarity index 100% rename from packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json rename to packages/ai/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json diff --git a/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json b/packages/ai/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json similarity index 100% rename from packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json rename to packages/ai/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json diff --git a/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json b/packages/ai/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json similarity index 100% rename from packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json rename to packages/ai/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json diff --git a/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-image.json b/packages/ai/test/fixtures/recordings/gemini/gemini-2-5-flash-image.json similarity index 100% rename from packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-image.json rename to packages/ai/test/fixtures/recordings/gemini/gemini-2-5-flash-image.json diff --git a/packages/llm/test/fixtures/recordings/gemini/streams-text.json b/packages/ai/test/fixtures/recordings/gemini/streams-text.json similarity index 100% rename from packages/llm/test/fixtures/recordings/gemini/streams-text.json rename to packages/ai/test/fixtures/recordings/gemini/streams-text.json diff --git a/packages/llm/test/fixtures/recordings/gemini/streams-tool-call.json b/packages/ai/test/fixtures/recordings/gemini/streams-tool-call.json similarity index 100% rename from packages/llm/test/fixtures/recordings/gemini/streams-tool-call.json rename to packages/ai/test/fixtures/recordings/gemini/streams-tool-call.json diff --git a/packages/llm/test/fixtures/recordings/openai-chat/continues-after-tool-result.json b/packages/ai/test/fixtures/recordings/openai-chat/continues-after-tool-result.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-chat/continues-after-tool-result.json rename to packages/ai/test/fixtures/recordings/openai-chat/continues-after-tool-result.json diff --git a/packages/llm/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json b/packages/ai/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json rename to packages/ai/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json diff --git a/packages/llm/test/fixtures/recordings/openai-chat/streams-text.json b/packages/ai/test/fixtures/recordings/openai-chat/streams-text.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-chat/streams-text.json rename to packages/ai/test/fixtures/recordings/openai-chat/streams-text.json diff --git a/packages/llm/test/fixtures/recordings/openai-chat/streams-tool-call.json b/packages/ai/test/fixtures/recordings/openai-chat/streams-tool-call.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-chat/streams-tool-call.json rename to packages/ai/test/fixtures/recordings/openai-chat/streams-tool-call.json diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json b/packages/ai/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json rename to packages/ai/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json b/packages/ai/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json rename to packages/ai/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json b/packages/ai/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json rename to packages/ai/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json b/packages/ai/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json rename to packages/ai/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json b/packages/ai/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json rename to packages/ai/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json b/packages/ai/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json rename to packages/ai/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json b/packages/ai/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json rename to packages/ai/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json b/packages/ai/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json rename to packages/ai/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json b/packages/ai/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json rename to packages/ai/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json b/packages/ai/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json rename to packages/ai/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json b/packages/ai/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json rename to packages/ai/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json diff --git a/packages/llm/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json b/packages/ai/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json rename to packages/ai/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json b/packages/ai/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json rename to packages/ai/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json b/packages/ai/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json rename to packages/ai/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json b/packages/ai/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json rename to packages/ai/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json b/packages/ai/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json rename to packages/ai/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json b/packages/ai/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json rename to packages/ai/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json b/packages/ai/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json similarity index 100% rename from packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json rename to packages/ai/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json diff --git a/packages/llm/test/generate-object.test.ts b/packages/ai/test/generate-object.test.ts similarity index 100% rename from packages/llm/test/generate-object.test.ts rename to packages/ai/test/generate-object.test.ts diff --git a/packages/llm/test/lib/effect.ts b/packages/ai/test/lib/effect.ts similarity index 100% rename from packages/llm/test/lib/effect.ts rename to packages/ai/test/lib/effect.ts diff --git a/packages/llm/test/lib/http.ts b/packages/ai/test/lib/http.ts similarity index 100% rename from packages/llm/test/lib/http.ts rename to packages/ai/test/lib/http.ts diff --git a/packages/llm/test/lib/openai-chunks.ts b/packages/ai/test/lib/openai-chunks.ts similarity index 100% rename from packages/llm/test/lib/openai-chunks.ts rename to packages/ai/test/lib/openai-chunks.ts diff --git a/packages/llm/test/lib/sse.ts b/packages/ai/test/lib/sse.ts similarity index 100% rename from packages/llm/test/lib/sse.ts rename to packages/ai/test/lib/sse.ts diff --git a/packages/llm/test/lib/tool-runtime.ts b/packages/ai/test/lib/tool-runtime.ts similarity index 100% rename from packages/llm/test/lib/tool-runtime.ts rename to packages/ai/test/lib/tool-runtime.ts diff --git a/packages/llm/test/llm.test.ts b/packages/ai/test/llm.test.ts similarity index 100% rename from packages/llm/test/llm.test.ts rename to packages/ai/test/llm.test.ts diff --git a/packages/llm/test/prepare.test.ts b/packages/ai/test/prepare.test.ts similarity index 100% rename from packages/llm/test/prepare.test.ts rename to packages/ai/test/prepare.test.ts diff --git a/packages/llm/test/provider-error.test.ts b/packages/ai/test/provider-error.test.ts similarity index 100% rename from packages/llm/test/provider-error.test.ts rename to packages/ai/test/provider-error.test.ts diff --git a/packages/ai/test/provider-package.test.ts b/packages/ai/test/provider-package.test.ts new file mode 100644 index 0000000000..2ddcb5e0d1 --- /dev/null +++ b/packages/ai/test/provider-package.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, test } from "bun:test" +import { model } from "@opencode-ai/ai/providers/openai" + +describe("provider package entrypoints", () => { + test("semantic API aliases expose the same contract", async () => { + const modules = await Promise.all([ + import("@opencode-ai/ai/providers/openai"), + import("@opencode-ai/ai/providers/openai/responses"), + import("@opencode-ai/ai/providers/openai/chat"), + import("@opencode-ai/ai/providers/anthropic"), + import("@opencode-ai/ai/providers/anthropic-compatible"), + import("@opencode-ai/ai/providers/openai-compatible"), + import("@opencode-ai/ai/providers/openai-compatible/responses"), + import("@opencode-ai/ai/providers/amazon-bedrock"), + import("@opencode-ai/ai/providers/azure"), + import("@opencode-ai/ai/providers/azure/responses"), + import("@opencode-ai/ai/providers/azure/chat"), + import("@opencode-ai/ai/providers/google"), + import("@opencode-ai/ai/providers/google-vertex"), + import("@opencode-ai/ai/providers/google-vertex/anthropic"), + ]) + + for (const module of modules) expect(module.model).toBeFunction() + expect(modules[0].model).toBe(modules[1].model) + expect(modules[8].model).toBe(modules[9].model) + }) + + test("maps package settings onto the executable model", () => { + const selected = model("gpt-5", { + apiKey: "fixture", + baseURL: "https://api.openai.test/v1", + headers: { "x-application": "opencode" }, + body: { service_tier: "priority" }, + limits: { context: 200_000, output: 64_000 }, + unrelatedInheritedSetting: true, + }) + + expect(selected.route.id).toBe("openai-responses") + expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" }) + expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) + }) + + test("selects transport without changing the semantic API", () => { + expect(model("gpt-5", { apiKey: "fixture" }).route.id).toBe("openai-responses") + expect(model("gpt-5", { apiKey: "fixture", transport: "websocket" }).route.id).toBe("openai-responses-websocket") + }) + + test("maps OpenAI-compatible Responses settings onto the executable model", async () => { + const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses") + const selected = OpenAICompatibleResponses.model("custom-model", { + apiKey: "fixture", + baseURL: "https://responses.example.test/v1", + provider: "example", + headers: { "x-application": "opencode" }, + body: { service_tier: "priority" }, + limits: { context: 200_000, output: 64_000 }, + providerOptions: { openai: { reasoningEffort: "low", store: true } }, + }) + + expect(String(selected.provider)).toBe("example") + expect(selected.route.id).toBe("openai-compatible-responses") + expect(selected.route.endpoint).toMatchObject({ + baseURL: "https://responses.example.test/v1", + path: "/responses", + }) + expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" }) + expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) + expect(selected.route.defaults.providerOptions).toEqual({ + openai: { reasoningEffort: "low", store: true }, + }) + }) + + test("maps Anthropic-compatible settings onto the executable model", async () => { + const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible") + const selected = AnthropicCompatible.model("compatible-model", { + apiKey: "fixture", + baseURL: "https://messages.example.test/v1", + provider: "example", + headers: { "x-application": "opencode" }, + body: { metadata: { user_id: "user_1" } }, + limits: { context: 200_000, output: 64_000 }, + }) + + expect(String(selected.provider)).toBe("example") + expect(selected.route.id).toBe("anthropic-messages") + expect(selected.route.endpoint).toMatchObject({ + baseURL: "https://messages.example.test/v1", + path: "/messages", + }) + expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } }) + expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) + }) + + test("requires an Anthropic-compatible base URL at runtime", async () => { + const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible") + expect(() => + Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]), + ).toThrow("Anthropic-compatible providers require a baseURL") + }) + + test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => { + const Anthropic = await import("@opencode-ai/ai/providers/anthropic") + const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible") + expect(() => + Reflect.apply(AnthropicCompatible.model, undefined, [ + "compatible-model", + { + apiKey: "fixture", + authToken: "token", + baseURL: "https://messages.example.test/v1", + }, + ]), + ).toThrow("Anthropic-compatible apiKey cannot be combined with authToken") + expect(() => + Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]), + ).toThrow("Anthropic apiKey cannot be combined with authToken") + }) + + test("maps legacy OpenAI organization and project settings to headers", () => { + const selected = model("gpt-5", { + apiKey: "fixture", + organization: "org_123", + project: "proj_123", + }) + + expect(selected.route.defaults.headers).toMatchObject({ + "OpenAI-Organization": "org_123", + "OpenAI-Project": "proj_123", + }) + }) + + test("selects Azure API entrypoints with the same model contract", async () => { + const Azure = await import("@opencode-ai/ai/providers/azure") + const AzureChat = await import("@opencode-ai/ai/providers/azure/chat") + const AzureResponses = await import("@opencode-ai/ai/providers/azure/responses") + const settings = { + apiKey: "fixture", + resourceName: "opencode-test", + headers: { "x-application": "opencode" }, + body: { service_tier: "priority" }, + limits: { context: 200_000, output: 64_000 }, + } + + const responses = AzureResponses.model("deployment", settings) + const chat = AzureChat.model("deployment", settings) + + expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses") + expect(responses.route.id).toBe("azure-openai-responses") + expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1") + expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" }) + expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) + expect(chat.route.id).toBe("azure-openai-chat") + }) + + test("maps Google package settings onto the Gemini model", async () => { + const Google = await import("@opencode-ai/ai/providers/google") + const selected = Google.model("gemini-2.5-flash", { + apiKey: "fixture", + baseURL: "https://generativelanguage.test/v1beta", + headers: { "x-application": "opencode" }, + body: { safetySettings: [] }, + limits: { context: 1_000_000, output: 65_536 }, + providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } }, + }) + + expect(selected.route.id).toBe("gemini") + expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta") + expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] }) + expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 }) + expect(selected.route.defaults.providerOptions).toEqual({ + gemini: { thinkingConfig: { thinkingBudget: 1_024 } }, + }) + }) + + test("selects Vertex entrypoints with the same model contract", async () => { + const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex") + const GoogleVertexAnthropic = await import("@opencode-ai/ai/providers/google-vertex/anthropic") + const gemini = GoogleVertex.model("gemini-3.5-flash", { + apiKey: "fixture", + headers: { "x-application": "opencode" }, + body: { safetySettings: [] }, + limits: { context: 1_000_000, output: 65_536 }, + }) + const anthropic = GoogleVertexAnthropic.model("claude-sonnet-4-6", { + accessToken: "fixture", + location: "global", + project: "vertex-project", + }) + + expect(gemini.route.id).toBe("google-vertex-gemini") + expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google") + expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] }) + expect(gemini.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 }) + expect( + GoogleVertex.model("gemini-3.5-flash", { + accessToken: "fixture", + location: "eu", + project: "vertex-project", + }).route.endpoint.baseURL, + ).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google") + expect(anthropic.route.id).toBe("google-vertex-anthropic") + expect(anthropic.route.endpoint.baseURL).toBe( + "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/publishers/anthropic/models", + ) + }) + + test("rejects conflicting Vertex auth settings at runtime", async () => { + const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex") + const GoogleVertexAnthropic = await import("@opencode-ai/ai/providers/google-vertex/anthropic") + const Providers = await import("@opencode-ai/ai/providers") + expect(() => + Reflect.apply(GoogleVertex.model, undefined, [ + "gemini-3.5-flash", + { accessToken: "token", apiKey: "fixture", project: "vertex-project" }, + ]), + ).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth") + const configured = Reflect.apply(GoogleVertex.configure, undefined, [ + { accessToken: "token", auth: {}, project: "vertex-project" }, + ]) + expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth") + expect(() => + Reflect.apply(GoogleVertexAnthropic.model, undefined, [ + "claude-sonnet-4-6", + { apiKey: "fixture", project: "vertex-project" }, + ]), + ).toThrow("Google Vertex Anthropic does not support API keys") + expect(() => + Reflect.apply(Providers.GoogleVertexAnthropic.configure, undefined, [ + { apiKey: "fixture", project: "vertex-project" }, + ]), + ).toThrow("Google Vertex Anthropic does not support API keys") + }) +}) diff --git a/packages/llm/test/provider.types.ts b/packages/ai/test/provider.types.ts similarity index 100% rename from packages/llm/test/provider.types.ts rename to packages/ai/test/provider.types.ts diff --git a/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts b/packages/ai/test/provider/anthropic-messages-cache.recorded.test.ts similarity index 100% rename from packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts rename to packages/ai/test/provider/anthropic-messages-cache.recorded.test.ts diff --git a/packages/llm/test/provider/anthropic-messages.recorded.test.ts b/packages/ai/test/provider/anthropic-messages.recorded.test.ts similarity index 100% rename from packages/llm/test/provider/anthropic-messages.recorded.test.ts rename to packages/ai/test/provider/anthropic-messages.recorded.test.ts diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts similarity index 100% rename from packages/llm/test/provider/anthropic-messages.test.ts rename to packages/ai/test/provider/anthropic-messages.test.ts diff --git a/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts b/packages/ai/test/provider/bedrock-converse-cache.recorded.test.ts similarity index 100% rename from packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts rename to packages/ai/test/provider/bedrock-converse-cache.recorded.test.ts diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/ai/test/provider/bedrock-converse.test.ts similarity index 100% rename from packages/llm/test/provider/bedrock-converse.test.ts rename to packages/ai/test/provider/bedrock-converse.test.ts diff --git a/packages/llm/test/provider/cloudflare.test.ts b/packages/ai/test/provider/cloudflare.test.ts similarity index 100% rename from packages/llm/test/provider/cloudflare.test.ts rename to packages/ai/test/provider/cloudflare.test.ts diff --git a/packages/llm/test/provider/gemini-cache.recorded.test.ts b/packages/ai/test/provider/gemini-cache.recorded.test.ts similarity index 100% rename from packages/llm/test/provider/gemini-cache.recorded.test.ts rename to packages/ai/test/provider/gemini-cache.recorded.test.ts diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts similarity index 100% rename from packages/llm/test/provider/gemini.test.ts rename to packages/ai/test/provider/gemini.test.ts diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/ai/test/provider/golden.recorded.test.ts similarity index 100% rename from packages/llm/test/provider/golden.recorded.test.ts rename to packages/ai/test/provider/golden.recorded.test.ts diff --git a/packages/ai/test/provider/google-vertex.test.ts b/packages/ai/test/provider/google-vertex.test.ts new file mode 100644 index 0000000000..aa90d67aee --- /dev/null +++ b/packages/ai/test/provider/google-vertex.test.ts @@ -0,0 +1,165 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM } from "../../src" +import { GoogleVertex, GoogleVertexAnthropic } from "../../src/providers" +import { LLMClient } from "../../src/route" +import { it } from "../lib/effect" +import { dynamicResponse } from "../lib/http" +import { sseEvents } from "../lib/sse" + +describe("Google Vertex providers", () => { + it.effect("sends Gemini requests to the global Vertex endpoint", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + model: GoogleVertex.configure({ + accessToken: "vertex-token", + location: "global", + project: "vertex-project", + }).model("gemini-3.5-flash"), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(request.url).toBe( + "https://aiplatform.googleapis.com/v1beta1/projects/vertex-project/locations/global/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse", + ) + expect(request.headers.get("authorization")).toBe("Bearer vertex-token") + expect(yield* Effect.promise(() => request.json())).toMatchObject({ + contents: [{ role: "user", parts: [{ text: "Say hello." }] }], + }) + return input.respond( + sseEvents({ + candidates: [ + { + content: { role: "model", parts: [{ text: "Hello." }] }, + finishReason: "STOP", + }, + ], + }), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + + expect(response.text).toBe("Hello.") + }), + ) + + it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + model: GoogleVertexAnthropic.configure({ + accessToken: "vertex-token", + location: "eu", + project: "vertex-project", + }).model("claude-sonnet-4-6"), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(request.url).toBe( + "https://aiplatform.eu.rep.googleapis.com/v1/projects/vertex-project/locations/eu/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict", + ) + expect(request.headers.get("authorization")).toBe("Bearer vertex-token") + expect(request.headers.get("anthropic-version")).toBeNull() + const body = yield* Effect.promise(() => request.json()) + expect(body).toMatchObject({ + anthropic_version: "vertex-2023-10-16", + messages: [{ role: "user", content: [{ type: "text", text: "Say hello." }] }], + stream: true, + }) + expect(body).not.toHaveProperty("model") + return input.respond( + sseEvents( + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello." } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } }, + { type: "message_stop" }, + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + + expect(response.text).toBe("Hello.") + }), + ) + + it.effect("protects the Vertex Anthropic API version from body overlays", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + model: GoogleVertexAnthropic.configure({ + accessToken: "vertex-token", + http: { body: { anthropic_version: "wrong" } }, + project: "vertex-project", + }).model("claude-sonnet-4-6"), + prompt: "Say hello.", + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("http.body cannot overlay protocol-owned field(s): anthropic_version") + }), + ) + + it.effect("routes tuned Gemini models through their deployed endpoint", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + model: GoogleVertex.configure({ + accessToken: "vertex-token", + location: "us-central1", + project: "vertex-project", + }).model("endpoints/1234567890"), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(request.url).toBe( + "https://us-central1-aiplatform.googleapis.com/v1beta1/projects/vertex-project/locations/us-central1/endpoints/1234567890:streamGenerateContent?alt=sse", + ) + return input.respond( + sseEvents({ + candidates: [ + { + content: { role: "model", parts: [{ text: "Hello." }] }, + finishReason: "STOP", + }, + ], + }), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + + expect(response.text).toBe("Hello.") + }), + ) + + it.effect("rejects tuned Gemini models in express mode", () => + Effect.sync(() => { + expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow( + "Google Vertex tuned models do not support Express Mode API keys", + ) + }), + ) +}) diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts similarity index 100% rename from packages/llm/test/provider/openai-chat.test.ts rename to packages/ai/test/provider/openai-chat.test.ts diff --git a/packages/llm/test/provider/openai-compatible-chat.test.ts b/packages/ai/test/provider/openai-compatible-chat.test.ts similarity index 100% rename from packages/llm/test/provider/openai-compatible-chat.test.ts rename to packages/ai/test/provider/openai-compatible-chat.test.ts diff --git a/packages/llm/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts similarity index 100% rename from packages/llm/test/provider/openai-compatible-responses.test.ts rename to packages/ai/test/provider/openai-compatible-responses.test.ts diff --git a/packages/llm/test/provider/openai-responses-cache.recorded.test.ts b/packages/ai/test/provider/openai-responses-cache.recorded.test.ts similarity index 100% rename from packages/llm/test/provider/openai-responses-cache.recorded.test.ts rename to packages/ai/test/provider/openai-responses-cache.recorded.test.ts diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts similarity index 100% rename from packages/llm/test/provider/openai-responses.test.ts rename to packages/ai/test/provider/openai-responses.test.ts diff --git a/packages/llm/test/provider/openrouter.test.ts b/packages/ai/test/provider/openrouter.test.ts similarity index 100% rename from packages/llm/test/provider/openrouter.test.ts rename to packages/ai/test/provider/openrouter.test.ts diff --git a/packages/llm/test/recorded-golden.ts b/packages/ai/test/recorded-golden.ts similarity index 100% rename from packages/llm/test/recorded-golden.ts rename to packages/ai/test/recorded-golden.ts diff --git a/packages/llm/test/recorded-runner.ts b/packages/ai/test/recorded-runner.ts similarity index 100% rename from packages/llm/test/recorded-runner.ts rename to packages/ai/test/recorded-runner.ts diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/ai/test/recorded-scenarios.ts similarity index 100% rename from packages/llm/test/recorded-scenarios.ts rename to packages/ai/test/recorded-scenarios.ts diff --git a/packages/llm/test/recorded-test.ts b/packages/ai/test/recorded-test.ts similarity index 100% rename from packages/llm/test/recorded-test.ts rename to packages/ai/test/recorded-test.ts diff --git a/packages/llm/test/recorded-utils.ts b/packages/ai/test/recorded-utils.ts similarity index 100% rename from packages/llm/test/recorded-utils.ts rename to packages/ai/test/recorded-utils.ts diff --git a/packages/llm/test/response.test.ts b/packages/ai/test/response.test.ts similarity index 100% rename from packages/llm/test/response.test.ts rename to packages/ai/test/response.test.ts diff --git a/packages/llm/test/route.test.ts b/packages/ai/test/route.test.ts similarity index 100% rename from packages/llm/test/route.test.ts rename to packages/ai/test/route.test.ts diff --git a/packages/llm/test/schema.test.ts b/packages/ai/test/schema.test.ts similarity index 100% rename from packages/llm/test/schema.test.ts rename to packages/ai/test/schema.test.ts diff --git a/packages/llm/test/tool-runtime.test.ts b/packages/ai/test/tool-runtime.test.ts similarity index 100% rename from packages/llm/test/tool-runtime.test.ts rename to packages/ai/test/tool-runtime.test.ts diff --git a/packages/llm/test/tool-schema-projection.test.ts b/packages/ai/test/tool-schema-projection.test.ts similarity index 100% rename from packages/llm/test/tool-schema-projection.test.ts rename to packages/ai/test/tool-schema-projection.test.ts diff --git a/packages/llm/test/tool-stream.test.ts b/packages/ai/test/tool-stream.test.ts similarity index 100% rename from packages/llm/test/tool-stream.test.ts rename to packages/ai/test/tool-stream.test.ts diff --git a/packages/llm/test/tool.types.ts b/packages/ai/test/tool.types.ts similarity index 100% rename from packages/llm/test/tool.types.ts rename to packages/ai/test/tool.types.ts diff --git a/packages/ai/tsconfig.build.json b/packages/ai/tsconfig.build.json new file mode 100644 index 0000000000..2e9770e8d3 --- /dev/null +++ b/packages/ai/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "noEmit": false + } +} diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json new file mode 100644 index 0000000000..dfd9a832c7 --- /dev/null +++ b/packages/ai/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + }, + "include": ["src"] +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 217ac4f86c..76eb75f015 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -55,6 +55,7 @@ }, "devDependencies": { "@opencode-ai/script": "workspace:*", + "@opencode-ai/protocol": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@types/semver": "catalog:", diff --git a/packages/cli/script/service-smoke.ts b/packages/cli/script/service-smoke.ts new file mode 100644 index 0000000000..c86e9f8280 --- /dev/null +++ b/packages/cli/script/service-smoke.ts @@ -0,0 +1,134 @@ +#!/usr/bin/env bun + +import { Service } from "@opencode-ai/client/effect/service" +import { ServiceStatus } from "@opencode-ai/protocol/groups/health" +import { Schema } from "effect" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}` +const directory = path.join(import.meta.dir, "..", "dist", target, "bin") +const binary = path.join(directory, `opencode2${process.platform === "win32" ? ".exe" : ""}`) +if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`) + +const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-")) +const env = { + ...process.env, + HOME: root, + USERPROFILE: root, + OPENCODE_DB: path.join(root, "opencode.db"), + OPENCODE_TEST_HOME: root, + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_STATE_HOME: path.join(root, "state"), +} +const processes: Array> = [] +const errors: Array> = [] +let failure: unknown +try { + spawnService() + spawnService() + const registration = await waitForRegistration() + const info = await Schema.decodeUnknownPromise(Service.Info)(await Bun.file(registration).json()) + if (info.id === undefined || info.password === undefined) throw new Error("Registration is missing service identity") + const credential = btoa(`opencode:${info.password}`) + const headers = { authorization: "Basic " + credential } + const token = encodeURIComponent(credential) + const health = await waitForReady(info.url, headers) + if (health.pid !== info.pid) throw new Error("Health process does not match registration") + const tokenHealth = await fetch( + new URL(`/api/health?auth_token=${token}`, info.url), + { signal: AbortSignal.timeout(5_000) }, + ) + if (tokenHealth.status !== 200) throw new Error("Compiled service rejected query authentication") + const tokenOpenApi = await fetch( + new URL(`/openapi.json?auth_token=${token}`, info.url), + { signal: AbortSignal.timeout(5_000) }, + ) + if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication") + + const unauthorizedHealth = await fetch(new URL("/api/health", info.url), { + signal: AbortSignal.timeout(5_000), + }) + if (unauthorizedHealth.status !== 401) throw new Error("Compiled service exposed health without authentication") + const unauthorizedOpenApi = await fetch(new URL("/openapi.json", info.url), { + signal: AbortSignal.timeout(5_000), + }) + if (unauthorizedOpenApi.status !== 401) throw new Error("Compiled service exposed application routes without authentication") + const unauthorizedStop = await fetch(new URL("/api/service/stop", info.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ instanceID: info.id }), + signal: AbortSignal.timeout(5_000), + }) + if (unauthorizedStop.status !== 401) throw new Error("Compiled service accepted unauthenticated stop") + + const winner = processes.find((process) => process.pid === info.pid) + const loser = processes.find((process) => process.pid !== info.pid) + if (!winner || !loser) throw new Error("Compiled contenders did not elect one registered owner") + if (!(await exitsWithin(loser, 10_000))) throw new Error("Losing compiled contender did not exit") + + const stopped = await Schema.decodeUnknownPromise(ServiceStatus.StopResponse)( + await fetch(new URL("/api/service/stop", info.url), { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ instanceID: info.id }), + signal: AbortSignal.timeout(5_000), + }).then((response) => response.json()), + ) + if (!stopped.accepted) throw new Error("Compiled service rejected exact-instance stop") + if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop") + for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25) + if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed") +} catch (cause) { + failure = cause +} finally { + processes.forEach((process) => process.kill()) + await Promise.all(processes.map((process) => process.exited)) +} + +const output = await Promise.all(errors) +await fs.rm(root, { recursive: true, force: true }) +if (failure) + throw new Error(output.filter(Boolean).join("\n") || "Compiled service lifecycle smoke test failed", { + cause: failure, + }) + +function spawnService() { + const process = Bun.spawn([binary, "serve", "--service"], { env, stdout: "ignore", stderr: "pipe" }) + processes.push(process) + errors.push(new Response(process.stderr).text()) + return process +} + +async function waitForRegistration() { + const directory = path.join(root, "state", "opencode") + for (let attempt = 0; attempt < 400; attempt++) { + const files = await fs.readdir(directory).catch(() => []) + const file = files.find( + (file) => file === "service.json" || (file.startsWith("service-") && file.endsWith(".json")), + ) + if (file) return path.join(directory, file) + await Bun.sleep(25) + } + throw new Error("Compiled service did not publish registration") +} + +async function waitForReady(url: string, headers: HeadersInit) { + const deadline = Date.now() + 20_000 + while (Date.now() < deadline) { + const response = await fetch(new URL("/api/health", url), { + headers, + signal: AbortSignal.timeout(1_000), + }).catch(() => undefined) + if (response?.ok) return Schema.decodeUnknownPromise(ServiceStatus.Health)(await response.json()) + await Bun.sleep(25) + } + throw new Error("Compiled service did not become ready") +} + +function exitsWithin(process: Bun.Subprocess, milliseconds: number) { + return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)]) +} diff --git a/packages/cli/src/commands/handlers/api.ts b/packages/cli/src/commands/handlers/api.ts index 49e367d48e..aa612f67c1 100644 --- a/packages/cli/src/commands/handlers/api.ts +++ b/packages/cli/src/commands/handlers/api.ts @@ -2,8 +2,8 @@ import { EOL } from "node:os" import { Effect, Option } from "effect" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { Service } from "@opencode-ai/client/effect" -import { Server } from "../../services/server" +import { Service, type Endpoint } from "@opencode-ai/client/effect/service" +import { ServerConnection } from "../../services/server-connection" const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"]) @@ -18,7 +18,7 @@ type OpenApi = { export default Runtime.handler( Commands.commands.api, Effect.fn("cli.api")(function* (input) { - const server = yield* Server.resolve({ + const server = yield* ServerConnection.resolve({ server: Option.getOrUndefined(input.server), standalone: input.standalone, mismatch: "ignore", @@ -62,11 +62,7 @@ export function rawRequest(input: readonly string[]) { return { method: input[0].toUpperCase(), path: input[1] } } -function resolveRequest( - endpoint: Service.Endpoint, - input: readonly string[], - params: Record, -) { +function resolveRequest(endpoint: Endpoint, input: readonly string[], params: Record) { const raw = rawRequest(input) if (raw) return Effect.succeed(raw) if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path")) diff --git a/packages/cli/src/commands/handlers/console/login.ts b/packages/cli/src/commands/handlers/console/login.ts index 973fe1a326..99af278a35 100644 --- a/packages/cli/src/commands/handlers/console/login.ts +++ b/packages/cli/src/commands/handlers/console/login.ts @@ -1,5 +1,5 @@ import { Cause, Effect, Exit, Option } from "effect" -import { Service } from "@opencode-ai/client/effect" +import { Service } from "@opencode-ai/client/effect/service" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { AppProcess } from "@opencode-ai/core/process" import { Commands } from "../../commands" @@ -35,7 +35,7 @@ const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHo yield* request(() => timeline.intro("Log in")) yield* request(() => timeline.pending("Connecting to OpenCode...")) - const endpoint = yield* Service.start(yield* ServiceConfig.options()) + const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const found = yield* request((signal) => client.integration.get({ integrationID, location }, { signal })) const integration = yield* required(found.data, "OpenCode Console integration is unavailable") diff --git a/packages/cli/src/commands/handlers/debug/agents.ts b/packages/cli/src/commands/handlers/debug/agents.ts index 3126e8d277..c178e90b45 100644 --- a/packages/cli/src/commands/handlers/debug/agents.ts +++ b/packages/cli/src/commands/handlers/debug/agents.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { OpenCode } from "@opencode-ai/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { Service } from "@opencode-ai/client/effect" +import { Service } from "@opencode-ai/client/effect/service" import { ServiceConfig } from "../../../services/service-config" export default Runtime.handler( @@ -11,7 +11,7 @@ export default Runtime.handler( Effect.fn("cli.debug.agents")(function* () { const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) - const endpoint = found ?? (yield* Service.start(options)) + const endpoint = found ?? (yield* Service.ensure(options)) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } })) process.stdout.write( diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 8143c26352..1f1a441571 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -4,8 +4,8 @@ import { run } from "@opencode-ai/tui" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Config } from "../../config" -import { Effect, Option } from "effect" -import { Server } from "../../services/server" +import { Context, Effect, FileSystem, Option } from "effect" +import { ServerConnection } from "../../services/server-connection" import { Updater } from "../../services/updater" import { UpdatePreflight } from "../../services/update-preflight" import { Npm } from "@opencode-ai/core/npm" @@ -18,11 +18,11 @@ export default Runtime.handler(Commands, (input) => yield* updater.check().pipe(Effect.forkScoped) const preflight = UpdatePreflight.make() yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close())) - const server = yield* Server.resolve({ + const server = yield* ServerConnection.resolve({ server: Option.getOrUndefined(input.server), standalone: input.standalone, - onStart: (reason, existing) => { - if (reason === "version-mismatch" && preflight.begin(existing?.version)) return + onStart: (reason, previousVersion) => { + if (reason === "version-mismatch" && preflight.begin(previousVersion)) return process.stderr.write( reason === "version-mismatch" ? "Restarting background server (version mismatch)...\n" @@ -37,11 +37,22 @@ export default Runtime.handler(Commands, (input) => preflight.loading() const config = yield* Config.Service const npm = yield* Npm.Service - const context = yield* Effect.context() + const fileSystem = yield* FileSystem.FileSystem + const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem)) + const context = yield* Effect.context() const runFork = Effect.runForkWith(context) const runPromise = Effect.runPromiseWith(context) + const service = server.service yield* run({ - server, + server: { + endpoint: server.endpoint, + service: service + ? { + reconnect: (signal) => runServicePromise(service.reconnect(), { signal }), + restart: () => runServicePromise(service.restart()), + } + : undefined, + }, args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, config: { path: config.path, diff --git a/packages/cli/src/commands/handlers/mcp/auth.ts b/packages/cli/src/commands/handlers/mcp/auth.ts index 6fc4200ff4..32e728b435 100644 --- a/packages/cli/src/commands/handlers/mcp/auth.ts +++ b/packages/cli/src/commands/handlers/mcp/auth.ts @@ -8,7 +8,7 @@ import { } from "@opencode-ai/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { Service } from "@opencode-ai/client/effect" +import { Service } from "@opencode-ai/client/effect/service" import { ServiceConfig } from "../../../services/service-config" import { resolveIntegration } from "./resolve" @@ -19,7 +19,7 @@ export default Runtime.handler( Effect.fn("cli.mcp.auth")(function* (input) { const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) - const endpoint = found ?? (yield* Service.start(options)) + const endpoint = found ?? (yield* Service.ensure(options)) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const integration = yield* resolveIntegration(client, input.name, location) diff --git a/packages/cli/src/commands/handlers/mcp/list.ts b/packages/cli/src/commands/handlers/mcp/list.ts index eb4412eedf..590fb818c5 100644 --- a/packages/cli/src/commands/handlers/mcp/list.ts +++ b/packages/cli/src/commands/handlers/mcp/list.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { OpenCode, type McpServer } from "@opencode-ai/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { Service } from "@opencode-ai/client/effect" +import { Service } from "@opencode-ai/client/effect/service" import { ServiceConfig } from "../../../services/service-config" export default Runtime.handler( @@ -11,7 +11,7 @@ export default Runtime.handler( Effect.fn("cli.mcp.list")(function* () { const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) - const endpoint = found ?? (yield* Service.start(options)) + const endpoint = found ?? (yield* Service.ensure(options)) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } })) const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name)) diff --git a/packages/cli/src/commands/handlers/mcp/logout.ts b/packages/cli/src/commands/handlers/mcp/logout.ts index bcf06ea983..47dc3052a6 100644 --- a/packages/cli/src/commands/handlers/mcp/logout.ts +++ b/packages/cli/src/commands/handlers/mcp/logout.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { OpenCode } from "@opencode-ai/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { Service } from "@opencode-ai/client/effect" +import { Service } from "@opencode-ai/client/effect/service" import { ServiceConfig } from "../../../services/service-config" import { resolveIntegration } from "./resolve" @@ -14,7 +14,7 @@ export default Runtime.handler( Effect.fn("cli.mcp.logout")(function* (input) { const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) - const endpoint = found ?? (yield* Service.start(options)) + const endpoint = found ?? (yield* Service.ensure(options)) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const integration = yield* resolveIntegration(client, input.name, location) diff --git a/packages/cli/src/commands/handlers/mini.ts b/packages/cli/src/commands/handlers/mini.ts index 7919b2bc8e..e44f7e0b2d 100644 --- a/packages/cli/src/commands/handlers/mini.ts +++ b/packages/cli/src/commands/handlers/mini.ts @@ -1,14 +1,14 @@ import { Effect, Option } from "effect" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { Server } from "../../services/server" +import { ServerConnection } from "../../services/server-connection" export default Runtime.handler(Commands.commands.mini, (input) => Effect.gen(function* () { const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini")) yield* Effect.promise(async () => validateMiniTerminal()) const serverURL = Option.getOrUndefined(input.server) - const server = yield* Server.resolve({ server: serverURL, standalone: input.standalone }) + const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone }) yield* Effect.promise(() => runMini({ server, diff --git a/packages/cli/src/commands/handlers/pair.ts b/packages/cli/src/commands/handlers/pair.ts index 5c40a20444..edbae68f6c 100644 --- a/packages/cli/src/commands/handlers/pair.ts +++ b/packages/cli/src/commands/handlers/pair.ts @@ -1,6 +1,6 @@ import { EOL } from "os" import { Effect } from "effect" -import { Service } from "@opencode-ai/client/effect" +import { Service } from "@opencode-ai/client/effect/service" import { OpenCode } from "@opencode-ai/client/promise" import { renderUnicodeCompact } from "uqr" import { Commands } from "../commands" @@ -10,7 +10,7 @@ import { ServiceConfig } from "../../services/service-config" export default Runtime.handler( Commands.commands.pair, Effect.fn("cli.pair")(function* () { - const endpoint = yield* Service.start(yield* ServiceConfig.options()) + const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) const password = yield* ServiceConfig.password() const server = yield* Effect.tryPromise(() => OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.get(), diff --git a/packages/cli/src/commands/handlers/run.ts b/packages/cli/src/commands/handlers/run.ts index e8abf0cb1e..50fbaa4e2c 100644 --- a/packages/cli/src/commands/handlers/run.ts +++ b/packages/cli/src/commands/handlers/run.ts @@ -1,13 +1,13 @@ import { Effect, Option } from "effect" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { Server } from "../../services/server" +import { ServerConnection } from "../../services/server-connection" export default Runtime.handler(Commands.commands.run, (input) => Effect.gen(function* () { const { runNonInteractive } = yield* Effect.promise(() => import("../../mini")) const separator = process.argv.indexOf("--", 2) - const server = yield* Server.resolve({ + const server = yield* ServerConnection.resolve({ server: Option.getOrUndefined(input.server), standalone: input.standalone, }) diff --git a/packages/cli/src/commands/handlers/service/restart.ts b/packages/cli/src/commands/handlers/service/restart.ts index 93b8836acd..fe306dcb02 100644 --- a/packages/cli/src/commands/handlers/service/restart.ts +++ b/packages/cli/src/commands/handlers/service/restart.ts @@ -1,6 +1,6 @@ import { EOL } from "os" import { Effect } from "effect" -import { Service } from "@opencode-ai/client/effect" +import { Service } from "@opencode-ai/client/effect/service" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { ServiceConfig } from "../../../services/service-config" @@ -10,7 +10,7 @@ export default Runtime.handler( Effect.fn("cli.service.restart")(function* () { const options = yield* ServiceConfig.options() yield* Service.stop(options) - const transport = yield* Service.start(options) + const transport = yield* Service.ensure(options) process.stdout.write(transport.url + EOL) }), ) diff --git a/packages/cli/src/commands/handlers/service/start.ts b/packages/cli/src/commands/handlers/service/start.ts index 602a26ecf1..dfdbac3997 100644 --- a/packages/cli/src/commands/handlers/service/start.ts +++ b/packages/cli/src/commands/handlers/service/start.ts @@ -1,6 +1,6 @@ import { EOL } from "os" import { Effect } from "effect" -import { Service } from "@opencode-ai/client/effect" +import { Service } from "@opencode-ai/client/effect/service" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { ServiceConfig } from "../../../services/service-config" @@ -8,7 +8,7 @@ import { ServiceConfig } from "../../../services/service-config" export default Runtime.handler( Commands.commands.service.commands.start, Effect.fn("cli.service.start")(function* () { - const transport = yield* Service.start(yield* ServiceConfig.options()) + const transport = yield* Service.ensure(yield* ServiceConfig.options()) process.stdout.write(transport.url + EOL) }), ) diff --git a/packages/cli/src/commands/handlers/service/status.ts b/packages/cli/src/commands/handlers/service/status.ts index bf58968eef..8f27ab2ec2 100644 --- a/packages/cli/src/commands/handlers/service/status.ts +++ b/packages/cli/src/commands/handlers/service/status.ts @@ -1,6 +1,6 @@ import { EOL } from "os" import { Effect } from "effect" -import { Service } from "@opencode-ai/client/effect" +import { Service } from "@opencode-ai/client/effect/service" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { ServiceConfig } from "../../../services/service-config" @@ -8,7 +8,8 @@ import { ServiceConfig } from "../../../services/service-config" export default Runtime.handler( Commands.commands.service.commands.status, Effect.fn("cli.service.status")(function* () { - const found = yield* Service.discover(yield* ServiceConfig.options()) - process.stdout.write((found ? found.url : "stopped") + EOL) + const options = yield* ServiceConfig.options() + const found = yield* Service.discover({ ...options, version: undefined }) + process.stdout.write((found?.url ?? "stopped") + EOL) }), ) diff --git a/packages/cli/src/commands/handlers/service/stop.ts b/packages/cli/src/commands/handlers/service/stop.ts index 5bf45ccabc..ca2c164165 100644 --- a/packages/cli/src/commands/handlers/service/stop.ts +++ b/packages/cli/src/commands/handlers/service/stop.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { Service } from "@opencode-ai/client/effect" +import { Service } from "@opencode-ai/client/effect/service" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { ServiceConfig } from "../../../services/service-config" diff --git a/packages/cli/src/config/migrate.ts b/packages/cli/src/config/migrate.ts index bf57fdb317..ca8070e25c 100644 --- a/packages/cli/src/config/migrate.ts +++ b/packages/cli/src/config/migrate.ts @@ -116,14 +116,11 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record → emits a specific tool/text type (text, reasoning, bash, +// /fmt → emits a specific tool/text type (text, reasoning, shell, // write, edit, patch, task, question, error, mix) // // Demo mode also handles permission and question replies locally, completing @@ -33,7 +33,7 @@ const KINDS = [ "table", "text", "reasoning", - "bash", + "shell", "write", "edit", "patch", @@ -42,7 +42,7 @@ const KINDS = [ "error", "mix", ] -const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const +const PERMISSIONS = ["edit", "shell", "read", "task", "external", "doom"] as const const QUESTIONS = ["multi", "single", "checklist", "custom"] as const type PermissionKind = (typeof PERMISSIONS)[number] @@ -436,7 +436,7 @@ function emitError(state: State, text: string): void { } async function emitBash(state: State, signal?: AbortSignal): Promise { - const ref = make(state, "bash", { + const ref = make(state, "shell", { command: "git status", workdir: process.cwd(), description: "Show git status", @@ -623,16 +623,16 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { const root = process.cwd() const file = path.join(root, "src", "demo-format.ts") - if (kind === "bash") { + if (kind === "shell") { const command = "git status --short" - const ref = make(state, "bash", { + const ref = make(state, "shell", { command, workdir: root, description: "Inspect worktree changes", }) askPermission(state, { ref, - permission: "bash", + permission: "shell", patterns: [command], always: ["*"], done: { @@ -862,7 +862,7 @@ async function emitFmt(state: State, kind: string, body: string, signal?: AbortS return true } - if (kind === "bash") { + if (kind === "shell") { await emitBash(state, signal) return true } @@ -924,7 +924,7 @@ function intro(state: State): void { `- /question [kind] (${QUESTIONS.join(", ")})`, `- /fmt (${KINDS.join(", ")})`, "Examples:", - "- /permission bash", + "- /permission shell", "- /question custom", "- /fmt markdown", "- /fmt table", diff --git a/packages/cli/src/mini/mini.ts b/packages/cli/src/mini/mini.ts index 521995b45c..99ec8bcf3b 100644 --- a/packages/cli/src/mini/mini.ts +++ b/packages/cli/src/mini/mini.ts @@ -1,12 +1,12 @@ -import { Service } from "@opencode-ai/client/effect" +import { Service } from "@opencode-ai/client/effect/service" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" -import { Server } from "../services/server" +import { ServerConnection } from "../services/server-connection" import { waitForCatalogReady } from "./catalog.shared" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin" import type { RunInput, RunTuiConfig } from "./types" export type MiniCommandInput = { - server: Server.Resolved + server: ServerConnection.Resolved continue?: boolean session?: string fork?: boolean @@ -38,10 +38,7 @@ export async function runMini(input: MiniCommandInput) { return agentTask } const resolveSession = async () => { - const [agent, selected] = await Promise.all([ - resolveAgent(), - selectSession(sdk, directory, input), - ]) + const [agent, selected] = await Promise.all([resolveAgent(), selectSession(sdk, directory, input)]) const readyModel = model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined) if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel }) diff --git a/packages/cli/src/mini/run.ts b/packages/cli/src/mini/run.ts index 2fc849bce0..cc4b7c91f8 100644 --- a/packages/cli/src/mini/run.ts +++ b/packages/cli/src/mini/run.ts @@ -1,10 +1,10 @@ -import { Service } from "@opencode-ai/client/effect" +import { Service, type Endpoint } from "@opencode-ai/client/effect/service" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { FSUtil } from "@opencode-ai/core/fs-util" import { Model } from "@opencode-ai/schema/model" import { open } from "node:fs/promises" import path from "node:path" -import { Server } from "../services/server" +import { ServerConnection } from "../services/server-connection" import { loadRunAgents, waitForCatalogReady } from "./catalog.shared" import { runNonInteractivePrompt } from "./noninteractive" import { toolInlineInfo } from "./tool" @@ -12,7 +12,7 @@ import type { MiniToolPart } from "./types" import { UI } from "./ui" export type RunCommandInput = { - server: Server.Resolved + server: ServerConnection.Resolved message: string[] continue?: boolean session?: string @@ -55,7 +55,7 @@ async function run(input: RunCommandInput) { return execute(input, prepared, input.server.endpoint) } -async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Service.Endpoint) { +async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint) { const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const requestedDirectory = prepared.directory ?? (await client.location.get()).directory if (!requestedDirectory) fail("Failed to resolve server directory") @@ -73,8 +73,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser .then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined)) : undefined const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel) - if (variant && !model) - return reportError(input, "Cannot select a variant before selecting a model", session?.id) + if (variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id) if (model) { await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model }) const available = await client.model.list({ location: { directory: cwd, workspace } }) diff --git a/packages/cli/src/mini/stream-v2.transport.ts b/packages/cli/src/mini/stream-v2.transport.ts index bf2ccd5bf9..057801d2cd 100644 --- a/packages/cli/src/mini/stream-v2.transport.ts +++ b/packages/cli/src/mini/stream-v2.transport.ts @@ -218,7 +218,7 @@ function shellCommit( kind: "tool", source: "tool", partID: `shell:${callID}`, - tool: "bash", + tool: "shell", shell: { callID, command }, ...next, } diff --git a/packages/cli/src/mini/tool.ts b/packages/cli/src/mini/tool.ts index 3772debdc5..fc30c565ec 100644 --- a/packages/cli/src/mini/tool.ts +++ b/packages/cli/src/mini/tool.ts @@ -1,6 +1,6 @@ // Per-tool display rules shared across `opencode run` output paths. // -// Each known tool (bash, edit, write, task, etc.) has a ToolRule that controls +// Each known tool (shell, edit, write, task, etc.) has a ToolRule that controls // five display hooks: // // view → visibility policy for progress/final scrollback entries and @@ -114,7 +114,7 @@ type ToolPermissionCtx = { type ToolName = | "invalid" - | "bash" + | "shell" | "write" | "edit" | "patch" @@ -648,7 +648,7 @@ function scrollBashProgress(p: ToolProps): string { return fmt(out) } -function scrollBashFinal(p: ToolProps): string { +function scrollShellFinal(p: ToolProps): string { if (p.frame.status === "error") { return fail(p.frame) } @@ -657,13 +657,13 @@ function scrollBashFinal(p: ToolProps): string { const time = span(p.frame.state) if (code === undefined) { if (!time) { - return "bash completed" + return "shell completed" } - return `bash completed · ${time}` + return `shell completed · ${time}` } - return `bash completed (exit ${code})${time ? ` · ${time}` : ""}` + return `shell completed (exit ${code})${time ? ` · ${time}` : ""}` } function scrollReadStart(p: ToolProps): string { @@ -976,16 +976,16 @@ const TOOL_RULES = { start: () => "", }, }, - bash: { + shell: { view: { output: true, final: false, }, - run: runBash, + run: runShell, scroll: { start: scrollBashStart, progress: scrollBashProgress, - final: scrollBashFinal, + final: scrollShellFinal, }, permission: permBash, }, @@ -1202,7 +1202,7 @@ export function toolFrame(commit: StreamCommit, raw: string): ToolFrame { } } -function runBash(p: ToolProps): ToolInline { +function runShell(p: ToolProps): ToolInline { return { icon: "$", title: p.input.command || "", diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 9f688fb16e..ae4e1a4b61 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -1,17 +1,16 @@ export * as ServerProcess from "./server-process" import { NodeServices } from "@effect/platform-node" -import { Service } from "@opencode-ai/client/effect" +import { Service } from "@opencode-ai/client/effect/service" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Global } from "@opencode-ai/core/global" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { AppProcess } from "@opencode-ai/core/process" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" -import { start } from "@opencode-ai/server/process" +import { ProcessLock } from "@opencode-ai/core/util/process-lock" import { randomBytes, randomUUID } from "node:crypto" import path from "node:path" -import { Effect, Exit, FileSystem, Logger, Option, Redacted, Schedule, Schema, Scope } from "effect" +import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect" import { HttpServer } from "effect/unstable/http" import { Env } from "./env" import { ServiceConfig } from "./services/service-config" @@ -28,7 +27,7 @@ export type Options = { export const run = Effect.fn("cli.server-process.run")((options: Options) => processEffect(options).pipe( Effect.provide(Updater.layer), - Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, EffectFlock.node]))), + Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))), Effect.provide(NodeServices.layer), ), ) @@ -38,15 +37,15 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { return yield* Effect.scoped( Effect.gen(function* () { const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined - const lockScope = serviceOptions === undefined ? undefined : yield* acquireServiceLock(serviceOptions.file) - if ( - serviceOptions !== undefined && - lockScope !== undefined && - (yield* Service.discover(serviceOptions)) !== undefined - ) { - yield* Scope.close(lockScope, Exit.void) - return + if (serviceOptions !== undefined) { + const acquired = yield* ProcessLock.acquire(serviceOptions.file + ".lock").pipe( + Effect.as(true), + Effect.catchTag("ProcessLockHeldError", () => Effect.succeed(false)), + ) + if (!acquired) return yield* Effect.void + if ((yield* Service.discover(serviceOptions)) !== undefined) return yield* Effect.void } + const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process")) const environmentPassword = yield* Env.password // Keep the lease credential out of the environment inherited by tools. if (options.mode === "stdio") { @@ -61,77 +60,82 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { ? Redacted.value(environmentPassword) : randomBytes(32).toString("base64url") if (!password) return yield* Effect.fail(new Error("Missing server password")) - const address = yield* start({ + const instanceID = randomUUID() + const server = yield* start({ hostname: options.hostname ?? config.hostname ?? "127.0.0.1", port: Option.fromNullishOr(options.port ?? config.port), password, - restartContinuity: options.mode === "service", + instanceID, + service: + serviceOptions === undefined + ? undefined + : { onListen: (address) => register(address, password, instanceID, serviceOptions.file) }, }).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false }))) - if (lockScope !== undefined) { - yield* register(address, password) - yield* Scope.close(lockScope, Exit.void) - } - const url = HttpServer.formatAddress(address) + const url = HttpServer.formatAddress(server.address) console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`) if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`) const updater = yield* Updater.Service yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped) - return yield* options.mode === "stdio" ? waitForStdinClose() : Effect.never + return yield* options.mode === "service" + ? server.shutdown + : options.mode === "stdio" + ? waitForStdinClose() + : Effect.never }).pipe(Effect.annotateLogs({ role: "server" })), ) }) -const acquireServiceLock = Effect.fnUntraced(function* (file: string) { - const flock = yield* EffectFlock.Service - const scope = yield* Scope.make() - yield* Effect.addFinalizer((exit) => Scope.close(scope, exit)) - yield* flock - .acquire(`service:${file}`, undefined, { staleMs: 3_000, timeoutMs: 3_000 }) - .pipe(Effect.provideService(Scope.Scope, scope)) - return scope -}) - -// The latest atomic registration wins. A displaced process notices the new id, -// exits, and cannot remove its successor's registration from its finalizer. const infoJson = Schema.fromJsonString(Service.Info) const encodeInfo = Schema.encodeEffect(infoJson) const decodeInfo = Schema.decodeUnknownEffect(infoJson) -const register = Effect.fnUntraced(function* (address: HttpServer.Address, password: string) { +const register = Effect.fnUntraced(function* ( + address: HttpServer.Address, + password: string, + id: string, + file: string, +) { const fs = yield* FileSystem.FileSystem - const options = yield* ServiceConfig.options() - const id = randomUUID() - const temp = options.file + "." + id + ".tmp" - yield* fs.makeDirectory(path.dirname(options.file), { recursive: true }) - const encoded = yield* encodeInfo({ + const temp = file + "." + id + ".tmp" + yield* fs.makeDirectory(path.dirname(file), { recursive: true }) + const info = { id, version: InstallationVersion, url: HttpServer.formatAddress(address), pid: process.pid, password, - }) - yield* fs.writeFileString(temp, encoded, { mode: 0o600 }) - yield* fs.rename(temp, options.file) - const currentID = fs.readFileString(options.file).pipe( + } + const encoded = yield* encodeInfo(info) + const publish = fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file))) + yield* publish + const current = fs.readFileString(file).pipe( Effect.flatMap(decodeInfo), - Effect.map((info) => info.id), Effect.orElseSucceed(() => undefined), ) - yield* currentID.pipe( - Effect.flatMap((current) => - current === id - ? Effect.void - : Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore), - ), - Effect.repeat(Schedule.spaced("10 seconds")), - Effect.forkScoped, - ) + const assertRegistration = Effect.gen(function* () { + const found = yield* current + if ( + found !== undefined && + found.id === info.id && + found.version === info.version && + found.url === info.url && + found.pid === info.pid && + found.password === info.password + ) + return + yield* publish + }) yield* Effect.addFinalizer(() => - currentID.pipe( - Effect.flatMap((current) => (current === id ? fs.remove(options.file) : Effect.void)), + current.pipe( + Effect.flatMap((current) => (current?.id === id ? fs.remove(file) : Effect.void)), Effect.ignore, ), ) + yield* assertRegistration.pipe( + Effect.catchCause((cause) => Effect.logWarning("failed to reassert service registration", { cause })), + Effect.repeat(Schedule.spaced("5 seconds")), + Effect.forkScoped, + ) }) function waitForStdinClose() { diff --git a/packages/cli/src/services/server.ts b/packages/cli/src/services/server-connection.ts similarity index 56% rename from packages/cli/src/services/server.ts rename to packages/cli/src/services/server-connection.ts index 34ddcf965d..4eccc95bc9 100644 --- a/packages/cli/src/services/server.ts +++ b/packages/cli/src/services/server-connection.ts @@ -1,5 +1,4 @@ -import { NodeFileSystem } from "@effect/platform-node" -import { Service } from "@opencode-ai/client/effect" +import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service" import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Effect, Redacted } from "effect" @@ -11,26 +10,23 @@ export type Args = { readonly server?: string readonly standalone?: boolean readonly mismatch?: "replace" | "ignore" | "error" - readonly onStart?: Service.StartOptions["onStart"] + readonly onStart?: EnsureOptions["onStart"] } export type Resolved = { - readonly endpoint: Service.Endpoint - readonly reconnect?: (attempt: number) => Promise - readonly reload?: () => Promise + readonly endpoint: Endpoint + readonly service?: ReturnType } -export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) { +export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) { if (args.server !== undefined && args.standalone) return yield* Effect.fail(new Error("--server and --standalone cannot be combined")) if (args.server !== undefined) { const password = yield* Env.password const endpoint = { url: args.server, - auth: password - ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } - : undefined, - } satisfies Service.Endpoint + auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined, + } satisfies Endpoint const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const health = yield* Effect.tryPromise({ try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }), @@ -47,44 +43,40 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) { } const options = yield* ServiceConfig.options() - const endpoint = yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace") - const reconnectOptions = { ...options, version: undefined } return { - endpoint, - reconnect: (attempt) => - Effect.runPromise( - Effect.gen(function* () { - if (attempt > 3) return yield* Service.start(reconnectOptions) - const endpoint = yield* Service.discover(reconnectOptions) - if (endpoint !== undefined) return endpoint - return yield* Effect.fail(new Error("Background server is unavailable")) - }).pipe(Effect.provide(NodeFileSystem.layer)), - ), - reload: () => - Effect.runPromise( - Effect.gen(function* () { - yield* Service.stop(options) - yield* Service.start(options) - }).pipe(Effect.provide(NodeFileSystem.layer)), - ), + endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace"), + service: managedService(options), } satisfies Resolved }) +function managedService(options: EnsureOptions) { + const reconnectOptions = { ...options, version: undefined } + return { + reconnect: () => Service.ensure(reconnectOptions), + restart: () => + Effect.gen(function* () { + yield* Service.stop(options) + yield* Service.ensure(options) + }), + } +} + const resolveManaged = Effect.fnUntraced(function* ( - options: Service.StartOptions, + options: EnsureOptions, mismatch: NonNullable, ) { - if (mismatch === "replace") return yield* Service.start(options) - if (mismatch === "ignore") return yield* Service.start({ ...options, version: undefined }) + if (mismatch === "replace") return yield* Service.ensure(options) + if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined }) const compatible = yield* Service.discover(options) if (compatible !== undefined) return compatible const existing = yield* Service.discover({ ...options, version: undefined }) - if (existing !== undefined) return yield* Effect.fail(new Error("Background server version does not match this client")) - return yield* Service.start(options) + if (existing !== undefined) + return yield* Effect.fail(new Error("Background server version does not match this client")) + return yield* Service.ensure(options) }) -function connectError(endpoint: Service.Endpoint, cause: unknown) { +function connectError(endpoint: Endpoint, cause: unknown) { if (isUnauthorizedError(cause)) { return new Error( endpoint.auth === undefined @@ -98,4 +90,4 @@ function connectError(endpoint: Service.Endpoint, cause: unknown) { return new Error(`Server at ${endpoint.url} did not provide a compatible V2 health response`, { cause }) } -export * as Server from "./server" +export * as ServerConnection from "./server-connection" diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index ecdd5bf19a..2446d30730 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -1,11 +1,12 @@ import { Global } from "@opencode-ai/core/global" import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" -import { Service } from "@opencode-ai/client/effect" -import { Effect, FileSystem, Schema } from "effect" +import { Hash } from "@opencode-ai/core/util/hash" +import { Service } from "@opencode-ai/client/effect/service" +import { Effect, FileSystem, Option, Schema } from "effect" import { randomBytes } from "crypto" import path from "path" -// The CLI's service configuration file, plus the Service.Options binding that +// The CLI's service configuration file, plus the Service.EnsureOptions binding that // points the client package's service operations at this CLI: which // registration file (by channel), which version, and how to spawn opencode. @@ -20,25 +21,63 @@ const keys = ["hostname", "port", "password"] as const type Key = (typeof keys)[number] const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) +const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info)) + +export function filename(channel = InstallationChannel) { + if (channel === "latest") return "service.json" + if (channel === "local") return "service-local.json" + return `service-${Hash.fast(channel)}.json` +} + +export function versionBelongsToChannel( + version: string | undefined, + channel = InstallationChannel, + installedVersion = InstallationVersion, +) { + if (version === undefined) return false + if (version === installedVersion) return true + const prefix = `0.0.0-${channel}-` + if (!version.startsWith(prefix)) return false + return /^\d+(?:\.\d+)?$/.test(version.slice(prefix.length)) +} + +export const migrateRegistration = Effect.fnUntraced(function* ( + legacy: string, + file: string, + channel = InstallationChannel, + installedVersion = InstallationVersion, +) { + if (channel === "latest" || channel === "local") return + const fs = yield* FileSystem.FileSystem + const text = yield* fs.readFileString(legacy).pipe(Effect.option) + if (Option.isNone(text)) return + const registration = yield* decodeRegistration(text.value).pipe(Effect.option) + if (Option.isNone(registration)) return + if (!versionBelongsToChannel(registration.value.version, channel, installedVersion)) return + yield* fs.writeFileString(file, text.value, { flag: "wx", mode: 0o600 }).pipe(Effect.ignore) +}) function configKey(key: string): Key { - if (keys.includes(key as Key)) return key as Key + if (key === "hostname" || key === "port" || key === "password") return key throw new Error(`Unknown service config key: ${key}`) } -const env = Effect.gen(function* () { +const paths = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const global = yield* Global.Service - const filename = InstallationChannel === "local" ? "service-local.json" : "service.json" + const name = filename() + const file = path.join(global.state, name) return { fs, - file: path.join(global.state, filename), - configFile: path.join(global.config, filename), + file, + legacyFile: path.join(global.state, "service.json"), + configFile: path.join(global.config, name), } }) export const options = Effect.fnUntraced(function* () { - const { file } = yield* env + const { file, legacyFile } = yield* paths + yield* migrateRegistration(legacyFile, file) const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun" const entrypoint = compiled ? undefined : process.argv[1] if (!compiled && entrypoint === undefined) return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint")) @@ -50,7 +89,7 @@ export const options = Effect.fnUntraced(function* () { }) export const read = Effect.fn("cli.service-config.read")(function* () { - const { fs, configFile } = yield* env + const { fs, configFile } = yield* paths return yield* fs.readFileString(configFile).pipe( Effect.flatMap(decodeInfo), Effect.catch(() => Effect.succeed({} as Info)), @@ -58,7 +97,7 @@ export const read = Effect.fn("cli.service-config.read")(function* () { }) const write = Effect.fn("cli.service-config.write")(function* (value: Info) { - const { fs, configFile } = yield* env + const { fs, configFile } = yield* paths const temp = configFile + ".tmp" yield* fs.makeDirectory(path.dirname(configFile), { recursive: true }) yield* fs.writeFileString(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }) @@ -93,6 +132,7 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string) return yield* password() } } + throw new Error(`Unknown service config key: ${key}`) }) export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string) { diff --git a/packages/cli/src/services/standalone.ts b/packages/cli/src/services/standalone.ts index 099b1c9df1..a3098e0076 100644 --- a/packages/cli/src/services/standalone.ts +++ b/packages/cli/src/services/standalone.ts @@ -1,4 +1,4 @@ -import { Service } from "@opencode-ai/client/effect" +import { Service, type Endpoint } from "@opencode-ai/client/effect/service" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Effect, Schema, Stream } from "effect" @@ -46,7 +46,7 @@ const makeEndpoint = Effect.fn("cli.standalone.endpoint")( url: ready.url, auth: { type: "basic" as const, username: "opencode", password }, pid: proc.pid, - } satisfies Service.Endpoint & { readonly pid: number } + } satisfies Endpoint & { readonly pid: number } }, Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)), ) diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index a337682666..41c023d1a1 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -47,7 +47,6 @@ test("migrates tui and kv config into cli.json", async () => { scrollbar_visible: true, thinking_mode: "show", exploration_grouping: false, - tips_hidden: true, dismissed_getting_started: true, animations_enabled: false, skipped_version: "9.9.9", @@ -75,7 +74,7 @@ test("migrates tui and kv config into cli.json", async () => { terminal: { title: false }, prompt: { editor: false, paste: "full" }, session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" }, - hints: { tips: false, onboarding: false }, + hints: { onboarding: false }, animations: false, mouse: false, }) diff --git a/packages/cli/test/fixture/standalone-owner.ts b/packages/cli/test/fixture/standalone-owner.ts index 7f92ac0636..62e1b74887 100644 --- a/packages/cli/test/fixture/standalone-owner.ts +++ b/packages/cli/test/fixture/standalone-owner.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { Service } from "@opencode-ai/client/effect" +import { Service } from "@opencode-ai/client/effect/service" import path from "node:path" import { Standalone } from "../../src/services/standalone" diff --git a/packages/cli/test/mini.test.ts b/packages/cli/test/mini.test.ts index 17ff0177c7..5fb8b3be7f 100644 --- a/packages/cli/test/mini.test.ts +++ b/packages/cli/test/mini.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "node:path" import { mergeInteractiveInput, mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/mini" +import { toolInlineInfo, toolView } from "../src/mini/tool" async function cli(args: string[]) { const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], { @@ -18,6 +19,23 @@ async function cli(args: string[]) { } describe("mini command", () => { + test("renders the renamed shell tool with the shell rule", () => { + const part = { + id: "part-shell", + sessionID: "session-shell", + messageID: "message-shell", + callID: "call-shell", + tool: "shell", + state: { + status: "pending" as const, + input: { command: "pwd" }, + }, + } as const + + expect(toolView(part.tool)).toEqual({ output: true, final: false }) + expect(toolInlineInfo(part)).toMatchObject({ icon: "$", title: "pwd", mode: "block" }) + }) + test("uses piped stdin as the initial prompt", () => { expect(mergeInteractiveInput("from stdin", undefined)).toBe("from stdin") expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag") diff --git a/packages/cli/test/server-connection.test.ts b/packages/cli/test/server-connection.test.ts new file mode 100644 index 0000000000..41a061f57c --- /dev/null +++ b/packages/cli/test/server-connection.test.ts @@ -0,0 +1,57 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { Global } from "@opencode-ai/core/global" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { expect, test } from "bun:test" +import { Effect, FileSystem, Scope } from "effect" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { ServerConnection } from "../src/services/server-connection" +import { ServiceConfig } from "../src/services/service-config" + +test("resolution groups Effect-native lifecycle operations only for the managed service", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-server-resolution-")) + const id = "server-resolution-test" + const server = Bun.serve({ + port: 0, + fetch() { + return Response.json({ + healthy: true, + version: InstallationVersion, + pid: process.pid, + }) + }, + }) + const registration = path.join(root, "state", ServiceConfig.filename()) + const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") }) + const runPromise = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer), Effect.scoped)) + + try { + await fs.mkdir(path.dirname(registration), { recursive: true }) + await fs.writeFile( + registration, + JSON.stringify({ + id, + version: InstallationVersion, + url: server.url.toString(), + pid: process.pid, + }), + ) + const resolved = await runPromise(ServerConnection.resolve({})) + + expect(resolved.endpoint.url).toBe(server.url.toString()) + expect(resolved.service).toBeDefined() + if (!resolved.service) throw new Error("Expected managed service capabilities") + expect(Effect.isEffect(resolved.service.reconnect())).toBe(true) + expect(Effect.isEffect(resolved.service.restart())).toBe(true) + expect(await runPromise(resolved.service.reconnect())).toEqual(resolved.endpoint) + + const explicit = await runPromise(ServerConnection.resolve({ server: server.url.toString() })) + expect(explicit.endpoint.url).toBe(server.url.toString()) + expect(explicit.service).toBeUndefined() + } finally { + await server.stop(true) + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index a793ce0257..d887e5b2b3 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -1,5 +1,5 @@ import { NodeFileSystem } from "@effect/platform-node" -import { Service } from "@opencode-ai/client/effect" +import { Service } from "@opencode-ai/client/effect/service" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" @@ -35,6 +35,61 @@ test("local channel stores service config with the local service filename", asyn } }) +test("service filenames isolate installation channels", () => { + expect(ServiceConfig.filename("latest")).toBe("service.json") + expect(ServiceConfig.filename("local")).toBe("service-local.json") + expect(ServiceConfig.filename("preview-a")).not.toBe(ServiceConfig.filename("preview-b")) + expect(ServiceConfig.filename("preview-a")).not.toBe(ServiceConfig.filename("latest")) + expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234", "preview-a")).toBe(true) + expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234.2", "preview-a")).toBe(true) + expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-other-1234", "preview-a")).toBe(false) + expect(ServiceConfig.versionBelongsToChannel("1.2.3", "preview-a")).toBe(false) +}) + +test("preview registration migration never moves stable discovery", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-migration-")) + const legacy = path.join(root, "service.json") + const target = path.join(root, ServiceConfig.filename("preview-a")) + try { + await fs.writeFile( + legacy, + JSON.stringify({ id: "old-preview", version: "0.0.0-preview-a-1234", url: "http://localhost:4096", pid: 1 }), + ) + await Effect.runPromise( + ServiceConfig.migrateRegistration(legacy, target, "preview-a", "0.0.0-preview-a-5678").pipe( + Effect.provide(NodeFileSystem.layer), + ), + ) + expect(await Bun.file(legacy).exists()).toBe(true) + expect(await Bun.file(target).json()).toMatchObject({ id: "old-preview" }) + + await fs.rm(target) + await fs.writeFile(legacy, JSON.stringify({ id: "stable", version: "1.2.3", url: "http://localhost:4096", pid: 1 })) + await Effect.runPromise( + ServiceConfig.migrateRegistration(legacy, target, "preview-a", "0.0.0-preview-a-5678").pipe( + Effect.provide(NodeFileSystem.layer), + ), + ) + expect(await Bun.file(legacy).exists()).toBe(true) + expect(await Bun.file(target).exists()).toBe(false) + + await fs.writeFile( + legacy, + JSON.stringify({ id: "old-preview", version: "0.0.0-preview-a-1234", url: "http://localhost:4096", pid: 1 }), + ) + await fs.writeFile(target, JSON.stringify({ id: "current-preview" })) + await Effect.runPromise( + ServiceConfig.migrateRegistration(legacy, target, "preview-a", "0.0.0-preview-a-5678").pipe( + Effect.provide(NodeFileSystem.layer), + ), + ) + expect(await Bun.file(legacy).exists()).toBe(true) + expect(await Bun.file(target).json()).toMatchObject({ id: "current-preview" }) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + test("concurrent service processes elect one server", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-election-")) const database = path.join(root, "opencode.db") @@ -74,18 +129,54 @@ test("concurrent service processes elect one server", async () => { }), ) const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"] - const first = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }) - const second = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }) + const registration = path.join(root, "state", "opencode", "service-local.json") + const processes = Array.from({ length: 10 }, () => Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })) try { - const registration = path.join(root, "state", "opencode", "service-local.json") const info = await waitForInfo(registration) - const winner = info.pid === first.pid ? first : second - const loser = info.pid === first.pid ? second : first - const exited = await Promise.race([loser.exited.then(() => true), Bun.sleep(10_000).then(() => false)]) + const winner = processes.find((process) => process.pid === info.pid) + const losers = processes.filter((process) => process.pid !== info.pid) + const exited = await Promise.all( + losers.map((process) => Promise.race([process.exited.then(() => true), Bun.sleep(10_000).then(() => false)])), + ) - expect(exited).toBe(true) - expect(winner.exitCode).toBe(null) + expect(exited).toEqual(losers.map(() => true)) + expect(winner?.exitCode).toBe(null) + expect( + await fetch(new URL("/api/health", info.url), { + headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) }, + }).then((response) => response.json()), + ).toEqual({ + healthy: true, + version: info.version, + pid: info.pid, + }) + const blockedTemp = registration + "." + info.id + ".tmp" + await fs.mkdir(blockedTemp) + await fs.rm(registration) + await Bun.sleep(6_000) + expect(await Bun.file(registration).exists()).toBe(false) + await fs.rm(blockedTemp, { recursive: true }) + const restored = await waitForInfo(registration) + expect(restored.id).toBe(info.id) + expect(restored.pid).toBe(info.pid) + await fs.writeFile(registration, "not-json") + const repaired = await waitForInfo(registration) + expect(repaired.id).toBe(info.id) + expect(repaired.pid).toBe(info.pid) + + const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }) + try { + const contenderExited = await Promise.race([ + contender.exited.then(() => true), + Bun.sleep(10_000).then(() => false), + ]) + expect(contenderExited).toBe(true) + expect((await waitForInfo(registration)).id).toBe(info.id) + } finally { + contender.kill("SIGTERM") + await contender.exited + } expect( await withDatabase( database, @@ -100,13 +191,57 @@ test("concurrent service processes elect one server", async () => { ), ).toEqual({ timeSuspended: null }) expect(await waitForExecutionStart(database, sessionID)).toBe(1) + await Effect.runPromise( + Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)), + ) + await winner?.exited } finally { - first.kill("SIGTERM") - second.kill("SIGTERM") - await Promise.all([first.exited, second.exited]) + processes.forEach((process) => process.kill("SIGTERM")) + await Promise.all(processes.map((process) => process.exited)) + try { + expect(await Bun.file(registration).exists()).toBe(false) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + } +}, 60_000) + +test("a failed service stays registered and owns the lock until stopped", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-failed-")) + const database = path.join(root, "database") + await fs.mkdir(database) + const env = { + ...process.env, + HOME: root, + OPENCODE_DB: database, + OPENCODE_TEST_HOME: root, + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_STATE_HOME: path.join(root, "state"), + } + const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"] + const registration = path.join(root, "state", "opencode", "service-local.json") + const owner = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }) + + try { + const info = await waitForInfo(registration) + expect(owner.exitCode).toBe(null) + + const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }) + expect(await Promise.race([contender.exited.then(() => true), Bun.sleep(10_000).then(() => false)])).toBe(true) + expect((await waitForInfo(registration)).id).toBe(info.id) + expect(owner.exitCode).toBe(null) + + await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer))) + await owner.exited + expect(await Bun.file(registration).exists()).toBe(false) + } finally { + owner.kill("SIGTERM") + await owner.exited await fs.rm(root, { recursive: true, force: true }) } -}) +}, 30_000) function withDatabase(file: string, effect: Effect.Effect) { return Effect.runPromise(effect.pipe(Effect.provide(Database.layerFromPath(file)), Effect.scoped)) @@ -143,7 +278,7 @@ function waitForExecutionStart(file: string, sessionID: SessionV2.ID) { } async function waitForInfo(file: string) { - for (let attempt = 0; attempt < 200; attempt++) { + for (let attempt = 0; attempt < 400; attempt++) { const value = await Bun.file(file) .json() .catch(() => undefined) diff --git a/packages/client/package.json b/packages/client/package.json index 531e28caaa..d7b4c9b24e 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -19,8 +19,10 @@ ".": "./src/promise/index.ts", "./promise": "./src/promise/index.ts", "./promise/api": "./src/promise/api.ts", + "./service": "./src/promise/service.ts", "./effect": "./src/effect/index.ts", - "./effect/api": "./src/effect/api.ts" + "./effect/api": "./src/effect/api.ts", + "./effect/service": "./src/effect/service.ts" }, "scripts": { "build": "bun run script/build-package.ts", diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 28c06cbf04..1da8209607 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -10,8 +10,14 @@ type StreamValue = A extends Stream.Stream ? Success export type Endpoint0_0Output = EffectValue> export type HealthGetOperation = () => Effect.Effect +type Endpoint0_1Request = Parameters[0] +export type Endpoint0_1Input = { readonly instanceID: Endpoint0_1Request["payload"]["instanceID"] } +export type Endpoint0_1Output = EffectValue> +export type HealthStopOperation = (input: Endpoint0_1Input) => Effect.Effect + export interface HealthApi { readonly get: HealthGetOperation + readonly stop: HealthStopOperation } export type Endpoint1_0Output = EffectValue> @@ -121,8 +127,8 @@ export type SessionRenameOperation = (input: Endpoint5_8Input) => Eff type Endpoint5_9Request = Parameters[0] export type Endpoint5_9Input = { readonly sessionID: Endpoint5_9Request["params"]["sessionID"] - readonly destination: Endpoint5_9Request["payload"]["destination"] - readonly moveChanges?: Endpoint5_9Request["payload"]["moveChanges"] + readonly directory: Endpoint5_9Request["payload"]["directory"] + readonly workspaceID?: Endpoint5_9Request["payload"]["workspaceID"] } export type Endpoint5_9Output = EffectValue> export type SessionMoveOperation = (input: Endpoint5_9Input) => Effect.Effect diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 1f58717113..ed2918eb9d 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -16,7 +16,12 @@ const mapClientError = (error: E) => const Endpoint0_0 = (raw: RawClient["server.health"]) => () => raw["health.get"]({}).pipe(Effect.mapError(mapClientError)) -const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) }) +type Endpoint0_1Request = Parameters[0] +type Endpoint0_1Input = { readonly instanceID: Endpoint0_1Request["payload"]["instanceID"] } +const Endpoint0_1 = (raw: RawClient["server.health"]) => (input: Endpoint0_1Input) => + raw["health.stop"]({ payload: { instanceID: input["instanceID"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw), stop: Endpoint0_1(raw) }) const Endpoint1_0 = (raw: RawClient["server.server"]) => () => raw["server.get"]({}).pipe(Effect.mapError(mapClientError)) @@ -149,13 +154,13 @@ const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Inp type Endpoint5_9Request = Parameters[0] type Endpoint5_9Input = { readonly sessionID: Endpoint5_9Request["params"]["sessionID"] - readonly destination: Endpoint5_9Request["payload"]["destination"] - readonly moveChanges?: Endpoint5_9Request["payload"]["moveChanges"] + readonly directory: Endpoint5_9Request["payload"]["directory"] + readonly workspaceID?: Endpoint5_9Request["payload"]["workspaceID"] } const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) => raw["session.move"]({ params: { sessionID: input["sessionID"] }, - payload: { destination: input["destination"], moveChanges: input["moveChanges"] }, + payload: { directory: input["directory"], workspaceID: input["workspaceID"] }, }).pipe(Effect.mapError(mapClientError)) type Endpoint5_10Request = Parameters[0] diff --git a/packages/client/src/effect/index.ts b/packages/client/src/effect/index.ts index 67c2c42142..27424aad82 100644 --- a/packages/client/src/effect/index.ts +++ b/packages/client/src/effect/index.ts @@ -17,7 +17,6 @@ export type { SessionApi, SkillApi, } from "./api.js" -export { Service } from "./service.js" export { Agent } from "@opencode-ai/schema/agent" export { Command } from "@opencode-ai/schema/command" export { Credential } from "@opencode-ai/schema/credential" diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index 3c42f0de56..e4bf5bbefb 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -1,7 +1,18 @@ +import { ServiceStatus } from "@opencode-ai/protocol/groups/health" import { Effect, FileSystem, Option, Schedule, Schema } from "effect" -import { spawn } from "node:child_process" +import { spawn, type ChildProcess } from "node:child_process" import { homedir } from "node:os" import { join } from "node:path" +import type { + DiscoverOptions, + Endpoint, + EnsureOptions, + StopOptions, +} from "../service.js" + +export * from "../service.js" +/** Contents of the local service registration file. */ +export type Info = import("../service.js").Info // Find, start, and stop the local opencode background service. // @@ -11,94 +22,111 @@ import { join } from "node:path" // is all a client needs to connect. The daemon's own configuration (port, // persisted password) is CLI-owned and never read here. -export type Endpoint = { - readonly url: string - readonly auth?: { - readonly type: "basic" - readonly username: string - readonly password: string - } -} - -export type Options = { - // Absolute path to the service registration file. Defaults to - // opencode/service.json in the XDG state directory. - readonly file?: string - // When set, discovery only returns a server reporting this exact version, - // and start() replaces a healthy server whose version differs. - readonly version?: string - // Argv used to spawn the service. Defaults to ["opencode", "serve", - // "--service"] resolved from PATH. - readonly command?: ReadonlyArray -} - -export type StartReason = "missing" | "version-mismatch" - -export type StartOptions = Options & { - // Called once when start() decides it must spawn: either no service was - // found, or a healthy service with a different version is being replaced. - // `existing` carries the registration of the service being replaced. - readonly onStart?: (reason: StartReason, existing?: Info) => void +type Contender = { + readonly child: ChildProcess + readonly error: () => Error | undefined } // Read-only lookup: registration file plus health check and version gate. -// Never spawns; escalation to start() is the caller's policy. -export const discover = Effect.fn("service.discover")(function* (options: Options = {}) { +// Never spawns; escalation to ensure() is the caller's policy. +/** Discover a healthy, compatible local service without starting one. */ +export const discover = Effect.fn("service.discover")(function* (options: DiscoverOptions = {}) { return (yield* discoverLocal(options))?.endpoint }) -const discoverLocal = Effect.fnUntraced(function* (options: Options) { - const info = yield* read(options.file) - if (info === undefined) return undefined - if (options.version !== undefined && info.version !== options.version) return undefined - return yield* probe(info, options.version) +const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) { + const found = (yield* registered(options.file)).service + if (found?.state !== "ready") return undefined + if (options.version !== undefined && found.version !== options.version) return undefined + return found }) // Idempotent ensure-running: reuses a healthy compatible server, replaces a -// version-mismatched one, and otherwise spawns the service command detached. -export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) { - const compatible = yield* discover(options) - if (compatible !== undefined) return compatible - const existing = yield* find(options) - if (existing?.version !== undefined && (options.version === undefined || existing.version === options.version)) - return existing.endpoint - yield* Effect.sync(() => options.onStart?.(existing === undefined ? "missing" : "version-mismatch", existing?.info)) - if (existing !== undefined) yield* kill(existing.info, options).pipe(Effect.ignore) - - const [command, ...args] = options.command ?? ["opencode", "serve", "--service"] - if (command === undefined) return yield* Effect.fail(new Error("Missing service command")) - const child = yield* Effect.try({ - try: () => { - const child = spawn(command, args, { detached: true, stdio: "ignore" }) - child.unref() - return child - }, - catch: (cause) => new Error("Failed to start server", { cause }), +// version-mismatched one, and otherwise spawns small contenders until a server +// becomes discoverable. A contender is never killed merely for slow startup. +/** Ensure a healthy, compatible local service is running. */ +export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) { + const contenders = new Set() + let announced = false + let lastSpawn = 0 + let spawnDelay = 5_000 + let ownerHeld = false + const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => + Effect.sync(() => { + if (announced) return + announced = true + options.onStart?.(reason, previousVersion) + }) + const spawnContender = Effect.gen(function* () { + const [command, ...args] = options.command ?? ["opencode", "serve", "--service"] + if (command === undefined) return yield* Effect.fail(new Error("Missing service command")) + return yield* Effect.try({ + try: () => { + const child = spawn(command, args, { detached: true, stdio: "ignore" }) + let error: Error | undefined + child.once("error", (cause) => { + error = new Error("Failed to start server", { cause }) + }) + child.unref() + return { child, error: () => error } + }, + catch: (cause) => new Error("Failed to start server", { cause }), + }) }) + const found = yield* Effect.gen(function* () { + const registration = yield* registered(options.file, true) + const info = registration.info + const service = registration.service + if (service !== undefined) { + ownerHeld = false + spawnDelay = 5_000 + const compatible = !service.legacy && (options.version === undefined || service.version === options.version) + if (compatible && service.state === "ready") return Option.some(service) + if (compatible && service.state === "failed") return yield* Effect.fail(new Error("Background service failed to start")) + if (compatible) return Option.none() + yield* announce("version-mismatch", service.version) + yield* kill(service, options).pipe(Effect.ignore) + lastSpawn = 0 + return Option.none() + } else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now() - return yield* discoverLocal(options).pipe( - Effect.flatMap((found) => - found === undefined ? Effect.fail(new Error("Server is not ready")) : Effect.succeed(found), - ), - Effect.retry(poll), - Effect.tap((found) => - found.info.pid === child.pid - ? Effect.void - : Effect.sync(() => { - child.kill("SIGTERM") - }), - ), - Effect.map((found) => found.endpoint), - Effect.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)), - Effect.mapError(() => new Error("Failed to start server")), - ) + const failure = [...contenders].map(contenderFailure).find((error): error is Error => error !== undefined) + if (failure !== undefined) return yield* Effect.fail(failure) + const finished = [...contenders].filter(contenderFinished) + if (finished.some((item) => item.child.exitCode === 0)) { + ownerHeld = true + spawnDelay = Math.min(spawnDelay * 2, 30_000) + } + finished.forEach((item) => contenders.delete(item)) + // Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery. + if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) { + yield* announce("missing") + contenders.add(yield* spawnContender) + lastSpawn = Date.now() + } + return Option.none() + }).pipe(Effect.repeat({ until: Option.isSome, schedule: Schedule.spaced("1 second") })) + return Option.getOrThrow(found).endpoint }) -export const stop = Effect.fn("service.stop")(function* (options: Options = {}) { - const fs = yield* FileSystem.FileSystem +function contenderFailure(contender: Contender) { + const error = contender.error() + if (error !== undefined) return error + if (contender.child.exitCode !== null && contender.child.exitCode !== 0) + return new Error(`Server process exited with code ${contender.child.exitCode}`) + if (contender.child.signalCode !== null) + return new Error(`Server process terminated by ${contender.child.signalCode}`) + return undefined +} + +function contenderFinished(contender: Contender) { + return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null +} + +/** Stop the registered local service. */ +export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) { const existing = yield* find(options) - if (existing !== undefined) yield* kill(existing.info, options) - yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore) + if (existing !== undefined) yield* kill(existing, options) }) function fallback() { @@ -106,11 +134,13 @@ function fallback() { return join(state, "opencode", "service.json") } -export function headers(endpoint: Endpoint): RequestInit["headers"] { +/** Create HTTP authentication headers for a service endpoint. */ +export function headers(endpoint: Endpoint) { if (endpoint.auth === undefined) return undefined return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) } } +/** Schema for the local service registration file. */ export const Info = Schema.Struct({ id: Schema.optional(Schema.String), version: Schema.optional(Schema.String), @@ -118,12 +148,9 @@ export const Info = Schema.Struct({ pid: Schema.Int.check(Schema.isGreaterThan(0)), password: Schema.optional(Schema.String), }) -export type Info = typeof Info.Type const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) -const decodeHealth = Schema.decodeUnknownOption( - Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String, pid: Schema.Int }), -) +const decodeHealth = Schema.decodeUnknownOption(ServiceStatus.Health) const decodeLegacyHealth = Schema.decodeUnknownOption(Schema.Struct({ healthy: Schema.Literal(true) })) // A missing or corrupt file means no valid info; callers treat both @@ -139,9 +166,11 @@ type LocalService = { readonly info: Info readonly endpoint: Endpoint readonly version?: string + readonly state: "ready" | "waiting" | "failed" + readonly legacy: boolean } -const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) { +const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) { const endpoint = { url: info.url, auth: @@ -155,14 +184,19 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLe signal: AbortSignal.timeout(2_000), }), ).pipe(Effect.option, Effect.map(Option.getOrUndefined)) - if (response === undefined || !response.ok) return undefined + if (response === undefined) return undefined const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined)) const health = decodeHealth(body) if (Option.isSome(health)) { if (health.value.pid !== info.pid) return undefined if (info.version !== undefined && health.value.version !== info.version) return undefined - if (version !== undefined && health.value.version !== version) return undefined - return { info, endpoint, version: health.value.version } satisfies LocalService + return { + info, + endpoint, + version: health.value.version, + state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting", + legacy: false, + } satisfies LocalService } if ( !allowLegacy || @@ -170,18 +204,23 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLe (typeof body === "object" && body !== null && ("version" in body || "pid" in body)) ) return undefined - return { info, endpoint } satisfies LocalService + return { info, endpoint, state: "ready", legacy: true } satisfies LocalService +}) + +const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false) { + const info = yield* read(file) + if (info === undefined) return { info: undefined, service: undefined } + return { info, service: yield* probe(info, allowLegacy) } }) // Health-checked lookup without the version gate: lifecycle operations must be // able to see (and replace or stop) a server from a different version. -const find = Effect.fnUntraced(function* (options: Options) { - const info = yield* read(options.file) - if (info === undefined) return undefined - return yield* probe(info, undefined, true) +const find = Effect.fnUntraced(function* (options: { readonly file?: string }) { + return (yield* registered(options.file, true)).service }) -// 50ms cadence bounded at ~5s, shared by stop escalation and start readiness. +// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure +// discovery window. const poll = Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100))) const signal = (pid: number, name: NodeJS.Signals) => @@ -199,20 +238,46 @@ function same(left: Info, right: Info) { return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid } -const kill = Effect.fnUntraced(function* (info: Info, options: Options) { - // A stale registration may point at a PID that has since been reused by - // another process. Only signal the PID after authenticating the server. - const current = yield* find(options) - if (current === undefined || !same(current.info, info)) return - - yield* signal(info.pid, "SIGTERM") - const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option) +const kill = Effect.fnUntraced(function* ( + service: LocalService, + options: { readonly file?: string }, +) { + const requested = yield* requestStop(service) + if (requested === "rejected") return + if (requested === "unsupported") { + // A stale registration may point at a reused PID. Authenticate again + // immediately before the legacy signal fallback. + const current = yield* find(options) + if (current === undefined || !same(current.info, service.info)) return + yield* signal(service.info.pid, "SIGTERM") + } + const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll), Effect.option) if (Option.isSome(done)) return const latest = yield* find(options) - if (latest === undefined || !same(latest.info, info)) return - yield* signal(info.pid, "SIGKILL") - yield* stopped(info.pid).pipe(Effect.retry(poll)) + if (latest === undefined || !same(latest.info, service.info)) return + yield* signal(service.info.pid, "SIGKILL") + yield* stopped(service.info.pid).pipe(Effect.retry(poll)) }) -export * as Service from "./service.js" +const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse) + +const requestStop = Effect.fnUntraced(function* (service: LocalService) { + if (service.info.id === undefined || service.legacy) return "unsupported" as const + const response = yield* Effect.tryPromise(() => + fetch(new URL("/api/service/stop", service.info.url), { + method: "POST", + headers: { ...headers(service.endpoint), "content-type": "application/json" }, + body: JSON.stringify({ instanceID: service.info.id }), + signal: AbortSignal.timeout(2_000), + }), + ).pipe(Effect.option, Effect.map(Option.getOrUndefined)) + if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const + const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined)) + const decoded = decodeStopResponse(body) + if (!response.ok || Option.isNone(decoded) || !decoded.value.accepted) return "rejected" as const + return "accepted" as const +}) + +/** Effect-based local service lifecycle operations. */ +export const Service = { discover, ensure, stop, headers, Info } diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index bed325d976..b6f4d44b67 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -1,5 +1,7 @@ import type { HealthGetOutput, + HealthStopInput, + HealthStopOutput, ServerGetOutput, LocationGetInput, LocationGetOutput, @@ -332,6 +334,18 @@ export function make(options: ClientOptions) { { method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, requestOptions, ), + stop: (input: HealthStopInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/service/stop`, + body: { instanceID: input["instanceID"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), }, server: { get: (requestOptions?: RequestOptions) => @@ -508,7 +522,7 @@ export function make(options: ClientOptions) { { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/move`, - body: { destination: input["destination"], moveChanges: input["moveChanges"] }, + body: { directory: input["directory"], workspaceID: input["workspaceID"] }, successStatus: 204, declaredStatuses: [404, 400, 401], empty: true, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 255beaa1bb..e2ed9a9f9c 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1,5 +1,9 @@ export type JsonValue = null | boolean | number | string | Array | { [key: string]: JsonValue } +export type ServiceHealth = { healthy: true; version: string; pid: number } + +export type ServiceStopResponse = { accepted: boolean } + export type ModelRef = { id: string; providerID: string; variant?: string } export type ProviderSettings = { [x: string]: JsonValue } @@ -551,7 +555,7 @@ export type SessionMoved = { type: "session.moved" durable: { aggregateID: string; seq: number; version: 1 } location?: LocationRef - data: { sessionID: string; location: LocationRef; subpath?: string } + data: { sessionID: string; location: LocationRef; projectID?: string; subpath?: string } } export type SessionRenamed = { @@ -2483,7 +2487,11 @@ export type ProjectCopyError = { export const isProjectCopyError = (value: unknown): value is ProjectCopyError => typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError" -export type HealthGetOutput = { healthy: true; version: string; pid: number } +export type HealthGetOutput = ServiceHealth + +export type HealthStopInput = { readonly instanceID: { readonly instanceID: string }["instanceID"] } + +export type HealthStopOutput = ServiceStopResponse export type ServerGetOutput = { urls: Array } @@ -2692,14 +2700,8 @@ export type SessionRenameOutput = void export type SessionMoveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] - readonly destination: { - readonly destination: { readonly directory: string } - readonly moveChanges?: boolean | undefined - }["destination"] - readonly moveChanges?: { - readonly destination: { readonly directory: string } - readonly moveChanges?: boolean | undefined - }["moveChanges"] + readonly directory: { readonly directory: string; readonly workspaceID?: string }["directory"] + readonly workspaceID?: { readonly directory: string; readonly workspaceID?: string }["workspaceID"] } export type SessionMoveOutput = void diff --git a/packages/client/src/promise/service.ts b/packages/client/src/promise/service.ts new file mode 100644 index 0000000000..aecaf95f4f --- /dev/null +++ b/packages/client/src/promise/service.ts @@ -0,0 +1,252 @@ +import { readFile } from "node:fs/promises" +import { spawn, type ChildProcess } from "node:child_process" +import { homedir } from "node:os" +import { join } from "node:path" +import type { + DiscoverOptions, + Endpoint, + Info, + EnsureOptions, + StopOptions, +} from "../service.js" +import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js" + +export * from "../service.js" + +// Find, start, and stop the local opencode background service. +// +// The registration file is the complete discovery contract. This module is +// intentionally implemented with Node APIs so Promise clients do not need +// Effect or @effect/platform-node at runtime. + +type Contender = { + readonly child: ChildProcess + readonly error: () => Error | undefined +} + +/** Discover a healthy, compatible local service without starting one. */ +export async function discover(options: DiscoverOptions = {}) { + return (await discoverLocal(options))?.endpoint +} + +async function discoverLocal(options: DiscoverOptions) { + const found = (await registered(options.file)).service + if (found?.state !== "ready") return undefined + if (options.version !== undefined && found.version !== options.version) return undefined + return found +} + +/** Ensure a healthy, compatible local service is running. */ +export async function ensure(options: EnsureOptions = {}): Promise { + const contenders = new Set() + let announced = false + let lastSpawn = 0 + let spawnDelay = 5_000 + let ownerHeld = false + + const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => { + if (announced) return + announced = true + options.onStart?.(reason, previousVersion) + } + const spawnContender = () => { + const [command, ...args] = options.command ?? ["opencode", "serve", "--service"] + if (command === undefined) throw new Error("Missing service command") + try { + const child = spawn(command, args, { detached: true, stdio: "ignore" }) + let error: Error | undefined + child.once("error", (cause) => { + error = new Error("Failed to start server", { cause }) + }) + child.unref() + return { child, error: () => error } + } catch (cause) { + throw new Error("Failed to start server", { cause }) + } + } + + while (true) { + const registration = await registered(options.file, true) + + if (registration.service !== undefined) { + ownerHeld = false + spawnDelay = 5_000 + const service = registration.service + const compatible = !service.legacy && (options.version === undefined || service.version === options.version) + if (compatible && service.state === "ready") return service.endpoint + if (compatible && service.state === "failed") throw new Error("Background service failed to start") + if (!compatible) { + announce("version-mismatch", service.version) + await kill(service, options).catch(() => undefined) + lastSpawn = 0 + } + } else { + if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now() + const failure = [...contenders].map(contenderFailure).find((error) => error !== undefined) + if (failure !== undefined) throw failure + const finished = [...contenders].filter(contenderFinished) + if (finished.some((item) => item.child.exitCode === 0)) { + ownerHeld = true + spawnDelay = Math.min(spawnDelay * 2, 30_000) + } + finished.forEach((item) => contenders.delete(item)) + // Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery. + if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) { + announce("missing") + contenders.add(spawnContender()) + lastSpawn = Date.now() + } + } + await delay(1_000) + } +} + +function contenderFailure(contender: Contender) { + const error = contender.error() + if (error !== undefined) return error + if (contender.child.exitCode !== null && contender.child.exitCode !== 0) + return new Error(`Server process exited with code ${contender.child.exitCode}`) + if (contender.child.signalCode !== null) + return new Error(`Server process terminated by ${contender.child.signalCode}`) + return undefined +} + +function contenderFinished(contender: Contender) { + return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null +} + +/** Stop the registered local service. */ +export async function stop(options: StopOptions = {}) { + const existing = await find(options) + if (existing !== undefined) await kill(existing, options) +} + +function fallback() { + return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json") +} + +/** Create HTTP authentication headers for a service endpoint. */ +export function headers(endpoint: Endpoint) { + if (endpoint.auth === undefined) return undefined + return { authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64") } +} + +async function read(file?: string) { + const text = await readFile(file ?? fallback(), "utf8").catch(() => undefined) + if (text === undefined) return undefined + try { + return JSON.parse(text) as Info + } catch { + return undefined + } +} + +type LocalService = { + readonly info: Info + readonly endpoint: Endpoint + readonly version?: string + readonly state: "ready" | "waiting" | "failed" + readonly legacy: boolean +} + +async function probe(info: Info, allowLegacy = false): Promise { + const endpoint = { + url: info.url, + auth: + info.password === undefined + ? undefined + : { type: "basic" as const, username: "opencode", password: info.password }, + } satisfies Endpoint + const response = await fetch(new URL("/api/health", info.url), { + headers: headers(endpoint), + signal: AbortSignal.timeout(2_000), + }).catch(() => undefined) + const body = (await response?.json().catch(() => undefined)) as ServiceHealth | { readonly healthy: true } | undefined + if (body !== undefined && "version" in body && "pid" in body) { + if (body.pid !== info.pid) return undefined + if (info.version !== undefined && body.version !== info.version) return undefined + return { + info, + endpoint, + version: body.version, + state: response?.ok ? "ready" : response?.status === 500 ? "failed" : "waiting", + legacy: false, + } + } + if (!allowLegacy || body?.healthy !== true) return undefined + return { info, endpoint, state: "ready", legacy: true } +} + +async function registered(file?: string, allowLegacy = false) { + const info = await read(file) + if (info === undefined) return { info: undefined, service: undefined } + return { info, service: await probe(info, allowLegacy) } +} + +async function find(options: { readonly file?: string }) { + return (await registered(options.file, true)).service +} + +function signal(pid: number, name: NodeJS.Signals) { + try { + process.kill(pid, name) + } catch {} +} + +function stopped(pid: number) { + try { + process.kill(pid, 0) + return false + } catch { + return true + } +} + +async function waitUntilStopped(pid: number) { + for (let attempt = 0; attempt <= 100; attempt++) { + if (stopped(pid)) return true + if (attempt < 100) await delay(50) + } + return false +} + +function same(left: Info, right: Info) { + return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid +} + +async function kill(service: LocalService, options: { readonly file?: string }) { + const requested = await requestStop(service) + if (requested === "rejected") return + if (requested === "unsupported") { + const current = await find(options) + if (current === undefined || !same(current.info, service.info)) return + signal(service.info.pid, "SIGTERM") + } + if (await waitUntilStopped(service.info.pid)) return + + const latest = await find(options) + if (latest === undefined || !same(latest.info, service.info)) return + signal(service.info.pid, "SIGKILL") + if (!(await waitUntilStopped(service.info.pid))) throw new Error(`Server process ${service.info.pid} is still running`) +} + +async function requestStop(service: LocalService) { + if (service.info.id === undefined || service.legacy) return "unsupported" as const + const response = await fetch(new URL("/api/service/stop", service.info.url), { + method: "POST", + headers: { ...headers(service.endpoint), "content-type": "application/json" }, + body: JSON.stringify({ instanceID: service.info.id }), + signal: AbortSignal.timeout(2_000), + }).catch(() => undefined) + if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const + const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined + if (!response.ok || body?.accepted !== true) return "rejected" as const + return "accepted" as const +} + +function delay(milliseconds: number) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +/** Promise-based local service lifecycle operations. */ +export const Service = { discover, ensure, stop, headers } diff --git a/packages/client/src/service.ts b/packages/client/src/service.ts new file mode 100644 index 0000000000..96d54871f6 --- /dev/null +++ b/packages/client/src/service.ts @@ -0,0 +1,53 @@ +/** Connection details for a local OpenCode service. */ +export type Endpoint = { + /** Base URL of the service. */ + readonly url: string + /** Authentication required by the service, when configured. */ + readonly auth?: { + /** HTTP authentication scheme. */ + readonly type: "basic" + /** Basic authentication username. */ + readonly username: string + /** Basic authentication password. */ + readonly password: string + } +} + +/** Options used to discover the local OpenCode service. */ +export type DiscoverOptions = { + /** Absolute registration file path. Defaults to the XDG state directory. */ + readonly file?: string + /** Required service version. */ + readonly version?: string +} + +/** Reason ensuring the service requires a new process. */ +export type EnsureReason = "missing" | "version-mismatch" + +/** Options used to ensure the local OpenCode service is running. */ +export type EnsureOptions = DiscoverOptions & { + /** Service command and arguments. Defaults to `opencode serve --service`. */ + readonly command?: ReadonlyArray + /** Called once before spawning a new service process. */ + readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void +} + +/** Options used to stop the local OpenCode service. */ +export type StopOptions = { + /** Absolute registration file path. Defaults to the XDG state directory. */ + readonly file?: string +} + +/** Contents of the local service registration file. */ +export type Info = { + /** Unique service instance identifier. */ + readonly id?: string + /** OpenCode version served by the process. */ + readonly version?: string + /** Base URL advertised by the service. */ + readonly url: string + /** Operating system process identifier. */ + readonly pid: number + /** Private service password, when authentication is enabled. */ + readonly password?: string +} diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index c1254ab7d3..b52d86d382 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -15,6 +15,18 @@ import { const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) } +test("health.get decodes the readiness response", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ healthy: true, version: "old", pid: 123 }))), + ) + const result = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client.health.get() + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(result).toEqual({ healthy: true, version: "old", pid: 123 }) +}) + test("session.get returns the decoded Effect projection", async () => { const httpClient = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))), diff --git a/packages/client/test/fixture/service.ts b/packages/client/test/fixture/service.ts index 3f77edb419..40ef5fb5da 100644 --- a/packages/client/test/fixture/service.ts +++ b/packages/client/test/fixture/service.ts @@ -1,13 +1,45 @@ -import { rename, writeFile } from "node:fs/promises" +import { appendFile, rename, writeFile } from "node:fs/promises" -const [registration, mode] = process.argv.slice(2) +const [registration, mode, delay] = process.argv.slice(2) if (registration === undefined || mode === undefined) throw new Error("Missing service fixture arguments") +if (mode === "failed") process.exit(1) +if (mode === "record-start") { + await writeFile(registration + ".started", "") + process.exit(1) +} +if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL") + +if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated") { + await appendFile(registration + ".starts", process.pid + "\n") + const owner = await writeFile(registration + ".owner", String(process.pid), { flag: "wx" }) + .then(() => true) + .catch(() => false) + if (!owner) process.exit() + if (mode === "coordinated") { + while ((await Bun.file(registration + ".starts").text()).trim().split("\n").length < 2) await Bun.sleep(10) + } else await Bun.sleep(Number(delay)) + if (mode === "delayed-failed") process.exit(1) +} let requests = 0 +const version = mode === "old" || mode === "reject-stop" ? "old" : "test" +const id = crypto.randomUUID() const server = Bun.serve({ port: 0, async fetch(request) { - if (new URL(request.url).pathname !== "/api/health") return new Response(null, { status: 404 }) + const pathname = new URL(request.url).pathname + if (pathname === "/api/service/stop" && mode === "reject-stop") { + await writeFile(registration + ".stop-attempt", "") + return Response.json({ accepted: false }) + } + if (pathname === "/api/service/stop" && mode === "graceful") { + const body = await request.json() + if (typeof body !== "object" || body === null || body.instanceID !== id) return Response.json({ accepted: false }) + await writeFile(registration + ".stop", JSON.stringify(body)) + setTimeout(shutdown, 25) + return Response.json({ accepted: true }) + } + if (pathname !== "/api/health") return new Response(null, { status: 404 }) requests += 1 if (mode === "modern" && requests === 1) { await writeFile(registration + ".first-request", "") @@ -15,15 +47,21 @@ const server = Bun.serve({ return new Response(null, { status: 503 }) } if (mode === "legacy") return Response.json({ healthy: true }) - return Response.json({ healthy: true, version: "test", pid: process.pid }) + if (mode === "starting" && !(await Bun.file(registration + ".release").exists())) + return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 }) + if (mode === "failed-owner") + return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 }) + if (mode === "starting" || mode === "graceful" || mode === "reject-stop") + return Response.json({ healthy: true, version, pid: process.pid }) + return Response.json({ healthy: true, version, pid: process.pid }) }, }) await writeFile( registration + ".tmp", JSON.stringify({ - id: crypto.randomUUID(), - version: mode === "legacy" ? undefined : "test", + id, + version: mode === "legacy" ? undefined : version, url: server.url.toString(), pid: process.pid, }), @@ -31,7 +69,7 @@ await writeFile( ) await rename(registration + ".tmp", registration) -const shutdown = () => { +function shutdown() { server.stop(true) process.exit() } diff --git a/packages/client/test/import-boundaries.test.ts b/packages/client/test/import-boundaries.test.ts index 5b881edc32..6a979b00a7 100644 --- a/packages/client/test/import-boundaries.test.ts +++ b/packages/client/test/import-boundaries.test.ts @@ -20,15 +20,28 @@ describe("public import boundaries", () => { expect(within(root, core)).toEqual([]) expect(within(root, server)).toEqual([]) - // The effect entry includes local service lifecycle (node spawn/fs), so it - // bundles for bun; the boundary assertions below are what matter. - const network = await bundleInputs("@opencode-ai/client/effect", "bun") + const network = await bundleInputs("@opencode-ai/client/effect", "browser") expect(within(network, effect).length).toBeGreaterThan(0) expect(within(network, schema).length).toBeGreaterThan(0) expect(within(network, protocol).length).toBeGreaterThan(0) expect(within(network, core)).toEqual([]) expect(within(network, server)).toEqual([]) + + const promiseService = await bundleInputs("@opencode-ai/client/service", "bun") + + expect(within(promiseService, effect)).toEqual([]) + expect(within(promiseService, schema)).toEqual([]) + expect(within(promiseService, protocol)).toEqual([]) + expect(within(promiseService, core)).toEqual([]) + expect(within(promiseService, server)).toEqual([]) + + const effectService = await bundleInputs("@opencode-ai/client/effect/service", "bun") + + expect(within(effectService, effect).length).toBeGreaterThan(0) + expect(within(effectService, protocol).length).toBeGreaterThan(0) + expect(within(effectService, core)).toEqual([]) + expect(within(effectService, server)).toEqual([]) }) }) diff --git a/packages/client/test/promise-service.test.ts b/packages/client/test/promise-service.test.ts new file mode 100644 index 0000000000..1cf6757a78 --- /dev/null +++ b/packages/client/test/promise-service.test.ts @@ -0,0 +1,96 @@ +import { afterEach, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { Service, type EnsureReason } from "../src/promise/service" + +const fixture = join(import.meta.dir, "fixture/service.ts") +const processes: Bun.Subprocess[] = [] +const directories: string[] = [] + +afterEach(async () => { + processes.forEach((process) => process.kill("SIGTERM")) + await Promise.all(processes.splice(0).map((process) => process.exited)) + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +test("discovers a registered service", async () => { + const registration = await setup("graceful") + + expect(await Service.discover({ file: registration, version: "test" })).toEqual( + expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }), + ) + expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined() +}) + +test("ensures a missing service with native promises", async () => { + const directory = await temp() + const registration = join(directory, "service.json") + const starts: EnsureReason[] = [] + + const endpoint = await Service.ensure({ + file: registration, + version: "test", + command: [process.execPath, fixture, registration, "coordinated"], + onStart: (reason) => starts.push(reason), + }) + const info = await Bun.file(registration).json() + try { + expect(endpoint.url).toBe(info.url) + expect(starts).toEqual(["missing"]) + } finally { + process.kill(info.pid, "SIGTERM") + await waitForExit(info.pid) + } +}, 15_000) + +test("reports a failed registered service", async () => { + const registration = await setup("failed-owner") + + await expect(Service.ensure({ file: registration, version: "test", command: [] })).rejects.toThrow( + "Background service failed to start", + ) +}) + +test("requests graceful stop of the exact service instance", async () => { + const registration = await setup("graceful") + const info = await Bun.file(registration).json() + + await Service.stop({ file: registration }) + + expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id }) +}) + +async function setup(mode: string) { + const directory = await temp() + const registration = join(directory, "service.json") + processes.push(Bun.spawn([process.execPath, fixture, registration, mode], { stdout: "ignore", stderr: "inherit" })) + await waitForFile(registration) + return registration +} + +async function temp() { + const directory = await mkdtemp(join(tmpdir(), "opencode-promise-service-")) + directories.push(directory) + return directory +} + +async function waitForFile(file: string) { + for (let attempt = 0; attempt < 600; attempt++) { + if (await Bun.file(file).exists()) return + await Bun.sleep(5) + } + throw new Error(`Timed out waiting for ${file}`) +} + +async function waitForExit(pid: number) { + for (let attempt = 0; attempt < 600; attempt++) { + try { + process.kill(pid, 0) + } catch { + return + } + await Bun.sleep(5) + } + throw new Error(`Timed out waiting for process ${pid}`) +} diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index ba822f28e5..502bcae678 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -16,7 +16,7 @@ test("exposes every standard HTTP API group", () => { "generate", "provider", "integration", - "server.mcp", + "mcp", "credential", "project", "form", @@ -61,6 +61,22 @@ test("server.get uses the public HTTP contract", async () => { expect(request?.url).toBe("http://localhost:3000/api/server") }) +test("health.stop sends exact replacement identity", async () => { + let request: Request | undefined + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input, init) => { + request = input instanceof Request ? input : new Request(input, init) + return Response.json({ accepted: true }) + }, + }) + + expect(await client.health.stop({ instanceID: "instance" })).toEqual({ accepted: true }) + expect(request?.method).toBe("POST") + expect(request?.url).toBe("http://localhost:3000/api/service/stop") + expect(await request?.json()).toEqual({ instanceID: "instance" }) +}) + test("MCP resource catalog uses the public HTTP contract", async () => { let request: Request | undefined const client = OpenCode.make({ @@ -77,7 +93,7 @@ test("MCP resource catalog uses the public HTTP contract", async () => { }, }) - const result = await client["server.mcp"].resource.catalog({ location: { directory: "/tmp/project" } }) + const result = await client.mcp.resource.catalog({ location: { directory: "/tmp/project" } }) expect(result.data.resources[0]?.uri).toBe("docs://readme") expect(request?.method).toBe("GET") diff --git a/packages/client/test/service.test.ts b/packages/client/test/service.test.ts index 21428c9166..8e73536bca 100644 --- a/packages/client/test/service.test.ts +++ b/packages/client/test/service.test.ts @@ -4,7 +4,7 @@ import { Effect } from "effect" import { mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" -import { Service } from "../src/effect/index" +import { Service, type EnsureReason } from "../src/effect/service" const fixture = join(import.meta.dir, "fixture/service.ts") const processes: Bun.Subprocess[] = [] @@ -23,9 +23,9 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as await waitForFile(registration) const original = await Bun.file(registration).json() - const starts: Service.StartReason[] = [] + const starts: EnsureReason[] = [] const first = run( - Service.start({ + Service.ensure({ file: registration, version: "test", command: [], @@ -34,7 +34,7 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as ) await waitForFile(registration + ".first-request") - const resolved = await run(Service.start({ file: registration, version: "test" })) + const resolved = await run(Service.ensure({ file: registration, version: "test" })) expect(resolved.url).toBe(original.url) await writeFile(registration + ".release", "") @@ -45,21 +45,169 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid }) }) +test("waits for a registered service to finish starting", async () => { + const directory = await temp() + const registration = join(directory, "service.json") + const process = spawn(registration, "starting") + await waitForFile(registration) + const result = run(Service.ensure({ file: registration, version: "test", command: [] })) + + await Bun.sleep(500) + expect(process.exitCode).toBe(null) + await writeFile(registration + ".release", "") + expect((await result).url).toBe((await Bun.file(registration).json()).url) +}) + +test("reports a failed registered service without spawning", async () => { + const directory = await temp() + const registration = join(directory, "service.json") + const process = spawn(registration, "failed-owner") + await waitForFile(registration) + + await expect(run(Service.ensure({ file: registration, version: "test", command: [] }))).rejects.toThrow( + "Background service failed to start", + ) + expect(process.exitCode).toBe(null) +}) + +test("requests graceful stop of the exact service instance", async () => { + const directory = await temp() + const registration = join(directory, "service.json") + const process = spawn(registration, "graceful") + await waitForFile(registration) + const info = await Bun.file(registration).json() + + await run(Service.stop({ file: registration })) + await process.exited + expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id }) +}) + +test("does not spawn contenders while an incompatible service rejects replacement", async () => { + const directory = await temp() + const registration = join(directory, "service.json") + const contender = join(directory, "contender.json") + const existing = spawn(registration, "reject-stop") + await waitForFile(registration) + const controller = new AbortController() + const starting = Effect.runPromise( + Service.ensure({ + file: registration, + version: "test", + command: [process.execPath, fixture, contender, "record-start"], + }).pipe(Effect.provide(NodeFileSystem.layer)), + { signal: controller.signal }, + ) + + await waitForFile(registration + ".stop-attempt") + await Bun.sleep(500) + controller.abort() + await starting.catch(() => undefined) + + expect(await Bun.file(contender + ".started").exists()).toBe(false) + expect(existing.exitCode).toBe(null) +}) + test("a legacy health response is still replaced", async () => { const directory = await temp() const registration = join(directory, "service.json") const existing = spawn(registration, "legacy") await waitForFile(registration) - const starts: Service.StartReason[] = [] - const result = run(Service.start({ file: registration, command: [], onStart: (reason) => starts.push(reason) })) + const starts: EnsureReason[] = [] + const result = run(Service.ensure({ file: registration, command: [], onStart: (reason) => starts.push(reason) })) await expect(result).rejects.toThrow("Missing service command") expect(starts).toEqual(["version-mismatch"]) await existing.exited -}) +}, 10_000) -function run(effect: Effect.Effect) { +test("waits for a slow winner while bounding lock probes", async () => { + const directory = await temp() + const registration = join(directory, "service.json") + const endpoint = await run( + Service.ensure({ + file: registration, + version: "test", + command: [process.execPath, fixture, registration, "coordinated"], + }), + ) + const info = await Bun.file(registration).json() + try { + expect(endpoint.url).toBe(info.url) + expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: info.pid }) + expect((await Bun.file(registration + ".starts").text()).trim().split("\n")).toHaveLength(2) + } finally { + process.kill(info.pid, "SIGTERM") + } +}, 15_000) + +test("reports a contender that fails to start", async () => { + const directory = await temp() + const registration = join(directory, "service.json") + await expect( + run( + Service.ensure({ + file: registration, + version: "test", + command: [process.execPath, fixture, registration, "failed"], + }), + ), + ).rejects.toThrow("Server process exited with code 1") +}, 10_000) + +test("reports a contender terminated by a signal", async () => { + const directory = await temp() + const registration = join(directory, "service.json") + await expect( + run( + Service.ensure({ + file: registration, + version: "test", + command: [process.execPath, fixture, registration, "signal"], + }), + ), + ).rejects.toThrow(/Server process (terminated by|exited with code)/) +}, 10_000) + +test("reports a slow contender that eventually fails", async () => { + const directory = await temp() + const registration = join(directory, "service.json") + await expect( + run( + Service.ensure({ + file: registration, + version: "test", + command: [process.execPath, fixture, registration, "delayed-failed", "8000"], + }), + ), + ).rejects.toThrow("Server process exited with code 1") +}, 15_000) + +test("replaces an incompatible owner that appears during startup", async () => { + const directory = await temp() + const registration = join(directory, "service.json") + const starting = run( + Service.ensure({ + file: registration, + version: "test", + command: [process.execPath, fixture, registration, "delayed", "8000"], + }), + ) + await Bun.sleep(1_000) + const old = spawn(registration, "old") + await waitForFile(registration) + const endpoint = await starting + const info = await Bun.file(registration).json() + try { + expect(endpoint.url).toBe(info.url) + expect(info.version).toBe("test") + await old.exited + } finally { + process.kill(info.pid, "SIGTERM") + } +}, 20_000) + +function run(effect: Effect.Effect) { return Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer))) } diff --git a/packages/codemode/README.md b/packages/codemode/README.md index e8447e09b2..51634d91ba 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -15,7 +15,7 @@ runs, no sandbox required. The deliberate differences: - **No ambient authority.** No `fetch`, `process`, filesystem, timers, or host globals - only the allowlisted standard - library and the `tools` tree. + library and supplied `tools`. - **No dynamic code.** No `eval`, `Function`, or module loading. - **Plain-data boundaries.** Tool arguments and program results are JSON-like data. Dates become ISO strings, RegExp, Map, and Set serialize as `{}`, and promises, functions, and runtime references cannot cross the boundary. @@ -33,8 +33,8 @@ generators, and full sparse-array parity) are tracked as unchecked items in the ## Quick Start The package is workspace-private (`"@opencode-ai/codemode": "workspace:*"`). Hosts interact with it through `effect` -and should depend on `effect` themselves. Define tools with Effect Schema, then place them in the object tree exposed -to programs as `tools`: +and should depend on `effect` themselves. Define tools with Effect Schema, then expose them to programs through +`tools`: ```ts import { CodeMode, Tool } from "@opencode-ai/codemode" @@ -75,6 +75,10 @@ is decoded before `run` is invoked; an Effect Schema `output` is decoded and cop Schemas only shape the model-visible signature. Without `output` the signature advertises `Promise`. Descriptions and schemas are model-visible contract; keep authorization in `run`. +Dots in tool names are namespace separators: `{ "issues.list": tool }` exposes `tools.issues.list(...)`, exactly like +`{ issues: { list: tool } }`. Other non-identifier characters render with bracket notation, e.g. +`tools.context7["resolve-library-id"](...)`. + ### `CodeMode.execute` and `CodeMode.make` `CodeMode.execute({ ...options, code })` runs once and is equivalent to `CodeMode.make(options).execute(code)`. A @@ -94,8 +98,8 @@ Effect-returning and must not fail. ### OpenAPI tools -`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation, namespaced by dotted -`operationId`: +`OpenAPI.fromSpec` turns an OpenAPI 3.x document into namespaced tools - one tool per operation, using dotted +`operationId` segments as namespaces: ```ts const api = OpenAPI.fromSpec({ spec, auth: { resolve } }) diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 692e5d03e2..a950eaa72f 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -77,16 +77,28 @@ ultimate source of truth. - [x] Synchronous and `async` functions. - [x] Closures, recursion, default parameters, rest parameters, and destructured parameters. - [x] Expression and block function bodies. -- [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, and string-replacement APIs. -- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, and URI helpers as callbacks where applicable. +- [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, string-replacement, and `Array.from` + mapper APIs, with one shared acceptance rule everywhere including promise reactions. +- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, and URI helpers as callbacks. +- [x] Built-in method references as callbacks, such as `values.map(Math.abs)`, `records.map(JSON.stringify)`, + `items.forEach(console.log)`, and `Promise.resolve(-1).then(Math.abs)`. Extra callback arguments a built-in + does not consume are ignored, like JS; consumed arguments stay strictly validated (`Math.floor` still rejects a + string). Intrinsic references keep their receiver (`"abc".includes` works as a predicate), unlike detached JS + methods, which lose `this`. +- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`), + and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Promise`) throw a `TypeError`, + like JS. +- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an + arrow function. - [x] Async string replacement callbacks; replacements are evaluated sequentially. -- [ ] `this`, `super`, constructor functions, or function prototype methods such as `call`, `apply`, and `bind`. +- [x] The optional `thisArg` of iteration methods is accepted and ignored: CodeMode functions have no `this`, so + ignoring it matches JS arrow-function semantics exactly. +- [ ] `this`, `super`, user-defined constructor functions, or function prototype methods such as `call`, `apply`, + and `bind`. - [ ] Classes and private fields. - [ ] Generator functions and `yield`. - [ ] Async predicates, reducers, and comparators with automatic awaiting. Async mapping can be joined explicitly with `Promise.all`, but a promise is not a meaningful predicate or sort result. -- [ ] General built-in callable references as callbacks, such as `values.map(Math.abs)` or - `records.map(JSON.stringify)`. ## Expressions and operators @@ -137,14 +149,17 @@ ultimate source of truth. - [x] `new Promise((resolve, reject) => ...)`: the executor runs synchronously and receives first-class resolve/reject callables that settle the promise exactly once (they may escape the executor and settle later); an executor throw rejects unless the promise already settled, resolving with a promise adopts it, and resolving with the - promise itself rejects with a `TypeError`. Resolver callables work as `.then`/`.catch` handlers and collection - callbacks but remain opaque references that cannot cross the data boundary. + promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are accepted, including + `.then`/`.catch` handlers and collection callbacks, but remain opaque references that cannot cross the data + boundary. - [ ] Thenable assimilation (objects with a `then` method are plain data, not promises). - [ ] Async iterables, host streams, and stream consumption. ## Objects and properties - [x] Own-field reads and writes on plain data objects. +- [x] `Object()` and `new Object()` return `{}` for nullish arguments and pass objects through unchanged; + primitive wrapper objects (`Object(1)`) are rejected explicitly. - [x] Computed property names and object spread. - [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`. - [x] `Object.keys` over arrays and tool references. @@ -153,11 +168,17 @@ ultimate source of truth. - [ ] `Object.is`; runtime and tool-reference identity semantics need to be defined first. - [ ] `Object.groupBy`. - [ ] Object creation, descriptors, freezing/sealing, prototype APIs, and reflection APIs. -- [ ] A final policy for legal data/tool keys named `__proto__`, `constructor`, or `prototype`. +- [ ] A final policy for legal data keys named `__proto__`, `constructor`, or `prototype` (tool path segments + already allow them; see known semantic gaps). ## Arrays -- [x] Static methods: `Array.isArray`, `Array.of`, and `Array.from`. +- [x] The `Array` constructor with or without `new`: `Array(a, b)` collects arguments, `Array(n)` creates a + sparse array of that length (invalid lengths throw a `RangeError`). Holes behave like JS in iteration, + spread, join, and JSON; `sort` densifies holes into trailing `undefined`, and results returned to the + host normalize holes to `null`. +- [x] Static methods: `Array.isArray`, `Array.of`, and `Array.from`, including the `Array.from` mapper form with + `(value, index)` arguments. - [x] Iteration/transformation: `map`, `filter`, `flatMap`, and `forEach`. - [x] Searching/tests: `find`, `findIndex`, `findLast`, `findLastIndex`, `some`, `every`, `includes`, `indexOf`, and `lastIndexOf`. @@ -167,7 +188,7 @@ ultimate source of truth. - [x] Mutation: `push`, `pop`, `shift`, `unshift`, `splice`, `fill`, and `copyWithin`. - [x] Materialized iteration helpers: `keys`, `values`, and `entries` return arrays rather than iterators. - [x] `length`, numeric indexing, index assignment, spread, and `for...of`. -- [ ] The mapper and `thisArg` forms of `Array.from`. +- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows. - [ ] `Array.prototype.toSpliced`. - [ ] Canonical index handling: a key such as `"01"` must not alias index `1`. - [ ] Complete sparse-array parity. Promise combinators do consume holes as `undefined` members, as in JS. @@ -219,6 +240,8 @@ ultimate source of truth. - [x] `Date.now`, `Date.parse`, and `Date.UTC`. - [x] `new Date()` from the current time, epoch milliseconds, a date string, another Date, or local components. +- [x] `Date()` without `new` returns the current time as a string, like JS, but in deterministic ISO format + rather than the host's locale/timezone string. - [x] `getTime`, `valueOf`, `toISOString`, `toJSON`, and deterministic ISO `toString`. - [x] Local getters: `getFullYear`, `getMonth`, `getDate`, `getDay`, `getHours`, `getMinutes`, `getSeconds`, and `getMilliseconds`. @@ -234,7 +257,7 @@ ultimate source of truth. ## Regular expressions -- [x] Literal and `new RegExp(pattern, flags)` construction. +- [x] Literal and `RegExp(pattern, flags)` construction, with or without `new`. - [x] `test`, `exec`, and `toString`. - [x] Readable `source`, `flags`, `lastIndex`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`, and `dotAll`. - [x] Captures, named groups, match indexes, and stateful global matching. @@ -290,13 +313,23 @@ ultimate source of truth. These are actionable implementation items. Check them off only when behavior and direct tests land. - [x] Return real promises from `Promise.all`, `Promise.allSettled`, and `Promise.race`. -- [ ] Guarantee every advertised tool path is executable, including dotted and blocked path segments. +- [x] Canonicalize dotted tool names into namespace paths so every advertised dotted path is executable, one + canonical path can be both a callable tool and a namespace, and the last definition supplied for a canonical + path wins. +- [x] Allow blocked member names (`constructor`, `prototype`, `__proto__`) as tool path segments: segments are Map + keys and inert strings, never plain-object property accesses, so every advertised path is executable. Blocked + member access on data values stays rejected. Tool names with empty segments are rejected at construction. - [ ] Define safe outbound handling for non-finite numbers and `undefined` so invalid values cannot silently become `null` in render-only or OpenAPI tool calls. - [ ] Make regular-expression execution genuinely timeout-safe, or narrow the timeout guarantee explicitly. - [ ] Complete lexical declaration and destructuring semantics listed above. -- [ ] Make callback acceptance and async callback behavior consistent across built-ins. -- [ ] Reject every unsupported callback argument explicitly rather than silently ignoring it. +- [x] Make callback acceptance consistent across built-ins: collections, sort, string replacers, `Array.from` + mappers, and promise reactions share one acceptance rule. +- [x] Reject every unsupported callable callback argument explicitly rather than silently ignoring it + (`JSON.stringify` replacers and `JSON.parse` revivers fail loudly). The iteration-method `thisArg` is + accepted and ignored: CodeMode functions have no `this`, so ignoring it matches JS arrow semantics. +- [ ] Make async callback behavior consistent across built-ins; only string replacers settle async callback results + today. - [ ] Resolve the built-in correctness gaps listed in the Array, String, Number, Date, and RegExp sections. - [ ] Make tool search tokenization Unicode-aware. - [ ] Design explicit tagged representations and size limits before adding binary values or streams. diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index c53c7b40ab..1f99c01e74 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -1,7 +1,7 @@ import { Effect, Schema } from "effect" import { executeWithLimits } from "./interpreter/execute.js" -import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js" -import type { Definition } from "./tool.js" +import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js" +import type { Tools } from "./tools.js" /** A tool call admitted during an execution. */ export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js" @@ -28,10 +28,6 @@ export type DiscoveryOptions = { readonly catalogBudget?: number } -type ToolTree = { - readonly [name: string]: Definition | ToolTree -} - export type ResolvedExecutionLimits = { readonly timeoutMs: number | undefined readonly maxToolCalls: number | undefined @@ -39,24 +35,24 @@ export type ResolvedExecutionLimits = { } /** Options for one CodeMode execution. */ -export type ExecuteOptions = {}> = { +export type ExecuteOptions = {}> = { /** Source for one program in the supported JavaScript subset. */ code: string - /** Explicit tool tree exposed to the program as `tools`. */ - tools?: Tools & ToolTree> + /** Explicit tools exposed to the program as `tools`. */ + tools?: Provided & Tools> /** Per-execution overrides for the default resource limits. */ limits?: ExecutionLimits /** Observes decoded tool input immediately before tool execution. */ - onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect> + onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect> /** Observes each admitted tool call as it settles, with outcome and duration. */ - onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect> + onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect> } /** A JSON value that can cross the confined interpreter boundary. */ export type DataValue = Schema.Json /** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */ -export type Options = {}> = Omit, "code"> & { +export type Options = {}> = Omit, "code"> & { /** Progressive-disclosure configuration for the agent-facing tool catalog. */ readonly discovery?: DiscoveryOptions } @@ -117,7 +113,7 @@ export const Result = Schema.Union([Success, Failure]) /** Result of executing a CodeMode program. Program failures are data, not Effect failures. */ export type Result = typeof Result.Type -/** Reusable confined runtime over one explicit tool tree. */ +/** Reusable confined runtime over explicit tools. */ export type Runtime = { readonly catalog: () => ReadonlyArray readonly instructions: () => string @@ -138,24 +134,24 @@ const resolveExecutionLimits = (limits?: ExecutionLimits): ResolvedExecutionLimi }) /** Executes one Effect-native CodeMode program without constructing a reusable runtime. */ -export const execute = >( - options: ExecuteOptions, -): Effect.Effect> => { - const tools = (options.tools ?? {}) as HostTools> +export const execute = >( + options: ExecuteOptions, +): Effect.Effect> => { + const tools = (options.tools ?? {}) as Tools> return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools)) } /** Creates an Effect-native runtime over explicit, schema-described tools. */ -export const make = = {}>( - options: Options = {} as Options, -): Runtime> => { - const tools = (options.tools ?? {}) as HostTools> +export const make = = {}>( + options: Options = {} as Options, +): Runtime> => { + const tools = (options.tools ?? {}) as Tools> const limits = resolveExecutionLimits(options.limits) const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget) return { catalog: () => prepared.catalog, instructions: () => prepared.instructions, - execute: (code) => executeWithLimits({ ...options, code }, limits, prepared.searchIndex), + execute: (code) => executeWithLimits({ ...options, code }, limits, prepared.searchIndex), } } diff --git a/packages/codemode/src/interpreter/execute.ts b/packages/codemode/src/interpreter/execute.ts index 1e81a0bfca..5fcd708752 100644 --- a/packages/codemode/src/interpreter/execute.ts +++ b/packages/codemode/src/interpreter/execute.ts @@ -2,17 +2,18 @@ import { parse } from "acorn" import { Cause, Effect, Scope } from "effect" import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" import type { DataValue, Diagnostic, ExecuteOptions, ResolvedExecutionLimits, Result } from "../codemode.js" -import { copyIn, copyOut, ToolRuntime, type HostTools, type Services } from "../tool-runtime.js" +import { copyIn, copyOut, ToolRuntime, type Services } from "../tool-runtime.js" +import type { Tools } from "../tools.js" import { normalizeError } from "./errors.js" import { InterpreterRuntimeError, isRecord, type ProgramNode } from "./model.js" import { PromiseRuntime } from "./promises.js" import { Interpreter } from "./runtime.js" -export const executeWithLimits = >( - options: ExecuteOptions, +export const executeWithLimits = >( + options: ExecuteOptions, limits: ResolvedExecutionLimits, searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"], -): Effect.Effect> => { +): Effect.Effect> => { if (options.code.trim().length === 0) { return Effect.succeed({ ok: false, @@ -24,7 +25,7 @@ export const executeWithLimits = >( // Allocate execution state inside suspension so reused Effects never share it. return Effect.suspend(() => { const tools = ToolRuntime.make( - (options.tools ?? {}) as HostTools>, + (options.tools ?? {}) as Tools>, limits.maxToolCalls, searchIndex, { @@ -35,15 +36,21 @@ export const executeWithLimits = >( const logs: Array = [] const logged = () => (logs.length > 0 ? { logs: [...logs] } : {}) // Set only after copy-out so timeouts cannot report invalid values as completed. - let returned: { value: DataValue; promises: PromiseRuntime> } | undefined + let returned: { value: DataValue; promises: PromiseRuntime> } | undefined const base = Effect.acquireUseRelease( Scope.make("parallel"), (scope) => Effect.gen(function* () { const program = parseProgram(options.code) - const promises = new PromiseRuntime>(scope) - const interpreter = new Interpreter>(tools.invoke, tools.search, tools.keys, promises, logs) + const promises = new PromiseRuntime>(scope) + const interpreter = new Interpreter>( + tools.invoke, + tools.search, + tools.keys, + promises, + logs, + ) const value = yield* interpreter.run(program) const result = copyOut(copyIn(value, "Execution result"), true) as DataValue returned = { value: result, promises } diff --git a/packages/codemode/src/interpreter/methods.ts b/packages/codemode/src/interpreter/methods.ts index 4757dd55df..21d66ea3c5 100644 --- a/packages/codemode/src/interpreter/methods.ts +++ b/packages/codemode/src/interpreter/methods.ts @@ -3,14 +3,16 @@ import { type AstNode, CodeModeFunction, CoercionFunction, + ErrorConstructorReference, GlobalMethodReference, + GlobalNamespace, IntrinsicReference, InterpreterRuntimeError, PromiseCapabilityFunction, - supportedSyntaxMessage, + PromiseNamespace, UriFunction, } from "./model.js" -import { rejectCircularInsertion } from "./references.js" +import { rejectCircularInsertion, typeofValue } from "./references.js" import { isBlockedMember, type SafeObject } from "../tool-runtime.js" import { CodeModeDate, @@ -28,14 +30,47 @@ import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js" import { invokeObjectMethod } from "../stdlib/object.js" import { invokeRegExpMethod, matchToValue, toHostRegex } from "../stdlib/regexp.js" import { invokeStringStatic } from "../stdlib/string.js" -import { invokeUriFunction, invokeURLMethod, invokeURLStatic, uriArgument } from "../stdlib/url.js" -import { boundedData, coerceToNumber, coerceToString, invokeCoercion } from "../stdlib/value.js" +import { invokeURLMethod, invokeURLStatic, uriArgument } from "../stdlib/url.js" +import { boundedData, coerceToNumber, coerceToString, errorBrandName } from "../stdlib/value.js" export type CallbackRunner = { readonly invokeFunction: (fn: CodeModeFunction, args: Array) => Effect.Effect + readonly invokeCallable: ( + callable: unknown, + args: Array, + node: AstNode, + ) => Effect.Effect readonly settlePromise: (promise: CodeModePromise) => Effect.Effect } +// The single acceptance list for callbacks: collections, sort, string replacers, +// Array.from mappers, and promise reactions all admit exactly these callables. +// Admission means dispatchable, not necessarily invocable: new-requiring +// constructors pass the gate and throw a TypeError on call, like JS. +export type SupportedCallback = + | CodeModeFunction + | CoercionFunction + | UriFunction + | PromiseCapabilityFunction + | GlobalMethodReference + | IntrinsicReference + | ErrorConstructorReference + | GlobalNamespace + | PromiseNamespace + +export const isSupportedCallback = (value: unknown): value is SupportedCallback => + value instanceof CodeModeFunction || + value instanceof CoercionFunction || + value instanceof UriFunction || + value instanceof PromiseCapabilityFunction || + value instanceof GlobalMethodReference || + value instanceof IntrinsicReference || + value instanceof ErrorConstructorReference || + // Callable namespaces dispatch like JS: Array/Object/Date/RegExp construct, + // new-requiring constructors throw a TypeError. Math/JSON/console stay non-callable. + (value instanceof GlobalNamespace && typeofValue(value) === "function") || + value instanceof PromiseNamespace + export const invokeIntrinsic = ( runner: CallbackRunner, ref: IntrinsicReference, @@ -43,11 +78,14 @@ export const invokeIntrinsic = ( node: AstNode, ): Effect.Effect => { if (typeof ref.receiver === "string") { - if ( - (ref.name === "replace" || ref.name === "replaceAll") && - (args[1] instanceof CodeModeFunction || args[1] instanceof CoercionFunction || args[1] instanceof UriFunction) - ) { - return invokeStringReplacer(runner, ref.receiver, ref.name, args, node) + if (ref.name === "replace" || ref.name === "replaceAll") { + if (isSupportedCallback(args[1])) return invokeStringReplacer(runner, ref.receiver, ref.name, args, node) + if (typeofValue(args[1]) === "function") { + throw new InterpreterRuntimeError( + `String.${ref.name} cannot use this callable as a replacer; wrap it in an arrow function, e.g. (match) => tools.ns.tool(match).`, + node, + ) + } } return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node)) } @@ -269,49 +307,60 @@ const invokeArrayStatic = (name: string, args: Array, node: AstNode): u return Array.isArray(args[0]) case "of": return [...args] - case "from": { - if (args.length > 1) { - throw new InterpreterRuntimeError( - "Array.from(...) does not support a map function in CodeMode; call .map() on the result instead.", - node, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) - } - if (args[0] instanceof CodeModeMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item]) - if (args[0] instanceof CodeModeSet) return Array.from(args[0].set.values()) - if (args[0] instanceof CodeModeURLSearchParams) { - return Array.from(args[0].params.entries(), ([key, value]) => [key, value]) - } - const source = args[0] - if (source instanceof CodeModePromise) { - throw new InterpreterRuntimeError( - "Array.from received an un-awaited Promise; await it before creating the array.", - node, - "InvalidDataValue", - ) - } - if (typeof source === "string") return Array.from(source) - if (Array.isArray(source)) return [...source] - if ( - source !== null && - typeof source === "object" && - (Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) && - typeof (source as { length?: unknown }).length === "number" - ) { - return Array.from(source as ArrayLike) - } - throw new InterpreterRuntimeError( - "Array.from expects an array, string, Map, Set, or array-like value.", - node, - "InvalidDataValue", - ) - } + case "from": + return arrayFromItems(args[0], node) default: throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node) } } +const arrayFromItems = (source: unknown, node: AstNode): Array => { + if (source instanceof CodeModeMap) return Array.from(source.map.entries(), ([key, item]) => [key, item]) + if (source instanceof CodeModeSet) return Array.from(source.set.values()) + if (source instanceof CodeModeURLSearchParams) { + return Array.from(source.params.entries(), ([key, value]) => [key, value]) + } + if (source instanceof CodeModePromise) { + throw new InterpreterRuntimeError( + "Array.from received an un-awaited Promise; await it before creating the array.", + node, + "InvalidDataValue", + ) + } + if (typeof source === "string") return Array.from(source) + if (Array.isArray(source)) return [...source] + if ( + source !== null && + typeof source === "object" && + (Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) && + typeof (source as { length?: unknown }).length === "number" + ) { + return Array.from(source as ArrayLike) + } + throw new InterpreterRuntimeError( + "Array.from expects an array, string, Map, Set, or array-like value.", + node, + "InvalidDataValue", + ) +} + +export const invokeArrayFrom = ( + runner: CallbackRunner, + args: Array, + node: AstNode, +): Effect.Effect => { + const items = arrayFromItems(args[0], node) + if (args.length < 2 || args[1] === undefined) return Effect.succeed(items) + const apply = applyCollectionCallback(runner, args[1], "Array.from", node) + return Effect.gen(function* () { + const values: Array = [] + for (let index = 0; index < items.length; index += 1) { + values.push(yield* apply([items[index], index])) + } + return values + }) +} + const invokeStringReplacer = ( runner: CallbackRunner, value: string, @@ -367,9 +416,12 @@ const invokeStringReplacer = ( args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof CodeModePromise ? yield* runner.settlePromise(replacement) : replacement + // Error values are branded plain objects; boundedData would strip the brand before coercion. output.push( value.slice(end, match.offset), - coerceToString(boundedData(resolved, `String.${name} replacer result`)), + errorBrandName(resolved) + ? coerceToString(resolved) + : coerceToString(boundedData(resolved, `String.${name} replacer result`)), ) end = match.offset + match.match.length } @@ -384,22 +436,16 @@ export const applyCollectionCallback = ( name: string, node: AstNode, ): ((args: Array) => Effect.Effect) => { - if ( - !(callback instanceof CodeModeFunction) && - !(callback instanceof CoercionFunction) && - !(callback instanceof UriFunction) && - !(callback instanceof PromiseCapabilityFunction) - ) { + if (!isSupportedCallback(callback)) { + if (typeofValue(callback) === "function") { + throw new InterpreterRuntimeError( + `${name} cannot use this callable as a callback; wrap it in an arrow function, e.g. (value) => tools.ns.tool(value).`, + node, + ) + } throw new InterpreterRuntimeError(`${name} expects a function callback.`, node) } - return (callbackArgs) => - callback instanceof CoercionFunction - ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) - : callback instanceof UriFunction - ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node)) - : callback instanceof PromiseCapabilityFunction - ? Effect.sync(() => callback.settle(callbackArgs[0])) - : runner.invokeFunction(callback, callbackArgs) + return (callbackArgs) => runner.invokeCallable(callback, callbackArgs, node) } const invokeMapMethod = ( @@ -603,12 +649,12 @@ const invokeArrayMethod = ( case "reverse": return Effect.succeed(target.reverse()) case "sort": - return Effect.map(sortArray(runner, target, args[0], node), (sorted) => { + return Effect.map(sortArray(runner, target, args[0], "Array.sort", node), (sorted) => { target.splice(0, target.length, ...sorted) return target }) case "toSorted": - return sortArray(runner, target, args[0], node) + return sortArray(runner, target, args[0], "Array.toSorted", node) case "toReversed": return Effect.succeed([...target].reverse()) case "with": { @@ -782,12 +828,10 @@ const sortArray = ( runner: CallbackRunner, target: Array, comparator: unknown, + name: string, node: AstNode, ): Effect.Effect, unknown, R> => { - if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) { - throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node) - } - if (!(comparator instanceof CodeModeFunction)) { + if (comparator === undefined) { return Effect.sync(() => [...target].sort((a, b) => { const left = coerceToString(a) @@ -796,6 +840,7 @@ const sortArray = ( }), ) } + const apply = applyCollectionCallback(runner, comparator, name, node) const mergeSort = (items: Array): Effect.Effect, unknown, R> => { if (items.length <= 1) return Effect.succeed(items) const midpoint = Math.floor(items.length / 2) @@ -807,7 +852,7 @@ const sortArray = ( let rightIndex = 0 while (leftIndex < left.length && rightIndex < right.length) { // Treat a NaN comparator result as equal to preserve stable ordering. - const order = coerceToNumber(yield* runner.invokeFunction(comparator, [left[leftIndex], right[rightIndex]])) + const order = coerceToNumber(yield* apply([left[leftIndex], right[rightIndex]])) if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++]) else merged.push(right[rightIndex++]) } diff --git a/packages/codemode/src/interpreter/promises.ts b/packages/codemode/src/interpreter/promises.ts index 2bf1b1ea30..6f7c07c5b8 100644 --- a/packages/codemode/src/interpreter/promises.ts +++ b/packages/codemode/src/interpreter/promises.ts @@ -4,16 +4,14 @@ import type { SafeObject } from "../tool-runtime.js" import { type AstNode, CodeModeFunction, - CoercionFunction, InterpreterRuntimeError, ProgramThrow, PromiseCapabilityFunction, PromiseInstanceMethodReference, PromiseMethodReference, - UriFunction, } from "./model.js" import { caughtErrorValue, normalizeError } from "./errors.js" -import { applyCollectionCallback, type CallbackRunner } from "./methods.js" +import { applyCollectionCallback, isSupportedCallback, type CallbackRunner, type SupportedCallback } from "./methods.js" import { typeofValue } from "./references.js" import { spreadItems } from "../stdlib/collections.js" import { createAggregateErrorValue } from "../stdlib/value.js" @@ -258,20 +256,11 @@ class PromiseAnyFulfilled { constructor(readonly value: unknown) {} } -type ReactionHandler = CodeModeFunction | CoercionFunction | UriFunction | PromiseCapabilityFunction - -const reactionHandler = (value: unknown, method: string, node: AstNode): ReactionHandler | undefined => { - if ( - value instanceof CodeModeFunction || - value instanceof CoercionFunction || - value instanceof UriFunction || - value instanceof PromiseCapabilityFunction - ) { - return value - } +const reactionHandler = (value: unknown, method: string, node: AstNode): SupportedCallback | undefined => { + if (isSupportedCallback(value)) return value if (typeofValue(value) === "function") { throw new InterpreterRuntimeError( - `${method} handlers must be plain functions; wrap other callables in an arrow function, e.g. (value) => tools.ns.tool(value).`, + `${method} cannot use this callable as a handler; wrap it in an arrow function, e.g. (value) => tools.ns.tool(value).`, node, ) } @@ -294,8 +283,8 @@ const chainReaction = ( runner: CallbackRunner, promises: PromiseRuntime, source: CodeModePromise, - onFulfilled: ReactionHandler | undefined, - onRejected: ReactionHandler | undefined, + onFulfilled: SupportedCallback | undefined, + onRejected: SupportedCallback | undefined, method: string, node: AstNode, ): Effect.Effect => { @@ -320,7 +309,7 @@ const chainFinally = ( runner: CallbackRunner, promises: PromiseRuntime, source: CodeModePromise, - cleanup: ReactionHandler | undefined, + cleanup: SupportedCallback | undefined, method: string, node: AstNode, ): Effect.Effect => diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 3dfc9c5cb9..b9ed33fd61 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -34,7 +34,7 @@ import { UriFunction, } from "./model.js" import { caughtErrorValue, constructErrorValue } from "./errors.js" -import { type CallbackRunner, invokeGlobalMethod, invokeIntrinsic } from "./methods.js" +import { type CallbackRunner, invokeArrayFrom, invokeGlobalMethod, invokeIntrinsic } from "./methods.js" import { constructPromise, invokePromiseInstanceMethod, @@ -153,6 +153,7 @@ export class Interpreter { private readonly promises: PromiseRuntime private readonly runner: CallbackRunner = { invokeFunction: (fn, args) => this.invokeFunction(fn, args), + invokeCallable: (callable, args, node) => this.invokeCallable(callable, args, node), settlePromise: (promise) => this.settlePromise(promise), } @@ -997,6 +998,13 @@ export class Interpreter { if (errorConstructors.has(name)) { return Effect.map(this.evaluateCallArguments(argNodes), (args) => constructErrorValue(name, args, node)) } + // Array and Object construct identically with or without new, like JS. + if (name === "Array") { + return Effect.map(this.evaluateCallArguments(argNodes), (args) => self.constructArray(args, node)) + } + if (name === "Object") { + return Effect.map(this.evaluateCallArguments(argNodes), (args) => self.constructObject(args, node)) + } if (valueConstructors.has(name)) { return Effect.gen(function* () { const args = yield* self.evaluateCallArguments(argNodes) @@ -1019,6 +1027,27 @@ export class Interpreter { throw unsupportedSyntax("NewExpression", node) } + private constructArray(args: Array, node: AstNode): Array { + if (args.length !== 1) return [...args] + const first = args[0] + if (typeof first !== "number") return [first] + if (!Number.isInteger(first) || first < 0 || first > 4294967295) { + throw new InterpreterRuntimeError("Invalid array length.", node).as("RangeError") + } + // Sparse like JS: Array(3) has holes, and combinator loops already skip them. + return new Array(first) + } + + private constructObject(args: Array, node: AstNode): unknown { + const first = args[0] + if (first === null || first === undefined) return {} + if (typeof first === "object") return first + throw new InterpreterRuntimeError( + `Object(${typeof first}) wrapper objects are not supported in CodeMode; use the primitive value directly.`, + node, + ) + } + private constructDate(args: Array): CodeModeDate { if (args.length === 0) return new CodeModeDate(Date.now()) if (args.length === 1) { @@ -1041,7 +1070,7 @@ export class Interpreter { throw new InterpreterRuntimeError( `RegExp flags must be a string of flag characters (e.g. "g", "gi"), not ${flagsArg === null ? "null" : typeof flagsArg}.`, node, - ) + ).as("SyntaxError") } const flags = flagsArg ?? (first instanceof CodeModeRegExp ? first.regex.flags : "") try { @@ -1401,7 +1430,19 @@ export class Interpreter { if ((callable === null || callable === undefined) && node.optional === true) return OptionalShortCircuit const args = yield* self.evaluateCallArguments(argNodes) + return yield* self.invokeCallable(callable, args, node, callee) + }) + } + // The single dispatch for every invocation: call expressions and callbacks share it. + private invokeCallable( + callable: unknown, + args: Array, + node: AstNode, + callee: AstNode = node, + ): Effect.Effect { + const self = this + return Effect.gen(function* () { if (callable instanceof ToolReference) { if (callable.path.length === 0) throw new InterpreterRuntimeError("The tools root is not callable.", callee) return yield* self.createToolCallPromise(callable.path, args) @@ -1426,7 +1467,10 @@ export class Interpreter { if (callable.namespace === "Object" && objectMethodsPreservingIdentity.has(callable.name)) { return invokeGlobalMethod(callable, args, node) } - if (callable.namespace === "Array" && (callable.name === "from" || callable.name === "of")) { + if (callable.namespace === "Array" && callable.name === "from") { + return yield* invokeArrayFrom(self.runner, args, node) + } + if (callable.namespace === "Array" && callable.name === "of") { return invokeGlobalMethod(callable, args, node) } return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`) @@ -1443,6 +1487,22 @@ export class Interpreter { if (callable instanceof ErrorConstructorReference) { return constructErrorValue(callable.name, args, node) } + if (callable instanceof GlobalNamespace) { + // Real JS permits calling Array, Object, Date, and RegExp without new. + if (callable.name === "Array") return self.constructArray(args, node) + if (callable.name === "Object") return self.constructObject(args, node) + // ISO instead of the host's locale string: CodeMode date strings are + // deterministic and must not leak the host timezone. + if (callable.name === "Date") return new Date().toISOString() + if (callable.name === "RegExp") return self.constructRegExp(args, node) + if (typeofValue(callable) === "function") { + throw new InterpreterRuntimeError(`Constructor ${callable.name} requires 'new'.`, node).as("TypeError") + } + throw new InterpreterRuntimeError(`${callable.name} is not a function.`, node).as("TypeError") + } + if (callable instanceof PromiseNamespace) { + throw new InterpreterRuntimeError("Constructor Promise requires 'new'.", node).as("TypeError") + } if (callable instanceof PromiseCapabilityFunction) { callable.settle(args[0]) return undefined @@ -1604,7 +1664,8 @@ export class Interpreter { return Effect.gen(function* () { for (const elementValue of elements) { if (elementValue === null) { - values.push(undefined) + // A literal elision is a real hole, like JS: extend length without an own index. + values.length += 1 continue } const element = asNode(elementValue, "elements") @@ -1698,8 +1759,8 @@ export class Interpreter { : self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode) if (objectValue instanceof ToolReference) { - if (typeof key !== "string" || isBlockedMember(key)) { - throw new InterpreterRuntimeError("Tool paths must use safe string property names.", propertyNode) + if (typeof key !== "string") { + throw new InterpreterRuntimeError("Tool paths must use string property names.", propertyNode) } return new ToolReference([...objectValue.path, key]) } diff --git a/packages/codemode/src/openapi/runtime.ts b/packages/codemode/src/openapi/runtime.ts index 2b2dcd1e51..d64d03e552 100644 --- a/packages/codemode/src/openapi/runtime.ts +++ b/packages/codemode/src/openapi/runtime.ts @@ -68,14 +68,16 @@ const buildRequest = ( } let request = HttpClientRequest.make(plan.operation.method as HttpMethod.HttpMethod)(url) + const query: Array = [] for (const field of plan.fields) { if (field.location !== "query") continue const item = own(input, field.inputName) if (item === undefined) continue - const serialized = serializeQuery(request, field, item) + const serialized = serializeQuery(field, item) if (serialized instanceof ToolError) return yield* Effect.fail(serialized) - request = serialized + for (const parameter of serialized) query.push(parameter) } + if (query.length > 0) request = HttpClientRequest.appendUrlParams(request, query) request = HttpClientRequest.setHeaders(request, plan.headers) for (const field of plan.fields) { @@ -246,40 +248,46 @@ const serializeSimple = ( } const serializeQuery = ( - request: HttpClientRequest.HttpClientRequest, field: Plan["fields"][number], value: unknown, -): HttpClientRequest.HttpClientRequest | ToolError => { +): ReadonlyArray | ToolError => { if (field.style === "deepObject") { if (!isRecord(value)) return toolError(`Deep-object parameter '${field.inputName}' must be an object.`) - return Object.entries(value).reduce((current, [name, item]) => { - if (current instanceof ToolError) return current + const parameters: Array = [] + for (const [name, item] of Object.entries(value)) { if (item === undefined || (item !== null && typeof item === "object")) { return toolError(`Deep-object parameter '${field.inputName}' contains an unsupported nested value.`) } - return HttpClientRequest.appendUrlParam(current, `${field.name}[${name}]`, String(item)) - }, request) + parameters.push([`${field.name}[${name}]`, String(item)]) + } + return parameters } if (Array.isArray(value)) { - const rendered = serializeSimple(field, value, String) - if (rendered instanceof ToolError) return rendered - if (!field.explode) return HttpClientRequest.appendUrlParam(request, field.name, rendered) - if (value.some((item) => item === undefined || (item !== null && typeof item === "object"))) { - return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`) + if (!field.explode) { + const rendered = serializeSimple(field, value, String) + return rendered instanceof ToolError ? rendered : [[field.name, rendered]] } - return value.reduce((current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)), request) + const parameters: Array = [] + for (const item of value) { + if (item !== null && typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean") { + return toolError(`Parameter '${field.inputName}' contains an unsupported nested value.`) + } + parameters.push([field.name, String(item)]) + } + return parameters } if (isRecord(value) && field.explode) { - return Object.entries(value).reduce((current, [name, item]) => { - if (current instanceof ToolError) return current + const parameters: Array = [] + for (const [name, item] of Object.entries(value)) { if (item === undefined || (item !== null && typeof item === "object")) { return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`) } - return HttpClientRequest.appendUrlParam(current, name, String(item)) - }, request) + parameters.push([name, String(item)]) + } + return parameters } const rendered = serializeSimple(field, value, String) - return rendered instanceof ToolError ? rendered : HttpClientRequest.appendUrlParam(request, field.name, rendered) + return rendered instanceof ToolError ? rendered : [[field.name, rendered]] } const readResponseBody = ( diff --git a/packages/codemode/src/openapi/types.ts b/packages/codemode/src/openapi/types.ts index 6f3eb86283..252f49d86c 100644 --- a/packages/codemode/src/openapi/types.ts +++ b/packages/codemode/src/openapi/types.ts @@ -61,7 +61,7 @@ export type Skipped = { export type Tools = { [name: string]: Definition | Tools } export type Result = { - /** Tool subtree; the host places it under a key in its `tools` tree. */ + /** Namespaced tools; the host places them under a key in its `tools` object. */ readonly tools: Tools readonly skipped: ReadonlyArray } diff --git a/packages/codemode/src/stdlib/json.ts b/packages/codemode/src/stdlib/json.ts index a7cc13629e..d65da7fe20 100644 --- a/packages/codemode/src/stdlib/json.ts +++ b/packages/codemode/src/stdlib/json.ts @@ -1,16 +1,12 @@ -import { - type AstNode, - CodeModeFunction, - InterpreterRuntimeError, - supportedSyntaxMessage, -} from "../interpreter/model.js" +import { type AstNode, InterpreterRuntimeError, supportedSyntaxMessage } from "../interpreter/model.js" +import { typeofValue } from "../interpreter/references.js" import { copyIn, copyOut } from "../tool-runtime.js" export const invokeJsonMethod = (name: string, args: Array, node: AstNode): unknown => { switch (name) { case "stringify": { const replacer = args[1] - if (Array.isArray(replacer) || replacer instanceof CodeModeFunction) { + if (Array.isArray(replacer) || typeofValue(replacer) === "function") { throw new InterpreterRuntimeError( "JSON.stringify replacers are not supported in CodeMode.", node, @@ -25,6 +21,14 @@ export const invokeJsonMethod = (name: string, args: Array, node: AstNo case "parse": { const text = args[0] if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node) + if (typeofValue(args[1]) === "function") { + throw new InterpreterRuntimeError( + "JSON.parse revivers are not supported in CodeMode.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } try { return copyIn(JSON.parse(text), "JSON.parse result") } catch (error) { diff --git a/packages/codemode/src/stdlib/math.ts b/packages/codemode/src/stdlib/math.ts index 7720f69775..54fa3be91b 100644 --- a/packages/codemode/src/stdlib/math.ts +++ b/packages/codemode/src/stdlib/math.ts @@ -42,16 +42,26 @@ export const mathMethods = new Set([ export const invokeMathMethod = (name: string, args: Array, node: AstNode): number => { if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) if (name === "random") return Math.random() - const nums = args.map((arg) => { + // Validate only the arguments the method consumes; like JS, extras are ignored + // (so built-ins work as callbacks receiving (element, index, array)). + const num = (index: number): number => { + if (index >= args.length) return Number.NaN + const arg = args[index] if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node) return arg - }) - const [a = Number.NaN, b = Number.NaN] = nums + } + const nums = () => + args.map((arg) => { + if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node) + return arg + }) + const a = num(0) + const b = () => num(1) switch (name) { case "max": - return Math.max(...nums) + return Math.max(...nums()) case "min": - return Math.min(...nums) + return Math.min(...nums()) case "abs": return Math.abs(a) case "acos": @@ -65,7 +75,7 @@ export const invokeMathMethod = (name: string, args: Array, node: AstNo case "atan": return Math.atan(a) case "atan2": - return Math.atan2(a, b) + return Math.atan2(a, b()) case "atanh": return Math.atanh(a) case "floor": @@ -83,9 +93,9 @@ export const invokeMathMethod = (name: string, args: Array, node: AstNo case "cbrt": return Math.cbrt(a) case "pow": - return Math.pow(a, b) + return Math.pow(a, b()) case "hypot": - return Math.hypot(...nums) + return Math.hypot(...nums()) case "cos": return Math.cos(a) case "cosh": @@ -117,7 +127,7 @@ export const invokeMathMethod = (name: string, args: Array, node: AstNo case "clz32": return Math.clz32(a) case "imul": - return Math.imul(a, b) + return Math.imul(a, b()) } throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) } diff --git a/packages/codemode/src/stdlib/value.ts b/packages/codemode/src/stdlib/value.ts index 9a47285da5..2b5ff00f48 100644 --- a/packages/codemode/src/stdlib/value.ts +++ b/packages/codemode/src/stdlib/value.ts @@ -41,6 +41,15 @@ export const coerceToString = (value: unknown): string => { if (value instanceof CodeModeSet) return "[object Set]" if (value instanceof CodeModeURL) return value.url.href if (value instanceof CodeModeURLSearchParams) return value.params.toString() + if (errorBrandName(value) !== undefined) { + // Match Error.prototype.toString: "name: message", or just one when the other is empty. + const error = value as { name?: unknown; message?: unknown } + const name = typeof error.name === "string" ? error.name : "Error" + const message = typeof error.message === "string" ? error.message : "" + if (message === "") return name + if (name === "") return message + return `${name}: ${message}` + } if (typeof value === "object") { return Array.isArray(value) ? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",") @@ -57,6 +66,8 @@ export const coerceToNumber = (value: unknown): number => { export const invokeCoercion = (ref: CoercionFunction, args: Array, node: AstNode): unknown => { const raw = args[0] + // Error values are plain SafeObjects; the boundedData path below would strip their brand. + if (ref.name === "String" && errorBrandName(raw) !== undefined) return coerceToString(raw) if (isCodeModeValue(raw)) { if (ref.name === "Boolean") return true if (ref.name === "Number") return coerceToNumber(raw) diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 90c3b2de60..e4e365cb4e 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -9,6 +9,7 @@ import { outputTypeScript, } from "./tool-schema.js" import { isDefinition as isToolDefinition, type Definition } from "./tool.js" +import type { Tools } from "./tools.js" import { CodeModeDate, CodeModeMap, @@ -21,28 +22,20 @@ import { const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4)) -export type HostTool = (...args: Array) => Effect.Effect +export type Services = ServicesOf -export type HostTools = { - [name: string]: HostTool | Definition | HostTools -} - -export type Services = ServicesOf - -type ServicesOf> = Depth["length"] extends 8 +type ServicesOf> = Depth["length"] extends 8 ? never - : Tools extends (...args: Array) => Effect.Effect + : T extends { + readonly _tag: "CodeModeTool" + readonly run: (input: unknown) => Effect.Effect + } ? R - : Tools extends { - readonly _tag: "CodeModeTool" - readonly run: (input: unknown) => Effect.Effect - } - ? R - : Tools extends object - ? string extends keyof Tools - ? ServicesOf - : ServicesOf - : never + : T extends object + ? string extends keyof T + ? ServicesOf + : ServicesOf + : never export type ToolCall = { readonly name: string @@ -125,7 +118,7 @@ export class ToolRuntimeError extends Error { } } -const isDefinition = (value: HostTool | Definition | HostTools): value is Definition => +const isDefinition = (value: Definition | Tools): value is Definition => isToolDefinition(value) const runHost = (effect: Effect.Effect): Effect.Effect => @@ -270,7 +263,8 @@ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => { return null } if (Array.isArray(value)) { - return value.map((item) => copyOut(item, undefinedAsNull)) + // Array.from densifies holes so sparse arrays normalize at the boundary like JSON does. + return Array.from(value, (item) => copyOut(item, undefinedAsNull)) } if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) { @@ -280,15 +274,41 @@ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => { return value } +// Dots in tool names are namespace separators; the last definition for a canonical path wins. +type ToolNode = { + definition?: Definition + readonly children: Map> +} + +const toolTrie = (tools: Tools): ToolNode => { + const root: ToolNode = { children: new Map() } + const insert = (node: ToolNode, group: Tools): void => { + for (const [name, value] of Object.entries(group)) { + let current = node + for (const segment of name.split(".")) { + if (segment === "") throw new TypeError(`Tool name '${name}' contains an empty segment.`) + const child = current.children.get(segment) ?? { children: new Map() } + current.children.set(segment, child) + current = child + } + if (isDefinition(value)) current.definition = value + else insert(current, value) + } + } + insert(root, tools) + return root +} + +const canonicalSegments = (path: ReadonlyArray): ReadonlyArray => + path.flatMap((segment) => segment.split(".")) + const definitions = ( - tools: HostTools, + node: ToolNode, path: ReadonlyArray = [], -): Array<{ path: string; definition: Definition }> => - Object.entries(tools).flatMap(([name, value]) => { - const next = [...path, name] - if (isDefinition(value)) return [{ path: next.join("."), definition: value }] - return typeof value === "function" ? [] : definitions(value, next) - }) +): Array<{ path: string; definition: Definition }> => [ + ...(node.definition === undefined ? [] : [{ path: path.join("."), definition: node.definition }]), + ...Array.from(node.children, ([name, child]) => definitions(child, [...path, name])).flat(), +] const describeDefinition = (path: string, definition: Definition): ToolDescription => ({ path, @@ -296,8 +316,8 @@ const describeDefinition = (path: string, definition: Definition): ToolDes signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`, }) -const visibleDefinitions = (tools: HostTools) => - definitions(tools).map(({ path, definition }) => ({ +const visibleDefinitions = (tools: Tools) => + definitions(toolTrie(tools)).map(({ path, definition }) => ({ path, definition, description: describeDefinition(path, definition), @@ -414,11 +434,11 @@ const toSearchEntry = (path: string, definition: Definition, description: .toLowerCase(), }) -export const searchIndex = (tools: HostTools): ReadonlyArray => +export const searchIndex = (tools: Tools): ReadonlyArray => visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description)) // Budget signatures round-robin so every namespace remains visible. -export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBudget): DiscoveryPlan => { +export const prepare = (tools: Tools, catalogBudget = defaultCatalogBudget): DiscoveryPlan => { if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) { throw new RangeError("discovery.catalogBudget must be a non-negative safe integer") } @@ -561,47 +581,30 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu } } -const namespaceKeys = (tools: HostTools, path: ReadonlyArray): ReadonlyArray => { - let value: HostTool | Definition | HostTools = tools - for (const segment of path) { - if ( - isBlockedMember(segment) || - typeof value === "function" || - isDefinition(value) || - !Object.hasOwn(value, segment) - ) { - throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${path.join(".")}'.`, [ - "Object.keys(tools) lists the available namespaces; search({ query }) finds described tools.", - ]) - } - value = value[segment] as HostTool | Definition | HostTools +const lookup = (root: ToolNode, segments: ReadonlyArray): ToolNode | undefined => + segments.reduce | undefined>((node, segment) => node?.children.get(segment), root) + +const namespaceKeys = (root: ToolNode, path: ReadonlyArray): ReadonlyArray => { + const segments = canonicalSegments(path) + const node = lookup(root, segments) + if (node === undefined) { + throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${segments.join(".")}'.`) } - if (typeof value === "function" || isDefinition(value)) return [] - return Object.keys(value) + return Array.from(node.children.keys()) } -const resolve = (tools: HostTools, path: ReadonlyArray): HostTool | Definition => { - let value: HostTool | Definition | HostTools = tools - - for (const segment of path) { - if ( - isBlockedMember(segment) || - typeof value === "function" || - isDefinition(value) || - !Object.hasOwn(value, segment) - ) { - throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, [ - "Use search({ query }) to find available described tools.", - ]) - } - value = value[segment] as HostTool | Definition | HostTools +const resolve = (root: ToolNode, path: ReadonlyArray): Definition => { + const segments = canonicalSegments(path) + const node = lookup(root, segments) + if (node === undefined) { + throw new ToolRuntimeError("UnknownTool", `Unknown tool '${segments.join(".")}'.`, [ + "Use search({ query }) to find available described tools.", + ]) } - - if (typeof value !== "function" && !isDefinition(value)) { - throw new ToolRuntimeError("UnknownTool", `Tool '${path.join(".")}' is not callable.`) + if (node.definition === undefined) { + throw new ToolRuntimeError("UnknownTool", `Tool '${segments.join(".")}' is not callable.`) } - - return value + return node.definition } export type ToolRuntime = { @@ -613,12 +616,13 @@ export type ToolRuntime = { } export const make = ( - tools: HostTools, + tools: Tools, maxToolCalls: number | undefined, searchIndex: ReadonlyArray, hooks?: ToolCallHooks, ): ToolRuntime => { const calls: Array = [] + const root = toolTrie(tools) const searchTool = makeSearchTool(searchIndex) // End hooks observe settled success or failure; interruption emits neither outcome. @@ -686,7 +690,7 @@ export const make = ( return { root: new ToolReference([]), calls, - keys: (path) => namespaceKeys(tools, path), + keys: (path) => namespaceKeys(root, path), search: (args) => Effect.suspend(() => invokeDefinition( @@ -697,17 +701,10 @@ export const make = ( ), invoke: (path, args) => Effect.gen(function* () { - const name = path.join(".") + const name = canonicalSegments(path).join(".") const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`))) - const tool = resolve(tools, path) - if (isDefinition(tool)) return yield* invokeDefinition(name, tool, externalArgs) - const index = yield* recordAndObserve(name, externalArgs) - return yield* observeEnd( - Effect.gen(function* () { - return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name) - }), - { index, name, input: externalArgs }, - ) + const tool = resolve(root, path) + return yield* invokeDefinition(name, tool, externalArgs) }), } } diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index 0535cc9caa..e75fa7ba26 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -29,7 +29,7 @@ export type JsonSchema = { /** Either a validating Effect Schema or a render-only JSON Schema document. */ export type SchemaType = Schema.Decoder | JsonSchema -/** Schema-backed tool definition consumed by a CodeMode tool tree. */ +/** Schema-backed tool definition exposed through CodeMode's `tools` object. */ export type Definition = { readonly _tag: "CodeModeTool" readonly description: string @@ -50,8 +50,13 @@ export type Options) => Effect.Effect, unknown, R> } +// Object.hasOwn: an inherited _tag must not classify a namespace as a Definition. export const isDefinition = (value: unknown): value is Definition => - typeof value === "object" && value !== null && "_tag" in value && value._tag === "CodeModeTool" + typeof value === "object" && + value !== null && + "_tag" in value && + Object.hasOwn(value, "_tag") && + value._tag === "CodeModeTool" /** * Defines one schema-described tool available to a CodeMode program through `tools.*`. diff --git a/packages/codemode/src/tools.ts b/packages/codemode/src/tools.ts new file mode 100644 index 0000000000..04e36dcef2 --- /dev/null +++ b/packages/codemode/src/tools.ts @@ -0,0 +1,5 @@ +import type { Definition } from "./tool.js" + +export type Tools = { + readonly [name: string]: Definition | Tools +} diff --git a/packages/codemode/test/callbacks.test.ts b/packages/codemode/test/callbacks.test.ts new file mode 100644 index 0000000000..6391448a70 --- /dev/null +++ b/packages/codemode/test/callbacks.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { CodeMode, Tool } from "../src/index.js" + +// Callback acceptance is one gate shared by array methods, sort, string replacers, +// Array.from mappers, Map/Set/URLSearchParams forEach, and promise reactions: +// interpreter functions, coercion/URI builtins, resolver capabilities, and built-in +// method references are callable; tools and other opaque callables get a wrap hint. +const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} })) +const value = async (code: string) => { + const result = await run(code) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} +const error = async (code: string) => { + const result = await run(code) + if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) + return result.error +} +const logsOf = async (code: string) => { + const result = await run(code) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.logs ?? [] +} + +const echo = Tool.make({ + description: "Echo the input", + input: Schema.Struct({ id: Schema.Number }), + output: Schema.Number, + run: (input: { id: number }) => Effect.succeed(input.id), +}) +const withTool = (code: string) => Effect.runPromise(CodeMode.make({ tools: { host: { echo } } }).execute(code)) +const toolError = async (code: string) => { + const result = await withTool(code) + if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) + return result.error +} + +describe("built-in method references as callbacks", () => { + test("map accepts Math methods", async () => { + expect(await value(`return [-1, 2, -3].map(Math.abs)`)).toEqual([1, 2, 3]) + expect(await value(`return [1.5, 2.7].map(Math.floor)`)).toEqual([1, 2]) + }) + + test("map(JSON.stringify) matches JS: the index replacer and array space are ignored", async () => { + expect(await value(`return [{ a: 1 }, [2]].map(JSON.stringify)`)).toEqual(['{"a":1}', "[2]"]) + }) + + test("map(Number.parseInt) reproduces the JS radix footgun", async () => { + // parseInt("2", 1) is NaN in real JS; NaN serializes to null at the result boundary. + expect(await value(`return ["1", "2"].map(Number.parseInt)`)).toEqual([1, null]) + }) + + test("filter and find accept built-in predicates", async () => { + expect(await value(`return [0, 1, NaN, 2].filter(Number.isInteger)`)).toEqual([0, 1, 2]) + expect(await value(`return [1.5, 3, 2.5].find(Number.isInteger)`)).toBe(3) + }) + + test("forEach(console.log) captures one log line per element", async () => { + const logs = await logsOf(`["a", "b"].forEach(console.log); return null`) + expect(logs).toHaveLength(2) + expect(logs[0]).toContain("a") + expect(logs[1]).toContain("b") + }) + + test("intrinsic method references keep their receiver, unlike detached JS methods", async () => { + expect(await value(`return ["a", "z"].filter("abc".includes)`)).toEqual(["a"]) + }) + + test("promise reactions accept built-in references", async () => { + expect(await value(`return await Promise.resolve(-5).then(Math.abs)`)).toBe(5) + const logs = await logsOf(`await Promise.resolve("done").then(console.log); return null`) + expect(logs).toHaveLength(1) + expect(logs[0]).toContain("done") + }) +}) + +describe("constructors callable without new, like JS", () => { + test("Error constructors work as callbacks and direct calls", async () => { + expect(await value(`return ["boom"].map(Error)[0].message`)).toBe("boom") + expect(await value(`return TypeError("bad").name`)).toBe("TypeError") + }) + + test("error values stringify like JS Error.prototype.toString", async () => { + expect(await value(`return String(TypeError("bad"))`)).toBe("TypeError: bad") + expect(await value(`return String(Error(""))`)).toBe("Error") + expect(await value(`return "x" + RangeError("oops")`)).toBe("xRangeError: oops") + expect(await value(`return "a1b2".replace(/\\d/, Error)`)).toBe("aError: 1b2") + }) + + test("literal elisions are real holes, like JS", async () => { + expect(await value(`return (0 in [, 1])`)).toBe(false) + expect(await value(`return Object.keys([, 1, ,])`)).toEqual(["1"]) + expect(await value(`return [, 1, ,].filter(() => true).length`)).toBe(1) + expect(await value(`return [, ,].every((x) => false)`)).toBe(true) + }) + + test("Array constructs from arguments or a length", async () => { + expect(await value(`return Array(1, 2, 3)`)).toEqual([1, 2, 3]) + expect(await value(`return Array("3")`)).toEqual(["3"]) + expect(await value(`return Array(3).length`)).toBe(3) + expect(await value(`return new Array(2).length`)).toBe(2) + // Holes stay holes, like JS: map skips them (length preserved, normalized to + // null at the host boundary), spread materializes undefined. + expect(await value(`return Array(3).map((x) => 1)`)).toEqual([null, null, null]) + expect(await value(`return Array(3).map((x) => 1).length`)).toBe(3) + expect(await value(`return [...Array(3)].map((_, i) => i)`)).toEqual([0, 1, 2]) + expect((await error(`return Array(-1)`)).message).toContain("Invalid array length") + expect((await error(`return Array(1.5)`)).message).toContain("Invalid array length") + }) + + test("Object returns objects unchanged and rejects primitive wrappers", async () => { + expect(await value(`return Object()`)).toEqual({}) + expect(await value(`const o = { a: 1 }; return Object(o) === o`)).toBe(true) + expect((await error(`return Object(1)`)).message).toContain("wrapper objects are not supported") + }) + + test("Date() without new returns a deterministic ISO string and ignores arguments", async () => { + expect(await value(`return /^\\d{4}-\\d{2}-\\d{2}T.*Z$/.test(Date(1000))`)).toBe(true) + expect(await value(`return "abc".replace(RegExp("b"), "x")`)).toBe("axc") + }) + + test("map(Array) matches the JS 3-argument call", async () => { + expect(await value(`return [7].map(Array)`)).toEqual([[7, 0, [7]]]) + }) + + test("array length boundaries match JS", async () => { + expect(await value(`return Array(4294967295).length`)).toBe(4294967295) + const diagnostic = await error(`return Array(4294967296)`) + expect(diagnostic.message).toContain("Invalid array length") + expect((await error(`try { Array(-1) } catch (e) { throw Error(e.name) }`)).message).toContain("RangeError") + }) + + test("sort densifies trailing holes into undefined (documented divergence)", async () => { + expect(await value(`return Array(2).sort().map(() => 1)`)).toEqual([1, 1]) + }) + + test("returned sparse arrays normalize holes to null at the host boundary", async () => { + expect(await value(`return Array(3)`)).toEqual([null, null, null]) + }) + + test("RegExp with non-string flags throws a SyntaxError, like JS", async () => { + expect((await error(`try { RegExp("a", 0) } catch (e) { throw Error(e.name) }`)).message).toContain("SyntaxError") + }) + + test("new-requiring constructors throw a TypeError when called", async () => { + expect((await error(`return Map()`)).message).toContain("Constructor Map requires 'new'") + expect((await error(`return [1].map(Set)`)).message).toContain("Constructor Set requires 'new'") + expect((await error(`return Promise(() => 1)`)).message).toContain("Constructor Promise requires 'new'") + // As a reaction handler the TypeError rejects the derived promise catchably, like JS. + expect(await value(`return await Promise.resolve(1).then(Map).catch((e) => e.name)`)).toBe("TypeError") + }) +}) + +describe("sort accepts the unified callback set", () => { + test("sort and toSorted take built-in comparators", async () => { + expect(await value(`return [0, 1, 0].sort(Boolean)`)).toEqual([0, 0, 1]) + expect(await value(`return [0, 1, 0].toSorted(Boolean)`)).toEqual([0, 0, 1]) + }) + + test("a non-callable comparator is rejected", async () => { + expect((await error(`return [2, 1].sort(42)`)).message).toContain("Array.sort expects a function callback") + expect((await error(`return [2, 1].toSorted(42)`)).message).toContain("Array.toSorted expects a function callback") + }) +}) + +describe("Array.from mapper", () => { + test("maps with (value, index) over arrays, strings, and Sets", async () => { + expect(await value(`return Array.from([1, 2, 3], (x) => x * 2)`)).toEqual([2, 4, 6]) + expect(await value(`return Array.from("ab", (c, i) => c + i)`)).toEqual(["a0", "b1"]) + expect(await value(`return Array.from(new Set([1, 2]), (x) => x * 10)`)).toEqual([10, 20]) + }) + + test("accepts coercion builtins and an explicit undefined mapper", async () => { + expect(await value(`return Array.from(["5", "7"], Number)`)).toEqual([5, 7]) + expect(await value(`return Array.from([1, 2], undefined)`)).toEqual([1, 2]) + }) + + test("rejects a non-callable mapper", async () => { + expect((await error(`return Array.from([1], 42)`)).message).toContain("Array.from expects a function callback") + }) +}) + +describe("thisArg is accepted and ignored, like JS arrows", () => { + // CodeMode functions have no `this`, so a thisArg can never change behavior — + // exactly like passing one alongside an arrow function in real JS. + test("iteration methods and Array.from ignore a thisArg", async () => { + expect(await value(`return [1, 2].map((x) => x * 2, {})`)).toEqual([2, 4]) + expect(await value(`return [1, 2].map((x) => x, undefined)`)).toEqual([1, 2]) + expect(await value(`return Array.from([1], (x) => x + 1, {})`)).toEqual([2]) + }) + + test("Map, Set, and URLSearchParams forEach ignore a thisArg", async () => { + expect(await value(`const o = []; new Map([["a", 1]]).forEach((v, k) => o.push(k), {}); return o`)).toEqual(["a"]) + expect(await value(`const o = []; new Set([1]).forEach((v) => o.push(v), "self"); return o`)).toEqual([1]) + expect(await value(`const o = []; new URLSearchParams("a=1").forEach((v) => o.push(v), 0); return o`)).toEqual([ + "1", + ]) + }) +}) + +describe("still-rejected callables get the wrap hint", () => { + test("tool references as callbacks suggest an arrow wrapper", async () => { + const diagnostic = await toolError(`return [1, 2].map(tools.host.echo)`) + expect(diagnostic.message).toContain("wrap it in an arrow function") + expect(await withTool(`return await Promise.all([1, 2].map((id) => tools.host.echo({ id })))`)).toMatchObject({ + ok: true, + value: [1, 2], + }) + }) + + test("detached Promise statics as callbacks suggest an arrow wrapper", async () => { + expect((await error(`return [1].map(Promise.resolve)`)).message).toContain("wrap it in an arrow function") + }) + + test("string replacers reject opaque callables with the wrap hint, not a type error", async () => { + const diagnostic = await toolError(`return "abc".replace(/b/, tools.host.echo)`) + expect(diagnostic.message).toContain("wrap it in an arrow function") + expect(diagnostic.message).not.toContain("argument 2") + }) + + test("built-in references work as replacers", async () => { + // Like real JS: JSON.stringify(match, offset, string) quotes the match. + expect(await value(`return "abc".replace(/b/, JSON.stringify)`)).toBe('a"b"c') + // Math methods stay strict about consumed arguments: a match string is not coerced. + expect((await error(`return "3.7".replace(/\\d\\.\\d/, Math.floor)`)).message).toContain( + "Math.floor expects number arguments", + ) + }) + + test("non-callables still get the plain callback error", async () => { + expect((await error(`return [1].map(42)`)).message).toContain("Array.map expects a function callback") + }) + + test("promise handlers reject opaque callables with the wrap hint", async () => { + const diagnostic = await toolError(`return await Promise.resolve(1).then(tools.host.echo)`) + expect(diagnostic.message).toContain("Promise.prototype.then cannot use this callable as a handler") + expect(diagnostic.message).toContain("wrap it in an arrow function") + }) + + test("callable JSON.stringify replacers are rejected, never silently ignored", async () => { + expect((await error(`return JSON.stringify({ a: 1 }, Math.abs)`)).message).toContain( + "JSON.stringify replacers are not supported", + ) + expect((await toolError(`return JSON.stringify({ a: 1 }, tools.host.echo)`)).message).toContain( + "JSON.stringify replacers are not supported", + ) + }) + + test("callable JSON.parse revivers are rejected, never silently ignored", async () => { + expect((await error(`return JSON.parse('{"a":1}', (key, v) => 99)`)).message).toContain( + "JSON.parse revivers are not supported", + ) + expect(await value(`return JSON.parse('{"a":1}', undefined)`)).toEqual({ a: 1 }) + // A non-callable reviver is silently ignored, matching JS's IsCallable check. + expect(await value(`return JSON.parse('{"a":1}', 42)`)).toEqual({ a: 1 }) + }) +}) diff --git a/packages/codemode/test/enumeration.test.ts b/packages/codemode/test/enumeration.test.ts index ca71226a57..116fbab0d9 100644 --- a/packages/codemode/test/enumeration.test.ts +++ b/packages/codemode/test/enumeration.test.ts @@ -3,7 +3,7 @@ import { Effect, Schema } from "effect" import { CodeMode, Tool } from "../src/index.js" // Key enumeration: Object.keys and for...in share one surface over plain objects, arrays -// (index strings), and tool references (namespace/tool names from the host tool tree), so a +// (index strings), and tool references (namespace/tool names from the supplied tools), so a // model can discover what it may call instead of guessing names from the instructions. The // motivating transcript: `Object.keys(tools)` failed with the generic plain-objects-only // message and `for (const key in tools)` was unsupported syntax, forcing blind guesses. @@ -56,11 +56,10 @@ describe("Object.keys over tool references", () => { expect(await value(`return typeof search`)).toBe("function") }) - test("an unknown namespace is an UnknownTool error pointing at the discovery idioms", async () => { + test("an unknown namespace is an UnknownTool error", async () => { const failure = await error(`return Object.keys(tools.nonexistent)`) expect(failure.kind).toBe("UnknownTool") expect(failure.message).toContain("Unknown tool namespace 'nonexistent'") - expect(failure.suggestions?.join(" ")).toContain("Object.keys(tools)") }) test("Object.values/entries on a tool reference explain the working idioms", async () => { @@ -137,7 +136,7 @@ describe("for...in", () => { ).toBe("only") }) - test("enumerates namespaces and tools from the callable tool tree", async () => { + test("enumerates namespaces and tools from the supplied tools", async () => { expect( await value(` const names = [] diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index 2d256393a2..3901ea8836 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -495,6 +495,48 @@ describe("OpenAPI.fromSpec", () => { ) }) + test("preserves ordered exploded and deep-object query parameters", async () => { + const client = recordingClient(() => json({ ok: true })) + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation({ + parameters: [ + { name: "tags", in: "query", style: "form", explode: true, schema: { type: "array" } }, + { name: "filter", in: "query", style: "form", explode: true, schema: { type: "object" } }, + { name: "location", in: "query", style: "deepObject", explode: true, schema: { type: "object" } }, + ], + }), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + await Effect.runPromise( + tool + .run({ + tags: ["first value", "second&value"], + filter: { state: "open now", page: 2 }, + location: { directory: "/tmp/a b", workspace: "work&1" }, + }) + .pipe(Effect.provide(client.layer)), + ) + + expect(client.requests[0]?.url).toBe( + `${baseUrl}/test?tags=first+value&tags=second%26value&state=open+now&page=2&location%5Bdirectory%5D=%2Ftmp%2Fa+b&location%5Bworkspace%5D=work%261`, + ) + await expect( + Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer))), + ).rejects.toThrow("Parameter 'tags' contains an unsupported nested value.") + await expect( + Effect.runPromise(tool.run({ filter: { state: {} } }).pipe(Effect.provide(client.layer))), + ).rejects.toThrow("Query parameter 'filter' contains an unsupported nested value.") + await expect( + Effect.runPromise(tool.run({ location: { directory: [] } }).pipe(Effect.provide(client.layer))), + ).rejects.toThrow("Deep-object parameter 'location' contains an unsupported nested value.") + expect(client.requests).toHaveLength(1) + }) + test("skips unsupported parameter encodings and malformed security", () => { const result = OpenAPI.fromSpec({ baseUrl, diff --git a/packages/codemode/test/promise-test262.test.ts b/packages/codemode/test/promise-test262.test.ts index 2ba3a3aa14..0dc57e7b75 100644 --- a/packages/codemode/test/promise-test262.test.ts +++ b/packages/codemode/test/promise-test262.test.ts @@ -1406,9 +1406,8 @@ describe("Test262 Promise constructor", () => { ).toEqual(["before", "executor", "after"]) }) - test.failing("calling Promise without new throws TypeError", async () => { + test("calling Promise without new throws TypeError", async () => { // Source: test/built-ins/Promise/undefined-newtarget.js - // CodeMode currently reports a generic Error ("Only tools are callable in CodeMode."). expect( await value(` try { diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index e7141a61fc..f5b52eeefd 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -1083,9 +1083,10 @@ describe("promise chaining", () => { expect(result.warnings).toBeUndefined() }) - test("non-plain-function handlers fail loudly instead of being ignored", async () => { + test("unsupported callable handlers fail loudly with a wrap hint", async () => { const diagnostic = await error(`return await tools.host.echo({ id: 1 }).then(tools.host.completed)`) - expect(diagnostic.message).toContain("Promise.prototype.then handlers must be plain functions") + expect(diagnostic.message).toContain("Promise.prototype.then cannot use this callable as a handler") + expect(diagnostic.message).toContain("wrap it in an arrow function") }) test("chaining methods are opaque references until called", async () => { diff --git a/packages/codemode/test/tool-paths.test.ts b/packages/codemode/test/tool-paths.test.ts new file mode 100644 index 0000000000..c92739c5df --- /dev/null +++ b/packages/codemode/test/tool-paths.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { CodeMode, Tool } from "../src/index.js" + +const echo = (description: string, result: string) => + Tool.make({ + description, + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.succeed(result), + }) + +const value = async (runtime: CodeMode.Runtime, code: string) => { + const result = await Effect.runPromise(runtime.execute(code)) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} + +const failure = async (runtime: CodeMode.Runtime, code: string) => { + const result = await Effect.runPromise(runtime.execute(code)) + if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) + return result.error +} + +describe("dotted tool names", () => { + const runtime = CodeMode.make({ tools: { api: { "issues.list": echo("List issues", "listed") } } }) + + test("a dotted name becomes nested namespaces in the catalog", () => { + const catalog = runtime.catalog() + expect(catalog).toHaveLength(1) + expect(catalog[0]?.path).toBe("api.issues.list") + expect(catalog[0]?.signature).toStartWith("tools.api.issues.list(input:") + expect(runtime.instructions()).toContain("tools.api.issues.list(input:") + }) + + test("the advertised dotted path is executable", async () => { + expect(await value(runtime, `return await tools.api.issues.list({})`)).toBe("listed") + }) + + test("bracket access with a dotted segment spells the same canonical path", async () => { + expect(await value(runtime, `return await tools.api["issues.list"]({})`)).toBe("listed") + expect(await value(runtime, `return await tools["api.issues"].list({})`)).toBe("listed") + }) + + test("intermediate segments enumerate like ordinary namespaces", async () => { + expect(await value(runtime, `return [Object.keys(tools.api), Object.keys(tools.api.issues)]`)).toEqual([ + ["issues"], + ["list"], + ]) + expect(await value(runtime, `return Object.keys(tools["api.issues"])`)).toEqual(["list"]) + }) + + test("a top-level dotted name nests from the root", async () => { + const flat = CodeMode.make({ tools: { "issues.list": echo("List issues", "flat") } }) + expect(flat.catalog()[0]?.path).toBe("issues.list") + expect(await value(flat, `return await tools.issues.list({})`)).toBe("flat") + }) +}) + +describe("callable namespaces", () => { + const runtime = CodeMode.make({ + tools: { issues: echo("All issues", "all"), "issues.list": echo("List issues", "list") }, + }) + + test("a path can hold a tool and child tools at once", async () => { + expect(await value(runtime, `return await tools.issues({})`)).toBe("all") + expect(await value(runtime, `return await tools.issues.list({})`)).toBe("list") + expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues", "issues.list"]) + }) + + test("a callable namespace enumerates its children", async () => { + expect(await value(runtime, `return Object.keys(tools.issues)`)).toEqual(["list"]) + }) + + test("search returns executable paths for both", async () => { + const result = await value(runtime, `return search({ query: "", namespace: "issues" })`) + expect((result as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([ + "tools.issues", + "tools.issues.list", + ]) + const exact = await value(runtime, `return search({ query: "tools.issues.list" })`) + expect((exact as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual(["tools.issues.list"]) + }) + + test("an unknown child under a callable tool is an UnknownTool error", async () => { + const diagnostic = await failure(runtime, `return await tools.issues.missing({})`) + expect(diagnostic.kind).toBe("UnknownTool") + expect(diagnostic.message).toContain("Unknown tool 'issues.missing'") + }) + + test("a namespace without its own definition stays non-callable", async () => { + const nested = CodeMode.make({ tools: { "issues.list": echo("List issues", "list") } }) + const diagnostic = await failure(nested, `return await tools.issues({})`) + expect(diagnostic.kind).toBe("UnknownTool") + expect(diagnostic.message).toContain("Tool 'issues' is not callable") + }) +}) + +describe("blocked member names on tool paths", () => { + const runtime = CodeMode.make({ + tools: { + prototype: echo("Prototype tool", "proto"), + "issues.constructor": echo("Constructor tool", "ctor"), + nested: { ["__proto__"]: echo("Proto tool", "dunder") }, + }, + }) + + test("tools may use blocked member names because path segments never touch real properties", async () => { + expect(runtime.catalog().map((tool) => tool.path)).toEqual(["prototype", "issues.constructor", "nested.__proto__"]) + expect(await value(runtime, `return await tools.prototype({})`)).toBe("proto") + expect(await value(runtime, `return await tools.issues.constructor({})`)).toBe("ctor") + expect(await value(runtime, `return await tools["issues.constructor"]({})`)).toBe("ctor") + expect(await value(runtime, `return await tools.nested.__proto__({})`)).toBe("dunder") + expect(await value(runtime, `return Object.keys(tools.issues)`)).toEqual(["constructor"]) + }) + + test("a literal __proto__ key cannot poison a namespace into a fake definition", async () => { + const poisoned = CodeMode.make({ + tools: { ns: { "__proto__": echo("Hidden", "hidden"), real: echo("Real tool", "real") } }, + }) + expect(poisoned.catalog().map((tool) => tool.path)).toEqual(["ns.real"]) + expect(await value(poisoned, `return await tools.ns.real({})`)).toBe("real") + }) + + test("blocked member access on data values stays blocked", async () => { + const diagnostic = await failure(runtime, `const x = {}; return x.constructor`) + expect(diagnostic.message).toContain("constructor") + expect(Object.keys(Object.prototype)).toEqual([]) + }) +}) + +describe("empty segments", () => { + test("tool names with empty segments are rejected at make", () => { + for (const name of ["", "a..b", "trail.", ".lead"]) { + expect(() => CodeMode.make({ tools: { [name]: echo("Bad", "bad") } })).toThrow("empty segment") + } + }) +}) + +describe("canonical path collisions", () => { + test("the last definition supplied for a canonical path wins", async () => { + const runtime = CodeMode.make({ + tools: { "issues.list": echo("First", "first"), issues: { list: echo("Second", "second") } }, + }) + expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second") + expect(runtime.catalog()).toHaveLength(1) + expect(runtime.catalog()[0]?.description).toBe("Second") + }) + + test("overriding one path keeps sibling tools from both shapes", async () => { + const runtime = CodeMode.make({ + tools: { + "issues.list": echo("First list", "first"), + issues: { list: echo("Second list", "second"), get: echo("Get issue", "got") }, + "issues.close": echo("Close issue", "closed"), + }, + }) + // Catalog order follows first appearance of each canonical path. + expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.list", "issues.get", "issues.close"]) + expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second") + expect(await value(runtime, `return await tools.issues.get({})`)).toBe("got") + expect(await value(runtime, `return await tools.issues.close({})`)).toBe("closed") + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json index 374eb40f4d..cf6a25774d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -93,7 +93,7 @@ "@opencode-ai/codemode": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", - "@opencode-ai/llm": "workspace:*", + "@opencode-ai/ai": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opentelemetry/api": "1.9.0", diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 3330c77f4a..4ecda81b9e 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -28,8 +28,8 @@ import { type LLMRequest, type ToolDefinition, type UsageInput, -} from "@opencode-ai/llm" -import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/llm/route" +} from "@opencode-ai/ai" +import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route" import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect" import { ModelV2 } from "./model" import { ProviderV2 } from "./provider" @@ -103,11 +103,12 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) { } function prepareOptions(model: ModelV2.Info, pkg: string) { + const projected = mapBodyToProviderOptions(model, pkg) const options: Record = { name: model.providerID, ...(model.settings ?? {}), headers: model.headers, - body: model.body, + body: projected.body, } const customFetch = options.fetch @@ -264,7 +265,7 @@ export const locationLayer = Layer.effect( cause: new Error(`Unsupported package ${model.package}`), }) - const packageName = ProviderV2.packageName(model.package) ?? "" + const packageName = ProviderV2.packageName(model.package) const options = prepareOptions(model, packageName) const sdkKey = cacheKey({ providerID: model.providerID, @@ -300,10 +301,17 @@ export const locationLayer = Layer.effect( export const defaultLayer = locationLayer function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) { - const settings = requestSettings(info.settings) - const optionKey = providerOptionKey(ProviderV2.packageName(info.package), info.providerID) + const packageName = ProviderV2.packageName(info.package!) + const projected = mapBodyToProviderOptions(info, packageName) + const optionKey = providerOptionKey(packageName, info.providerID) + const providerOptions = (() => { + if (projected.settings === undefined) return + if (packageName === "@ai-sdk/gateway") return gatewayProviderOptions(info.modelID ?? info.id, projected.settings) + if (packageName === "@ai-sdk/azure") return { openai: projected.settings, azure: projected.settings } + return { [optionKey]: projected.settings } + })() const route: AnyRoute = { - id: `ai-sdk:${ProviderV2.packageName(info.package) ?? "unknown"}`, + id: `ai-sdk:${packageName}`, provider: ProviderID.make(info.providerID), providerMetadataKey: optionKey, protocol: "ai-sdk", @@ -317,11 +325,14 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) { defaults: { headers: info.headers, http: - info.body === undefined && info.headers === undefined + projected.body === undefined && info.headers === undefined ? undefined - : { body: info.body === undefined ? undefined : { ...info.body }, headers: info.headers }, + : { + body: projected.body === undefined ? undefined : { ...projected.body }, + headers: info.headers, + }, limits: { context: info.limit.context, output: info.limit.output }, - providerOptions: settings === undefined ? undefined : { [optionKey]: settings }, + providerOptions, }, body: { schema: Schema.Unknown, @@ -335,13 +346,35 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) { return Model.make({ id: info.modelID ?? info.id, provider: info.providerID, route }) } +function gatewayProviderOptions(modelID: ModelV2.ID, settings: Readonly>) { + const gateway = + typeof settings.gateway === "object" && settings.gateway !== null && !Array.isArray(settings.gateway) + ? Object.fromEntries(Object.entries(settings.gateway)) + : undefined + const model = Object.fromEntries(Object.entries(settings).filter(([key]) => key !== "gateway")) + if (Object.keys(model).length === 0) return gateway === undefined ? undefined : { gateway } + + const separator = modelID.indexOf("/") + const prefix = separator > 0 ? modelID.slice(0, separator) : undefined + if (prefix) + return { ...(gateway === undefined ? {} : { gateway }), [prefix === "amazon" ? "bedrock" : prefix]: model } + if (typeof gateway === "object" && gateway !== null && !Array.isArray(gateway)) + return { gateway: { ...gateway, ...model } } + return { gateway: model } +} + function providerOptionKey(packageName: string | undefined, providerID: ProviderV2.ID) { if (packageName === "@ai-sdk/google") return "google" if (packageName === "@ai-sdk/google-vertex") return "vertex" if (packageName === "@ai-sdk/google-vertex/anthropic") return "anthropic" - if (packageName === "@ai-sdk/amazon-bedrock" || packageName === "@ai-sdk/amazon-bedrock/mantle") return "bedrock" + if (packageName === "@ai-sdk/amazon-bedrock") return "bedrock" + if (packageName === "@ai-sdk/amazon-bedrock/mantle") return "openai" if (packageName === "@ai-sdk/azure") return "azure" + if (packageName === "@ai-sdk/github-copilot") return "copilot" + if (packageName === "@jerome-benoit/sap-ai-provider-v2") return "sap-ai" + if (packageName === "@ai-sdk/openai-compatible") return providerID.split(".")[0] if (packageName === "@openrouter/ai-sdk-provider") return "openrouter" + if (packageName === "ai-gateway-provider") return "openaiCompatible" if (packageName?.startsWith("@ai-sdk/")) return packageName.slice("@ai-sdk/".length) return providerID } @@ -356,6 +389,22 @@ function requestSettings(settings: Readonly> | undefined return Object.keys(result).length === 0 ? undefined : result } +function mapBodyToProviderOptions(model: ModelV2.Info, packageName: string) { + const settings = requestSettings(model.settings) + const pro = Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning) + const forceReasoning = + ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle"].includes(packageName) && + (pro || settings?.reasoningEffort !== undefined || settings?.reasoningSummary !== undefined) + const normalized = forceReasoning ? ProviderV2.mergeOverlay(settings, { forceReasoning: true }) : settings + if (!pro) return { settings: normalized, body: model.body } + const body = { ...model.body } + delete body.reasoning + return { + settings: ProviderV2.mergeOverlay(normalized, { reasoningMode: "pro" }), + body: Object.keys(body).length === 0 ? undefined : body, + } +} + function callOptions(request: LLMRequest): LanguageModelV3CallOptions { return { prompt: prompt(request), diff --git a/packages/core/src/control-plane/move-session.ts b/packages/core/src/control-plane/move-session.ts index 84d135d3f9..1fadfea1e2 100644 --- a/packages/core/src/control-plane/move-session.ts +++ b/packages/core/src/control-plane/move-session.ts @@ -2,16 +2,15 @@ export * as MoveSession from "./move-session" import { Context, DateTime, Effect, Layer, Schema } from "effect" import { makeGlobalNode } from "../effect/app-node" -import { EventV2 } from "../event" +import { FSUtil } from "../fs-util" import { Git } from "../git" -import { Location } from "../location" +import { Global } from "../global" import { ProjectV2 } from "../project" import { SessionV2 } from "../session" -import { SessionEvent } from "../session/event" import { SessionExecution } from "../session/execution" import { SessionSchema } from "../session/schema" import { SessionStore } from "../session/store" -import { AbsolutePath, RelativePath } from "../schema" +import { AbsolutePath } from "../schema" import path from "path" export const Destination = Schema.Struct({ @@ -34,6 +33,16 @@ export class DestinationProjectMismatchError extends Schema.TaggedErrorClass()( + "MoveSession.DestinationNotFoundError", + { directory: AbsolutePath }, +) {} + +export class DestinationNotDirectoryError extends Schema.TaggedErrorClass()( + "MoveSession.DestinationNotDirectoryError", + { directory: AbsolutePath }, +) {} + export class ApplyChangesError extends Schema.TaggedErrorClass()("MoveSession.ApplyChangesError", { message: Schema.String, }) {} @@ -57,6 +66,10 @@ export class ResetSourceChangesError extends Schema.TaggedErrorClass Effect.succeed(undefined))) + if (!destinationInfo) return yield* new DestinationNotFoundError({ directory }) + if (destinationInfo.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory }) if (current.location.directory === directory) return const source = yield* project.resolve(current.location.directory) const destination = yield* project.resolve(directory) - if (current.projectID !== destination.id) { + if (input.moveChanges && current.projectID !== destination.id) { return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id }) } - // A move must not race active execution: a mid-drain relocation would let // the source Location dispatch a request assembled under stale instructions // and history. Serialize like removal does — stop the drain, then move. @@ -111,10 +130,9 @@ const layer = Layer.effect( .pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message }))) } - yield* events.publish(SessionEvent.Moved, { + yield* session.move({ sessionID: input.sessionID, - location: Location.Ref.make({ directory }), - subpath: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")), + directory, }) if (patch) { @@ -151,5 +169,13 @@ const layer = Layer.effect( export const node = makeGlobalNode({ service: Service, layer, - deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node, SessionExecution.node], + deps: [ + FSUtil.node, + Git.node, + Global.node, + ProjectV2.node, + SessionV2.node, + SessionStore.node, + SessionExecution.node, + ], }) diff --git a/packages/core/src/effect/app-node-platform.ts b/packages/core/src/effect/app-node-platform.ts index adba0eb9cc..23b221d178 100644 --- a/packages/core/src/effect/app-node-platform.ts +++ b/packages/core/src/effect/app-node-platform.ts @@ -1,5 +1,5 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node" -import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" +import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route" import { FileSystem, Path } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { HttpClient } from "effect/unstable/http" diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 8470134ff7..f1dafd5c55 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -31,6 +31,8 @@ export type ListInput = typeof ListInput.Type export { FindInput } +export const DEFAULT_SEARCH_LIMIT = 100 + export class GlobInput extends Schema.Class("FileSystem.GlobInput")({ pattern: Schema.String, path: RelativePath.pipe(Schema.optional), diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 6130fddf10..a2b21d32cd 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -56,7 +56,7 @@ export const ripgrepLayer = Layer.effect( .glob({ cwd, pattern: input.pattern, - limit: input.limit ?? Number.MAX_SAFE_INTEGER, + limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, }) .pipe( Effect.map((result) => @@ -81,7 +81,7 @@ export const ripgrepLayer = Layer.effect( pattern: input.pattern, file: info.type === "File" ? path.basename(target) : undefined, include: input.include, - limit: input.limit ?? Number.MAX_SAFE_INTEGER, + limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, }) .pipe( Effect.map((result) => @@ -150,7 +150,7 @@ export const fffLayer = Layer.effect( const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "") const found = result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, { pageIndex: 0, - pageSize: input.limit, + pageSize: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, }) if (!found.ok) throw found.error return found.value.items.map((item) => @@ -167,7 +167,7 @@ export const fffLayer = Layer.effect( [prefix ? `${prefix}/**` : undefined, input.include, input.pattern] .filter((value) => value !== undefined) .join(" "), - { mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 }, + { mode: "regex", pageSize: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, timeBudgetMs: 1_500 }, ) if (!found.ok) throw found.error return found.value.items.map((match) => { diff --git a/packages/core/src/generate.ts b/packages/core/src/generate.ts index 81c2a0f495..3dbc86dc62 100644 --- a/packages/core/src/generate.ts +++ b/packages/core/src/generate.ts @@ -1,6 +1,6 @@ export * as Generate from "./generate" -import { LLM, LLMClient, LLMError } from "@opencode-ai/llm" +import { LLM, LLMClient, LLMError } from "@opencode-ai/ai" import { Context, Effect, Layer, Schema } from "effect" import { Catalog } from "./catalog" import { makeLocationNode } from "./effect/app-node" diff --git a/packages/core/src/instructions/builtins.ts b/packages/core/src/instructions/builtins.ts index b116971834..f8ce9b5535 100644 --- a/packages/core/src/instructions/builtins.ts +++ b/packages/core/src/instructions/builtins.ts @@ -3,10 +3,11 @@ export * as InstructionBuiltIns from "./builtins" import { makeLocationNode } from "../effect/app-node" import { Context, DateTime, Effect, Layer, Schema } from "effect" import { Location } from "../location" +import { SessionSchema } from "../session/schema" import { Instructions } from "./index" export interface Interface { - readonly load: () => Effect.Effect + readonly load: (sessionID: SessionSchema.ID) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/InstructionBuiltIns") {} @@ -15,38 +16,45 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const location = yield* Location.Service - const instructions = Instructions.combine([ - Instructions.make({ - key: Instructions.Key.make("core/environment"), - codec: Schema.toCodecJson(Schema.String), - read: Effect.sync(() => - [ - "", - ` Working directory: ${location.directory}`, - ` Workspace root folder: ${location.project.directory}`, - ` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`, - ` Platform: ${process.platform}`, - "", - ].join("\n"), + return Service.of({ + load: (sessionID) => + Effect.succeed( + Instructions.combine([ + Instructions.make({ + key: Instructions.Key.make("core/environment"), + codec: Schema.toCodecJson(Schema.String), + read: Effect.sync(() => + [ + "", + ` Session ID: ${sessionID}`, + ` Working directory: ${location.directory}`, + ` Workspace root folder: ${location.project.directory}`, + ` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`, + ` Platform: ${process.platform}`, + "", + ].join("\n"), + ), + render: { + initial: (environment) => + ["Here is some useful information about the environment you are running in:", environment].join( + "\n", + ), + changed: (_previous, environment) => + ["The environment you are running in is now:", environment].join("\n"), + }, + }), + Instructions.make({ + key: Instructions.Key.make("core/date"), + codec: Schema.toCodecJson(Schema.String), + read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())), + render: { + initial: (date) => `Today's date: ${date}`, + changed: (_previous, date) => `Today's date is now: ${date}`, + }, + }), + ]), ), - render: { - initial: (environment) => - ["Here is some useful information about the environment you are running in:", environment].join("\n"), - changed: (_previous, environment) => ["The environment you are running in is now:", environment].join("\n"), - }, - }), - Instructions.make({ - key: Instructions.Key.make("core/date"), - codec: Schema.toCodecJson(Schema.String), - read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())), - render: { - initial: (date) => `Today's date: ${date}`, - changed: (_previous, date) => `Today's date is now: ${date}`, - }, - }), - ]) - - return Service.of({ load: () => Effect.succeed(instructions) }) + }) }), ) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 9cbc3fa73c..0caf414cce 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -56,7 +56,10 @@ type SourceModel = { string, { readonly cost?: Cost - readonly provider?: { readonly body?: ProviderV2.Settings; readonly headers?: Readonly> } + readonly provider?: { + readonly body?: ProviderV2.Settings + readonly headers?: Readonly> + } } > > @@ -70,7 +73,7 @@ type SourceProvider = { readonly name: string readonly env: readonly string[] readonly id: string - readonly npm?: string + readonly npm: string readonly models: Readonly> } @@ -87,7 +90,7 @@ function normalize(input: Record): readonly Snapshot[] { const info = { id: providerID, name: item.name, - package: item.npm ? ProviderV2.aisdk(item.npm) : "", + package: ProviderV2.aisdk(item.npm), ...(item.api ? { settings: { baseURL: item.api } } : {}), } satisfies ProviderV2.Info const models: ModelV2.Info[] = [] @@ -185,61 +188,294 @@ function mergeCost(base: ModelV2.Info["cost"], override: SourceModel["cost"] | u } const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] +const OUTPUT_TOKEN_MAX = 32_000 function reasoningVariants(provider: SourceProvider, model: SourceModel): NonNullable { const npm = model.provider?.npm ?? provider.npm - const options = model.reasoning_options ?? [] + const options = model.reasoning_options + if (!options?.length) return [] + const toggle = options.some((option) => option.type === "toggle") const effort = options.find((option) => option.type === "effort") if (effort?.type === "effort") { - return effort.values.flatMap((value) => { - const raw: unknown = value - const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined - if (id === undefined) return [] - const settings = settingsForEffort(npm, id) - return settings ? [{ id: ModelV2.VariantID.make(id), settings }] : [] - }) + const off = toggle ? toggleVariants(npm, model.id).filter((variant) => variant.id === "none") : [] + const variants = [ + ...off, + ...effort.values.flatMap((value) => { + const raw: unknown = value + const id = typeof raw === "string" && raw !== "null" ? raw : undefined + if (id === undefined) return [] + if (id === "none" && off.length > 0) return [] + const settings = settingsForEffort(npm, model.id, id) + return settings ? [{ id: ModelV2.VariantID.make(id), settings }] : [] + }), + ] + return [...new Map(variants.map((variant) => [variant.id, variant])).values()] } const budget = options.find((option) => option.type === "budget_tokens") - if (budget?.type === "budget_tokens") return budgetVariants(npm, budget) + if (budget?.type === "budget_tokens") + return [ + ...(toggle ? toggleVariants(npm, model.id).filter((variant) => variant.id === "none") : []), + ...budgetVariants(npm, model, budget), + ] + if (toggle) return toggleVariants(npm, model.id) return [] } -function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.Settings | undefined { +function settingsForEffort(npm: string, modelID: string, effort: string): ProviderV2.Settings | undefined { if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { effort } } - if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") - return { thinking: { type: "adaptive", display: "summarized" }, effort } + if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { + if (anthropicManualThinking(modelID)) return { effort } + return { + thinking: { type: "adaptive", display: "summarized" }, + effort, + } + } if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } - if (npm === "@ai-sdk/azure") return { reasoningEffort: effort } - if (npm === "@ai-sdk/openai") + if (npm === "@ai-sdk/amazon-bedrock") { + if (modelID.includes("anthropic")) + return { + reasoningConfig: { + ...(anthropicManualThinking(modelID) ? {} : { type: "adaptive", display: "summarized" }), + maxReasoningEffort: effort, + }, + } + return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } } + } + if (npm === "@ai-sdk/gateway") { + const upstream = gatewayPackage(modelID) + if (upstream) return settingsForEffort(upstream, modelID, effort) + return { reasoningEffort: effort } + } + if (npm === "@ai-sdk/github-copilot") { + if (modelID.includes("gemini")) return + if (modelID.includes("claude")) return { reasoningEffort: effort } return { reasoningEffort: effort, reasoningSummary: "auto", include: OPENAI_INCLUDE_ENCRYPTED_REASONING } - if (npm === "@ai-sdk/openai-compatible") return { reasoningEffort: effort } + } + if (npm === "@ai-sdk/openai" || npm === "@ai-sdk/amazon-bedrock/mantle" || npm === "@ai-sdk/azure") + return { reasoningEffort: effort, reasoningSummary: "auto", include: OPENAI_INCLUDE_ENCRYPTED_REASONING } + if (npm === "@jerome-benoit/sap-ai-provider-v2") { + if (modelID.includes("anthropic")) + return { + modelParams: { + additionalModelRequestFields: { + ...(anthropicManualThinking(modelID) ? {} : { thinking: { type: "adaptive", display: "summarized" } }), + output_config: { effort }, + }, + }, + } + if (modelID.includes("gemini")) + return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } } + if (modelID.includes("amazon--nova")) + return { modelParams: { additionalModelRequestFields: { output_config: { effort } } } } + return { modelParams: { reasoning_effort: effort } } + } + if ( + [ + "@ai-sdk/openai-compatible", + "@ai-sdk/xai", + "@ai-sdk/mistral", + "@ai-sdk/groq", + "@ai-sdk/cerebras", + "@ai-sdk/deepinfra", + "@ai-sdk/togetherai", + "venice-ai-sdk-provider", + "ai-gateway-provider", + ].includes(npm) + ) + return { reasoningEffort: effort } } function budgetVariants( - npm: string | undefined, + npm: string, + model: SourceModel, option: Extract[number], { type: "budget_tokens" }>, ): NonNullable { - const max = option.max - const high = - option.max === undefined - ? Math.max(option.min ?? 0, 16_000) - : Math.min(Math.max(option.min ?? 0, 16_000), option.max) + const maximum = Math.min(option.max ?? OUTPUT_TOKEN_MAX - 1, model.limit.output - 1, OUTPUT_TOKEN_MAX - 1) + if (maximum <= 0) return [] + const high = Math.min(Math.max(option.min ?? 0, Math.floor((maximum + 1) / 2)), maximum) return [ { id: "high", budget: high }, - ...(max === undefined || max === high ? [] : [{ id: "max", budget: max }]), + { id: "max", budget: maximum }, ].flatMap((item) => { - const settings = settingsForBudget(npm, item.budget) + const settings = settingsForBudget(npm, model.id, item.budget) return settings ? [{ id: ModelV2.VariantID.make(item.id), settings }] : [] }) } -function settingsForBudget(npm: string | undefined, budget: number): ProviderV2.Settings | undefined { +function toggleVariants(npm: string, modelID: string): NonNullable { + if (npm === "@ai-sdk/gateway") { + const upstream = gatewayPackage(modelID) + if (upstream) return toggleVariants(upstream, modelID) + return [ + { + id: ModelV2.VariantID.make("none"), + settings: { reasoning: { enabled: false } }, + }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { reasoning: { enabled: true } }, + }, + ] + } + if (npm === "@openrouter/ai-sdk-provider") + return [ + { id: ModelV2.VariantID.make("none"), settings: { reasoning: { enabled: false } } }, + { id: ModelV2.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } }, + ] + if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") + return [ + { id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { + thinking: { type: "adaptive", display: "summarized" }, + }, + }, + ] + if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") + return [ + { + id: ModelV2.VariantID.make("none"), + settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } }, + }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } }, + }, + ] + if (npm === "@ai-sdk/amazon-bedrock") { + const anthropic = modelID.includes("anthropic") + return [ + { + id: ModelV2.VariantID.make("none"), + settings: { + additionalModelRequestFields: anthropic + ? { thinking: { type: "disabled" } } + : { reasoningConfig: { type: "disabled" } }, + }, + }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { + additionalModelRequestFields: anthropic + ? { thinking: { type: "adaptive", display: "summarized" } } + : { reasoningConfig: { type: "enabled" } }, + }, + }, + ] + } + if (npm === "@ai-sdk/alibaba") + return [ + { id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } }, + { id: ModelV2.VariantID.make("thinking"), settings: { enableThinking: true } }, + ] + if (npm === "@ai-sdk/cohere") + return [ + { id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } }, + { id: ModelV2.VariantID.make("thinking"), settings: { thinking: { type: "enabled" } } }, + ] + if (npm === "@jerome-benoit/sap-ai-provider-v2") { + if (modelID.includes("gemini")) + return [ + { + id: ModelV2.VariantID.make("none"), + settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } }, + }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } } }, + }, + ] + if (modelID.includes("cohere")) + return [ + { + id: ModelV2.VariantID.make("none"), + settings: { modelParams: { thinking: { type: "disabled" } } }, + }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { modelParams: { thinking: { type: "enabled" } } }, + }, + ] + if (modelID.includes("amazon--nova")) + return [ + { + id: ModelV2.VariantID.make("none"), + settings: { modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } } }, + }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { modelParams: { additionalModelRequestFields: { thinking: { type: "enabled" } } } }, + }, + ] + if (modelID.includes("anthropic")) + return [ + { + id: ModelV2.VariantID.make("none"), + settings: { + modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } }, + }, + }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { + modelParams: { + additionalModelRequestFields: { + thinking: { type: "adaptive", display: "summarized" }, + }, + }, + }, + }, + ] + } + return [] +} + +function settingsForBudget(npm: string, modelID: string, budget: number): ProviderV2.Settings | undefined { if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { max_tokens: budget } } if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") return { thinking: { type: "enabled", budgetTokens: budget } } if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } + if (npm === "@ai-sdk/amazon-bedrock") return { reasoningConfig: { type: "enabled", budgetTokens: budget } } + if (npm === "@ai-sdk/gateway") { + const upstream = gatewayPackage(modelID) + return upstream ? settingsForBudget(upstream, modelID, budget) : { reasoning: { max_tokens: budget } } + } + if (npm === "@ai-sdk/cohere") return { thinking: { type: "enabled", tokenBudget: budget } } + if (npm === "@ai-sdk/alibaba") return { enableThinking: true, thinkingBudget: budget } + if (npm === "@jerome-benoit/sap-ai-provider-v2") { + if (modelID.includes("anthropic")) + return { + modelParams: { + additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: budget } }, + }, + } + if (modelID.includes("gemini")) + return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } } + if (modelID.includes("cohere")) return { modelParams: { thinking: { type: "enabled", token_budget: budget } } } + } +} + +function gatewayPackage(modelID: string) { + const separator = modelID.indexOf("/") + if (separator <= 0) return + const prefix = modelID.slice(0, separator) + if (prefix === "anthropic") return "@ai-sdk/anthropic" + if (prefix === "google") return "@ai-sdk/google" + if (prefix === "amazon") return "@ai-sdk/amazon-bedrock" + if (prefix === "alibaba") return "@ai-sdk/alibaba" +} + +function anthropicManualThinking(modelID: string) { + const familyFirst = /(?:claude-)?(?:opus|sonnet|haiku)-(\d+)(?:[.-](\d+))?/i.exec(modelID) + const versionFirst = /claude-(\d+)(?:[.-](\d+))?-(?:opus|sonnet|haiku)/i.exec(modelID) + const major = Number(familyFirst?.[1] ?? versionFirst?.[1]) + const rawMinor = Number(familyFirst?.[2] ?? versionFirst?.[2] ?? 0) + if (!Number.isFinite(major)) return false + const minor = rawMinor > 9 ? 0 : rawMinor + return major < 4 || (major === 4 && minor < 6) } function modeName(model: SourceModel, mode: string) { diff --git a/packages/core/src/plugin/hooks.ts b/packages/core/src/plugin/hooks.ts index 0023e62f6c..5c36938612 100644 --- a/packages/core/src/plugin/hooks.ts +++ b/packages/core/src/plugin/hooks.ts @@ -1,5 +1,6 @@ export * as PluginHooks from "./hooks" +import type { AIHooks } from "@opencode-ai/plugin/v2/effect/ai" import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk" import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool" import { Context, Effect, Layer, Scope } from "effect" @@ -7,6 +8,7 @@ import { makeLocationNode } from "../effect/app-node" import { State } from "../state" export interface Domains { + readonly ai: AIHooks readonly aisdk: AISDKHooks readonly tool: ToolHooks } diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 9b175a80b4..207a0cb1e1 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -80,6 +80,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }) }), }, + ai: { + hook: (name, callback) => hooks.register("ai", name, callback), + }, aisdk: { hook: (name, callback) => { if (name === "sdk") { diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 53caee351c..670964b3ca 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -63,6 +63,10 @@ export function fromPromise(plugin: Plugin) { transform: transform(host.agent), reload: () => run(host.agent.reload()), }, + ai: { + hook: (name, callback) => + register(host.ai.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), + }, aisdk: { hook: (name, callback) => register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), diff --git a/packages/core/src/plugin/provider/openai-codex.ts b/packages/core/src/plugin/provider/openai-codex.ts index de31d39b39..8d4389d969 100644 --- a/packages/core/src/plugin/provider/openai-codex.ts +++ b/packages/core/src/plugin/provider/openai-codex.ts @@ -31,7 +31,7 @@ export const accountID = (credential: CredentialLike | undefined) => { } const allowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"]) -const disallowed = new Set(["gpt-5.5-pro"]) +const disallowed = new Set(["gpt-5.5-pro", "gpt-5.6"]) /** Which API model ids a ChatGPT subscription may call through the codex backend. */ export const eligible = (apiID: string) => { diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 5e4a1b08ef..111f66105a 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -194,6 +194,10 @@ export const OpenAIPlugin = define({ // ChatGPT-plan tokens only authorize codex-eligible models, and the // subscription covers usage, so hide the rest and zero the cost. evt.model.update(item.provider.id, model.id, (draft) => { + if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) { + draft.enabled = false + return + } if (!OpenAICodex.eligible(draft.modelID ?? draft.id)) { draft.enabled = false return diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index 724a3d2567..76e7968ebb 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -1,10 +1,159 @@ -import { Effect } from "effect" +import { createServer } from "node:http" +import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Clock, Deferred, Effect, Option, Schema } from "effect" +import { Credential } from "../../credential" +import { InstallationVersion } from "../../installation/version" +import { Integration } from "../../integration" +import { OauthCallbackPage } from "../../oauth/page" import { ProviderV2 } from "../../provider" +const clientID = "b1a00492-073a-47ea-816f-4c329264a828" +const issuer = "https://auth.x.ai/oauth2" +const deviceGrant = "urn:ietf:params:oauth:grant-type:device_code" +const scope = "openid profile email offline_access grok-cli:access api:access" +const callbackHost = "127.0.0.1" +const callbackPort = 56121 +const callbackPath = "/callback" +const redirectURI = `http://${callbackHost}:${callbackPort}${callbackPath}` +const pollingSafetyMargin = 3000 +const corsOrigins = new Set(["https://accounts.x.ai", "https://auth.x.ai"]) +const browserMethodID = Integration.MethodID.make("browser") +const deviceMethodID = Integration.MethodID.make("device") + +type Pkce = { + verifier: string + challenge: string +} + +const Token = Schema.Struct({ + access_token: Schema.String, + refresh_token: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Number), +}) +type Token = typeof Token.Type + +const Device = Schema.Struct({ + device_code: Schema.String, + user_code: Schema.String, + verification_uri: Schema.String, + verification_uri_complete: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Number), + interval: Schema.optional(Schema.Number), +}) + +const DeviceError = Schema.Struct({ + error: Schema.optional(Schema.String), + error_description: Schema.optional(Schema.String), +}) +const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError)) + +const browser = { + integrationID: Integration.ID.make("xai"), + method: { + id: browserMethodID, + type: "oauth", + label: "xAI Grok OAuth (SuperGrok Subscription)", + }, + authorize: () => + Effect.gen(function* () { + const pkce = yield* Effect.promise(generatePKCE) + const state = randomString(32) + const code = yield* Deferred.make() + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", redirectURI) + const origin = request.headers.origin + if (origin && corsOrigins.has(origin)) { + response.setHeader("Access-Control-Allow-Origin", origin) + response.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS") + response.setHeader("Access-Control-Allow-Headers", "Content-Type") + response.setHeader("Access-Control-Allow-Private-Network", "true") + response.setHeader("Vary", "Origin") + } + if (request.method === "OPTIONS") { + response.writeHead(204).end() + return + } + if (url.pathname !== callbackPath) { + response.writeHead(404).end("Not found") + return + } + const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") + const value = url.searchParams.get("code") + if (error) { + Effect.runFork(Deferred.fail(code, new Error(error))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(error, { provider: "xAI" })) + return + } + if (!value || url.searchParams.get("state") !== state) { + const message = value ? "Invalid OAuth state" : "Missing authorization code" + Effect.runFork(Deferred.fail(code, new Error(message))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(message, { provider: "xAI" })) + return + } + Effect.runFork(Deferred.succeed(code, value)) + response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "xAI" })) + }) + yield* Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(callbackPort, callbackHost, () => resume(Effect.void)) + }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())) + return { + mode: "auto" as const, + url: authorizeURL(pkce, state, randomString(32)), + instructions: "Complete authorization in your browser. This window will close automatically.", + callback: Deferred.await(code).pipe( + Effect.flatMap((value) => exchange(value, pkce)), + Effect.flatMap((tokens) => credential(browserMethodID, tokens)), + ), + } + }), + refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID })), +} satisfies IntegrationOAuthMethodRegistration + +const device = { + integrationID: Integration.ID.make("xai"), + method: { + id: deviceMethodID, + type: "oauth", + label: "xAI Grok OAuth (Headless / Remote / VPS)", + }, + authorize: () => + request( + `${issuer}/device/code`, + { + method: "POST", + headers: headers(), + body: new URLSearchParams({ client_id: clientID, scope }).toString(), + }, + Device, + ).pipe( + Effect.map((value) => ({ + mode: "auto" as const, + url: value.verification_uri_complete ?? value.verification_uri, + instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`, + callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))), + })), + ), + refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID })), +} satisfies IntegrationOAuthMethodRegistration + export const XAIPlugin = define({ id: "opencode.provider.xai", effect: Effect.fn(function* (ctx) { + yield* ctx.integration.transform((draft) => { + draft.update("xai", (integration) => { + integration.name = "xAI" + }) + draft.method.update(browser) + draft.method.update(device) + draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } }) + }) yield* ctx.aisdk.hook( "sdk", Effect.fn(function* (evt) { @@ -22,3 +171,178 @@ export const XAIPlugin = define({ ) }), }) + +function exchange(code: string, pkce: Pkce) { + return request( + `${issuer}/token`, + { + method: "POST", + headers: headers(), + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectURI, + client_id: clientID, + code_verifier: pkce.verifier, + }).toString(), + }, + Token, + ) +} + +function refresh(methodID: Integration.MethodID, value: Credential.OAuth) { + return request( + `${issuer}/token`, + { + method: "POST", + headers: headers(), + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: value.refresh, + client_id: clientID, + }).toString(), + }, + Token, + ).pipe(Effect.flatMap((tokens) => credential(methodID, tokens, value.refresh, value.metadata))) +} + +function poll(device: typeof Device.Type): Effect.Effect { + return Effect.gen(function* () { + const started = yield* Clock.currentTimeMillis + const expires = started + positiveSeconds(device.expires_in, 300) * 1000 + const loop = (interval: number): Effect.Effect => + Effect.gen(function* () { + if ((yield* Clock.currentTimeMillis) >= expires) { + return yield* Effect.fail(new Error("xAI device authorization timed out")) + } + const response = yield* send(`${issuer}/token`, { + method: "POST", + headers: headers(), + body: new URLSearchParams({ + grant_type: deviceGrant, + client_id: clientID, + device_code: device.device_code, + }).toString(), + }) + if (response.ok) return yield* decode(response, Token) + const error = yield* Effect.promise(() => response.text()).pipe( + Effect.map((body) => Option.getOrUndefined(decodeDeviceError(body))), + Effect.catch(() => Effect.succeed(undefined)), + ) + if (error?.error === "authorization_pending") { + return yield* Effect.sleep(interval + pollingSafetyMargin).pipe(Effect.andThen(loop(interval))) + } + if (error?.error === "slow_down") { + const next = interval + 5000 + return yield* Effect.sleep(next + pollingSafetyMargin).pipe(Effect.andThen(loop(next))) + } + if (error?.error === "access_denied" || error?.error === "authorization_denied") { + return yield* Effect.fail(new Error("xAI device authorization was denied")) + } + if (error?.error === "expired_token") { + return yield* Effect.fail(new Error("xAI device code expired - please re-run login")) + } + const detail = error?.error_description ?? error?.error + return yield* Effect.fail( + new Error(`xAI device token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`), + ) + }) + return yield* loop(Math.max(positiveSeconds(device.interval, 5) * 1000, 1000)) + }) +} + +function request>(url: string, init: RequestInit, schema: S) { + return send(url, init).pipe( + Effect.flatMap((response) => { + if (response.ok) return decode(response, schema) + return Effect.promise(() => response.text()).pipe( + Effect.flatMap((detail) => + Effect.fail(new Error(`xAI request failed (${response.status})${detail ? `: ${detail}` : ""}`)), + ), + ) + }), + ) +} + +function send(url: string, init: RequestInit) { + return Effect.tryPromise({ + try: (signal) => fetch(url, { ...init, signal }), + catch: (cause) => cause, + }) +} + +function decode>(response: Response, schema: S) { + return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(schema))) +} + +function credential( + methodID: Integration.MethodID, + tokens: Token, + currentRefresh?: string, + metadata?: Readonly>, +) { + const refresh = tokens.refresh_token ?? currentRefresh + if (!refresh) return Effect.fail(new Error("xAI token response is missing refresh_token")) + return Effect.succeed( + Credential.OAuth.make({ + type: "oauth", + methodID, + refresh, + access: tokens.access_token, + expires: tokenExpiration(tokens), + metadata, + }), + ) +} + +function tokenExpiration(tokens: Token) { + if (tokens.expires_in) return Date.now() + positiveSeconds(tokens.expires_in, 3600) * 1000 + const payload = tokens.access_token.split(".")[1] + if (!payload) return Date.now() + 3600 * 1000 + const claims = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ exp: Schema.optional(Schema.Number) })), + )(Buffer.from(payload, "base64url").toString()) + const expiration = Option.getOrUndefined(claims)?.exp + return expiration ? expiration * 1000 : Date.now() + 3600 * 1000 +} + +function headers() { + return { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": `opencode/${InstallationVersion}`, + } +} + +function positiveSeconds(value: unknown, fallback: number) { + const seconds = Number(value) + return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback +} + +async function generatePKCE(): Promise { + const verifier = randomString(64) + const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString( + "base64url", + ) + return { verifier, challenge } +} + +function randomString(length: number) { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("") +} + +function authorizeURL(pkce: Pkce, state: string, nonce: string) { + return `${issuer}/authorize?${new URLSearchParams({ + response_type: "code", + client_id: clientID, + redirect_uri: redirectURI, + scope, + code_challenge: pkce.challenge, + code_challenge_method: "S256", + state, + nonce, + plan: "generic", + referrer: "opencode", + }).toString()}` +} diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 165ffa516b..4e43963d19 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -3,7 +3,7 @@ export * as ProviderV2 from "./provider" import { Effect, Schema } from "effect" import { pathToFileURL } from "url" import { Provider } from "@opencode-ai/schema/provider" -import type { ProviderPackageDefinition } from "@opencode-ai/llm" +import type { ProviderPackageDefinition } from "@opencode-ai/ai" import { Npm } from "./npm" import type { DeepMutable } from "./schema" @@ -11,9 +11,12 @@ export const ID = Provider.ID export type ID = typeof ID.Type export const AISDK_PREFIX = "aisdk:" -export const isAISDK = (value: string | undefined) => value?.startsWith(AISDK_PREFIX) ?? false +export const isAISDK = (value: string | undefined): value is string => value?.startsWith(AISDK_PREFIX) ?? false export const aisdk = (value: string) => (isAISDK(value) ? value : `${AISDK_PREFIX}${value}`) -export const packageName = (value: string | undefined) => { +export function packageName(value: string): string +export function packageName(value: undefined): undefined +export function packageName(value: string | undefined): string | undefined +export function packageName(value: string | undefined) { if (value === undefined || !isAISDK(value)) return value return value.slice(AISDK_PREFIX.length) } @@ -32,7 +35,7 @@ const packages = new Map>() export const loadPackage = Effect.fn("ProviderV2.loadPackage")(function* (specifier: string, npm?: Npm.Interface) { const resolved = yield* Effect.sync(() => { - if (specifier.startsWith("file://") || specifier.startsWith("@opencode-ai/llm/")) return specifier + if (specifier.startsWith("file://") || specifier.startsWith("@opencode-ai/ai/")) return specifier try { return import.meta.resolve(specifier) } catch { diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index a888498c15..34ff7ecfa9 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -43,6 +43,7 @@ import { SkillV2 } from "./skill" import { Job } from "./job" import { CommandV2 } from "./command" import { Shell } from "./shell" +import { Global } from "./global" import { Shell as ShellSchema } from "@opencode-ai/schema/shell" import { KeyedMutex } from "./effect/keyed-mutex" import { fileURLToPath } from "url" @@ -146,6 +147,16 @@ export class BusyError extends Schema.TaggedErrorClass()("Session.Bus export class SkillNotFoundError extends Schema.TaggedErrorClass()("Session.SkillNotFoundError", { skill: SkillV2.ID, }) {} + +export class DestinationNotFoundError extends Schema.TaggedErrorClass()( + "Session.DestinationNotFoundError", + { directory: AbsolutePath }, +) {} + +export class DestinationNotDirectoryError extends Schema.TaggedErrorClass()( + "Session.DestinationNotDirectoryError", + { directory: AbsolutePath }, +) {} export const MessageNotFoundError = SessionRevert.MessageNotFoundError export type MessageNotFoundError = SessionRevert.MessageNotFoundError @@ -159,6 +170,8 @@ export type Error = | CompactionConflictError | BusyError | SkillNotFoundError + | DestinationNotFoundError + | DestinationNotDirectoryError | CommandV2.NotFoundError | CommandV2.EvaluationError | MessageNotFoundError @@ -215,6 +228,11 @@ export interface Interface { model: ModelV2.Ref }) => Effect.Effect readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect + readonly move: (input: { + sessionID: SessionSchema.ID + directory: AbsolutePath + workspaceID?: Location.Ref["workspaceID"] + }) => Effect.Effect readonly prompt: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID @@ -288,6 +306,7 @@ const layer = Layer.effect( const db = database.db const events = yield* EventV2.Service const projects = yield* ProjectV2.Service + const global = yield* Global.Service const execution = yield* SessionExecution.Service const store = yield* SessionStore.Service const locations = yield* LocationServiceMap.Service @@ -673,6 +692,38 @@ const layer = Layer.effect( title: input.title, }) }), + move: Effect.fn("V2Session.move")(function* (input) { + const current = yield* result.get(input.sessionID) + const value = input.directory.trim() + const expanded = + value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value + const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded)) + const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!info) return yield* new DestinationNotFoundError({ directory }) + if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory }) + if ( + current.location.directory === directory && + current.location.workspaceID === input.workspaceID + ) + return + const project = yield* projects.resolve(directory) + yield* db + .insert(ProjectTable) + .values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + if ((yield* execution.active).has(input.sessionID)) { + yield* execution.interrupt(input.sessionID) + yield* execution.awaitIdle(input.sessionID) + } + yield* events.publish(SessionEvent.Moved, { + sessionID: input.sessionID, + location: Location.Ref.make({ directory, workspaceID: input.workspaceID }), + projectID: project.id, + subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")), + }) + }), compact: Effect.fn("V2Session.compact")(function* (input) { yield* result.get(input.sessionID) const inputID = input.id ?? SessionMessage.ID.create() @@ -949,5 +1000,6 @@ export const node = makeGlobalNode({ LocationServiceMap.node, SessionProjector.node, FSUtil.node, + Global.node, ], }) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 19ba3c9053..c15919f60b 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -1,6 +1,6 @@ export * as SessionCompaction from "./compaction" -import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm" +import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/ai" import { SessionError } from "@opencode-ai/schema/session-error" import { Context, Effect, Layer, Stream } from "effect" import { Config } from "../config" @@ -9,6 +9,7 @@ import { makeLocationNode } from "../effect/app-node" import { llmClient } from "../effect/app-node-platform" import { SessionEvent } from "./event" import type { SessionMessage } from "./message" +import { SessionModelHeaders } from "./model-headers" import { SessionRunnerModel } from "./runner/model" import { SessionSchema } from "./schema" import { toSessionError } from "./to-session-error" @@ -66,7 +67,7 @@ type Dependencies = { } export type AutoInput = { - readonly sessionID: SessionSchema.ID + readonly session: SessionSchema.Info readonly messages: readonly SessionMessage.Info[] readonly model: Model } @@ -78,7 +79,7 @@ export type ManualInput = { } type Plan = { - readonly sessionID: SessionSchema.ID + readonly session: SessionSchema.Info readonly model: Model readonly reason: SessionMessage.Compaction["reason"] readonly prompt: string @@ -230,7 +231,7 @@ const make = (dependencies: Dependencies) => { }) const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) { yield* dependencies.events.publish(SessionEvent.Compaction.Started, { - sessionID: plan.sessionID, + sessionID: plan.session.id, reason: plan.reason, recent: plan.recent, inputID: plan.inputID, @@ -242,6 +243,7 @@ const make = (dependencies: Dependencies) => { .stream( LLM.request({ model: plan.model, + http: { headers: SessionModelHeaders.make(plan.session) }, messages: [Message.user(plan.prompt)], tools: [], }), @@ -256,7 +258,7 @@ const make = (dependencies: Dependencies) => { if (LLMEvent.is.textDelta(event)) { chunks.push(event.text) return dependencies.events.publish(SessionEvent.Compaction.Delta, { - sessionID: plan.sessionID, + sessionID: plan.session.id, text: event.text, }) } @@ -270,7 +272,7 @@ const make = (dependencies: Dependencies) => { Effect.onInterrupt(() => plan.reason === "auto" ? failed({ - sessionID: plan.sessionID, + sessionID: plan.session.id, reason: plan.reason, error: { type: "compaction.interrupted", message: "Compaction was interrupted" }, inputID: plan.inputID, @@ -282,14 +284,14 @@ const make = (dependencies: Dependencies) => { if (failure || !summary.trim()) { const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" } return yield* failed({ - sessionID: plan.sessionID, + sessionID: plan.session.id, reason: plan.reason, error, inputID: plan.inputID, }) } yield* dependencies.events.publish(SessionEvent.Compaction.Ended, { - sessionID: plan.sessionID, + sessionID: plan.session.id, reason: plan.reason, text: summary, recent: plan.recent, @@ -300,14 +302,14 @@ const make = (dependencies: Dependencies) => { const content = planContent(input.messages, config.tokens) if (content) return yield* execute({ - sessionID: input.sessionID, + session: input.session, model: input.model, reason: "auto", ...content, }) const error = { type: "compaction.unavailable" as const, message: "Nothing to compact yet" } return yield* failed({ - sessionID: input.sessionID, + sessionID: input.session.id, reason: "auto", error, }) @@ -348,7 +350,7 @@ const make = (dependencies: Dependencies) => { ) if ("status" in resolved) return resolved return yield* execute({ - sessionID: input.session.id, + session: input.session, model: resolved.model, reason: "manual", inputID: input.inputID, diff --git a/packages/core/src/session/model-headers.ts b/packages/core/src/session/model-headers.ts new file mode 100644 index 0000000000..efc6d5e23b --- /dev/null +++ b/packages/core/src/session/model-headers.ts @@ -0,0 +1,15 @@ +export * as SessionModelHeaders from "./model-headers" + +import { Flag } from "../flag/flag" +import { InstallationVersion } from "../installation/version" +import { SessionSchema } from "./schema" + +export const make = (session: Pick) => ({ + "x-session-affinity": session.id, + "X-Session-Id": session.id, + ...(session.parentID ? { "x-parent-session-id": session.parentID } : {}), + "User-Agent": `opencode/${InstallationVersion}`, + "x-opencode-project": session.projectID, + "x-opencode-session": session.id, + "x-opencode-client": Flag.OPENCODE_CLIENT, +}) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 70362cc89b..3c3dccee6f 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -497,6 +497,7 @@ const layer = Layer.effectDiscard( .set({ directory: event.data.location.directory, path: event.data.subpath, + ...(event.data.projectID ? { project_id: event.data.projectID } : {}), workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null, time_updated: DateTime.toEpochMillis(event.created), }) diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 910b05e745..ee33da093f 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -1,6 +1,6 @@ export * as SessionRunner from "./index" -import type { LLMError } from "@opencode-ai/llm" +import type { LLMError } from "@opencode-ai/ai" import { Context, Effect } from "effect" import { SessionSchema } from "../schema" import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error" diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 48fcc4d8d6..3a7ab625bc 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -9,7 +9,8 @@ import { SystemPart, isContextOverflowFailure, type ProviderErrorEvent, -} from "@opencode-ai/llm" +} from "@opencode-ai/ai" +import type { AIHooks } from "@opencode-ai/plugin/v2/effect/ai" import { SessionError } from "@opencode-ai/schema/session-error" import { Money } from "@opencode-ai/schema/money" import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect" @@ -51,7 +52,8 @@ import { AgentNotFoundError, StepFailedError } from "../error" import { toSessionError } from "../to-session-error" import { SessionRunnerRetry } from "./retry" import { PluginSupervisor } from "../../plugin/supervisor" -import { Flag } from "../../flag/flag" +import { PluginHooks } from "../../plugin/hooks" +import { SessionModelHeaders } from "../model-headers" type StepTokens = { readonly input: number @@ -89,6 +91,7 @@ const layer = Layer.effect( const llm = yield* LLMClient.Service const agents = yield* AgentV2.Service const tools = yield* ToolRegistry.Service + const hooks = yield* PluginHooks.Service const models = yield* SessionRunnerModel.Service const store = yield* SessionStore.Service const location = yield* Location.Service @@ -145,7 +148,7 @@ const layer = Layer.effect( const loadInstructions = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) => Effect.all( [ - builtins.load(), + builtins.load(sessionID), discovery.load(), skillGuidance.load(agent), referenceGuidance.load(), @@ -187,7 +190,7 @@ const layer = Layer.effect( const providerMetadataKey = model.route.providerMetadataKey ?? model.provider const history = yield* SessionHistory.entriesForRunner(db, session.id, instructions) const context = history.entries.map((entry) => entry.message) - const compactionInput = { sessionID: session.id, messages: context, model } + const compactionInput = { session, messages: context, model } if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) { const compacted = yield* compaction.compact(compactionInput) if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const @@ -199,11 +202,7 @@ const layer = Layer.effect( const request = LLM.request({ model, http: { - headers: { - "x-opencode-project": session.projectID, - "x-opencode-session": session.id, - "x-opencode-client": Flag.OPENCODE_CLIENT, - }, + headers: SessionModelHeaders.make(session), }, providerOptions: { openai: { promptCacheKey } }, system: [agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(model), history.initial] @@ -216,6 +215,30 @@ const layer = Layer.effect( tools: toolMaterialization?.definitions ?? [], toolChoice: isLastStep ? "none" : undefined, }) + const availableTools = new Map(request.tools.map((tool) => [tool.name, tool])) + const requestEvent: AIHooks["request"] = { + sessionID: session.id, + agent: agent.id, + model: resolved.ref, + system: [...request.system], + messages: [...request.messages], + tools: Object.fromEntries( + request.tools.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]), + ), + } + // Plugins may reshape the draft but cannot advertise tools excluded by + // permissions, registration state, or the selected agent's step limit. + yield* hooks.trigger("ai", "request", requestEvent) + const hookedRequest = LLM.updateRequest(request, { + system: requestEvent.system, + messages: requestEvent.messages, + tools: Object.entries(requestEvent.tools).flatMap(([name, tool]) => { + const registered = availableTools.get(name) + if (!registered) return [] + return [{ ...registered, description: tool.description, inputSchema: tool.input }] + }), + }) + const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name)) const toolFibers = yield* FiberSet.make() const ownedToolFibers: Array> = [] let needsContinuation = false @@ -236,7 +259,7 @@ const layer = Layer.effect( const serialized = (effect: Effect.Effect) => publication.withPermit(effect) const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error)) let overflowFailure: ProviderErrorEvent | undefined - const providerStream = llm.stream(request).pipe( + const providerStream = llm.stream(hookedRequest).pipe( Stream.runForEach((event) => Effect.gen(function* () { if (overflowFailure || publisher.hasProviderError()) return @@ -248,11 +271,13 @@ const layer = Layer.effect( } yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return - if (!toolMaterialization) { + if (!toolMaterialization || (availableTools.has(event.name) && !advertisedTools.has(event.name))) { yield* serialized( publisher.failUnsettledTools({ type: "tool.execution", - message: "Tools are disabled after the maximum agent steps", + message: toolMaterialization + ? `Tool is not available for this request: ${event.name}` + : "Tools are disabled after the maximum agent steps", }), ) return @@ -341,7 +366,7 @@ const layer = Layer.effect( recoverOverflow && !publisher.hasRetryEvidence() && isContextOverflowFailure(overflowFailure ?? streamFailure) && - (yield* restore(recoverOverflow({ sessionID: session.id, messages: context, model }))).status === + (yield* restore(recoverOverflow({ session, messages: context, model }))).status === "completed" ) return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const @@ -589,6 +614,7 @@ export const node = makeLocationNode({ llmClient, AgentV2.node, ToolRegistry.node, + PluginHooks.node, SessionRunnerModel.node, SessionStore.node, Location.node, diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index defd95f9c1..4749e6f72d 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -1,14 +1,14 @@ export * as SessionRunnerModel from "./model" import { makeLocationNode } from "../../effect/app-node" -import { Model } from "@opencode-ai/llm" +import { Model } from "@opencode-ai/ai" // ast-grep-ignore: no-star-import -import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" +import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages" // ast-grep-ignore: no-star-import -import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat" +import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat" // ast-grep-ignore: no-star-import -import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses" -import { Auth, type AnyRoute } from "@opencode-ai/llm/route" +import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses" +import { Auth, type AnyRoute } from "@opencode-ai/ai/route" import { Context, Effect, Layer, Schema } from "effect" import { produce } from "immer" import { AISDK } from "../../aisdk" @@ -191,11 +191,7 @@ export const fromCatalogModel = ( const packageName = ProviderV2.packageName(resolved.package) const key = apiKey(resolved, credential) - if ( - OpenAICodex.isChatGPT(credential) && - !ProviderV2.isAISDK(resolved.package) && - isNativeOpenAI(resolved.package) - ) { + if (OpenAICodex.isChatGPT(credential) && !ProviderV2.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) { return Effect.succeed(codexModel(resolved, credential, key)) } @@ -243,11 +239,10 @@ export const fromCatalogModel = ( const module = yield* (dependencies.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe( Effect.mapError(() => unsupported(resolved)), ) + const configured = { ...resolved.settings, ...credential?.metadata } const settings = { - ...resolved.settings, - ...(credential?.type === "key" ? { apiKey: credential.key } : {}), - ...(credential?.type === "oauth" ? { apiKey: credential.access } : {}), - ...credential?.metadata, + ...(credential ? withoutNativeAuthSettings(configured) : configured), + ...nativeCredentialSettings(specifier, credential), headers: resolved.headers, body: resolved.body, limits: { context: resolved.limit.context, output: resolved.limit.output }, @@ -261,8 +256,29 @@ export const fromCatalogModel = ( } const isNativeOpenAI = (packageName: string | undefined) => - packageName === "@opencode-ai/llm/providers/openai" || - packageName?.startsWith("@opencode-ai/llm/providers/openai/") === true + packageName === "@opencode-ai/ai/providers/openai" || + packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true + +const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => { + if (!credential) return {} + if (credential.type === "key") return { apiKey: credential.key } + if ( + specifier === "@opencode-ai/ai/providers/anthropic" || + specifier === "@opencode-ai/ai/providers/anthropic-compatible" + ) + return { authToken: credential.access } + if ( + specifier === "@opencode-ai/ai/providers/google-vertex" || + specifier.startsWith("@opencode-ai/ai/providers/google-vertex/") + ) + return { accessToken: credential.access } + return { apiKey: credential.access } +} + +const withoutNativeAuthSettings = (settings: Record) => { + const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings + return rest +} const codexModel = ( model: ModelV2.Info, diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 643228e49d..35d80d7329 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -1,4 +1,4 @@ -import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm" +import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/ai" import { Effect } from "effect" import { EventV2 } from "../../event" import { ModelV2 } from "../../model" diff --git a/packages/core/src/session/runner/retry.ts b/packages/core/src/session/runner/retry.ts index 3ada83b4e4..44029e1b6a 100644 --- a/packages/core/src/session/runner/retry.ts +++ b/packages/core/src/session/runner/retry.ts @@ -1,6 +1,6 @@ export * as SessionRunnerRetry from "./retry" -import { LLMError } from "@opencode-ai/llm" +import { LLMError } from "@opencode-ai/ai" import { SessionError } from "@opencode-ai/schema/session-error" import { Data, Duration, Effect, Schedule } from "effect" import { EventV2 } from "../../event" diff --git a/packages/core/src/session/runner/system-prompt.ts b/packages/core/src/session/runner/system-prompt.ts index e4d442699c..e8b97d83d0 100644 --- a/packages/core/src/session/runner/system-prompt.ts +++ b/packages/core/src/session/runner/system-prompt.ts @@ -1,6 +1,6 @@ export * as SessionRunnerSystemPrompt from "./system-prompt" -import type { Model } from "@opencode-ai/llm" +import type { Model } from "@opencode-ai/ai" import PROMPT_ANTHROPIC from "./prompt/anthropic.txt" import PROMPT_BEAST from "./prompt/beast.txt" diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 7aa379ab27..db3ce25fe1 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -5,7 +5,7 @@ import { ToolResultPart, type ContentPart, type ProviderMetadata, -} from "@opencode-ai/llm" +} from "@opencode-ai/ai" import { Option, Schema } from "effect" import type { ModelV2 } from "../../model" import { SessionMessage } from "../message" @@ -235,7 +235,7 @@ ${message.recent} } } -/** Translate projected V2 Session history into canonical @opencode-ai/llm context. */ +/** Translate projected V2 Session history into canonical @opencode-ai/ai context. */ export const toLLMMessages = ( messages: readonly SessionMessage.Info[], model: ModelV2.Ref, diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index 8e7b5857b7..ac7b7c27e1 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -1,6 +1,6 @@ export * as SessionTitle from "./title" -import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/llm" +import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai" import { Context, DateTime, Effect, Layer, Stream } from "effect" import { AgentV2 } from "../agent" import { Database } from "../database/database" @@ -9,6 +9,7 @@ import { makeLocationNode } from "../effect/app-node" import { llmClient } from "../effect/app-node-platform" import { SessionEvent } from "./event" import { SessionHistory } from "./history" +import { SessionModelHeaders } from "./model-headers" import { SessionRunnerModel } from "./runner/model" import { SessionSchema } from "./schema" @@ -54,6 +55,7 @@ const make = (dependencies: Dependencies) => { .stream( LLM.request({ model: resolved.model, + http: { headers: SessionModelHeaders.make(session) }, system: agent.system, messages: [Message.user(firstUser.text)], tools: [], diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index 6d3d521fb7..e3ad94fd10 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -1,4 +1,4 @@ -import { LLMError, ToolFailure } from "@opencode-ai/llm" +import { LLMError, ToolFailure } from "@opencode-ai/ai" import { Tool } from "@opencode-ai/plugin/v2/effect/tool" import { SessionError } from "@opencode-ai/schema/session-error" import { PermissionV2 } from "../permission" diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts index 690f2cc85b..057c6b4c68 100644 --- a/packages/core/src/tool-output-store.ts +++ b/packages/core/src/tool-output-store.ts @@ -8,7 +8,7 @@ import { Global } from "./global" import { makeGlobalNode, makeLocationNode } from "./effect/app-node" import { SessionSchema } from "./session/schema" import { Identifier } from "./util/identifier" -import type { ToolOutput } from "@opencode-ai/llm" +import type { ToolOutput } from "@opencode-ai/ai" export const MAX_LINES = 2_000 export const MAX_BYTES = 50 * 1024 diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 1d79459550..2319d4baf2 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -7,7 +7,7 @@ export * as EditTool from "./edit" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" -import { ToolFailure } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/ai" import { FileDiff } from "@opencode-ai/schema/file-diff" import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Schema } from "effect" diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index 701b5ff2bb..0a62223770 100644 --- a/packages/core/src/tool/execute.ts +++ b/packages/core/src/tool/execute.ts @@ -1,7 +1,7 @@ export * as ExecuteTool from "./execute" import { CodeMode, Tool, toolError } from "@opencode-ai/codemode" -import { ToolOutput } from "@opencode-ai/llm" +import { ToolOutput } from "@opencode-ai/ai" import { Effect, Ref, Schema } from "effect" import { definition, make, settle, type AnyTool } from "./tool" diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index f71102c362..35dc58d6cf 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -1,6 +1,6 @@ export * as GlobTool from "./glob" -import { ToolFailure } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/ai" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Schema } from "effect" import path from "path" @@ -20,7 +20,7 @@ export const Input = Schema.Struct({ description: "Relative directory to search. Defaults to the active Location.", }), limit: FileSystem.GlobInput.fields.limit.annotate({ - description: "Maximum results to return", + description: `Maximum results to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`, }), }) @@ -86,7 +86,7 @@ export const Plugin = { .glob({ cwd, pattern: input.pattern, - limit: input.limit ?? Number.MAX_SAFE_INTEGER, + limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, }) .pipe( Effect.map((result) => diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 99e64af50a..ef7c76c745 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -1,7 +1,7 @@ export * as GrepTool from "./grep" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" -import { ToolFailure } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/ai" import { Effect, Schema } from "effect" import path from "path" import { FileSystem } from "../filesystem" @@ -25,7 +25,7 @@ export const Input = Schema.Struct({ description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")', }), limit: FileSystem.GrepInput.fields.limit.annotate({ - description: "Maximum matches to return", + description: `Maximum matches to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`, }), }) @@ -106,7 +106,7 @@ export const Plugin = { pattern: input.pattern, file: info?.type === "File" ? path.basename(target) : undefined, include: input.include, - limit: input.limit ?? Number.MAX_SAFE_INTEGER, + limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, }) .pipe( Effect.map((result) => diff --git a/packages/core/src/tool/hooks.ts b/packages/core/src/tool/hooks.ts index abe829aa1b..b6dba58ffb 100644 --- a/packages/core/src/tool/hooks.ts +++ b/packages/core/src/tool/hooks.ts @@ -6,7 +6,7 @@ import { Session } from "@opencode-ai/schema/session" import { SessionMessage } from "../session/message" import { State } from "../state" import { Context, Effect, Layer, Scope } from "effect" -import type { ToolOutput, ToolResultValue } from "@opencode-ai/llm" +import type { ToolOutput, ToolResultValue } from "@opencode-ai/ai" export interface BeforeEvent { readonly tool: string diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index 0a15ba2350..a636671f60 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -1,6 +1,6 @@ export * as McpTool from "./mcp" -import { ToolFailure } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/ai" import { McpEvent } from "@opencode-ai/schema/mcp-event" import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect" import { makeLocationNode } from "../effect/app-node" diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index 8d986790ce..57e8c0ef2a 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -1,7 +1,7 @@ export * as PatchTool from "./patch" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" -import { ToolFailure } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/ai" import { FileDiff } from "@opencode-ai/schema/file-diff" import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Schema } from "effect" @@ -195,6 +195,19 @@ export const Plugin = { ), ) .pipe(Effect.orDie) + + yield* ctx.ai.hook("request", (event) => + Effect.sync(() => { + const usePatch = + event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt") + if (usePatch) { + delete event.tools.edit + delete event.tools.write + return + } + delete event.tools.patch + }), + ) }), } diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index 6b179fe9b8..aa343c9652 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -1,7 +1,7 @@ export * as QuestionTool from "./question" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" -import { ToolFailure } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/ai" import { Effect, Schema } from "effect" import { Form } from "../form" import { PermissionV2 } from "../permission" diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 646e09daf6..899e464929 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -2,7 +2,7 @@ export * as ReadTool from "./read" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { dirname } from "path" -import { ToolFailure } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/ai" import { Effect, Schema } from "effect" import { FileSystem } from "../filesystem" import { FSUtil } from "../fs-util" diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 6e1910ae56..98120fc2d9 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -1,6 +1,6 @@ export * as ToolRegistry from "./registry" -import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm" +import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/ai" import { Context, Effect, Layer, Scope } from "effect" import type { AgentV2 } from "../agent" import { PermissionV2 } from "../permission" diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index 5109e2a535..300acf8cd5 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -1,9 +1,9 @@ export * as ShellTool from "./shell" import path from "path" -import { ToolFailure } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/ai" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" -import { Effect, Schema, Scope } from "effect" +import { Effect, Fiber, Schedule, Schema, Scope } from "effect" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" @@ -72,7 +72,6 @@ const modelOutput = (output: Output): string | undefined => { // TODO: Replace token-based command-argument external-directory advisories with parser-based detection. // TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows. // TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist. -// TODO: Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired. // TODO: Persist job status and define restart recovery before exposing remote observation. // TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined. // TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it. @@ -201,9 +200,18 @@ export const Plugin = { metadata: { sessionID: context.sessionID }, }) + const captureShell = Effect.fn("ShellTool.captureShell")(function* () { + const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) + const truncated = page.size > page.cursor + const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : "" + return { + output: `${page.output || "(no output)"}${notice}`, + truncated, + } + }) + const settleShell = Effect.fn("ShellTool.settleShell")(function* () { const final = yield* shell.wait(info.id) - const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) if (final.status === "timeout") { return { @@ -215,13 +223,11 @@ export const Plugin = { } } - const truncated = page.size > page.cursor - const body = page.output || "(no output)" - const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" + const capture = yield* captureShell() return { exit: final.exit, - output: `${body}${notice}`, - truncated, + output: capture.output, + truncated: capture.truncated, status: "completed" as const, } }) @@ -250,9 +256,24 @@ export const Plugin = { } } - const result = yield* runtime.job - .block({ id: job.id, sessionID: context.sessionID }) - .pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore))) + const progress = yield* Effect.sleep("1 second").pipe( + Effect.andThen( + captureShell().pipe( + Effect.flatMap((capture) => + context.progress({ + structured: { truncated: capture.truncated }, + content: [{ type: "text", text: capture.output }], + }), + ), + ), + ), + Effect.repeat(Schedule.forever), + Effect.forkIn(scope, { startImmediately: true }), + ) + const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe( + Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)), + Effect.ensuring(Fiber.interrupt(progress)), + ) if (result?.type === "backgrounded") { yield* shell.timeout(info.id, 0) yield* notifyWhenDone(context.sessionID, context.callID, input.command) diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 07d935a127..bf2ac0540d 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -2,7 +2,7 @@ export * as SkillTool from "./skill" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import path from "path" -import { ToolFailure } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/ai" import { Effect, Schema } from "effect" import { FSUtil } from "../fs-util" import { SkillV2 } from "../skill" diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index ea4dc87d5e..0cc2de9db5 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -1,6 +1,6 @@ export * as SubagentTool from "./subagent" -import { ToolFailure } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/ai" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Schema, Scope } from "effect" import { AgentV2 } from "../agent" @@ -215,5 +215,32 @@ export const Plugin = { ), ) .pipe(Effect.orDie) + + yield* ctx.ai.hook("request", (event) => + Effect.gen(function* () { + const tool = event.tools[name] + if (!tool) return + const selected = yield* agents.resolve(event.agent) + if (!selected) return + const available = (yield* agents.list()) + .filter( + (agent) => + agent.mode !== "primary" && + !agent.hidden && + PermissionV2.evaluate(name, agent.id, selected.permissions).effect !== "deny", + ) + .toSorted((a, b) => a.id.localeCompare(b.id)) + if (available.length === 0) return + tool.description = [ + tool.description, + "", + "Available subagents:", + ...available.map( + (agent) => + `- ${agent.id}: ${agent.description ?? "This subagent should only be called when explicitly requested."}`, + ), + ].join("\n") + }), + ) }), } diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index 6a5910c057..e7a372e4b4 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -1,7 +1,7 @@ export * as WebFetchTool from "./webfetch" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" -import { ToolFailure } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/ai" import { Duration, Effect, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Parser } from "htmlparser2" diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 0e23fc8a32..a92ce5f8ef 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -1,7 +1,7 @@ export * as WebSearchTool from "./websearch" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" -import { ToolFailure } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/ai" import { Context, Duration, Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { makeLocationNode } from "../effect/app-node" diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index e634411e01..6c8041ff42 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -7,7 +7,7 @@ export * as WriteTool from "./write" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" -import { ToolFailure } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/ai" import { Effect, Schema } from "effect" import { FileMutation } from "../file-mutation" import { LocationMutation } from "../location-mutation" diff --git a/packages/core/src/util/process-lock.ts b/packages/core/src/util/process-lock.ts new file mode 100644 index 0000000000..045ac9fa05 --- /dev/null +++ b/packages/core/src/util/process-lock.ts @@ -0,0 +1,181 @@ +import { dlopen, read, type Pointer } from "bun:ffi" +import { closeSync, existsSync, mkdirSync, openSync } from "node:fs" +import { connect, createServer, type Server, type Socket } from "node:net" +import path from "node:path" +import { Effect, Schema } from "effect" +import { Hash } from "./hash" + +export namespace ProcessLock { + export class HeldError extends Schema.TaggedErrorClass()("ProcessLockHeldError", { + file: Schema.String, + }) { + override get message() { + return `Process lock is already held: ${this.file}` + } + } + + export class SystemError extends Schema.TaggedErrorClass()("ProcessLockSystemError", { + file: Schema.String, + operation: Schema.Literals(["open", "acquire"]), + code: Schema.String, + }) { + override get message() { + return `Process lock ${this.operation} failed for ${this.file}: ${this.code}` + } + } + + export type LockError = HeldError | SystemError + + const acquirePosix = Effect.fnUntraced(function* (file: string) { + const fd = yield* Effect.try({ + try: () => { + mkdirSync(path.dirname(file), { recursive: true }) + return openSync(file, "a+", 0o600) + }, + catch: (cause) => + new SystemError({ + file, + operation: "open", + code: cause instanceof Error ? cause.message : String(cause), + }), + }) + const result = yield* Effect.try({ + try: () => lock(fd), + catch: (cause) => + new SystemError({ + file, + operation: "acquire", + code: cause instanceof Error ? cause.message : String(cause), + }), + }).pipe( + Effect.tapError(() => + Effect.sync(() => { + closeSync(fd) + }), + ), + ) + if (result.acquired) { + return fd + } + closeSync(fd) + return yield* result.held + ? new HeldError({ file }) + : new SystemError({ file, operation: "acquire", code: String(result.code) }) + }) + + export const acquire = Effect.fn("ProcessLock.acquire")(function* (file: string) { + if (process.platform === "win32") { + yield* Effect.acquireRelease(acquireWindows(file), closeWindows) + return + } + yield* Effect.acquireRelease(acquirePosix(file), (fd) => + Effect.sync(() => { + closeSync(fd) + }), + ) + }) +} + +type Result = + | { readonly acquired: true } + | { readonly acquired: false; readonly held: true } + | { readonly acquired: false; readonly held: false; readonly code: number } + +const LOCK_EX = 2 +const LOCK_NB = 4 +const DARWIN_EWOULDBLOCK = 35 +const LINUX_EWOULDBLOCK = 11 + +function lock(fd: number): Result { + if (process.platform === "darwin") return lockDarwin(fd) + if (process.platform === "linux") return lockLinux(fd) + throw new Error(`Unsupported process lock platform: ${process.platform}`) +} + +function lockDarwin(fd: number): Result { + const library = dlopen("/usr/lib/libSystem.B.dylib", { + flock: { args: ["i32", "i32"], returns: "i32" }, + __error: { args: [], returns: "ptr" }, + }) + try { + const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB) + const code = result === 0 ? 0 : errorCode(library.symbols.__error()) + if (result === 0) return { acquired: true } + if (code === DARWIN_EWOULDBLOCK) return { acquired: false, held: true } + return { acquired: false, held: false, code } + } finally { + library.close() + } +} + +function lockLinux(fd: number): Result { + const musl = `/lib/libc.musl-${process.arch === "arm64" ? "aarch64" : "x86_64"}.so.1` + const library = dlopen(existsSync(musl) ? musl : "libc.so.6", { + flock: { args: ["i32", "i32"], returns: "i32" }, + __errno_location: { args: [], returns: "ptr" }, + }) + try { + const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB) + const code = result === 0 ? 0 : errorCode(library.symbols.__errno_location()) + if (result === 0) return { acquired: true } + if (code === LINUX_EWOULDBLOCK) return { acquired: false, held: true } + return { acquired: false, held: false, code } + } finally { + library.close() + } +} + +function errorCode(pointer: Pointer | null) { + if (pointer === null) throw new Error("Failed to read process lock error code") + return read.i32(pointer, 0) +} + +function acquireWindows(file: string) { + return Effect.callback((resume) => { + const server = createServer() + let probe: Socket | undefined + const pipe = `\\\\.\\pipe\\opencode-process-lock-${Hash.sha256(path.resolve(file).toLowerCase())}` + const onError = (cause: NodeJS.ErrnoException) => { + server.off("listening", onListening) + probe = connect(pipe) + const onProbeError = () => { + probe?.off("connect", onConnect) + resume( + Effect.fail( + new ProcessLock.SystemError({ + file, + operation: "acquire", + code: cause.code ?? cause.message, + }), + ), + ) + } + const onConnect = () => { + probe?.off("error", onProbeError) + probe?.destroy() + resume(Effect.fail(new ProcessLock.HeldError({ file }))) + } + probe.once("connect", onConnect) + probe.once("error", onProbeError) + } + const onListening = () => { + server.off("error", onError) + resume(Effect.succeed(server)) + } + server.once("error", onError) + server.once("listening", onListening) + server.on("connection", (socket) => socket.destroy()) + server.listen(pipe) + return Effect.sync(() => { + probe?.destroy() + server.close() + }) + }) +} + +function closeWindows(server: Server) { + return Effect.callback((resume) => { + if (!server.listening) return resume(Effect.void) + server.close((error) => resume(error ? Effect.die(error) : Effect.void)) + }) +} diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 0a4b7dadce..59c8f8e8d3 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -2,8 +2,8 @@ import type { LanguageModelV3CallOptions } from "@ai-sdk/provider" import { AISDK } from "@opencode-ai/core/aisdk" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import { LLM, Message } from "@opencode-ai/llm" -import { LLMClient } from "@opencode-ai/llm/route" +import { LLM, Message } from "@opencode-ai/ai" +import { LLMClient } from "@opencode-ai/ai/route" import { expect } from "bun:test" import { Effect } from "effect" import { testEffect } from "./lib/effect" @@ -68,6 +68,126 @@ it.effect("projects request settings, headers, and body overlays", () => }), ) +it.effect("maps pro reasoning bodies to AI SDK provider options", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + let body: unknown + yield* aisdk.hook.sdk((event) => { + body = event.options.body + event.sdk = { languageModel: () => ({ provider: event.model.providerID }) } + }) + + const resolved = yield* aisdk.model({ + ...model("@ai-sdk/openai"), + body: { reasoning: { mode: "pro" } }, + }) + const prepared = yield* LLMClient.prepare( + LLM.request({ model: resolved, prompt: "Hello" }), + ) + + expect(body).toBeUndefined() + expect(prepared.body.providerOptions).toEqual({ + openai: { forceReasoning: true, reasoningMode: "pro" }, + }) + }), +) + +it.effect("maps package-specific AI SDK provider option keys", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + yield* aisdk.hook.sdk((event) => { + event.sdk = { languageModel: () => ({ provider: event.model.providerID }) } + }) + + const cases = [ + ["@ai-sdk/github-copilot", "copilot", { reasoningEffort: "high" }], + ["@ai-sdk/amazon-bedrock/mantle", "openai", { reasoningEffort: "high", forceReasoning: true }], + ["@ai-sdk/openai-compatible", "test-provider", { reasoningEffort: "high" }], + ["@jerome-benoit/sap-ai-provider-v2", "sap-ai", { reasoningEffort: "high" }], + ["ai-gateway-provider", "openaiCompatible", { reasoningEffort: "high" }], + ] as const + for (const [packageName, key, settings] of cases) { + const resolved = yield* aisdk.model(model(packageName, { reasoningEffort: "high" })) + const prepared = yield* LLMClient.prepare( + LLM.request({ model: resolved, prompt: "Hello" }), + ) + expect(prepared.body.providerOptions).toEqual({ [key]: settings }) + } + }), +) + +it.effect("forces reasoning and projects both Azure AI SDK namespaces", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + yield* aisdk.hook.sdk((event) => { + event.sdk = { languageModel: () => ({ provider: event.model.providerID }) } + }) + + const openai = yield* aisdk.model(model("@ai-sdk/openai", { reasoningEffort: "high" })) + const openaiPrepared = yield* LLMClient.prepare( + LLM.request({ model: openai, prompt: "Hello" }), + ) + expect(openaiPrepared.body.providerOptions).toEqual({ + openai: { reasoningEffort: "high", forceReasoning: true }, + }) + + const azure = yield* aisdk.model(model("@ai-sdk/azure", { reasoningEffort: "high" })) + const azurePrepared = yield* LLMClient.prepare( + LLM.request({ model: azure, prompt: "Hello" }), + ) + expect(azurePrepared.body.providerOptions).toEqual({ + openai: { reasoningEffort: "high", forceReasoning: true }, + azure: { reasoningEffort: "high", forceReasoning: true }, + }) + }), +) + +it.effect("routes AI Gateway model options by upstream prefix", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + yield* aisdk.hook.sdk((event) => { + event.sdk = { languageModel: () => ({ provider: event.model.providerID }) } + }) + + const anthropic = yield* aisdk.model({ + ...model("@ai-sdk/gateway", { + gateway: { order: ["anthropic"] }, + thinking: { type: "adaptive" }, + }), + modelID: ModelV2.ID.make("anthropic/claude-sonnet-5"), + }) + const anthropicPrepared = yield* LLMClient.prepare( + LLM.request({ model: anthropic, prompt: "Hello" }), + ) + expect(anthropicPrepared.body.providerOptions).toEqual({ + gateway: { order: ["anthropic"] }, + anthropic: { thinking: { type: "adaptive" } }, + }) + + const bedrock = yield* aisdk.model({ + ...model("@ai-sdk/gateway", { reasoningConfig: { type: "enabled" } }), + modelID: ModelV2.ID.make("amazon/nova-2-lite"), + }) + const bedrockPrepared = yield* LLMClient.prepare( + LLM.request({ model: bedrock, prompt: "Hello" }), + ) + expect(bedrockPrepared.body.providerOptions).toEqual({ + bedrock: { reasoningConfig: { type: "enabled" } }, + }) + + const fallback = yield* aisdk.model({ + ...model("@ai-sdk/gateway", { reasoningEffort: "high" }), + modelID: ModelV2.ID.make("deepseek/deepseek-v4"), + }) + const fallbackPrepared = yield* LLMClient.prepare( + LLM.request({ model: fallback, prompt: "Hello" }), + ) + expect(fallbackPrepared.body.providerOptions).toEqual({ + deepseek: { reasoningEffort: "high" }, + }) + }), +) + it.effect("projects replay metadata onto AI SDK prompt parts", () => Effect.gen(function* () { const aisdk = yield* AISDK.Service diff --git a/packages/core/test/fixture/process-lock-worker.ts b/packages/core/test/fixture/process-lock-worker.ts new file mode 100644 index 0000000000..8a283c68fe --- /dev/null +++ b/packages/core/test/fixture/process-lock-worker.ts @@ -0,0 +1,17 @@ +import { ProcessLock } from "@opencode-ai/core/util/process-lock" +import { Effect, Schema } from "effect" +import fs from "node:fs/promises" + +const input = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Struct({ file: Schema.String, ready: Schema.String })), +)(process.argv[2]) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* ProcessLock.acquire(input.file) + yield* Effect.promise(() => fs.writeFile(input.ready, String(process.pid))) + return yield* Effect.never + }), + ), +) diff --git a/packages/core/test/instructions/builtins.test.ts b/packages/core/test/instructions/builtins.test.ts index 43f2c92aee..079c443392 100644 --- a/packages/core/test/instructions/builtins.test.ts +++ b/packages/core/test/instructions/builtins.test.ts @@ -7,6 +7,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { AbsolutePath } from "@opencode-ai/core/schema" import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins" +import { SessionSchema } from "@opencode-ai/core/session/schema" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { readInitial, readUpdate } from "../lib/instructions" @@ -14,6 +15,7 @@ import { readInitial, readUpdate } from "../lib/instructions" const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core")) const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo")) const timestamp = Date.parse("2026-06-03T12:00:00.000Z") +const sessionID = SessionSchema.ID.make("ses_builtin_test") const localDate = (time: number) => new Date(time).toDateString() const locationLayer = Layer.succeed( Location.Service, @@ -36,12 +38,13 @@ describe("InstructionBuiltIns", () => { Effect.gen(function* () { yield* TestClock.setTime(timestamp) const context = yield* InstructionBuiltIns.Service - const initialized = yield* readInitial(yield* context.load()) + const initialized = yield* readInitial(yield* context.load(sessionID)) expect(initialized.text).toBe( [ "Here is some useful information about the environment you are running in:", "", + ` Session ID: ${sessionID}`, ` Working directory: ${directory}`, ` Workspace root folder: ${projectDirectory}`, " Is directory a git repo: yes", @@ -58,10 +61,10 @@ describe("InstructionBuiltIns", () => { Effect.gen(function* () { yield* TestClock.setTime(timestamp) const context = yield* InstructionBuiltIns.Service - const initialized = yield* readInitial(yield* context.load()) + const initialized = yield* readInitial(yield* context.load(sessionID)) yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000) - const refreshed = yield* readUpdate(yield* context.load(), initialized) + const refreshed = yield* readUpdate(yield* context.load(sessionID), initialized) expect(refreshed.text).toBe(`Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`) }), @@ -71,10 +74,10 @@ describe("InstructionBuiltIns", () => { Effect.gen(function* () { yield* TestClock.setTime(timestamp) const context = yield* InstructionBuiltIns.Service - const initialized = yield* readInitial(yield* context.load()) + const initialized = yield* readInitial(yield* context.load(sessionID)) yield* TestClock.setTime(timestamp + 60 * 60 * 1000) - expect((yield* readUpdate(yield* context.load(), initialized)).changed).toBe(false) + expect((yield* readUpdate(yield* context.load(sessionID), initialized)).changed).toBe(false) }), ) }) diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index 468f9e3d9c..9adf3c240b 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -44,6 +44,9 @@ export const registerToolPlugin = (plugin: { Effect.gen(function* () { const tools = yield* Tools.Service const context = host({ + ai: { + hook: () => Effect.succeed({ dispose: Effect.void }), + }, tool: { transform: (callback) => Effect.gen(function* () { diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index f3d32c04db..638cfc463e 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -37,6 +37,7 @@ const fixture = { id: "acme", name: "Acme", env: ["ACME_API_KEY"], + npm: "@ai-sdk/openai-compatible", models: { "acme-1": { id: "acme-1", @@ -57,7 +58,7 @@ const fixtureSnapshot = [ info: { id: ProviderV2.ID.make("acme"), name: "Acme", - package: "", + package: ProviderV2.aisdk("@ai-sdk/openai-compatible"), }, models: [ { @@ -97,6 +98,7 @@ const fixture2 = { id: "beta", name: "Beta", env: ["BETA_API_KEY"], + npm: "@ai-sdk/openai-compatible", models: { "beta-1": { id: "beta-1", @@ -117,7 +119,7 @@ const fixture2Snapshot = [ info: { id: ProviderV2.ID.make("beta"), name: "Beta", - package: "", + package: ProviderV2.aisdk("@ai-sdk/openai-compatible"), }, models: [ { diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index c550de1d3b..77d0840756 100644 --- a/packages/core/test/move-session.test.ts +++ b/packages/core/test/move-session.test.ts @@ -43,6 +43,7 @@ const it = testEffect( EventV2.node, ProjectDirectories.node, Project.node, + SessionV2.node, SessionProjector.node, SessionStore.node, ]), @@ -137,7 +138,6 @@ describe("MoveSession", () => { yield* Effect.promise(() => initRepo(root.path)) const source = abs(yield* Effect.promise(() => fs.realpath(root.path))) const destination = abs(path.join(source, "packages")) - yield* Effect.promise(() => fs.mkdir(destination)) yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n")) yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n")) @@ -164,8 +164,14 @@ describe("MoveSession", () => { .run() .pipe(Effect.orDie) + const missing = yield* SessionV2.Service.use((service) => + service.move({ sessionID, directory: abs("packages") }).pipe(Effect.flip), + ) + expect(missing._tag).toBe("Session.DestinationNotFoundError") + yield* Effect.promise(() => fs.mkdir(destination)) + yield* MoveSession.Service.use((service) => - service.moveSession({ sessionID, destination: { directory: destination }, moveChanges: true }), + service.moveSession({ sessionID, destination: { directory: abs("packages") }, moveChanges: true }), ) expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("changed\n") @@ -180,6 +186,58 @@ describe("MoveSession", () => { }), ) + it.live("moves a session to another project", () => + Effect.gen(function* () { + const root = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(root.path)) + const source = abs(yield* Effect.promise(() => fs.realpath(root.path))) + const destination = abs(`${root.path}-other-project`) + yield* Effect.acquireRelease( + Effect.promise(() => fs.mkdir(destination, { recursive: true })), + () => Effect.promise(() => fs.rm(destination, { recursive: true, force: true })), + ) + + const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id + const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id + const sessionID = SessionV2.ID.make("ses_move_project") + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: projectID, + slug: "move-project", + directory: source, + title: "move project", + version: "test", + time_created: 1, + time_updated: 1, + }) + .run() + .pipe(Effect.orDie) + + yield* SessionV2.Service.use((service) => + service.move({ sessionID, directory: destination }), + ) + + expect( + yield* db + .select({ projectID: SessionTable.project_id, directory: SessionTable.directory }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get(), + ).toEqual({ projectID: destinationProjectID, directory: destination }) + }), + ) + it.live("moves nested session changes without cleaning unrelated files", () => Effect.gen(function* () { const root = yield* Effect.acquireRelease( diff --git a/packages/core/test/plugin-hooks.test.ts b/packages/core/test/plugin-hooks.test.ts new file mode 100644 index 0000000000..1223aa0c94 --- /dev/null +++ b/packages/core/test/plugin-hooks.test.ts @@ -0,0 +1,45 @@ +import { describe, expect } from "bun:test" +import { Message, SystemPart } from "@opencode-ai/ai" +import { Agent } from "@opencode-ai/schema/agent" +import { Model } from "@opencode-ai/schema/model" +import { Provider } from "@opencode-ai/schema/provider" +import { Session } from "@opencode-ai/schema/session" +import { Effect, Layer } from "effect" +import { PluginHooks } from "../src/plugin/hooks" +import { testEffect } from "./lib/effect" + +const layer = PluginHooks.node.implementation as Layer.Layer +const it = testEffect(layer) + +describe("PluginHooks", () => { + it.effect("registers scoped AI hooks and triggers them sequentially", () => + Effect.gen(function* () { + const hooks = yield* PluginHooks.Service + const seen: string[] = [] + yield* hooks.register("ai", "request", (event) => + Effect.sync(() => { + seen.push("first") + event.system.push(SystemPart.make("second")) + }), + ) + yield* hooks.register("ai", "request", (event) => + Effect.sync(() => { + seen.push(event.system[1]?.text ?? "missing") + event.messages = [Message.user("changed")] + }), + ) + const event = { + sessionID: Session.ID.make("ses_hooks"), + agent: Agent.ID.make("build"), + model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }), + system: [SystemPart.make("first")], + messages: [Message.user("original")], + tools: {}, + } + + expect(yield* hooks.trigger("ai", "request", event)).toBe(event) + expect(seen).toEqual(["first", "second"]) + expect(event.messages).toEqual([Message.user("changed")]) + }), + ) +}) diff --git a/packages/core/test/plugin/fixtures/models-dev-reasoning.json b/packages/core/test/plugin/fixtures/models-dev-reasoning.json index 410b7c2572..b0bc8f5216 100644 --- a/packages/core/test/plugin/fixtures/models-dev-reasoning.json +++ b/packages/core/test/plugin/fixtures/models-dev-reasoning.json @@ -13,7 +13,7 @@ "attachment": false, "reasoning": true, "reasoning_options": [ - { "type": "effort", "values": ["low", "high"] }, + { "type": "effort", "values": [null, "null", "low", "high"] }, { "type": "budget_tokens", "min": 1024, "max": 64000 }, { "type": "toggle" } ], @@ -27,6 +27,11 @@ "headers": { "x-mode": "high" }, "body": { "service_tier": "priority" } } + }, + "pro": { + "provider": { + "body": { "reasoning": { "mode": "pro" } } + } } } } @@ -49,18 +54,296 @@ "reasoning_options": [{ "type": "budget_tokens", "min": 1024, "max": 64000 }], "temperature": true, "tool_call": true, - "limit": { "context": 128000, "output": 8192 } + "limit": { "context": 128000, "output": 64000 } }, "claude-effort": { - "id": "claude-effort", + "id": "claude-opus-4.7", "name": "Claude Effort", "release_date": "2026-01-01", "attachment": false, "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "effort", "values": ["low"] }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 8192 } + }, + "claude-toggle": { + "id": "claude-toggle", + "name": "Claude Toggle", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 8192 } + }, + "claude-opus-4-5": { + "id": "claude-opus-4-5", + "name": "Claude Opus 4.5", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [ + { "type": "effort", "values": ["low", "high"] }, + { "type": "budget_tokens", "min": 1024 } + ], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 8192 } + } + } + }, + "xai": { + "id": "xai", + "name": "xAI", + "env": ["XAI_API_KEY"], + "npm": "@ai-sdk/xai", + "models": { + "grok-4.5": { + "id": "grok-4.5", + "name": "Grok 4.5", + "release_date": "2026-07-08", + "attachment": true, + "reasoning": true, + "reasoning_options": [{ "type": "effort", "values": ["low", "medium", "high"] }], + "temperature": true, + "tool_call": true, + "limit": { "context": 500000, "output": 500000 } + } + } + }, + "opencode-go": { + "id": "opencode-go", + "name": "OpenCode Go", + "env": ["OPENCODE_API_KEY"], + "npm": "@ai-sdk/openai-compatible", + "models": { + "minimax-m3": { + "id": "minimax-m3", + "name": "MiniMax-M3", + "release_date": "2026-05-31", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }], + "temperature": true, + "tool_call": true, + "limit": { "context": 1000000, "output": 131072 }, + "provider": { "npm": "@ai-sdk/anthropic" } + } + } + }, + "alibaba": { + "id": "alibaba", + "name": "Alibaba", + "env": ["ALIBABA_API_KEY"], + "npm": "@ai-sdk/alibaba", + "models": { + "toggle-only": { + "id": "toggle-only", + "name": "Toggle Only", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + }, + "toggle-budget": { + "id": "toggle-budget", + "name": "Toggle Budget", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "budget_tokens", "max": 16000 }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + } + } + }, + "vercel": { + "id": "vercel", + "name": "Vercel AI Gateway", + "env": ["AI_GATEWAY_API_KEY"], + "npm": "@ai-sdk/gateway", + "models": { + "alibaba/qwen-toggle": { + "id": "alibaba/qwen-toggle", + "name": "Gateway Alibaba Toggle", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "budget_tokens", "max": 16000 }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + }, + "amazon/nova-2-lite": { + "id": "amazon/nova-2-lite", + "name": "Gateway Nova 2 Lite", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "effort", "values": ["low", "high"] }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + }, + "deepseek/deepseek-toggle": { + "id": "deepseek/deepseek-toggle", + "name": "Gateway DeepSeek Toggle", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "effort", "values": ["low", "high"] }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + } + } + }, + "openrouter": { + "id": "openrouter", + "name": "OpenRouter", + "env": ["OPENROUTER_API_KEY"], + "npm": "@openrouter/ai-sdk-provider", + "models": { + "openrouter-toggle": { + "id": "openrouter-toggle", + "name": "OpenRouter Toggle", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + } + } + }, + "google": { + "id": "google", + "name": "Google", + "env": ["GOOGLE_GENERATIVE_AI_API_KEY"], + "npm": "@ai-sdk/google", + "models": { + "gemini-2.5-flash": { + "id": "gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "budget_tokens", "min": 0, "max": 16000 }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + } + } + }, + "google-vertex": { + "id": "google-vertex", + "name": "Google Vertex", + "env": ["GOOGLE_VERTEX_PROJECT"], + "npm": "@ai-sdk/google-vertex", + "models": { + "gemini-2.5-flash-lite": { + "id": "gemini-2.5-flash-lite", + "name": "Gemini 2.5 Flash Lite", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "budget_tokens", "min": 512, "max": 16000 }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + } + } + }, + "amazon-bedrock": { + "id": "amazon-bedrock", + "name": "Amazon Bedrock", + "env": ["AWS_ACCESS_KEY_ID"], + "npm": "@ai-sdk/amazon-bedrock", + "models": { + "amazon.nova-2-lite-v1:0": { + "id": "amazon.nova-2-lite-v1:0", + "name": "Nova 2 Lite", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "effort", "values": ["low", "high"] }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + } + } + }, + "sap-ai-core": { + "id": "sap-ai-core", + "name": "SAP AI Core", + "env": ["AICORE_SERVICE_KEY"], + "npm": "@jerome-benoit/sap-ai-provider-v2", + "models": { + "gemini-2.5-flash": { + "id": "gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "budget_tokens", "min": 0, "max": 16000 }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + }, + "amazon--nova-lite": { + "id": "amazon--nova-lite", + "name": "Nova Lite", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "effort", "values": ["low", "high"] }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + }, + "cohere--command-a-reasoning": { + "id": "cohere--command-a-reasoning", + "name": "Command A Reasoning", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [ + { "type": "toggle" }, + { "type": "effort", "values": ["low", "high"] }, + { "type": "budget_tokens", "min": 1 } + ], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + }, + "anthropic--claude-4.7-opus": { + "id": "anthropic--claude-4.7-opus", + "name": "Claude 4.7 Opus", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, "reasoning_options": [{ "type": "effort", "values": ["low"] }], "temperature": true, "tool_call": true, - "limit": { "context": 128000, "output": 8192 } + "limit": { "context": 128000, "output": 20000 } + }, + "anthropic--claude-4-sonnet": { + "id": "anthropic--claude-4-sonnet", + "name": "Claude 4 Sonnet", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "budget_tokens", "min": 1024, "max": 16000 }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } } } } diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 679624ee57..ae7c66ec83 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -18,6 +18,9 @@ export function host(overrides: Overrides = {}): PluginContext { transform: () => Effect.die("unused agent.transform"), reload: () => Effect.die("unused agent.reload"), }, + ai: overrides.ai ?? { + hook: () => Effect.die("unused ai.hook"), + }, aisdk: overrides.aisdk ?? { hook: () => Effect.die("unused aisdk.hook"), }, diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 096d73f5bf..f07c96b439 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -292,6 +292,12 @@ describe("ModelsDevPlugin", () => { ModelV2.VariantID.make("high"), ]) + const pro = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-pro")) + expect(pro).toMatchObject({ + id: "gpt-reasoning-pro", + body: { reasoning: { mode: "pro" } }, + }) + const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget")) expect(budgetModel?.variants).toContainEqual({ id: ModelV2.VariantID.make("high"), @@ -299,17 +305,292 @@ describe("ModelsDevPlugin", () => { }) expect(budgetModel?.variants).toContainEqual({ id: ModelV2.VariantID.make("max"), - settings: { thinking: { type: "enabled", budgetTokens: 64000 } }, + settings: { thinking: { type: "enabled", budgetTokens: 31999 } }, }) const anthropicEffortModel = yield* catalog.model.get( ProviderV2.ID.anthropic, - ModelV2.ID.make("claude-effort"), + ModelV2.ID.make("claude-opus-4.7"), ) - expect(anthropicEffortModel?.variants).toContainEqual({ - id: ModelV2.VariantID.make("low"), - settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" }, - }) + expect(anthropicEffortModel?.variants).toEqual([ + { id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } }, + { + id: ModelV2.VariantID.make("low"), + settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" }, + }, + ]) + + const anthropicToggleModel = yield* catalog.model.get( + ProviderV2.ID.anthropic, + ModelV2.ID.make("claude-toggle"), + ) + expect(anthropicToggleModel?.variants).toEqual([ + { id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { thinking: { type: "adaptive", display: "summarized" } }, + }, + ]) + + const opus45 = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-opus-4-5")) + expect(opus45?.variants).toEqual([ + { id: ModelV2.VariantID.make("low"), settings: { effort: "low" } }, + { id: ModelV2.VariantID.make("high"), settings: { effort: "high" } }, + ]) + + const grok = yield* catalog.model.get(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4.5")) + expect(grok?.variants).toEqual( + ["low", "medium", "high"].map((id) => ({ + id: ModelV2.VariantID.make(id), + settings: { reasoningEffort: id }, + })), + ) + + const minimax = yield* catalog.model.get(ProviderV2.ID.make("opencode-go"), ModelV2.ID.make("minimax-m3")) + expect(minimax?.variants).toEqual([ + { id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { thinking: { type: "adaptive", display: "summarized" } }, + }, + ]) + + const toggle = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-only")) + expect(toggle?.variants).toEqual([ + { id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } }, + { id: ModelV2.VariantID.make("thinking"), settings: { enableThinking: true } }, + ]) + + const combined = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-budget")) + expect(combined?.variants).toEqual([ + { id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } }, + { + id: ModelV2.VariantID.make("high"), + settings: { enableThinking: true, thinkingBudget: 8000 }, + }, + { + id: ModelV2.VariantID.make("max"), + settings: { enableThinking: true, thinkingBudget: 16000 }, + }, + ]) + + const gateway = yield* catalog.model.get(ProviderV2.ID.make("vercel"), ModelV2.ID.make("alibaba/qwen-toggle")) + expect(gateway?.variants).toEqual([ + { id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } }, + { + id: ModelV2.VariantID.make("high"), + settings: { enableThinking: true, thinkingBudget: 8000 }, + }, + { + id: ModelV2.VariantID.make("max"), + settings: { enableThinking: true, thinkingBudget: 16000 }, + }, + ]) + + const gatewayNova = yield* catalog.model.get( + ProviderV2.ID.make("vercel"), + ModelV2.ID.make("amazon/nova-2-lite"), + ) + expect(gatewayNova?.variants).toEqual([ + { + id: ModelV2.VariantID.make("none"), + settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } }, + }, + { + id: ModelV2.VariantID.make("low"), + settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } }, + }, + { + id: ModelV2.VariantID.make("high"), + settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }, + }, + ]) + + const gatewayFallback = yield* catalog.model.get( + ProviderV2.ID.make("vercel"), + ModelV2.ID.make("deepseek/deepseek-toggle"), + ) + expect(gatewayFallback?.variants).toEqual([ + { + id: ModelV2.VariantID.make("none"), + settings: { reasoning: { enabled: false } }, + }, + { + id: ModelV2.VariantID.make("low"), + settings: { reasoningEffort: "low" }, + }, + { + id: ModelV2.VariantID.make("high"), + settings: { reasoningEffort: "high" }, + }, + ]) + + const openrouter = yield* catalog.model.get( + ProviderV2.ID.make("openrouter"), + ModelV2.ID.make("openrouter-toggle"), + ) + expect(openrouter?.variants).toEqual([ + { id: ModelV2.VariantID.make("none"), settings: { reasoning: { enabled: false } } }, + { id: ModelV2.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } }, + ]) + + const google = yield* catalog.model.get(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini-2.5-flash")) + expect(google?.variants).toEqual([ + { + id: ModelV2.VariantID.make("none"), + settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } }, + }, + { + id: ModelV2.VariantID.make("high"), + settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } }, + }, + { + id: ModelV2.VariantID.make("max"), + settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } }, + }, + ]) + + const vertex = yield* catalog.model.get( + ProviderV2.ID.make("google-vertex"), + ModelV2.ID.make("gemini-2.5-flash-lite"), + ) + expect(vertex?.variants).toEqual([ + { + id: ModelV2.VariantID.make("none"), + settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } }, + }, + { + id: ModelV2.VariantID.make("high"), + settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } }, + }, + { + id: ModelV2.VariantID.make("max"), + settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } }, + }, + ]) + + const bedrock = yield* catalog.model.get( + ProviderV2.ID.make("amazon-bedrock"), + ModelV2.ID.make("amazon.nova-2-lite-v1:0"), + ) + expect(bedrock?.variants).toEqual([ + { + id: ModelV2.VariantID.make("none"), + settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } }, + }, + { + id: ModelV2.VariantID.make("low"), + settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } }, + }, + { + id: ModelV2.VariantID.make("high"), + settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }, + }, + ]) + + const sapGemini = yield* catalog.model.get( + ProviderV2.ID.make("sap-ai-core"), + ModelV2.ID.make("gemini-2.5-flash"), + ) + expect(sapGemini?.variants).toEqual([ + { + id: ModelV2.VariantID.make("none"), + settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } }, + }, + { + id: ModelV2.VariantID.make("high"), + settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } } }, + }, + { + id: ModelV2.VariantID.make("max"), + settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } } }, + }, + ]) + + const sapNova = yield* catalog.model.get( + ProviderV2.ID.make("sap-ai-core"), + ModelV2.ID.make("amazon--nova-lite"), + ) + expect(sapNova?.variants).toEqual([ + { + id: ModelV2.VariantID.make("none"), + settings: { + modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } }, + }, + }, + { + id: ModelV2.VariantID.make("low"), + settings: { + modelParams: { additionalModelRequestFields: { output_config: { effort: "low" } } }, + }, + }, + { + id: ModelV2.VariantID.make("high"), + settings: { + modelParams: { additionalModelRequestFields: { output_config: { effort: "high" } } }, + }, + }, + ]) + + const sapCohere = yield* catalog.model.get( + ProviderV2.ID.make("sap-ai-core"), + ModelV2.ID.make("cohere--command-a-reasoning"), + ) + expect(sapCohere?.variants).toEqual([ + { + id: ModelV2.VariantID.make("none"), + settings: { modelParams: { thinking: { type: "disabled" } } }, + }, + { + id: ModelV2.VariantID.make("low"), + settings: { modelParams: { reasoning_effort: "low" } }, + }, + { + id: ModelV2.VariantID.make("high"), + settings: { modelParams: { reasoning_effort: "high" } }, + }, + ]) + + const sapAnthropicEffort = yield* catalog.model.get( + ProviderV2.ID.make("sap-ai-core"), + ModelV2.ID.make("anthropic--claude-4.7-opus"), + ) + expect(sapAnthropicEffort?.variants).toEqual([ + { + id: ModelV2.VariantID.make("low"), + settings: { + modelParams: { + additionalModelRequestFields: { + thinking: { type: "adaptive", display: "summarized" }, + output_config: { effort: "low" }, + }, + }, + }, + }, + ]) + + const sapAnthropicBudget = yield* catalog.model.get( + ProviderV2.ID.make("sap-ai-core"), + ModelV2.ID.make("anthropic--claude-4-sonnet"), + ) + expect(sapAnthropicBudget?.variants).toEqual([ + { + id: ModelV2.VariantID.make("high"), + settings: { + modelParams: { + additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 8000 } }, + }, + }, + }, + { + id: ModelV2.VariantID.make("max"), + settings: { + modelParams: { + additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 16000 } }, + }, + }, + }, + ]) }).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))), (previous) => Effect.sync(() => { diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 2c6fccc892..e7577faa6a 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -1,13 +1,18 @@ import { describe, expect } from "bun:test" +import { Message, SystemPart } from "@opencode-ai/ai" import { Effect, Schema } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginPromise } from "@opencode-ai/core/plugin/promise" import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { Plugin } from "@opencode-ai/plugin/v2" +import type { AIHooks } from "@opencode-ai/plugin/v2/effect/ai" +import { Model } from "@opencode-ai/schema/model" +import { Provider } from "@opencode-ai/schema/provider" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -72,6 +77,38 @@ describe("fromPromise", () => { }), ) + it.effect("forwards AI request hooks", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const hooks = yield* PluginHooks.Service + const host = yield* PluginHost.make(plugin) + yield* PluginPromise.fromPromise( + Plugin.define({ + id: "promise-ai-request", + setup: async (ctx) => { + await ctx.ai.hook("request", (event) => { + event.system.push(SystemPart.make("Promise hook")) + delete event.tools.echo + }) + }, + }), + ).effect(host) + const event: AIHooks["request"] = { + sessionID: SessionV2.ID.make("ses_promise_ai_request"), + agent: AgentV2.ID.make("build"), + model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }), + system: [SystemPart.make("Initial")], + messages: [Message.user("Hello")], + tools: { echo: { description: "Echo", input: { type: "object" } } }, + } + + yield* hooks.trigger("ai", "request", event) + + expect(event.system.map((part) => part.text)).toEqual(["Initial", "Promise hook"]) + expect(event.tools).toEqual({}) + }), + ) + it.effect("disposes a hook registration on request", () => Effect.gen(function* () { const agents = yield* AgentV2.Service diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 13109550c7..ca4772a584 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -198,6 +198,12 @@ describe("OpenAIPlugin", () => { ] }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5-pro"), () => {}) + catalog.model.update(item.id, ModelV2.ID.make("gpt-5.4-pro"), (model) => { + model.modelID = ModelV2.ID.make("gpt-5.4") + model.body = { reasoning: { mode: "pro" } } + }) + catalog.model.update(item.id, ModelV2.ID.make("gpt-5.6"), () => {}) + catalog.model.update(item.id, ModelV2.ID.make("gpt-5.6-sol"), () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {}) }) yield* credentials.create({ @@ -223,6 +229,13 @@ describe("OpenAIPlugin", () => { expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5-pro"))).enabled).toBe( false, ) + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.4-pro"))).enabled).toBe( + false, + ) + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.6"))).enabled).toBe(false) + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.6-sol"))).enabled).toBe( + true, + ) expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(false) }), ) diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index 94824af339..04bfc508f7 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -2,6 +2,7 @@ import { AISDK } from "@opencode-ai/core/aisdk" import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" import { Effect } from "effect" +import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" @@ -14,7 +15,6 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service const host = yield* PluginHost.make(plugin) yield* XAIPlugin.effect(host) }) @@ -33,9 +33,30 @@ function fakeSelectorSdk(calls: string[]) { } describe("XAIPlugin", () => { + it.effect("registers browser OAuth, device OAuth, and API key methods", () => + Effect.gen(function* () { + yield* addPlugin() + const integrations = yield* Integration.Service + const integration = yield* integrations.get(Integration.ID.make("xai")) + expect(integration?.name).toBe("xAI") + expect(integration?.methods).toEqual([ + { + id: Integration.MethodID.make("browser"), + type: "oauth", + label: "xAI Grok OAuth (SuperGrok Subscription)", + }, + { + id: Integration.MethodID.make("device"), + type: "oauth", + label: "xAI Grok OAuth (Headless / Remote / VPS)", + }, + { type: "key", label: "Manually enter API Key" }, + ]) + }), + ) + it.effect("creates an xAI SDK only for @ai-sdk/xai", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() @@ -66,7 +87,6 @@ describe("XAIPlugin", () => { it.effect("creates xAI SDKs for custom provider IDs", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() @@ -86,7 +106,6 @@ describe("XAIPlugin", () => { it.effect("uses responses with the model modelID for xAI language models", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service const calls: string[] = [] @@ -108,7 +127,6 @@ describe("XAIPlugin", () => { it.effect("ignores non-xAI providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service const calls: string[] = [] diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index f0e4049cc4..da90068ea4 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" -import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm" -import { OpenAIChat } from "@opencode-ai/llm/protocols" +import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai" +import { OpenAIChat } from "@opencode-ai/ai/protocols" import { Config } from "@opencode-ai/core/config" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index dc57b46f44..f9cf2e4c69 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" -import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm" -import { OpenAIChat } from "@opencode-ai/llm/protocols" +import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai" +import { OpenAIChat } from "@opencode-ai/ai/protocols" import { Config } from "@opencode-ai/core/config" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -18,6 +18,8 @@ import { SessionStore } from "@opencode-ai/core/session/store" import { SessionV2 } from "@opencode-ai/core/session" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" +import { Flag } from "@opencode-ai/core/flag/flag" +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { AbsolutePath } from "@opencode-ai/core/schema" import { DateTime, Effect, Fiber, Layer, Stream } from "effect" import { asc, eq } from "drizzle-orm" @@ -104,6 +106,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () => const events = yield* EventV2.Service const store = yield* SessionStore.Service const sessionID = SessionV2.ID.make("ses_manual_compaction") + const parentID = SessionV2.ID.make("ses_manual_compaction_parent") const userMessage = { id: SessionMessage.ID.create(), type: "user" as const, @@ -121,6 +124,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () => .values({ id: sessionID, project_id: Project.ID.global, + parent_id: parentID, slug: "manual-compaction", directory: "/project", title: "Manual compaction", @@ -151,6 +155,15 @@ it.effect("manual compaction summarizes short context instead of no-op", () => expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"]) expect(requests).toHaveLength(1) + expect(requests[0]?.http?.headers).toEqual({ + "x-session-affinity": sessionID, + "X-Session-Id": sessionID, + "x-parent-session-id": parentID, + "User-Agent": `opencode/${InstallationVersion}`, + "x-opencode-project": Project.ID.global, + "x-opencode-session": sessionID, + "x-opencode-client": Flag.OPENCODE_CLIENT, + }) expect(requests[0]?.generation).toBeUndefined() expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.") expect(yield* store.context(sessionID)).toMatchObject([ diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index 46bdb09e70..825910f7a4 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -14,7 +14,7 @@ import { TransportReason, UnknownProviderReason, ToolFailure, -} from "@opencode-ai/llm" +} from "@opencode-ai/ai" import { PermissionV2 } from "@opencode-ai/core/permission" import { Tool } from "@opencode-ai/plugin/v2/effect/tool" import { toSessionError } from "@opencode-ai/core/session/to-session-error" diff --git a/packages/core/test/session-execution.test.ts b/packages/core/test/session-execution.test.ts index 2775df3564..0e25881a62 100644 --- a/packages/core/test/session-execution.test.ts +++ b/packages/core/test/session-execution.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { LLMError, TransportReason } from "@opencode-ai/llm" +import { LLMError, TransportReason } from "@opencode-ai/ai" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index 1591dc4ed4..64f667541e 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { DateTime, Effect, Layer } from "effect" -import { Message } from "@opencode-ai/llm" +import { Message } from "@opencode-ai/ai" import { AgentV2 } from "@opencode-ai/core/agent" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 396b833a6d..8ddec2fae8 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Message } from "@opencode-ai/llm" +import { Message } from "@opencode-ai/ai" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionMessage } from "@opencode-ai/core/session/message" diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index f3c666b4a9..19f7d1999f 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" -import { LLM, Model } from "@opencode-ai/llm" -import { LLMClient } from "@opencode-ai/llm/route" +import { LLM, Model } from "@opencode-ai/ai" +import { LLMClient } from "@opencode-ai/ai/route" import { DateTime, Effect } from "effect" import { Money } from "@opencode-ai/schema/money" import { Headers } from "effect/unstable/http" @@ -378,7 +378,7 @@ describe("SessionRunnerModel", () => { it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( - model("@opencode-ai/llm/providers/openai", { + model("@opencode-ai/ai/providers/openai", { settings: { baseURL: "https://openai.example/v1" }, }), Credential.OAuth.make({ @@ -407,7 +407,7 @@ describe("SessionRunnerModel", () => { it.effect("does not route native OpenAI-compatible packages to the codex backend", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( - model("@opencode-ai/llm/providers/openai-compatible", { + model("@opencode-ai/ai/providers/openai-compatible", { settings: { baseURL: "https://compatible.example/v1" }, }), Credential.OAuth.make({ @@ -511,7 +511,7 @@ describe("SessionRunnerModel", () => { }), ) const resolved = yield* SessionRunnerModel.fromCatalogModel( - model("@opencode-ai/llm/providers/custom", { + model("@opencode-ai/ai/providers/custom", { settings: { region: "test" }, headers: { "x-package": "header" }, body: { custom: true }, @@ -519,7 +519,7 @@ describe("SessionRunnerModel", () => { undefined, { loadPackage: (specifier) => { - expect(specifier).toBe("@opencode-ai/llm/providers/custom") + expect(specifier).toBe("@opencode-ai/ai/providers/custom") return Effect.succeed({ model: (modelID, settings) => { expect(modelID).toBe("api-test-model") @@ -540,6 +540,42 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("maps OAuth credentials to native provider auth settings", () => + Effect.gen(function* () { + const native = yield* SessionRunnerModel.fromCatalogModel( + model(ProviderV2.aisdk("@ai-sdk/openai"), { + settings: { baseURL: "https://openai.example/v1" }, + }), + ) + const credential = Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("device"), + access: "oauth-token", + refresh: "refresh", + expires: Date.now() + 60_000, + }) + const packages = [ + ["@opencode-ai/ai/providers/google-vertex", "accessToken"], + ["@opencode-ai/ai/providers/google-vertex/anthropic", "accessToken"], + ["@opencode-ai/ai/providers/anthropic", "authToken"], + ["@opencode-ai/ai/providers/anthropic-compatible", "authToken"], + ] as const + + yield* Effect.forEach(packages, ([specifier, key]) => + SessionRunnerModel.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, { + loadPackage: () => + Effect.succeed({ + model: (modelID, settings) => { + expect(settings).toMatchObject({ [key]: "oauth-token" }) + expect(settings).not.toHaveProperty("apiKey") + return Model.make({ id: modelID, provider: "package-provider", route: native.route }) + }, + }), + }), + ) + }), + ) + it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () => Effect.gen(function* () { const native = yield* SessionRunnerModel.fromCatalogModel( @@ -601,7 +637,7 @@ describe("SessionRunnerModel", () => { it.effect("reports whether a catalog model declares a provider package", () => Effect.sync(() => { expect(SessionRunnerModel.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true) - expect(SessionRunnerModel.supported(model("@opencode-ai/llm/providers/custom"))).toBe(true) + expect(SessionRunnerModel.supported(model("@opencode-ai/ai/providers/custom"))).toBe(true) expect(SessionRunnerModel.supported(model(undefined))).toBe(false) }), ) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index d4fb21fbe3..58893bbb0f 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -1,6 +1,6 @@ import { HttpRecorder } from "@opencode-ai/http-recorder" -import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" -import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route" +import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat" +import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/ai/route" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" diff --git a/packages/core/test/session-runner-system-prompt.test.ts b/packages/core/test/session-runner-system-prompt.test.ts index c1e4b86eec..a367d913e5 100644 --- a/packages/core/test/session-runner-system-prompt.test.ts +++ b/packages/core/test/session-runner-system-prompt.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { Model } from "@opencode-ai/llm" -import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" +import { Model } from "@opencode-ai/ai" +import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat" import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt" const prompt = (id: string) => diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 1cdc71788a..93600c3058 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import { Effect, Schema } from "effect" -import { LLMEvent } from "@opencode-ai/llm" +import { LLMEvent } from "@opencode-ai/ai" import { Money } from "@opencode-ai/schema/money" import { EventV2 } from "@opencode-ai/core/event" import { AgentV2 } from "@opencode-ai/core/agent" diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 5add9239ed..2585355fe7 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -3,7 +3,9 @@ import { LLMClient, LLMError, LLMEvent, + Message, Model, + SystemPart, ToolFailure, TransportReason, InvalidProviderOutputReason, @@ -11,8 +13,8 @@ import { RateLimitReason, type LLMClientShape, type LLMRequest, -} from "@opencode-ai/llm" -import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" +} from "@opencode-ai/ai" +import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat" import { Database } from "@opencode-ai/core/database/database" import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -20,6 +22,7 @@ import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Flag } from "@opencode-ai/core/flag/flag" +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { PermissionV2 } from "@opencode-ai/core/permission" import { EventTable } from "@opencode-ai/core/event/sql" import { Project } from "@opencode-ai/core/project" @@ -41,6 +44,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { QuestionTool } from "@opencode-ai/core/tool/question" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { AgentV2 } from "@opencode-ai/core/agent" @@ -396,6 +400,7 @@ const it = testEffect( AgentV2.node, ToolRegistry.node, ToolRegistry.toolsNode, + PluginHooks.node, echoNode, SessionRunnerModel.node, InstructionBuiltIns.node, @@ -772,6 +777,32 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => }) describe("SessionRunnerLLM", () => { + it.effect("applies AI request hooks without exposing unavailable tools", () => + Effect.gen(function* () { + const session = yield* setup + const hooks = yield* PluginHooks.Service + yield* hooks.register("ai", "request", (event) => + Effect.sync(() => { + event.system = [SystemPart.make("Hooked system")] + event.messages = [Message.user("Hooked message")] + delete event.tools.echo + event.tools.unregistered = { description: "Unavailable", input: { type: "object" } } + }), + ) + yield* admit(session, "Original message") + responses = [reply.tool("call-removed", "echo", { text: "blocked" })] + + yield* session.resume(sessionID) + + expect(requests).toHaveLength(1) + expect(requests[0]?.system.map((part) => part.text)).toEqual(["Hooked system"]) + expect(requests[0]?.messages).toEqual([Message.user("Hooked message")]) + expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo") + expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("unregistered") + expect(executions).toEqual([]) + }), + ) + it.effect("advertises and executes a location registered tool", () => Effect.gen(function* () { const session = yield* setup @@ -3086,6 +3117,9 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) expect(requests[0]?.http?.headers).toEqual({ + "x-session-affinity": sessionID, + "X-Session-Id": sessionID, + "User-Agent": `opencode/${InstallationVersion}`, "x-opencode-project": Project.ID.global, "x-opencode-session": sessionID, "x-opencode-client": Flag.OPENCODE_CLIENT, @@ -3093,6 +3127,25 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("adds the parent session header to child model requests", () => + Effect.gen(function* () { + const session = yield* setup + const parentID = SessionV2.ID.make("ses_runner_parent") + const { db } = yield* Database.Service + yield* db + .update(SessionTable) + .set({ parent_id: parentID }) + .where(eq(SessionTable.id, sessionID)) + .run() + .pipe(Effect.orDie) + yield* admit(session, "Run child request") + + yield* session.resume(sessionID) + + expect(requests[0]?.http?.headers?.["x-parent-session-id"]).toBe(parentID) + }), + ) + it.effect("runs different sessions concurrently", () => Effect.gen(function* () { const session = yield* setup diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index aeb0963660..dccfaf63df 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" -import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm" -import { OpenAIChat } from "@opencode-ai/llm/protocols" +import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai" +import { OpenAIChat } from "@opencode-ai/ai/protocols" import { AgentV2 } from "@opencode-ai/core/agent" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -17,6 +17,8 @@ import { SessionTitle } from "@opencode-ai/core/session/title" import { SessionV2 } from "@opencode-ai/core/session" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" +import { Flag } from "@opencode-ai/core/flag/flag" +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { AbsolutePath } from "@opencode-ai/core/schema" import { DateTime, Effect, Layer, Stream } from "effect" import { testEffect } from "./lib/effect" @@ -117,6 +119,14 @@ it.effect("generates a title from the sole user message and renames the session" yield* title.generateForFirstPrompt(session) expect(requests).toHaveLength(1) + expect(requests[0]?.http?.headers).toEqual({ + "x-session-affinity": sessionID, + "X-Session-Id": sessionID, + "User-Agent": `opencode/${InstallationVersion}`, + "x-opencode-project": Project.ID.global, + "x-opencode-session": sessionID, + "x-opencode-client": Flag.OPENCODE_CLIENT, + }) expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build") const renamed = yield* store.get(sessionID) expect(renamed?.title).toBe("Generated Title") diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index b4ee1ed494..37b67c63a8 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -56,7 +56,7 @@ test("Core reuses the canonical shared schemas", async () => { import("@opencode-ai/core/filesystem"), import("@opencode-ai/core/integration"), import("@opencode-ai/core/location"), - import("@opencode-ai/llm"), + import("@opencode-ai/ai"), import("@opencode-ai/core/permission"), import("@opencode-ai/core/v1/permission"), import("@opencode-ai/core/project/copy"), diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index b1bad34102..45a3e42296 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -1,8 +1,11 @@ import { describe, expect } from "bun:test" +import fs from "fs/promises" +import path from "path" import { Effect, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FileSystem } from "@opencode-ai/core/filesystem" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" import { PermissionV2 } from "@opencode-ai/core/permission" @@ -16,7 +19,7 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool" +import { executeTool, registerToolPlugin, settleTool, toolIdentity } from "./lib/tool" const globToolNode = makeLocationNode({ name: "test/glob-tool-plugin", @@ -66,6 +69,32 @@ const call = (name: "glob" | "grep", input: unknown) => ({ const it = testEffect(Layer.empty) describe("search tools", () => { + it.live("bounds omitted glob and grep limits", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all( + Array.from({ length: FileSystem.DEFAULT_SEARCH_LIMIT + 1 }, (_, index) => + fs.writeFile(path.join(tmp.path, `${index}.txt`), "needle\n"), + ), + ), + ) + yield* withTools(tmp.path, (registry) => + Effect.gen(function* () { + const glob = yield* settleTool(registry, call("glob", { pattern: "*" })) + const grep = yield* settleTool(registry, call("grep", { pattern: "needle" })) + + expect(glob.output?.structured).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT) + expect(grep.output?.structured).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT) + }), + ) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + for (const name of ["glob", "grep"] as const) { it.live(`${name} reports a missing search path`, () => Effect.acquireUseRelease( diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 896cc96d03..007099b000 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -166,6 +166,10 @@ const overflowCommand = (bytes: number) => isWindows ? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100` : `head -c ${bytes} /dev/zero | tr '\\0' 'x'` +const progressOverflowCommand = (bytes: number) => + isWindows + ? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 1500` + : `head -c ${bytes} /dev/zero | tr '\\0' 'x'; sleep 1.5` const withSession = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => Effect.gen(function* () { @@ -413,6 +417,35 @@ describe("ShellTool", () => { ), ) + it.live("reports bounded output progress for a running command", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024 + return withSession(tmp.path, (registry) => + Effect.gen(function* () { + const progress: ToolRegistry.Progress[] = [] + yield* settleTool(registry, { + ...call({ command: progressOverflowCommand(bytes) }, "call-progress"), + progress: (update) => Effect.sync(() => progress.push(update)), + }) + + expect(progress).toHaveLength(1) + expect(progress[0]?.structured).toEqual({ truncated: true }) + const content = progress[0]?.content[0] + expect(content?.type).toBe("text") + if (content?.type !== "text") return + expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe( + ShellTool.MAX_CAPTURE_BYTES, + ) + }), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), + ), + ) + it.live("returns a useful timeout settlement", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -572,7 +605,6 @@ test("keeps locked deferred parity TODOs visible", async () => { "Replace token-based command-argument external-directory advisories with parser-based detection.", "Restore PowerShell and cmd-specific invocation/path handling on Windows.", "Add plugin shell.env environment augmentation once V2 plugin hooks exist.", - "Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.", "Persist job status and define restart recovery before exposing remote observation.", "Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.", "Revisit binary output handling if stdout/stderr decoding is text-only.", diff --git a/packages/core/test/util/process-lock.test.ts b/packages/core/test/util/process-lock.test.ts new file mode 100644 index 0000000000..9457b9c08e --- /dev/null +++ b/packages/core/test/util/process-lock.test.ts @@ -0,0 +1,71 @@ +import { expect } from "bun:test" +import { ProcessLock } from "@opencode-ai/core/util/process-lock" +import { Effect } from "effect" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { it } from "../lib/effect" + +const worker = path.join(import.meta.dir, "../fixture/process-lock-worker.ts") + +it.live( + "releases ownership when the scope closes", + Effect.gen(function* () { + const root = yield* temp("opencode-process-lock-") + const file = path.join(root, "service.lock") + yield* Effect.scoped(ProcessLock.acquire(file)) + yield* Effect.scoped(ProcessLock.acquire(file)) + }), +) + +it.live( + "releases ownership when the process dies", + Effect.gen(function* () { + const root = yield* temp("opencode-process-lock-death-") + const file = path.join(root, "service.lock") + const ready = path.join(root, "ready") + const child = yield* Effect.acquireRelease( + Effect.sync(() => + Bun.spawn([process.execPath, worker, JSON.stringify({ file, ready })], { + stdout: "ignore", + stderr: "pipe", + }), + ), + (child) => + Effect.promise(async () => { + kill(child) + await child.exited + }), + ) + yield* Effect.promise(async () => { + for (let attempt = 0; attempt < 100 && !(await Bun.file(ready).exists()); attempt++) await Bun.sleep(20) + }) + expect(yield* Effect.promise(() => Bun.file(ready).exists())).toBe(true) + + const error = yield* Effect.scoped(ProcessLock.acquire(file)).pipe(Effect.flip) + expect(error._tag).toBe("ProcessLockHeldError") + + if (process.platform !== "win32") { + process.kill(child.pid, "SIGSTOP") + const paused = yield* Effect.scoped(ProcessLock.acquire(file)).pipe(Effect.flip) + expect(paused._tag).toBe("ProcessLockHeldError") + process.kill(child.pid, "SIGCONT") + } + + kill(child) + yield* Effect.promise(() => child.exited) + yield* Effect.scoped(ProcessLock.acquire(file)) + }), +) + +function temp(prefix: string) { + return Effect.acquireRelease( + Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), prefix))), + (root) => Effect.promise(() => fs.rm(root, { recursive: true, force: true })), + ) +} + +function kill(child: Bun.Subprocess) { + if (process.platform === "win32") return child.kill() + return child.kill("SIGKILL") +} diff --git a/packages/docs/build/client.mdx b/packages/docs/build/client.mdx index 70eed5104f..0465dd6fed 100644 --- a/packages/docs/build/client.mdx +++ b/packages/docs/build/client.mdx @@ -70,6 +70,48 @@ for await (const event of client.event.subscribe()) { } ``` +## Local background service + +The main client entrypoints are browser-compatible and do not include local +process management. In a Node application, import the native Promise service +API from `@opencode-ai/client/service`. + +- `Service.discover()` returns a healthy registered endpoint without starting + a process. +- `Service.ensure()` returns a compatible service, starting one when needed. +- `Service.stop()` stops the exact registered service instance. +- `Service.headers(endpoint)` creates the authentication headers for a client. + +```ts +import { OpenCode } from "@opencode-ai/client" +import { Service } from "@opencode-ai/client/service" + +const endpoint = await Service.ensure() +const client = OpenCode.make({ + baseUrl: endpoint.url, + headers: Service.headers(endpoint), +}) + +const health = await client.health.get() +``` + +`Service.ensure()` accepts an optional registration file, required version, +service command, and `onStart` callback: + +```ts +const endpoint = await Service.ensure({ + file: "/var/run/opencode/service.json", + version: "2.0.0", + command: ["opencode", "serve", "--service"], + onStart(reason, previousVersion) { + console.log(reason, previousVersion) + }, +}) +``` + +Omit these options to use the standard registration path and +`opencode serve --service` command. + ## Effect OpenCode provides a first-class Effect client through the @@ -106,16 +148,11 @@ const session = await Effect.runPromise( Streaming operations, including `client.event.subscribe()` and `client.session.log(...)`, return Effect `Stream` values. -### Service +### Local background service -`Service` discovers and manages the local OpenCode background service from a -Node application: - -- `Service.discover()` returns a healthy registered endpoint without starting - a process. -- `Service.start()` reuses a compatible service or starts one when needed. -- `Service.stop()` stops the registered service. -- `Service.headers(endpoint)` creates the authentication headers for a client. +The Node-only `@opencode-ai/client/effect/service` entrypoint exposes the same +operations as Effect values. Add `@effect/platform-node` and provide its +filesystem layer when running them. ```sh bun add @effect/platform-node @@ -123,12 +160,13 @@ bun add @effect/platform-node ```ts import { NodeFileSystem } from "@effect/platform-node" -import { OpenCode, Service } from "@opencode-ai/client/effect" +import { OpenCode } from "@opencode-ai/client/effect" +import { Service } from "@opencode-ai/client/effect/service" import { Effect } from "effect" import { FetchHttpClient } from "effect/unstable/http" const program = Effect.gen(function* () { - const endpoint = yield* Service.start() + const endpoint = yield* Service.ensure() const client = yield* OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint), diff --git a/packages/docs/providers.mdx b/packages/docs/providers.mdx index 46e6c3f93c..a88eca912c 100644 --- a/packages/docs/providers.mdx +++ b/packages/docs/providers.mdx @@ -140,7 +140,7 @@ provider has no Models.dev catalog entries: "acme": { "name": "Acme Gateway", "env": ["ACME_API_KEY"], - "package": "@opencode-ai/llm/providers/openai-compatible", + "package": "@opencode-ai/ai/providers/openai-compatible", "settings": { "baseURL": "https://llm.acme.example/v1" }, @@ -170,7 +170,7 @@ The `package` field supports two runtime contracts: | Form | Contract | | --- | --- | -| `"@opencode-ai/llm/providers/openai-compatible"` | A V2 native package exporting `model(modelID, settings)`. An npm specifier or absolute `file://` URL may use the same contract. | +| `"@opencode-ai/ai/providers/openai-compatible"` | A V2 native package exporting `model(modelID, settings)`. An npm specifier or absolute `file://` URL may use the same contract. | | `"aisdk:@ai-sdk/openai-compatible"` | An AI SDK provider package. The `aisdk:` prefix is required. | Native packages receive the merged `settings` plus the resolved `apiKey`, `headers`, `body`, and `limits`. AI SDK diff --git a/packages/docs/troubleshooting.mdx b/packages/docs/troubleshooting.mdx index 57856c9d71..f0fe58cee9 100644 --- a/packages/docs/troubleshooting.mdx +++ b/packages/docs/troubleshooting.mdx @@ -4,8 +4,8 @@ description: "Diagnose OpenCode startup, server, and session issues." --- - You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read the - steps below, inspect its service and logs, and help identify the issue. + You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read + the steps below, inspect its service and logs, and help identify the issue. OpenCode runs as two processes: the TUI is a client, while a background server owns sessions, plugins, permissions, and @@ -31,10 +31,10 @@ If the service is stuck or unhealthy, restart it: opencode2 service restart ``` -From inside the TUI, run `/reload` to restart the managed service and reconnect: +From inside the TUI, run `/restart` to restart the managed service and reconnect: ```text -/reload +/restart ``` You can also stop and start it explicitly: @@ -45,8 +45,8 @@ opencode2 service start ``` - OpenCode normally discovers or starts the shared background service automatically. The service commands are only needed - when diagnosing its lifecycle. + OpenCode normally discovers or starts the shared background service automatically. The service commands are only + needed when diagnosing its lifecycle. ## Run an isolated session @@ -125,8 +125,8 @@ The database normally lives at: `OPENCODE_DB` can override the database location. - Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the daemon, - and make a backup before inspecting persistent data with external tools. + Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the + daemon, and make a backup before inspecting persistent data with external tools. ## Explicit servers diff --git a/packages/llm/package.json b/packages/llm/package.json deleted file mode 100644 index 3ca36b1310..0000000000 --- a/packages/llm/package.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.20", - "name": "@opencode-ai/llm", - "type": "module", - "license": "MIT", - "private": true, - "scripts": { - "setup:recording-env": "bun run script/setup-recording-env.ts", - "test": "bun test --timeout 30000 --only-failures", - "typecheck": "tsgo --noEmit" - }, - "exports": { - ".": "./src/index.ts", - "./route": "./src/route/index.ts", - "./provider": "./src/provider.ts", - "./providers": "./src/providers/index.ts", - "./provider-package": "./src/provider-package.ts", - "./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts", - "./providers/anthropic": "./src/providers/anthropic.ts", - "./providers/azure": "./src/providers/azure.ts", - "./providers/azure/responses": "./src/providers/azure/responses.ts", - "./providers/azure/chat": "./src/providers/azure/chat.ts", - "./providers/cloudflare": "./src/providers/cloudflare.ts", - "./providers/github-copilot": "./src/providers/github-copilot.ts", - "./providers/google": "./src/providers/google.ts", - "./providers/openai": "./src/providers/openai.ts", - "./providers/openai/responses": "./src/providers/openai/responses.ts", - "./providers/openai/chat": "./src/providers/openai/chat.ts", - "./providers/openai-compatible": "./src/providers/openai-compatible.ts", - "./providers/openai-compatible/responses": "./src/providers/openai-compatible-responses.ts", - "./providers/openai-compatible-profile": "./src/providers/openai-compatible-profile.ts", - "./providers/openrouter": "./src/providers/openrouter.ts", - "./providers/xai": "./src/providers/xai.ts", - "./protocols": "./src/protocols/index.ts", - "./protocols/anthropic-messages": "./src/protocols/anthropic-messages.ts", - "./protocols/bedrock-converse": "./src/protocols/bedrock-converse.ts", - "./protocols/gemini": "./src/protocols/gemini.ts", - "./protocols/openai-chat": "./src/protocols/openai-chat.ts", - "./protocols/openai-compatible-chat": "./src/protocols/openai-compatible-chat.ts", - "./protocols/openai-compatible-responses": "./src/protocols/openai-compatible-responses.ts", - "./protocols/openai-responses": "./src/protocols/openai-responses.ts" - }, - "devDependencies": { - "@clack/prompts": "1.0.0-alpha.1", - "@effect/platform-node": "catalog:", - "@opencode-ai/http-recorder": "workspace:*", - "@tsconfig/bun": "catalog:", - "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:" - }, - "dependencies": { - "@smithy/eventstream-codec": "4.2.14", - "@smithy/util-utf8": "4.2.2", - "@opencode-ai/schema": "workspace:*", - "aws4fetch": "1.0.20", - "effect": "catalog:" - } -} diff --git a/packages/llm/test/provider-package.test.ts b/packages/llm/test/provider-package.test.ts deleted file mode 100644 index 0428c02eec..0000000000 --- a/packages/llm/test/provider-package.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { model } from "@opencode-ai/llm/providers/openai" - -describe("provider package entrypoints", () => { - test("semantic API aliases expose the same contract", async () => { - const modules = await Promise.all([ - import("@opencode-ai/llm/providers/openai"), - import("@opencode-ai/llm/providers/openai/responses"), - import("@opencode-ai/llm/providers/openai/chat"), - import("@opencode-ai/llm/providers/anthropic"), - import("@opencode-ai/llm/providers/openai-compatible"), - import("@opencode-ai/llm/providers/openai-compatible/responses"), - import("@opencode-ai/llm/providers/amazon-bedrock"), - import("@opencode-ai/llm/providers/azure"), - import("@opencode-ai/llm/providers/azure/responses"), - import("@opencode-ai/llm/providers/azure/chat"), - import("@opencode-ai/llm/providers/google"), - ]) - - for (const module of modules) expect(module.model).toBeFunction() - expect(modules[0].model).toBe(modules[1].model) - expect(modules[7].model).toBe(modules[8].model) - }) - - test("maps package settings onto the executable model", () => { - const selected = model("gpt-5", { - apiKey: "fixture", - baseURL: "https://api.openai.test/v1", - headers: { "x-application": "opencode" }, - body: { service_tier: "priority" }, - limits: { context: 200_000, output: 64_000 }, - unrelatedInheritedSetting: true, - }) - - expect(selected.route.id).toBe("openai-responses") - expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) - expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" }) - expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) - }) - - test("selects transport without changing the semantic API", () => { - expect(model("gpt-5", { apiKey: "fixture" }).route.id).toBe("openai-responses") - expect(model("gpt-5", { apiKey: "fixture", transport: "websocket" }).route.id).toBe("openai-responses-websocket") - }) - - test("maps OpenAI-compatible Responses settings onto the executable model", async () => { - const OpenAICompatibleResponses = await import("@opencode-ai/llm/providers/openai-compatible/responses") - const selected = OpenAICompatibleResponses.model("custom-model", { - apiKey: "fixture", - baseURL: "https://responses.example.test/v1", - provider: "example", - headers: { "x-application": "opencode" }, - body: { service_tier: "priority" }, - limits: { context: 200_000, output: 64_000 }, - providerOptions: { openai: { reasoningEffort: "low", store: true } }, - }) - - expect(String(selected.provider)).toBe("example") - expect(selected.route.id).toBe("openai-compatible-responses") - expect(selected.route.endpoint).toMatchObject({ - baseURL: "https://responses.example.test/v1", - path: "/responses", - }) - expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) - expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" }) - expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) - expect(selected.route.defaults.providerOptions).toEqual({ - openai: { reasoningEffort: "low", store: true }, - }) - }) - - test("maps legacy OpenAI organization and project settings to headers", () => { - const selected = model("gpt-5", { - apiKey: "fixture", - organization: "org_123", - project: "proj_123", - }) - - expect(selected.route.defaults.headers).toMatchObject({ - "OpenAI-Organization": "org_123", - "OpenAI-Project": "proj_123", - }) - }) - - test("selects Azure API entrypoints with the same model contract", async () => { - const Azure = await import("@opencode-ai/llm/providers/azure") - const AzureChat = await import("@opencode-ai/llm/providers/azure/chat") - const AzureResponses = await import("@opencode-ai/llm/providers/azure/responses") - const settings = { - apiKey: "fixture", - resourceName: "opencode-test", - headers: { "x-application": "opencode" }, - body: { service_tier: "priority" }, - limits: { context: 200_000, output: 64_000 }, - } - - const responses = AzureResponses.model("deployment", settings) - const chat = AzureChat.model("deployment", settings) - - expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses") - expect(responses.route.id).toBe("azure-openai-responses") - expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1") - expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" }) - expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" }) - expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) - expect(chat.route.id).toBe("azure-openai-chat") - }) - - test("maps Google package settings onto the Gemini model", async () => { - const Google = await import("@opencode-ai/llm/providers/google") - const selected = Google.model("gemini-2.5-flash", { - apiKey: "fixture", - baseURL: "https://generativelanguage.test/v1beta", - headers: { "x-application": "opencode" }, - body: { safetySettings: [] }, - limits: { context: 1_000_000, output: 65_536 }, - providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } }, - }) - - expect(selected.route.id).toBe("gemini") - expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta") - expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) - expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] }) - expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 }) - expect(selected.route.defaults.providerOptions).toEqual({ - gemini: { thinkingConfig: { thinkingBudget: 1_024 } }, - }) - }) -}) diff --git a/packages/llm/tsconfig.json b/packages/llm/tsconfig.json deleted file mode 100644 index 2bc480ffbb..0000000000 --- a/packages/llm/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@tsconfig/bun/tsconfig.json", - "compilerOptions": { - "lib": ["ESNext", "DOM", "DOM.Iterable"], - "noUncheckedIndexedAccess": false, - "plugins": [ - { - "name": "@effect/language-service", - "transform": "@effect/language-service/transform", - "namespaceImportPackages": ["effect", "@effect/*"] - } - ] - } -} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 0f4c85d246..a65144ef82 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -87,7 +87,7 @@ "@opencode-ai/client": "workspace:*", "@opencode-ai/cli": "workspace:*", "@opencode-ai/codemode": "workspace:*", - "@opencode-ai/llm": "workspace:*", + "@opencode-ai/ai": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/protocol": "workspace:*", "@opencode-ai/schema": "workspace:*", diff --git a/packages/opencode/specs/simulation/simulated-network-llm.md b/packages/opencode/specs/simulation/simulated-network-llm.md index f09685b26a..29ccc5d4a8 100644 --- a/packages/opencode/specs/simulation/simulated-network-llm.md +++ b/packages/opencode/specs/simulation/simulated-network-llm.md @@ -4,7 +4,7 @@ Status: design for the Phase 2 network and LLM items in `simulation-phases.md`. ## Summary -Simulation replaces the `HttpClient.HttpClient` platform node with a simulated network. The LLM is not a separate fake: it is one registered route in that network (`api.openai.com`), answered by the **external driver** over the existing control WebSocket. When the app issues a provider request, the backend forwards it to the driver and the driver streams response chunks back. There is no enqueueing and no scripted-response store; the driver is the model. +Simulation replaces the `HttpClient.HttpClient` platform node with a simulated network. The LLM is not replaced: an OpenAI route intercepts the real provider request and delegates its response to a **simulated model provider** controlled by the external driver. There is no server-side response script or replay adapter; the driver decides what the provider returns. Everything above the HTTP boundary runs real: catalog and auth resolution, `LLMClient`, request body construction, SSE framing, the OpenAI protocol event schema, the `step` state machine, `Lifecycle` grammar, tool-argument accumulation, the session runner, tools, and permissions. @@ -29,12 +29,12 @@ Replacing `httpClient` (already a `LayerNode` in `app-node-platform.ts`, already ### 1. Simulated network (`packages/simulation/src/backend/network.ts`) -Replaces `httpClient` in `simulationReplacements`. An in-memory route table: +Replaces `httpClient` in `simulationReplacements`. Each acquired network run owns its route table and bounded request log: -- `register(matcher, responder)` where matcher is method + URL pattern and responder is `(HttpClientRequest) => Effect`. +- `make(routes)` constructs one isolated client and log; routes are ordinary matchers supplied at acquisition. - Unknown requests fail loudly with a typed simulation error (spec: deny unknown external network by default). - Optional loopback allowance for the app's own server is not required server-side (the server does not call itself over HTTP); revisit if a consumer needs it. -- Every request/response summary is traced. +- Every request summary is timestamped through Effect `Clock` and retained only for that run. ### 2. OpenAI endpoint route (`packages/simulation/src/backend/openai.ts`) @@ -42,36 +42,44 @@ Registered in the network at startup for `POST {DEFAULT_BASE_URL}{PATH}` from `p On request: -1. Allocate an exchange id. Parse the real OpenAI request body (available to the driver for assertions). -2. Publish a `request` record to the LLM exchange service (below) and create a chunk `Queue`. -3. Return `HttpClientResponse` with `content-type: text/event-stream` whose body stream reads from the queue, encoding each item as an SSE `data:` frame, terminated by `[DONE]`. +1. Parse the real OpenAI request body, which remains available to the driver for assertions. +2. Call `SimulatedProvider.Service.stream({ url, body })`. +3. Encode the returned provider response events as SSE `data:` frames and terminate a finished response with `[DONE]`. Chunks are constructed through the `OpenAIChatEvent` schema so drift in the protocol schema breaks the build, not the runtime. -The response stream is interruptible like a real HTTP response: if the runner cancels (user interrupt), the exchange closes and the driver is notified. +The response stream is interruptible like a real HTTP response. If the runner cancels, the provider invocation is removed and later driver commands for its id fail. -### 3. LLM exchange service (`packages/simulation/src/backend/llm-exchange.ts`) +### 3. Simulated provider (`packages/simulation/src/backend/simulated-provider.ts`) -Process-global simulation service owning pending exchanges: +The OpenAI route sees one Effect service: -``` -Exchange = { id, body, queue: Queue, deferred lifecycle } +```ts +interface SimulatedProvider { + stream(request: ProviderRequest): Stream +} ``` -- `requests()` — stream of newly opened exchanges (consumed by the control route). -- `push(id, item)` — append one response item to an open exchange. -- `finish(id, reason)` / `fail(id, failure)` — terminate the exchange. -- Exchanges that receive no driver within a configurable timeout fail the provider request with a simulation error (surfaces in the real provider-error path). +`SimulatedProvider.layerDrive({ endpoint })` owns the Drive adapter in one Effect scope: + +- Pending provider invocations and response queues. +- Late controller attachment and pending-invocation replay. +- The backend control WebSocket and its request fibers. +- Stream interruption, explicit disconnect, finish, and scope cleanup. + +Invocation ids, queues, controller attachment, and WebSocket commands remain private to `layerDrive`. The OpenAI route only sees a provider request producing a response stream. ### 4. Backend control WebSocket (simulation-gated) Started when `OPENCODE_DRIVE` names a registry manifest: a loopback JSON-RPC 2.0 WebSocket at that manifest's exact backend endpoint, hosted by the backend process. Drivers connect to it directly — the standalone topology has exactly one backend per TUI, so there is no proxying through the frontend. This socket is also the headless-simulation interface: it works with no TUI at all. -Server -> driver notification (after `llm.attach`; pending exchanges are replayed on attach so late-attaching drivers miss nothing): +The backend and frontend control sockets share one scoped Effect adapter. It owns the Bun server, a bounded sequential message queue, its worker fiber, schema-based JSON decoding, and shutdown ordering. + +Server -> driver notification (after `llm.attach`; pending invocations are replayed on attach so late-attaching drivers miss nothing): ``` { "jsonrpc": "2.0", "method": "llm.request", - "params": { "id": "ex_1", "url": "...", "body": { ...openai request body... } } } + "params": { "id": "inv_1", "url": "...", "body": { ...openai request body... } } } ``` Driver -> server methods: @@ -79,8 +87,9 @@ Driver -> server methods: ``` llm.attach subscribe to llm.request notifications llm.chunk { id, items: Item[] } append response items -llm.finish { id, reason?: "stop" | ... } finish the exchange -llm.pending list open exchanges +llm.finish { id, reason?: "stop" | ... } finish the invocation +llm.disconnect { id } fail the provider response stream +llm.pending list pending invocations network.log simulated network request log ``` @@ -102,13 +111,13 @@ Failure injection (`llm.fail`: HTTP status instead of SSE) is specced but not ye A driver manages two loopback WebSocket connections: - TUI control server (manifest `endpoints.ui`) — UI state, actions, render, trace. -- Backend control server (manifest `endpoints.backend`) — LLM exchanges, network log. +- Backend control server (manifest `endpoints.backend`) — simulated provider invocations. The network request log remains run-local diagnostic state. Both speak the same JSON-RPC shape. Headless drivers use only the backend socket plus the normal HTTP API. Multiple drivers are out of scope; last attach wins. ### 6. Pacing and the clock -No server-side pacing by default: the driver controls timing by when it sends chunks, which is the point of driver-in-the-loop. A convenience `llm.chunk` option `{ delayMs }` may sleep via `Effect.sleep` between items server-side; because that uses the fiber `Clock`, scoping a controllable clock to the exchange stream (`Stream.provideService(Clock.Clock, simClock)`) remains available for deterministic replay without touching app time. Defer until replay work needs it. +No server-side pacing exists. The driver controls timing by deciding when to send chunks. ### 7. Catalog and auth seeding @@ -123,14 +132,14 @@ driver TUI drive server backend + drive WS | |-- (normal app HTTP) ---->| session runner starts | | | llm.stream -> HttpClient | | | simulated network matches openai route - |<================== llm.request {ex_1} ===============| exchange ex_1 opened - |-- llm.chunk {ex_1,[...]} ============================>| SSE frames flow into the real - |-- llm.chunk {ex_1,[...]} ============================>| decode -> step -> LLMEvents -> - |-- llm.finish {ex_1} =================================>| runner publishes, TUI renders + |<================= llm.request {inv_1} ================| provider invocation inv_1 opened + |-- llm.chunk {inv_1,[...]} ===========================>| SSE frames flow into the real + |-- llm.chunk {inv_1,[...]} ===========================>| decode -> step -> LLMEvents -> + |-- llm.finish {inv_1} ================================>| runner publishes, TUI renders | | | | (if toolCall was sent: runner executes the real tool against the - | fake filesystem, then issues the next provider turn -> new exchange - | ex_2 -> driver decides the next response) + | fake filesystem, then starts the next model invocation -> inv_2 + | -> driver decides the next provider response) ``` The driver observes the TUI through `ui.state` while chunks stream, so mid-stream UI assertions need no clock control at all: the driver simply has not sent the rest yet. @@ -138,13 +147,13 @@ The driver observes the TUI through `ui.state` while chunks stream, so mid-strea ## Implementation order 1. `network.ts`: simulated `HttpClient` + route table + deny-unknown + trace. Replace `httpClient` in `simulationReplacements`. -2. `llm-exchange.ts` + `openai.ts`: exchange service and the OpenAI SSE route (schema-constructed chunks, `[DONE]`, interruption). -3. `control.ts`: backend-hosted control WebSocket (`llm.attach|chunk|finish|pending`, `network.log`), started when the simulation module loads. +2. `simulated-provider.ts` + `openai.ts`: scoped Drive-controlled provider and the OpenAI SSE route (schema-constructed chunks, `[DONE]`, interruption). +3. `SimulatedProvider.layerDrive`: backend-hosted control WebSocket (`llm.attach|chunk|finish|disconnect|pending`), acquired only when `OPENCODE_DRIVE` is set. 4. Config seeding for the sim provider; end-to-end verification via `packages/server/script/e2e-sim.ts` (headless) and `packages/tui/script/sim-llm-driver.ts` (TUI + backend sockets). -5. Trace records for network and LLM exchange activity. +5. Trace records for network and simulated provider activity. ## Consequences - No enqueue/script store to keep consistent; the driver is the single source of model behavior. -- Deterministic tests write drivers (respond to `llm.request` programmatically) instead of pre-baked scripts; replay (Phase 4) records exchanges and replays them as an automatic driver. +- Deterministic tests write drivers that respond to `llm.request` programmatically instead of adding a second provider implementation. - Provider-coupling is confined to `openai.ts` (one wire encoder against a schema that lives in the repo); a second simulated provider (e.g. Anthropic) is another route file if ever needed. diff --git a/packages/opencode/specs/simulation/simulation-phases.md b/packages/opencode/specs/simulation/simulation-phases.md index c8a470a449..74fd92889b 100644 --- a/packages/opencode/specs/simulation/simulation-phases.md +++ b/packages/opencode/specs/simulation/simulation-phases.md @@ -64,14 +64,16 @@ Implementation checklist: - [x] Verify end to end: `opencode serve` boots with `OPENCODE_SIMULATE=1` + `OPENCODE_SIMULATE_STATE` + path/DB env seams (`OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`); `fs.list`/`fs.read` observe only seeded in-memory files; the anchor directory on the host remains empty after the run. - [ ] Create the anchor directory + `chdir` + env seam setup automatically in CLI startup when simulation mode is enabled (currently set manually by the runner; a full run needs `OPENCODE_SIMULATE_STATE`, `OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`, and `XDG_*_HOME` pointed into the anchor, plus Bun's `--preload=@opentui/solid/preload` when launched outside `packages/cli`). - [ ] Assert the anchor directory is still empty at the end of the run (KV/log/flock still write through real XDG paths; they are contained in the anchor by the env seams but not yet in-memory). -- [x] Add simulated network registry (`packages/simulation/src/backend/network.ts`): replaces the `httpClient` platform node, resolves all outbound HTTP against an in-memory route table, denies unknown destinations loudly, and keeps a bounded request log (design: `simulated-network-llm.md`). -- [x] Add driver-answered LLM as an OpenAI route in the simulated network (`openai.ts` + `llm-exchange.ts`): provider requests open exchanges; the driver streams chunks back which are encoded as real OpenAI Chat SSE (schema-checked against `OpenAIChatEvent`) and consumed by the real protocol pipeline. No enqueue store — the driver is the model. -- [x] Add backend-hosted drive control WebSocket (`control.ts`): JSON-RPC at the named manifest's backend endpoint, started when `OPENCODE_DRIVE` is set. Drivers connect directly (standalone topology — no frontend proxy): `llm.attach` (replays pending exchanges), `llm.chunk`, `llm.finish`, `llm.pending`, `network.log`; `llm.request` notifications push opened exchanges. This is also the headless-simulation interface. Drivers manage the manifest's UI endpoint for UI control and backend endpoint for LLM/network control. +- [x] Add run-local simulated network (`packages/simulation/src/backend/network.ts`): replaces the `httpClient` platform node, resolves outbound HTTP against routes supplied at acquisition, denies unknown destinations loudly, and keeps an isolated bounded request log timestamped through Effect `Clock` (design: `simulated-network-llm.md`). +- [x] Add a simulated model provider behind the OpenAI route (`simulated-provider.ts` + `openai.ts`): real provider requests call `SimulatedProvider.Service.stream`; the Drive adapter streams response events back as schema-checked OpenAI Chat SSE consumed by the real protocol pipeline. +- [x] Scope the backend Drive control WebSocket, pending provider invocations, queues, and request fibers to `SimulatedProvider.layerDrive`. JSON-RPC remains at the named manifest's backend endpoint: `llm.attach` replays pending invocations; `llm.chunk`, `llm.finish`, `llm.disconnect`, and `llm.pending` control them; `llm.request` reports provider-native requests. +- [x] Scope the frontend Drive control WebSocket, request queue, renderer, and optional recording timeline to the TUI Effect scope. Server shutdown and request interruption precede renderer destruction; timeline finalization runs last and remains explicitly finishable through `ui.recording.finish`. +- [x] Decode Drive manifests through Effect `Config`, `FileSystem`, and `Schema`, with typed config, not-found, read, and decode failures. - [x] Answer `https://models.dev/api.json` with an empty catalog in the simulated network; providers come from seeded config (`opencode.json` in the snapshot defines an openai-compatible provider with a dummy `apiKey`, which passes the catalog availability gate and resolves onto the real openai-chat route). - [x] Fix `buildLocationServiceMap` to apply replacements when compiling hoisted global nodes; platform-node replacements (filesystem, httpClient) were silently ignored inside hoisted globals. - [x] Verify end to end headless (real route stack in-process + backend control WS: prompt -> `llm.request` -> driver chunks -> assistant message contains driver text; script: `packages/server/script/e2e-sim.ts`) and through the TUI (fake renderer, both sockets: type + submit via TUI WS, answer `llm.request` via backend WS, assistant reply rendered on screen; script: `packages/tui/script/sim-llm-driver.ts`). - [ ] Add simulated process registry (shell via `just-bash`, minimal fake `git`, deny unsupported spawns). -- [ ] Trace filesystem, process, and LLM exchange activity (network requests are traced in the backend network log ring buffer; LLM exchange trace records moved out with the frontend proxy and need re-adding on the backend control server). +- [ ] Trace filesystem, process, and simulated provider activity (network requests are traced in the backend network log ring buffer; provider trace records still need adding on the backend control server). Scope: diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 21149a2cf3..ab85b6d1ab 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -2,7 +2,7 @@ import { APICallError } from "ai" import { STATUS_CODES } from "http" import { iife } from "@/util/iife" import type { ProviderV2 } from "@opencode-ai/core/provider" -import { isContextOverflow } from "@opencode-ai/llm" +import { isContextOverflow } from "@opencode-ai/ai" export class HeaderTimeoutError extends Error { public override readonly name = "ProviderHeaderTimeoutError" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts index 84a0a71652..efa4e51c1e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts @@ -3,7 +3,7 @@ import { Provider } from "@/provider/provider" import { LLM } from "@/session/llm" import { MessageID, SessionID } from "@/session/schema" import { Slug } from "@opencode-ai/core/util/slug" -import { LLMEvent } from "@opencode-ai/llm" +import { LLMEvent } from "@opencode-ai/ai" import { Effect, Stream } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a99f8acff2..9d652f8aab 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -7,9 +7,9 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Context, Effect, Layer } from "effect" import * as Stream from "effect/Stream" import { streamText, wrapLanguageModel, type ModelMessage, type Tool } from "ai" -import type { LLMEvent } from "@opencode-ai/llm" -import { LLMClient } from "@opencode-ai/llm/route" -import type { LLMClientService } from "@opencode-ai/llm/route" +import type { LLMEvent } from "@opencode-ai/ai" +import { LLMClient } from "@opencode-ai/ai/route" +import type { LLMClientService } from "@opencode-ai/ai/route" import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" import { ProviderTransform } from "@/provider/transform" import { Config } from "@/config/config" @@ -221,7 +221,7 @@ const live: Layer.Layer< }) : undefined - // Runtime seam: native is an opt-in adapter over @opencode-ai/llm. It + // Runtime seam: native is an opt-in adapter over @opencode-ai/ai. It // either returns a ready LLMEvent stream or a concrete fallback reason. if (flags.experimentalNativeLlm) { const native = LLMNativeRuntime.stream({ diff --git a/packages/opencode/src/session/llm/AGENTS.md b/packages/opencode/src/session/llm/AGENTS.md index cfb6a89cef..03f7eeff47 100644 --- a/packages/opencode/src/session/llm/AGENTS.md +++ b/packages/opencode/src/session/llm/AGENTS.md @@ -4,8 +4,8 @@ This folder contains adapters behind that service boundary: -- `ai-sdk.ts` converts AI SDK `fullStream` parts into `@opencode-ai/llm` `LLMEvent`s. This is the default runtime path. -- `native-request.ts` converts opencode's normalized session input into a native `@opencode-ai/llm` `LLMRequest`. It does not execute requests. +- `ai-sdk.ts` converts AI SDK `fullStream` parts into `@opencode-ai/ai` `LLMEvent`s. This is the default runtime path. +- `native-request.ts` converts opencode's normalized session input into a native `@opencode-ai/ai` `LLMRequest`. It does not execute requests. - `native-runtime.ts` is the opt-in native runtime adapter. It decides whether a selected model is supported, builds the native request, bridges opencode tools into native executable tools, and delegates transport to `LLMClient` / `RequestExecutor`. ## File Structure @@ -15,19 +15,19 @@ src/session/ llm.ts session-owned orchestration and runtime selection llm/ AGENTS.md boundary notes for the adapter layer - ai-sdk.ts AI SDK fullStream -> @opencode-ai/llm LLMEvent adapter - native-request.ts opencode/AI SDK-shaped input -> @opencode-ai/llm LLMRequest + ai-sdk.ts AI SDK fullStream -> @opencode-ai/ai LLMEvent adapter + native-request.ts opencode/AI SDK-shaped input -> @opencode-ai/ai LLMRequest native-runtime.ts native runtime gate, tool bridge, and LLMClient handoff ``` Integration points: -- `../llm.ts` imports `LLMClient` from `@opencode-ai/llm/route`; native execution is the only path that calls it directly. +- `../llm.ts` imports `LLMClient` from `@opencode-ai/ai/route`; native execution is the only path that calls it directly. - `../llm.ts` imports `LLMAISDK` from `./llm/ai-sdk`; the AI SDK path still calls `streamText(...)` locally, then adapts `result.fullStream` into shared `LLMEvent`s. - `../llm.ts` imports `LLMNativeRuntime` from `./llm/native-runtime`; this is the runtime-selection seam. Unsupported native requests return a reason and fall back to AI SDK. - `native-runtime.ts` imports `LLMNative` from `./native-request`; this keeps request lowering separate from transport and tool execution. -- `native-request.ts` is the only adapter file that should construct `LLM.request(...)`, `LLM.model(...)`, `Message.*`, `SystemPart`, `ToolCallPart`, `ToolResultPart`, or `ToolDefinition` values from `@opencode-ai/llm`. -- `ai-sdk.ts` and `native-runtime.ts` both emit `@opencode-ai/llm` `LLMEvent`s so downstream session processing does not care which runtime handled the request. +- `native-request.ts` is the only adapter file that should construct `LLM.request(...)`, `LLM.model(...)`, `Message.*`, `SystemPart`, `ToolCallPart`, `ToolResultPart`, or `ToolDefinition` values from `@opencode-ai/ai`. +- `ai-sdk.ts` and `native-runtime.ts` both emit `@opencode-ai/ai` `LLMEvent`s so downstream session processing does not care which runtime handled the request. Keep new integration code on one of these seams. Avoid importing session services into `native-request.ts`; pass normalized data through `RequestInput` instead. @@ -80,7 +80,7 @@ Both runtimes converge on the same `LLMEvent` stream consumed by the session pro ╰─────────────────╯ ╰─────────────────────────────╯ ``` -`native-runtime.ts` evaluates the gate and either bridges into `@opencode-ai/llm` or returns control so `llm.ts` can take the AI SDK path. Tool execution stays opencode-owned in both branches; only request lowering and transport differ. +`native-runtime.ts` evaluates the gate and either bridges into `@opencode-ai/ai` or returns control so `llm.ts` can take the AI SDK path. Tool execution stays opencode-owned in both branches; only request lowering and transport differ. Safety boundary: diff --git a/packages/opencode/src/session/llm/ai-sdk.ts b/packages/opencode/src/session/llm/ai-sdk.ts index 8db8985d7b..f51d681121 100644 --- a/packages/opencode/src/session/llm/ai-sdk.ts +++ b/packages/opencode/src/session/llm/ai-sdk.ts @@ -1,4 +1,4 @@ -import { FinishReason, LLMEvent, ProviderMetadata, ToolResultValue } from "@opencode-ai/llm" +import { FinishReason, LLMEvent, ProviderMetadata, ToolResultValue } from "@opencode-ai/ai" import { Effect, Schema } from "effect" import { type streamText } from "ai" import { errorMessage } from "@/util/error" @@ -28,7 +28,7 @@ function providerMetadata(value: unknown): ProviderMetadata | undefined { } // Temporary AI SDK bridge: Copilot billing survives only in raw provider chunks here. -// Move this extraction into @opencode-ai/llm when Copilot is handled by the native runtime. +// Move this extraction into @opencode-ai/ai when Copilot is handled by the native runtime. function copilotTotalNanoAiu(value: unknown) { if (!value || typeof value !== "object") return const raw = value as Record diff --git a/packages/opencode/src/session/llm/native-request.ts b/packages/opencode/src/session/llm/native-request.ts index b7f30e24c3..19927ffc2a 100644 --- a/packages/opencode/src/session/llm/native-request.ts +++ b/packages/opencode/src/session/llm/native-request.ts @@ -1,5 +1,5 @@ -import type { JsonSchema, LLMRequest, ProviderMetadata } from "@opencode-ai/llm" -import { LLM, Message, SystemPart, ToolCallPart, ToolDefinition, ToolResultPart } from "@opencode-ai/llm" +import type { JsonSchema, LLMRequest, ProviderMetadata } from "@opencode-ai/ai" +import { LLM, Message, SystemPart, ToolCallPart, ToolDefinition, ToolResultPart } from "@opencode-ai/ai" import { AmazonBedrock, Anthropic, @@ -8,7 +8,7 @@ import { OpenAI, OpenAICompatible, OpenRouter, -} from "@opencode-ai/llm/providers" +} from "@opencode-ai/ai/providers" import type { ModelMessage } from "ai" import type { Provider } from "@/provider/provider" import { isRecord } from "@/util/record" @@ -181,7 +181,7 @@ export const model = (input: Provider.Model | RequestInput, headers?: Record { const converted = messages(input.messages) // This is the only native adapter boundary that should construct canonical - // @opencode-ai/llm request objects from opencode's session/AI SDK-shaped data. + // @opencode-ai/ai request objects from opencode's session/AI SDK-shaped data. return LLM.request({ model: model(input, input.headers), system: [...(input.system ?? []).map(SystemPart.make), ...converted.system], diff --git a/packages/opencode/src/session/llm/native-runtime.ts b/packages/opencode/src/session/llm/native-runtime.ts index bac385c591..2d7b270c47 100644 --- a/packages/opencode/src/session/llm/native-runtime.ts +++ b/packages/opencode/src/session/llm/native-runtime.ts @@ -15,8 +15,8 @@ import { toDefinitions, type JsonSchema, type LLMEvent, -} from "@opencode-ai/llm" -import type { LLMClientShape } from "@opencode-ai/llm/route" +} from "@opencode-ai/ai" +import type { LLMClientShape } from "@opencode-ai/ai/route" import { LLMNative } from "./native-request" export type RuntimeStatus = @@ -76,7 +76,7 @@ export function stream(input: StreamInput): StreamResult { const current = statusWithFetch(input, fetch) if (current.type === "unsupported") return current - // Integration point with @opencode-ai/llm: native-request lowers session data + // Integration point with @opencode-ai/ai: native-request lowers session data // into an LLMRequest, then LLMClient handles route selection and transport. // // ProviderTransform.providerOptions builds AI-SDK-shaped options for the @@ -171,7 +171,7 @@ export function nativeTools(tools: Record, input: Pick [ name, // Tool execution remains opencode-owned. The native runtime only adapts - // the @opencode-ai/llm tool call back into the AI SDK Tool.execute shape. + // the @opencode-ai/ai tool call back into the AI SDK Tool.execute shape. NativeTool.make({ description: item.description ?? "", jsonSchema: nativeSchema(item.inputSchema), diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 20aa8a8404..cd13346617 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -24,7 +24,7 @@ import { errorMessage } from "@/util/error" import { isRecord } from "@/util/record" import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@opencode-ai/core/database/database" -import { Usage, type LLMEvent } from "@opencode-ai/llm" +import { Usage, type LLMEvent } from "@opencode-ai/ai" const DOOM_LOOP_THRESHOLD = 3 export type Result = "compact" | "stop" | "continue" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2a6d002c61..8b025cc2f0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -55,7 +55,7 @@ import { eq } from "drizzle-orm" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionReminders } from "./reminders" import { SessionTools } from "./tools" -import { LLMEvent } from "@opencode-ai/llm" +import { LLMEvent } from "@opencode-ai/ai" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 2ce86abe43..d9f27ddaef 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -9,7 +9,7 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use" import path from "path" import { Job } from "@/job" import { Decimal } from "decimal.js" -import type { ProviderMetadata, Usage } from "@opencode-ai/llm" +import type { ProviderMetadata, Usage } from "@opencode-ai/ai" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Database } from "@opencode-ai/core/database/database" import { EventV2Bridge } from "@/event-v2-bridge" diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 4a4210cf08..44a0da29b1 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -28,7 +28,7 @@ import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { TestConfig } from "../fixture/config" import { RuntimeFlags } from "@/effect/runtime-flags" -import { LLMEvent, Usage } from "@opencode-ai/llm" +import { LLMEvent, Usage } from "@opencode-ai/ai" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index b4864d14ac..2fe945ea49 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -11,8 +11,8 @@ import { Auth } from "@/auth" import { Provider } from "@/provider/provider" import { Filesystem } from "@/util/filesystem" -import { LLMEvent, LLMResponse } from "@opencode-ai/llm" -import { RequestExecutor } from "@opencode-ai/llm/route" +import { LLMEvent, LLMResponse } from "@opencode-ai/ai" +import { RequestExecutor } from "@opencode-ai/ai/route" import { RuntimeFlags } from "@/effect/runtime-flags" import type { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index dd4d9cc174..77f042fe4c 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { LLMEvent, ToolFailure } from "@opencode-ai/llm" -import { LLMClient, RequestExecutor, WebSocketExecutor, type LLMClientShape } from "@opencode-ai/llm/route" +import { LLMEvent, ToolFailure } from "@opencode-ai/ai" +import { LLMClient, RequestExecutor, WebSocketExecutor, type LLMClientShape } from "@opencode-ai/ai/route" import { jsonSchema, tool, type ModelMessage, type Tool } from "ai" import { Effect, Fiber, Layer, Stream } from "effect" import { FetchHttpClient } from "effect/unstable/http" diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 33c2957372..4e431ecaeb 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -9,7 +9,7 @@ import { InstanceRef } from "../../src/effect/instance-ref" import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import z from "zod" import { LLM } from "../../src/session/llm" -import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" +import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route" import { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { ModelsDev } from "@opencode-ai/core/models-dev" diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 5287605436..a599ad78b7 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -25,7 +25,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { SessionProjector } from "@opencode-ai/core/session/projector" -import { LLMEvent } from "@opencode-ai/llm" +import { LLMEvent } from "@opencode-ai/ai" const summary = Layer.succeed( SessionSummary.Service, diff --git a/packages/plugin/package.json b/packages/plugin/package.json index bae8482a36..c3a0c6ec99 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -25,6 +25,7 @@ ], "dependencies": { "@ai-sdk/provider": "3.0.8", + "@opencode-ai/ai": "workspace:*", "@opencode-ai/client": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index d3f144e859..000a8c6aba 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -438,7 +438,7 @@ type TuiConfigView = { markdown?: "source" | "rendered" grouping?: "auto" | "none" } - hints?: { tips?: boolean; onboarding?: boolean } + hints?: { onboarding?: boolean } animations?: boolean mouse: boolean keybinds: TuiBindingLookupView diff --git a/packages/plugin/src/v2/effect/README.md b/packages/plugin/src/v2/effect/README.md index f2526ec860..50ee83615b 100644 --- a/packages/plugin/src/v2/effect/README.md +++ b/packages/plugin/src/v2/effect/README.md @@ -80,14 +80,16 @@ yield * Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks. -Session request context is mutable immediately before provider dispatch: +AI request context is mutable immediately before provider dispatch: ```ts yield * - ctx.session.hook("request", (event) => { - event.tools.read.description = "Read a file using narrow line ranges." - delete event.tools.write - }) + ctx.ai.hook("request", (event) => + Effect.sync(() => { + event.tools.read.description = "Read a file using narrow line ranges." + delete event.tools.write + }), + ) ``` ## Reloading A Domain diff --git a/packages/plugin/src/v2/effect/ai.ts b/packages/plugin/src/v2/effect/ai.ts new file mode 100644 index 0000000000..306d53cd17 --- /dev/null +++ b/packages/plugin/src/v2/effect/ai.ts @@ -0,0 +1,23 @@ +import type { Message, SystemPart } from "@opencode-ai/ai" +import type { Agent } from "@opencode-ai/schema/agent" +import type { Model } from "@opencode-ai/schema/model" +import type { Session } from "@opencode-ai/schema/session" +import type { JsonSchema } from "effect" +import type { Hooks } from "./registration.js" + +export interface AIRequest { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + system: Array + messages: Array + tools: Record +} + +export interface AIHooks { + readonly request: AIRequest +} + +export interface AIDomain { + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 3b0b6eb918..7be0b228e1 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -2,6 +2,7 @@ import type { PluginApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" import type { PluginOptions } from "../options.js" import type { AgentDomain } from "./agent.js" +import type { AIDomain } from "./ai.js" import type { AISDKDomain } from "./aisdk.js" import type { CatalogDomain } from "./catalog.js" import type { CommandDomain } from "./command.js" @@ -15,6 +16,7 @@ import type { ToolDomain } from "./tool.js" export interface Context { readonly options: PluginOptions readonly agent: AgentDomain + readonly ai: AIDomain readonly aisdk: AISDKDomain readonly catalog: CatalogDomain readonly command: CommandDomain diff --git a/packages/plugin/src/v2/promise/README.md b/packages/plugin/src/v2/promise/README.md index f94f18d060..268a98a94b 100644 --- a/packages/plugin/src/v2/promise/README.md +++ b/packages/plugin/src/v2/promise/README.md @@ -85,10 +85,10 @@ await ctx.aisdk.hook("language", (event) => { }) ``` -Session request context is mutable immediately before provider dispatch: +AI request context is mutable immediately before provider dispatch: ```ts -await ctx.session.hook("request", (event) => { +await ctx.ai.hook("request", (event) => { event.tools.read.description = "Read a file using narrow line ranges." delete event.tools.write }) diff --git a/packages/plugin/src/v2/promise/ai.ts b/packages/plugin/src/v2/promise/ai.ts new file mode 100644 index 0000000000..306d53cd17 --- /dev/null +++ b/packages/plugin/src/v2/promise/ai.ts @@ -0,0 +1,23 @@ +import type { Message, SystemPart } from "@opencode-ai/ai" +import type { Agent } from "@opencode-ai/schema/agent" +import type { Model } from "@opencode-ai/schema/model" +import type { Session } from "@opencode-ai/schema/session" +import type { JsonSchema } from "effect" +import type { Hooks } from "./registration.js" + +export interface AIRequest { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + system: Array + messages: Array + tools: Record +} + +export interface AIHooks { + readonly request: AIRequest +} + +export interface AIDomain { + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts index 2e05b1fded..1928ca3672 100644 --- a/packages/plugin/src/v2/promise/plugin.ts +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -1,6 +1,7 @@ import type { PluginApi } from "@opencode-ai/client/promise/api" import type { PluginOptions } from "../options.js" import type { AgentDomain } from "./agent.js" +import type { AIDomain } from "./ai.js" import type { AISDKDomain } from "./aisdk.js" import type { CatalogDomain } from "./catalog.js" import type { CommandDomain } from "./command.js" @@ -14,6 +15,7 @@ import type { ToolDomain } from "./tool.js" export interface Context { readonly options: PluginOptions readonly agent: AgentDomain + readonly ai: AIDomain readonly aisdk: AISDKDomain readonly catalog: CatalogDomain readonly command: CommandDomain diff --git a/packages/plugin/src/v2/tui/context.ts b/packages/plugin/src/v2/tui/context.ts index fba5f934cc..c95636b44a 100644 --- a/packages/plugin/src/v2/tui/context.ts +++ b/packages/plugin/src/v2/tui/context.ts @@ -24,7 +24,8 @@ import type { JSX } from "@opentui/solid" interface LocationCollection { list(location?: LocationRef): Value[] | undefined - refresh(location?: LocationRef): Promise + sync(location?: LocationRef): Promise + invalidate(location?: LocationRef): void } export interface Data { @@ -42,37 +43,45 @@ export interface Data { status(sessionID: string): "idle" | "running" readonly pending: { list(sessionID: string): SessionPendingInfo[] - refresh(sessionID: string): Promise + sync(sessionID: string): Promise + invalidate(sessionID: string): void } - refresh(sessionID: string): Promise + sync(sessionID: string): Promise + invalidate(sessionID: string): void readonly message: { list(sessionID: string): SessionMessageInfo[] get(sessionID: string, messageID: string): SessionMessageInfo | undefined - refresh(sessionID: string): Promise + sync(sessionID: string): Promise + invalidate(sessionID: string): void } readonly permission: { list(sessionID: string): PermissionV2Request[] | undefined - refresh(sessionID: string): Promise + sync(sessionID: string): Promise + invalidate(sessionID: string): void } readonly form: { list(sessionID: string, location?: LocationRef): Array | undefined - refresh(sessionID: string, location?: LocationRef): Promise + sync(sessionID: string, location?: LocationRef): Promise + invalidate(sessionID: string, location?: LocationRef): void } } readonly project: { readonly permission: { list(projectID: string): PermissionSavedInfo[] | undefined - refresh(projectID: string): Promise + sync(projectID: string): Promise + invalidate(projectID: string): void } } readonly shell: { list(location?: LocationRef): ShellInfo[] get(id: string): ShellInfo | undefined - refresh(location?: LocationRef): Promise + sync(location?: LocationRef): Promise + invalidate(location?: LocationRef): void } readonly location: { default(): LocationRef - refresh(location?: LocationRef): Promise + sync(location?: LocationRef): Promise + invalidate(location?: LocationRef): void readonly agent: LocationCollection readonly command: LocationCollection readonly integration: LocationCollection @@ -125,9 +134,13 @@ export interface KeymapCommand { readonly slash?: { readonly name: string readonly aliases?: string[] + /** Keeps the slash command in the prompt and passes its raw input to run. */ + readonly arguments?: true } + /** Promotes the command in discovery UI. */ + readonly suggested?: boolean | (() => boolean) /** Executes the command. Return false to let keymap dispatch continue. */ - readonly run: () => void | false | Promise + readonly run: (input?: string) => void | false | Promise } export interface KeymapLayer { @@ -149,7 +162,7 @@ export interface Keymap { /** Creates a reactive keymap layer owned by the calling component. */ layer(input: () => KeymapLayer): void /** Dispatches a reachable command by ID. */ - dispatch(id: string): void + dispatch(id: string, input?: string): void /** Returns the formatted shortcut for a registered command. */ shortcut(id: string): string | undefined /** Controls mutually exclusive OpenCode input modes. */ @@ -172,6 +185,7 @@ export interface UI { export interface Context { readonly options: Readonly> + readonly location: LocationRef | undefined readonly client: OpenCodeClient readonly data: Data readonly keymap: Keymap diff --git a/packages/protocol/src/groups/health.ts b/packages/protocol/src/groups/health.ts index 625cce3919..932f809e59 100644 --- a/packages/protocol/src/groups/health.ts +++ b/packages/protocol/src/groups/health.ts @@ -1,19 +1,46 @@ import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +export namespace ServiceStatus { + export const Health = Schema.Struct({ + healthy: Schema.Literal(true), + version: Schema.String, + pid: Schema.Int.check(Schema.isGreaterThan(0)), + }).annotate({ identifier: "ServiceHealth" }) + export type Health = typeof Health.Type + + export const StopRequest = Schema.Struct({ + instanceID: Schema.String, + }).annotate({ identifier: "ServiceStopRequest" }) + export type StopRequest = typeof StopRequest.Type + + export const StopResponse = Schema.Struct({ + accepted: Schema.Boolean, + }).annotate({ identifier: "ServiceStopResponse" }) + export type StopResponse = typeof StopResponse.Type +} + export const HealthGroup = HttpApiGroup.make("server.health") .add( HttpApiEndpoint.get("health.get", "/api/health", { - success: Schema.Struct({ - healthy: Schema.Literal(true), - version: Schema.String, - pid: Schema.Int.check(Schema.isGreaterThan(0)), - }), + success: ServiceStatus.Health, }).annotateMerge( OpenApi.annotations({ identifier: "v2.health.get", summary: "Check server health", - description: "Check whether the API server is ready to accept requests.", + description: "Report the owning server process and its application status.", + }), + ), + ) + .add( + HttpApiEndpoint.post("health.stop", "/api/service/stop", { + payload: ServiceStatus.StopRequest, + success: ServiceStatus.StopResponse, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.health.stop", + summary: "Stop the managed server", + description: "Request graceful shutdown of one exact managed server instance.", }), ), ) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 2e6f2ddb8c..753514a050 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -272,10 +272,7 @@ export const makeSessionGroup = (sessionLo .add( HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", { params: { sessionID: Session.ID }, - payload: Schema.Struct({ - destination: Schema.Struct({ directory: AbsolutePath }), - moveChanges: Schema.Boolean.pipe(Schema.optional), - }), + payload: Location.Ref, success: HttpApiSchema.NoContent, error: [SessionNotFoundError, InvalidRequestError], }) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 61341168ce..92a78273d1 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -21,6 +21,7 @@ import { Money } from "./money.js" import { Snapshot } from "./snapshot.js" import { TokenUsage } from "./token-usage.js" import { SessionPending } from "./session-pending.js" +import { Project } from "./project.js" export { FileAttachment } @@ -69,6 +70,7 @@ export const Moved = Event.durable({ schema: { ...Base, location: Location.Ref, + projectID: Project.ID.pipe(optional), subpath: RelativePath.pipe(optional), }, }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 9d1c3a96e2..32bc2d2074 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -179,6 +179,7 @@ import type { QuestionReplyErrors, QuestionReplyResponses, QuestionV2Reply, + ServiceStopRequest, SessionAbortErrors, SessionAbortResponses, SessionChildrenErrors, @@ -291,6 +292,8 @@ import type { V2GenerateTextResponses, V2HealthGetErrors, V2HealthGetResponses, + V2HealthStopErrors, + V2HealthStopResponses, V2IntegrationAttemptCancelErrors, V2IntegrationAttemptCancelResponses, V2IntegrationAttemptCompleteErrors, @@ -5072,7 +5075,7 @@ export class Health extends HeyApiClient { /** * Check server health * - * Check whether the API server is ready to accept requests. + * Report the owning server process and its application status. */ public get(options?: Options) { return (options?.client ?? this.client).get({ @@ -5080,6 +5083,30 @@ export class Health extends HeyApiClient { ...options, }) } + + /** + * Stop the managed server + * + * Request graceful shutdown of one exact managed server instance. + */ + public stop( + parameters: { + serviceStopRequest: ServiceStopRequest + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ key: "serviceStopRequest", map: "body" }] }]) + return (options?.client ?? this.client).post({ + url: "/api/service/stop", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } } export class Server extends HeyApiClient { @@ -6117,10 +6144,7 @@ export class Session3 extends HeyApiClient { public move( parameters: { sessionID: string - destination?: { - directory: string - } - moveChanges?: boolean | null + locationRefV2: LocationRefV2 }, options?: Options, ) { @@ -6130,8 +6154,7 @@ export class Session3 extends HeyApiClient { { args: [ { in: "path", key: "sessionID" }, - { in: "body", key: "destination" }, - { in: "body", key: "moveChanges" }, + { key: "locationRefV2", map: "body" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index cd391aab58..94c6f4cde2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -598,24 +598,6 @@ export type Part = | RetryPart | CompactionPart -export type Shell = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - completed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } -} - export type Pty = { id: string title: string @@ -803,6 +785,7 @@ export type GlobalEvent = { properties: { sessionID: string location: LocationRef + projectID?: string subpath?: string } } @@ -917,7 +900,7 @@ export type GlobalEvent = { type: "session.shell.started" properties: { sessionID: string - shell: Shell + shell: ShellInfo } } | { @@ -925,7 +908,7 @@ export type GlobalEvent = { type: "session.shell.ended" properties: { sessionID: string - shell: Shell + shell: ShellInfo output: { output: string cursor: number @@ -1358,7 +1341,7 @@ export type GlobalEvent = { id: string type: "shell.created" properties: { - info: Shell + info: ShellInfo } } | { @@ -1366,7 +1349,7 @@ export type GlobalEvent = { type: "shell.exited" properties: { id: string - exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + exit?: number status: "running" | "exited" | "timeout" | "killed" } } @@ -2812,11 +2795,25 @@ export type WorkspaceWarpError = { } } +export type ServiceHealth = { + healthy: true + version: string + pid: number +} + export type UnauthorizedError = { _tag: "UnauthorizedError" message: string } +export type ServiceStopRequest = { + instanceID: string +} + +export type ServiceStopResponse = { + accepted: boolean +} + export type SessionsResponse = { data: Array cursor: { @@ -2890,24 +2887,6 @@ export type InstructionEntryValueTooLargeError = { message: string } -export type Shell1 = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" - completed?: number | "NaN" | "Infinity" | "-Infinity" - } -} - export type SessionLogItem = SessionEventDurable | EventLogSynced export type SessionLogItemStream = string @@ -3154,24 +3133,6 @@ export type EffectHttpApiErrorForbidden = { _tag: "Forbidden" } -export type Shell2 = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" - completed?: number | "NaN" | "Infinity" | "-Infinity" - } -} - export type EventTuiPromptAppend2 = { id: string type: "tui.prompt.append" @@ -3398,6 +3359,24 @@ export type SessionStructuredError = { message: string } +export type ShellInfo = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number + metadata: { + [key: string]: unknown + } + time: { + started: number + completed?: number + } +} + export type SessionMessageProviderState = { [key: string]: unknown } @@ -3773,6 +3752,7 @@ export type SyncEventSessionMoved = { data: { sessionID: string location: LocationRef + projectID?: string subpath?: string } } @@ -3962,7 +3942,7 @@ export type SyncEventSessionShellStarted = { aggregateID: string data: { sessionID: string - shell: Shell + shell: ShellInfo } } } @@ -3977,7 +3957,7 @@ export type SyncEventSessionShellEnded = { aggregateID: string data: { sessionID: string - shell: Shell + shell: ShellInfo output: { output: string cursor: number @@ -4828,6 +4808,7 @@ export type SessionMoved = { data: { sessionID: string location: LocationRef + projectID?: string subpath?: string } } @@ -5083,7 +5064,7 @@ export type SessionShellStarted = { location?: LocationRef data: { sessionID: string - shell: Shell1 + shell: ShellInfo } } @@ -5102,7 +5083,7 @@ export type SessionShellEnded = { location?: LocationRef data: { sessionID: string - shell: Shell1 + shell: ShellInfo output: { output: string cursor: number @@ -6461,7 +6442,7 @@ export type ShellCreated = { type: "shell.created" location?: LocationRef data: { - info: Shell1 + info: ShellInfo } } @@ -6475,7 +6456,7 @@ export type ShellExited = { location?: LocationRef data: { id: string - exit?: number | "NaN" | "Infinity" | "-Infinity" + exit?: number status: "running" | "exited" | "timeout" | "killed" } } @@ -7158,6 +7139,7 @@ export type EventSessionMoved = { properties: { sessionID: string location: LocationRef + projectID?: string subpath?: string } } @@ -7285,7 +7267,7 @@ export type EventSessionShellStarted = { type: "session.shell.started" properties: { sessionID: string - shell: Shell2 + shell: ShellInfo } } @@ -7294,7 +7276,7 @@ export type EventSessionShellEnded = { type: "session.shell.ended" properties: { sessionID: string - shell: Shell2 + shell: ShellInfo output: { output: string cursor: number @@ -7772,7 +7754,7 @@ export type EventShellCreated = { id: string type: "shell.created" properties: { - info: Shell2 + info: ShellInfo } } @@ -7781,7 +7763,7 @@ export type EventShellExited = { type: "shell.exited" properties: { id: string - exit?: number | "NaN" | "Infinity" | "-Infinity" + exit?: number status: "running" | "exited" | "timeout" | "killed" } } @@ -8139,6 +8121,12 @@ export type BadRequestError = { } } +export type ServiceHealthV2 = { + healthy: true + version: string + pid: number +} + export type InvalidRequestErrorV2 = { _tag: "InvalidRequestError" message: string @@ -8179,24 +8167,6 @@ export type UnknownErrorV2 = { ref?: string | null } -export type ShellV2 = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" - completed?: number | "NaN" | "Infinity" | "-Infinity" - } -} - export type SessionMessagesResponseV2 = { data: Array cursor: { @@ -9064,6 +9034,7 @@ export type SessionMovedV2 = { data: { sessionID: string location: LocationRefV2 + projectID?: string subpath?: string } } @@ -9333,6 +9304,24 @@ export type SessionSkillActivatedV2 = { } } +export type ShellInfoV2 = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number + metadata: { + [key: string]: unknown + } + time: { + started: number + completed?: number + } +} + export type SessionShellStartedV2 = { id: string created: number @@ -9348,7 +9337,7 @@ export type SessionShellStartedV2 = { location?: LocationRefV2 data: { sessionID: string - shell: ShellV2 + shell: ShellInfoV2 } } @@ -9367,7 +9356,7 @@ export type SessionShellEndedV2 = { location?: LocationRefV2 data: { sessionID: string - shell: ShellV2 + shell: ShellInfoV2 output: { output: string cursor: number @@ -10514,7 +10503,7 @@ export type ShellCreatedV2 = { type: "shell.created" location?: LocationRefV2 data: { - info: ShellV2 + info: ShellInfoV2 } } @@ -10528,7 +10517,7 @@ export type ShellExitedV2 = { location?: LocationRefV2 data: { id: string - exit?: number | "NaN" | "Infinity" | "-Infinity" + exit?: number status: "running" | "exited" | "timeout" | "killed" } } @@ -10986,6 +10975,24 @@ export type PtyTicketConnectTokenV2 = { expires_in: number } +export type ShellInfo1 = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number + metadata: { + [key: string]: unknown + } + time: { + started: number + completed?: number + } +} + export type QuestionV2RequestV2 = { id: string sessionID: string @@ -15124,17 +15131,42 @@ export type V2HealthGetError = V2HealthGetErrors[keyof V2HealthGetErrors] export type V2HealthGetResponses = { /** - * Success + * ServiceHealth */ - 200: { - healthy: true - version: string - pid: number - } + 200: ServiceHealthV2 } export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses] +export type V2HealthStopData = { + body: ServiceStopRequest + path?: never + query?: never + url: "/api/service/stop" +} + +export type V2HealthStopErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2HealthStopError = V2HealthStopErrors[keyof V2HealthStopErrors] + +export type V2HealthStopResponses = { + /** + * ServiceStopResponse + */ + 200: ServiceStopResponse +} + +export type V2HealthStopResponse = V2HealthStopResponses[keyof V2HealthStopResponses] + export type V2ServerGetData = { body?: never path?: never @@ -15611,12 +15643,7 @@ export type V2SessionRenameResponses = { export type V2SessionRenameResponse = V2SessionRenameResponses[keyof V2SessionRenameResponses] export type V2SessionMoveData = { - body: { - destination: { - directory: string - } - moveChanges?: boolean | null - } + body: LocationRefV2 path: { sessionID: string } @@ -18318,7 +18345,7 @@ export type V2ShellListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -18362,7 +18389,7 @@ export type V2ShellCreateResponses = { */ 200: { location: LocationInfoV2 - data: ShellV2 + data: ShellInfo1 } } @@ -18445,7 +18472,7 @@ export type V2ShellGetResponses = { */ 200: { location: LocationInfoV2 - data: ShellV2 + data: ShellInfo1 } } @@ -18490,7 +18517,7 @@ export type V2ShellTimeoutResponses = { */ 200: { location: LocationInfoV2 - data: ShellV2 + data: ShellInfo1 } } diff --git a/packages/server/src/handlers/health.ts b/packages/server/src/handlers/health.ts index a73b050aa7..111113efd4 100644 --- a/packages/server/src/handlers/health.ts +++ b/packages/server/src/handlers/health.ts @@ -4,7 +4,13 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) => - handlers.handle("health.get", () => - Effect.succeed({ healthy: true as const, version: InstallationVersion, pid: process.pid }), - ), + handlers + .handle("health.get", () => + Effect.succeed({ + healthy: true as const, + version: InstallationVersion, + pid: process.pid, + }), + ) + .handle("health.stop", () => Effect.succeed({ accepted: false })), ) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index b2c2e7eb10..fa0b0e67ff 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,6 +1,5 @@ import { SessionV2 } from "@opencode-ai/core/session" import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry" -import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { DateTime, Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" @@ -25,7 +24,6 @@ const DefaultSessionsLimit = 50 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => Effect.gen(function* () { const session = yield* SessionV2.Service - const moveSession = yield* MoveSession.Service return handlers .handle( @@ -206,11 +204,11 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .handle( "session.move", Effect.fn(function* (ctx) { - yield* moveSession - .moveSession({ + yield* session + .move({ sessionID: ctx.params.sessionID, - destination: ctx.payload.destination, - moveChanges: ctx.payload.moveChanges, + directory: ctx.payload.directory, + workspaceID: ctx.payload.workspaceID, }) .pipe( Effect.catchTag("Session.NotFoundError", (error) => @@ -221,22 +219,11 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl }), ), ), - Effect.catchTag("MoveSession.DestinationProjectMismatchError", () => - Effect.fail(new InvalidRequestError({ message: "Destination directory belongs to another project" })), + Effect.catchTag("Session.DestinationNotFoundError", (error) => + Effect.fail(new InvalidRequestError({ message: `Directory does not exist: ${error.directory}` })), ), - Effect.catchTag("MoveSession.ApplyChangesError", () => - Effect.fail( - new InvalidRequestError({ - message: - "Unable to apply your changes in the destination directory. The files may conflict with existing changes.", - }), - ), - ), - Effect.catchTag("MoveSession.CaptureChangesError", (error) => - Effect.fail(new InvalidRequestError({ message: error.message })), - ), - Effect.catchTag("MoveSession.ResetSourceChangesError", (error) => - Effect.fail(new InvalidRequestError({ message: error.message })), + Effect.catchTag("Session.DestinationNotDirectoryError", (error) => + Effect.fail(new InvalidRequestError({ message: `Not a directory: ${error.directory}` })), ), ) return HttpApiSchema.NoContent.make() diff --git a/packages/server/src/middleware/authorization.ts b/packages/server/src/middleware/authorization.ts index cc785ee247..a004fa973d 100644 --- a/packages/server/src/middleware/authorization.ts +++ b/packages/server/src/middleware/authorization.ts @@ -35,6 +35,10 @@ function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) { return Effect.succeed(emptyCredential()) } +export function authorizedRequest(request: HttpServerRequest.HttpServerRequest, config: ServerAuth.Info) { + return credentialFromRequest(request).pipe(Effect.map((credential) => ServerAuth.authorized(credential, config))) +} + export const authorizationLayer = Layer.effect( Authorization, Effect.gen(function* () { @@ -46,8 +50,7 @@ export const authorizationLayer = Layer.effect( // Browsers cannot set headers on WebSocket upgrades, so a ticketed PTY connect skips // credential checks here; the connect handler consumes and validates the ticket. if (hasPtyConnectTicketURL(new URL(request.url, "http://localhost"))) return yield* effect - const credential = yield* credentialFromRequest(request) - if (ServerAuth.authorized(credential, config)) return yield* effect + if (yield* authorizedRequest(request, config)) return yield* effect yield* HttpEffect.appendPreResponseHandler((_request, response) => Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)), ) diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index d9bef69e10..800c100a7f 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -1,74 +1,210 @@ export * as ServerProcess from "./process" -import { NodeHttpClient, NodeHttpServer } from "@effect/platform-node" -import { HealthGroup } from "@opencode-ai/protocol/groups/health" -import { Context, Effect, Layer, Option } from "effect" -import { HttpClient, HttpClientRequest, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" -import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" -import { createServer } from "node:http" +import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node" +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { SessionRestart } from "@opencode-ai/core/session/execution/restart" +import { ServiceStatus } from "@opencode-ai/protocol/groups/health" +import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" +import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope } from "effect" +import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { createServer } from "node:http" import { ServerAuth } from "./auth" +import { authorizedRequest } from "./middleware/authorization" import { createRoutes } from "./routes" import { ServerInfo } from "./server-info" +import { Status } from "./service-status" -export type Options = { +export type Options = { readonly hostname: string readonly port: Option.Option readonly password: string - readonly restartContinuity?: boolean + readonly instanceID: string + readonly service?: { + readonly onListen: (address: HttpServer.Address) => Effect.Effect + } } -const ReadinessApi = HttpApi.make("readiness").add(HealthGroup) +type App = Effect.Effect< + HttpServerResponse.HttpServerResponse, + unknown, + HttpServerRequest.HttpServerRequest | Scope.Scope +> -export const start = Effect.fn("ServerProcess.start")(function* (options: Options) { +export const start = Effect.fn("ServerProcess.start")(function* (options: Options) { if (!options.password) return yield* Effect.fail(new Error("Missing server password")) - const address = yield* listen(options) - yield* Effect.gen(function* () { - const client = yield* HttpApiClient.make(ReadinessApi, { - baseUrl: HttpServer.formatAddress(address), - transformClient: HttpClient.mapRequest((request) => - HttpClientRequest.setHeader( - request, - "authorization", - ServerAuth.header({ username: "opencode", password: options.password }) ?? "", - ), + const shutdown = yield* Deferred.make() + const status = yield* Status.make({ + instanceID: options.instanceID, + managed: options.service !== undefined, + }) + const bound = yield* listen(options) + const application = yield* Ref.make(Option.none()) + yield* bound.http.serve(dispatch(options.password, status, application, shutdown), HttpMiddleware.logger) + if (options.service) yield* options.service.onListen(bound.http.address) + + const parentScope = yield* Scope.Scope + const applicationScope = yield* Scope.fork(parentScope) + yield* Effect.addFinalizer(() => + status + .beginStopping + .pipe( + Effect.andThen(Ref.set(application, Option.none())), + Effect.andThen(Effect.sync(() => bound.server.closeAllConnections())), ), - }) - yield* client["server.health"]["health.get"]({}) - }).pipe(Effect.provide(NodeHttpClient.layerNodeHttp)) - return address + ) + + const boot = Effect.gen(function* () { + const context = yield* Layer.buildWithScope( + createRoutes(options.password, () => { + const address = bound.server.address() + if (address === null || typeof address === "string") return [] + const host = address.family === "IPv6" ? `[${address.address}]` : address.address + return ServerInfo.connectionURLs(`http://${host}:${address.port}`, options.hostname) + }).pipe(Layer.provide(NodeHttpServer.layerHttpServices)), + applicationScope, + ) + if (options.service) { + yield* installRestartContinuity(Context.get(context, SessionRestart.Service)).pipe( + Effect.provideService(Scope.Scope, applicationScope), + ) + } + yield* Ref.set(application, Option.some(Context.get(context, HttpRouter.HttpRouter).asHttpEffect())) + yield* status.ready + return { address: bound.http.address, shutdown: Deferred.await(shutdown) } + }).pipe( + Effect.catchCause((cause) => { + if (!options.service || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause) + return status.fail.pipe( + Effect.andThen( + Scope.close(applicationScope, Exit.failCause(cause)).pipe( + Effect.catchCause((cleanupCause) => + Effect.logError("failed to clean up background service boot", { cause: cleanupCause }), + ), + ), + ), + Effect.andThen(Effect.logError("background service boot failed", { cause })), + Effect.andThen(Effect.never), + ) + }), + ) + if (!options.service) return yield* boot + return yield* Effect.raceFirst(boot, Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt))) }) -function listen(options: Options) { - if (Option.isSome(options.port)) return bind(options, options.port.value) +function listen(options: { readonly hostname: string; readonly port: Option.Option }) { + if (Option.isSome(options.port)) return bind(options.hostname, options.port.value) const next = (port: number): ReturnType => - bind(options, port).pipe(Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1)))) + bind(options.hostname, port).pipe( + Effect.catch((error) => (port < 65_535 && addressInUse(error) ? next(port + 1) : Effect.fail(error))), + ) return next(4096) } -function bind(options: Options, port: number) { - const server = createServer() - return Layer.build( - createRoutes(options.password, () => { - const address = server.address() - if (address === null || typeof address === "string") return [] - const host = address.family === "IPv6" ? `[${address.address}]` : address.address - return ServerInfo.connectionURLs(`http://${host}:${address.port}`, options.hostname) +function bind(hostname: string, port: number) { + return Effect.gen(function* () { + const parentScope = yield* Scope.Scope + const serverScope = yield* Scope.fork(parentScope) + const server = createServer() + return yield* Effect.gen(function* () { + const http = yield* NodeHttpServer.make(() => server, { port, host: hostname }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections())) + return { http, server } }).pipe( - Layer.flatMap((context) => { - const serve = HttpServer.serve( - Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), - HttpMiddleware.logger, - ) - if (!options.restartContinuity) return serve - const restart = Context.get(context, SessionRestart.Service) - return Layer.merge(serve, restartContinuity(restart)) - }), - Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: options.hostname })), - ), - ).pipe( - Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))), - Effect.map((context) => Context.get(context, HttpServer.HttpServer).address), + Effect.provideService(Scope.Scope, serverScope), + Effect.onError((cause) => Scope.close(serverScope, Exit.failCause(cause))), + ) + }) +} + +function addressInUse(error: unknown) { + if (typeof error !== "object" || error === null || !("cause" in error)) return false + const cause = error.cause + return typeof cause === "object" && cause !== null && "code" in cause && cause.code === "EADDRINUSE" +} + +function dispatch( + password: string, + status: Status.Interface, + application: Ref.Ref>, + shutdown: Deferred.Deferred, +): App { + const auth = ServerAuth.Config.of({ username: "opencode", password: Option.some(password) }) + return Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const url = new URL(request.url, "http://localhost") + const lifecycle = + request.method === "GET" && url.pathname === "/api/health" + ? "health" + : request.method === "POST" && url.pathname === "/api/service/stop" + ? "stop" + : undefined + if (lifecycle !== undefined) { + if (!(yield* authorizedRequest(request, auth))) return unauthorized() + return yield* control(request, lifecycle, status, () => Deferred.doneUnsafe(shutdown, Effect.void)) + } + const state = yield* status.current + const app = yield* Ref.get(application) + const ready = state.type === "ready" && Option.isSome(app) + if ((!ready || !hasPtyConnectTicketURL(url)) && !(yield* authorizedRequest(request, auth))) return unauthorized() + if (ready) return yield* app.value + return unavailable(state) + }) +} + +function unauthorized() { + return HttpServerResponse.empty({ + status: 401, + headers: { "www-authenticate": 'Basic realm="Secure Area"' }, + }) +} + +const control = Effect.fnUntraced(function* ( + request: HttpServerRequest.HttpServerRequest, + route: "health" | "stop", + status: Status.Interface, + stop: () => void, +) { + if (route === "health") return yield* healthResponse(status) + const body = yield* request.json.pipe(Effect.option) + const input = Option.isSome(body) ? Schema.decodeUnknownOption(ServiceStatus.StopRequest)(body.value) : Option.none() + if (Option.isNone(input)) return HttpServerResponse.jsonUnsafe({ code: "invalid_request" }, { status: 400 }) + const accepted = yield* status.requestStop(input.value) + if (accepted) { + const response = NodeHttpServerRequest.toServerResponse(request) + yield* Effect.sync(() => { + const complete = () => { + response.off("finish", complete) + response.off("close", complete) + stop() + } + response.once("finish", complete) + response.once("close", complete) + }) + } + return HttpServerResponse.jsonUnsafe({ accepted }) +}) + +const healthResponse = Effect.fnUntraced(function* (status: Status.Interface) { + const state = yield* status.current + return HttpServerResponse.jsonUnsafe({ healthy: true, version: InstallationVersion, pid: process.pid }, { + status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503, + headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined, + }) +}) + +function unavailable(status: Status.State) { + if (status.type === "failed") + return HttpServerResponse.jsonUnsafe( + { + code: "service_failed", + message: "The background service could not start.", + action: "Run `opencode service restart` after checking the service logs.", + }, + { status: 503 }, + ) + return HttpServerResponse.jsonUnsafe( + { code: status.type === "stopping" ? "service_stopping" : "service_starting" }, + { status: 503, headers: { "retry-after": "1" } }, ) } @@ -77,12 +213,8 @@ function bind(options: Options, port: number) { * suspends its own active Sessions on graceful shutdown. Suspension runs while the drains are still * alive: connections close first, this finalizer runs next, and Session execution teardown follows. */ -function restartContinuity(restart: SessionRestart.Interface) { - return Layer.effectDiscard( - Effect.gen(function* () { - yield* Effect.forkScoped(restart.resumeSuspendedSessions) - // Registered after the fork so suspension observes still-running resumed drains during teardown. - yield* Effect.addFinalizer(() => restart.suspendActiveSessions) - }), - ) -} +const installRestartContinuity = Effect.fnUntraced(function* (restart: SessionRestart.Interface) { + yield* Effect.forkScoped(restart.resumeSuspendedSessions) + // Registered after the fork so suspension observes still-running resumed drains during teardown. + yield* Effect.addFinalizer(() => restart.suspendActiveSessions) +}) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 3f44ec3bf1..05e76f0f97 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -8,7 +8,6 @@ import { Observability } from "@opencode-ai/core/observability" import { Credential } from "@opencode-ai/core/credential" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { PtyTicket } from "@opencode-ai/core/pty/ticket" -import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { Project } from "@opencode-ai/core/project" import { SessionV2 } from "@opencode-ai/core/session" import { Job } from "@opencode-ai/core/job" @@ -38,7 +37,6 @@ const applicationServices = LayerNode.group([ httpClient, ToolOutputStore.cleanupNode, Job.node, - MoveSession.node, Project.node, SessionV2.node, PluginRuntime.providerNode, @@ -76,14 +74,9 @@ function makeRoutes( const serviceLayer = simulateEnabled() ? Layer.unwrap( Effect.gen(function* () { - const { simulationReplacements, startDriveServer } = yield* Effect.promise( - () => import("@opencode-ai/simulation/backend"), - ) - if (driveEnabled()) startDriveServer() - return AppNodeBuilder.build(applicationServices, [ - ...replacements, - ...(simulateEnabled() ? simulationReplacements : []), - ]) + const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend")) + const simulation = yield* simulationReplacements() + return AppNodeBuilder.build(applicationServices, [...replacements, ...simulation]) }), ) : AppNodeBuilder.build(applicationServices, replacements) @@ -116,10 +109,5 @@ function simulateEnabled() { return !!process.env.OPENCODE_SIMULATE } -function driveEnabled() { - return !!process.env.OPENCODE_DRIVE -} - -export const routes = createRoutes() - -export const webHandler = () => HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices))) +export const webHandler = () => + HttpRouter.toWebHandler(createRoutes().pipe(Layer.provide(HttpServer.layerServices))) diff --git a/packages/server/src/service-status.ts b/packages/server/src/service-status.ts new file mode 100644 index 0000000000..bf2853479c --- /dev/null +++ b/packages/server/src/service-status.ts @@ -0,0 +1,45 @@ +export * as Status from "./service-status" + +import { ServiceStatus } from "@opencode-ai/protocol/groups/health" +import { Effect, Ref } from "effect" + +export type State = + | { readonly type: "starting" } + | { readonly type: "ready" } + | { readonly type: "stopping" } + | { readonly type: "failed" } + +export interface Interface { + readonly current: Effect.Effect + readonly ready: Effect.Effect + readonly fail: Effect.Effect + readonly beginStopping: Effect.Effect + readonly requestStop: (request: ServiceStatus.StopRequest) => Effect.Effect +} + +export const make = Effect.fnUntraced(function* (options: { + readonly instanceID: string + readonly managed: boolean + readonly initial?: State +}) { + const current = yield* Ref.make(options.initial ?? ({ type: "starting" } satisfies State)) + const beginStopping = Ref.update(current, (status) => + status.type === "stopping" ? status : ({ type: "stopping" } satisfies State), + ) + + return { + current: Ref.get(current), + ready: Ref.update(current, (status) => + status.type === "starting" ? ({ type: "ready" } satisfies State) : status, + ), + fail: Ref.update(current, (status) => + status.type === "starting" ? ({ type: "failed" } satisfies State) : status, + ), + beginStopping, + requestStop: (request) => { + if (!options.managed || request.instanceID !== options.instanceID) + return Effect.succeed(false) + return beginStopping.pipe(Effect.as(true)) + }, + } satisfies Interface +}) diff --git a/packages/server/test/service-status.test.ts b/packages/server/test/service-status.test.ts new file mode 100644 index 0000000000..18f495e235 --- /dev/null +++ b/packages/server/test/service-status.test.ts @@ -0,0 +1,45 @@ +import { expect } from "bun:test" +import { Effect } from "effect" +import { it } from "../../core/test/lib/effect" +import { Status } from "../src/service-status" + +it.effect("moves from starting to ready", () => + Effect.gen(function* () { + const status = yield* Status.make({ instanceID: "one", managed: false }) + expect(yield* status.current).toEqual({ type: "starting" }) + yield* status.ready + expect(yield* status.current).toEqual({ type: "ready" }) + }), +) + +it.effect("keeps a startup failure until shutdown", () => + Effect.gen(function* () { + const status = yield* Status.make({ instanceID: "one", managed: true }) + yield* status.fail + yield* status.ready + yield* status.fail + expect(yield* status.current).toEqual({ type: "failed" }) + }), +) + +it.effect("stops only the addressed managed instance", () => + Effect.gen(function* () { + const status = yield* Status.make({ instanceID: "one", managed: true }) + + expect(yield* status.requestStop({ instanceID: "other" })).toBe(false) + expect(yield* status.current).toEqual({ type: "starting" }) + expect(yield* status.requestStop({ instanceID: "one" })).toBe(true) + expect(yield* status.current).toEqual({ type: "stopping" }) + }), +) + +it.effect("keeps stopping after shutdown begins", () => + Effect.gen(function* () { + const status = yield* Status.make({ instanceID: "one", managed: true }) + + yield* status.beginStopping + expect(yield* status.current).toEqual({ type: "stopping" }) + expect(yield* status.requestStop({ instanceID: "one" })).toBe(true) + expect(yield* status.current).toEqual({ type: "stopping" }) + }), +) diff --git a/packages/simulation/package.json b/packages/simulation/package.json index 624cf32ef7..c3baba77e9 100644 --- a/packages/simulation/package.json +++ b/packages/simulation/package.json @@ -17,10 +17,10 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@fontsource/adwaita-mono": "5.2.1", + "@fontsource/commit-mono": "5.2.5", "@napi-rs/canvas": "1.0.2", "@opencode-ai/core": "workspace:*", - "@opencode-ai/llm": "workspace:*", + "@opencode-ai/ai": "workspace:*", "@opentui/core": "catalog:", "effect": "catalog:" }, diff --git a/packages/simulation/src/assets.d.ts b/packages/simulation/src/assets.d.ts new file mode 100644 index 0000000000..f80c06f5d1 --- /dev/null +++ b/packages/simulation/src/assets.d.ts @@ -0,0 +1,4 @@ +declare module "*.woff2" { + const path: string + export default path +} diff --git a/packages/simulation/src/backend/control.ts b/packages/simulation/src/backend/control.ts deleted file mode 100644 index eaacc162c1..0000000000 --- a/packages/simulation/src/backend/control.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Effect } from "effect" -import { SimulationProtocol } from "../protocol" -import { SimulationLLMExchange } from "./llm-exchange" - -/** - * Backend-hosted simulation control WebSocket. - * - * JSON-RPC 2.0 over a loopback WebSocket, mirroring the protocol of the TUI - * simulation server. Drivers connect directly (standalone topology; no - * frontend proxy) to answer LLM exchanges and inspect the simulated network. - * This is also the headless-simulation interface: it works with no TUI at - * all. - * - * Methods: - * - `llm.attach` -> subscribe; pending and future exchanges arrive - * as `llm.request` notifications - * - `llm.chunk` { id, items } append response items to an exchange - * - `llm.finish` { id, reason? } finish an exchange - * - `llm.disconnect` { id } abruptly terminate an exchange without a finish - * - `llm.pending` list open exchanges - */ - -type ControlSocket = Bun.ServerWebSocket<{ unsubscribe?: () => void }> - -function parseRequest(input: string | Buffer) { - return SimulationProtocol.Backend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) -} - -async function handle(socket: ControlSocket, request: SimulationProtocol.Backend.Request): Promise { - switch (request.method) { - case "llm.attach": { - socket.data.unsubscribe?.() - socket.data.unsubscribe = SimulationLLMExchange.subscribe((exchange) => { - socket.send(JSON.stringify({ jsonrpc: "2.0", method: "llm.request", params: exchange })) - }) - return { attached: true } - } - case "llm.chunk": { - await Effect.runPromise( - SimulationLLMExchange.push( - request.params.id, - request.params.items.map((item) => ({ type: "item", item }) as const), - ), - ) - return { ok: true } - } - case "llm.finish": { - await Effect.runPromise( - SimulationLLMExchange.push(request.params.id, [{ type: "finish", reason: request.params.reason }]), - ) - return { ok: true } - } - case "llm.disconnect": { - await Effect.runPromise(SimulationLLMExchange.disconnect(request.params.id)) - return { ok: true } - } - case "llm.pending": - return { exchanges: SimulationLLMExchange.pending() } - } -} - -export function start(endpoint: string) { - const url = new URL(endpoint) - const server = Bun.serve<{ unsubscribe?: () => void }>({ - hostname: url.hostname, - port: Number(url.port), - fetch(request, server) { - if (server.upgrade(request, { data: {} })) return undefined - return new Response("opencode drive backend websocket", { status: 426 }) - }, - websocket: { - close(socket) { - socket.data.unsubscribe?.() - }, - async message(socket, message) { - let request: SimulationProtocol.Backend.Request | undefined - try { - request = parseRequest(message) - const result = await handle(socket, request) - const response = SimulationProtocol.JsonRpc.success(request.id, result) - if (response) socket.send(JSON.stringify(response)) - } catch (error) { - socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error))) - } - }, - }, - }) - process.stderr.write(`opencode drive backend websocket: ${endpoint}\n`) - return { - url: endpoint, - stop: () => { - server.stop(true) - }, - } -} - -export * as SimulationControl from "./control" diff --git a/packages/simulation/src/backend/index.ts b/packages/simulation/src/backend/index.ts index 2b8bb876e6..56c2ed87c2 100644 --- a/packages/simulation/src/backend/index.ts +++ b/packages/simulation/src/backend/index.ts @@ -1,9 +1,11 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { httpClient } from "@opencode-ai/core/effect/app-node-platform" +import { Config, Effect, Layer } from "effect" +import { HttpClient } from "effect/unstable/http" import { DriveManifest } from "../manifest" -import { SimulationControl } from "./control" import { SimulationNetwork } from "./network" import { SimulationOpenAI } from "./openai" +import { SimulatedProvider } from "./simulated-provider" /** * Layer replacements applied when the server is built in simulation mode. @@ -17,17 +19,29 @@ import { SimulationOpenAI } from "./openai" * */ -SimulationNetwork.register(SimulationOpenAI.route) -// ModelsDev dies when its catalog fetch fails, so simulation answers it with -// an empty catalog; providers come from seeded config instead. -SimulationNetwork.register(SimulationNetwork.json("GET", "https://models.dev/api.json", {})) +export const simulationReplacements = Effect.fn("Simulation.replacements")(function* () { + // ModelsDev dies when its catalog fetch fails, so simulation answers it with + // an empty catalog; providers come from seeded config instead. + const models = SimulationNetwork.json("GET", "https://models.dev/api.json", {}) + const drive = yield* Config.string("OPENCODE_DRIVE").pipe(Config.withDefault(undefined)) + if (!drive) return [[httpClient, SimulationNetwork.layer([models])]] satisfies LayerNode.Replacements -export function startDriveServer() { - return SimulationControl.start(DriveManifest.resolve().endpoints.backend) -} - -export const simulationReplacements: LayerNode.Replacements = [ - [httpClient, SimulationNetwork.layer], -] + const manifest = yield* DriveManifest.resolve() + const networkLayer = Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + const provider = yield* SimulatedProvider.Service + const network = yield* SimulationNetwork.make([SimulationOpenAI.route(provider), models]) + return network.client + }), + ).pipe( + Layer.provide( + SimulatedProvider.layerDrive({ + endpoint: manifest.endpoints.backend, + }), + ), + ) + return [[httpClient, networkLayer]] satisfies LayerNode.Replacements +}) export * as Simulation from "./index" diff --git a/packages/simulation/src/backend/llm-exchange.ts b/packages/simulation/src/backend/llm-exchange.ts deleted file mode 100644 index 638f80c1d0..0000000000 --- a/packages/simulation/src/backend/llm-exchange.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Effect, Queue } from "effect" - -/** - * Pending driver-answered LLM exchanges. - * - * When the simulated network receives a provider request it opens an - * exchange: the parsed request body plus a queue of response chunks. The - * simulation control WebSocket notifies the external driver, and the driver - * pushes chunks back until it finishes the exchange. The driver is the - * model; nothing is scripted or enqueued server-side. - * - * Process-global by design (plain module state, like the network route - * table): the simulated network and the control server must observe the same - * exchanges regardless of which layer instance touched them. - */ - -/** One response item the driver sends back. Compiled to provider wire chunks by the endpoint. */ -export type Item = - | { readonly type: "textDelta"; readonly text: string } - | { readonly type: "reasoningDelta"; readonly text: string } - | { - readonly type: "toolCall" - readonly index: number - readonly id: string - readonly name: string - readonly input: unknown - } - | { readonly type: "raw"; readonly chunk: unknown } - -export type FinishReason = "stop" | "tool-calls" | "length" | "content-filter" - -export type Chunk = - | { readonly type: "item"; readonly item: Item } - | { readonly type: "finish"; readonly reason: FinishReason } - -export interface Exchange { - readonly id: string - readonly url: string - readonly body: unknown - readonly queue: Queue.Queue -} - -export interface OpenedExchange { - readonly id: string - readonly url: string - readonly body: unknown -} - -const state = { - counter: 0, - exchanges: new Map(), - listeners: new Set<(exchange: OpenedExchange) => void>(), -} - -export class ExchangeNotFoundError extends Error { - constructor(id: string) { - super(`Simulation LLM exchange not found or already finished: ${id}`) - } -} - -/** Opens an exchange and notifies listeners. Called by the simulated provider endpoint. */ -export const open = (input: { readonly url: string; readonly body: unknown }) => - Effect.gen(function* () { - const id = `ex_${++state.counter}` - const queue = yield* Queue.unbounded() - const exchange: Exchange = { id, url: input.url, body: input.body, queue } - state.exchanges.set(id, exchange) - for (const listener of state.listeners) listener({ id, url: input.url, body: input.body }) - return exchange - }) - -/** Closes an exchange without consuming remaining chunks (response interrupted or finished). */ -export const close = (id: string) => - Effect.suspend(() => { - const exchange = state.exchanges.get(id) - state.exchanges.delete(id) - if (!exchange) return Effect.void - return Queue.shutdown(exchange.queue).pipe(Effect.asVoid) - }) - -/** Appends response chunks to an open exchange. Driver-facing. */ -export const push = (id: string, chunks: readonly Chunk[]) => - Effect.gen(function* () { - const exchange = state.exchanges.get(id) - if (!exchange) return yield* Effect.fail(new ExchangeNotFoundError(id)) - yield* Queue.offerAll(exchange.queue, chunks) - }) - -/** Abruptly ends the provider body without a finish chunk or SSE sentinel. */ -export const disconnect = (id: string) => - Effect.gen(function* () { - const exchange = state.exchanges.get(id) - if (!exchange) return yield* Effect.fail(new ExchangeNotFoundError(id)) - yield* Queue.shutdown(exchange.queue) - }) - -/** - * Registers a listener for newly opened exchanges and immediately replays - * currently-pending ones, so a late-attaching driver observes requests that - * arrived before it connected. Returns an unsubscribe function. - */ -export function subscribe(listener: (exchange: OpenedExchange) => void) { - state.listeners.add(listener) - for (const exchange of pending()) listener(exchange) - return () => { - state.listeners.delete(listener) - } -} - -/** Snapshot of currently open exchanges, for control-surface inspection. */ -export function pending(): OpenedExchange[] { - return [...state.exchanges.values()].map((exchange) => ({ - id: exchange.id, - url: exchange.url, - body: exchange.body, - })) -} - -export * as SimulationLLMExchange from "./llm-exchange" diff --git a/packages/simulation/src/backend/network.ts b/packages/simulation/src/backend/network.ts index 7b002fcb4e..f91ec8246c 100644 --- a/packages/simulation/src/backend/network.ts +++ b/packages/simulation/src/backend/network.ts @@ -1,7 +1,8 @@ -import { Effect, Layer } from "effect" -import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { Clock, Effect, Layer, Ref } from "effect" +import { HttpClient, HttpClientResponse, type HttpMethod } from "effect/unstable/http" import { HttpClientError, TransportError } from "effect/unstable/http/HttpClientError" import type { HttpClientRequest } from "effect/unstable/http" +import { SimulationProtocol } from "../protocol" /** * Simulated network. @@ -12,8 +13,7 @@ import type { HttpClientRequest } from "effect/unstable/http" * silently reach the real network. The scripted LLM is one registered route, * not a separate mechanism. * - * The route table is process-global module state so the control surface and - * the client layer observe the same registrations. + * Each acquired run owns its routes and request log. */ export interface Route { @@ -21,33 +21,15 @@ export interface Route { readonly match: ( request: HttpClientRequest.HttpClientRequest, url: URL, - ) => Effect.Effect | undefined + ) => Effect.Effect | undefined } -interface LogEntry { - readonly time: number - readonly method: string - readonly url: string - readonly matched: boolean -} - -const state = { - routes: [] as Route[], - log: [] as LogEntry[], -} +export type LogEntry = SimulationProtocol.Backend.NetworkLogEntry const LOG_LIMIT = 1000 -export function register(route: Route) { - state.routes.push(route) - return () => { - const index = state.routes.indexOf(route) - if (index >= 0) state.routes.splice(index, 1) - } -} - /** Static JSON route: exact method + origin/path match answered with a fixed body. */ -export function json(method: string, url: string, body: unknown): Route { +export function json(method: HttpMethod.HttpMethod, url: string, body: unknown): Route { return { match: (request, requestUrl) => { if (request.method !== method) return undefined @@ -62,24 +44,29 @@ export function json(method: string, url: string, body: unknown): Route { } } -export function log(): readonly LogEntry[] { - return state.log +export interface Run { + readonly client: HttpClient.HttpClient + readonly log: () => Effect.Effect } -function record(entry: LogEntry) { - state.log.push(entry) - if (state.log.length > LOG_LIMIT) state.log.splice(0, state.log.length - LOG_LIMIT) -} - -export const layer = Layer.sync(HttpClient.HttpClient)(() => - HttpClient.make((request, url) => - Effect.suspend(() => { - const matched = state.routes - .map((route) => route.match(request, url)) - .find((response) => response !== undefined) - record({ time: Date.now(), method: request.method, url: url.toString(), matched: matched !== undefined }) - if (matched) return matched - return Effect.fail( +export const make = Effect.fn("SimulationNetwork.make")(function* (routes: readonly Route[] = []) { + const log = yield* Ref.make([]) + const client = HttpClient.make((request, url) => + Effect.gen(function* () { + let matched: Effect.Effect | undefined + for (const route of routes) { + matched = route.match(request, url) + if (matched) break + } + const entry = { + time: yield* Clock.currentTimeMillis, + method: request.method, + url: url.toString(), + matched: matched !== undefined, + } + yield* Ref.update(log, (entries) => [...entries, entry].slice(-LOG_LIMIT)) + if (matched) return yield* matched + return yield* Effect.fail( new HttpClientError({ reason: new TransportError({ request, @@ -88,7 +75,11 @@ export const layer = Layer.sync(HttpClient.HttpClient)(() => }), ) }), - ), -) + ) + return { client, log: () => Ref.get(log) } satisfies Run +}) + +export const layer = (routes: readonly Route[] = []) => + Layer.effect(HttpClient.HttpClient, make(routes).pipe(Effect.map((run) => run.client))) export * as SimulationNetwork from "./network" diff --git a/packages/simulation/src/backend/openai.ts b/packages/simulation/src/backend/openai.ts index fb58a44724..248866e1a1 100644 --- a/packages/simulation/src/backend/openai.ts +++ b/packages/simulation/src/backend/openai.ts @@ -1,14 +1,15 @@ import { Effect, Schema, Stream } from "effect" import { HttpClientResponse } from "effect/unstable/http" -import { OpenAIChatEvent, DEFAULT_BASE_URL, PATH } from "@opencode-ai/llm/protocols/openai-chat" -import { SimulationLLMExchange } from "./llm-exchange" +import { HttpClientError, TransportError } from "effect/unstable/http/HttpClientError" +import { OpenAIChatEvent, DEFAULT_BASE_URL, PATH } from "@opencode-ai/ai/protocols/openai-chat" import { SimulationNetwork } from "./network" +import { SimulatedProvider } from "./simulated-provider" /** * Driver-answered OpenAI endpoint for the simulated network. * * Claims `POST {DEFAULT_BASE_URL}{PATH}` (the real openai-chat route - * endpoint), opens an LLM exchange, and streams the driver's chunks back as + * endpoint), invokes the simulated provider, and streams the driver's events back as * an OpenAI Chat SSE response terminated by `[DONE]`. Everything downstream * of the response bytes is the real pipeline: SSE framing, the OpenAIChat * event schema, the protocol state machine, and Lifecycle grammar. @@ -17,11 +18,15 @@ import { SimulationNetwork } from "./network" const encodeChunk = Schema.encodeUnknownSync(OpenAIChatEvent) const encoder = new TextEncoder() +const decodeBody = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Json)) // The simulated model id is echoed back only in non-schema fields; the // protocol event schema ignores unknown fields, so id/object/model are // decorative wire realism. -function chunkOf(item: SimulationLLMExchange.Item): OpenAIChatEvent | unknown { +type ProviderItem = Exclude +type FinishReason = Extract["reason"] + +function chunkOf(item: ProviderItem): OpenAIChatEvent | unknown { if (item.type === "textDelta") return { choices: [{ delta: { content: item.text } }] } if (item.type === "reasoningDelta") return { choices: [{ delta: { reasoning_content: item.text } }] } if (item.type === "toolCall") @@ -43,7 +48,7 @@ function chunkOf(item: SimulationLLMExchange.Item): OpenAIChatEvent | unknown { return item.chunk } -const finishReasonWire: Record = { +const finishReasonWire: Record = { stop: "stop", "tool-calls": "tool_calls", length: "length", @@ -54,40 +59,49 @@ function frame(payload: unknown): Uint8Array { return encoder.encode(`data: ${JSON.stringify(payload)}\n\n`) } -function sseBody(exchange: SimulationLLMExchange.Exchange): Stream.Stream { - const chunks = Stream.fromQueue(exchange.queue).pipe( - Stream.takeUntil((chunk) => chunk.type === "finish"), - Stream.map((chunk) => { - if (chunk.type === "finish") - return frame(encodeChunk({ choices: [{ delta: {}, finish_reason: finishReasonWire[chunk.reason] }] })) - if (chunk.item.type === "raw") return frame(chunk.item.chunk) - return frame(encodeChunk(chunkOf(chunk.item))) +function sseBody( + events: Stream.Stream, +): Stream.Stream { + return events.pipe( + Stream.map((event) => { + if (event.type === "finish") + return frame(encodeChunk({ choices: [{ delta: {}, finish_reason: finishReasonWire[event.reason] }] })) + if (event.type === "raw") return frame(event.chunk) + return frame(encodeChunk(chunkOf(event))) }), - ) - return chunks.pipe( Stream.concat(Stream.make(encoder.encode("data: [DONE]\n\n"))), - // Close the exchange when the response body ends or is interrupted, so - // late driver pushes fail with ExchangeNotFoundError instead of leaking. - Stream.ensuring(SimulationLLMExchange.close(exchange.id)), ) } -export const route: SimulationNetwork.Route = { +export const route = (provider: SimulatedProvider.Interface): SimulationNetwork.Route => ({ match: (request, url) => { if (request.method !== "POST") return undefined if (url.origin + url.pathname !== DEFAULT_BASE_URL + PATH) return undefined return Effect.gen(function* () { - const body = request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : {} - const exchange = yield* SimulationLLMExchange.open({ url: url.toString(), body }) + const body = + request.body._tag === "Uint8Array" + ? yield* decodeBody(new TextDecoder().decode(request.body.body)).pipe( + Effect.mapError( + (cause) => + new HttpClientError({ + reason: new TransportError({ + request, + cause, + description: "Simulation received an invalid OpenAI request body", + }), + }), + ), + ) + : {} return HttpClientResponse.fromWeb( request, - new Response(Stream.toReadableStream(sseBody(exchange)), { + new Response(Stream.toReadableStream(sseBody(provider.stream({ url: url.toString(), body }))), { status: 200, headers: { "content-type": "text/event-stream" }, }), ) }) }, -} +}) export * as SimulationOpenAI from "./openai" diff --git a/packages/simulation/src/backend/simulated-provider.ts b/packages/simulation/src/backend/simulated-provider.ts new file mode 100644 index 0000000000..69f01184aa --- /dev/null +++ b/packages/simulation/src/backend/simulated-provider.ts @@ -0,0 +1,270 @@ +import { Cause, Context, Effect, Fiber, FiberSet, Layer, PubSub, Queue, Ref, Schema, Semaphore, Stream } from "effect" +import { SimulationControlServer } from "../control-server" +import { SimulationProtocol } from "../protocol" + +export interface ProviderRequest { + readonly url: string + readonly body: unknown +} + +export type ProviderResponseEvent = + | SimulationProtocol.Backend.Item + | { readonly type: "finish"; readonly reason: SimulationProtocol.Backend.FinishReason } + +export class ProviderDisconnectedError extends Schema.TaggedErrorClass()( + "SimulatedProvider.ProviderDisconnectedError", + { message: Schema.String }, +) {} + +export interface Interface { + readonly stream: (request: ProviderRequest) => Stream.Stream +} + +export class Service extends Context.Service()("@opencode/simulation/SimulatedProvider") {} + +interface ProviderInvocation extends ProviderRequest { + readonly id: string +} + +interface PendingInvocation extends ProviderInvocation { + readonly responses: Queue.Queue +} + +interface State { + readonly counter: number + readonly pending: ReadonlyMap +} + +interface Driver { + readonly requests: Stream.Stream + readonly push: ( + id: string, + items: readonly SimulationProtocol.Backend.Item[], + ) => Effect.Effect + readonly finish: ( + id: string, + reason: SimulationProtocol.Backend.FinishReason, + ) => Effect.Effect + readonly disconnect: (id: string) => Effect.Effect + readonly pending: () => Effect.Effect +} + +class InvocationNotFoundError extends Schema.TaggedErrorClass()( + "SimulatedProvider.InvocationNotFoundError", + { id: Schema.String, message: Schema.String }, +) {} + +class ControllerDisconnectedError extends Schema.TaggedErrorClass()( + "SimulatedProvider.ControllerDisconnectedError", + { message: Schema.String }, +) {} + +type ControlSocket = SimulationControlServer.Socket + +export const layerDrive = (options: { readonly endpoint: string }) => + Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* Ref.make({ counter: 0, pending: new Map() }) + const opened = yield* PubSub.unbounded() + const lock = yield* Semaphore.make(1) + + const close = (invocation: PendingInvocation) => + Effect.gen(function* () { + yield* Queue.shutdown(invocation.responses) + yield* lock.withPermit( + Ref.update(state, (current) => + current.pending.get(invocation.id) === invocation ? remove(current, invocation.id) : current, + ), + ) + }) + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const current = yield* Ref.get(state) + yield* Effect.forEach(current.pending.values(), (invocation) => Queue.shutdown(invocation.responses), { + discard: true, + }) + yield* PubSub.shutdown(opened) + }), + ) + + const open = (request: ProviderRequest) => + lock.withPermit( + Effect.gen(function* () { + const current = yield* Ref.get(state) + const id = `inv_${current.counter + 1}` + const responses = yield* Queue.bounded(256) + const invocation: PendingInvocation = { id, ...request, responses } + yield* Ref.set(state, { + counter: current.counter + 1, + pending: new Map(current.pending).set(id, invocation), + }) + yield* PubSub.publish(opened, { id, ...request }) + return invocation + }), + ) + + const requireInvocation = (id: string) => + Effect.gen(function* () { + const current = yield* Ref.get(state) + const invocation = current.pending.get(id) + if (invocation) return invocation + return yield* Effect.fail( + new InvocationNotFoundError({ + id, + message: `Simulated provider invocation not found or already finished: ${id}`, + }), + ) + }) + + const remove = (current: State, id: string) => { + const pending = new Map(current.pending) + pending.delete(id) + return { ...current, pending } + } + + const driver: Driver = { + requests: Stream.unwrap( + lock.withPermit( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(opened) + const current = yield* Ref.get(state) + const pending = Array.from(current.pending.values(), ({ id, url, body }) => ({ id, url, body })) + return Stream.concat(Stream.fromIterable(pending), Stream.fromEffectRepeat(PubSub.take(subscription))) + }), + ), + ), + push: (id, items) => + Effect.gen(function* () { + const invocation = yield* lock.withPermit(requireInvocation(id)) + yield* Queue.offerAll(invocation.responses, items) + }), + finish: (id, reason) => + Effect.gen(function* () { + const invocation = yield* lock.withPermit( + Effect.gen(function* () { + const invocation = yield* requireInvocation(id) + const current = yield* Ref.get(state) + yield* Ref.set(state, remove(current, id)) + return invocation + }), + ) + yield* Queue.offer(invocation.responses, { type: "finish", reason }) + yield* Queue.end(invocation.responses) + }), + disconnect: (id) => + Effect.gen(function* () { + const invocation = yield* lock.withPermit( + Effect.gen(function* () { + const invocation = yield* requireInvocation(id) + const current = yield* Ref.get(state) + yield* Ref.set(state, remove(current, id)) + return invocation + }), + ) + yield* Queue.fail( + invocation.responses, + new ProviderDisconnectedError({ message: "Simulated model provider disconnected" }), + ) + }), + pending: () => + lock.withPermit( + Ref.get(state).pipe( + Effect.map((current) => Array.from(current.pending.values(), ({ id, url, body }) => ({ id, url, body }))), + ), + ), + } + + const fibers = yield* FiberSet.make() + const activeController = yield* Ref.make | undefined>(undefined) + const controllerLock = yield* Semaphore.make(1) + yield* SimulationControlServer.start({ + endpoint: options.endpoint, + label: "opencode drive backend websocket", + data: () => ({}), + decode: SimulationProtocol.Backend.decodeRequestEffect, + handle: (socket, request) => handle(driver, fibers, activeController, controllerLock, socket, request), + close: (socket) => releaseController(activeController, controllerLock, socket), + }) + yield* Effect.sync(() => process.stderr.write(`opencode drive backend websocket: ${options.endpoint}\n`)) + + return Service.of({ + stream: (request) => + Stream.unwrap( + Effect.acquireRelease(open(request), close).pipe( + Effect.map((invocation) => + Stream.fromQueue(invocation.responses).pipe(Stream.takeUntil((event) => event.type === "finish")), + ), + ), + ), + }) + }), + ) + +function handle( + driver: Driver, + fibers: FiberSet.FiberSet, + activeController: Ref.Ref | undefined>, + controllerLock: Semaphore.Semaphore, + socket: ControlSocket, + request: SimulationProtocol.Backend.Request, +) { + switch (request.method) { + case "llm.attach": + return controllerLock.withPermit( + Effect.gen(function* () { + if (socket.data.closed) + return yield* Effect.fail( + new ControllerDisconnectedError({ message: "Drive controller disconnected before attachment" }), + ) + const previous = yield* Ref.get(activeController) + if (previous) yield* Fiber.interrupt(previous) + const attachment = yield* FiberSet.run( + fibers, + driver.requests.pipe( + Stream.runForEach((invocation) => + Effect.sync(() => { + socket.send(JSON.stringify({ jsonrpc: "2.0", method: "llm.request", params: invocation })) + }), + ), + ), + ) + if (socket.data.closed) { + yield* Fiber.interrupt(attachment) + return yield* Effect.fail( + new ControllerDisconnectedError({ message: "Drive controller disconnected during attachment" }), + ) + } + socket.data.attachment = attachment + yield* Ref.set(activeController, attachment) + return { attached: true } + }), + ) + case "llm.chunk": + return driver.push(request.params.id, request.params.items).pipe(Effect.as({ ok: true })) + case "llm.finish": + return driver.finish(request.params.id, request.params.reason).pipe(Effect.as({ ok: true })) + case "llm.disconnect": + return driver.disconnect(request.params.id).pipe(Effect.as({ ok: true })) + case "llm.pending": + return driver.pending().pipe(Effect.map((invocations) => ({ invocations }))) + } +} + +function releaseController( + activeController: Ref.Ref | undefined>, + controllerLock: Semaphore.Semaphore, + socket: ControlSocket, +) { + return controllerLock.withPermit( + Effect.gen(function* () { + const attachment = socket.data.attachment + if (!attachment) return + yield* Fiber.interrupt(attachment) + yield* Ref.update(activeController, (active) => (active === attachment ? undefined : active)) + }), + ) +} + +export * as SimulatedProvider from "./simulated-provider" diff --git a/packages/simulation/src/control-server.ts b/packages/simulation/src/control-server.ts new file mode 100644 index 0000000000..fd4211669e --- /dev/null +++ b/packages/simulation/src/control-server.ts @@ -0,0 +1,91 @@ +import { Effect, Fiber, Queue, Stream } from "effect" +import { SimulationProtocol } from "./protocol" + +export interface Server { + readonly url: string +} + +interface Request { + readonly id?: string | number | null +} + +export interface SocketData { + readonly drive?: true + attachment?: Fiber.Fiber + closed?: true +} + +export type Socket = Bun.ServerWebSocket + +export function start(options: { + readonly endpoint: string + readonly label: string + readonly data: () => SocketData + readonly decode: (input: string) => Effect.Effect + readonly handle: (socket: Socket, request: RequestType) => Effect.Effect + readonly close?: (socket: Socket) => Effect.Effect +}) { + return Effect.gen(function* () { + const messages = yield* Queue.bounded<{ readonly socket: Socket; readonly input: string }>(256) + const closures = yield* Queue.unbounded() + yield* Stream.fromQueue(messages).pipe( + Stream.runForEach((message) => + options.decode(message.input).pipe( + Effect.flatMap((request) => + options.handle(message.socket, request).pipe( + Effect.matchEffect({ + onFailure: (error) => send(message.socket, SimulationProtocol.JsonRpc.failure(request.id, error)), + onSuccess: (result) => send(message.socket, SimulationProtocol.JsonRpc.success(request.id, result)), + }), + ), + ), + Effect.catch((error) => send(message.socket, SimulationProtocol.JsonRpc.failure(undefined, error))), + ), + ), + Effect.forkScoped, + ) + yield* Stream.fromQueue(closures).pipe( + Stream.runForEach((socket) => options.close?.(socket) ?? Effect.void), + Effect.forkScoped, + ) + const url = yield* Effect.try({ try: () => new URL(options.endpoint), catch: (cause) => cause }) + yield* Effect.acquireRelease( + Effect.sync(() => + Bun.serve({ + hostname: url.hostname, + port: Number(url.port), + fetch(request, server) { + if (server.upgrade(request, { data: options.data() })) return undefined + return new Response(options.label, { status: 426 }) + }, + websocket: { + close(socket) { + socket.data.closed = true + Queue.offerUnsafe(closures, socket) + }, + message(socket, message) { + const input = typeof message === "string" ? message : message.toString() + if (Queue.offerUnsafe(messages, { socket, input })) return + socket.send( + JSON.stringify( + SimulationProtocol.JsonRpc.failure(undefined, new Error("Simulation control queue is full")), + ), + ) + }, + }, + }), + ), + (server) => Effect.promise(() => server.stop(true)), + ) + return { url: options.endpoint } satisfies Server + }) +} + +function send(socket: Socket, response: SimulationProtocol.JsonRpc.Response | undefined) { + if (!response) return Effect.void + return Effect.sync(() => { + socket.send(JSON.stringify(response)) + }) +} + +export * as SimulationControlServer from "./control-server" diff --git a/packages/simulation/src/frontend/actions.ts b/packages/simulation/src/frontend/actions.ts index dbd54eb23b..64e9fd1cd8 100644 --- a/packages/simulation/src/frontend/actions.ts +++ b/packages/simulation/src/frontend/actions.ts @@ -1,11 +1,10 @@ -import { mkdir } from "node:fs/promises" import { tmpdir } from "node:os" import { extname, join, resolve } from "node:path" import type { CliRenderer, Renderable } from "@opentui/core" import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing" +import { Config, Effect, FileSystem } from "effect" import type { SimulationProtocol } from "../protocol" import { SimulationRenderer } from "./renderer" -import { SimulationPng } from "./png" export type Action = SimulationProtocol.Frontend.Action export type Element = SimulationProtocol.Frontend.Element @@ -72,10 +71,7 @@ export function createHarness(renderer: CliRenderer): Harness { // captureCharFrame follows the test renderer's output sink. Recording // redirects that sink to the timeline, so read the live render buffer // instead; it is also the source used by screenshots. - screen: () => - decoder.decode( - (Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes(), - ), + screen: () => decoder.decode((Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes()), } } @@ -114,31 +110,48 @@ export function matches(harness: Pick, text: string) { return harness.screen().includes(text) } -export async function screenshot(harness: Harness, name?: string) { - await harness.renderOnce() - const image = SimulationPng.screenshot(harness.renderer) - const filename = name ?? `screenshot-${crypto.randomUUID()}` - if ( - !filename || - filename.includes("/") || - filename.includes("\\") || - extname(filename) - ) - throw new Error("screenshot name must not contain a path or extension") - const directory = resolve( - process.env.OPENCODE_DRIVE_MEDIA_DIR ?? - join(tmpdir(), "opencode-drive", "output"), - ) - await mkdir(directory, { recursive: true }) - const path = join(directory, `${filename}.png`) - await Bun.write(path, image.data) - return path -} +export const capture = Effect.fn("SimulationActions.capture")(function* (harness: Harness) { + yield* Effect.tryPromise(() => harness.renderOnce()) + const buffer = harness.renderer.currentRenderBuffer + return { + cols: buffer.width, + rows: buffer.height, + cursor: [0, 0] as const, + lines: buffer.getSpanLines().map((line) => ({ + spans: line.spans.map((span) => ({ + text: span.text, + fg: span.fg.toInts(), + bg: span.bg.toInts(), + attributes: span.attributes, + width: span.width, + })), + })), + } satisfies SimulationProtocol.Frontend.CapturedFrame +}) -export async function execute(harness: Harness, action: Action) { +export const screenshot = Effect.fn("SimulationActions.screenshot")(function* (harness: Harness, name?: string) { + const filename = name ?? `screenshot-${crypto.randomUUID()}` + if (!filename || filename.includes("/") || filename.includes("\\") || extname(filename)) + return yield* Effect.fail(new Error("screenshot name must not contain a path or extension")) + yield* Effect.tryPromise(() => harness.renderOnce()) + const { SimulationPng } = yield* Effect.promise(() => import("./png")) + const image = SimulationPng.screenshot(harness.renderer) + const directory = resolve( + yield* Config.string("OPENCODE_DRIVE_MEDIA_DIR").pipe( + Config.withDefault(join(tmpdir(), "opencode-drive", "output")), + ), + ) + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(directory, { recursive: true }) + const path = join(directory, `${filename}.png`) + yield* fs.writeFile(path, image.data) + return path +}) + +export const execute = Effect.fn("SimulationActions.execute")(function* (harness: Harness, action: Action) { switch (action.type) { case "ui.type": - await harness.mockInput.typeText(action.text) + yield* Effect.tryPromise(() => harness.mockInput.typeText(action.text)) break case "ui.press": harness.mockInput.pressKey(action.key, action.modifiers) @@ -155,18 +168,23 @@ export async function execute(harness: Harness, action: Action) { ?.focus() break case "ui.click": - await harness.mockMouse.click(action.x, action.y) + yield* Effect.tryPromise(() => harness.mockMouse.click(action.x, action.y)) break case "ui.resize": - if (!Number.isSafeInteger(action.cols) || action.cols <= 0 || !Number.isSafeInteger(action.rows) || action.rows <= 0) { - throw new Error("resize cols and rows must be positive integers") + if ( + !Number.isSafeInteger(action.cols) || + action.cols <= 0 || + !Number.isSafeInteger(action.rows) || + action.rows <= 0 + ) { + return yield* Effect.fail(new Error("resize cols and rows must be positive integers")) } harness.resize(action.cols, action.rows) SimulationRenderer.recordResize(harness.renderer, action.cols, action.rows) break } - await harness.renderOnce() + yield* Effect.tryPromise(() => harness.renderOnce()) return state(harness) -} +}) export * as SimulationActions from "./actions" diff --git a/packages/simulation/src/frontend/png.ts b/packages/simulation/src/frontend/png.ts index 4f7b11c1a0..7022ee2272 100644 --- a/packages/simulation/src/frontend/png.ts +++ b/packages/simulation/src/frontend/png.ts @@ -1,22 +1,20 @@ -import { fileURLToPath } from "node:url" -import { GlobalFonts, createCanvas } from "@napi-rs/canvas" +/// +import { GlobalFonts, createCanvas, type SKRSContext2D } from "@napi-rs/canvas" import { TextAttributes, type CapturedFrame, type CliRenderer, type RGBA } from "@opentui/core" +import regularFont from "@fontsource/commit-mono/files/commit-mono-latin-400-normal.woff2" with { type: "file" } +import boldFont from "@fontsource/commit-mono/files/commit-mono-latin-700-normal.woff2" with { type: "file" } +import italicFont from "@fontsource/commit-mono/files/commit-mono-latin-400-italic.woff2" with { type: "file" } +import boldItalicFont from "@fontsource/commit-mono/files/commit-mono-latin-700-italic.woff2" with { type: "file" } const CellWidth = 10 const CellHeight = 20 const FontSize = 16 const FontFamily = "OpenCode Mono" -for (const file of [ - "adwaita-mono-latin-400-normal.woff2", - "adwaita-mono-latin-700-normal.woff2", - "adwaita-mono-latin-400-italic.woff2", - "adwaita-mono-latin-700-italic.woff2", -]) { - GlobalFonts.registerFromPath( - fileURLToPath(import.meta.resolve(`@fontsource/adwaita-mono/files/${file}`)), - FontFamily, - ) +for (const file of [regularFont, boldFont, italicFont, boldItalicFont]) { + const font = Buffer.from(await Bun.file(file).arrayBuffer()) + if (!GlobalFonts.register(font, FontFamily)) + throw new Error(`Failed to register screenshot font: ${file}`) } export function screenshot(renderer: CliRenderer) { @@ -54,13 +52,17 @@ export function screenshotFrame(frame: CapturedFrame) { } if (!hidden && char.codePointAt(0) !== 0x0a00) { context.fillStyle = color(foreground, attributes & TextAttributes.DIM ? 0.55 : 1) - context.font = `${attributes & TextAttributes.ITALIC ? "italic " : ""}${attributes & TextAttributes.BOLD ? "bold " : ""}${FontSize}px "${FontFamily}"` - context.fillText(char, column * CellWidth, row * CellHeight + 1) + const x = column * CellWidth + const y = row * CellHeight + if (!drawBlockElement(context, char, x, y, cells)) { + context.font = `${attributes & TextAttributes.ITALIC ? "italic " : ""}${attributes & TextAttributes.BOLD ? "bold " : ""}${FontSize}px "${FontFamily}"` + context.fillText(char, x, y + 1) + } if (attributes & TextAttributes.UNDERLINE) { - context.fillRect(column * CellWidth, row * CellHeight + 17, cells * CellWidth, 1) + context.fillRect(x, y + 17, cells * CellWidth, 1) } if (attributes & TextAttributes.STRIKETHROUGH) { - context.fillRect(column * CellWidth, row * CellHeight + 10, cells * CellWidth, 1) + context.fillRect(x, y + 10, cells * CellWidth, 1) } } column += cells @@ -83,6 +85,17 @@ export function screenshotFrame(frame: CapturedFrame) { } } +function drawBlockElement(context: SKRSContext2D, char: string, x: number, y: number, cells: number) { + const width = cells * CellWidth + if (char === "█") context.fillRect(x, y, width, CellHeight) + else if (char === "▀") context.fillRect(x, y, width, CellHeight / 2) + else if (char === "▄") context.fillRect(x, y + CellHeight / 2, width, CellHeight / 2) + else if (char === "┃") context.fillRect(x + CellWidth / 2 - 1, y, 2, CellHeight) + else if (char === "╹") context.fillRect(x + CellWidth / 2 - 1, y, 2, CellHeight / 2) + else return false + return true +} + function color(value: RGBA, opacity = 1) { const [red, green, blue, alpha] = value.toInts() return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255) * opacity})` diff --git a/packages/simulation/src/frontend/renderer.ts b/packages/simulation/src/frontend/renderer.ts index 8224a082c3..f10f0ec076 100644 --- a/packages/simulation/src/frontend/renderer.ts +++ b/packages/simulation/src/frontend/renderer.ts @@ -1,5 +1,6 @@ import type { CliRenderer, CliRendererConfig } from "@opentui/core" import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing" +import { Effect } from "effect" import { Timeline } from "../recording" const setups = new WeakMap() @@ -16,37 +17,47 @@ export interface Viewport { readonly rows: number } -export async function create(options: CliRendererConfig, path?: string, viewport?: Viewport): Promise { +export const create = Effect.fn("SimulationRenderer.create")(function* ( + options: CliRendererConfig, + path?: string, + viewport?: Viewport, +) { const cols = viewport?.cols ?? 100 const rows = viewport?.rows ?? 40 - if (!path) { - const setup = await createTestRenderer({ - ...options, - width: cols, - height: rows, - }) - setups.set(setup.renderer, setup) - return setup.renderer - } - const recording = await Timeline.create(path, cols, rows) - const setup = await createTestRenderer({ - ...options, - width: cols, - height: rows, - stdout: recording as unknown as NodeJS.WriteStream, - bufferedOutput: "stdout", - onDestroy: () => { - void recording.finish().catch((error) => process.stderr.write(`Failed to finish UI recording: ${error}\n`)) - options.onDestroy?.() - }, - }).catch(async (error) => { - await recording.finish().catch(() => undefined) - throw error - }) + const recording = path + ? yield* Effect.acquireRelease( + Effect.tryPromise(() => Timeline.create(path, cols, rows)), + (recording) => + Effect.tryPromise(() => recording.finish()).pipe( + Effect.catch((error) => + Effect.sync(() => process.stderr.write(`Failed to finish UI recording: ${error}\n`)), + ), + ), + ) + : undefined + const setup = yield* Effect.acquireRelease( + Effect.tryPromise(() => + createTestRenderer({ + ...options, + width: cols, + height: rows, + ...(recording + ? { + stdout: recording as unknown as NodeJS.WriteStream, + bufferedOutput: "stdout" as const, + } + : {}), + }), + ), + (setup) => + Effect.sync(() => { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + }), + ) setups.set(setup.renderer, setup) - recordings.set(setup.renderer, recording) + if (recording) recordings.set(setup.renderer, recording) return setup.renderer -} +}) export function recordResize(renderer: CliRenderer, cols: number, rows: number) { recordings.get(renderer)?.resize(cols, rows) @@ -58,8 +69,8 @@ export function setupFor(renderer: CliRenderer): TestRendererSetup | undefined { export function finish(renderer: CliRenderer) { const recording = recordings.get(renderer) - if (!recording) throw new Error("UI recording is not available") - return recording.finish() + if (!recording) return Effect.fail(new Error("UI recording is not available")) + return Effect.tryPromise(() => recording.finish()) } export * as SimulationRenderer from "./renderer" diff --git a/packages/simulation/src/frontend/server.ts b/packages/simulation/src/frontend/server.ts index ccb1bd626e..533ddeb3c6 100644 --- a/packages/simulation/src/frontend/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -1,31 +1,21 @@ +import { Effect } from "effect" +import { SimulationControlServer } from "../control-server" import { SimulationProtocol } from "../protocol" import { SimulationActions, type Harness } from "./actions" +import { SimulationRenderer } from "./renderer" -export interface Server { - readonly url: string - readonly stop: () => void -} - -function parseRequest(input: string | Buffer) { - return SimulationProtocol.Frontend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) -} - -async function handle( - harness: Harness, - request: SimulationProtocol.Frontend.Request, - finishRecording?: () => Promise, -) { +function handle(harness: Harness, request: SimulationProtocol.Frontend.Request) { switch (request.method) { + case "ui.capture": + return SimulationActions.capture(harness) case "ui.screenshot": return SimulationActions.screenshot(harness, request.params?.name) - case "ui.state": { - return SimulationActions.state(harness) - } + case "ui.state": + return Effect.sync(() => SimulationActions.state(harness)) case "ui.matches": - return SimulationActions.matches(harness, request.params.text) + return Effect.sync(() => SimulationActions.matches(harness, request.params.text)) case "ui.recording.finish": - if (!finishRecording) throw new Error("UI recording is not available") - return finishRecording() + return SimulationRenderer.finish(harness.renderer) case "ui.type": return SimulationActions.execute(harness, { type: "ui.type", text: request.params.text }) case "ui.enter": @@ -48,39 +38,22 @@ async function handle( y: request.params.y, }) case "ui.resize": - return SimulationActions.execute(harness, { type: "ui.resize", cols: request.params.cols, rows: request.params.rows }) + return SimulationActions.execute(harness, { + type: "ui.resize", + cols: request.params.cols, + rows: request.params.rows, + }) } } -export function start(harness: Harness, endpoint: string, finishRecording?: () => Promise): Server { - const url = new URL(endpoint) - const server = Bun.serve<{ readonly drive: true }>({ - hostname: url.hostname, - port: Number(url.port), - fetch(request, server) { - if (server.upgrade(request, { data: { drive: true } })) return undefined - return new Response("opencode drive ui websocket", { status: 426 }) - }, - websocket: { - async message(socket, message) { - let request: SimulationProtocol.Frontend.Request | undefined - try { - request = parseRequest(message) - const result = await handle(harness, request, finishRecording) - const next = SimulationProtocol.JsonRpc.success(request.id, result) - if (next) socket.send(JSON.stringify(next)) - } catch (error) { - socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error))) - } - }, - }, +export const start = Effect.fn("SimulationServer.start")(function* (harness: Harness, endpoint: string) { + return yield* SimulationControlServer.start({ + endpoint, + label: "opencode drive ui websocket", + data: () => ({ drive: true as const }), + decode: SimulationProtocol.Frontend.decodeRequestEffect, + handle: (_socket, request) => handle(harness, request), }) - return { - url: endpoint, - stop: () => { - server.stop(true) - }, - } -} +}) export * as SimulationServer from "./server" diff --git a/packages/simulation/src/frontend/simulation.ts b/packages/simulation/src/frontend/simulation.ts index 591ee4e0ba..0843754ff1 100644 --- a/packages/simulation/src/frontend/simulation.ts +++ b/packages/simulation/src/frontend/simulation.ts @@ -1,32 +1,27 @@ -import { createCliRenderer, type CliRenderer, type CliRendererConfig } from "@opentui/core" +import { createCliRenderer, type CliRendererConfig } from "@opentui/core" +import { Config, Effect } from "effect" import { DriveManifest } from "../manifest" import { SimulationActions } from "./actions" import { SimulationRenderer } from "./renderer" import { SimulationServer } from "./server" -/** - * Drive-mode renderer entry point. - * - * Creates the renderer (headless when OPENCODE_DRIVE_RENDERER=headless, the normal - * visible renderer otherwise) and starts the UI control - * server against it. The server stops when the renderer is destroyed, so the - * caller only manages the renderer lifecycle. - */ -export async function create(options: CliRendererConfig): Promise { - const headless = process.env.OPENCODE_DRIVE_RENDERER === "headless" - const manifest = DriveManifest.resolve() +/** Drive-mode renderer and control-server acquisition. */ +export const create = Effect.fn("Drive.create")(function* (options: CliRendererConfig) { + const headless = (yield* Config.string("OPENCODE_DRIVE_RENDERER").pipe(Config.withDefault("visible"))) === "headless" + const manifest = yield* DriveManifest.resolve() const renderer = headless - ? await SimulationRenderer.create(options, manifest.recording?.timeline, manifest.viewport) - : await createCliRenderer(options) + ? yield* SimulationRenderer.create(options, manifest.recording?.timeline, manifest.viewport) + : yield* Effect.acquireRelease( + Effect.tryPromise(() => createCliRenderer(options)), + (renderer) => + Effect.sync(() => { + if (!renderer.isDestroyed) renderer.destroy() + }), + ) if (!headless && manifest.viewport) renderer.resize(manifest.viewport.cols, manifest.viewport.rows) - const server = SimulationServer.start( - SimulationActions.createHarness(renderer), - manifest.endpoints.ui, - headless && manifest.recording ? () => SimulationRenderer.finish(renderer) : undefined, - ) - process.stderr.write(`opencode drive ui websocket: ${server.url}\n`) - renderer.once("destroy", () => server.stop()) + const server = yield* SimulationServer.start(SimulationActions.createHarness(renderer), manifest.endpoints.ui) + yield* Effect.sync(() => process.stderr.write(`opencode drive ui websocket: ${server.url}\n`)) return renderer -} +}) export * as Drive from "./simulation" diff --git a/packages/simulation/src/manifest.ts b/packages/simulation/src/manifest.ts index 4896c2d740..fe276ee688 100644 --- a/packages/simulation/src/manifest.ts +++ b/packages/simulation/src/manifest.ts @@ -1,20 +1,54 @@ -import { existsSync, readFileSync } from "node:fs" import { homedir } from "node:os" import { isAbsolute, join } from "node:path" +import { Config, Effect, FileSystem, Schema } from "effect" +import { PositiveInt } from "@opencode-ai/core/schema" -export interface Manifest { - readonly endpoints: { - readonly ui: string - readonly backend: string - } - readonly viewport?: { - readonly cols: number - readonly rows: number - } - readonly recording?: { - readonly timeline: string - } -} +const InstanceName = Schema.String.check( + Schema.makeFilter((value) => + /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(value) ? undefined : "a valid Drive instance name", + ), +) + +const Endpoint = Schema.String.check( + Schema.makeFilter((value) => { + if (!URL.canParse(value)) return "a loopback WebSocket endpoint with an explicit port" + const endpoint = new URL(value) + const port = Number(endpoint.port) + return endpoint.protocol === "ws:" && endpoint.hostname === "127.0.0.1" && Number.isInteger(port) && port >= 1 + ? undefined + : "a loopback WebSocket endpoint with an explicit port" + }), +) + +const AbsolutePath = Schema.String.check( + Schema.makeFilter((value) => (isAbsolute(value) ? undefined : "an absolute path")), +) + +export const Manifest = Schema.Struct({ + endpoints: Schema.Struct({ + ui: Endpoint, + backend: Endpoint, + }), + viewport: Schema.optionalKey( + Schema.Struct({ + cols: PositiveInt, + rows: PositiveInt, + }), + ), + recording: Schema.optionalKey( + Schema.Struct({ + timeline: AbsolutePath, + }), + ), +}) +export interface Manifest extends Schema.Schema.Type {} + +export class ResolveError extends Schema.TaggedErrorClass()("DriveManifest.ResolveError", { + reason: Schema.Literals(["config", "not-found", "read", "decode"]), + path: Schema.optionalKey(Schema.String), + message: Schema.String, + cause: Schema.Defect(), +}) {} export const defaults: Manifest = { endpoints: { @@ -23,48 +57,54 @@ export const defaults: Manifest = { }, } -export function resolve() { - const name = process.env.OPENCODE_DRIVE - if (!name) throw new Error("OPENCODE_DRIVE must contain a drive instance name") +const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest)) + +const configError = (cause: unknown) => + new ResolveError({ + reason: "config", + message: `Invalid Drive configuration: ${String(cause)}`, + cause, + }) + +export const resolve = Effect.fn("DriveManifest.resolve")(function* () { + const name = yield* Config.schema(InstanceName, "OPENCODE_DRIVE").pipe(Effect.mapError(configError)) if (name === "1") return defaults - if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) throw new Error(`Invalid drive instance name: ${name}`) - const directory = - process.env.DRIVE_REGISTRY_DIR ?? - join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode-drive", "instances") + const state = yield* Config.string("XDG_STATE_HOME").pipe( + Config.withDefault(join(homedir(), ".local", "state")), + Effect.mapError(configError), + ) + const directory = yield* Config.string("DRIVE_REGISTRY_DIR").pipe( + Config.withDefault(join(state, "opencode-drive", "instances")), + Effect.mapError(configError), + ) const file = join(directory, `${name}.json`) - if (!existsSync(file)) throw new Error(`Drive manifest not found: ${file}`) - - const manifest: unknown = JSON.parse(readFileSync(file, "utf8")) - if (!isManifest(manifest)) throw new Error(`Invalid drive manifest: ${file}`) - validateEndpoint(manifest.endpoints.ui, "ui") - validateEndpoint(manifest.endpoints.backend, "backend") - if (manifest.viewport) validateViewport(manifest.viewport) - if (manifest.recording && !isAbsolute(manifest.recording.timeline)) { - throw new Error(`Invalid drive recording timeline path: ${manifest.recording.timeline}`) - } - return manifest -} - -function isManifest(value: unknown): value is Manifest { - if (typeof value !== "object" || value === null || !("endpoints" in value)) return false - if (typeof value.endpoints !== "object" || value.endpoints === null) return false - return "ui" in value.endpoints && "backend" in value.endpoints -} - -function validateEndpoint(value: string, name: string) { - const endpoint = new URL(value) - const port = Number(endpoint.port) - if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" || !Number.isInteger(port) || port < 1) { - throw new Error(`Invalid drive ${name} endpoint: ${value}`) - } -} - -function validateViewport(value: Manifest["viewport"]) { - if (!value) return - if (!Number.isSafeInteger(value.cols) || value.cols <= 0 || !Number.isSafeInteger(value.rows) || value.rows <= 0) { - throw new Error(`Invalid drive viewport: ${JSON.stringify(value)}`) - } -} + const fs = yield* FileSystem.FileSystem + const contents = yield* fs.readFileString(file).pipe( + Effect.mapError( + (cause) => + new ResolveError({ + reason: cause.reason._tag === "NotFound" ? "not-found" : "read", + path: file, + message: + cause.reason._tag === "NotFound" + ? `Drive manifest not found: ${file}` + : `Failed to read Drive manifest: ${file}: ${cause.message}`, + cause, + }), + ), + ) + return yield* decode(contents).pipe( + Effect.mapError( + (cause) => + new ResolveError({ + reason: "decode", + path: file, + message: `Invalid Drive manifest: ${file}: ${cause.message}`, + cause, + }), + ), + ) +}) export * as DriveManifest from "./manifest" diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts index 556e644a02..55f08733bb 100644 --- a/packages/simulation/src/protocol/index.ts +++ b/packages/simulation/src/protocol/index.ts @@ -95,6 +95,29 @@ export namespace Frontend { export const Screenshot = Schema.String export type Screenshot = Schema.Schema.Type + export const Color = Schema.Tuple([Schema.Number, Schema.Number, Schema.Number, Schema.Number]) + export type Color = Schema.Schema.Type + + export const CapturedFrame = Schema.Struct({ + cols: Schema.Number, + rows: Schema.Number, + cursor: Schema.Tuple([Schema.Number, Schema.Number]), + lines: Schema.Array( + Schema.Struct({ + spans: Schema.Array( + Schema.Struct({ + text: Schema.String, + fg: Color, + bg: Color, + attributes: Schema.Number, + width: Schema.Number, + }), + ), + }), + ), + }) + export interface CapturedFrame extends Schema.Schema.Type {} + export const RecordingFinish = Schema.String export type RecordingFinish = Schema.Schema.Type @@ -142,9 +165,11 @@ export namespace Frontend { ...JsonRpc.RequestFields, method: Schema.Literals(["ui.enter", "ui.state", "ui.recording.finish"]), }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.capture") }), ]) export type Request = Schema.Schema.Type export const decodeRequest = Schema.decodeUnknownSync(Request) + export const decodeRequestEffect = Schema.decodeUnknownEffect(Schema.fromJsonString(Request)) } export namespace Backend { @@ -188,9 +213,10 @@ export namespace Backend { ]) export type Request = Schema.Schema.Type export const decodeRequest = Schema.decodeUnknownSync(Request) + export const decodeRequestEffect = Schema.decodeUnknownEffect(Schema.fromJsonString(Request)) - export const OpenedExchange = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json }) - export interface OpenedExchange extends Schema.Schema.Type {} + export const ProviderInvocation = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json }) + export interface ProviderInvocation extends Schema.Schema.Type {} export const NetworkLogEntry = Schema.Struct({ time: Schema.Number, diff --git a/packages/simulation/test/fixture/websocket.ts b/packages/simulation/test/fixture/websocket.ts new file mode 100644 index 0000000000..092b81b4fb --- /dev/null +++ b/packages/simulation/test/fixture/websocket.ts @@ -0,0 +1,26 @@ +import { Effect } from "effect" + +export function availableEndpoint() { + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() }) + const endpoint = `ws://127.0.0.1:${server.port}` + server.stop(true) + return endpoint +} + +export function connect(endpoint: string) { + return Effect.acquireRelease( + Effect.callback((resume) => { + const socket = new WebSocket(endpoint) + const open = () => resume(Effect.succeed(socket)) + const error = () => resume(Effect.fail(new Error(`Failed to connect to ${endpoint}`))) + socket.addEventListener("open", open, { once: true }) + socket.addEventListener("error", error, { once: true }) + return Effect.sync(() => { + socket.removeEventListener("open", open) + socket.removeEventListener("error", error) + socket.close() + }) + }), + (socket) => Effect.sync(() => socket.close()), + ) +} diff --git a/packages/simulation/test/frontend-server.test.ts b/packages/simulation/test/frontend-server.test.ts new file mode 100644 index 0000000000..451675d2e2 --- /dev/null +++ b/packages/simulation/test/frontend-server.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test" +import { Effect, FileSystem, Queue } from "effect" +import { SimulationActions } from "../src/frontend/actions" +import { SimulationRenderer } from "../src/frontend/renderer" +import { SimulationServer } from "../src/frontend/server" +import { availableEndpoint, connect } from "./fixture/websocket" + +test("scopes the frontend control server and reports malformed JSON", async () => { + const endpoint = availableEndpoint() + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const renderer = yield* SimulationRenderer.create({}) + yield* SimulationServer.start(SimulationActions.createHarness(renderer), endpoint) + const socket = yield* connect(endpoint) + const messages = yield* Queue.unbounded() + socket.addEventListener("message", (event) => { + Queue.offerUnsafe(messages, JSON.parse(String(event.data))) + }) + + socket.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ui.state" })) + expect(yield* Queue.take(messages)).toMatchObject({ + id: 1, + result: { focused: { editor: false }, elements: [] }, + }) + + socket.send(JSON.stringify({ jsonrpc: "2.0", id: 2, method: "ui.capture" })) + expect(yield* Queue.take(messages)).toMatchObject({ + id: 2, + result: { + cols: 100, + rows: 40, + cursor: [0, 0], + lines: expect.any(Array), + }, + }) + + socket.send("{") + expect(yield* Queue.take(messages)).toMatchObject({ + id: null, + error: { code: -32000 }, + }) + }), + ).pipe(Effect.provide(FileSystem.layerNoop({}))), + ) + + const url = new URL(endpoint) + const rebound = Bun.serve({ hostname: url.hostname, port: Number(url.port), fetch: () => new Response() }) + await rebound.stop(true) +}) diff --git a/packages/simulation/test/manifest.test.ts b/packages/simulation/test/manifest.test.ts new file mode 100644 index 0000000000..7755421d6f --- /dev/null +++ b/packages/simulation/test/manifest.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from "bun:test" +import { ConfigProvider, Effect, FileSystem, Layer } from "effect" +import { DriveManifest } from "../src/manifest" + +test("loads and validates a Drive manifest through Effect services", async () => { + const manifest = await Effect.runPromise( + DriveManifest.resolve().pipe( + Effect.provide( + Layer.merge( + FileSystem.layerNoop({ + readFileString: () => + Effect.succeed( + JSON.stringify({ + endpoints: { + ui: "ws://127.0.0.1:41000", + backend: "ws://127.0.0.1:41050", + }, + viewport: { cols: 120, rows: 50 }, + recording: { timeline: "/tmp/drive/timeline.jsonl" }, + }), + ), + }), + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_DRIVE: "test-instance", + DRIVE_REGISTRY_DIR: "/tmp/drive", + }), + ), + ), + ), + ), + ) + + expect(manifest).toEqual({ + endpoints: { + ui: "ws://127.0.0.1:41000", + backend: "ws://127.0.0.1:41050", + }, + viewport: { cols: 120, rows: 50 }, + recording: { timeline: "/tmp/drive/timeline.jsonl" }, + }) +}) + +test("reports schema-invalid manifests as typed decode failures", async () => { + const error = await Effect.runPromise( + DriveManifest.resolve().pipe( + Effect.flip, + Effect.provide( + Layer.merge( + FileSystem.layerNoop({ + readFileString: () => + Effect.succeed( + JSON.stringify({ + endpoints: { + ui: "https://example.com", + backend: "ws://127.0.0.1:41050", + }, + }), + ), + }), + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_DRIVE: "test-instance", + DRIVE_REGISTRY_DIR: "/tmp/drive", + }), + ), + ), + ), + ), + ) + + expect(error).toBeInstanceOf(DriveManifest.ResolveError) + expect(error.reason).toBe("decode") +}) diff --git a/packages/simulation/test/network.test.ts b/packages/simulation/test/network.test.ts new file mode 100644 index 0000000000..9049b5700c --- /dev/null +++ b/packages/simulation/test/network.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test" +import { Effect, Exit } from "effect" +import { TestClock } from "effect/testing" +import { HttpClientRequest } from "effect/unstable/http" +import { SimulationNetwork } from "../src/backend/network" + +test("keeps routes and request logs local to each network", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(1_234) + const first = yield* SimulationNetwork.make([ + SimulationNetwork.json("GET", "https://example.test/value", { source: "first" }), + ]) + const second = yield* SimulationNetwork.make() + const request = HttpClientRequest.get("https://example.test/value") + + const response = yield* first.client.execute(request) + expect(yield* response.text).toBe('{"source":"first"}') + expect(Exit.isFailure(yield* second.client.execute(request).pipe(Effect.exit))).toBe(true) + + expect(yield* first.log()).toEqual([ + { time: 1_234, method: "GET", url: "https://example.test/value", matched: true }, + ]) + expect(yield* second.log()).toEqual([ + { time: 1_234, method: "GET", url: "https://example.test/value", matched: false }, + ]) + }).pipe(Effect.provide(TestClock.layer())), + ), + ) +}) diff --git a/packages/simulation/test/openai.test.ts b/packages/simulation/test/openai.test.ts new file mode 100644 index 0000000000..069b4feed4 --- /dev/null +++ b/packages/simulation/test/openai.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test" +import { DEFAULT_BASE_URL, PATH } from "@opencode-ai/ai/protocols/openai-chat" +import { Effect, Stream } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { HttpClientError } from "effect/unstable/http/HttpClientError" +import { SimulationOpenAI } from "../src/backend/openai" +import { SimulatedProvider } from "../src/backend/simulated-provider" + +test("encodes every simulated provider event as OpenAI SSE", async () => { + const provider: SimulatedProvider.Interface = { + stream: () => + Stream.make( + { type: "textDelta", text: "Hello " }, + { type: "textDelta", text: "from Drive" }, + { type: "finish", reason: "stop" }, + ), + } + const url = new URL(DEFAULT_BASE_URL + PATH) + const request = HttpClientRequest.post(url).pipe(HttpClientRequest.bodyJsonUnsafe({ model: "gpt-5" })) + const matched = SimulationOpenAI.route(provider).match(request, url) + if (!matched) throw new Error("The simulated OpenAI route did not match") + + const body = await Effect.runPromise(matched.pipe(Effect.flatMap((response) => response.text))) + + expect(body).toBe( + [ + 'data: {"choices":[{"delta":{"content":"Hello "}}]}', + 'data: {"choices":[{"delta":{"content":"from Drive"}}]}', + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}', + "data: [DONE]", + "", + ].join("\n\n"), + ) +}) + +test("rejects malformed intercepted OpenAI JSON as an HTTP client error", async () => { + const provider: SimulatedProvider.Interface = { stream: () => Stream.empty } + const url = new URL(DEFAULT_BASE_URL + PATH) + const request = HttpClientRequest.post(url).pipe(HttpClientRequest.bodyText("{")) + const matched = SimulationOpenAI.route(provider).match(request, url) + if (!matched) throw new Error("The simulated OpenAI route did not match") + + const error = await Effect.runPromise(matched.pipe(Effect.flip)) + + expect(error).toBeInstanceOf(HttpClientError) + expect(error.reason._tag).toBe("TransportError") +}) diff --git a/packages/simulation/test/png.test.ts b/packages/simulation/test/png.test.ts new file mode 100644 index 0000000000..af41e355d2 --- /dev/null +++ b/packages/simulation/test/png.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from "bun:test" +import { createCanvas, loadImage } from "@napi-rs/canvas" +import { RGBA, TextAttributes, type CapturedFrame } from "@opentui/core" +import { SimulationPng } from "../src/frontend/png" + +test("renders captured frames with bundled fonts", () => { + const frame: CapturedFrame = { + cols: 4, + rows: 1, + cursor: [0, 0], + lines: [ + { + spans: [ + { + text: "Test", + width: 4, + fg: RGBA.fromInts(255, 255, 255), + bg: RGBA.fromInts(0, 0, 0), + attributes: TextAttributes.BOLD | TextAttributes.ITALIC, + }, + ], + }, + ], + } + + const image = SimulationPng.screenshotFrame(frame) + expect(image.width).toBe(40) + expect(image.height).toBe(20) + expect(image.data.subarray(1, 4).toString()).toBe("PNG") +}) + +test("fills adjacent block elements without glyph gaps", async () => { + const image = SimulationPng.screenshotFrame({ + cols: 2, + rows: 1, + cursor: [0, 0], + lines: [ + { + spans: [ + { + text: "▀▀", + width: 2, + fg: RGBA.fromInts(255, 255, 255), + bg: RGBA.fromInts(0, 0, 0), + attributes: 0, + }, + ], + }, + ], + }) + const canvas = createCanvas(image.width, image.height) + const context = canvas.getContext("2d") + context.drawImage(await loadImage(image.data), 0, 0) + + expect([...context.getImageData(0, 5, image.width, 1).data]).toEqual( + Array.from({ length: image.width }, () => [255, 255, 255, 255]).flat(), + ) + expect([...context.getImageData(0, 15, image.width, 1).data]).toEqual( + Array.from({ length: image.width }, () => [0, 0, 0, 255]).flat(), + ) +}) + +test("draws heavy vertical box elements on cell boundaries", async () => { + const image = SimulationPng.screenshotFrame({ + cols: 1, + rows: 2, + cursor: [0, 0], + lines: [ + { + spans: [ + { + text: "┃", + width: 1, + fg: RGBA.fromInts(255, 255, 255), + bg: RGBA.fromInts(0, 0, 0), + attributes: 0, + }, + ], + }, + { + spans: [ + { + text: "╹", + width: 1, + fg: RGBA.fromInts(255, 255, 255), + bg: RGBA.fromInts(0, 0, 0), + attributes: 0, + }, + ], + }, + ], + }) + const canvas = createCanvas(image.width, image.height) + const context = canvas.getContext("2d") + context.drawImage(await loadImage(image.data), 0, 0) + + expect([...context.getImageData(4, 0, 2, 30).data]).toEqual( + Array.from({ length: 60 }, () => [255, 255, 255, 255]).flat(), + ) + expect([...context.getImageData(4, 30, 2, 10).data]).toEqual( + Array.from({ length: 20 }, () => [0, 0, 0, 255]).flat(), + ) +}) diff --git a/packages/simulation/test/recording.test.ts b/packages/simulation/test/recording.test.ts index cbff7ac74c..00ef08b69d 100644 --- a/packages/simulation/test/recording.test.ts +++ b/packages/simulation/test/recording.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path" import { TextRenderable } from "@opentui/core" import { createHarness, matches } from "../src/frontend/actions" import { SimulationRenderer } from "../src/frontend/renderer" +import { Effect } from "effect" import { Timeline, type Event } from "../src/recording" test("streams ANSI chunks into a versioned JSONL timeline", async () => { @@ -39,21 +40,25 @@ test("streams ANSI chunks into a versioned JSONL timeline", async () => { test("captures native renderer output and finishes on destroy", async () => { const directory = await mkdtemp(join(tmpdir(), "simulation-renderer-recording-")) const path = join(directory, "timeline.jsonl") - const renderer = await SimulationRenderer.create({}, path) try { - await SimulationRenderer.setupFor(renderer)?.renderOnce() - renderer.destroy() - expect(await SimulationRenderer.finish(renderer)).toBe(path) + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const renderer = yield* SimulationRenderer.create({}, path) + yield* Effect.promise(() => SimulationRenderer.setupFor(renderer)?.renderOnce() ?? Promise.resolve()) + renderer.destroy() + expect(yield* SimulationRenderer.finish(renderer)).toBe(path) - const events = (await Bun.file(path).text()) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as Event) - expect(events.some((event) => event.type === "output")).toBe(true) + const events = (yield* Effect.promise(() => Bun.file(path).text())) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Event) + expect(events.some((event) => event.type === "output")).toBe(true) + }), + ), + ) } finally { - if (!renderer.isDestroyed) renderer.destroy() - await SimulationRenderer.finish(renderer) await rm(directory, { recursive: true, force: true }) } }) @@ -61,16 +66,20 @@ test("captures native renderer output and finishes on destroy", async () => { test("matches live screen text while recording", async () => { const directory = await mkdtemp(join(tmpdir(), "simulation-recording-matches-")) const path = join(directory, "timeline.jsonl") - const renderer = await SimulationRenderer.create({}, path) try { - renderer.root.add(new TextRenderable(renderer, { content: "recorded screen text" })) - await SimulationRenderer.setupFor(renderer)?.renderOnce() + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const renderer = yield* SimulationRenderer.create({}, path) + renderer.root.add(new TextRenderable(renderer, { content: "recorded screen text" })) + yield* Effect.promise(() => SimulationRenderer.setupFor(renderer)?.renderOnce() ?? Promise.resolve()) - expect(matches(createHarness(renderer), "recorded screen text")).toBe(true) + expect(matches(createHarness(renderer), "recorded screen text")).toBe(true) + }), + ), + ) } finally { - renderer.destroy() - await SimulationRenderer.finish(renderer) await rm(directory, { recursive: true, force: true }) } }) diff --git a/packages/simulation/test/simulated-provider.test.ts b/packages/simulation/test/simulated-provider.test.ts new file mode 100644 index 0000000000..23da2d9d0d --- /dev/null +++ b/packages/simulation/test/simulated-provider.test.ts @@ -0,0 +1,230 @@ +import { expect, test } from "bun:test" +import { Deferred, Effect, Fiber, Queue, Stream } from "effect" +import type { Scope } from "effect/Scope" +import { SimulatedProvider } from "../src/backend/simulated-provider" +import { availableEndpoint, connect } from "./fixture/websocket" + +test("streams a Drive-controlled provider response and removes the finished invocation", async () => { + await runProvider((provider, socket, messages) => + Effect.gen(function* () { + socket.send("{") + expect(yield* Queue.take(messages)).toMatchObject({ id: null, error: { code: -32000 } }) + yield* attach(socket, messages) + + const response = yield* provider.stream(request).pipe(Stream.runCollect, Effect.forkScoped) + + const opened = yield* takeInvocation(messages) + expect(opened).toMatchObject({ + method: "llm.request", + params: { + url: "https://api.openai.com/v1/chat/completions", + body: { model: "gpt-5" }, + }, + }) + const params = requireRecord(opened.params) + if (typeof params.id !== "string") throw new Error("llm.request did not contain an invocation id") + expect(response.pollUnsafe()).toBeUndefined() + + socket.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "llm.chunk", + params: { id: params.id, items: [{ type: "textDelta", text: "Hello from Drive" }] }, + }), + ) + expect(yield* Queue.take(messages)).toMatchObject({ id: 2, result: { ok: true } }) + + socket.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 3, + method: "llm.finish", + params: { id: params.id, reason: "stop" }, + }), + ) + expect(yield* Queue.take(messages)).toMatchObject({ id: 3, result: { ok: true } }) + + expect(Array.from(yield* Fiber.join(response))).toEqual([ + { type: "textDelta", text: "Hello from Drive" }, + { type: "finish", reason: "stop" }, + ]) + + socket.send(JSON.stringify({ jsonrpc: "2.0", id: 4, method: "llm.pending" })) + expect(yield* Queue.take(messages)).toMatchObject({ id: 4, result: { invocations: [] } }) + }), + ) +}) + +test("replays an invocation to a controller that attaches after it opens", async () => { + await runProvider((provider, socket, messages) => + Effect.gen(function* () { + const response = yield* provider.stream(request).pipe(Stream.runCollect, Effect.forkScoped) + + socket.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "llm.attach" })) + const received = [requireRecord(yield* Queue.take(messages)), requireRecord(yield* Queue.take(messages))] + expect(received).toContainEqual(expect.objectContaining({ id: 1, result: { attached: true } })) + const opened = received.find((message) => message.method === "llm.request") + if (!opened) throw new Error("The pending invocation was not replayed") + const params = requireRecord(opened.params) + if (typeof params.id !== "string") throw new Error("llm.request did not contain an invocation id") + + socket.send( + JSON.stringify({ jsonrpc: "2.0", id: 2, method: "llm.finish", params: { id: params.id, reason: "stop" } }), + ) + expect(yield* Queue.take(messages)).toMatchObject({ id: 2, result: { ok: true } }) + expect(Array.from(yield* Fiber.join(response))).toEqual([{ type: "finish", reason: "stop" }]) + }), + ) +}) + +test("replaces the previous attached controller", async () => { + const endpoint = availableEndpoint() + await Effect.runPromise( + Effect.gen(function* () { + const provider = yield* SimulatedProvider.Service + const first = yield* connect(endpoint) + const second = yield* connect(endpoint) + const firstMessages = yield* messagesFrom(first) + const secondMessages = yield* messagesFrom(second) + + yield* attach(first, firstMessages) + yield* attach(second, secondMessages) + const response = yield* provider.stream(request).pipe(Stream.runCollect, Effect.forkScoped) + const opened = yield* takeInvocation(secondMessages) + expect(yield* Queue.size(firstMessages)).toBe(0) + const params = requireRecord(opened.params) + if (typeof params.id !== "string") throw new Error("llm.request did not contain an invocation id") + + second.send( + JSON.stringify({ jsonrpc: "2.0", id: 2, method: "llm.finish", params: { id: params.id, reason: "stop" } }), + ) + expect(yield* Queue.take(secondMessages)).toMatchObject({ id: 2, result: { ok: true } }) + expect(Array.from(yield* Fiber.join(response))).toEqual([{ type: "finish", reason: "stop" }]) + }).pipe(Effect.provide(SimulatedProvider.layerDrive({ endpoint })), Effect.scoped), + ) +}) + +test("removes an invocation when its response stream is interrupted", async () => { + await runProvider((provider, socket, messages) => + Effect.gen(function* () { + yield* attach(socket, messages) + const response = yield* provider.stream(request).pipe(Stream.runDrain, Effect.forkScoped) + yield* takeInvocation(messages) + + yield* Fiber.interrupt(response) + + socket.send(JSON.stringify({ jsonrpc: "2.0", id: 2, method: "llm.pending" })) + expect(yield* Queue.take(messages)).toMatchObject({ id: 2, result: { invocations: [] } }) + }), + ) +}) + +test("releases a backpressured response when its consumer is interrupted", async () => { + await runProvider((provider, socket, messages) => + Effect.gen(function* () { + yield* attach(socket, messages) + const started = yield* Deferred.make() + const response = yield* provider.stream(request).pipe( + Stream.runForEach(() => Deferred.succeed(started, void 0).pipe(Effect.andThen(Effect.never))), + Effect.forkScoped, + ) + const opened = yield* takeInvocation(messages) + const params = requireRecord(opened.params) + if (typeof params.id !== "string") throw new Error("llm.request did not contain an invocation id") + + socket.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "llm.chunk", + params: { + id: params.id, + items: Array.from({ length: 300 }, (_, index) => ({ type: "textDelta", text: String(index) })), + }, + }), + ) + const result = yield* Queue.take(messages).pipe(Effect.forkScoped) + yield* Deferred.await(started) + expect(result.pollUnsafe()).toBeUndefined() + + yield* Fiber.interrupt(response) + expect(yield* Fiber.join(result)).toMatchObject({ id: 2 }) + }), + ) +}) + +test("fails the provider stream when Drive disconnects the invocation", async () => { + await runProvider((provider, socket, messages) => + Effect.gen(function* () { + yield* attach(socket, messages) + const response = yield* provider.stream(request).pipe(Stream.runCollect, Effect.flip, Effect.forkScoped) + const opened = yield* takeInvocation(messages) + const params = requireRecord(opened.params) + if (typeof params.id !== "string") throw new Error("llm.request did not contain an invocation id") + + socket.send(JSON.stringify({ jsonrpc: "2.0", id: 2, method: "llm.disconnect", params: { id: params.id } })) + expect(yield* Queue.take(messages)).toMatchObject({ id: 2, result: { ok: true } }) + expect(yield* Fiber.join(response)).toBeInstanceOf(SimulatedProvider.ProviderDisconnectedError) + }), + ) +}) + +const request: SimulatedProvider.ProviderRequest = { + url: "https://api.openai.com/v1/chat/completions", + body: { model: "gpt-5", messages: [{ role: "user", content: "Hello" }] }, +} + +function runProvider( + body: ( + provider: SimulatedProvider.Interface, + socket: WebSocket, + messages: Queue.Queue, + ) => Effect.Effect, +) { + const endpoint = availableEndpoint() + return Effect.runPromise( + Effect.gen(function* () { + const provider = yield* SimulatedProvider.Service + const socket = yield* connect(endpoint) + const messages = yield* messagesFrom(socket) + yield* body(provider, socket, messages) + }).pipe(Effect.provide(SimulatedProvider.layerDrive({ endpoint })), Effect.scoped), + ) +} + +function messagesFrom(socket: WebSocket) { + return Effect.gen(function* () { + const messages = yield* Queue.unbounded() + socket.addEventListener("message", (event) => { + Queue.offerUnsafe(messages, JSON.parse(String(event.data))) + }) + return messages + }) +} + +function attach(socket: WebSocket, messages: Queue.Queue) { + return Effect.gen(function* () { + socket.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "llm.attach" })) + expect(yield* Queue.take(messages)).toMatchObject({ id: 1, result: { attached: true } }) + }) +} + +function takeInvocation(messages: Queue.Queue) { + return Queue.take(messages).pipe( + Effect.map((message) => { + const opened = requireRecord(message) + if (opened.method !== "llm.request") throw new Error("Expected an llm.request notification") + return opened + }), + ) +} + +function requireRecord(value: unknown): Record { + if (!isRecord(value)) throw new Error("Expected an object") + return value +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} diff --git a/packages/tui/package.json b/packages/tui/package.json index 162b3ddb94..e631731ee6 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -20,7 +20,6 @@ "./context/epilogue": "./src/context/epilogue.tsx", "./context/exit": "./src/context/exit.tsx", "./context/log": "./src/context/log.tsx", - "./context/project": "./src/context/project.tsx", "./context/runtime": "./src/context/runtime.tsx", "./context/client": "./src/context/client.tsx", "./context/theme": "./src/context/theme.tsx", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 9672bba540..b8c1f32063 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1,7 +1,7 @@ import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" import { registerOpencodeSpinner } from "./component/register-spinner" import { Deferred, Effect } from "effect" -import { Service } from "@opencode-ai/client/effect" +import { Service, type Endpoint } from "@opencode-ai/client/effect/service" import { OpenCode } from "@opencode-ai/client" import { Global } from "@opencode-ai/core/global" import { Flag } from "@opencode-ai/core/flag/flag" @@ -42,14 +42,13 @@ import { DialogProvider, useDialog } from "./ui/dialog" import { DialogIntegration } from "./component/dialog-integration" import { ErrorComponent } from "./component/error-component" import { PluginRouteMissing } from "./component/plugin-route-missing" -import { ProjectProvider, useProject } from "./context/project" import { EditorContextProvider } from "./context/editor" import { useEvent } from "./context/event" import { ClientProvider, useClient } from "./context/client" import { StartupLoading } from "./component/startup-loading" import { Reconnecting } from "./component/reconnecting" import { DataProvider, useData } from "./context/data" -import { LocationProvider } from "./context/location" +import { LocationProvider, useLocation } from "./context/location" import { LocalProvider, useLocal } from "./context/local" import { PermissionProvider } from "./context/permission" import { DialogModel } from "./component/dialog-model" @@ -69,7 +68,7 @@ import { Session } from "./routes/session" import { PromptHistoryProvider } from "./component/prompt/history" import { FrecencyProvider } from "./component/prompt/frecency" import { PromptStashProvider } from "./component/prompt/stash" -import { ToastProvider, useToast } from "./ui/toast" +import { Toast, ToastProvider, useToast } from "./ui/toast" import { isDefaultTitle } from "./util/session" import * as Model from "./util/model" import { ArgsProvider, useArgs, type Args } from "./context/args" @@ -119,6 +118,7 @@ const appBindingCommands = [ "provider.connect", "opencode.status", "server.pair", + "service.restart", "opencode.debug", "theme.switch", "theme.switch_mode", @@ -138,9 +138,11 @@ const appBindingCommands = [ export type TuiInput = { server: { - endpoint: Service.Endpoint - reconnect?: (attempt: number) => Promise - reload?: () => Promise + endpoint: Endpoint + service?: { + reconnect: (signal: AbortSignal) => Promise + restart: () => Promise + } } args: Args config: Config.Interface @@ -184,55 +186,52 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))), ) const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined - const reconnectEndpoint = input.server.reconnect - const reconnect = reconnectEndpoint - ? async (attempt: number) => { - const endpoint = await reconnectEndpoint(attempt) - const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) } - return { - api: OpenCode.make(next), - } + const managed = input.server.service + const service = managed + ? { + reconnect: async (signal: AbortSignal) => { + const endpoint = await managed.reconnect(signal) + const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) } + return { api: OpenCode.make(next) } + }, + restart: managed.restart, } : undefined const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } const result = yield* Effect.scoped( Effect.gen(function* () { - const renderer = yield* Effect.acquireRelease( - Effect.tryPromise({ - try: async () => { - const options = { - externalOutputMode: "passthrough", - targetFps: 60, - gatherStats: false, - exitOnCtrlC: false, - useKittyKeyboard: {}, - autoFocus: false, - openConsoleOnError: false, - useMouse: !Flag.OPENCODE_DISABLE_MOUSE && config.mouse, - consoleOptions: { - keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], - }, - } satisfies CliRendererConfig - - if (handoff) { - handoff.renderer.useMouse = options.useMouse - return handoff.renderer - } - - if (process.env.OPENCODE_DRIVE) { - const { Drive } = await import("@opencode-ai/simulation/frontend") - return Drive.create(options) - } - - return createCliRenderer(options) - }, - catch: (error) => (error instanceof Error ? error : new Error(String(error))), - }), - (renderer) => - Effect.sync(() => { - destroyRenderer(renderer) + const options = { + externalOutputMode: "passthrough", + targetFps: 60, + gatherStats: false, + exitOnCtrlC: false, + useKittyKeyboard: {}, + autoFocus: false, + openConsoleOnError: false, + useMouse: !Flag.OPENCODE_DISABLE_MOUSE && config.mouse, + consoleOptions: { + keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], + }, + } satisfies CliRendererConfig + const renderer = yield* Effect.gen(function* () { + if (handoff) { + handoff.renderer.useMouse = options.useMouse + return yield* Effect.acquireRelease(Effect.succeed(handoff.renderer), (renderer) => + Effect.sync(() => destroyRenderer(renderer)), + ) + } + if (process.env.OPENCODE_DRIVE) { + const { Drive } = yield* Effect.promise(() => import("@opencode-ai/simulation/frontend")) + return yield* Drive.create(options) + } + return yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => createCliRenderer(options), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), }), - ) + (renderer) => Effect.sync(() => destroyRenderer(renderer)), + ) + }) win32DisableProcessedInput() const finalizers = new Set<() => Promise>() yield* Effect.addFinalizer(() => @@ -329,10 +328,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { } > - + - - + + @@ -341,20 +340,18 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - - - - - + + + @@ -363,8 +360,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - - + + @@ -417,7 +414,7 @@ function App(props: { pair?: DialogPairCredentials }) { const themeState = useTheme() const { theme, mode, setMode, locked, lock, unlock } = themeState const data = useData() - const project = useProject() + const location = useLocation() const exit = useExit() const promptRef = usePromptRef() const pluginRuntime = usePluginRuntime() @@ -764,19 +761,21 @@ function App(props: { pair?: DialogPairCredentials }) { }, category: "System", }, - ...(client.reload + ...(client.restart ? [ { - name: "server.reload", - title: "Reload server", - slash: { name: "reload" }, + name: "service.restart", + title: "Restart service", + slash: { name: "restart" }, run: async () => { + const restart = client.restart + if (!restart) return dialog.clear() - toast.show({ variant: "info", message: "Reloading server...", duration: 30000 }) - // reload resolves once the replacement service is healthy; the + toast.show({ variant: "info", message: "Restarting service...", duration: 30000 }) + // restart resolves once the replacement service is healthy; the // event stream reattaches through the reconnect loop. - await client.reload!() - .then(() => toast.show({ variant: "success", message: "Server reloaded" })) + await restart() + .then(() => toast.show({ variant: "success", message: "Service restarted" })) .catch(toast.error) }, category: "System", @@ -992,12 +991,12 @@ function App(props: { pair?: DialogPairCredentials }) { })) event.on("tui.command.execute", (evt, { workspace }) => { - if (workspace !== project.workspace.current()) return + if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return keymap.dispatchCommand(evt.data.command) }) event.on("tui.toast.show", (evt, { workspace }) => { - if (workspace !== project.workspace.current()) return + if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return toast.show({ title: evt.data.title, message: evt.data.message, @@ -1006,14 +1005,8 @@ function App(props: { pair?: DialogPairCredentials }) { }) }) - event.on("plugin.updated", (_evt, { directory, workspace }) => { - if (directory !== project.instance.directory()) return - if (workspace !== project.workspace.current()) return - toast.show({ variant: "success", message: "Plugins reloaded" }) - }) - event.on("tui.session.select", (evt, { workspace }) => { - if (workspace !== project.workspace.current()) return + if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return route.navigate({ type: "session", sessionID: evt.data.sessionID, @@ -1031,7 +1024,7 @@ function App(props: { pair?: DialogPairCredentials }) { }) event.on("session.error", (evt, { workspace }) => { - if (workspace !== project.workspace.current()) return + if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return const error = evt.data.error if (error && typeof error === "object" && error.name === "MessageAbortedError") return const message = errorMessage(error) @@ -1121,8 +1114,9 @@ function App(props: { pair?: DialogPairCredentials }) { - + + ) } diff --git a/packages/tui/src/component/command-palette.tsx b/packages/tui/src/component/command-palette.tsx index ff7b863678..e7c55b5a48 100644 --- a/packages/tui/src/component/command-palette.tsx +++ b/packages/tui/src/component/command-palette.tsx @@ -1,63 +1,35 @@ import { createMemo } from "solid-js" import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select" import { type DialogContext } from "../ui/dialog" -import { - COMMAND_PALETTE_COMMAND, - formatKeyBindings, - type OpenTuiKeymap, - useKeymapSelector, - useOpencodeKeymap, -} from "../keymap" -import { useConfig } from "../config" +import { COMMAND_PALETTE_COMMAND } from "../keymap" +import { Keymap, type KeymapCommand } from "../context/keymap" -type PaletteCommandEntry = ReturnType[number] - -function isVisiblePaletteCommand(command: PaletteCommandEntry["command"]) { - return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND -} - -function isSuggestedPaletteCommand(entry: PaletteCommandEntry) { - const suggested = entry.command.suggested +function isSuggestedPaletteCommand(command: KeymapCommand) { + const suggested = command.suggested if (typeof suggested === "boolean") return suggested if (typeof suggested === "function") return suggested() === true return false } export function CommandPaletteDialog() { - const config = useConfig().data - const keymap = useOpencodeKeymap() - const entries = useKeymapSelector((keymap: OpenTuiKeymap) => { - const query = { - namespace: "palette", - } - const reachable = keymap.getCommandEntries({ - ...query, - visibility: "reachable", - filter: isVisiblePaletteCommand, - }) - const registeredBindings = keymap.getCommandBindings({ - visibility: "registered", - commands: reachable.map((entry) => entry.command.name), - }) - - return reachable.map((entry) => ({ - ...entry, - bindings: registeredBindings.get(entry.command.name) ?? entry.bindings, - })) - }) + const commands = Keymap.useCommands() + const shortcuts = Keymap.useShortcuts() const options = createMemo(() => - entries().map((entry) => ({ - title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name, - description: typeof entry.command.desc === "string" ? entry.command.desc : undefined, - category: typeof entry.command.category === "string" ? entry.command.category : undefined, - footer: formatKeyBindings(entry.bindings, config), - value: entry.command.name, - suggested: isSuggestedPaletteCommand(entry), - onSelect: (dialog: DialogContext) => { - dialog.clear() - keymap.dispatchCommand(entry.command.name) - }, - })), + commands().flatMap((command) => { + if (!command.id || !command.palette || command.id === COMMAND_PALETTE_COMMAND) return [] + return { + title: command.title ?? command.id, + description: command.description, + category: command.group, + footer: shortcuts.all(command.id), + value: command.id, + suggested: isSuggestedPaletteCommand(command), + onSelect: (dialog: DialogContext) => { + dialog.clear() + command.run() + }, + } + }), ) let ref: DialogSelectRef diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index 64df044591..c63e6e5d87 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -39,14 +39,6 @@ const settings: Setting[] = [ values: [false, true], labels: ["off", "on"], }, - { - title: "Tips", - category: "Appearance", - path: ["hints", "tips"], - default: true, - values: [false, true], - labels: ["off", "on"], - }, { title: "Onboarding", category: "Appearance", diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 58b18e16f4..218e2cd220 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -467,11 +467,10 @@ async function connected( toast: ReturnType, onConnected?: OnIntegrationConnected, ) { - await Promise.all([ - data.location.integration.refresh(), - data.location.model.refresh(), - data.location.provider.refresh(), - ]) + data.location.integration.invalidate() + data.location.model.invalidate() + data.location.provider.invalidate() + await Promise.all([data.location.integration.sync(), data.location.model.sync(), data.location.provider.sync()]) toast.show({ variant: "success", message: `Connected ${integration.name}` }) if (onConnected) { onConnected(providerID(data, integration.id)) @@ -498,11 +497,10 @@ async function disconnected( dialog: ReturnType, toast: ReturnType, ) { - await Promise.all([ - data.location.integration.refresh(), - data.location.model.refresh(), - data.location.provider.refresh(), - ]) + data.location.integration.invalidate() + data.location.model.invalidate() + data.location.provider.invalidate() + await Promise.all([data.location.integration.sync(), data.location.model.sync(), data.location.provider.sync()]) toast.show({ variant: "success", message: `Disconnected ${name}` }) dialog.clear() } diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index 6ff94d690c..9f6c165e17 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -14,7 +14,6 @@ import { Locale } from "../util/locale" import { errorMessage } from "../util/error" import { isRecord } from "../util/record" import { useToast } from "../ui/toast" -import { useProject } from "../context/project" import { Spinner } from "./spinner" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" import type { ProjectDirectoriesOutput } from "@opencode-ai/client" @@ -41,11 +40,11 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const dimensions = useTerminalDimensions() const { theme } = useTheme() const sessionData = useData() - const projectContext = useProject() const route = useRoute() const toast = useToast() const paths = useTuiPaths() const shortcuts = Keymap.useShortcuts() + const location = createMemo(() => sessionData.location.info()) const [working, setWorking] = createSignal(Boolean(props.initialRemoving)) const [toDelete, setToDelete] = createSignal() const [removing, setRemoving] = createSignal(props.initialRemoving) @@ -63,15 +62,15 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { // swallow it and let the directory list render without a current marker. // Once the current project is known, a mismatch is a guaranteed miss. const [loadedProject] = createResource( - () => (projectContext.project() === undefined ? props.projectID : undefined), + () => (location()?.project.id === props.projectID ? undefined : props.projectID), (projectID) => client.api.project - .current({ location: { directory: projectContext.instance.directory() || paths.cwd } }) + .current({ location: { directory: location()?.directory || paths.cwd } }) .then((project) => (project.id === projectID ? project.directory : undefined)) .catch(() => undefined), ) const currentCheckout = createMemo(() => { - if (projectContext.project() === props.projectID) return projectContext.instance.path().worktree + if (location()?.project.id === props.projectID) return location()?.project.directory return loadedProject() }) @@ -79,14 +78,14 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { () => (props.initialRemoving ? undefined : props.projectID), async (projectID, info): Promise | undefined> => { try { - const location = { directory: projectContext.instance.directory() || paths.cwd } + const requestLocation = { directory: location()?.directory || paths.cwd } await client.api.projectCopy.refresh({ projectID, - location, + location: requestLocation, }) const directories = await client.api.project.directories({ projectID, - location, + location: requestLocation, }) setLoadError(undefined) return directories @@ -204,7 +203,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { async function removedCurrent(current: boolean) { if (!current) return false - const fallback = projectContext.data.project.mainDir + const fallback = directoryData()?.findLast((item) => item.strategy === undefined)?.directory if (fallback) setReplacementCurrent(fallback) if (route.data.type === "session") { route.navigate({ type: "home" }) @@ -236,7 +235,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const error = await client.api.projectCopy .remove({ projectID: props.projectID, - location: { directory: projectContext.instance.directory() || paths.cwd }, + location: { directory: location()?.directory || paths.cwd }, directory: selected.directory, force: false, }) @@ -263,7 +262,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const forcedError = await client.api.projectCopy .remove({ projectID: props.projectID, - location: { directory: projectContext.instance.directory() || paths.cwd }, + location: { directory: location()?.directory || paths.cwd }, directory: selected.directory, force: true, }) diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index c7eb69ce37..04783790e4 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -7,7 +7,6 @@ import { useRoute } from "../context/route" import { useData } from "../context/data" import { Keymap } from "../context/keymap" import { Locale } from "../util/locale" -import { useProject } from "../context/project" import { useTheme } from "../context/theme" import { useClient } from "../context/client" import { useLocal } from "../context/local" @@ -21,7 +20,6 @@ export function DialogSessionList() { const dialog = useDialog() const route = useRoute() const data = useData() - const project = useProject() const { theme } = useTheme() const client = useClient() const local = useLocal() @@ -33,15 +31,16 @@ export function DialogSessionList() { const [searchResults] = createResource(search, async (query) => { if (!query) return - const location = data.location.default() try { + if (!data.location.info()) await data.location.sync() + const current = data.location.info() + if (!current) throw new Error("Location unavailable") const response = await client.api.session.list({ + project: current.project.id, search: query, limit: 50, order: "desc", parentID: null, - directory: location.directory, - workspace: location.workspaceID, }) return { query, sessions: response.data, error: undefined } } catch (error) { @@ -101,7 +100,8 @@ export function DialogSessionList() { const option = (session: SessionInfo, category: string) => { const directory = session.location.directory - const footer = directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : "" + const footer = + directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : "" const slot = slotByID.get(session.id) const deleting = toDelete() === session.id return { diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index 3826a5a3c5..7387012dee 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -25,7 +25,7 @@ export function DialogSkill(props: DialogSkillProps) { .then(async () => { const current = data.location.skill.list(props.location) if (current) return current - await data.location.skill.refresh(props.location) + await data.location.skill.sync(props.location) return data.location.skill.list(props.location) ?? [] }) // Catch so the rejected resource never reaches the memo below: reading diff --git a/packages/tui/src/component/dialog-tag.tsx b/packages/tui/src/component/dialog-tag.tsx index 69d0c51de1..4c1fceb75b 100644 --- a/packages/tui/src/component/dialog-tag.tsx +++ b/packages/tui/src/component/dialog-tag.tsx @@ -1,14 +1,14 @@ import { createMemo, createResource } from "solid-js" import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" -import { useProject } from "../context/project" import { useClient } from "../context/client" +import { useData } from "../context/data" import { createStore } from "solid-js/store" export function DialogTag(props: { onSelect?: (value: string) => void }) { const client = useClient() const dialog = useDialog() - const project = useProject() + const data = useData() const [store] = createStore({ filter: "", @@ -22,7 +22,10 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) { query: store.filter, type: "file", limit: 5, - location: { workspace: project.workspace.current() }, + location: { + directory: data.location.default().directory, + workspace: data.location.default().workspaceID, + }, }) .catch(() => undefined) return result?.data.map((item) => item.path) ?? [] diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 5b14926896..545f8099d6 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -6,7 +6,6 @@ import { firstBy } from "remeda" import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { useEditorContext } from "../../context/editor" -import { useProject } from "../../context/project" import { useClient } from "../../context/client" import { useData } from "../../context/data" import { getScrollAcceleration } from "../../util/scroll" @@ -19,7 +18,7 @@ import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "../../util/locale" import type { PromptInfo, PromptPartRef } from "../../prompt/history" import { useFrecency } from "../../prompt/frecency" -import { useBindings, useCommandSlashes } from "../../keymap" +import { useBindings } from "../../keymap" import { Keymap } from "../../context/keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" import type { FileSystemEntry } from "@opencode-ai/client" @@ -87,9 +86,8 @@ export function Autocomplete(props: { const editor = useEditorContext() const client = useClient() const data = useData() - const project = useProject() - const slashes = useCommandSlashes() const keymap = Keymap.use() + const keymapCommands = Keymap.useCommands() const { theme } = useTheme() const dimensions = useTerminalDimensions() const frecency = useFrecency() @@ -284,7 +282,7 @@ export function Autocomplete(props: { }) function normalizeMentionPath(filePath: string) { - const baseDir = location()?.directory || project.instance.directory() || paths.cwd + const baseDir = location.current?.directory || data.location.info()?.directory || paths.cwd const absolute = path.resolve(filePath) const relative = path.relative(baseDir, absolute) @@ -310,7 +308,7 @@ export function Autocomplete(props: { } const [files] = createResource( - () => ({ query: search(), location: location(), visible: store.visible }), + () => ({ query: search(), location: location.current, visible: store.visible }), async (input) => { if (!input.visible || input.visible === "/") return { options: [], failed: false } if (referenceMatch()) return { options: [], failed: false } @@ -322,7 +320,7 @@ export function Autocomplete(props: { limit: 20, location: { directory: input.location?.directory, - workspace: input.location?.workspaceID ?? project.workspace.current(), + workspace: input.location?.workspaceID ?? data.location.default().workspaceID, }, }) .then( @@ -365,7 +363,7 @@ export function Autocomplete(props: { const options: AutocompleteOption[] = [] const width = props.anchor().width - 4 - for (const res of data.location.mcp.resource.list(location()) ?? []) { + for (const res of data.location.mcp.resource.list(location.current) ?? []) { options.push({ display: Locale.truncateMiddle(res.name, width), // Match the name only; matching the URI caused unrelated fuzzy hits. @@ -429,38 +427,43 @@ export function Autocomplete(props: { ), ) + function insertSlash(name: string) { + const newText = `/${name} ` + const cursor = props.input().logicalCursor + props.input().deleteRange(0, 0, cursor.row, cursor.col) + props.input().insertText(newText) + props.input().cursorOffset = Bun.stringWidth(newText) + } + const commands = createMemo((): AutocompleteOption[] => { - const results: AutocompleteOption[] = [...slashes()] + const results: AutocompleteOption[] = keymapCommands().flatMap((command) => { + const slash = command.slash + if (!slash) return [] + return { + display: `/${slash.name}`, + description: command.description ?? command.title, + aliases: slash.aliases?.map((alias) => `/${alias}`), + onSelect: slash.arguments ? () => insertSlash(slash.name) : command.run, + } + }) const commandNames = new Set() - for (const serverCommand of data.location.command.list(location()) ?? []) { + for (const serverCommand of data.location.command.list(location.current) ?? []) { commandNames.add(serverCommand.name) results.push({ display: "/" + serverCommand.name, description: serverCommand.description, - onSelect: () => { - const newText = "/" + serverCommand.name + " " - const cursor = props.input().logicalCursor - props.input().deleteRange(0, 0, cursor.row, cursor.col) - props.input().insertText(newText) - props.input().cursorOffset = Bun.stringWidth(newText) - }, + onSelect: () => insertSlash(serverCommand.name), }) } for (const skill of data.location.skill - .list(location()) + .list(location.current) ?.filter((skill) => skill.slash === true && !commandNames.has(skill.id)) ?? []) { results.push({ display: "/" + skill.id, description: skill.description, - onSelect: () => { - const newText = "/" + skill.id + " " - const cursor = props.input().logicalCursor - props.input().deleteRange(0, 0, cursor.row, cursor.col) - props.input().insertText(newText) - props.input().cursorOffset = Bun.stringWidth(newText) - }, + onSelect: () => insertSlash(skill.id), }) } diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 7d8dec1379..fb0a6e2fca 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -23,7 +23,6 @@ import { useClipboard } from "../../context/clipboard" import { Spinner } from "../spinner" import { useClient } from "../../context/client" import { useRoute } from "../../context/route" -import { useProject } from "../../context/project" import { useEvent } from "../../context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor" import { normalizePromptContent, openEditor } from "../../editor" @@ -53,7 +52,9 @@ import { usePromptMove } from "./move" import { readLocalAttachment } from "./local-attachment" import { useData } from "../../context/data" import { useLocation } from "../../context/location" +import { Keymap, type KeymapCommand } from "../../context/keymap" import { contextUsage } from "../../util/session" +import { abbreviateHome } from "../../runtime" registerOpencodeSpinner() @@ -137,6 +138,18 @@ function formatEditorContext(selection: EditorSelection) { let stashed: { prompt: PromptInfo; cursor: number } | undefined +function argumentSlash(input: string, commands: readonly KeymapCommand[]) { + if (!input.startsWith("/")) return + const separator = input.search(/\s/) + const name = input.slice(1, separator === -1 ? undefined : separator) + const command = commands.find( + (command) => + command.slash?.arguments && (command.slash.name === name || command.slash.aliases?.includes(name) === true), + ) + if (!command) return + return { command, input: separator === -1 ? "" : input.slice(separator + 1) } +} + export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable @@ -151,8 +164,8 @@ export function Prompt(props: PromptProps) { const client = useClient() const editor = useEditorContext() const route = useRoute() - const project = useProject() const data = useData() + const keymapCommands = Keymap.useCommands() const currentLocation = useLocation() const config = useConfig().data const dialog = useDialog() @@ -165,7 +178,8 @@ export function Prompt(props: PromptProps) { .filter((id) => id !== props.sessionID && data.session.status(id) === "running").length }) const runningShells = createMemo( - () => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length, + () => + data.shell.list(currentLocation.current).filter((shell) => shell.metadata.sessionID === props.sessionID).length, ) const history = usePromptHistory() const stash = usePromptStash() @@ -214,9 +228,34 @@ export function Prompt(props: PromptProps) { const editorContextLabelState = createMemo(() => editor.labelState()) const [auto, setAuto] = createSignal() const move = usePromptMove({ - projectID: () => (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? project.project(), + projectID: () => + (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? data.location.info()?.project.id, sessionID: () => props.sessionID, }) + Keymap.createLayer(() => ({ + mode: "global", + enabled: props.sessionID !== undefined, + commands: [ + { + id: "session.cd", + title: "Change working directory", + slash: { name: "cd", arguments: true }, + run: async (input) => { + const sessionID = props.sessionID + if (!sessionID) return + if (!input?.trim()) { + toast.show({ message: "Directory is required", variant: "error" }) + return + } + await client.api.session + .move({ sessionID, directory: input }) + .catch((error) => + toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" }), + ) + }, + }, + ], + })) const [cursorVersion, setCursorVersion] = createSignal(0) const currentProviderLabel = createMemo(() => local.model.parsed().provider) const connected = useConnected() @@ -244,7 +283,7 @@ export function Prompt(props: PromptProps) { const event = useEvent() event.on("tui.prompt.append", (evt, { workspace }) => { - if (workspace !== project.workspace.current()) return + if (workspace !== (currentLocation.current?.workspaceID ?? data.location.default().workspaceID)) return if (!input || input.isDestroyed) return input.insertText(evt.data.text) setTimeout(() => { @@ -465,8 +504,8 @@ export function Prompt(props: PromptProps) { renderer, value, cwd: - (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || - project.instance.directory() || + (data.location.info()?.project.directory === "/" ? undefined : data.location.info()?.project.directory) || + data.location.default().directory || paths.cwd, }) if (!content) return @@ -502,7 +541,7 @@ export function Prompt(props: PromptProps) { run: () => { dialog.replace(() => ( { input.setText(`/${skill} `) setStore("prompt", { @@ -947,6 +986,12 @@ export function Prompt(props: PromptProps) { void exit() return true } + const slash = argumentSlash(store.prompt.text, keymapCommands()) + if (slash) { + clearPrompt() + await slash.command.run(slash.input) + return true + } const agent = local.agent.current() if (!agent) return false const selectedModel = local.model.current() @@ -1016,7 +1061,7 @@ export function Prompt(props: PromptProps) { setStore("mode", "normal") } else if ( inputText.startsWith("/") && - (data.location.command.list(currentLocation()) ?? []).some( + (data.location.command.list(currentLocation.current) ?? []).some( (command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1), ) ) { @@ -1043,7 +1088,7 @@ export function Prompt(props: PromptProps) { }) } else if ( inputText.startsWith("/") && - (data.location.skill.list(currentLocation()) ?? []).some( + (data.location.skill.list(currentLocation.current) ?? []).some( (skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1), ) ) { @@ -1055,7 +1100,7 @@ export function Prompt(props: PromptProps) { } else { move.startSubmit() if (!session) { - await data.session.refresh(sessionID) + await data.session.sync(sessionID) session = data.session.get(sessionID) } if (session?.agent !== agent.id) { @@ -1293,6 +1338,11 @@ export function Prompt(props: PromptProps) { if (!list().length) return undefined return `Ask anything... "${list()[store.placeholder % list().length]}"` }) + const locationLabel = createMemo(() => { + if (!props.sessionID || status() !== "idle") return + const directory = data.session.get(props.sessionID)?.location.directory + return directory ? abbreviateHome(directory, paths.home) : undefined + }) const spinnerDef = createMemo(() => { const agent = status() === "running" ? local.agent.current() : local.agent.current() @@ -1315,7 +1365,6 @@ export function Prompt(props: PromptProps) { } }) const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3))) - const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48))) return ( <> @@ -1515,7 +1564,18 @@ export function Prompt(props: PromptProps) { (new working copy) - {props.hint ?? } + + } + > + {(location) => ( + + {location()} + + )} + + diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index 9421916374..7b99d80167 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -6,9 +6,6 @@ import { useDialog } from "../../ui/dialog" import { useClient } from "../../context/client" import { useToast } from "../../ui/toast" import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session" -import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes" -import { useHomeSessionDestination } from "../../routes/home/session-destination" -import { useProject } from "../../context/project" import { useData } from "../../context/data" function moveReminderText(directory: string) { @@ -19,13 +16,12 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess const dialog = useDialog() const client = useClient() const toast = useToast() - const homeDestination = useHomeSessionDestination() - const project = useProject() const data = useData() const paths = useTuiPaths() const [creating, setCreating] = createSignal(false) const [creatingDots, setCreatingDots] = createSignal(3) const [progress, setProgress] = createSignal() + const [destination, setDestination] = createSignal() async function create(name: string) { const projectID = await resolveProjectID() @@ -35,7 +31,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess try { const result = await client.api.projectCopy.create({ projectID, - location: { directory: project.instance.directory() || paths.cwd }, + location: { directory: data.location.info()?.directory || paths.cwd }, strategy: "git_worktree", directory: path.join(paths.worktree, projectID.slice(0, 6)), name, @@ -43,13 +39,13 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess const directory = result.directory if (!directory) throw new Error("No project copy directory returned") - // Call a location-based route to make sure it's bootstrapped before moving on. + // Call a location-based route to initialize it before moving on. await client.api.location.get({ location: { directory } }) setProgress("Creating session") return directory } catch (err) { - homeDestination?.clear() + setDestination(undefined) setProgress(undefined) setCreating(false) toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" }) @@ -69,7 +65,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess string | undefined; sess } : { type: "directory", - directory: project.instance.directory(), - subdirectory: project.instance.directory() !== project.instance.path().worktree, + directory: data.location.default().directory, + subdirectory: data.location.default().directory !== data.location.info()?.project.directory, }) } - onCurrentChange={(selection) => homeDestination?.setDestination(selection)} + onCurrentChange={setDestination} onSelect={(selection) => { const sessionID = input.sessionID() if (!sessionID) { - homeDestination?.setDestination(selection) + setDestination(selection) dialog.clear() return } @@ -97,12 +93,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) { - const session = await resolveSession(sessionID) - const status = await client.api.vcs - .status({ location: session?.location.directory ? { directory: session.location.directory } : undefined }) - .catch(() => undefined) - const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no" - if (!choice) return dialog.clear() const directory = selection.type === "new" ? await create(selection.name) : selection.directory if (!directory) { @@ -112,7 +102,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } setProgress("Moving session") try { - await client.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" }) + await client.api.session.move({ sessionID, directory }) await client.api.session .synthetic({ sessionID, text: moveReminderText(directory), resume: false }) .catch(() => undefined) @@ -131,8 +121,10 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess if (projectID) return projectID const sessionID = input.sessionID() if (sessionID) return (await resolveSession(sessionID))?.projectID + const current = data.location.info() + if (current) return current.project.id return client.api.project - .current({ location: { directory: project.instance.directory() || paths.cwd } }) + .current({ location: { directory: data.location.default().directory || paths.cwd } }) .then((project) => project.id) .catch(() => undefined) } @@ -140,15 +132,15 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess async function resolveSession(sessionID: string) { const session = data.session.get(sessionID) if (session) return session - await data.session.refresh(sessionID).catch(() => undefined) + await data.session.sync(sessionID).catch(() => undefined) return data.session.get(sessionID) } - const pending = createMemo(() => Boolean(homeDestination?.destination())) - const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new") + const pending = createMemo(() => Boolean(destination())) + const pendingNew = createMemo(() => destination()?.type === "new") async function getDirectory() { - const value = homeDestination?.destination() + const value = destination() if (!value) return if (value.type === "directory") { return value.directory @@ -161,7 +153,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } function finishSubmit() { - homeDestination?.clear() + setDestination(undefined) setProgress(undefined) setCreating(false) } diff --git a/packages/tui/src/component/reconnecting.tsx b/packages/tui/src/component/reconnecting.tsx index 2ebbda9909..99fb3ecb79 100644 --- a/packages/tui/src/component/reconnecting.tsx +++ b/packages/tui/src/component/reconnecting.tsx @@ -1,8 +1,7 @@ -import { Show } from "solid-js" import { useTheme } from "../context/theme" import { Spinner } from "./spinner" -export function Reconnecting(props: { attempt: number; error?: string }) { +export function Reconnecting() { const theme = useTheme().theme return ( @@ -17,15 +16,8 @@ export function Reconnecting(props: { attempt: number; error?: string }) { alignItems="center" justifyContent="center" > - - Connection lost - Reconnecting to server... - Attempt {props.attempt} - - - {props.error} - - + + Waiting for background service... ) diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 7025effc16..46c649fff1 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -123,7 +123,6 @@ export const Info = Schema.Struct({ ).annotate({ description: "Session transcript presentation settings" }), hints: Schema.optional( Schema.Struct({ - tips: Schema.optional(Schema.Boolean).annotate({ description: "Show usage tips on the home screen" }), onboarding: Schema.optional(Schema.Boolean).annotate({ description: "Show getting-started guidance" }), }), ).annotate({ description: "In-product guidance settings" }), diff --git a/packages/tui/src/config/v1/keybind.ts b/packages/tui/src/config/v1/keybind.ts index 18c68a2ba4..67f64bc68e 100644 --- a/packages/tui/src/config/v1/keybind.ts +++ b/packages/tui/src/config/v1/keybind.ts @@ -215,7 +215,6 @@ export const Definitions = { terminal_suspend: keybind("ctrl+z", "Suspend terminal"), terminal_title_toggle: keybind("none", "Toggle terminal title"), - tips_toggle: keybind("h", "Toggle tips on home screen"), plugin_manager: keybind("none", "Open plugin manager dialog"), plugin_install: keybind("none", "Install plugin"), @@ -389,7 +388,6 @@ export const CommandMap = { history_next: "prompt.history.next", terminal_suspend: "terminal.suspend", terminal_title_toggle: "terminal.title.toggle", - tips_toggle: "tips.toggle", plugin_manager: "plugins.list", plugin_install: "plugins.install", which_key_toggle: "which-key.toggle", diff --git a/packages/tui/src/context/client.tsx b/packages/tui/src/context/client.tsx index 04e347900a..83182f241e 100644 --- a/packages/tui/src/context/client.tsx +++ b/packages/tui/src/context/client.tsx @@ -2,6 +2,7 @@ import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" +import { errorMessage } from "../util/error" import { createSimpleContext } from "./helper" import { useLog } from "./log" @@ -16,18 +17,18 @@ export type ClientConnectionEvent = { } } +type ManagedService = { + reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }> + restart: () => Promise +} + type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract } const connectTimeout = 2_000 const connectionHistoryLimit = 50 export const { use: useClient, provider: ClientProvider } = createSimpleContext({ name: "Client", - init: (props: { - api: OpenCodeClient - reconnect?: (attempt: number) => Promise<{ api: OpenCodeClient }> - // Stops and starts the managed service; present only in service mode. - reload?: () => Promise - }) => { + init: (props: { api: OpenCodeClient; service?: ManagedService }) => { const log = useLog({ component: "client" }) const abort = new AbortController() const history: ClientConnectionEvent[] = [] @@ -51,43 +52,36 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( function start() { stream?.abort() const controller = new AbortController() - let connected!: () => void - const ready = new Promise((resolve) => { - connected = resolve - }) stream = controller void (async () => { let attempt = 0 while (!abort.signal.aborted && !controller.signal.aborted) { - const connection = new AbortController() - const cancel = () => connection.abort(controller.signal.reason) - const timeout = setTimeout( - () => connection.abort(new Error("Timed out connecting to server")), - connectTimeout, - ) + let connectedAt: number | undefined + const request = new AbortController() + const cancel = () => request.abort(controller.signal.reason) + const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout) controller.signal.addEventListener("abort", cancel, { once: true }) const error = await (async () => { record(attempt === 0 ? "connecting" : "reconnecting", attempt) log.info("event stream connecting", { attempt }) - const iterator = api.event.subscribe({ signal: connection.signal })[Symbol.asyncIterator]() + const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]() const first = await iterator.next() - if (abort.signal.aborted || controller.signal.aborted) return + if (abort.signal.aborted || controller.signal.aborted) return undefined if (first.done) - return connection.signal.reason instanceof Error - ? connection.signal.reason + return request.signal.reason instanceof Error + ? request.signal.reason : new Error("Event stream disconnected") if (first.value.type !== "server.connected") return new Error("Event stream did not start with server.connected") clearTimeout(timeout) record("connected", attempt) - attempt = 0 + connectedAt = Date.now() log.info("event stream connected") events.emit(first.value.type, first.value) setConnection({ status: "connected", attempt: 0, error: undefined }) - connected() while (!abort.signal.aborted && !controller.signal.aborted) { const event = await iterator.next() - if (abort.signal.aborted || controller.signal.aborted) return + if (abort.signal.aborted || controller.signal.aborted) return undefined if (event.done) return new Error("Event stream disconnected") if ("durable" in event.value) log.debug("event", { @@ -97,42 +91,47 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( }) events.emit(event.value.type, event.value) } + return undefined })() .catch((error) => error) .finally(() => { + request.abort() clearTimeout(timeout) controller.signal.removeEventListener("abort", cancel) }) if (abort.signal.aborted || controller.signal.aborted) return + if (connectedAt !== undefined && Date.now() - connectedAt >= 1_000) attempt = 0 attempt += 1 - const message = error instanceof Error ? error.message : String(error) + const message = errorMessage(error) record("disconnected", attempt, message) log.info("event stream disconnected", { attempt, error: message, }) + setConnection({ status: "reconnecting", attempt, error: message }) // Re-resolve the transport before retrying: the server may have // moved (service restarted on a new port) or need starting. Static // transports (--server, standalone) resolve to the same address. - if (props.reconnect) { - const next = await props.reconnect(attempt).catch(() => undefined) + if (props.service) { + const next = await props.service.reconnect(controller.signal).catch((error) => { + if (!controller.signal.aborted) + log.info("server resolution failed", { + attempt, + error: errorMessage(error), + }) + }) if (abort.signal.aborted || controller.signal.aborted) return if (next) { api = next.api + if (attempt === 1) continue } } - setConnection({ - status: "reconnecting", - attempt, - error: message, - }) await wait(1_000, controller.signal) } })() - return ready } - onMount(() => void start()) + onMount(start) onCleanup(() => { abort.abort() stream?.abort() @@ -163,7 +162,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( }, }, }, - reload: props.reload, + restart: props.service?.restart, } }, }) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 2506023fbe..14990fb933 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1,7 +1,7 @@ // Client data layer: apply server events and cache API reads into a Solid store. // Prefer straightforward projection. Do not add generation counters, stale-response // merges, live/history overlays, or other race machinery here—last write wins. -// Reconnect may re-bootstrap; that is enough. UI and the server own ordering concerns. +// Reconnect invalidates cached reads; active UI owners decide what to sync again. import type { AgentInfo, @@ -9,6 +9,7 @@ import type { FormInfo, IntegrationInfo, LocationRef, + LocationGetOutput, McpResource, McpServer, ModelInfo, @@ -31,7 +32,7 @@ import type { Plugin } from "@opencode-ai/plugin/v2/tui" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useClient } from "./client" -import { createSignal, onCleanup } from "solid-js" +import { createEffect, createSignal, onCleanup } from "solid-js" export type DataSessionStatus = "idle" | "running" @@ -43,6 +44,7 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") export type FormWithLocation = FormInfo & { readonly location?: LocationRef } type LocationData = { + info?: LocationGetOutput agent?: AgentInfo[] command?: CommandInfo[] integration?: IntegrationInfo[] @@ -66,11 +68,10 @@ type Store = { // true root is not yet loaded). The value is a flat deduplicated list of every // session ID in that family, including the key itself once its info arrives. family: Record - status: Record + active: Record message: Record pending: Record input: Record - compaction: Record permission: Record // Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel. form: Record @@ -89,6 +90,37 @@ function locationQuery(ref?: LocationRef) { return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined } +function createSync() { + const state = new Map>() + return { + run(key: string, load: () => Promise) { + const active = state.get(key) + if (active === true) return Promise.resolve() + if (active) return active + const pending = load() + .then(() => { + if (state.get(key) === pending) state.set(key, true) + }) + .finally(() => { + if (state.get(key) === pending) state.delete(key) + }) + state.set(key, pending) + return pending + }, + complete(key: string) { + if (state.has(key)) return + state.set(key, true) + }, + invalidate(key?: string) { + if (key) { + state.delete(key) + return + } + state.clear() + }, + } +} + export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: () => { @@ -96,11 +128,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ session: { info: {}, family: {}, - status: {}, + active: {}, message: {}, pending: {}, input: {}, - compaction: {}, permission: {}, form: {}, }, @@ -115,16 +146,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: process.cwd(), }) const messageIndex = new Map>() - let bootstrapping: Promise | undefined - let connected = false + const sync = createSync() - function setSessionStatus(sessionID: string, status: DataSessionStatus) { - setStore("session", "status", sessionID, status) - } - - function addCompaction(sessionID: string, inputID: string) { - if (store.session.compaction[sessionID]?.includes(inputID)) return - setStore("session", "compaction", sessionID, [...(store.session.compaction[sessionID] ?? []), inputID]) + function setSessionActive(sessionID: string, status: DataSessionStatus) { + setStore("session", "active", sessionID, status) } function addPending(item: SessionPendingInfo) { @@ -142,16 +167,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) } - function removeCompaction(sessionID: string, inputID?: string) { - if (!inputID || !store.session.compaction[sessionID]?.includes(inputID)) return - setStore( - "session", - "compaction", - sessionID, - store.session.compaction[sessionID].filter((id) => id !== inputID), - ) - } - const message = { update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map) => void) { setStore( @@ -226,7 +241,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return current } - // Register one session into the family index. Idempotent: refreshing an + // Register one session into the family index. Idempotent: syncing an // existing session never duplicates its ID. When a tentative family keyed by // sessionID exists (descendants arrived while sessionID's own info was // absent) but sessionID turns out to have a parent, fold the orphan subtree @@ -254,15 +269,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ function removeSession(sessionID: string) { messageIndex.delete(sessionID) + sync.invalidate(`session:${sessionID}`) + sync.invalidate(`session.pending:${sessionID}`) + sync.invalidate(`session.message:${sessionID}`) + sync.invalidate(`session.permission:${sessionID}`) + sync.invalidate(`session.form:${sessionID}:`) setStore( "session", produce((draft) => { delete draft.info[sessionID] - delete draft.status[sessionID] + delete draft.active[sessionID] delete draft.message[sessionID] delete draft.pending[sessionID] delete draft.input[sessionID] - delete draft.compaction[sessionID] delete draft.permission[sessionID] delete draft.form[sessionID] for (const [rootID, family] of Object.entries(draft.family)) { @@ -277,7 +296,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ function handleEvent(event: OpenCodeEvent) { switch (event.type) { case "session.created": - void result.session.refresh(event.data.sessionID) + result.session.invalidate(event.data.sessionID) + void result.session.sync(event.data.sessionID) break case "session.deleted": removeSession(event.data.sessionID) @@ -290,19 +310,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "catalog.updated": - void Promise.all([ - result.location.model.refresh(event.location), - result.location.provider.refresh(event.location), - ]) + result.location.model.invalidate(event.location) + result.location.provider.invalidate(event.location) + void Promise.all([result.location.model.sync(event.location), result.location.provider.sync(event.location)]) break case "agent.updated": - void result.location.agent.refresh(event.location) + result.location.agent.invalidate(event.location) + void result.location.agent.sync(event.location) break case "command.updated": - void result.location.command.refresh(event.location) + result.location.command.invalidate(event.location) + void result.location.command.sync(event.location) break case "skill.updated": - void result.location.skill.refresh(event.location) + result.location.skill.invalidate(event.location) + void result.location.skill.sync(event.location) break case "session.agent.selected": if (store.session.info[event.data.sessionID]) @@ -346,6 +368,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.moved": if (store.session.info[event.data.sessionID]) { setStore("session", "info", event.data.sessionID, "location", event.data.location) + if (event.data.projectID) + setStore("session", "info", event.data.sessionID, "projectID", event.data.projectID) setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath) } break @@ -666,7 +690,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.execution.started": - setSessionStatus(event.data.sessionID, "running") + setSessionActive(event.data.sessionID, "running") break case "session.compaction.admitted": addPending({ @@ -676,11 +700,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ timeCreated: event.created, type: "compaction", }) - addCompaction(event.data.sessionID, event.data.inputID) break case "session.compaction.started": removePending(event.data.sessionID, event.data.inputID) - removeCompaction(event.data.sessionID, event.data.inputID) message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { id: event.data.inputID ?? messageIDFromEvent(event.id), @@ -696,7 +718,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.execution.succeeded": case "session.execution.failed": case "session.execution.interrupted": - setSessionStatus(event.data.sessionID, "idle") + setSessionActive(event.data.sessionID, "idle") message.update(event.data.sessionID, (draft) => { const currentAssistant = message.activeAssistant(draft) if (currentAssistant) currentAssistant.retry = undefined @@ -758,7 +780,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.compaction.failed": removePending(event.data.sessionID, event.data.inputID) - removeCompaction(event.data.sessionID, event.data.inputID) message.update(event.data.sessionID, (draft, index) => { const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running") const current = draft[position] @@ -837,22 +858,28 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) break case "reference.updated": - void result.location.reference.refresh() + result.location.reference.invalidate() + void result.location.reference.sync() break case "integration.updated": + result.location.integration.invalidate(event.location) + result.location.model.invalidate(event.location) + result.location.provider.invalidate(event.location) void Promise.all([ - result.location.integration.refresh(event.location), - result.location.model.refresh(event.location), - result.location.provider.refresh(event.location), + result.location.integration.sync(event.location), + result.location.model.sync(event.location), + result.location.provider.sync(event.location), ]) break // Authenticating an MCP integration reconnects its server, which emits mcp.status.changed, - // so the mcp list refreshes here rather than off integration.updated. + // so the mcp list syncs here rather than off integration.updated. case "mcp.status.changed": - void result.location.mcp.server.refresh(event.location) + result.location.mcp.server.invalidate(event.location) + void result.location.mcp.server.sync(event.location) break case "mcp.resources.changed": - void result.location.mcp.resource.refresh(event.location) + result.location.mcp.resource.invalidate(event.location) + void result.location.mcp.resource.sync(event.location) break } } @@ -883,7 +910,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) }, status(sessionID: string) { - return store.session.status[sessionID] ?? "idle" + return store.session.active[sessionID] ?? "idle" }, input: { list(sessionID: string) { @@ -893,38 +920,34 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.input[sessionID]?.includes(inputID) ?? false }, }, - compaction: { - list(sessionID: string) { - return store.session.compaction[sessionID] ?? [] - }, - async refresh(sessionID: string) { - await result.session.pending.refresh(sessionID) - }, - }, pending: { list(sessionID: string) { return store.session.pending[sessionID] ?? [] }, - async refresh(sessionID: string) { - const pending = await client.api.session.pending.list({ sessionID }) - setStore("session", "pending", sessionID, reconcile(pending)) - setStore( - "session", - "input", - sessionID, - reconcile(pending.filter((item) => item.type !== "compaction").map((item) => item.id)), - ) - setStore( - "session", - "compaction", - sessionID, - reconcile(pending.filter((item) => item.type === "compaction").map((item) => item.id)), - ) + sync(sessionID: string) { + return sync.run(`session.pending:${sessionID}`, async () => { + const pending = await client.api.session.pending.list({ sessionID }) + setStore("session", "pending", sessionID, reconcile(pending)) + setStore( + "session", + "input", + sessionID, + reconcile(pending.filter((item) => item.type !== "compaction").map((item) => item.id)), + ) + }) + }, + invalidate(sessionID: string) { + sync.invalidate(`session.pending:${sessionID}`) }, }, - async refresh(sessionID: string) { - setStore("session", "info", sessionID, await client.api.session.get({ sessionID })) - registerSession(sessionID) + sync(sessionID: string) { + return sync.run(`session:${sessionID}`, async () => { + setStore("session", "info", sessionID, await client.api.session.get({ sessionID })) + registerSession(sessionID) + }) + }, + invalidate(sessionID: string) { + sync.invalidate(`session:${sessionID}`) }, message: { list(sessionID: string) { @@ -935,18 +958,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const position = messageIndex.get(sessionID)?.get(messageID) return position === undefined ? undefined : messages?.[position] }, - async refresh(sessionID: string) { - const messages = (await client.api.message.list({ sessionID, limit: 200, order: "desc" })).data.toReversed() - messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) - setStore("session", "message", sessionID, reconcile(messages)) + sync(sessionID: string) { + return sync.run(`session.message:${sessionID}`, async () => { + const messages = ( + await client.api.message.list({ sessionID, limit: 200, order: "desc" }) + ).data.toReversed() + messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) + setStore("session", "message", sessionID, reconcile(messages)) + }) + }, + invalidate(sessionID: string) { + sync.invalidate(`session.message:${sessionID}`) }, }, permission: { list(sessionID: string) { return store.session.permission[sessionID] }, - async refresh(sessionID: string) { - setStore("session", "permission", sessionID, await client.api.permission.list({ sessionID })) + sync(sessionID: string) { + return sync.run(`session.permission:${sessionID}`, async () => { + setStore("session", "permission", sessionID, await client.api.permission.list({ sessionID })) + }) + }, + invalidate(sessionID: string) { + sync.invalidate(`session.permission:${sessionID}`) }, }, form: { @@ -957,23 +992,33 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const key = locationKey(ref) return forms?.filter((form) => form.location && locationKey(form.location) === key) }, - async refresh(sessionID: string, ref?: LocationRef) { - if (sessionID === "global") { - const response = await client.api.form.request.list({ location: locationQuery(ref ?? defaultLocation()) }) - const location = { - directory: response.location.directory, - workspaceID: response.location.workspaceID, + sync(sessionID: string, ref?: LocationRef) { + const key = `session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}` + return sync.run(key, async () => { + if (sessionID === "global") { + const response = await client.api.form.request.list({ + location: locationQuery(ref ?? defaultLocation()), + }) + const location = { + directory: response.location.directory, + workspaceID: response.location.workspaceID, + } + const locationID = locationKey(location) + setStore("session", "form", sessionID, [ + ...(store.session.form[sessionID] ?? []).filter( + (form) => form.location && locationKey(form.location) !== locationID, + ), + ...response.data.filter((form) => form.sessionID === "global").map((form) => ({ ...form, location })), + ]) + return } - const key = locationKey(location) - setStore("session", "form", sessionID, [ - ...(store.session.form[sessionID] ?? []).filter( - (form) => form.location && locationKey(form.location) !== key, - ), - ...response.data.filter((form) => form.sessionID === "global").map((form) => ({ ...form, location })), - ]) - return - } - setStore("session", "form", sessionID, await client.api.form.list({ sessionID })) + setStore("session", "form", sessionID, await client.api.form.list({ sessionID })) + }) + }, + invalidate(sessionID: string, ref?: LocationRef) { + sync.invalidate( + `session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`, + ) }, }, }, @@ -982,8 +1027,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ list(projectID: string) { return store.project.permission[projectID] }, - async refresh(projectID: string) { - setStore("project", "permission", projectID, await client.api.permission.saved.list({ projectID })) + sync(projectID: string) { + return sync.run(`project.permission:${projectID}`, async () => { + setStore("project", "permission", projectID, await client.api.permission.saved.list({ projectID })) + }) + }, + invalidate(projectID: string) { + sync.invalidate(`project.permission:${projectID}`) }, }, }, @@ -996,53 +1046,115 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ .map((data) => data.shell?.[id]) .find((shell) => shell !== undefined) }, - async refresh(ref?: LocationRef) { - const result = await client.api.shell.list({ location: locationQuery(ref) }) - const key = locationKey(result.location) - setStore("location", key, { - ...store.location[key], - shell: Object.fromEntries(result.data.map((info) => [info.id, info])), + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.shell:${id}`, async () => { + const response = await client.api.shell.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { + ...store.location[key], + shell: Object.fromEntries(response.data.map((info) => [info.id, info])), + }) }) }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.shell:${locationKey(ref ?? defaultLocation())}`) + }, }, location: { + info(ref?: LocationRef) { + return store.location[locationKey(ref ?? defaultLocation())]?.info + }, default() { return defaultLocation() }, - async refresh(ref?: LocationRef) { - const location = await client.api.location.get({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(location) - if (!store.location[key]) setStore("location", key, {}) - if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID }) + async sync(ref?: LocationRef) { + const current = ref ?? defaultLocation() + await sync.run(`location:${locationKey(current)}`, async () => { + const location = await client.api.location.get({ location: locationQuery(current) }) + const key = locationKey(location) + if (!store.location[key]) setStore("location", key, {}) + setStore("location", key, "info", location) + if (!ref) { + setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID }) + } + }) + const location = ref ?? defaultLocation() + await Promise.all([ + result.location.agent.sync(location), + result.location.command.sync(location), + result.location.integration.sync(location), + result.location.mcp.server.sync(location), + result.location.mcp.resource.sync(location), + result.location.model.sync(location), + result.location.provider.sync(location), + result.location.reference.sync(location), + result.location.skill.sync(location), + result.shell.sync(location), + result.session.form.sync("global", location), + ]) + }, + invalidate(ref?: LocationRef) { + const location = ref ?? defaultLocation() + sync.invalidate(`location:${locationKey(location)}`) + result.location.agent.invalidate(location) + result.location.command.invalidate(location) + result.location.integration.invalidate(location) + result.location.mcp.server.invalidate(location) + result.location.mcp.resource.invalidate(location) + result.location.model.invalidate(location) + result.location.provider.invalidate(location) + result.location.reference.invalidate(location) + result.location.skill.invalidate(location) + result.shell.invalidate(location) + result.session.form.invalidate("global", location) }, agent: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.agent }, - async refresh(ref?: LocationRef) { - const result = await client.api.agent.list({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { ...store.location[key], agent: result.data }) + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.agent:${id}`, async () => { + const response = await client.api.agent.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { ...store.location[key], agent: response.data }) + }) + }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.agent:${locationKey(ref ?? defaultLocation())}`) }, }, command: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.command }, - async refresh(ref?: LocationRef) { - const result = await client.api.command.list({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { ...store.location[key], command: result.data }) + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.command:${id}`, async () => { + const response = await client.api.command.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { ...store.location[key], command: response.data }) + }) + }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.command:${locationKey(ref ?? defaultLocation())}`) }, }, integration: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.integration }, - async refresh(ref?: LocationRef) { - const result = await client.api.integration.list({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { ...store.location[key], integration: result.data }) + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.integration:${id}`, async () => { + const response = await client.api.integration.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { ...store.location[key], integration: response.data }) + }) + }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.integration:${locationKey(ref ?? defaultLocation())}`) }, }, mcp: { @@ -1050,180 +1162,154 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server }, - async refresh(ref?: LocationRef) { - const result = await client.api.mcp.list({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { - ...store.location[key], - mcp: { ...store.location[key]?.mcp, server: result.data }, + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.mcp.server:${id}`, async () => { + const response = await client.api.mcp.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { + ...store.location[key], + mcp: { ...store.location[key]?.mcp, server: response.data }, + }) }) }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.mcp.server:${locationKey(ref ?? defaultLocation())}`) + }, }, resource: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource }, - async refresh(ref?: LocationRef) { - const result = await client.api.mcp.resource.catalog({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { - ...store.location[key], - mcp: { ...store.location[key]?.mcp, resource: result.data.resources }, + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.mcp.resource:${id}`, async () => { + const response = await client.api.mcp.resource.catalog({ + location: locationQuery(ref ?? defaultLocation()), + }) + const key = locationKey(response.location) + setStore("location", key, { + ...store.location[key], + mcp: { ...store.location[key]?.mcp, resource: response.data.resources }, + }) }) }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.mcp.resource:${locationKey(ref ?? defaultLocation())}`) + }, }, }, model: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.model }, - async refresh(ref?: LocationRef) { - const result = await client.api.model.list({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { ...store.location[key], model: result.data }) + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.model:${id}`, async () => { + const response = await client.api.model.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { ...store.location[key], model: response.data }) + }) + }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.model:${locationKey(ref ?? defaultLocation())}`) }, }, provider: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.provider }, - async refresh(ref?: LocationRef) { - const result = await client.api.provider.list({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { ...store.location[key], provider: result.data }) + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.provider:${id}`, async () => { + const response = await client.api.provider.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { ...store.location[key], provider: response.data }) + }) + }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.provider:${locationKey(ref ?? defaultLocation())}`) }, }, reference: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.reference }, - async refresh(ref?: LocationRef) { - const result = await client.api.reference.list({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { ...store.location[key], reference: result.data }) + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.reference:${id}`, async () => { + const response = await client.api.reference.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { ...store.location[key], reference: response.data }) + }) + }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.reference:${locationKey(ref ?? defaultLocation())}`) }, }, skill: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.skill }, - async refresh(ref?: LocationRef) { - const result = await client.api.skill.list({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { ...store.location[key], skill: result.data }) + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.skill:${id}`, async () => { + const response = await client.api.skill.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { ...store.location[key], skill: response.data }) + }) + }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.skill:${locationKey(ref ?? defaultLocation())}`) }, }, }, } result satisfies Plugin.Context["data"] - async function bootstrap() { - if (bootstrapping) return bootstrapping - bootstrapping = Promise.allSettled([ - client.api.session - .list({ - limit: 50, - order: "desc", - directory: defaultLocation().directory, - workspace: defaultLocation().workspaceID, - }) - .then((response) => { - setStore( - "session", - "info", - produce((draft) => { - for (const session of response.data) draft[session.id] = session - }), - ) - for (const session of response.data) registerSession(session.id) - }), - client.api.permission.request.list({ location: locationQuery(defaultLocation()) }).then((response) => { - const permissions = response.data.reduce>( - (result, request) => ({ - ...result, - [request.sessionID]: [...(result[request.sessionID] ?? []), request], - }), - {}, - ) - setStore("session", "permission", reconcile(permissions)) - }), - client.api.form.request.list({ location: locationQuery(defaultLocation()) }).then((response) => { - const location = { - directory: response.location.directory, - workspaceID: response.location.workspaceID, - } - const forms = response.data.reduce>( - (result, form) => ({ - ...result, - [form.sessionID]: [ - ...(result[form.sessionID] ?? []), - form.sessionID === "global" ? { ...form, location } : form, - ], - }), - {}, - ) - setStore("session", "form", reconcile(forms)) - }), - result.location.refresh(), - result.location.agent.refresh(), - result.location.integration.refresh(), - result.location.mcp.server.refresh(), - result.location.mcp.resource.refresh(), - result.location.model.refresh(), - result.location.provider.refresh(), - result.location.reference.refresh(), - result.location.command.refresh(), - result.location.skill.refresh(), - result.shell.refresh(), - ]) - .then(async (settled) => { - for (const failure of settled.filter((item) => item.status === "rejected")) - console.error("Failed to refresh default location data", failure.reason) - const key = locationKey(defaultLocation()) - const locations = new Map( - Object.values(store.session.info).map( - (session) => [locationKey(session.location), session.location] as const, - ), - ) - const refreshed = await Promise.allSettled( - Array.from(locations) - .filter(([location]) => location !== key) - .map(([, location]) => result.session.form.refresh("global", location)), - ) - for (const failure of refreshed.filter((item) => item.status === "rejected")) - console.error("Failed to refresh global forms", failure.reason) - }) - .finally(() => { - bootstrapping = undefined - }) - return bootstrapping - } - - function refreshActive() { - void client.api.session - .active() - .then((active) => { - setStore( - "session", - "status", - reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))), - ) - }) - .catch(() => undefined) - } + createEffect(() => { + if (client.connection.status() === "connected") return + sync.invalidate() + }) onCleanup( client.event.listen(({ details }) => { if (details.type === "server.connected") { - const messages = connected ? Object.keys(store.session.message) : [] - const compactions = connected ? Object.keys(store.session.compaction) : [] - connected = true - refreshActive() - void Promise.allSettled([ - bootstrap(), - ...messages.map(result.session.message.refresh), - ...compactions.map(result.session.compaction.refresh), - ]) + void client.api.session + .active() + .then((active) => { + setStore( + "session", + "active", + reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))), + ) + }) + .catch(() => undefined) + void client.api.location + .get({ location: locationQuery(defaultLocation()) }) + .then((location) => { + const key = locationKey(location) + setStore("location", key, { ...store.location[key], info: location }) + return client.api.session.list({ + project: location.project.id, + limit: 50, + order: "desc", + }) + }) + .then((response) => { + setStore( + "session", + "info", + produce((draft) => { + for (const session of response.data) draft[session.id] = session + }), + ) + for (const session of response.data) { + sync.complete(`session:${session.id}`) + registerSession(session.id) + } + }) + .catch((error) => console.error("Failed to preload sessions", error)) return } handleEvent(details) diff --git a/packages/tui/src/context/directory.ts b/packages/tui/src/context/directory.ts index 2c566f3373..5c4152812b 100644 --- a/packages/tui/src/context/directory.ts +++ b/packages/tui/src/context/directory.ts @@ -1,13 +1,13 @@ import { createMemo } from "solid-js" -import { useProject } from "./project" +import { useData } from "./data" import { abbreviateHome } from "../runtime" import { useTuiPaths } from "./runtime" export function useDirectory() { - const project = useProject() + const data = useData() const paths = useTuiPaths() return createMemo(() => { - const directory = project.instance.path().directory || paths.cwd + const directory = data.location.info()?.directory ?? data.location.default().directory ?? paths.cwd return abbreviateHome(directory, paths.home) }) } diff --git a/packages/tui/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx index 16fdb9c8c5..5e902c664e 100644 --- a/packages/tui/src/context/keymap.tsx +++ b/packages/tui/src/context/keymap.tsx @@ -9,7 +9,7 @@ import { registerManagedTextareaLayer, registerTimedLeader, } from "@opentui/keymap/addons/opentui" -import { formatKeySequence } from "@opentui/keymap/extras" +import { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { KeymapProvider, useBindings, useKeymapSelector } from "@opentui/keymap/solid" import { useRenderer } from "@opentui/solid" @@ -19,9 +19,11 @@ import { TuiKeybind } from "../config/keybind" declare module "@opentui/keymap" { interface Command { + opencode?: KeymapCommand slash?: { name: string aliases?: string[] + arguments?: true } } } @@ -31,13 +33,28 @@ const MODE = { key: "opencode.mode", base: "base" } as const type OpenTuiKeymap = Parameters[0]["keymap"] type Mode = ReturnType -const Context = createContext<{ readonly keymap: OpenTuiKeymap; readonly mode: Mode }>() +const Context = createContext<{ + readonly keymap: OpenTuiKeymap + readonly mode: Mode + readonly dispatch: (id: string, input?: string) => void + readonly input: (id: string) => string | undefined +}>() function Provider(props: ParentProps) { const renderer = useRenderer() const config = useConfig() const keymap = createDefaultOpenTuiKeymap(renderer) const mode = createMode(keymap) + let invocation: { readonly id: string; readonly input?: string } | undefined + const dispatch = (id: string, input?: string) => { + const previous = invocation + invocation = { id, input } + try { + keymap.dispatchCommand(id) + } finally { + invocation = previous + } + } const dispose = [ registerCommaBindings(keymap), keymap.appendBindingExpander((context) => { @@ -113,7 +130,11 @@ function Provider(props: ParentProps) { }) return ( - {props.children} + (invocation?.id === id ? invocation.input : undefined) }} + > + {props.children} + ) } @@ -122,7 +143,7 @@ export type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/v2/tui/cont export interface Keymap { /** Dispatches a reachable command by ID. */ - dispatch(id: string): void + dispatch(id: string, input?: string): void /** Controls mutually exclusive OpenCode input modes. */ readonly mode: { /** Returns the active mode. */ @@ -135,15 +156,15 @@ export interface Keymap { function use(): Keymap { const value = useValue() return { - dispatch(id) { - value.keymap.dispatchCommand(id) + dispatch(id, input) { + value.dispatch(id, input) }, mode: value.mode, } } function createLayer(input: () => KeymapLayer) { - useValue() + const value = useValue() const config = useConfig() useBindings(() => { const layer = input() @@ -173,10 +194,12 @@ function createLayer(input: () => KeymapLayer) { ...options, ...(mode === "global" ? {} : { mode: mode ?? MODE.base }), commands: grouped.named.map((command) => { - const { id, description, group, palette, bind, ...definition } = command + const { id, description, group, palette, bind, run, ...definition } = command return { ...definition, name: id, + opencode: command, + run: () => run(value.input(id)), ...(description === undefined ? {} : { desc: description }), ...(group === undefined ? {} : { category: group }), ...(palette === undefined ? {} : { namespace: "palette" }), @@ -215,12 +238,21 @@ function useShortcuts() { const commands = keymap.getCommands({ visibility: "registered" }).map((command) => command.name) const bindings = keymap.getCommandBindings({ visibility: "registered", commands }) return new Map( - commands.map((id) => [id, formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(config.data))]), + commands.map((id) => [ + id, + { + first: formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(config.data)), + all: formatCommandBindings(bindings.get(id) ?? [], formatOptions(config.data)), + }, + ]), ) }) return { get(id: string) { - return shortcuts().get(id) + return shortcuts().get(id)?.first + }, + all(id: string) { + return shortcuts().get(id)?.all }, } } @@ -232,17 +264,30 @@ function useCommands(): Accessor { .getCommandEntries({ visibility: "reachable", }) - .map((entry) => ({ - id: entry.command.name, - title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name, - description: typeof entry.command.desc === "string" ? entry.command.desc : undefined, - group: typeof entry.command.category === "string" ? entry.command.category : undefined, - palette: entry.command.namespace === "palette" ? true : undefined, - slash: entry.command.slash, - run: () => { - value.keymap.dispatchCommand(entry.command.name) - }, - })), + .map((entry) => { + const command = entry.command.opencode ?? { + id: entry.command.name, + title: typeof entry.command.title === "string" ? entry.command.title : undefined, + description: typeof entry.command.desc === "string" ? entry.command.desc : undefined, + group: typeof entry.command.category === "string" ? entry.command.category : undefined, + enabled: + typeof entry.command.enabled === "boolean" || typeof entry.command.enabled === "function" + ? (entry.command.enabled as boolean | (() => boolean)) + : undefined, + palette: entry.command.namespace === "palette" ? true : undefined, + slash: entry.command.slash, + suggested: + typeof entry.command.suggested === "boolean" || typeof entry.command.suggested === "function" + ? (entry.command.suggested as boolean | (() => boolean)) + : undefined, + } + return { + ...command, + run: (input?: string) => { + value.dispatch(entry.command.name, input) + }, + } + }), ) } diff --git a/packages/tui/src/context/location.tsx b/packages/tui/src/context/location.tsx index 52f73c0bbb..c539d95013 100644 --- a/packages/tui/src/context/location.tsx +++ b/packages/tui/src/context/location.tsx @@ -1,10 +1,48 @@ -import type { LocationRef } from "@opencode-ai/client" -import { createContext, useContext, type Accessor, type ParentProps } from "solid-js" +import type { LocationGetOutput, LocationRef } from "@opencode-ai/client" +import { createContext, createMemo, createSignal, onCleanup, useContext, type ParentProps } from "solid-js" +import { useClient } from "./client" +import { useData } from "./data" -const context = createContext>() +const context = createContext<{ + readonly current: LocationGetOutput | undefined + set: (location?: LocationRef) => void +}>() -export function LocationProvider(props: ParentProps<{ location?: LocationRef }>) { - return props.location}>{props.children} +export function LocationProvider(props: ParentProps) { + const client = useClient() + const data = useData() + const [ref, setRef] = createSignal() + const current = createMemo(() => data.location.info(ref())) + + function sync(location?: LocationRef) { + if (!location) return + const defaultLocation = data.location.default() + const target = + location.directory === defaultLocation.directory && location.workspaceID === defaultLocation.workspaceID + ? undefined + : location + void data.location.sync(target).catch(() => undefined) + } + + function set(location?: LocationRef) { + setRef(location) + if (client.connection.status() === "connected") sync(location) + } + + onCleanup(client.event.on("server.connected", () => sync(ref()))) + + return ( + + {props.children} + + ) } export function useLocation() { diff --git a/packages/tui/src/context/path-format.tsx b/packages/tui/src/context/path-format.tsx index 52b1bee897..d162145e55 100644 --- a/packages/tui/src/context/path-format.tsx +++ b/packages/tui/src/context/path-format.tsx @@ -7,8 +7,8 @@ export function usePathFormatter() { const paths = useTuiPaths() const location = useLocation() return { - path: () => location()?.directory || paths.cwd, - format: (input?: string) => formatPath(input, location()?.directory || paths.cwd, paths.home), + path: () => location.current?.directory || paths.cwd, + format: (input?: string) => formatPath(input, location.current?.directory || paths.cwd, paths.home), } } diff --git a/packages/tui/src/context/project.tsx b/packages/tui/src/context/project.tsx deleted file mode 100644 index 466e663ab1..0000000000 --- a/packages/tui/src/context/project.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { batch } from "solid-js" -import { createStore, reconcile } from "solid-js/store" -import { createSimpleContext } from "./helper" -import { useClient } from "./client" - -export const { use: useProject, provider: ProjectProvider } = createSimpleContext({ - name: "Project", - init: () => { - const client = useClient() - - const defaultPath = { - home: "", - state: "", - config: "", - worktree: "", - directory: process.cwd(), - } - - const [store, setStore] = createStore({ - project: { - id: undefined as string | undefined, - worktree: undefined as string | undefined, - mainDir: undefined as string | undefined, - }, - instance: { - path: defaultPath, - }, - workspace: { - current: undefined as string | undefined, - }, - }) - - async function sync() { - const workspace = store.workspace.current - const location = { workspace } - const current = await client.api.location.get({ location }) - const directories = await client.api.project.directories({ projectID: current.project.id, location }) - batch(() => { - setStore( - "instance", - "path", - reconcile({ ...defaultPath, worktree: current.project.directory, directory: current.directory }), - ) - setStore("project", "id", current.project.id) - setStore("project", "worktree", current.project.directory) - setStore("project", "mainDir", directories.findLast((item) => item.strategy === undefined)?.directory) - }) - } - - return { - data: store, - project() { - return store.project.id - }, - instance: { - path() { - return store.instance.path - }, - directory() { - return store.instance.path.directory - }, - }, - workspace: { - current() { - return store.workspace.current - }, - set(next?: string | null) { - const workspace = next ?? undefined - if (store.workspace.current === workspace) return - setStore("workspace", "current", workspace) - }, - }, - sync, - } - }, -}) diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 19fbeb6ec6..3a456fbafc 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -62,12 +62,9 @@ export { allThemes, generateSyntax, hasTheme, - isTheme, - resolveTheme, selectedForeground, upsertTheme, type Theme, - type ThemeJson, } from "../theme" const THEME_REFRESH_DELAYS = [250, 1000] as const diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index 6956ad8678..8a1fc02cb2 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -4,19 +4,15 @@ import { createMemo, Match, Show, Switch } from "solid-js" import { useTerminalDimensions } from "@opentui/solid" import { useTuiPaths } from "../../context/runtime" import { useTheme } from "../../context/theme" -import { useHomeSessionDestination } from "../../routes/home/session-destination" import { abbreviateHome } from "../../runtime" import { FilePath } from "../../ui/file-path" function Directory(props: { context: Plugin.Context; maxWidth: number }) { const { theme } = useTheme() - const destination = useHomeSessionDestination() const paths = useTuiPaths() - const directory = createMemo(() => { - const selected = destination?.destination() - if (!selected || selected.type === "new") return - return abbreviateHome(selected.directory || props.context.data.location.default().directory, paths.home) - }) + const directory = createMemo(() => + props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined, + ) return ( @@ -27,7 +23,7 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) { function Mcp(props: { context: Plugin.Context }) { const { theme } = useTheme() - const list = createMemo(() => props.context.data.location.mcp.server.list() ?? []) + const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? []) const failed = createMemo(() => list().some((item) => item.status.status === "failed")) const count = createMemo(() => list().filter((item) => item.status.status === "connected").length) @@ -55,7 +51,7 @@ function View(props: { context: Plugin.Context }) { const { theme } = useTheme() const dimensions = useTerminalDimensions() const mcpWidth = createMemo(() => { - const list = props.context.data.location.mcp.server.list() ?? [] + const list = props.context.data.location.mcp.server.list(props.context.location) ?? [] if (list.length === 0) return 0 const count = list.filter((item) => item.status.status === "connected").length return Bun.stringWidth(`⊙ ${count} MCP /status`) + 2 diff --git a/packages/tui/src/feature-plugins/home/tips-view.tsx b/packages/tui/src/feature-plugins/home/tips-view.tsx deleted file mode 100644 index 8c65dbd603..0000000000 --- a/packages/tui/src/feature-plugins/home/tips-view.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import { createMemo, For, type Accessor } from "solid-js" -import { DEFAULT_THEMES, useTheme } from "../../context/theme" -import { Keymap } from "../../context/keymap" - -const themeCount = Object.keys(DEFAULT_THEMES).length - -type TipPart = { text: string; highlight: boolean } -type TipShortcut = Accessor -type Shortcuts = { - agentCycle: TipShortcut - childFirst: TipShortcut - childNext: TipShortcut - childPrevious: TipShortcut - commandList: TipShortcut - editorOpen: TipShortcut - helpShow: TipShortcut - inputClear: TipShortcut - inputNewline: TipShortcut - inputPaste: TipShortcut - inputUndo: TipShortcut - leader: TipShortcut - messagesCopy: TipShortcut - messagesFirst: TipShortcut - messagesLast: TipShortcut - messagesPageDown: TipShortcut - messagesPageUp: TipShortcut - modelCycleRecent: TipShortcut - modelList: TipShortcut - sessionExport: TipShortcut - sessionInterrupt: TipShortcut - sessionList: TipShortcut - sessionNew: TipShortcut - sessionParent: TipShortcut - sessionPinToggle: TipShortcut - sessionQuickSwitch1: TipShortcut - sessionQuickSwitch9: TipShortcut - sessionSidebarToggle: TipShortcut - sessionTimeline: TipShortcut - statusView: TipShortcut - terminalSuspend: TipShortcut - themeList: TipShortcut -} -type Tip = string | ((shortcuts: Shortcuts) => string | undefined) - -function parse(tip: string): TipPart[] { - const parts: TipPart[] = [] - const regex = /\{highlight\}(.*?)\{\/highlight\}/g - const found = Array.from(tip.matchAll(regex)) - const state = found.reduce( - (acc, match) => { - const start = match.index ?? 0 - if (start > acc.index) { - acc.parts.push({ text: tip.slice(acc.index, start), highlight: false }) - } - acc.parts.push({ text: match[1], highlight: true }) - acc.index = start + match[0].length - return acc - }, - { parts, index: 0 }, - ) - - if (state.index < tip.length) { - parts.push({ text: tip.slice(state.index), highlight: false }) - } - - return parts -} - -const NO_MODELS_TIP = "Run {highlight}/connect{/highlight} to add an AI provider and start coding" -const NO_MODELS_PARTS = parse(NO_MODELS_TIP) - -function shortcutText(value: string) { - return `{highlight}${value}{/highlight}` -} - -function commandText(command: string, shortcut: string | undefined) { - if (!shortcut) return shortcutText(command) - return `${shortcutText(command)} or ${shortcutText(shortcut)}` -} - -function press(shortcut: string | undefined, text: string) { - if (!shortcut) return undefined - return `Press ${shortcutText(shortcut)} ${text}` -} - -export function Tips(props: { connected?: boolean }) { - const theme = useTheme().theme - const keymap = Keymap.useShortcuts() - const tipOffset = Math.random() - const shortcut = (id: string) => () => keymap.get(id) - const shortcuts: Shortcuts = { - agentCycle: shortcut("agent.cycle"), - childFirst: shortcut("session.child.first"), - childNext: shortcut("session.child.next"), - childPrevious: shortcut("session.child.previous"), - commandList: shortcut("command.palette.show"), - editorOpen: shortcut("prompt.editor"), - helpShow: shortcut("help.show"), - inputClear: shortcut("prompt.clear"), - inputNewline: shortcut("input.newline"), - inputPaste: shortcut("prompt.paste"), - inputUndo: shortcut("input.undo"), - leader: shortcut("leader"), - messagesCopy: shortcut("messages.copy"), - messagesFirst: shortcut("session.first"), - messagesLast: shortcut("session.last"), - messagesPageDown: shortcut("session.page.down"), - messagesPageUp: shortcut("session.page.up"), - modelCycleRecent: shortcut("model.cycle_recent"), - modelList: shortcut("model.list"), - sessionExport: shortcut("session.export"), - sessionInterrupt: shortcut("session.interrupt"), - sessionList: shortcut("session.list"), - sessionNew: shortcut("session.new"), - sessionParent: shortcut("session.parent"), - sessionPinToggle: shortcut("session.pin.toggle"), - sessionQuickSwitch1: shortcut("session.quick_switch.1"), - sessionQuickSwitch9: shortcut("session.quick_switch.9"), - sessionSidebarToggle: shortcut("session.sidebar.toggle"), - sessionTimeline: shortcut("session.timeline"), - statusView: shortcut("opencode.status"), - terminalSuspend: shortcut("terminal.suspend"), - themeList: shortcut("theme.switch"), - } - const tip = createMemo(() => { - if (props.connected === false) return NO_MODELS_TIP - const tips = [...TIPS, process.platform !== "win32" ? TERMINAL_SUSPEND_TIP : INPUT_UNDO_TIP].flatMap((item) => { - const value = typeof item === "string" ? item : item(shortcuts) - return value ? [value] : [] - }) - return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP - }, NO_MODELS_TIP) - // Solid can expose a memo's initial value while a pure computation is pending. - const parts = createMemo(() => { - const value = tip() - if (typeof value === "string") return parse(value) - return NO_MODELS_PARTS - }, NO_MODELS_PARTS) - - return ( - - - ● Tip{" "} - - - - {(part) => {part.text}} - - - - ) -} - -const TIPS: Tip[] = [ - "Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files", - "Start a message with {highlight}!{/highlight} to run shell commands (e.g., {highlight}!ls -la{/highlight})", - (shortcuts) => press(shortcuts.agentCycle(), "to cycle between Build and Plan agents"), - "Use {highlight}/undo{/highlight} to revert the last message and file changes", - "Use {highlight}/redo{/highlight} to restore previously undone messages and file changes", - "Run {highlight}/share{/highlight} to create a public opencode.ai link", - "Drag and drop images or PDFs into the terminal as context", - (shortcuts) => press(shortcuts.inputPaste(), "to paste images from your clipboard into the prompt"), - (shortcuts) => `Use ${commandText("/editor", shortcuts.editorOpen())} to compose messages in your external editor`, - "Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase", - (shortcuts) => `Use ${commandText("/models", shortcuts.modelList())} to switch between available AI models`, - (shortcuts) => `Use ${commandText("/themes", shortcuts.themeList())} to switch between ${themeCount} built-in themes`, - (shortcuts) => `Use ${commandText("/new", shortcuts.sessionNew())} to start a fresh conversation session`, - (shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list, pin, and continue sessions`, - (shortcuts) => press(shortcuts.sessionPinToggle(), "in the session list to pin one at the top"), - (shortcuts) => { - const first = shortcuts.sessionQuickSwitch1() - const last = shortcuts.sessionQuickSwitch9() - if (!first || !last) return undefined - return `Use ${shortcutText(first)} through ${shortcutText(last)} to switch pinned sessions` - }, - "Run {highlight}/compact{/highlight} to summarize long sessions near context limits", - (shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`, - (shortcuts) => press(shortcuts.messagesCopy(), "to copy the assistant's last message to clipboard"), - (shortcuts) => press(shortcuts.commandList(), "to see all available actions and commands"), - "Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers", - (shortcuts) => { - const leader = shortcuts.leader() - if (!leader) return undefined - return `The leader key is ${shortcutText(leader)}; combine with other keys for quick actions` - }, - (shortcuts) => press(shortcuts.modelCycleRecent(), "to quickly switch between recently used models"), - (shortcuts) => press(shortcuts.sessionSidebarToggle(), "in a session to show or hide the sidebar panel"), - (shortcuts) => { - const up = shortcuts.messagesPageUp() - const down = shortcuts.messagesPageDown() - if (!up || !down) return undefined - return `Use ${shortcutText(up)}/${shortcutText(down)} to navigate through conversation history` - }, - (shortcuts) => press(shortcuts.messagesFirst(), "to jump to the beginning of the conversation"), - (shortcuts) => press(shortcuts.messagesLast(), "to jump to the most recent message"), - (shortcuts) => press(shortcuts.inputNewline(), "to add newlines in your prompt"), - (shortcuts) => press(shortcuts.inputClear(), "when typing to clear the input field"), - (shortcuts) => press(shortcuts.sessionInterrupt(), "to stop the AI mid-response"), - "Switch to {highlight}Plan{/highlight} agent for suggestions without making changes", - "Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents", - (shortcuts) => { - const items = [ - shortcuts.sessionParent(), - shortcuts.childFirst(), - shortcuts.childPrevious(), - shortcuts.childNext(), - ].filter((item): item is string => Boolean(item)) - if (!items.length) return undefined - return `Use ${items.map(shortcutText).join(" / ")} for parent/child sessions` - }, - "Create {highlight}opencode.json{/highlight} for server settings, and {highlight}tui.json{/highlight} for TUI", - "Place TUI settings in {highlight}~/.config/opencode/tui.json{/highlight} for global config", - "Add {highlight}$schema{/highlight} to your config for autocomplete in your editor", - "Configure {highlight}model{/highlight} in config to set your default model", - "Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section", - "Set any keybind to {highlight}none{/highlight} to disable it completely", - "Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section", - "Add {highlight}.md{/highlight} files to {highlight}.opencode/commands/{/highlight} for reusable prompts", - "Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input", - "Use backticks to inject shell output (e.g., {highlight}`git status`{/highlight})", - "Add {highlight}.md{/highlight} files to {highlight}.opencode/agents/{/highlight} for specialized AI personas", - "Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}shell{/highlight}, and {highlight}webfetch{/highlight} tools", - 'Use patterns like {highlight}"git *": "allow"{/highlight} for granular shell permissions', - 'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands', - 'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing', - 'Set {highlight}"formatter": true{/highlight} to enable built-in formatters', - 'Set {highlight}"formatter": false{/highlight} to disable inherited formatters', - "Define custom formatter commands with file extensions in config", - 'Set {highlight}"lsp": true{/highlight} to enable built-in LSP code analysis', - "Create {highlight}.ts{/highlight} files in {highlight}.opencode/tools/{/highlight} to define new LLM tools", - "Tool definitions can invoke scripts written in Python, Go, etc", - "Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugins/{/highlight} for event hooks", - "Use plugins to send OS notifications when sessions complete", - "Create a plugin to prevent OpenCode from reading sensitive files", - "Use {highlight}opencode run{/highlight} for non-interactive scripting", - "Use {highlight}opencode --continue{/highlight} to resume the last session", - "Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI", - "Use {highlight}--format json{/highlight} for machine-readable output in scripts", - "Run {highlight}opencode serve{/highlight} for headless API access to OpenCode", - "Use {highlight}opencode run --attach{/highlight} to connect to a running server", - "Run {highlight}opencode upgrade{/highlight} to update to the latest version", - "Run {highlight}opencode auth list{/highlight} to see all configured providers", - "Run {highlight}opencode agent create{/highlight} for guided agent creation", - "Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions", - "Run {highlight}opencode github install{/highlight} to set up the GitHub workflow", - "Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs", - "Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews", - 'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors', - "Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory", - "Themes support dark/light variants for both modes", - "Use numeric xterm color codes 0-255 in custom theme JSON", - "Use {highlight}{env:VAR_NAME}{/highlight} for environment variables in config", - "Use {highlight}{file:path}{/highlight} to include file contents in config values", - "Use {highlight}instructions{/highlight} in config to load additional rules files", - "Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)", - "Configure {highlight}steps{/highlight} to limit agentic iterations per request", - 'Set {highlight}"tools": {"shell": false}{/highlight} to disable specific tools', - 'Set {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server', - "Override global tool settings per agent configuration", - 'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions', - 'Set {highlight}"share": "disabled"{/highlight} to prevent any session sharing', - "Run {highlight}/unshare{/highlight} to remove a session from public access", - "Permission {highlight}doom_loop{/highlight} prevents infinite tool call loops", - "Permission {highlight}external_directory{/highlight} protects files outside project", - "Run {highlight}opencode debug config{/highlight} to troubleshoot configuration", - "Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr", - (shortcuts) => `Use ${commandText("/timeline", shortcuts.sessionTimeline())} to jump to specific messages`, - (shortcuts) => `Use ${commandText("/status", shortcuts.statusView())} to see system status info`, - "Enable {highlight}scroll.acceleration{/highlight} in {highlight}cli.json{/highlight} for smooth scrolling", - (shortcuts) => { - const commandList = shortcuts.commandList() - return commandList - ? `Toggle username display in chat via the command palette (${shortcutText(commandList)})` - : "Toggle username display in chat via the command palette" - }, - "Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} in a container", - "Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models", - "Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing", - "Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs", - (shortcuts) => `Use ${commandText("/help", shortcuts.helpShow())} to show the help dialog`, - "Use {highlight}/rename{/highlight} to rename the current session", -] - -const INPUT_UNDO_TIP: Tip = (shortcuts) => press(shortcuts.inputUndo(), "to undo changes in your prompt") -const TERMINAL_SUSPEND_TIP: Tip = (shortcuts) => - press(shortcuts.terminalSuspend(), "to suspend the terminal and return to your shell") diff --git a/packages/tui/src/feature-plugins/home/tips.tsx b/packages/tui/src/feature-plugins/home/tips.tsx deleted file mode 100644 index 0fedb29be5..0000000000 --- a/packages/tui/src/feature-plugins/home/tips.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Plugin } from "@opencode-ai/plugin/v2/tui" -import { createMemo, Show } from "solid-js" -import { Tips } from "./tips-view" -import { Keymap } from "../../context/keymap" -import { useData } from "../../context/data" -import { hasConnectedProvider } from "../../util/connected-provider" -import { useConfig } from "../../config" -import { useDialog } from "../../ui/dialog" - -function View() { - const config = useConfig() - const data = useData() - const dialog = useDialog() - const hidden = createMemo(() => !(config.data.hints?.tips ?? true)) - const first = createMemo(() => data.session.list().length === 0) - const connected = createMemo(() => hasConnectedProvider(data.location.integration.list() ?? [])) - const show = createMemo(() => (!first() || !connected()) && !hidden()) - - Keymap.createLayer(() => ({ - commands: [ - { - id: "tips.toggle", - title: hidden() ? "Show tips" : "Hide tips", - group: "System", - run() { - void config - .update((draft) => { - draft.hints = { ...draft.hints, tips: hidden() } - }) - .catch(() => {}) - dialog.clear() - }, - }, - ], - })) - - return ( - - - - - - ) -} - -export default Plugin.define({ - id: "internal:home-tips", - setup(context) { - context.ui.slot("home.bottom", () => ) - }, -}) diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index bea5afa9b5..12e6b965a3 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -1,14 +1,22 @@ import { Plugin } from "@opencode-ai/plugin/v2/tui" +import { createMemo, Show } from "solid-js" +import { useTuiPaths } from "../../context/runtime" import { useTheme } from "../../context/theme" +import { abbreviateHome } from "../../runtime" +import { FilePath } from "../../ui/file-path" -function View() { +function View(props: { context: Plugin.Context }) { const { theme } = useTheme() - return Sidebar footer unavailable + const paths = useTuiPaths() + const directory = createMemo(() => + props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined, + ) + return {(value) => } } export default Plugin.define({ id: "opencode.sidebar-footer", setup(context) { - context.ui.slot("sidebar.footer", () => ) + context.ui.slot("sidebar.footer", () => ) }, }) diff --git a/packages/tui/src/keymap.tsx b/packages/tui/src/keymap.tsx index 4278c67b63..ebf78ffc24 100644 --- a/packages/tui/src/keymap.tsx +++ b/packages/tui/src/keymap.tsx @@ -13,15 +13,18 @@ import { formatKeySequence as formatKeySequenceExtra, } from "@opentui/keymap/extras" import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid" -import { createMemo, type Accessor } from "solid-js" +import type { Accessor } from "solid-js" import { useConfig } from "./config" import { TuiKeybind } from "./config/keybind" +import type { KeymapCommand } from "@opencode-ai/plugin/v2/tui/context" declare module "@opentui/keymap" { interface Command { + opencode?: KeymapCommand slash?: { name: string aliases?: string[] + arguments?: true } } } @@ -39,13 +42,6 @@ export const useOpencodeKeymap = useKeymap export type OpenTuiKeymap = ReturnType type OpencodeModeStack = ReturnType -type CommandSlashEntry = { - display: string - description?: string - aliases?: string[] - onSelect: () => void -} -type RegisteredCommand = ReturnType[number] type BindingLookup = { get(command: string): readonly Binding[] } @@ -54,10 +50,6 @@ type ResolvedKeymapConfig = FormatConfig & ({ leader: { timeout: number } } | { const modeStacks = new WeakMap() -function isVisiblePaletteCommand(command: RegisteredCommand) { - return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND -} - export function createOpencodeModeStack(keymap: OpenTuiKeymap) { keymap.setData(OPENCODE_MODE_KEY, OPENCODE_BASE_MODE) @@ -268,32 +260,3 @@ export function useCommandShortcut(command: string): Accessor { ), ) } - -export function useCommandSlashes(): Accessor { - const keymap = useOpencodeKeymap() - const entries = useKeymapSelector((keymap: OpenTuiKeymap) => - keymap.getCommandEntries({ - visibility: "reachable", - namespace: "palette", - filter: isVisiblePaletteCommand, - }), - ) - - return createMemo(() => - entries().flatMap((entry) => { - const slash = entry.command.slash - if (!slash) return [] - return { - display: `/${slash.name}`, - description: - typeof entry.command.desc === "string" - ? entry.command.desc - : typeof entry.command.title === "string" - ? entry.command.title - : undefined, - aliases: slash.aliases?.map((alias) => `/${alias}`), - onSelect: () => keymap.dispatchCommand(entry.command.name), - } - }), - ) -} diff --git a/packages/tui/src/plugin/builtins.ts b/packages/tui/src/plugin/builtins.ts index ef8a370095..4e9040ce6e 100644 --- a/packages/tui/src/plugin/builtins.ts +++ b/packages/tui/src/plugin/builtins.ts @@ -1,5 +1,4 @@ import HomeFooter from "../feature-plugins/home/footer" -import HomeTips from "../feature-plugins/home/tips" import SidebarContext from "../feature-plugins/sidebar/context" import SidebarFooter from "../feature-plugins/sidebar/footer" import SidebarLsp from "../feature-plugins/sidebar/lsp" @@ -9,7 +8,6 @@ import Scrap from "../feature-plugins/system/scrap" export const builtins = [ HomeFooter, - HomeTips, SidebarContext, SidebarMcp, SidebarLsp, diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index a9f9577660..29777342c8 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -21,6 +21,7 @@ import { useData } from "../context/data" import { Keymap } from "../context/keymap" import { useRoute } from "../context/route" import { useTuiLifecycle } from "../context/runtime" +import { useLocation } from "../context/location" import { builtins } from "./builtins" export interface PackageResolver { @@ -63,6 +64,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> const keymap = Keymap.use() const shortcuts = Keymap.useShortcuts() const lifecycle = useTuiLifecycle() + const location = useLocation() const directory = config.path ? path.dirname(config.path) : process.cwd() const [store, setStore] = createStore({ ready: false, @@ -82,6 +84,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> const owned: Dispose[] = [] const context: Context = { options: item.options ?? {}, + get location() { + return location.current + }, client: client.api, data, keymap: { diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index 4c59d20e68..92640c56b3 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -1,16 +1,14 @@ import { Prompt, type PromptRef } from "../component/prompt" import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js" import { Logo } from "../component/logo" -import { Toast } from "../ui/toast" import { useArgs } from "../context/args" import { useRouteData } from "../context/route" import { usePromptRef } from "../context/prompt" import { useLocal } from "../context/local" import { usePluginRuntime } from "../plugin/runtime" import { useEditorContext } from "../context/editor" -import { HomeSessionDestinationProvider } from "./home/session-destination" import { useData } from "../context/data" -import { LocationProvider } from "../context/location" +import { useLocation } from "../context/location" import { FormPrompt } from "./session/form" import { PluginSlot } from "../plugin/context" @@ -29,10 +27,13 @@ export function Home() { const local = useLocal() const editor = useEditorContext() const data = useData() + const location = useLocation() // Global MCP elicitations can arrive without a session route, so keep them reachable from Home. const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? []) let sent = false + createEffect(() => location.set(data.location.default())) + onMount(() => { editor.clearSelection() }) @@ -64,47 +65,44 @@ export function Home() { }) return ( - - - - - - - - - - - - - - } - placeholders={placeholder} - disabled={forms().length > 0} - /> - - - - - + <> + + + + + + + - - + + + + } + placeholders={placeholder} + disabled={forms().length > 0} + /> + - - {(_) => { - const form = forms()[0] - return form ? ( - - - - + + + + + + + + {(_) => { + const form = forms()[0] + return form ? ( + + + - ) : null - }} - - - + + ) : null + }} + + ) } diff --git a/packages/tui/src/routes/home/session-destination.tsx b/packages/tui/src/routes/home/session-destination.tsx deleted file mode 100644 index bf1e1c1db5..0000000000 --- a/packages/tui/src/routes/home/session-destination.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { - createContext, - createMemo, - createSignal, - useContext, - type Accessor, - type ParentProps, - type Setter, -} from "solid-js" -import { useTuiPaths } from "../../context/runtime" -import { useProject } from "../../context/project" - -export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string } - -type Context = { - destination: Accessor - setDestination: Setter - clear: () => void -} - -const HomeSessionDestinationContext = createContext() - -export function HomeSessionDestinationProvider(props: ParentProps) { - const project = useProject() - const paths = useTuiPaths() - const [selected, setDestination] = createSignal() - const destination = createMemo( - () => selected() ?? { type: "directory", directory: project.instance.directory() || paths.cwd, subdirectory: false }, - ) - return ( - setDestination(undefined) }} - > - {props.children} - - ) -} - -export function useHomeSessionDestination() { - return useContext(HomeSessionDestinationContext) -} diff --git a/packages/tui/src/routes/session/composer/shell-tab.tsx b/packages/tui/src/routes/session/composer/shell-tab.tsx index 097125092e..956d7715e1 100644 --- a/packages/tui/src/routes/session/composer/shell-tab.tsx +++ b/packages/tui/src/routes/session/composer/shell-tab.tsx @@ -18,9 +18,7 @@ export function ShellTab(props: { sessionID: string }) { const shortcuts = Keymap.useShortcuts() const entries = createMemo(() => - data.shell - .list() - .filter((shell) => shell.metadata.sessionID === props.sessionID && shell.status === "running"), + data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID && shell.status === "running"), ) const [store, setStore] = createStore({ selected: 0 }) @@ -47,8 +45,7 @@ export function ShellTab(props: { sessionID: string }) { const cleanup = composer.register({ id: "shell", label: "Shell", - hints: () => - selectedEntry() ? [{ label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" }] : [], + hints: () => (selectedEntry() ? [{ label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" }] : []), }) onCleanup(cleanup) }) @@ -87,7 +84,7 @@ export function ShellTab(props: { sessionID: string }) { run() { const entry = selectedEntry() if (!entry) return - const ref = location() + const ref = location.current void client.api.shell.remove({ id: entry.id, location: ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined, @@ -99,11 +96,7 @@ export function ShellTab(props: { sessionID: string }) { return ( - (scroll = r)} - > + (scroll = r)}> 0} fallback={ No shell commands}> {(shell, index) => { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 7118df30d2..8c8ede263f 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -18,7 +18,6 @@ import { EOL, tmpdir } from "node:os" import { mkdir, writeFile } from "node:fs/promises" import { useRoute, useRouteData } from "../../context/route" import { createStore } from "solid-js/store" -import { useProject } from "../../context/project" import { useData } from "../../context/data" import { SplitBorder } from "../../ui/border" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" @@ -53,7 +52,7 @@ import { Composer } from "./composer" import { filetype } from "../../util/filetype" import parsers from "../../parsers-config" import { errorMessage } from "../../util/error" -import { Toast, useToast } from "../../ui/toast" +import { useToast } from "../../ui/toast" import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" import { useEpilogue } from "../../context/epilogue" @@ -71,7 +70,7 @@ import { collapseToolOutput } from "../../util/collapse-tool-output" import { usePluginRuntime } from "../../plugin/runtime" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap" import { usePathFormatter } from "../../context/path-format" -import { LocationProvider } from "../../context/location" +import { useLocation } from "../../context/location" import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows" import { switchLabel } from "../../util/model" @@ -106,7 +105,6 @@ export function Session() { const route = useRouteData("session") const { navigate } = useRoute() const data = useData() - const project = useProject() const paths = useTuiPaths() const configState = useConfig() const config = configState.data @@ -115,6 +113,9 @@ export function Session() { const session = createMemo(() => data.session.get(route.sessionID)) const messages = () => data.session.message.list(route.sessionID) const location = createMemo(() => session()?.location) + const currentLocation = useLocation() + + createEffect(() => currentLocation.set(location())) createEffect(() => { const title = Locale.truncate(session()?.title ?? "", 50) @@ -181,23 +182,22 @@ export function Session() { const rows = createSessionRows(() => route.sessionID) createEffect( - on(descendantSessionIDs, (sessionIDs) => { + on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => { + if (status !== "connected") return void Promise.all( - sessionIDs.flatMap((sessionID) => [ - data.session.permission.refresh(sessionID), - data.session.form.refresh(sessionID), - ]), + sessionIDs.flatMap((sessionID) => [data.session.permission.sync(sessionID), data.session.form.sync(sessionID)]), ) }), ) createEffect(() => { + if (client.connection.status() !== "connected") return const sessionID = route.sessionID void (async () => { await Promise.all([ - data.session.refresh(sessionID), - data.session.permission.refresh(sessionID), - data.session.form.refresh(sessionID), + data.session.sync(sessionID), + data.session.permission.sync(sessionID), + data.session.form.sync(sessionID), ]) const info = data.session.get(sessionID) if (!info) { @@ -209,14 +209,6 @@ export function Session() { navigate({ type: "home" }) return } - void data.session.form.refresh("global", info.location).catch((error) => - toast.show({ - message: `Failed to refresh global forms: ${errorMessage(error)}`, - variant: "error", - duration: 5000, - }), - ) - project.workspace.set(info.location.workspaceID) editor.reconnect(info.location.directory) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) })().catch((error) => { @@ -823,133 +815,130 @@ export function Session() { ) return ( - - - - - - (scroll = r)} - viewportOptions={{ - paddingRight: showScrollbar() ? 1 : 0, - }} - verticalScrollbarOptions={{ - paddingLeft: 1, - visible: showScrollbar(), - trackOptions: { - backgroundColor: theme.backgroundElement, - foregroundColor: theme.border, - }, - }} - stickyScroll={true} - stickyStart="bottom" - flexGrow={1} - scrollAcceleration={scrollAcceleration()} - > - - {(row) => ( - data.session.message.get(route.sessionID, messageID)} - /> - )} - - - - message.id >= session()!.revert!.messageID && message.type === "user", - ).length - } - files={session()!.revert!.files ?? []} + + + + + (scroll = r)} + viewportOptions={{ + paddingRight: showScrollbar() ? 1 : 0, + }} + verticalScrollbarOptions={{ + paddingLeft: 1, + visible: showScrollbar(), + trackOptions: { + backgroundColor: theme.backgroundElement, + foregroundColor: theme.border, + }, + }} + stickyScroll={true} + stickyStart="bottom" + flexGrow={1} + scrollAcceleration={scrollAcceleration()} + > + + {(row) => ( + data.session.message.get(route.sessionID, messageID)} /> - - - - setComposer("open", false)} + )} + + + + message.id >= session()!.revert!.messageID && message.type === "user", + ).length + } + files={session()!.revert!.files ?? []} /> - - {null} - 0}> - - - 0}> - - {(_) => { - const form = forms()[0] - return form ? : null - }} - - - - + + + setComposer("open", false)} + /> + + {null} + 0}> + + + 0}> + + {(_) => { + const form = forms()[0] + return form ? : null + }} + + + + + - { - toBottom() - }} - sessionID={route.sessionID} - right={} - /> - - - - - - - - - - - - - - - - - - + disabled={false} + onSubmit={() => { + toBottom() + }} + sessionID={route.sessionID} + right={} + /> + + + + - - + + + + + + + + + + + + + + ) } @@ -1091,32 +1080,28 @@ function SessionReasoningGroupView(props: { const renderer = useRenderer() const [expanded, setExpanded] = createSignal(false) const [hover, setHover] = createSignal(false) - const parts = createMemo<{ message: SessionMessageAssistant; part: SessionMessageAssistantReasoning }[]>( - (previous) => { - const next = props.refs.flatMap((ref) => { - const message = props.message(ref.messageID) - if (message?.type !== "assistant") return [] - const part = resolvePart(message, ref.partID) - if (part?.type !== "reasoning" || !part.text.replace("[REDACTED]", "").trim()) return [] - return [{ message, part }] - }) - return next.length > 0 ? next : previous - }, - [] as { message: SessionMessageAssistant; part: SessionMessageAssistantReasoning }[], + const parts = createMemo(() => + props.refs.flatMap((ref) => { + const message = props.message(ref.messageID) + if (message?.type !== "assistant") return [] + const part = resolvePart(message, ref.partID) + if (part?.type !== "reasoning" || !reasoningContent(part)) return [] + return [{ message, part }] + }), ) const latest = createMemo((previous: string | null) => { const item = parts().at(-1) if (!item) return previous - const title = reasoningSummary(item.part.text.replace("[REDACTED]", "").trim()).title + const title = reasoningSummary(reasoningContent(item.part)).title if (title) return title if (item.part.time?.completed !== undefined || item.message.time.completed !== undefined) return null return previous }, null) const duration = createMemo(() => parts().reduce((total, item) => { - const end = item.part.time?.completed ?? item.message.time.completed - const start = item.part.time?.created ?? item.message.time.created - return total + (end === undefined ? 0 : Math.max(0, end - start)) + const start = item.part.time?.created + const end = item.part.time?.completed + return total + (start === undefined || end === undefined ? 0 : Math.max(0, end - start)) }, 0), ) @@ -1124,9 +1109,7 @@ function SessionReasoningGroupView(props: { 0}> {(item) => } - } + fallback={{(ref) => }} > - - {(item) => ( - - - - - - )} + + {(ref) => { + const message = createMemo(() => { + const item = props.message(ref.messageID) + return item?.type === "assistant" ? item : undefined + }) + const part = createMemo(() => { + const item = message() + if (!item) return undefined + const part = resolvePart(item, ref.partID) + return part?.type === "reasoning" ? part : undefined + }) + const content = createMemo(() => { + const item = part() + return item ? reasoningContent(item) : "" + }) + return ( + + + + + + + + ) + }} @@ -1788,10 +1789,7 @@ function ReasoningPart(props: { // layout never shifts. Click to open the full markdown block, click to close. const [expanded, setExpanded] = createSignal(false) - const content = createMemo(() => { - // OpenRouter encrypts some reasoning blocks; drop the placeholder. - return props.part.text.replace("[REDACTED]", "").trim() - }) + const content = createMemo(() => reasoningContent(props.part)) const isDone = createMemo( () => props.part.time?.completed !== undefined || props.message.time.completed !== undefined, ) @@ -1851,6 +1849,11 @@ function ReasoningPart(props: { ) } +function reasoningContent(part: SessionMessageAssistantReasoning) { + // OpenRouter encrypts some reasoning blocks; drop the placeholder. + return part.text.replace("[REDACTED]", "").trim() +} + function ReasoningHeader(props: { toggleable: boolean open: boolean @@ -2315,6 +2318,7 @@ function BlockTool(props: { function Shell(props: ToolProps) { const { theme } = useTheme() const ctx = use() + const client = useClient() const data = useData() const permission = createMemo(() => { const request = data.session.permission.list(ctx.sessionID)?.[0] @@ -2328,13 +2332,35 @@ function Shell(props: ToolProps) { }) const isRunning = createMemo(() => props.part.state.status === "running" || backgroundRunning()) const command = createMemo(() => stringValue(props.input.command)) + const [expanded, setExpanded] = createSignal(false) + const [backgroundOutput, setBackgroundOutput] = createSignal("") + let loading = false + const loadBackgroundOutput = async () => { + const id = shellID() + if (!id || loading) return + loading = true + const location = data.session.get(ctx.sessionID)?.location + await client.api.shell + .output({ + id, + limit: 1024 * 1024, + location: location ? { directory: location.directory, workspace: location.workspaceID } : undefined, + }) + .then((response) => setBackgroundOutput(stripAnsi(response.data.output.trim()))) + .catch(() => undefined) + loading = false + } + createEffect(() => { + if (!expanded() || !backgroundRunning()) return + const interval = setInterval(() => void loadBackgroundOutput(), 1_000) + onCleanup(() => clearInterval(interval)) + }) const output = createMemo(() => { if (props.part.state.status === "streaming") return "" - if (shellID()) return "" + if (shellID()) return expanded() ? backgroundOutput() : "" const content = props.part.state.content[0] return stripAnsi(content?.type === "text" ? content.text.trim() : "") }) - const [expanded, setExpanded] = createSignal(false) const maxLines = 10 const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6)) const input = createMemo(() => (command() ? `${isRunning() ? "" : "$ "}${command()}` : "")) @@ -2344,9 +2370,15 @@ function Shell(props: ToolProps) { if (expanded() || !collapsed().overflow) return content() return collapsed().output }) + const expandable = createMemo(() => Boolean(shellID()) || collapsed().overflow) + const toggle = () => { + const next = !expanded() + setExpanded(next) + if (next) void loadBackgroundOutput() + } return ( - setExpanded((prev) => !prev) : undefined}> + Background - - {expanded() ? "Click to collapse" : "Click to expand"} - ) @@ -2500,9 +2529,7 @@ function Subagent(props: ToolProps) { const { navigate } = useRoute() const data = useData() const input = createMemo(() => (typeof props.part.state.input === "string" ? {} : props.part.state.input)) - const metadata = createMemo(() => - props.part.state.status === "streaming" ? {} : props.part.state.structured, - ) + const metadata = createMemo(() => (props.part.state.status === "streaming" ? {} : props.part.state.structured)) const sessionID = createMemo(() => stringValue(metadata().sessionID) ?? stringValue(metadata().sessionId)) const description = createMemo(() => stringValue(input().description)) const isRunning = createMemo(() => { @@ -2697,27 +2724,36 @@ function ApplyPatch(props: ToolProps) { }} part={props.part} > - - - + + -{file.deletions} line{file.deletions !== 1 ? "s" : ""} + + } + > + + + + )} diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 6d3f4cf422..471b282097 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -2,6 +2,7 @@ import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/c import { createEffect, on, onCleanup, type Accessor } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useData } from "../../context/data" +import { useClient } from "../../context/client" export type PartRef = { messageID: string @@ -29,6 +30,7 @@ export type SessionRow = export function createSessionRows(sessionID: Accessor) { const data = useData() + const client = useClient() const [rows, setRows] = createStore([]) const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID @@ -42,9 +44,10 @@ export function createSessionRows(sessionID: Accessor) { rows.splice( position === -1 ? rows.length : position, 0, - ...data.session.compaction + ...data.session.pending .list(sessionID()) - .map((inputID): SessionRow => ({ type: "compaction-queued", inputID })), + .filter((item) => item.type === "compaction") + .map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })), ) return rows } @@ -67,10 +70,11 @@ export function createSessionRows(sessionID: Accessor) { }) createEffect( - on(sessionID, (id) => { + on([sessionID, () => client.connection.status()], ([id, status]) => { + if (status !== "connected") return setRows(reconcile(reduce())) - void data.session.compaction.refresh(id).catch(() => undefined) - void data.session.message.refresh(id).then( + void data.session.pending.sync(id).catch(() => undefined) + void data.session.message.sync(id).then( () => { if (sessionID() !== id) return setRows(reconcile(reduce())) @@ -89,7 +93,11 @@ export function createSessionRows(sessionID: Accessor) { createEffect( on( - () => data.session.compaction.list(sessionID()).map((inputID) => inputID), + () => + data.session.pending + .list(sessionID()) + .filter((item) => item.type === "compaction") + .map((item) => item.id), () => setRows(reconcile(reduce())), ), ) @@ -132,39 +140,11 @@ export function createSessionRows(sessionID: Accessor) { }), ) - const appendPart = (ref: PartRef, name?: string) => + const appendPart = (ref: PartRef, part: AppendPart) => setRows( produce((draft) => { if (hasPart(draft, ref)) return - const index = queuedStart(draft) - if (ref.partID.startsWith("reasoning:")) { - const previous = draft[index - 1] - if (previous?.type === "group" && previous.kind === "reasoning") { - previous.refs.push(ref) - return - } - completePrevious(draft, index) - draft.splice(index, 0, { type: "group", kind: "reasoning", refs: [ref], completed: false }) - return - } - if (name && exploration(name)) { - const previous = draft[index - 1] - if (previous?.type === "group" && previous.kind === "exploration") { - previous.refs.push(ref) - return - } - completePrevious(draft, index) - draft.splice(index, 0, { - type: "group", - kind: "exploration", - refs: [ref], - pending: [], - completed: false, - }) - return - } - completePrevious(draft, index) - draft.splice(index, 0, { type: "part", ref }) + append(draft, ref, part, queuedStart(draft)) }), ) @@ -230,23 +210,32 @@ export function createSessionRows(sessionID: Accessor) { data.on("session.model.selected", message), data.on("session.text.delta", (event) => { if (event.data.sessionID === sessionID() && event.data.delta.trim()) - appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }) + appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }, { type: "text" }) }), data.on("session.text.ended", (event) => { if (event.data.sessionID === sessionID() && event.data.text.trim()) - appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }) + appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }, { type: "text" }) }), data.on("session.reasoning.delta", (event) => { - if (event.data.sessionID === sessionID()) - appendPart({ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` }) + if (event.data.sessionID === sessionID() && event.data.delta.trim()) + appendPart( + { messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` }, + { type: "reasoning" }, + ) }), data.on("session.reasoning.ended", (event) => { if (event.data.sessionID === sessionID() && event.data.text.trim()) - appendPart({ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` }) + appendPart( + { messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` }, + { type: "reasoning" }, + ) }), data.on("session.tool.input.started", (event) => { if (event.data.sessionID === sessionID()) - appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name) + appendPart( + { messageID: event.data.assistantMessageID, partID: event.data.callID }, + { type: "tool", name: event.data.name }, + ) }), data.on("session.retry.scheduled", (event) => { if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID) @@ -305,31 +294,31 @@ export function resolvePart(message: SessionMessageAssistant, partID: string) { return message.content.filter((part) => part.type === match[1])[ordinal] } -function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) { +type AppendPart = { type: "text" } | { type: "reasoning" } | { type: "tool"; name: string } + +function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) { if (part.type === "reasoning") { - const previous = rows.at(-1) + const previous = rows[index - 1] if (previous?.type === "group" && previous.kind === "reasoning") { previous.refs.push(ref) return } - completePrevious(rows) - rows.push({ type: "group", kind: "reasoning", refs: [ref], completed: false }) + completePrevious(rows, index) + rows.splice(index, 0, { type: "group", kind: "reasoning", refs: [ref], completed: false }) return } - if (part.type === "tool") { - if (exploration(part.name)) { - const previous = rows.at(-1) - if (previous?.type === "group" && previous.kind === "exploration") { - previous.refs.push(ref) - return - } - completePrevious(rows) - rows.push({ type: "group", kind: "exploration", refs: [ref], pending: [], completed: false }) + if (part.type === "tool" && exploration(part.name)) { + const previous = rows[index - 1] + if (previous?.type === "group" && previous.kind === "exploration") { + previous.refs.push(ref) return } + completePrevious(rows, index) + rows.splice(index, 0, { type: "group", kind: "exploration", refs: [ref], pending: [], completed: false }) + return } - completePrevious(rows) - rows.push({ type: "part", ref }) + completePrevious(rows, index) + rows.splice(index, 0, { type: "part", ref }) } function completePrevious(rows: SessionRow[], index = rows.length) { diff --git a/packages/tui/src/theme/index.ts b/packages/tui/src/theme/index.ts index 36263febae..73f12bd16b 100644 --- a/packages/tui/src/theme/index.ts +++ b/packages/tui/src/theme/index.ts @@ -1,167 +1,8 @@ -import { SyntaxStyle, RGBA } from "@opentui/core" +import { RGBA } from "@opentui/core" import { ansiToRgba } from "./color" -import aura from "./assets/aura.json" with { type: "json" } -import ayu from "./assets/ayu.json" with { type: "json" } -import carbonfox from "./assets/carbonfox.json" with { type: "json" } -import catppuccinFrappe from "./assets/catppuccin-frappe.json" with { type: "json" } -import catppuccinMacchiato from "./assets/catppuccin-macchiato.json" with { type: "json" } -import catppuccin from "./assets/catppuccin.json" with { type: "json" } -import cobalt2 from "./assets/cobalt2.json" with { type: "json" } -import cursor from "./assets/cursor.json" with { type: "json" } -import dracula from "./assets/dracula.json" with { type: "json" } -import everforest from "./assets/everforest.json" with { type: "json" } -import flexoki from "./assets/flexoki.json" with { type: "json" } -import github from "./assets/github.json" with { type: "json" } -import gruvbox from "./assets/gruvbox.json" with { type: "json" } -import kanagawa from "./assets/kanagawa.json" with { type: "json" } -import lucentOrng from "./assets/lucent-orng.json" with { type: "json" } -import material from "./assets/material.json" with { type: "json" } -import matrix from "./assets/matrix.json" with { type: "json" } -import mercury from "./assets/mercury.json" with { type: "json" } -import monokai from "./assets/monokai.json" with { type: "json" } -import nightowl from "./assets/nightowl.json" with { type: "json" } -import nord from "./assets/nord.json" with { type: "json" } -import onedark from "./assets/one-dark.json" with { type: "json" } -import opencode from "./assets/opencode.json" with { type: "json" } -import orng from "./assets/orng.json" with { type: "json" } -import osakaJade from "./assets/osaka-jade.json" with { type: "json" } -import palenight from "./assets/palenight.json" with { type: "json" } -import rosepine from "./assets/rosepine.json" with { type: "json" } -import solarized from "./assets/solarized.json" with { type: "json" } -import synthwave84 from "./assets/synthwave84.json" with { type: "json" } -import tokyonight from "./assets/tokyonight.json" with { type: "json" } -import vercel from "./assets/vercel.json" with { type: "json" } -import vesper from "./assets/vesper.json" with { type: "json" } -import zenburn from "./assets/zenburn.json" with { type: "json" } +import { DEFAULT_THEMES, type ColorValue, type Theme, type ThemeColor, type ThemeJson } from "./v1" -export type Theme = { - readonly primary: RGBA - readonly secondary: RGBA - readonly accent: RGBA - readonly error: RGBA - readonly warning: RGBA - readonly success: RGBA - readonly info: RGBA - readonly text: RGBA - readonly textMuted: RGBA - readonly selectedListItemText: RGBA - readonly background: RGBA - readonly backgroundPanel: RGBA - readonly backgroundElement: RGBA - readonly backgroundMenu: RGBA - readonly border: RGBA - readonly borderActive: RGBA - readonly borderSubtle: RGBA - readonly diffAdded: RGBA - readonly diffRemoved: RGBA - readonly diffContext: RGBA - readonly diffHunkHeader: RGBA - readonly diffHighlightAdded: RGBA - readonly diffHighlightRemoved: RGBA - readonly diffAddedBg: RGBA - readonly diffRemovedBg: RGBA - readonly diffContextBg: RGBA - readonly diffLineNumber: RGBA - readonly diffAddedLineNumberBg: RGBA - readonly diffRemovedLineNumberBg: RGBA - readonly markdownText: RGBA - readonly markdownHeading: RGBA - readonly markdownLink: RGBA - readonly markdownLinkText: RGBA - readonly markdownCode: RGBA - readonly markdownBlockQuote: RGBA - readonly markdownEmph: RGBA - readonly markdownStrong: RGBA - readonly markdownHorizontalRule: RGBA - readonly markdownListItem: RGBA - readonly markdownListEnumeration: RGBA - readonly markdownImage: RGBA - readonly markdownImageText: RGBA - readonly markdownCodeBlock: RGBA - readonly syntaxComment: RGBA - readonly syntaxKeyword: RGBA - readonly syntaxFunction: RGBA - readonly syntaxVariable: RGBA - readonly syntaxString: RGBA - readonly syntaxNumber: RGBA - readonly syntaxType: RGBA - readonly syntaxOperator: RGBA - readonly syntaxPunctuation: RGBA - readonly thinkingOpacity: number - _hasSelectedListItemText: boolean -} -type ThemeColor = Exclude - -export function selectedForeground(theme: Theme, bg?: RGBA): RGBA { - // If theme explicitly defines selectedListItemText, use it - if (theme._hasSelectedListItemText) { - return theme.selectedListItemText - } - - // For transparent backgrounds, calculate contrast based on the actual bg (or fallback to primary) - if (theme.background.a === 0) { - const targetColor = bg ?? theme.primary - const { r, g, b } = targetColor - const luminance = 0.299 * r + 0.587 * g + 0.114 * b - return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255) - } - - // Fall back to background color - return theme.background -} - -type HexColor = `#${string}` -type RefName = string -type Variant = { - dark: HexColor | RefName - light: HexColor | RefName -} -type ColorValue = HexColor | RefName | Variant | RGBA -export type ThemeJson = { - $schema?: string - defs?: Record - theme: Omit, "selectedListItemText" | "backgroundMenu"> & { - selectedListItemText?: ColorValue - backgroundMenu?: ColorValue - thinkingOpacity?: number - } -} - -export const DEFAULT_THEMES: Record = { - aura, - ayu, - catppuccin, - ["catppuccin-frappe"]: catppuccinFrappe, - ["catppuccin-macchiato"]: catppuccinMacchiato, - cobalt2, - cursor, - dracula, - everforest, - flexoki, - github, - gruvbox, - kanagawa, - material, - matrix, - mercury, - monokai, - nightowl, - nord, - ["one-dark"]: onedark, - ["osaka-jade"]: osakaJade, - opencode, - orng, - ["lucent-orng"]: lucentOrng, - palenight, - rosepine, - solarized, - synthwave84, - tokyonight, - vesper, - vercel, - zenburn, - carbonfox, -} +export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeJson } from "./v1" const pluginThemes: Record = {} let customThemes: Record = {} @@ -297,506 +138,3 @@ export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { thinkingOpacity, } as Theme } - -export function generateSyntax(theme: Theme) { - return SyntaxStyle.fromTheme(getSyntaxRules(theme)) -} - -function getSyntaxRules(theme: Theme) { - return [ - { - scope: ["default"], - style: { - foreground: theme.text, - }, - }, - { - scope: ["prompt"], - style: { - foreground: theme.accent, - }, - }, - { - scope: ["extmark.file"], - style: { - foreground: theme.warning, - bold: true, - }, - }, - { - scope: ["extmark.agent"], - style: { - foreground: theme.secondary, - bold: true, - }, - }, - { - scope: ["extmark.paste"], - style: { - foreground: selectedForeground(theme, theme.warning), - background: theme.warning, - bold: true, - }, - }, - { - scope: ["comment"], - style: { - foreground: theme.syntaxComment, - italic: true, - }, - }, - { - scope: ["comment.documentation"], - style: { - foreground: theme.syntaxComment, - italic: true, - }, - }, - { - scope: ["string", "symbol"], - style: { - foreground: theme.syntaxString, - }, - }, - { - scope: ["number", "boolean"], - style: { - foreground: theme.syntaxNumber, - }, - }, - { - scope: ["character.special"], - style: { - foreground: theme.syntaxString, - }, - }, - { - scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], - style: { - foreground: theme.syntaxKeyword, - italic: true, - }, - }, - { - scope: ["keyword.type"], - style: { - foreground: theme.syntaxType, - bold: true, - italic: true, - }, - }, - { - scope: ["keyword.function", "function.method"], - style: { - foreground: theme.syntaxFunction, - }, - }, - { - scope: ["keyword"], - style: { - foreground: theme.syntaxKeyword, - italic: true, - }, - }, - { - scope: ["keyword.import"], - style: { - foreground: theme.syntaxKeyword, - }, - }, - { - scope: ["operator", "keyword.operator", "punctuation.delimiter"], - style: { - foreground: theme.syntaxOperator, - }, - }, - { - scope: ["keyword.conditional.ternary"], - style: { - foreground: theme.syntaxOperator, - }, - }, - { - scope: ["variable", "variable.parameter", "function.method.call", "function.call"], - style: { - foreground: theme.syntaxVariable, - }, - }, - { - scope: ["variable.member", "function", "constructor"], - style: { - foreground: theme.syntaxFunction, - }, - }, - { - scope: ["type", "module"], - style: { - foreground: theme.syntaxType, - }, - }, - { - scope: ["constant"], - style: { - foreground: theme.syntaxNumber, - }, - }, - { - scope: ["property"], - style: { - foreground: theme.syntaxVariable, - }, - }, - { - scope: ["class"], - style: { - foreground: theme.syntaxType, - }, - }, - { - scope: ["parameter"], - style: { - foreground: theme.syntaxVariable, - }, - }, - { - scope: ["punctuation", "punctuation.bracket"], - style: { - foreground: theme.syntaxPunctuation, - }, - }, - { - scope: ["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin"], - style: { - foreground: theme.error, - }, - }, - { - scope: ["variable.super"], - style: { - foreground: theme.error, - }, - }, - { - scope: ["string.escape", "string.regexp"], - style: { - foreground: theme.syntaxKeyword, - }, - }, - { - scope: ["keyword.directive"], - style: { - foreground: theme.syntaxKeyword, - italic: true, - }, - }, - { - scope: ["punctuation.special"], - style: { - foreground: theme.syntaxOperator, - }, - }, - { - scope: ["keyword.modifier"], - style: { - foreground: theme.syntaxKeyword, - italic: true, - }, - }, - { - scope: ["keyword.exception"], - style: { - foreground: theme.syntaxKeyword, - italic: true, - }, - }, - // Markdown specific styles - { - scope: ["markup.heading"], - style: { - foreground: theme.markdownHeading, - bold: true, - }, - }, - { - scope: ["markup.heading.1"], - style: { - foreground: theme.markdownHeading, - bold: true, - underline: true, - }, - }, - { - scope: ["markup.heading.2"], - style: { - foreground: theme.markdownHeading, - bold: true, - }, - }, - { - scope: ["markup.heading.3"], - style: { - foreground: theme.markdownHeading, - bold: true, - }, - }, - { - scope: ["markup.heading.4"], - style: { - foreground: theme.markdownHeading, - bold: true, - }, - }, - { - scope: ["markup.heading.5"], - style: { - foreground: theme.markdownHeading, - bold: true, - }, - }, - { - scope: ["markup.heading.6"], - style: { - foreground: theme.markdownHeading, - bold: true, - }, - }, - { - scope: ["markup.bold", "markup.strong"], - style: { - foreground: theme.markdownStrong, - bold: true, - }, - }, - { - scope: ["markup.italic"], - style: { - foreground: theme.markdownEmph, - italic: true, - }, - }, - { - scope: ["markup.list"], - style: { - foreground: theme.markdownListItem, - }, - }, - { - scope: ["markup.quote"], - style: { - foreground: theme.markdownBlockQuote, - italic: true, - }, - }, - { - scope: ["markup.raw", "markup.raw.block"], - style: { - foreground: theme.markdownCode, - }, - }, - { - scope: ["markup.raw.inline"], - style: { - foreground: theme.markdownCode, - background: theme.background, - }, - }, - { - scope: ["markup.link"], - style: { - foreground: theme.markdownLink, - underline: true, - }, - }, - { - scope: ["markup.link.label"], - style: { - foreground: theme.markdownLinkText, - underline: true, - }, - }, - { - scope: ["markup.link.url"], - style: { - foreground: theme.markdownLink, - underline: true, - }, - }, - { - scope: ["label"], - style: { - foreground: theme.markdownLinkText, - }, - }, - { - scope: ["spell", "nospell"], - style: { - foreground: theme.text, - }, - }, - // Additional common highlight groups - { - scope: ["string.special", "string.special.url"], - style: { - foreground: theme.markdownLink, - underline: true, - }, - }, - { - scope: ["character"], - style: { - foreground: theme.syntaxString, - }, - }, - { - scope: ["float"], - style: { - foreground: theme.syntaxNumber, - }, - }, - { - scope: ["comment.error"], - style: { - foreground: theme.error, - italic: true, - bold: true, - }, - }, - { - scope: ["comment.warning"], - style: { - foreground: theme.warning, - italic: true, - bold: true, - }, - }, - { - scope: ["comment.todo", "comment.note"], - style: { - foreground: theme.info, - italic: true, - bold: true, - }, - }, - { - scope: ["namespace"], - style: { - foreground: theme.syntaxType, - }, - }, - { - scope: ["field"], - style: { - foreground: theme.syntaxVariable, - }, - }, - { - scope: ["type.definition"], - style: { - foreground: theme.syntaxType, - bold: true, - }, - }, - { - scope: ["keyword.export"], - style: { - foreground: theme.syntaxKeyword, - }, - }, - { - scope: ["attribute", "annotation"], - style: { - foreground: theme.warning, - }, - }, - { - scope: ["tag"], - style: { - foreground: theme.error, - }, - }, - { - scope: ["tag.attribute"], - style: { - foreground: theme.syntaxKeyword, - }, - }, - { - scope: ["tag.delimiter"], - style: { - foreground: theme.syntaxOperator, - }, - }, - { - scope: ["markup.strikethrough"], - style: { - foreground: theme.textMuted, - }, - }, - { - scope: ["markup.underline"], - style: { - foreground: theme.text, - underline: true, - }, - }, - { - scope: ["markup.list.checked"], - style: { - foreground: theme.success, - }, - }, - { - scope: ["markup.list.unchecked"], - style: { - foreground: theme.textMuted, - }, - }, - { - scope: ["diff.plus"], - style: { - foreground: theme.diffAdded, - background: theme.diffAddedBg, - }, - }, - { - scope: ["diff.minus"], - style: { - foreground: theme.diffRemoved, - background: theme.diffRemovedBg, - }, - }, - { - scope: ["diff.delta"], - style: { - foreground: theme.diffContext, - background: theme.diffContextBg, - }, - }, - { - scope: ["error"], - style: { - foreground: theme.error, - bold: true, - }, - }, - { - scope: ["warning"], - style: { - foreground: theme.warning, - bold: true, - }, - }, - { - scope: ["info"], - style: { - foreground: theme.info, - }, - }, - { - scope: ["debug"], - style: { - foreground: theme.textMuted, - }, - }, - ] -} diff --git a/packages/tui/src/theme/v1.ts b/packages/tui/src/theme/v1.ts new file mode 100644 index 0000000000..f921038373 --- /dev/null +++ b/packages/tui/src/theme/v1.ts @@ -0,0 +1,661 @@ +import { RGBA, SyntaxStyle } from "@opentui/core" +import aura from "./assets/aura.json" with { type: "json" } +import ayu from "./assets/ayu.json" with { type: "json" } +import carbonfox from "./assets/carbonfox.json" with { type: "json" } +import catppuccinFrappe from "./assets/catppuccin-frappe.json" with { type: "json" } +import catppuccinMacchiato from "./assets/catppuccin-macchiato.json" with { type: "json" } +import catppuccin from "./assets/catppuccin.json" with { type: "json" } +import cobalt2 from "./assets/cobalt2.json" with { type: "json" } +import cursor from "./assets/cursor.json" with { type: "json" } +import dracula from "./assets/dracula.json" with { type: "json" } +import everforest from "./assets/everforest.json" with { type: "json" } +import flexoki from "./assets/flexoki.json" with { type: "json" } +import github from "./assets/github.json" with { type: "json" } +import gruvbox from "./assets/gruvbox.json" with { type: "json" } +import kanagawa from "./assets/kanagawa.json" with { type: "json" } +import lucentOrng from "./assets/lucent-orng.json" with { type: "json" } +import material from "./assets/material.json" with { type: "json" } +import matrix from "./assets/matrix.json" with { type: "json" } +import mercury from "./assets/mercury.json" with { type: "json" } +import monokai from "./assets/monokai.json" with { type: "json" } +import nightowl from "./assets/nightowl.json" with { type: "json" } +import nord from "./assets/nord.json" with { type: "json" } +import onedark from "./assets/one-dark.json" with { type: "json" } +import opencode from "./assets/opencode.json" with { type: "json" } +import orng from "./assets/orng.json" with { type: "json" } +import osakaJade from "./assets/osaka-jade.json" with { type: "json" } +import palenight from "./assets/palenight.json" with { type: "json" } +import rosepine from "./assets/rosepine.json" with { type: "json" } +import solarized from "./assets/solarized.json" with { type: "json" } +import synthwave84 from "./assets/synthwave84.json" with { type: "json" } +import tokyonight from "./assets/tokyonight.json" with { type: "json" } +import vercel from "./assets/vercel.json" with { type: "json" } +import vesper from "./assets/vesper.json" with { type: "json" } +import zenburn from "./assets/zenburn.json" with { type: "json" } + +export type Theme = { + readonly primary: RGBA + readonly secondary: RGBA + readonly accent: RGBA + readonly error: RGBA + readonly warning: RGBA + readonly success: RGBA + readonly info: RGBA + readonly text: RGBA + readonly textMuted: RGBA + readonly selectedListItemText: RGBA + readonly background: RGBA + readonly backgroundPanel: RGBA + readonly backgroundElement: RGBA + readonly backgroundMenu: RGBA + readonly border: RGBA + readonly borderActive: RGBA + readonly borderSubtle: RGBA + readonly diffAdded: RGBA + readonly diffRemoved: RGBA + readonly diffContext: RGBA + readonly diffHunkHeader: RGBA + readonly diffHighlightAdded: RGBA + readonly diffHighlightRemoved: RGBA + readonly diffAddedBg: RGBA + readonly diffRemovedBg: RGBA + readonly diffContextBg: RGBA + readonly diffLineNumber: RGBA + readonly diffAddedLineNumberBg: RGBA + readonly diffRemovedLineNumberBg: RGBA + readonly markdownText: RGBA + readonly markdownHeading: RGBA + readonly markdownLink: RGBA + readonly markdownLinkText: RGBA + readonly markdownCode: RGBA + readonly markdownBlockQuote: RGBA + readonly markdownEmph: RGBA + readonly markdownStrong: RGBA + readonly markdownHorizontalRule: RGBA + readonly markdownListItem: RGBA + readonly markdownListEnumeration: RGBA + readonly markdownImage: RGBA + readonly markdownImageText: RGBA + readonly markdownCodeBlock: RGBA + readonly syntaxComment: RGBA + readonly syntaxKeyword: RGBA + readonly syntaxFunction: RGBA + readonly syntaxVariable: RGBA + readonly syntaxString: RGBA + readonly syntaxNumber: RGBA + readonly syntaxType: RGBA + readonly syntaxOperator: RGBA + readonly syntaxPunctuation: RGBA + readonly thinkingOpacity: number + _hasSelectedListItemText: boolean +} + +export type ThemeColor = Exclude +export type HexColor = `#${string}` +export type RefName = string +export type Variant = { + dark: HexColor | RefName + light: HexColor | RefName +} +export type ColorValue = HexColor | RefName | Variant | RGBA +export type ThemeJson = { + $schema?: string + defs?: Record + theme: Omit, "selectedListItemText" | "backgroundMenu"> & { + selectedListItemText?: ColorValue + backgroundMenu?: ColorValue + thinkingOpacity?: number + } +} + +export const DEFAULT_THEMES: Record = { + aura, + ayu, + catppuccin, + ["catppuccin-frappe"]: catppuccinFrappe, + ["catppuccin-macchiato"]: catppuccinMacchiato, + cobalt2, + cursor, + dracula, + everforest, + flexoki, + github, + gruvbox, + kanagawa, + material, + matrix, + mercury, + monokai, + nightowl, + nord, + ["one-dark"]: onedark, + ["osaka-jade"]: osakaJade, + opencode, + orng, + ["lucent-orng"]: lucentOrng, + palenight, + rosepine, + solarized, + synthwave84, + tokyonight, + vesper, + vercel, + zenburn, + carbonfox, +} + +export function selectedForeground(theme: Theme, bg?: RGBA): RGBA { + if (theme._hasSelectedListItemText) return theme.selectedListItemText + + if (theme.background.a === 0) { + const targetColor = bg ?? theme.primary + const { r, g, b } = targetColor + const luminance = 0.299 * r + 0.587 * g + 0.114 * b + return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255) + } + + return theme.background +} + +export function generateSyntax(theme: Theme) { + return SyntaxStyle.fromTheme(getSyntaxRules(theme)) +} + +function getSyntaxRules(theme: Theme) { + return [ + { + scope: ["default"], + style: { + foreground: theme.text, + }, + }, + { + scope: ["prompt"], + style: { + foreground: theme.accent, + }, + }, + { + scope: ["extmark.file"], + style: { + foreground: theme.warning, + bold: true, + }, + }, + { + scope: ["extmark.agent"], + style: { + foreground: theme.secondary, + bold: true, + }, + }, + { + scope: ["extmark.paste"], + style: { + foreground: selectedForeground(theme, theme.warning), + background: theme.warning, + bold: true, + }, + }, + { + scope: ["comment"], + style: { + foreground: theme.syntaxComment, + italic: true, + }, + }, + { + scope: ["comment.documentation"], + style: { + foreground: theme.syntaxComment, + italic: true, + }, + }, + { + scope: ["string", "symbol"], + style: { + foreground: theme.syntaxString, + }, + }, + { + scope: ["number", "boolean"], + style: { + foreground: theme.syntaxNumber, + }, + }, + { + scope: ["character.special"], + style: { + foreground: theme.syntaxString, + }, + }, + { + scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], + style: { + foreground: theme.syntaxKeyword, + italic: true, + }, + }, + { + scope: ["keyword.type"], + style: { + foreground: theme.syntaxType, + bold: true, + italic: true, + }, + }, + { + scope: ["keyword.function", "function.method"], + style: { + foreground: theme.syntaxFunction, + }, + }, + { + scope: ["keyword"], + style: { + foreground: theme.syntaxKeyword, + italic: true, + }, + }, + { + scope: ["keyword.import"], + style: { + foreground: theme.syntaxKeyword, + }, + }, + { + scope: ["operator", "keyword.operator", "punctuation.delimiter"], + style: { + foreground: theme.syntaxOperator, + }, + }, + { + scope: ["keyword.conditional.ternary"], + style: { + foreground: theme.syntaxOperator, + }, + }, + { + scope: ["variable", "variable.parameter", "function.method.call", "function.call"], + style: { + foreground: theme.syntaxVariable, + }, + }, + { + scope: ["variable.member", "function", "constructor"], + style: { + foreground: theme.syntaxFunction, + }, + }, + { + scope: ["type", "module"], + style: { + foreground: theme.syntaxType, + }, + }, + { + scope: ["constant"], + style: { + foreground: theme.syntaxNumber, + }, + }, + { + scope: ["property"], + style: { + foreground: theme.syntaxVariable, + }, + }, + { + scope: ["class"], + style: { + foreground: theme.syntaxType, + }, + }, + { + scope: ["parameter"], + style: { + foreground: theme.syntaxVariable, + }, + }, + { + scope: ["punctuation", "punctuation.bracket"], + style: { + foreground: theme.syntaxPunctuation, + }, + }, + { + scope: ["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin"], + style: { + foreground: theme.error, + }, + }, + { + scope: ["variable.super"], + style: { + foreground: theme.error, + }, + }, + { + scope: ["string.escape", "string.regexp"], + style: { + foreground: theme.syntaxKeyword, + }, + }, + { + scope: ["keyword.directive"], + style: { + foreground: theme.syntaxKeyword, + italic: true, + }, + }, + { + scope: ["punctuation.special"], + style: { + foreground: theme.syntaxOperator, + }, + }, + { + scope: ["keyword.modifier"], + style: { + foreground: theme.syntaxKeyword, + italic: true, + }, + }, + { + scope: ["keyword.exception"], + style: { + foreground: theme.syntaxKeyword, + italic: true, + }, + }, + // Markdown specific styles + { + scope: ["markup.heading"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.heading.1"], + style: { + foreground: theme.markdownHeading, + bold: true, + underline: true, + }, + }, + { + scope: ["markup.heading.2"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.heading.3"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.heading.4"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.heading.5"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.heading.6"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.bold", "markup.strong"], + style: { + foreground: theme.markdownStrong, + bold: true, + }, + }, + { + scope: ["markup.italic"], + style: { + foreground: theme.markdownEmph, + italic: true, + }, + }, + { + scope: ["markup.list"], + style: { + foreground: theme.markdownListItem, + }, + }, + { + scope: ["markup.quote"], + style: { + foreground: theme.markdownBlockQuote, + italic: true, + }, + }, + { + scope: ["markup.raw", "markup.raw.block"], + style: { + foreground: theme.markdownCode, + }, + }, + { + scope: ["markup.raw.inline"], + style: { + foreground: theme.markdownCode, + background: theme.background, + }, + }, + { + scope: ["markup.link"], + style: { + foreground: theme.markdownLink, + underline: true, + }, + }, + { + scope: ["markup.link.label"], + style: { + foreground: theme.markdownLinkText, + underline: true, + }, + }, + { + scope: ["markup.link.url"], + style: { + foreground: theme.markdownLink, + underline: true, + }, + }, + { + scope: ["label"], + style: { + foreground: theme.markdownLinkText, + }, + }, + { + scope: ["spell", "nospell"], + style: { + foreground: theme.text, + }, + }, + // Additional common highlight groups + { + scope: ["string.special", "string.special.url"], + style: { + foreground: theme.markdownLink, + underline: true, + }, + }, + { + scope: ["character"], + style: { + foreground: theme.syntaxString, + }, + }, + { + scope: ["float"], + style: { + foreground: theme.syntaxNumber, + }, + }, + { + scope: ["comment.error"], + style: { + foreground: theme.error, + italic: true, + bold: true, + }, + }, + { + scope: ["comment.warning"], + style: { + foreground: theme.warning, + italic: true, + bold: true, + }, + }, + { + scope: ["comment.todo", "comment.note"], + style: { + foreground: theme.info, + italic: true, + bold: true, + }, + }, + { + scope: ["namespace"], + style: { + foreground: theme.syntaxType, + }, + }, + { + scope: ["field"], + style: { + foreground: theme.syntaxVariable, + }, + }, + { + scope: ["type.definition"], + style: { + foreground: theme.syntaxType, + bold: true, + }, + }, + { + scope: ["keyword.export"], + style: { + foreground: theme.syntaxKeyword, + }, + }, + { + scope: ["attribute", "annotation"], + style: { + foreground: theme.warning, + }, + }, + { + scope: ["tag"], + style: { + foreground: theme.error, + }, + }, + { + scope: ["tag.attribute"], + style: { + foreground: theme.syntaxKeyword, + }, + }, + { + scope: ["tag.delimiter"], + style: { + foreground: theme.syntaxOperator, + }, + }, + { + scope: ["markup.strikethrough"], + style: { + foreground: theme.textMuted, + }, + }, + { + scope: ["markup.underline"], + style: { + foreground: theme.text, + underline: true, + }, + }, + { + scope: ["markup.list.checked"], + style: { + foreground: theme.success, + }, + }, + { + scope: ["markup.list.unchecked"], + style: { + foreground: theme.textMuted, + }, + }, + { + scope: ["diff.plus"], + style: { + foreground: theme.diffAdded, + background: theme.diffAddedBg, + }, + }, + { + scope: ["diff.minus"], + style: { + foreground: theme.diffRemoved, + background: theme.diffRemovedBg, + }, + }, + { + scope: ["diff.delta"], + style: { + foreground: theme.diffContext, + background: theme.diffContextBg, + }, + }, + { + scope: ["error"], + style: { + foreground: theme.error, + bold: true, + }, + }, + { + scope: ["warning"], + style: { + foreground: theme.warning, + bold: true, + }, + }, + { + scope: ["info"], + style: { + foreground: theme.info, + }, + }, + { + scope: ["debug"], + style: { + foreground: theme.textMuted, + }, + }, + ] +} diff --git a/packages/tui/src/theme/v2/component.ts b/packages/tui/src/theme/v2/component.ts new file mode 100644 index 0000000000..25a46c48ce --- /dev/null +++ b/packages/tui/src/theme/v2/component.ts @@ -0,0 +1,106 @@ +import type { RGBA } from "@opentui/core" +import type { Accessor } from "solid-js" +import type { ActionState, ActionVariant, ResolvedActionState, ResolvedThemeView } from "./index" + +export function createComponentTheme(current: Accessor) { + const textAction = actions((variant, state) => current().color.text.action[variant][state]) + const backgroundAction = actions((variant, state) => current().color.background.action[variant][state]) + const text = Object.assign(() => current().color.text.default, { + subdued: () => current().color.text.subdued, + action: textAction, + feedback: { + error: feedbackText("error"), + warning: feedbackText("warning"), + success: feedbackText("success"), + info: feedbackText("info"), + }, + }) + const background = Object.assign(() => current().color.background.default, { + action: backgroundAction, + feedback: { + error: () => current().color.background.feedback.error.default, + warning: () => current().color.background.feedback.warning.default, + success: () => current().color.background.feedback.success.default, + info: () => current().color.background.feedback.info.default, + }, + }) + const markdown = Object.assign(() => current().color.markdown.text, { + heading: () => current().color.markdown.heading, + link: () => current().color.markdown.link, + linkText: () => current().color.markdown.linkText, + code: () => current().color.markdown.code, + blockQuote: () => current().color.markdown.blockQuote, + emphasis: () => current().color.markdown.emphasis, + strong: () => current().color.markdown.strong, + horizontalRule: () => current().color.markdown.horizontalRule, + listItem: () => current().color.markdown.listItem, + listEnumeration: () => current().color.markdown.listEnumeration, + image: () => current().color.markdown.image, + imageText: () => current().color.markdown.imageText, + codeBlock: () => current().color.markdown.codeBlock, + }) + + function feedbackText(kind: "error" | "warning" | "success" | "info") { + return Object.assign(() => current().color.text.feedback[kind].default, { + subdued: () => current().color.text.feedback[kind].subdued, + }) + } + + return { + hue: () => current().hue, + color: { + text, + background, + border: () => current().color.border.default, + scrollbar: () => current().color.scrollbar.default, + diff: { + text: { + added: () => current().color.diff.text.added, + removed: () => current().color.diff.text.removed, + context: () => current().color.diff.text.context, + hunkHeader: () => current().color.diff.text.hunkHeader, + }, + background: { + added: () => current().color.diff.background.added, + removed: () => current().color.diff.background.removed, + context: () => current().color.diff.background.context, + }, + highlight: { + added: () => current().color.diff.highlight.added, + removed: () => current().color.diff.highlight.removed, + }, + lineNumber: { + text: () => current().color.diff.lineNumber.text, + background: { + added: () => current().color.diff.lineNumber.background.added, + removed: () => current().color.diff.lineNumber.background.removed, + }, + }, + }, + syntax: { + comment: () => current().color.syntax.comment, + keyword: () => current().color.syntax.keyword, + function: () => current().color.syntax.function, + variable: () => current().color.syntax.variable, + string: () => current().color.syntax.string, + number: () => current().color.syntax.number, + type: () => current().color.syntax.type, + operator: () => current().color.syntax.operator, + punctuation: () => current().color.syntax.punctuation, + }, + markdown, + }, + } +} + +function actions(get: (variant: ActionVariant, state: ResolvedActionState) => RGBA) { + const action = (variant: ActionVariant) => (state: ActionState | "default" = "default") => get(variant, state) + const primary = action("primary") + return Object.assign(primary, { + primary, + secondary: action("secondary"), + destructive: action("destructive"), + }) +} + +export type ComponentTheme = ReturnType diff --git a/packages/tui/src/theme/v2/defaults.ts b/packages/tui/src/theme/v2/defaults.ts new file mode 100644 index 0000000000..3396c8fe7a --- /dev/null +++ b/packages/tui/src/theme/v2/defaults.ts @@ -0,0 +1,391 @@ +import type { ThemeFile } from "./index" + +export const DEFAULT_THEME = { + version: 2, + light: { + hue: { + gray: { + 100: "#f3f4f6", + 200: "#e5e7eb", + 300: "#d1d5db", + 400: "#9ca3af", + 500: "#6b7280", + 600: "#4b5563", + 700: "#374151", + 800: "#1f2937", + 900: "#111827", + }, + red: { + 100: "#fee2e2", + 200: "#fecaca", + 300: "#fca5a5", + 400: "#f87171", + 500: "#ef4444", + 600: "#dc2626", + 700: "#b91c1c", + 800: "#991b1b", + 900: "#7f1d1d", + }, + orange: { + 100: "#ffedd5", + 200: "#fed7aa", + 300: "#fdba74", + 400: "#fb923c", + 500: "#f97316", + 600: "#ea580c", + 700: "#c2410c", + 800: "#9a3412", + 900: "#7c2d12", + }, + yellow: { + 100: "#fef9c3", + 200: "#fef08a", + 300: "#fde047", + 400: "#facc15", + 500: "#eab308", + 600: "#ca8a04", + 700: "#a16207", + 800: "#854d0e", + 900: "#713f12", + }, + green: { + 100: "#dcfce7", + 200: "#bbf7d0", + 300: "#86efac", + 400: "#4ade80", + 500: "#22c55e", + 600: "#16a34a", + 700: "#15803d", + 800: "#166534", + 900: "#14532d", + }, + cyan: { + 100: "#cffafe", + 200: "#a5f3fc", + 300: "#67e8f9", + 400: "#22d3ee", + 500: "#06b6d4", + 600: "#0891b2", + 700: "#0e7490", + 800: "#155e75", + 900: "#164e63", + }, + blue: { + 100: "#dbeafe", + 200: "#bfdbfe", + 300: "#93c5fd", + 400: "#60a5fa", + 500: "#3b82f6", + 600: "#2563eb", + 700: "#1d4ed8", + 800: "#1e40af", + 900: "#1e3a8a", + }, + purple: { + 100: "#f3e8ff", + 200: "#e9d5ff", + 300: "#d8b4fe", + 400: "#c084fc", + 500: "#a855f7", + 600: "#9333ea", + 700: "#7e22ce", + 800: "#6b21a8", + 900: "#581c87", + }, + accent: "$hue.blue", + neutral: "$hue.gray", + }, + color: { + text: { + default: "$hue.neutral.900", + subdued: "$hue.neutral.600", + action: { + primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" }, + secondary: { default: "$hue.neutral.900", $disabled: "$hue.neutral.500" }, + destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" }, + }, + feedback: { + error: { default: "$hue.red.700", subdued: "$hue.red.600" }, + warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" }, + success: { default: "$hue.green.700", subdued: "$hue.green.600" }, + info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" }, + }, + }, + background: { + default: "$hue.neutral.100", + action: { + primary: { + default: "$hue.accent.600", $hovered: "$hue.accent.700", $pressed: "$hue.accent.800", + $selected: "$hue.accent.700", $disabled: "$hue.neutral.300", + }, + secondary: { + default: "$hue.neutral.200", $hovered: "$hue.neutral.300", $pressed: "$hue.neutral.400", + $selected: "$hue.neutral.300", $disabled: "$hue.neutral.200", + }, + destructive: { + default: "$hue.red.600", $hovered: "$hue.red.700", $pressed: "$hue.red.800", + $selected: "$hue.red.700", $disabled: "$hue.neutral.300", + }, + }, + feedback: { + error: { default: "$color.background.default" }, + warning: { default: "$color.background.default" }, + success: { default: "$color.background.default" }, + info: { default: "$color.background.default" }, + }, + }, + border: { default: "$hue.neutral.300" }, + scrollbar: { default: "$hue.neutral.400" }, + diff: { + text: { + added: "$hue.green.700", removed: "$hue.red.700", context: "$hue.neutral.900", + hunkHeader: "$hue.purple.600", + }, + background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" }, + highlight: { added: "$hue.green.600", removed: "$hue.red.600" }, + lineNumber: { + text: "$hue.neutral.600", + background: { added: "$hue.green.200", removed: "$hue.red.200" }, + }, + }, + syntax: { + comment: "$hue.neutral.600", + keyword: "$hue.purple.600", + function: "$hue.accent.600", + variable: "$hue.neutral.900", + string: "$hue.green.700", + number: "$hue.yellow.800", + type: "$hue.yellow.500", + operator: "$hue.cyan.600", + punctuation: "$hue.neutral.900", + }, + markdown: { + text: "$hue.neutral.900", + heading: "$hue.purple.600", + link: "$hue.accent.600", + linkText: "$hue.cyan.600", + code: "$hue.green.700", + blockQuote: "$hue.neutral.600", + emphasis: "$hue.yellow.500", + strong: "$hue.neutral.900", + horizontalRule: "$hue.neutral.300", + listItem: "$hue.accent.600", + listEnumeration: "$hue.cyan.600", + image: "$hue.accent.600", + imageText: "$hue.cyan.600", + codeBlock: "$hue.neutral.900", + }, + }, + "@context:elevated": { + color: { + text: { action: { primary: { default: "$hue.neutral.100" } } }, + background: { + default: "$hue.neutral.200", + action: { primary: { default: "$hue.accent.500" } }, + }, + }, + }, + "@context:overlay": { + color: { + text: { action: { primary: { default: "$hue.neutral.100" } } }, + background: { + default: "$hue.neutral.300", + action: { primary: { default: "$hue.accent.500" } }, + }, + }, + }, + }, + dark: { + hue: { + gray: { + 100: "#f3f4f6", + 200: "#e5e7eb", + 300: "#d1d5db", + 400: "#9ca3af", + 500: "#6b7280", + 600: "#4b5563", + 700: "#374151", + 800: "#1f2937", + 900: "#111827", + }, + red: { + 100: "#fee2e2", + 200: "#fecaca", + 300: "#fca5a5", + 400: "#f87171", + 500: "#ef4444", + 600: "#dc2626", + 700: "#b91c1c", + 800: "#991b1b", + 900: "#7f1d1d", + }, + orange: { + 100: "#ffedd5", + 200: "#fed7aa", + 300: "#fdba74", + 400: "#fb923c", + 500: "#f97316", + 600: "#ea580c", + 700: "#c2410c", + 800: "#9a3412", + 900: "#7c2d12", + }, + yellow: { + 100: "#fef9c3", + 200: "#fef08a", + 300: "#fde047", + 400: "#facc15", + 500: "#eab308", + 600: "#ca8a04", + 700: "#a16207", + 800: "#854d0e", + 900: "#713f12", + }, + green: { + 100: "#dcfce7", + 200: "#bbf7d0", + 300: "#86efac", + 400: "#4ade80", + 500: "#22c55e", + 600: "#16a34a", + 700: "#15803d", + 800: "#166534", + 900: "#14532d", + }, + cyan: { + 100: "#cffafe", + 200: "#a5f3fc", + 300: "#67e8f9", + 400: "#22d3ee", + 500: "#06b6d4", + 600: "#0891b2", + 700: "#0e7490", + 800: "#155e75", + 900: "#164e63", + }, + blue: { + 100: "#dbeafe", + 200: "#bfdbfe", + 300: "#93c5fd", + 400: "#60a5fa", + 500: "#3b82f6", + 600: "#2563eb", + 700: "#1d4ed8", + 800: "#1e40af", + 900: "#1e3a8a", + }, + purple: { + 100: "#f3e8ff", + 200: "#e9d5ff", + 300: "#d8b4fe", + 400: "#c084fc", + 500: "#a855f7", + 600: "#9333ea", + 700: "#7e22ce", + 800: "#6b21a8", + 900: "#581c87", + }, + accent: "$hue.blue", + neutral: "$hue.gray", + }, + color: { + text: { + default: "$hue.neutral.100", + subdued: "$hue.neutral.400", + action: { + primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" }, + secondary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" }, + destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" }, + }, + feedback: { + error: { default: "$hue.red.300", subdued: "$hue.red.400" }, + warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" }, + success: { default: "$hue.green.300", subdued: "$hue.green.400" }, + info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" }, + }, + }, + background: { + default: "$hue.neutral.900", + action: { + primary: { + default: "$hue.accent.500", $hovered: "$hue.accent.600", $pressed: "$hue.accent.800", + $selected: "$hue.accent.600", $disabled: "$hue.neutral.800", + }, + secondary: { + default: "$hue.neutral.800", $hovered: "$hue.neutral.700", $pressed: "$hue.neutral.900", + $selected: "$hue.neutral.700", $disabled: "$hue.neutral.900", + }, + destructive: { + default: "$hue.red.600", $hovered: "$hue.red.700", $pressed: "$hue.red.800", + $selected: "$hue.red.700", $disabled: "$hue.neutral.800", + }, + }, + feedback: { + error: { default: "$color.background.default" }, + warning: { default: "$color.background.default" }, + success: { default: "$color.background.default" }, + info: { default: "$color.background.default" }, + }, + }, + border: { default: "$hue.neutral.700" }, + scrollbar: { default: "$hue.neutral.600" }, + diff: { + text: { + added: "$hue.green.300", removed: "$hue.red.300", context: "$hue.neutral.100", + hunkHeader: "$hue.purple.400", + }, + background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" }, + highlight: { added: "$hue.green.400", removed: "$hue.red.400" }, + lineNumber: { + text: "$hue.neutral.400", + background: { added: "$hue.green.800", removed: "$hue.red.800" }, + }, + }, + syntax: { + comment: "$hue.neutral.400", + keyword: "$hue.purple.400", + function: "$hue.accent.400", + variable: "$hue.neutral.100", + string: "$hue.green.300", + number: "$hue.yellow.200", + type: "$hue.yellow.500", + operator: "$hue.cyan.400", + punctuation: "$hue.neutral.100", + }, + markdown: { + text: "$hue.neutral.100", + heading: "$hue.purple.400", + link: "$hue.accent.400", + linkText: "$hue.cyan.400", + code: "$hue.green.300", + blockQuote: "$hue.neutral.400", + emphasis: "$hue.yellow.500", + strong: "$hue.neutral.100", + horizontalRule: "$hue.neutral.700", + listItem: "$hue.accent.400", + listEnumeration: "$hue.cyan.400", + image: "$hue.accent.400", + imageText: "$hue.cyan.400", + codeBlock: "$hue.neutral.100", + }, + }, + "@context:elevated": { + color: { + text: { action: { primary: { default: "$hue.neutral.100" } } }, + background: { + default: "$hue.neutral.800", + action: { primary: { default: "$hue.accent.400" } }, + }, + }, + }, + "@context:overlay": { + color: { + text: { action: { primary: { default: "$hue.neutral.900" } } }, + background: { + default: "$hue.neutral.700", + action: { primary: { default: "$hue.accent.400" } }, + }, + }, + }, + }, +} satisfies ThemeFile diff --git a/packages/tui/src/theme/v2/expand.ts b/packages/tui/src/theme/v2/expand.ts new file mode 100644 index 0000000000..34971c9581 --- /dev/null +++ b/packages/tui/src/theme/v2/expand.ts @@ -0,0 +1,97 @@ +import type { + BackgroundDefinition, + ModeDefinition, + StatefulColorDefinition, + TextDefinition, + ThemeTokensDefinition, +} from "./index" +import { ActionState } from "./schema" + +export function expandTheme(definition: Definition): Definition { + return { + ...definition, + ...expandTokens(definition), + ...Object.fromEntries( + Object.entries(definition) + .filter(([key]) => key.startsWith("@context:")) + .map(([key, value]) => [key, expandTokens(value as ThemeTokensDefinition)]), + ), + } +} + +export function expandTokens(definition: ThemeTokensDefinition): ThemeTokensDefinition { + if (!definition.color) return { ...definition } + return { + ...definition, + color: { + ...definition.color, + text: expandText(definition.color.text), + background: expandBackground(definition.color.background), + }, + } +} + +export function mergeTheme(...values: unknown[]): Record { + return values.reduce>((result, value) => { + if (!isRecord(value)) return result + return Object.entries(value).reduce>((next, [key, item]) => { + if (item === undefined || key === "mergeMode") return next + return { + ...next, + [key]: isRecord(item) ? mergeTheme(next[key], item) : item, + } + }, result) + }, {}) +} + +function expandText(definition: TextDefinition | undefined): TextDefinition | undefined { + if (!definition) return + return { + ...definition, + subdued: definition.subdued ?? (definition.default ? "$color.text.default" : undefined), + action: expandActions(definition.action, "color.text.action"), + feedback: definition.feedback + ? Object.fromEntries( + Object.entries(definition.feedback).map(([kind, feedback]) => { + return [ + kind, + { + ...feedback, + subdued: feedback.subdued ?? (feedback.default ? `$color.text.feedback.${kind}.default` : undefined), + }, + ] + }), + ) + : undefined, + } +} + +function expandBackground(definition: BackgroundDefinition | undefined): BackgroundDefinition | undefined { + if (!definition) return + return { ...definition, action: expandActions(definition.action, "color.background.action") } +} + +function expandActions>>( + definition: Definition | undefined, + path: string, +) { + if (!definition) return + return Object.fromEntries( + Object.entries(definition).map(([variant, value]) => { + if (!value?.default) return [variant, value] + return [ + variant, + { + ...value, + ...Object.fromEntries( + ActionState.literals.map((state) => [`$${state}`, value[`$${state}`] ?? `$${path}.${variant}.default`]), + ), + }, + ] + }), + ) as Definition +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/tui/src/theme/v2/fallback.ts b/packages/tui/src/theme/v2/fallback.ts new file mode 100644 index 0000000000..d37af9a968 --- /dev/null +++ b/packages/tui/src/theme/v2/fallback.ts @@ -0,0 +1,56 @@ +import type { ThemeTokensDefinition } from "./index" +import { ActionVariant, FeedbackKind } from "./schema" + +export function fallback(): ThemeTokensDefinition { + const red = "#ff0000" + + return { + color: { + text: { + default: red, + action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])), + feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])), + }, + background: { + default: red, + action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])), + feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])), + }, + border: { default: red }, + scrollbar: { default: red }, + diff: { + text: { added: red, removed: red, context: red, hunkHeader: red }, + background: { added: red, removed: red, context: red }, + highlight: { added: red, removed: red }, + lineNumber: { text: red, background: { added: red, removed: red } }, + }, + syntax: { + comment: red, + keyword: red, + function: red, + variable: red, + string: red, + number: red, + type: red, + operator: red, + punctuation: red, + }, + markdown: { + text: red, + heading: red, + link: red, + linkText: red, + code: red, + blockQuote: red, + emphasis: red, + strong: red, + horizontalRule: red, + listItem: red, + listEnumeration: red, + image: red, + imageText: red, + codeBlock: red, + }, + }, + } +} diff --git a/packages/tui/src/theme/v2/index.ts b/packages/tui/src/theme/v2/index.ts new file mode 100644 index 0000000000..f2901c6d60 --- /dev/null +++ b/packages/tui/src/theme/v2/index.ts @@ -0,0 +1,30 @@ +export { + ActionState, + type ActionStateKey, + ActionVariant, + BaseHue, + FeedbackKind, + HueAlias, + HueStep, + MarkdownDefinition, + MarkdownToken, + ModeDefinition, + SyntaxDefinition, + SyntaxToken, + ThemeDefinition, + ThemeFile, + type BackgroundDefinition, + type DiffDefinition, + type FileThemeDefinition, + type HueDefinition, + type HueOverrideDefinition, + type MergeModeDefinition, + type Mode, + type StatefulColorDefinition, + type ContextKey, + type TextDefinition, + type ThemeTokensDefinition, +} from "./schema" + +export type { Hue, HueScale, ResolvedActionState, ResolvedTheme, ResolvedThemeView, StatefulColor } from "./types" +export { migrateV1 } from "./v1-migrate" diff --git a/packages/tui/src/theme/v2/resolve.ts b/packages/tui/src/theme/v2/resolve.ts new file mode 100644 index 0000000000..d11e7c5ae3 --- /dev/null +++ b/packages/tui/src/theme/v2/resolve.ts @@ -0,0 +1,207 @@ +import { RGBA } from "@opentui/core" +import { Schema } from "effect" +import { DEFAULT_THEME } from "./defaults" +import { expandTheme, expandTokens, mergeTheme } from "./expand" +import { fallback } from "./fallback" +import { + ActionState, + ActionVariant, + BaseHue, + FeedbackKind, + HueAlias, + HueStep, + ThemeDefinition, + ThemeFile, +} from "./schema" +import type { + ActionStateKey, + HueDefinition, + HueScale, + ResolvedActionState, + ResolvedTheme, + ResolvedThemeView, + StatefulColorDefinition, + ThemeTokensDefinition, +} from "./index" +import { selectTheme, selectThemeMode } from "./select" + +const decodeThemeDefinition = Schema.decodeUnknownSync(ThemeDefinition) +const decodeThemeFile = Schema.decodeUnknownSync(ThemeFile) + +export function resolveThemeFile(file: ThemeFile, mode?: "light" | "dark") { + const decoded = decodeThemeFile(file) + const selected = selectThemeMode(decoded, mode) + const definition = selected.expanded ? selected.theme : expandTheme(selected.theme) + const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode)) + const core = expandTokens(fallback()) + const merged = decoded.standalone + ? mergeTheme(core, definition) + : mergeTheme(core, defaults, definition) + if (!merged["hue"]) throw new Error("Standalone themes must provide hues") + return resolveExpandedTheme(merged as ThemeDefinition) +} + +export function resolveTheme(definition: ThemeDefinition): ResolvedTheme { + return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition))) +} + +function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme { + const hue = resolveHue(definition.hue) + const base = tokens(definition) + const resolved = resolveView(base, hue) + const contexts = Object.fromEntries( + Object.entries(definition) + .filter(([key]) => key.startsWith("@context:")) + .map(([key, override]) => { + const contextual = contextualize(base, override as ThemeTokensDefinition) + return [key, resolveView(contextual, hue)] + }), + ) + + return { ...resolved, contexts } as ResolvedTheme +} + +function tokens(definition: ThemeDefinition): ThemeTokensDefinition { + return { + color: definition.color, + } +} + +function contextualize(base: ThemeTokensDefinition, override: ThemeTokensDefinition) { + const result = mergeTheme(base, override) + const baseText = base.color?.text?.action + const contextText = override.color?.text?.action + const baseBackground = base.color?.background?.action + const contextBackground = override.color?.background?.action + const color = result["color"] as NonNullable + return { + ...result, + color: { + ...color, + text: { ...color.text, action: contextualActions(baseText, contextText) }, + background: { ...color.background, action: contextualActions(baseBackground, contextBackground) }, + }, + } as ThemeTokensDefinition +} + +function contextualActions( + base: Partial> | undefined, + context: Partial> | undefined, +) { + return Object.fromEntries( + ActionVariant.literals.map((variant) => { + const baseVariant = base?.[variant] + const contextVariant = context?.[variant] + return [ + variant, + Object.fromEntries( + (["default", ...ActionState.literals] as readonly ResolvedActionState[]).map((state) => { + const key = state === "default" ? undefined : (`$${state}` as ActionStateKey) + return [ + key ?? "default", + (key ? contextVariant?.[key] : undefined) ?? + contextVariant?.default ?? + (key ? baseVariant?.[key] : undefined) ?? + baseVariant?.default, + ] + }), + ), + ] + }), + ) +} + +function resolveView(definition: ThemeTokensDefinition, hue: ResolvedThemeView["hue"]): ResolvedThemeView { + const source: Record = { hue, ...definition } + return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue } +} + +function resolveHue(definition: HueDefinition) { + const source = definition as Record + const cache = new Map() + const expected = new Set([...BaseHue.literals, ...HueAlias.literals]) + for (const name of Object.keys(source)) { + if (!expected.has(name)) throw new Error(`Unknown hue "${name}"`) + } + + function resolve(name: string, stack: string[]): HueScale { + const hit = cache.get(name) + if (hit) return hit + if (stack.includes(name)) throw new Error(`Circular hue reference: ${[...stack, name].join(" -> ")}`) + const value = source[name] + if (typeof value === "string") { + if ((BaseHue.literals as readonly string[]).includes(name)) throw new Error(`Base hue "${name}" must be a scale`) + const match = /^\$hue\.([^.]+)$/.exec(value) + if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`) + const result = resolve(match[1], [...stack, name]) + cache.set(name, result) + return result + } + if (!isRecord(value)) throw new Error(`Hue "${name}" was not found`) + const result = Object.fromEntries( + HueStep.literals.map((step) => { + const color = value[step] + if (typeof color !== "string" || !isHex(color)) throw new Error(`Invalid hue color at "hue.${name}.${step}"`) + return [step, RGBA.fromHex(color)] + }), + ) as HueScale + for (const step of Object.keys(value)) { + if (!HueStep.literals.includes(Number(step) as HueStep)) throw new Error(`Unknown hue step at "hue.${name}.${step}"`) + } + cache.set(name, result) + return result + } + + return Object.fromEntries( + [...BaseHue.literals, ...HueAlias.literals].map((name) => [name, resolve(name, [])]), + ) as ResolvedThemeView["hue"] +} + +function createResolver(source: Record) { + const cache = new Map() + + function resolve(value: unknown, path: string, stack: string[] = []): unknown { + if (value instanceof RGBA) return value + if (typeof value === "string") return resolveColor(value, path, stack) + if (typeof value === "number") return value + if (!isRecord(value)) throw new Error(`Invalid theme value at "${path}"`) + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [resolvedKey(key), resolve(item, `${path}.${key}`, stack)]), + ) + } + + function resolveColor(value: string, path: string, stack: string[]) { + if (isHex(value)) return RGBA.fromHex(value) + if (!value.startsWith("$")) throw new Error(`Invalid color "${value}" at "${path}"`) + const target = value.slice(1) + const hit = cache.get(target) + if (hit) return hit + if (stack.includes(target)) throw new Error(`Circular theme reference: ${[...stack, target].join(" -> ")}`) + const result = resolve(read(source, target), target, [...stack, target]) + if (!(result instanceof RGBA)) throw new Error(`Theme reference "${value}" at "${path}" is not a color`) + cache.set(target, result) + return result + } + + return (value: unknown, path: string) => resolve(value, path) +} + +function resolvedKey(key: string) { + if (!key.startsWith("$")) return key + const state = key.slice(1) + return (ActionState.literals as readonly string[]).includes(state) ? state : key +} + +function read(source: Record, path: string) { + const result = path.split(".").reduce((value, key) => (isRecord(value) ? value[key] : undefined), source) + if (result === undefined) throw new Error(`Theme reference "$${path}" was not found`) + return result +} + +function isHex(value: string) { + return /^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(value) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof RGBA) +} diff --git a/packages/tui/src/theme/v2/schema.ts b/packages/tui/src/theme/v2/schema.ts new file mode 100644 index 0000000000..f098767be0 --- /dev/null +++ b/packages/tui/src/theme/v2/schema.ts @@ -0,0 +1,214 @@ +import { Schema } from "effect" + +export const HueStep = Schema.Literals([100, 200, 300, 400, 500, 600, 700, 800, 900]) +export type HueStep = Schema.Schema.Type + +export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"]) +export type BaseHue = Schema.Schema.Type + +export const HueAlias = Schema.Literals(["accent", "neutral"]) +export type HueAlias = Schema.Schema.Type + +export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"]) +export type ActionVariant = Schema.Schema.Type + +export const ActionState = Schema.Literals(["hovered", "pressed", "selected", "focused", "disabled"]) +export type ActionState = Schema.Schema.Type +export type ActionStateKey = `$${ActionState}` + +export const FeedbackKind = Schema.Literals(["error", "warning", "success", "info"]) +export type FeedbackKind = Schema.Schema.Type + +const Mode = Schema.Literals(["light", "dark"]) +export type Mode = Schema.Schema.Type + +const HexColor = Schema.String.check(Schema.isPattern(/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i)) + +const ColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$", Schema.NonEmptyString])]) + +const HueName = Schema.Union([BaseHue, HueAlias]) +const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])]) + +const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"]) +export type ContextKey = Schema.Schema.Type + +const HueScaleDefinition = Schema.Record(HueStep, HexColor) +const HueAliasDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition]) + +const HueDefinition = Schema.Struct({ + gray: HueScaleDefinition, + red: HueScaleDefinition, + orange: HueScaleDefinition, + yellow: HueScaleDefinition, + green: HueScaleDefinition, + cyan: HueScaleDefinition, + blue: HueScaleDefinition, + purple: HueScaleDefinition, + accent: HueAliasDefinition, + neutral: HueAliasDefinition, +}) +export type HueDefinition = Schema.Schema.Type + +const HueOverrideDefinition = Schema.Struct({ + gray: Schema.optional(HueScaleDefinition), + red: Schema.optional(HueScaleDefinition), + orange: Schema.optional(HueScaleDefinition), + yellow: Schema.optional(HueScaleDefinition), + green: Schema.optional(HueScaleDefinition), + cyan: Schema.optional(HueScaleDefinition), + blue: Schema.optional(HueScaleDefinition), + purple: Schema.optional(HueScaleDefinition), + accent: Schema.optional(HueAliasDefinition), + neutral: Schema.optional(HueAliasDefinition), +}) +export type HueOverrideDefinition = Schema.Schema.Type + +const StatefulColorDefinition = Schema.Struct({ + default: Schema.optional(ColorValue), + $hovered: Schema.optional(ColorValue), + $pressed: Schema.optional(ColorValue), + $selected: Schema.optional(ColorValue), + $focused: Schema.optional(ColorValue), + $disabled: Schema.optional(ColorValue), +}) +export type StatefulColorDefinition = Schema.Schema.Type + +const ActionColorDefinition = Schema.Struct({ + primary: Schema.optional(StatefulColorDefinition), + secondary: Schema.optional(StatefulColorDefinition), + destructive: Schema.optional(StatefulColorDefinition), +}) + +const TextFeedbackDefinition = Schema.Struct({ + default: Schema.optional(ColorValue), + subdued: Schema.optional(ColorValue), +}) + +const BackgroundFeedbackDefinition = Schema.Struct({ + default: Schema.optional(ColorValue), +}) + +const TextDefinition = Schema.Struct({ + default: Schema.optional(ColorValue), + subdued: Schema.optional(ColorValue), + action: Schema.optional(ActionColorDefinition), + feedback: Schema.optional( + Schema.Struct({ + error: Schema.optional(TextFeedbackDefinition), + warning: Schema.optional(TextFeedbackDefinition), + success: Schema.optional(TextFeedbackDefinition), + info: Schema.optional(TextFeedbackDefinition), + }), + ), +}) +export type TextDefinition = Schema.Schema.Type + +const BackgroundDefinition = Schema.Struct({ + default: Schema.optional(ColorValue), + action: Schema.optional(ActionColorDefinition), + feedback: Schema.optional( + Schema.Struct({ + error: Schema.optional(BackgroundFeedbackDefinition), + warning: Schema.optional(BackgroundFeedbackDefinition), + success: Schema.optional(BackgroundFeedbackDefinition), + info: Schema.optional(BackgroundFeedbackDefinition), + }), + ), +}) +export type BackgroundDefinition = Schema.Schema.Type + +export const SyntaxToken = Schema.Literals([ + "comment", "keyword", "function", "variable", "string", "number", "type", "operator", "punctuation", +]) +export type SyntaxToken = Schema.Schema.Type +export const SyntaxDefinition = Schema.Record(SyntaxToken, Schema.optionalKey(HueColorValue)) +export type SyntaxDefinition = Schema.Schema.Type + +export const MarkdownToken = Schema.Literals([ + "text", "heading", "link", "linkText", "code", "blockQuote", "emphasis", "strong", "horizontalRule", "listItem", + "listEnumeration", "image", "imageText", "codeBlock", +]) +export type MarkdownToken = Schema.Schema.Type +export const MarkdownDefinition = Schema.Record(MarkdownToken, Schema.optionalKey(HueColorValue)) +export type MarkdownDefinition = Schema.Schema.Type + +const DiffDefinition = Schema.Struct({ + text: Schema.optional( + Schema.Struct({ + added: Schema.optional(ColorValue), + removed: Schema.optional(ColorValue), + context: Schema.optional(ColorValue), + hunkHeader: Schema.optional(ColorValue), + }), + ), + background: Schema.optional( + Schema.Struct({ + added: Schema.optional(ColorValue), + removed: Schema.optional(ColorValue), + context: Schema.optional(ColorValue), + }), + ), + highlight: Schema.optional( + Schema.Struct({ added: Schema.optional(ColorValue), removed: Schema.optional(ColorValue) }), + ), + lineNumber: Schema.optional( + Schema.Struct({ + text: Schema.optional(ColorValue), + background: Schema.optional( + Schema.Struct({ added: Schema.optional(ColorValue), removed: Schema.optional(ColorValue) }), + ), + }), + ), +}) +export type DiffDefinition = Schema.Schema.Type + +const ThemeTokensDefinition = Schema.Struct({ + color: Schema.optional( + Schema.Struct({ + text: Schema.optional(TextDefinition), + background: Schema.optional(BackgroundDefinition), + border: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })), + scrollbar: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })), + diff: Schema.optional(DiffDefinition), + syntax: Schema.optional(SyntaxDefinition), + markdown: Schema.optional(MarkdownDefinition), + }), + ), +}) +export type ThemeTokensDefinition = Schema.Schema.Type + +const ThemeDefinitionFields = Schema.Struct({ + hue: HueDefinition, + ...ThemeTokensDefinition.fields, + "@context:elevated": Schema.optional(ThemeTokensDefinition), + "@context:overlay": Schema.optional(ThemeTokensDefinition), +}) +export const ThemeDefinition = ThemeDefinitionFields +export type ThemeDefinition = Schema.Schema.Type + +const FileThemeDefinition = Schema.Struct({ + hue: Schema.optional(HueOverrideDefinition), + ...ThemeTokensDefinition.fields, + "@context:elevated": Schema.optional(ThemeTokensDefinition), + "@context:overlay": Schema.optional(ThemeTokensDefinition), +}) +export type FileThemeDefinition = Schema.Schema.Type + +const MergeModeDefinition = Schema.Struct({ + mergeMode: Schema.Literal(true), + hue: Schema.optional(HueOverrideDefinition), + ...ThemeTokensDefinition.fields, + "@context:elevated": Schema.optional(ThemeTokensDefinition), + "@context:overlay": Schema.optional(ThemeTokensDefinition), +}) +export type MergeModeDefinition = Schema.Schema.Type +export const ModeDefinition = Schema.Union([FileThemeDefinition, MergeModeDefinition]) +export type ModeDefinition = Schema.Schema.Type + +const FileMetadata = { + $schema: Schema.optional(Schema.String), + version: Schema.Literal(2), + standalone: Schema.optional(Schema.Boolean), +} +export const ThemeFile = Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: ModeDefinition }) +export type ThemeFile = Schema.Schema.Type diff --git a/packages/tui/src/theme/v2/select.ts b/packages/tui/src/theme/v2/select.ts new file mode 100644 index 0000000000..15017a7473 --- /dev/null +++ b/packages/tui/src/theme/v2/select.ts @@ -0,0 +1,37 @@ +import { expandTheme, mergeTheme } from "./expand" +import type { + FileThemeDefinition, + MergeModeDefinition, + Mode, + ModeDefinition, + ThemeDefinition, + ThemeFile, +} from "./index" + +export function selectTheme( + file: Omit & { light: ThemeDefinition; dark: ThemeDefinition }, + mode?: Mode, +): ThemeDefinition +export function selectTheme(file: ThemeFile, mode?: Mode): FileThemeDefinition +export function selectTheme(file: ThemeFile, mode?: Mode) { + return selectThemeMode(file, mode).theme +} + +export function selectThemeMode( + file: ThemeFile, + mode: Mode = "light", +): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } { + if (merges(file.light) && merges(file.dark)) throw new Error("Light and dark themes cannot both merge modes") + const selected = file[mode] + if (!merges(selected)) return { theme: selected, mode, expanded: false } + + const otherMode = mode === "light" ? "dark" : "light" + const other = file[otherMode] + const merged = mergeTheme(expandTheme(other), expandTheme(selected)) + if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${mode} merges modes`) + return { theme: merged as FileThemeDefinition, mode, expanded: true } +} + +function merges(definition: ModeDefinition): definition is MergeModeDefinition { + return "mergeMode" in definition && definition.mergeMode === true +} diff --git a/packages/tui/src/theme/v2/solid.ts b/packages/tui/src/theme/v2/solid.ts new file mode 100644 index 0000000000..2e6364c4e2 --- /dev/null +++ b/packages/tui/src/theme/v2/solid.ts @@ -0,0 +1,50 @@ +import { createComponent, createContext, useContext, type Accessor, type ParentProps } from "solid-js" +import { createComponentTheme, type ComponentTheme } from "./component" +import type { ContextKey, ResolvedTheme } from "./index" + +type ThemeRuntime = { + readonly resolved: Accessor + readonly component: ComponentTheme +} + +const ThemeContext = createContext() + +export function ThemeProvider(props: ParentProps<{ theme: ResolvedTheme }>) { + const resolved = () => props.theme + return createComponent(ThemeContext.Provider, { + value: { resolved, component: createComponentTheme(resolved) }, + get children() { + return props.children + }, + }) +} + +export function ContextProvider(props: ParentProps<{ context: ContextKey }>) { + const parent = runtime() + const context = () => { + const value = parent.resolved().contexts[props.context] + if (!value) throw new Error(`Theme context is not defined: ${props.context}`) + return value + } + context() + return createComponent(ThemeContext.Provider, { + value: { resolved: parent.resolved, component: createComponentTheme(context) }, + get children() { + return props.children + }, + }) +} + +export function useTheme() { + return runtime().component +} + +export function useResolvedTheme() { + return runtime().resolved +} + +function runtime() { + const context = useContext(ThemeContext) + if (!context) throw new Error("Theme context must be used within a ThemeProvider") + return context +} diff --git a/packages/tui/src/theme/v2/types.ts b/packages/tui/src/theme/v2/types.ts new file mode 100644 index 0000000000..19c8729d8a --- /dev/null +++ b/packages/tui/src/theme/v2/types.ts @@ -0,0 +1,56 @@ +import type { RGBA } from "@opentui/core" +import type { + ActionState, + ActionVariant, + BaseHue, + FeedbackKind, + HueAlias, + HueStep, + MarkdownToken, + ContextKey, + SyntaxToken, +} from "./schema" + +export type ResolvedActionState = "default" | ActionState +export type HueScale = Readonly> +export type Hue = Readonly> +export type StatefulColor = Readonly> + +export type ResolvedThemeView = { + readonly hue: Hue + readonly color: { + readonly text: { + readonly default: RGBA + readonly subdued: RGBA + readonly action: Readonly> + readonly feedback: Readonly> + } + readonly background: { + readonly default: RGBA + readonly action: Readonly> + readonly feedback: Readonly> + } + readonly border: { readonly default: RGBA } + readonly scrollbar: { readonly default: RGBA } + readonly diff: { + readonly text: { + readonly added: RGBA + readonly removed: RGBA + readonly context: RGBA + readonly hunkHeader: RGBA + } + readonly background: { readonly added: RGBA; readonly removed: RGBA; readonly context: RGBA } + readonly highlight: { readonly added: RGBA; readonly removed: RGBA } + readonly lineNumber: { + readonly text: RGBA + readonly background: { readonly added: RGBA; readonly removed: RGBA } + } + } + readonly syntax: Readonly> + readonly markdown: Readonly> + } +} + +export type ResolvedTheme = ResolvedThemeView & { + readonly contexts: Readonly>> +} diff --git a/packages/tui/src/theme/v2/v1-migrate.ts b/packages/tui/src/theme/v2/v1-migrate.ts new file mode 100644 index 0000000000..3b1bf67b81 --- /dev/null +++ b/packages/tui/src/theme/v2/v1-migrate.ts @@ -0,0 +1,213 @@ +import { RGBA } from "@opentui/core" +import type { Theme, ThemeJson } from "../index" +import type { ThemeFile } from "./index" + +type ThemeColor = Exclude + +export function migrateV1(theme: ThemeJson): ThemeFile { + return { + version: 2, + light: migrateMode(resolveV1(theme, "light")), + dark: migrateMode(resolveV1(theme, "dark")), + } +} + +function migrateMode(theme: Theme): ThemeFile["light"] { + const color = (key: ThemeColor) => hex(theme[key]) + const selected = hex(selectedForeground(theme, theme.primary)) + const destructive = hex(selectedForeground(theme, theme.error)) + + return { + hue: { accent: accentScale(theme.accent) }, + color: { + text: { + default: color("text"), + subdued: color("textMuted"), + action: { + primary: { default: selected }, + secondary: { + default: color("text"), + $hovered: color("text"), + $pressed: color("text"), + $selected: color("text"), + $focused: color("text"), + $disabled: color("textMuted"), + }, + destructive: { default: destructive }, + }, + feedback: { + error: { default: color("error") }, + warning: { default: color("warning") }, + success: { default: color("success") }, + info: { default: color("info") }, + }, + }, + background: { + default: color("background"), + action: { + primary: { default: color("primary") }, + secondary: { + default: color("backgroundMenu"), + $hovered: color("backgroundElement"), + $pressed: color("backgroundElement"), + $selected: color("backgroundMenu"), + $focused: color("backgroundElement"), + $disabled: color("backgroundMenu"), + }, + destructive: { default: color("error") }, + }, + }, + border: { default: color("border") }, + scrollbar: { default: color("borderActive") }, + diff: { + text: { + added: color("diffAdded"), + removed: color("diffRemoved"), + context: color("diffContext"), + hunkHeader: color("diffHunkHeader"), + }, + background: { + added: color("diffAddedBg"), + removed: color("diffRemovedBg"), + context: color("diffContextBg"), + }, + highlight: { added: color("diffHighlightAdded"), removed: color("diffHighlightRemoved") }, + lineNumber: { + text: color("diffLineNumber"), + background: { + added: color("diffAddedLineNumberBg"), + removed: color("diffRemovedLineNumberBg"), + }, + }, + }, + syntax: { + comment: color("syntaxComment"), + keyword: color("syntaxKeyword"), + function: color("syntaxFunction"), + variable: color("syntaxVariable"), + string: color("syntaxString"), + number: color("syntaxNumber"), + type: color("syntaxType"), + operator: color("syntaxOperator"), + punctuation: color("syntaxPunctuation"), + }, + markdown: { + text: color("markdownText"), + heading: color("markdownHeading"), + link: color("markdownLink"), + linkText: color("markdownLinkText"), + code: color("markdownCode"), + blockQuote: color("markdownBlockQuote"), + emphasis: color("markdownEmph"), + strong: color("markdownStrong"), + horizontalRule: color("markdownHorizontalRule"), + listItem: color("markdownListItem"), + listEnumeration: color("markdownListEnumeration"), + image: color("markdownImage"), + imageText: color("markdownImageText"), + codeBlock: color("markdownCodeBlock"), + }, + }, + "@context:elevated": { color: { background: { default: color("backgroundPanel") } } }, + "@context:overlay": { color: { background: { default: color("backgroundMenu") } } }, + } +} + +function resolveV1(theme: ThemeJson, mode: "dark" | "light"): Theme { + const defs = theme.defs ?? {} + + function resolveColor(value: unknown, chain: string[] = []): RGBA { + if (value instanceof RGBA) return value + if (typeof value === "string") { + if (value === "transparent" || value === "none") return RGBA.fromInts(0, 0, 0, 0) + if (value.startsWith("#")) return RGBA.fromHex(value) + if (chain.includes(value)) throw new Error(`Circular color reference: ${[...chain, value].join(" -> ")}`) + const next = defs[value] ?? theme.theme[value as ThemeColor] + if (next === undefined) throw new Error(`Color reference "${value}" not found in defs or theme`) + return resolveColor(next, [...chain, value]) + } + if (typeof value === "number") return ansi(value) + if (!value || typeof value !== "object" || !(mode in value)) throw new Error("Invalid V1 theme color") + return resolveColor((value as Record<"dark" | "light", unknown>)[mode], chain) + } + + const resolved = Object.fromEntries( + Object.entries(theme.theme) + .filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity") + .map(([key, value]) => [key, resolveColor(value)]), + ) as Partial> + const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined + resolved.selectedListItemText = hasSelectedListItemText + ? resolveColor(theme.theme.selectedListItemText) + : resolved.background + resolved.backgroundMenu = theme.theme.backgroundMenu + ? resolveColor(theme.theme.backgroundMenu) + : resolved.backgroundElement + + return { + ...resolved, + _hasSelectedListItemText: hasSelectedListItemText, + thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6, + } as Theme +} + +function selectedForeground(theme: Theme, background: RGBA) { + if (theme._hasSelectedListItemText) return theme.selectedListItemText + if (theme.background.a !== 0) return theme.background + return 0.299 * background.r + 0.587 * background.g + 0.114 * background.b > 0.5 + ? RGBA.fromInts(0, 0, 0) + : RGBA.fromInts(255, 255, 255) +} + +function accentScale(accent: RGBA) { + return { + 100: mix(accent, 255, 0.66), + 200: mix(accent, 255, 0.33), + 300: hex(accent), + 400: mix(accent, 0, 0.1), + 500: mix(accent, 0, 0.2), + 600: mix(accent, 0, 0.3), + 700: mix(accent, 0, 0.4), + 800: mix(accent, 0, 0.5), + 900: mix(accent, 0, 0.6), + } +} + +function mix(color: RGBA, target: number, amount: number) { + const [r, g, b, a] = color.toInts() + return hexInts( + Math.round(r + (target - r) * amount), + Math.round(g + (target - g) * amount), + Math.round(b + (target - b) * amount), + a, + ) +} + +function hex(color: RGBA) { + return hexInts(...color.toInts()) +} + +function hexInts(r: number, g: number, b: number, a: number) { + const byte = (value: number) => value.toString(16).padStart(2, "0") + return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}` +} + +function ansi(code: number) { + if (code < 16) { + const colors = [ + "#000000", "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#c0c0c0", + "#808080", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff", + ] + return RGBA.fromHex(colors[code] ?? "#000000") + } + if (code < 232) { + const index = code - 16 + const value = (part: number) => (part === 0 ? 0 : part * 40 + 55) + return RGBA.fromInts(value(Math.floor(index / 36)), value(Math.floor(index / 6) % 6), value(index % 6)) + } + if (code < 256) { + const gray = (code - 232) * 10 + 8 + return RGBA.fromInts(gray, gray, gray) + } + return RGBA.fromInts(0, 0, 0) +} diff --git a/packages/tui/src/ui/toast.tsx b/packages/tui/src/ui/toast.tsx index fdacec41de..4f68a781d4 100644 --- a/packages/tui/src/ui/toast.tsx +++ b/packages/tui/src/ui/toast.tsx @@ -24,7 +24,7 @@ export function Toast() { position="absolute" justifyContent="center" alignItems="flex-start" - top={2} + top={1} right={2} maxWidth={Math.min(60, dimensions().width - 6)} paddingLeft={2} diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index bad8245fcd..a6cd421ea1 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -1,6 +1,6 @@ import { expect, mock, test } from "bun:test" import { createTestRenderer } from "@opentui/core/testing" -import { Effect } from "effect" +import { Effect, FileSystem } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/core/global" import { createEventStream, createFetch, directory, json } from "./fixture/tui-client" @@ -33,7 +33,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { packages: { resolve: async () => undefined }, args: {}, log: () => {}, - }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), ) await ready process.emit("SIGHUP") @@ -105,7 +105,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after packages: { resolve: async () => undefined }, args: { sessionID: "dummy" }, log: () => {}, - }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), ) await initialTitleSet diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 493c290325..c35138676a 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -4,10 +4,10 @@ import { testRender } from "@opentui/solid" import type { OpenCodeEvent } from "@opencode-ai/client" import { SessionMessage } from "@opencode-ai/core/session/message" import { EventV2 } from "@opencode-ai/core/event" -import { onMount } from "solid-js" -import { ProjectProvider } from "../../../src/context/project" +import { createEffect, onMount, type ParentProps } from "solid-js" import { ClientProvider, useClient } from "../../../src/context/client" -import { DataProvider, useData } from "../../../src/context/data" +import { DataProvider as DataProviderBase, useData } from "../../../src/context/data" +import { LocationProvider, useLocation } from "../../../src/context/location" import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows" import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client" import { TestTuiContexts } from "../../fixture/tui-environment" @@ -32,6 +32,28 @@ function emitEvent(events: ReturnType, event: OpenCode events.emit({ ...event, location: { directory } }) } +function DataProvider(props: ParentProps) { + return ( + + + + {props.children} + + + ) +} + +function ProjectProvider(props: ParentProps) { + return props.children +} + +function SyncLocation() { + const data = useData() + const location = useLocation() + createEffect(() => location.set(data.location.default())) + return null +} + function durable(sessionID: string, seq?: number): { aggregateID: string; seq: number; version: 1 } function durable( sessionID: string, @@ -64,16 +86,13 @@ test("bootstraps MCP data for the TUI location", async () => { try { await wait(() => requests.length === 2) - expect(requests.map((url) => url.searchParams.get("location[directory]"))).toEqual([ - process.cwd(), - process.cwd(), - ]) + expect(requests.map((url) => url.searchParams.get("location[directory]"))).toEqual([directory, directory]) } finally { app.renderer.destroy() } }) -test("refreshes MCP status when a connection settles during bootstrap", async () => { +test("syncs MCP status when a connection settles during bootstrap", async () => { const events = createEventStream() let mcpRequests = 0 let resolveModels!: (response: Response) => void @@ -190,9 +209,9 @@ test("refreshes resources into reactive getters", async () => { expect(data.session.get("ses_test")).toBeUndefined() expect(data.location.agent.list(location)).toBeUndefined() - await data.session.refresh("ses_test") - await data.session.message.refresh("ses_test") - await data.location.agent.refresh() + await data.session.sync("ses_test") + await data.session.message.sync("ses_test") + await data.location.agent.sync() expect(data.session.get("ses_test")?.title).toBe("Test session") expect(data.session.message.list("ses_test").map((message) => message.id)).toEqual(["msg_first", "msg_second"]) @@ -243,7 +262,7 @@ test("applies absolute usage events to session info", async () => { )) try { - await data.session.refresh(sessionID) + await data.session.sync(sessionID) emitEvent(events, { id: "evt_usage_2", created: 2, @@ -328,7 +347,7 @@ test("truncates committed revert messages without changing lifetime usage", asyn )) try { - await data.session.refresh(sessionID) + await data.session.sync(sessionID) emitEvent(events, { id: "evt_revert_boundary_started", created: 1, @@ -467,7 +486,7 @@ test("updates session location when moved", async () => { try { await mounted - await data.session.refresh("ses_test") + await data.session.sync("ses_test") emitEvent(events, { id: "evt_moved_1", created: 1, @@ -476,10 +495,12 @@ test("updates session location when moved", async () => { data: { sessionID: "ses_test", location: { directory: destination }, + projectID: "project-moved", subpath: "packages/cli", }, }) await wait(() => data.session.get("ses_test")?.location.directory === destination) + expect(data.session.get("ses_test")?.projectID).toBe("project-moved") expect(data.session.get("ses_test")?.subpath).toBe("packages/cli") } finally { app.renderer.destroy() @@ -525,7 +546,7 @@ test("restores running manual compaction before applying live deltas", async () )) try { - await data.session.message.refresh("session-compaction") + await data.session.message.sync("session-compaction") expect(data.session.message.get("session-compaction", "message-compaction")).toMatchObject({ type: "compaction", status: "running", @@ -548,7 +569,7 @@ test("restores running manual compaction before applying live deltas", async () } }) -test("reconnects the event stream and bootstraps fresh data", async () => { +test("reconnects the event stream and resyncs active data", async () => { const events = createEventStream() const requests = { active: 0, event: 0, message: 0, model: 0 } let resolveActive!: (response: Response) => void @@ -621,7 +642,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => { try { await wait(() => data.location.model.list()?.[0]?.id === "model-1") await wait(() => data.session.status("session-stale") === "running") - await data.session.message.refresh("session-stale") + await data.session.message.sync("session-stale") expect(data.session.message.get("session-stale", "message-stale")?.id).toBe("message-stale") expect(client.connection.status()).toBe("connected") expect(client.connection.attempt()).toBe(0) @@ -633,6 +654,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => { await wait(() => requests.active === 2 && client.connection.status() === "connected", 4000) resolveActive(json({ data: { "session-new": { type: "running" } } })) + void data.session.message.sync("session-stale") await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000) await wait(() => data.session.status("session-stale") === "idle") @@ -664,8 +686,10 @@ test("completes exploration when a queued prompt is promoted", async () => { if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) }, events) let rows!: ReturnType + let client!: ReturnType function Probe() { + client = useClient() rows = createSessionRows(() => sessionID) return } @@ -683,6 +707,7 @@ test("completes exploration when a queued prompt is promoted", async () => { )) try { + await wait(() => client.connection.status() === "connected") emitEvent(events, { id: "evt_step_started", created: 1, @@ -737,6 +762,55 @@ test("completes exploration when a queued prompt is promoted", async () => { } }) +test("classifies live tool rows independently of their call ID", async () => { + const events = createEventStream() + const sessionID = "session-tool-call-id" + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) + }, events) + let rows!: ReturnType + let client!: ReturnType + + function Probe() { + client = useClient() + rows = createSessionRows(() => sessionID) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => client.connection.status() === "connected") + emitEvent(events, { + id: "evt_tool_started", + created: 1, + type: "session.tool.input.started", + durable: durable(sessionID), + data: { + sessionID, + assistantMessageID: "message-assistant", + callID: "reasoning:0", + name: "bash", + }, + }) + + await wait(() => rows.length > 0) + expect(rows).toEqual([{ type: "part", ref: { messageID: "message-assistant", partID: "reasoning:0" } }]) + } finally { + app.renderer.destroy() + } +}) + test("removes committed revert messages from local state", async () => { const events = createEventStream() const sessionID = "session-revert" @@ -908,7 +982,7 @@ test("tracks session status from active sessions and execution events", async () try { await wait(() => data.session.status("session-active") === "running") expect(data.session.status("session-idle")).toBe("idle") - await data.session.refresh("session-live") + await data.session.sync("session-live") settled = true emitEvent(events, { @@ -975,7 +1049,7 @@ test("tracks session status from active sessions and execution events", async () }) await wait(() => data.session.status("session-live") === "idle") - await data.session.refresh("session-failed") + await data.session.sync("session-failed") emitEvent(events, { id: "evt_failed_execution_started", created: 0, @@ -1138,7 +1212,7 @@ test("tracks session status from active sessions and execution events", async () durable: durable("session-manual", 1), data: { sessionID: "session-manual", inputID: "message-compaction" }, }) - await wait(() => data.session.compaction.list("session-manual").includes("message-compaction")) + await wait(() => data.session.pending.list("session-manual").some((item) => item.id === "message-compaction")) emitEvent(events, { id: "evt_manual_compaction_started", created: 1, @@ -1156,10 +1230,8 @@ test("tracks session status from active sessions and execution events", async () const message = data.session.message.get("session-manual", "message-compaction") return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary" }) - expect(data.session.compaction.list("session-manual")).toEqual([]) - const compactionRow = manualRows.find( - (row) => row.type === "message" && row.messageID === "message-compaction", - ) + expect(data.session.pending.list("session-manual")).toEqual([]) + const compactionRow = manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction") emitEvent(events, { id: "evt_manual_compaction_ended", created: 3, @@ -1201,9 +1273,7 @@ test("tracks session status from active sessions and execution events", async () const message = data.session.message.get("session-live", "msg_compaction_started") return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary" }) - const autoCompactionRow = rows.find( - (row) => row.type === "message" && row.messageID === "msg_compaction_started", - ) + const autoCompactionRow = rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started") emitEvent(events, { id: "evt_compaction_ended", @@ -1255,9 +1325,11 @@ test("restores queued compaction from durable pending input", async () => { }, events) let data!: ReturnType let rows!: ReturnType + let client!: ReturnType function Probe() { data = useData() + client = useClient() rows = createSessionRows(() => sessionID) return } @@ -1275,8 +1347,9 @@ test("restores queued compaction from durable pending input", async () => { )) try { - await wait(() => data.session.compaction.list(sessionID).length === 2) - expect(data.session.compaction.list(sessionID)).toEqual([ + await wait(() => client.connection.status() === "connected") + await wait(() => data.session.pending.list(sessionID).length === 2) + expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual([ "message-compaction-queued", "message-compaction-later", ]) @@ -1313,8 +1386,8 @@ test("restores queued compaction from durable pending input", async () => { inputID: "message-compaction-queued", }, }) - await wait(() => data.session.compaction.list(sessionID).length === 1) - expect(data.session.compaction.list(sessionID)).toEqual(["message-compaction-later"]) + await wait(() => data.session.pending.list(sessionID).length === 1) + expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"]) emitEvent(events, { id: "evt_compaction_ended", @@ -1323,15 +1396,12 @@ test("restores queued compaction from durable pending input", async () => { durable: durable(sessionID, 5), data: { sessionID, reason: "manual", text: "Summary", recent: "" }, }) - expect(data.session.compaction.list(sessionID)).toEqual(["message-compaction-later"]) + expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"]) pending = [] - emitEvent(events, { - id: "evt_reconnected", - type: "server.connected", - data: {}, - }) - await wait(() => data.session.compaction.list(sessionID).length === 0) + data.session.pending.invalidate(sessionID) + await data.session.pending.sync(sessionID) + await wait(() => data.session.pending.list(sessionID).length === 0) } finally { app.renderer.destroy() } @@ -1643,7 +1713,7 @@ test("keeps shell state scoped to location", async () => { try { await wait(() => data.shell.list().some((shell) => shell.id === "sh_default")) - await data.shell.refresh({ directory: other }) + await data.shell.sync({ directory: other }) expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"]) expect(data.shell.list({ directory: other }).map((shell) => shell.id)).toEqual(["sh_other"]) @@ -1744,22 +1814,27 @@ test("adds and dismisses permission requests from live events", async () => { } }) -test("reconciles all pending permission requests when the event stream reconnects", async () => { +test("reconciles active session permissions when the event stream reconnects", async () => { const events = createEventStream() let requests = [ - { id: "per_old", sessionID: "ses_old", action: "read", resources: ["old.txt"] }, - { id: "per_keep", sessionID: "ses_keep", action: "shell", resources: ["bun test"] }, + { id: "per_old", sessionID: "ses_active", action: "read", resources: ["old.txt"] }, + { id: "per_keep", sessionID: "ses_active", action: "shell", resources: ["bun test"] }, ] let calls = 0 const fetch = createFetch((url) => { - if (url.pathname !== "/api/permission/request") return + if (url.pathname !== "/api/session/ses_active/permission") return calls++ - return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests }) + return json({ data: requests }) }, events) let data!: ReturnType function Probe() { data = useData() + const client = useClient() + createEffect(() => { + if (client.connection.status() !== "connected") return + void data.session.permission.sync("ses_active") + }) return } @@ -1776,15 +1851,12 @@ test("reconciles all pending permission requests when the event stream reconnect )) try { - await wait(() => data.session.permission.list("ses_old")?.[0]?.id === "per_old") - expect(data.session.permission.list("ses_keep")?.[0]?.id).toBe("per_keep") + await wait(() => data.session.permission.list("ses_active")?.length === 2) - requests = [{ id: "per_new", sessionID: "ses_new", action: "edit", resources: ["new.txt"] }] + requests = [{ id: "per_new", sessionID: "ses_active", action: "edit", resources: ["new.txt"] }] events.disconnect() - await wait(() => calls === 2 && data.session.permission.list("ses_new")?.[0]?.id === "per_new") - expect(data.session.permission.list("ses_old")).toBeUndefined() - expect(data.session.permission.list("ses_keep")).toBeUndefined() + await wait(() => calls === 2 && data.session.permission.list("ses_active")?.[0]?.id === "per_new") } finally { app.renderer.destroy() } @@ -1857,7 +1929,7 @@ test("adds, dismisses, and refreshes form requests", async () => { }) await wait(() => data.session.form.list("ses_1")?.length === 0) - await data.session.form.refresh("ses_1") + await data.session.form.sync("ses_1") expect(data.session.form.list("ses_1")?.map((form) => form.id)).toEqual(["frm_remote"]) } finally { app.renderer.destroy() @@ -1929,7 +2001,7 @@ test("tracks global forms by location", async () => { } }) -test("refreshes global forms for the requested location", async () => { +test("syncs global forms once for each requested location", async () => { const events = createEventStream() const requests: URL[] = [] const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" } @@ -1979,20 +2051,24 @@ test("refreshes global forms for the requested location", async () => { await wait(() => client.connection.status() === "connected" && requests.length > 0) requests.length = 0 - await data.session.form.refresh("global", { directory }) - await data.session.form.refresh("global", other) + await data.session.form.sync("global", { directory }) + await data.session.form.sync("global", other) - expect(requests).toHaveLength(2) - expect(requests[1]?.searchParams.get("location[directory]")).toBe(other.directory) - expect(requests[1]?.searchParams.get("location[workspace]")).toBe(other.workspaceID) + expect(requests).toHaveLength(1) + expect(requests[0]?.searchParams.get("location[directory]")).toBe(other.directory) + expect(requests[0]?.searchParams.get("location[workspace]")).toBe(other.workspaceID) expect(data.session.form.list("global", other)?.map((form) => form.id)).toEqual(["frm_other"]) expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual(["frm_default"]) + + data.session.form.invalidate("global", other) + await data.session.form.sync("global", other) + expect(requests).toHaveLength(2) } finally { app.renderer.destroy() } }) -test("refreshes global forms once per loaded location after reconnect", async () => { +test("resyncs global forms only for the active location after reconnect", async () => { const events = createEventStream() const requests: URL[] = [] const counts = new Map() @@ -2052,58 +2128,54 @@ test("refreshes global forms once per loaded location after reconnect", async () )) try { - await wait( - () => - data.session.form.list("global", home)?.[0]?.id === "frm_default_1" && - data.session.form.list("global", other)?.[0]?.id === "frm_other_1", - ) + await wait(() => data.session.form.list("global", home)?.[0]?.id === "frm_default_1") + await data.session.form.sync("global", other) + expect(data.session.form.list("global", other)?.[0]?.id).toBe("frm_other_1") expect(requests).toHaveLength(2) requests.length = 0 events.disconnect() - await wait( - () => - data.session.form.list("global", home)?.[0]?.id === "frm_default_2" && - data.session.form.list("global", other)?.[0]?.id === "frm_other_2", - 4000, - ) - expect(requests).toHaveLength(2) + await wait(() => data.session.form.list("global", home)?.[0]?.id === "frm_default_2", 4000) + expect(data.session.form.list("global", other)?.[0]?.id).toBe("frm_other_1") + expect(requests).toHaveLength(1) expect( requests.map((url) => [ url.searchParams.get("location[directory]") ?? directory, url.searchParams.get("location[workspace]") ?? undefined, ]), - ).toEqual([ - [home.directory, undefined], - [other.directory, other.workspaceID], - ]) + ).toEqual([[home.directory, undefined]]) } finally { app.renderer.destroy() } }) -test("reconciles all pending form requests when the event stream reconnects", async () => { +test("reconciles active session forms when the event stream reconnects", async () => { const events = createEventStream() let requests = [ - { id: "frm_old", sessionID: "ses_old", title: "Input requested", fields: formFields }, + { id: "frm_old", sessionID: "ses_active", title: "Input requested", fields: formFields }, { id: "frm_keep", - sessionID: "ses_keep", + sessionID: "ses_active", title: "Input requested", fields: [{ key: "authorization", type: "external" as const, url: "https://example.com" }], }, ] let calls = 0 const fetch = createFetch((url) => { - if (url.pathname !== "/api/form/request") return + if (url.pathname !== "/api/session/ses_active/form") return calls++ - return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests }) + return json({ data: requests }) }, events) let data!: ReturnType function Probe() { data = useData() + const client = useClient() + createEffect(() => { + if (client.connection.status() !== "connected") return + void data.session.form.sync("ses_active") + }) return } @@ -2120,15 +2192,12 @@ test("reconciles all pending form requests when the event stream reconnects", as )) try { - await wait(() => data.session.form.list("ses_old")?.[0]?.id === "frm_old") - expect(data.session.form.list("ses_keep")?.[0]?.id).toBe("frm_keep") + await wait(() => data.session.form.list("ses_active")?.length === 2) - requests = [{ id: "frm_new", sessionID: "ses_new", title: "Input requested", fields: formFields }] + requests = [{ id: "frm_new", sessionID: "ses_active", title: "Input requested", fields: formFields }] events.disconnect() - await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new") - expect(data.session.form.list("ses_old")).toBeUndefined() - expect(data.session.form.list("ses_keep")).toBeUndefined() + await wait(() => calls === 2 && data.session.form.list("ses_active")?.[0]?.id === "frm_new") } finally { app.renderer.destroy() } @@ -2375,7 +2444,7 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn ]) expect(sync.session.input.list(sessionID)).toEqual([messageID]) - await sync.session.message.refresh(sessionID) + await sync.session.message.sync(sessionID) expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined() emitEvent(events, { @@ -2492,8 +2561,7 @@ function sessionInfo(id: string, parentID: string | undefined, cost = 0) { async function mountData(parents: Record, costs: Record = {}) { const calls = createFetch((url) => { const match = url.pathname.match(/^\/api\/session\/([^/]+)$/) - if (match && match[1] !== "active") - return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) }) + if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) }) }) let data!: ReturnType let ready!: () => void @@ -2523,13 +2591,13 @@ async function mountData(parents: Record, costs: Record { const { data, app } = await mountData({ child: "root" }) try { - await data.session.refresh("child") + await data.session.sync("child") // Parent info is absent, so the missing parent is the furthest-known ancestor. expect(data.session.root("child")).toBe("root") expect(data.session.family("child")).toEqual(["child"]) expect(data.session.family("root")).toEqual(["child"]) - await data.session.refresh("root") + await data.session.sync("root") expect(data.session.root("root")).toBe("root") // The tentative root entry folds into the now-known root's family. expect(data.session.family("child")).toEqual(["child", "root"]) @@ -2542,17 +2610,17 @@ test("groups an orphan child under its missing parent until the root arrives", a test("indexes arbitrarily deep nesting under a single root", async () => { const { data, app } = await mountData({ grandchild: "child", child: "root" }) try { - await data.session.refresh("grandchild") + await data.session.sync("grandchild") expect(data.session.root("grandchild")).toBe("child") expect(data.session.family("grandchild")).toEqual(["grandchild"]) - await data.session.refresh("child") + await data.session.sync("child") // grandchild's tentative family (keyed by the missing "child") merges up // toward the still-missing "root". expect(data.session.root("child")).toBe("root") expect(data.session.family("grandchild")).toEqual(["grandchild", "child"]) - await data.session.refresh("root") + await data.session.sync("root") expect(data.session.root("grandchild")).toBe("root") expect(data.session.root("child")).toBe("root") expect(data.session.family("root")).toEqual(["grandchild", "child", "root"]) @@ -2562,14 +2630,11 @@ test("indexes arbitrarily deep nesting under a single root", async () => { }) test("totals family cost for roots and keeps subagent cost scoped", async () => { - const { data, app } = await mountData( - { grandchild: "child", child: "root" }, - { root: 1, child: 2, grandchild: 3 }, - ) + const { data, app } = await mountData({ grandchild: "child", child: "root" }, { root: 1, child: 2, grandchild: 3 }) try { - await data.session.refresh("grandchild") - await data.session.refresh("child") - await data.session.refresh("root") + await data.session.sync("grandchild") + await data.session.sync("child") + await data.session.sync("root") expect(data.session.cost("root")).toBe(6) expect(data.session.cost("child")).toBe(2) @@ -2582,15 +2647,15 @@ test("totals family cost for roots and keeps subagent cost scoped", async () => test("re-registering an existing session is idempotent", async () => { const { data, app } = await mountData({ grandchild: "child", child: "root" }) try { - await data.session.refresh("grandchild") - await data.session.refresh("child") - await data.session.refresh("root") + await data.session.sync("grandchild") + await data.session.sync("child") + await data.session.sync("root") const before = data.session.family("root") expect(before).toEqual(["grandchild", "child", "root"]) - await data.session.refresh("child") - await data.session.refresh("root") - await data.session.refresh("grandchild") + await data.session.sync("child") + await data.session.sync("root") + await data.session.sync("grandchild") expect(data.session.family("root")).toEqual(before) expect(data.session.family("root")).toHaveLength(3) } finally { @@ -2601,8 +2666,8 @@ test("re-registering an existing session is idempotent", async () => { test("stops at the last non-repeating ancestor on a parent cycle", async () => { const { data, app } = await mountData({ x: "y", y: "x" }) try { - await data.session.refresh("x") - await data.session.refresh("y") + await data.session.sync("x") + await data.session.sync("y") // Does not hang; walking up from "y" stops before re-entering "x". expect(data.session.root("y")).toBe("x") expect(data.session.family("y")).toEqual(["x", "y"]) diff --git a/packages/tui/test/cli/tui/keymap-arguments.test.tsx b/packages/tui/test/cli/tui/keymap-arguments.test.tsx new file mode 100644 index 0000000000..a42da75fe8 --- /dev/null +++ b/packages/tui/test/cli/tui/keymap-arguments.test.tsx @@ -0,0 +1,44 @@ +/** @jsxImportSource @opentui/solid */ +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { ConfigProvider } from "../../../src/config" +import { Keymap } from "../../../src/context/keymap" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +test("dispatch passes slash input through the registered command", async () => { + const received: Array = [] + let dispatch: ReturnType["dispatch"] + + function Commands() { + const keymap = Keymap.use() + dispatch = keymap.dispatch + Keymap.createLayer(() => ({ + mode: "global", + commands: [ + { + id: "project.cd", + slash: { name: "cd", arguments: true }, + run: (input) => { + received.push(input) + }, + }, + ], + })) + return + } + + const app = await testRender(() => ( + + + + + + )) + try { + dispatch!("project.cd") + dispatch!("project.cd", "src/components with spaces") + expect(received).toEqual([undefined, "src/components with spaces"]) + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index b0f2f64fbc..e158a377b0 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -38,7 +38,7 @@ test("keeps non-exploration tools as individual part rows", () => { const messages: SessionMessageInfo[] = [ assistant("assistant-1", [ { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }, - { type: "tool", id: "bash-1", name: "bash", state: pending(), time: { created: 2 } }, + { type: "tool", id: "reasoning:0", name: "bash", state: pending(), time: { created: 2 } }, { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }, ]), ] @@ -51,7 +51,7 @@ test("keeps non-exploration tools as individual part rows", () => { completed: true, refs: [{ messageID: "assistant-1", partID: "read-1" }], }, - { type: "part", ref: { messageID: "assistant-1", partID: "bash-1" } }, + { type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } }, { type: "group", kind: "exploration", diff --git a/packages/tui/test/cli/tui/use-event.test.tsx b/packages/tui/test/cli/tui/use-event.test.tsx index 68b5f275c4..c26d2c876b 100644 --- a/packages/tui/test/cli/tui/use-event.test.tsx +++ b/packages/tui/test/cli/tui/use-event.test.tsx @@ -3,7 +3,6 @@ import { describe, expect, test } from "bun:test" import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" import { testRender } from "@opentui/solid" import { onMount } from "solid-js" -import { ProjectProvider, useProject } from "../../../src/context/project" import { ClientProvider, useClient } from "../../../src/context/client" import { useEvent } from "../../../src/context/event" import { createApi, createEventStream, createFetch } from "../../fixture/tui-client" @@ -53,49 +52,44 @@ function update(version: string): OpenCodeEvent { } async function mount( - reconnect?: (attempt: number) => Promise<{ api: OpenCodeClient }>, + reconnect?: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>, log?: LogSink, ) { const events = createEventStream() const calls = createFetch(undefined, events) const seen: OpenCodeEvent[] = [] const workspaces: Array = [] - let project!: ReturnType let client!: ReturnType let done!: () => void const ready = new Promise((resolve) => { done = resolve }) + const service = reconnect ? { reconnect, restart: () => Promise.resolve() } : undefined const app = await testRender(() => ( - - - { - project = ctx.project - client = ctx.client - await project.sync() - done() - }} - seen={seen} - workspaces={workspaces} - /> - + + { + client = ctx.client + done() + }} + seen={seen} + workspaces={workspaces} + /> )) await ready - return { app, events, emit: events.emit, project, client, seen, workspaces } + return { app, events, emit: events.emit, client, seen, workspaces } } function Probe(props: { seen: OpenCodeEvent[] workspaces: Array - onReady: (ctx: { project: ReturnType; client: ReturnType }) => void + onReady: (ctx: { client: ReturnType }) => void }) { - const project = useProject() const client = useClient() const event = useEvent() @@ -104,7 +98,7 @@ function Probe(props: { props.seen.push(evt) props.workspaces.push(workspace) }) - props.onReady({ project, client }) + props.onReady({ client }) }) return @@ -160,10 +154,9 @@ describe("useEvent", () => { }) test("delivers current project events regardless of active workspace", async () => { - const { app, emit, project, seen } = await mount() + const { app, emit, seen } = await mount() try { - project.workspace.set("ws_a") emit(event(vcs("ws"), { directory: "/tmp/other", project: projectID, workspace: "ws_b" })) await wait(() => seen.length === 1) @@ -175,10 +168,9 @@ describe("useEvent", () => { }) test("delivers truly global events even when a workspace is active", async () => { - const { app, emit, project, seen } = await mount() + const { app, emit, seen } = await mount() try { - project.workspace.set("ws_a") emit(event(update("1.2.3"), { directory: "global" })) await wait(() => seen.length === 1) @@ -194,8 +186,8 @@ describe("useEvent", () => { const replacementEvents = createEventStream() const replacementCalls = createFetch(undefined, replacementEvents) const replacement = { api: createApi(replacementCalls.fetch) } - const { app, events, client, seen } = await mount(async (attempt) => { - attempts.push(attempt) + const { app, events, client, seen } = await mount(async () => { + attempts.push(attempts.length + 1) return replacement }) @@ -246,4 +238,66 @@ describe("useEvent", () => { app.renderer.destroy() } }) + + test("backs off when a resolved event stream keeps failing", async () => { + let calls = 0 + const encoder = new TextEncoder() + const replacementCalls = createFetch((url) => { + if (url.pathname !== "/api/event") return undefined + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n')) + controller.close() + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + }) + const replacement = { + api: createApi(replacementCalls.fetch), + } + const { app, events, client } = await mount(async () => { + calls += 1 + return replacement + }) + + try { + await wait(() => client.connection.status() === "connected") + events.disconnect() + await Promise.race([ + wait(() => calls === 2), + Bun.sleep(500).then(() => { + throw new Error("resolved event stream did not retry immediately") + }), + ]) + await Bun.sleep(200) + expect(calls).toBe(2) + } finally { + app.renderer.destroy() + } + }) + + test("cancels pending endpoint resolution on cleanup", async () => { + let aborted = false + const { app, events, client } = await mount( + (signal) => + new Promise((_, reject) => { + signal.addEventListener( + "abort", + () => { + aborted = true + reject(signal.reason) + }, + { once: true }, + ) + }), + ) + + await wait(() => client.connection.status() === "connected") + events.disconnect() + await wait(() => client.connection.status() === "reconnecting") + app.renderer.destroy() + await wait(() => aborted) + }) }) diff --git a/packages/tui/test/theme/v2/component.test.ts b/packages/tui/test/theme/v2/component.test.ts new file mode 100644 index 0000000000..840bc41013 --- /dev/null +++ b/packages/tui/test/theme/v2/component.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test" +import { createSignal } from "solid-js" +import { createComponentTheme } from "../../../src/theme/v2/component" +import { DEFAULT_THEME } from "../../../src/theme/v2/defaults" +import { resolveTheme } from "../../../src/theme/v2/resolve" +import { selectTheme } from "../../../src/theme/v2/select" +import type { ContextKey } from "../../../src/theme/v2" + +test("provides reactive property, variant, state, and context accessors", () => { + const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light"))) + const [context, setContext] = createSignal() + const theme = createComponentTheme(() => { + const key = context() + return key ? resolved().contexts[key] ?? resolved() : resolved() + }) + + expect(theme.color.text()).toBe(resolved().color.text.default) + expect(theme.color.text.subdued()).toBe(resolved().color.text.subdued) + expect(theme.color.text.action()).toBe(resolved().color.text.action.primary.default) + expect(theme.color.text.action.primary("pressed")).toBe(resolved().color.text.action.primary.pressed) + expect(theme.color.background.action.secondary("disabled")).toBe( + resolved().color.background.action.secondary.disabled, + ) + expect(theme.color.scrollbar()).toBe(resolved().color.scrollbar.default) + expect(theme.color.diff.text.added()).toBe(resolved().color.diff.text.added) + + setContext("@context:elevated") + expect(theme.color.text()).toBe(resolved().contexts["@context:elevated"]!.color.text.default) + expect(theme.color.background.action.primary("selected")).toBe( + resolved().contexts["@context:elevated"]!.color.background.action.primary.selected, + ) + + setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark"))) + expect(theme.color.text()).toBe(resolved().contexts["@context:elevated"]!.color.text.default) +}) diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts new file mode 100644 index 0000000000..5718ce3565 --- /dev/null +++ b/packages/tui/test/theme/v2/resolve.test.ts @@ -0,0 +1,200 @@ +import { expect, test } from "bun:test" +import { RGBA } from "@opentui/core" +import { DEFAULT_THEME } from "../../../src/theme/v2/defaults" +import type { ThemeDefinition } from "../../../src/theme/v2" +import { resolveTheme, resolveThemeFile } from "../../../src/theme/v2/resolve" +import { selectTheme } from "../../../src/theme/v2/select" + +const light = selectTheme(DEFAULT_THEME, "light") +const dark = selectTheme(DEFAULT_THEME, "dark") + +test("resolves independent definitions and hue aliases", () => { + const lightTheme = resolveTheme(light) + const darkTheme = resolveTheme(dark) + + expect(lightTheme.hue.accent).toBe(lightTheme.hue.blue) + expect(lightTheme.hue.neutral).toBe(lightTheme.hue.gray) + expect(lightTheme.color.text.default).toBeInstanceOf(RGBA) + expect(darkTheme.color.background.default).toBeInstanceOf(RGBA) + expect(lightTheme.color.syntax.keyword).toBeInstanceOf(RGBA) + expect(lightTheme.color.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) + expect(lightTheme.contexts["@context:elevated"]?.color.background.action.primary.default).toBe( + lightTheme.hue.accent[500], + ) + expect(lightTheme.contexts["@context:elevated"]?.color.text.action.primary.default).toBe( + lightTheme.hue.neutral[100], + ) + expect(lightTheme.contexts["@context:overlay"]?.color.background.action.primary.default).toBe( + lightTheme.hue.accent[500], + ) + expect(lightTheme.contexts["@context:overlay"]?.color.text.action.primary.default).toBe( + lightTheme.hue.neutral[100], + ) + expect(darkTheme.contexts["@context:elevated"]?.color.background.action.primary.default).toBe( + darkTheme.hue.accent[400], + ) + expect(darkTheme.contexts["@context:elevated"]?.color.text.action.primary.default).toBe( + darkTheme.hue.neutral[100], + ) + expect(darkTheme.contexts["@context:overlay"]?.color.background.action.primary.default).toBe( + darkTheme.hue.accent[400], + ) + expect(darkTheme.contexts["@context:overlay"]?.color.text.action.primary.default).toBe( + darkTheme.hue.neutral[900], + ) +}) + +test("merges partial files with the selected OpenCode defaults", () => { + const theme = resolveThemeFile( + { + version: 2, + light: { + hue: light.hue, + color: { text: { default: "#123456" } }, + }, + dark: { hue: dark.hue }, + }, + "light", + ) + + expect(theme.color.text.default.toInts()).toEqual([18, 52, 86, 255]) + expect(theme.color.text.subdued.toInts()).toEqual([18, 52, 86, 255]) + expect(theme.color.background.action.destructive.pressed).toBeInstanceOf(RGBA) +}) + +test("expands user structural fallbacks before merging defaults", () => { + const expanded = resolveThemeFile( + { + version: 2, + light: { + hue: light.hue, + color: { background: { action: { primary: { default: "#123456" } } } }, + }, + dark: { hue: dark.hue }, + }, + "light", + ) + const isolatedState = resolveThemeFile( + { + version: 2, + light: { + hue: light.hue, + color: { background: { action: { primary: { $pressed: "#654321" } } } }, + }, + dark: { hue: dark.hue }, + }, + "light", + ) + + expect(expanded.color.background.action.primary.pressed.toInts()).toEqual([18, 52, 86, 255]) + expect(isolatedState.color.background.action.primary.pressed.toInts()).toEqual([101, 67, 33, 255]) + expect(isolatedState.color.background.action.primary.hovered.toInts()).toEqual( + resolveTheme(light).color.background.action.primary.hovered.toInts(), + ) +}) + +test("standalone themes skip OpenCode defaults and use the red core fallback", () => { + const file = { version: 2, standalone: true, light: { hue: light.hue }, dark: { hue: dark.hue } } as const + const lightTheme = resolveThemeFile(file, "light") + const darkTheme = resolveThemeFile(file, "dark") + + expect(lightTheme.color.text.default.toInts()).toEqual([255, 0, 0, 255]) + expect(lightTheme.color.background.default.toInts()).toEqual([255, 0, 0, 255]) + expect(darkTheme.color.text.default.toInts()).toEqual([255, 0, 0, 255]) + expect(darkTheme.color.background.default.toInts()).toEqual([255, 0, 0, 255]) +}) + +test("uses defaults for the selected mode when it merges the other mode", () => { + const theme = resolveThemeFile({ version: 2, light: { hue: light.hue }, dark: { mergeMode: true } }, "dark") + expect(theme.color.background.default.toInts()).toEqual(resolveTheme(dark).color.background.default.toInts()) +}) + +test("resolves matched action variants and states", () => { + const theme = resolveTheme(light) + + expect(theme.color.text.action.primary.pressed).toBeInstanceOf(RGBA) + expect(theme.color.background.action.primary.pressed).toBeInstanceOf(RGBA) + expect(theme.color.text.action.secondary.default).toBeInstanceOf(RGBA) + expect(theme.color.background.action.destructive.disabled).toBeInstanceOf(RGBA) +}) + +test("context overrides rewire semantic references and apply state precedence", () => { + const definition = override(light, { + color: { + text: { + default: "#111111", + action: { + primary: { default: "$color.text.default", $pressed: "#222222" }, + secondary: { default: "$color.text.default" }, + }, + }, + }, + "@context:elevated": { + color: { + text: { + default: "#333333", + action: { primary: { default: "#444444", $selected: "#555555" } }, + }, + }, + }, + }) + const theme = resolveTheme(definition) + const overlay = theme.contexts["@context:elevated"]! + + expect(overlay.color.text.default.toInts()).toEqual([51, 51, 51, 255]) + expect(overlay.color.text.action.secondary.default.toInts()).toEqual([51, 51, 51, 255]) + expect(overlay.color.text.action.primary.pressed.toInts()).toEqual([68, 68, 68, 255]) + expect(overlay.color.text.action.primary.selected.toInts()).toEqual([85, 85, 85, 255]) +}) + +test("rejects missing, base, and contextual reference cycles", () => { + expect(() => resolveTheme(override(light, { color: { text: { default: "$missing.color" } } }))).toThrow( + 'Theme reference "$missing.color" was not found', + ) + expect(() => + resolveTheme( + override(light, { + color: { text: { default: "$color.text.subdued", subdued: "$color.text.default" } }, + }), + ), + ).toThrow("Circular theme reference") + expect(() => + resolveTheme( + override(light, { + "@context:elevated": { color: { text: { default: "$color.text.default" } } }, + }), + ), + ).toThrow("Circular theme reference") +}) + +test("validates complete hues, resolved groups, and hue-only syntax", () => { + expect(() => + resolveTheme( + { + ...light, + hue: { ...light.hue, accent: "$hue.missing" }, + } as unknown as ThemeDefinition, + ), + ).toThrow("$hue.missing") + expect(() => + resolveTheme({ + ...light, + color: { ...light.color, syntax: { ...light.color?.syntax, keyword: "$color.text.default" } }, + } as unknown as ThemeDefinition), + ).toThrow("$color.text.default") +}) + +function override(base: ThemeDefinition, value: Partial) { + return merge(base, value) as ThemeDefinition +} + +function merge(...values: unknown[]): Record { + return values.reduce>((result, value) => { + if (!value || typeof value !== "object" || Array.isArray(value)) return result + for (const [key, item] of Object.entries(value)) { + if (item === undefined) continue + result[key] = item && typeof item === "object" && !Array.isArray(item) ? merge(result[key], item) : item + } + return result + }, {}) +} diff --git a/packages/tui/test/theme/v2/select.test.ts b/packages/tui/test/theme/v2/select.test.ts new file mode 100644 index 0000000000..eae10f9769 --- /dev/null +++ b/packages/tui/test/theme/v2/select.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test" +import type { HueDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2" +import { selectTheme, selectThemeMode } from "../../../src/theme/v2/select" + +const hue = {} as HueDefinition +const light = { hue, color: { text: { default: "#111111", subdued: "#222222" } } } satisfies ThemeDefinition +const dark = { hue, color: { text: { default: "#eeeeee", subdued: "#dddddd" } } } satisfies ThemeDefinition + +test("requires and selects independent light and dark themes", () => { + const file = { version: 2, light, dark } satisfies ThemeFile + expect(selectTheme(file)).toBe(light) + expect(selectTheme(file, "light")).toBe(light) + expect(selectTheme(file, "dark")).toBe(dark) + expect(selectThemeMode(file, "dark").mode).toBe("dark") +}) + +test("merges an expanded mode override over the other mode", () => { + const file = { + version: 2, + light, + dark: { mergeMode: true, color: { text: { default: "#ffffff" } } }, + } satisfies ThemeFile + const selected = selectTheme(file, "dark") + + expect(selected.hue).toBeDefined() + expect(selected.color?.text?.default).toBe("#ffffff") + expect(selected.color?.text?.subdued).toBe("$color.text.default") +}) + +test("rejects mutual mode merging", () => { + const file = { + version: 2, + light: { mergeMode: true }, + dark: { mergeMode: true }, + } satisfies ThemeFile + expect(() => selectTheme(file)).toThrow("cannot both merge") +}) diff --git a/packages/tui/test/theme/v2/types.test.ts b/packages/tui/test/theme/v2/types.test.ts new file mode 100644 index 0000000000..e95716278e --- /dev/null +++ b/packages/tui/test/theme/v2/types.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test" +import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2" + +const text = { + default: "$hue.neutral.900", + subdued: "$hue.neutral.600", + action: { + primary: { default: "$hue.neutral.100", $pressed: "$hue.neutral.200" }, + secondary: { default: "$hue.neutral.900" }, + destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" }, + }, + feedback: { + error: { default: "$hue.red.700", subdued: "$hue.red.600" }, + }, +} satisfies TextDefinition + +const background = { + default: "$hue.neutral.100", + action: { + primary: { default: "$hue.accent.600", $pressed: "$hue.accent.800" }, + secondary: { default: "$hue.neutral.200" }, + destructive: { default: "$hue.red.600" }, + }, + feedback: { error: { default: "$hue.red.100" } }, +} satisfies BackgroundDefinition + +const definition = { + hue: {} as ThemeDefinition["hue"], + color: { text, background, border: { default: "$hue.neutral.300" } }, + "@context:elevated": { + color: { + text: { default: "$hue.neutral.800" }, + background: { default: "$hue.neutral.200" }, + }, + }, + "@context:overlay": { color: { background: { default: "$hue.neutral.300" } } }, +} satisfies ThemeDefinition + +const file = { version: 2, light: definition, dark: definition } satisfies ThemeFile + +test("supports property-first definitions, variants, states, and contexts", () => { + expect(text.action.primary.$pressed).toBe("$hue.neutral.200") + expect(background.action.destructive.default).toBe("$hue.red.600") + expect(definition["@context:elevated"].color?.text?.default).toBe("$hue.neutral.800") + expect(definition["@context:overlay"].color?.background?.default).toBe("$hue.neutral.300") + expect(file.light).toBe(definition) +}) diff --git a/packages/tui/test/theme/v2/v1-migrate.test.ts b/packages/tui/test/theme/v2/v1-migrate.test.ts new file mode 100644 index 0000000000..1157077b50 --- /dev/null +++ b/packages/tui/test/theme/v2/v1-migrate.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "bun:test" +import { DEFAULT_THEMES, resolveTheme as resolveV1, selectedForeground } from "../../../src/theme" +import { resolveThemeFile } from "../../../src/theme/v2/resolve" +import { migrateV1 } from "../../../src/theme/v2/v1-migrate" + +test("migrates resolved V1 modes into literal V2 tokens", () => { + const migrated = migrateV1(DEFAULT_THEMES.opencode) + const legacy = resolveV1(DEFAULT_THEMES.opencode, "light") + const resolved = resolveThemeFile(migrated, "light") + + expect(migrated.standalone).toBeUndefined() + expect(migrated.light.hue?.accent).toBeObject() + if (typeof migrated.light.hue?.accent !== "object") throw new Error("Expected a concrete accent scale") + expect(migrated.light.hue.accent[300]).toBe(hex(legacy.accent)) + expect(migrated.light.color?.background?.default).toBe(hex(legacy.background)) + expect(migrated.light.color?.background?.action?.primary?.default).toBe(hex(legacy.primary)) + expect(migrated.light.color?.text?.action?.primary?.default).toBe(hex(selectedForeground(legacy, legacy.primary))) + expect(migrated.light.color?.scrollbar?.default).toBe(hex(legacy.borderActive)) + expect(migrated.light.color?.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg)) + expect(migrated.light.color?.markdown?.emphasis).toBe(hex(legacy.markdownEmph)) + expect(resolved.color.background.action.secondary.hovered.toInts()).toEqual(legacy.backgroundElement.toInts()) + expect(resolved.color.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts()) + expect(resolved.contexts["@context:elevated"]?.color.background.default.toInts()).toEqual( + legacy.backgroundPanel.toInts(), + ) + expect(resolved.contexts["@context:overlay"]?.color.background.default.toInts()).toEqual( + legacy.backgroundMenu.toInts(), + ) +}) + +test("preserves V1 selected foreground behavior on transparent backgrounds", () => { + const source = structuredClone(DEFAULT_THEMES.opencode) + source.theme.background = "transparent" + source.theme.primary = { light: "#ffffff", dark: "#000000" } + delete source.theme.selectedListItemText + const migrated = migrateV1(source) + + expect(migrated.light.color?.text?.action?.primary?.default).toBe("#000000") + expect(migrated.dark.color?.text?.action?.primary?.default).toBe("#ffffff") +}) + +test("retains V1 circular reference errors", () => { + const source = structuredClone(DEFAULT_THEMES.opencode) + source.defs = { ...source.defs, one: "two", two: "one" } + source.theme.primary = "one" + + expect(() => migrateV1(source)).toThrow("Circular color reference: one -> two -> one") +}) + +test("migrates every built-in V1 theme in both modes", () => { + for (const source of Object.values(DEFAULT_THEMES)) { + const migrated = migrateV1(source) + expect(resolveThemeFile(migrated, "light").color.text.default).toBeDefined() + expect(resolveThemeFile(migrated, "dark").color.text.default).toBeDefined() + } +}) + +function hex(color: { toInts(): [number, number, number, number] }) { + const [r, g, b, a] = color.toInts() + const byte = (value: number) => value.toString(16).padStart(2, "0") + return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}` +} diff --git a/packages/web/src/content/docs/ar/keybinds.mdx b/packages/web/src/content/docs/ar/keybinds.mdx index d40d49729e..792b82eac1 100644 --- a/packages/web/src/content/docs/ar/keybinds.mdx +++ b/packages/web/src/content/docs/ar/keybinds.mdx @@ -99,7 +99,6 @@ description: خصّص اختصارات لوحة المفاتيح. "history_next": "down", "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "display_thinking": "none" } } diff --git a/packages/web/src/content/docs/bs/keybinds.mdx b/packages/web/src/content/docs/bs/keybinds.mdx index 31fed59005..4b63d3cef2 100644 --- a/packages/web/src/content/docs/bs/keybinds.mdx +++ b/packages/web/src/content/docs/bs/keybinds.mdx @@ -99,7 +99,6 @@ OpenCode ima listu veza tipki koje možete prilagoditi putem `tui.json`. "history_next": "down", "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "display_thinking": "none" } } diff --git a/packages/web/src/content/docs/da/keybinds.mdx b/packages/web/src/content/docs/da/keybinds.mdx index 7b2f2a7f31..e06368dfc7 100644 --- a/packages/web/src/content/docs/da/keybinds.mdx +++ b/packages/web/src/content/docs/da/keybinds.mdx @@ -99,7 +99,6 @@ OpenCode har en liste over nøglebindinger, som du kan tilpasse gennem `tui.json "history_next": "down", "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "display_thinking": "none" } } diff --git a/packages/web/src/content/docs/de/keybinds.mdx b/packages/web/src/content/docs/de/keybinds.mdx index 26edf9bdbb..77a3790486 100644 --- a/packages/web/src/content/docs/de/keybinds.mdx +++ b/packages/web/src/content/docs/de/keybinds.mdx @@ -99,7 +99,6 @@ OpenCode verfügt über eine Liste von Tastenkombinationen, die Sie über `tui.j "history_next": "down", "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "display_thinking": "none" } } diff --git a/packages/web/src/content/docs/es/keybinds.mdx b/packages/web/src/content/docs/es/keybinds.mdx index 2fc57fa69f..da8b5bf4a0 100644 --- a/packages/web/src/content/docs/es/keybinds.mdx +++ b/packages/web/src/content/docs/es/keybinds.mdx @@ -99,7 +99,6 @@ OpenCode tiene una lista de combinaciones de teclas que puede personalizar a tra "history_next": "down", "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "display_thinking": "none" } } diff --git a/packages/web/src/content/docs/fr/keybinds.mdx b/packages/web/src/content/docs/fr/keybinds.mdx index feb4ba37f6..757edfe89a 100644 --- a/packages/web/src/content/docs/fr/keybinds.mdx +++ b/packages/web/src/content/docs/fr/keybinds.mdx @@ -99,7 +99,6 @@ OpenCode a une liste de raccourcis clavier que vous pouvez personnaliser via la "history_next": "down", "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "display_thinking": "none" } } diff --git a/packages/web/src/content/docs/it/keybinds.mdx b/packages/web/src/content/docs/it/keybinds.mdx index aef0a4c936..b8e8a5b063 100644 --- a/packages/web/src/content/docs/it/keybinds.mdx +++ b/packages/web/src/content/docs/it/keybinds.mdx @@ -99,7 +99,6 @@ OpenCode ha una lista di scorciatoie che puoi personalizzare tramite `tui.json`. "history_next": "down", "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "display_thinking": "none" } } diff --git a/packages/web/src/content/docs/ja/keybinds.mdx b/packages/web/src/content/docs/ja/keybinds.mdx index 8d2de3ba95..305d5b2034 100644 --- a/packages/web/src/content/docs/ja/keybinds.mdx +++ b/packages/web/src/content/docs/ja/keybinds.mdx @@ -99,7 +99,6 @@ OpenCode には、`tui.json` を通じてカスタマイズできるキーバイ "history_next": "down", "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "display_thinking": "none" } } diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 86f67dfd73..b496aadc07 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -159,7 +159,6 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "plugin_manager": "none", "plugin_install": "none", diff --git a/packages/web/src/content/docs/ko/keybinds.mdx b/packages/web/src/content/docs/ko/keybinds.mdx index b6a605f2eb..928186cd0c 100644 --- a/packages/web/src/content/docs/ko/keybinds.mdx +++ b/packages/web/src/content/docs/ko/keybinds.mdx @@ -99,7 +99,6 @@ OpenCode에는 `tui.json`을 통해 커스터마이즈할 수 있는 키바인 "history_next": "down", "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "display_thinking": "none" } } diff --git a/packages/web/src/content/docs/nb/keybinds.mdx b/packages/web/src/content/docs/nb/keybinds.mdx index 623f7aa241..87dbb717cf 100644 --- a/packages/web/src/content/docs/nb/keybinds.mdx +++ b/packages/web/src/content/docs/nb/keybinds.mdx @@ -99,7 +99,6 @@ OpenCode har en liste over tastebindinger som du kan tilpasse gjennom `tui.json` "history_next": "down", "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "display_thinking": "none" } } diff --git a/packages/web/src/content/docs/pl/keybinds.mdx b/packages/web/src/content/docs/pl/keybinds.mdx index 0d3068feaf..c4747a9369 100644 --- a/packages/web/src/content/docs/pl/keybinds.mdx +++ b/packages/web/src/content/docs/pl/keybinds.mdx @@ -99,7 +99,6 @@ OpenCode zawiera listę skrótów klawiszowych, które można dostosować za pom "history_next": "down", "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "display_thinking": "none" } } diff --git a/packages/web/src/content/docs/pt-br/keybinds.mdx b/packages/web/src/content/docs/pt-br/keybinds.mdx index e1f0f8e7ca..2c78ba125c 100644 --- a/packages/web/src/content/docs/pt-br/keybinds.mdx +++ b/packages/web/src/content/docs/pt-br/keybinds.mdx @@ -99,7 +99,6 @@ O opencode tem uma lista de atalhos de teclado que você pode personalizar atrav "history_next": "down", "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "display_thinking": "none" } } diff --git a/packages/web/src/content/docs/ru/keybinds.mdx b/packages/web/src/content/docs/ru/keybinds.mdx index bde4e15530..60696b5128 100644 --- a/packages/web/src/content/docs/ru/keybinds.mdx +++ b/packages/web/src/content/docs/ru/keybinds.mdx @@ -99,7 +99,6 @@ opencode имеет список сочетаний клавиш, которые "history_next": "down", "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "display_thinking": "none" } } diff --git a/packages/web/src/content/docs/th/keybinds.mdx b/packages/web/src/content/docs/th/keybinds.mdx index 2f5bcc5d74..339a697219 100644 --- a/packages/web/src/content/docs/th/keybinds.mdx +++ b/packages/web/src/content/docs/th/keybinds.mdx @@ -99,7 +99,6 @@ OpenCode มีรายการปุ่มลัดที่คุณปร "history_next": "down", "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "display_thinking": "none" } } diff --git a/packages/web/src/content/docs/tr/keybinds.mdx b/packages/web/src/content/docs/tr/keybinds.mdx index bbf8c3bfa9..15cca8b5cf 100644 --- a/packages/web/src/content/docs/tr/keybinds.mdx +++ b/packages/web/src/content/docs/tr/keybinds.mdx @@ -99,7 +99,6 @@ opencode, `tui.json` aracılığıyla özelleştirebileceğiniz bir tuş bağlan "history_next": "down", "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "display_thinking": "none" } } diff --git a/packages/web/src/content/docs/zh-cn/keybinds.mdx b/packages/web/src/content/docs/zh-cn/keybinds.mdx index 0989a30f57..067a432a4b 100644 --- a/packages/web/src/content/docs/zh-cn/keybinds.mdx +++ b/packages/web/src/content/docs/zh-cn/keybinds.mdx @@ -99,7 +99,6 @@ OpenCode 提供了一系列快捷键,您可以通过 `tui.json` 进行自定 "history_next": "down", "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "display_thinking": "none" } } diff --git a/packages/web/src/content/docs/zh-tw/keybinds.mdx b/packages/web/src/content/docs/zh-tw/keybinds.mdx index 61d902141a..6f0643aee4 100644 --- a/packages/web/src/content/docs/zh-tw/keybinds.mdx +++ b/packages/web/src/content/docs/zh-tw/keybinds.mdx @@ -99,7 +99,6 @@ OpenCode 提供了一系列快捷鍵,您可以透過 `tui.json` 進行自訂 "history_next": "down", "terminal_suspend": "ctrl+z", "terminal_title_toggle": "none", - "tips_toggle": "h", "display_thinking": "none" } } diff --git a/packages/www/content/docs/(docs)/providers.mdx b/packages/www/content/docs/(docs)/providers.mdx index c9b7000698..7e711f9dfd 100644 --- a/packages/www/content/docs/(docs)/providers.mdx +++ b/packages/www/content/docs/(docs)/providers.mdx @@ -140,7 +140,7 @@ provider has no Models.dev catalog entries: "acme": { "name": "Acme Gateway", "env": ["ACME_API_KEY"], - "package": "@opencode-ai/llm/providers/openai-compatible", + "package": "@opencode-ai/ai/providers/openai-compatible", "settings": { "baseURL": "https://llm.acme.example/v1" }, @@ -170,7 +170,7 @@ The `package` field supports two runtime contracts: | Form | Contract | | --- | --- | -| `"@opencode-ai/llm/providers/openai-compatible"` | A V2 native package exporting `model(modelID, settings)`. An npm specifier or absolute `file://` URL may use the same contract. | +| `"@opencode-ai/ai/providers/openai-compatible"` | A V2 native package exporting `model(modelID, settings)`. An npm specifier or absolute `file://` URL may use the same contract. | | `"aisdk:@ai-sdk/openai-compatible"` | An AI SDK provider package. The `aisdk:` prefix is required. | Native packages receive the merged `settings` plus the resolved `apiKey`, `headers`, `body`, and `limits`. AI SDK diff --git a/packages/www/content/docs/(docs)/troubleshooting.mdx b/packages/www/content/docs/(docs)/troubleshooting.mdx index dd3b4362fc..9696c1fdf0 100644 --- a/packages/www/content/docs/(docs)/troubleshooting.mdx +++ b/packages/www/content/docs/(docs)/troubleshooting.mdx @@ -4,8 +4,8 @@ description: "Diagnose OpenCode startup, server, and session issues." --- - You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read the - steps below, inspect its service and logs, and help identify the issue. + You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read + the steps below, inspect its service and logs, and help identify the issue. OpenCode runs as two processes: the TUI is a client, while a background server owns sessions, plugins, permissions, and @@ -31,10 +31,10 @@ If the service is stuck or unhealthy, restart it: opencode2 service restart ``` -From inside the TUI, run `/reload` to restart the managed service and reconnect: +From inside the TUI, run `/restart` to restart the managed service and reconnect: ```text -/reload +/restart ``` You can also stop and start it explicitly: @@ -45,8 +45,8 @@ opencode2 service start ``` - OpenCode normally discovers or starts the shared background service automatically. The service commands are only needed - when diagnosing its lifecycle. + OpenCode normally discovers or starts the shared background service automatically. The service commands are only + needed when diagnosing its lifecycle. ## Run an isolated session @@ -125,8 +125,8 @@ The database normally lives at: `OPENCODE_DB` can override the database location. - Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the daemon, - and make a backup before inspecting persistent data with external tools. + Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the + daemon, and make a backup before inspecting persistent data with external tools. ## Explicit servers diff --git a/packages/www/content/docs/build/client.mdx b/packages/www/content/docs/build/client.mdx index 0c251ca386..ace8cc573b 100644 --- a/packages/www/content/docs/build/client.mdx +++ b/packages/www/content/docs/build/client.mdx @@ -113,7 +113,7 @@ Node application: - `Service.discover()` returns a healthy registered endpoint without starting a process. -- `Service.start()` reuses a compatible service or starts one when needed. +- `Service.ensure()` returns a compatible service, starting one when needed. - `Service.stop()` stops the registered service. - `Service.headers(endpoint)` creates the authentication headers for a client. @@ -123,12 +123,13 @@ bun add @effect/platform-node ```ts import { NodeFileSystem } from "@effect/platform-node" -import { OpenCode, Service } from "@opencode-ai/client/effect" +import { OpenCode } from "@opencode-ai/client/effect" +import { Service } from "@opencode-ai/client/effect/service" import { Effect } from "effect" import { FetchHttpClient } from "effect/unstable/http" const program = Effect.gen(function* () { - const endpoint = yield* Service.start() + const endpoint = yield* Service.ensure() const client = yield* OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint), diff --git a/script/publish.ts b/script/publish.ts index 33ffef6532..7b5ca8a39d 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -38,6 +38,9 @@ await prepareReleaseFiles() console.log("\n=== schema ===\n") await $`bun ./packages/schema/script/publish.ts` +console.log("\n=== ai ===\n") +await $`bun ./packages/ai/script/publish.ts` + console.log("\n=== protocol ===\n") await $`bun ./packages/protocol/script/publish.ts` diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 4aae4391bd..e5ed230d51 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -287,7 +287,7 @@ Affected schema: - Core-owned typed tool registry contract. - Canonical tool output content and structured settlement schemas. -- Canonical tagged tool file sources in `@opencode-ai/llm`. +- Canonical tagged tool file sources in `@opencode-ai/ai`. - Durable tool called, progress, success, and failure events and projected assistant-tool states. Change: