diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 52eec90991..9501a1be65 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,4 +2,4 @@ blank_issues_enabled: false contact_links: - name: 💬 Discord Community url: https://discord.gg/opencode - about: For quick questions or real-time discussion. Note that issues are searchable and help others with the same question. + about: For support, troubleshooting, how-to questions, and real-time discussion. diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml deleted file mode 100644 index 8930ba693c..0000000000 --- a/.github/ISSUE_TEMPLATE/question.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: Question -description: Ask a question -body: - - type: textarea - id: question - attributes: - label: Question - description: What's your question? - validations: - required: true diff --git a/.github/workflows/compliance-close.yml b/.github/workflows/compliance-close.yml index 14e68701e5..a83824e5cf 100644 --- a/.github/workflows/compliance-close.yml +++ b/.github/workflows/compliance-close.yml @@ -34,10 +34,48 @@ jobs: const now = Date.now(); const twoHours = 2 * 60 * 60 * 1000; + const orgMemberAssociations = new Set(['OWNER', 'MEMBER']); + const agentLogin = 'opencode-agent[bot]'; + const { data: file } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/TEAM_MEMBERS', + ref: 'dev', + }); + const teamMembers = new Set( + Buffer.from(file.content, 'base64') + .toString() + .split('\n') + .map((line) => line.trim().toLowerCase()) + .filter(Boolean) + ); + + function isExempt(item) { + const login = item.user?.login?.toLowerCase(); + return ( + login === agentLogin || + orgMemberAssociations.has(item.author_association) || + (login && teamMembers.has(login)) + ); + } for (const item of items) { const isPR = !!item.pull_request; const kind = isPR ? 'PR' : 'issue'; + const login = item.user?.login; + + if (isExempt(item)) { + core.info(`Skipping ${kind} #${item.number}; author ${login || 'unknown'} is exempt`); + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: item.number, + name: 'needs:compliance', + }); + } catch (e) {} + continue; + } const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index 4648a2d0c3..3972247daf 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -17,12 +17,31 @@ jobs: with: fetch-depth: 1 + - name: Check exempt issue author + id: author + run: | + LOGIN="${{ github.event.issue.user.login }}" + ASSOCIATION="${{ github.event.issue.author_association }}" + + if [ "$LOGIN" = "opencode-agent[bot]" ] || + [ "$ASSOCIATION" = "OWNER" ] || + [ "$ASSOCIATION" = "MEMBER" ] || + grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - uses: ./.github/actions/setup-bun + if: steps.author.outputs.skip != 'true' - name: Install opencode + if: steps.author.outputs.skip != 'true' run: curl -fsSL https://opencode.ai/install | bash - name: Check duplicates and compliance + if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -38,6 +57,7 @@ jobs: opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created: Issue number: ${{ github.event.issue.number }} + Issue author association: ${{ github.event.issue.author_association }} Lookup this issue with gh issue view ${{ github.event.issue.number }}. @@ -49,6 +69,8 @@ jobs: Check whether the issue follows our contributing guidelines and issue templates. + If the issue author association is OWNER or MEMBER, skip this compliance check. Do not add the needs:compliance label for organization-owned issues. + This project has three issue templates that every issue MUST use one of: 1. Bug Report - requires a Description field with real content @@ -83,7 +105,7 @@ jobs: Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows: - If the issue is NOT compliant, start the comment with: + If the issue is NOT compliant and the author association is not OWNER or MEMBER, start the comment with: Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance @@ -129,12 +151,31 @@ jobs: with: fetch-depth: 1 + - name: Check exempt issue author + id: author + run: | + LOGIN="${{ github.event.issue.user.login }}" + ASSOCIATION="${{ github.event.issue.author_association }}" + + if [ "$LOGIN" = "opencode-agent[bot]" ] || + [ "$ASSOCIATION" = "OWNER" ] || + [ "$ASSOCIATION" = "MEMBER" ] || + grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - uses: ./.github/actions/setup-bun + if: steps.author.outputs.skip != 'true' - name: Install opencode + if: steps.author.outputs.skip != 'true' run: curl -fsSL https://opencode.ai/install | bash - name: Recheck compliance + if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -148,9 +189,12 @@ jobs: } run: | opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited. + Issue author association: ${{ github.event.issue.author_association }} Lookup this issue with gh issue view ${{ github.event.issue.number }}. + If the issue author association is OWNER or MEMBER, remove the needs:compliance label if present, delete the previous compliance comment if present, and do not post a new comment. + Re-check whether the issue now follows our contributing guidelines and issue templates. This project has three issue templates that every issue MUST use one of: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 29b5aa3047..037020c03a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -325,6 +325,7 @@ jobs: run: bun run build working-directory: packages/desktop env: + NODE_OPTIONS: --max-old-space-size=4096 OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 1e652104d6..be2e099d0e 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -9,6 +9,7 @@ on: - "bun.lock" - "packages/storybook/**" - "packages/ui/**" + - "packages/session-ui/**" pull_request: branches: [dev] paths: @@ -17,6 +18,7 @@ on: - "bun.lock" - "packages/storybook/**" - "packages/ui/**" + - "packages/session-ui/**" workflow_dispatch: concurrency: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4c36f41106..c69de1d93b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,10 +65,15 @@ jobs: - name: Run unit tests timeout-minutes: 20 - run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task + run: GITHUB_ACTIONS=false bun turbo test env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} + - name: Check generated client + if: runner.os == 'Linux' + working-directory: packages/client + run: bun run check:generated + - name: Run HttpApi exerciser gates if: runner.os == 'Linux' working-directory: packages/opencode @@ -99,7 +104,8 @@ jobs: - name: Setup Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: "24" + # Playwright 1.59 hangs while extracting Chromium with Node 24.16. + node-version: "24.15" - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index 27852a12ce..0350e43877 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -16,13 +16,32 @@ jobs: with: fetch-depth: 1 + - name: Check exempt issue author + id: author + run: | + LOGIN="${{ github.event.issue.user.login }}" + ASSOCIATION="${{ github.event.issue.author_association }}" + + if [ "$LOGIN" = "opencode-agent[bot]" ] || + [ "$ASSOCIATION" = "OWNER" ] || + [ "$ASSOCIATION" = "MEMBER" ] || + grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - name: Setup Bun + if: steps.author.outputs.skip != 'true' uses: ./.github/actions/setup-bun - name: Install opencode + if: steps.author.outputs.skip != 'true' run: curl -fsSL https://opencode.ai/install | bash - name: Triage issue + if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.opencode-version b/.opencode-version index 6d4057fbe4..7b3ca16c53 100644 --- a/.opencode-version +++ b/.opencode-version @@ -1 +1 @@ -v1.17.9 +v1.17.13 diff --git a/.prettierignore b/.prettierignore index a2a2776596..7c72d70225 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,4 @@ sst-env.d.ts packages/desktop/src/bindings.ts +packages/client/src/generated/ +packages/client/src/generated-effect/ diff --git a/AGENTS.md b/AGENTS.md index 02b1c4cb77..cd2327e888 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,6 @@ -- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. +- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`. +- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly. +- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server. - The default branch in this repo is `dev`. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. @@ -28,6 +30,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi - Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity - Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream - In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module. +- In Effect generators, bind services to named variables before calling methods. Do not use nested service yields such as `yield* (yield* Foo.Service).bar()`. Reduce total variable count by inlining when a value is only used once. @@ -137,7 +140,7 @@ const table = sqliteTable("session", { ## Testing -- Avoid mocks as much as possible +- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option. - Test actual implementation, do not duplicate logic into tests - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. @@ -152,7 +155,7 @@ const table = sqliteTable("session", { - Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op. - Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. - Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop. -- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work. -- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles. +- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary. +- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once. - Keep EventV2 replay owner claims separate from clustered Session execution ownership. - Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned. diff --git a/CONTEXT.md b/CONTEXT.md index 9df6a670d5..f11e48e95b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -24,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta _Avoid_: System update, system notification, raw text diff **Context Epoch**: -The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition. +The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline. **Baseline System Context**: The full **System Context** rendered at the start of a **Context Epoch**. @@ -39,6 +39,18 @@ An expected temporary inability to observe a **Context Source** value; the runti **Safe Provider-Turn Boundary**: The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically. +**Admitted Prompt**: +A durable user input accepted into the Session inbox but not yet included in **Session History**. + +**Prompt Promotion**: +The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**. + +**Provider Turn**: +One request to a model provider and the response projected from that request. + +**Session Drain**: +One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary. + **Model Tool Output**: The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit. @@ -52,9 +64,27 @@ _Avoid_: Request body, wire options **Generation Controls**: Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog. +**Native Continuation Metadata**: +Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier. + **PTY Environment**: The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory. +**OpenCode Client**: +The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers. +_Avoid_: Remote client + +**SDK Contract IR**: +The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter. + +**Embedded OpenCode**: +A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly. +_Avoid_: Local implementation + +**Page**: +A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction. +_Avoid_: Response envelope + ## Relationships - A **System Context** is an opaque carrier composed from zero or more **Context Sources**. @@ -67,6 +97,11 @@ The host-supplied environment overlay applied by the server when creating a PTY, - Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**. - Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes. - At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**. +- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**. +- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message. +- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once. +- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another. +- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity. - The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline. - Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion. - Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history. @@ -75,34 +110,78 @@ The host-supplied environment overlay applied by the server when creating a PTY, - Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed. - `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**. - `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked. -- `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context. -- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run. +- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable. - **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text. - Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**. - Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**. - Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location. - Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote. -- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move. - Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values. - The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**. - Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam. - Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool. -- Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent. -- A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged baseline. +- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn. +- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline. - Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy. - Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily. - Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry. - **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them. - The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later. - A **Context Epoch** begins with one immutable **Baseline System Context**. -- A **Context Epoch** durably records the effective agent that owns its **Baseline System Context**. - A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**. - A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. -- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache. -- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history. +- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history. +- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn. +- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility. - **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding. - **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing. - The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `KILO_TERMINAL`. +- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs. +- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately. +- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers. +- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property. +- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names. +- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server. +- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently. +- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR. +- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR. +- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime. +- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface. +- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy. +- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors. +- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division. +- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred. +- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly. +- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction. +- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client. +- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides. +- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation. +- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently. +- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol. +- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@kilocode/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client. +- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors. +- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names. +- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately. +- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes. +- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state. +- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior. +- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API. +- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed. +- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question. +- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented. +- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields. +- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor. +- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid. +- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary. +- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op. +- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry. +- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate. +- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata? +- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior. +- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics. +- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics. +- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session. +- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation. - A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. - When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply. @@ -119,6 +198,23 @@ The host-supplied environment overlay applied by the server when creating a PTY, - **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority. - Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads. +## Client contract architecture + +Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server. + +Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases. + +Before stabilizing the client API: + +- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM. +- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API. +- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract. +- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier. +- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change. +- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests. +- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly. +- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client. + ## Example dialogue > **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?" diff --git a/bun.lock b/bun.lock index f2a529b4f5..ebf0a2acc8 100644 --- a/bun.lock +++ b/bun.lock @@ -29,11 +29,16 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { + "@dnd-kit/abstract": "0.5.0", + "@dnd-kit/dom": "0.5.0", + "@dnd-kit/helpers": "0.5.0", + "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", "@pierre/trees": "1.0.0-beta.4", "@sentry/solid": "catalog:", @@ -58,7 +63,7 @@ "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "github:anomalyco/ghostty-web#main", + "ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553", "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:", @@ -78,6 +83,7 @@ "@types/luxon": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "tw-animate-css": "1.4.0", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-icons-spritesheet": "3.0.1", @@ -86,7 +92,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.17.9", + "version": "1.17.13", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -109,9 +115,32 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/client": { + "name": "@opencode-ai/client", + "dependencies": { + "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/schema": "workspace:*", + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/httpapi-codegen": "workspace:*", + "@opencode-ai/server": "workspace:*", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "effect": "catalog:", + }, + "peerDependencies": { + "effect": "4.0.0-beta.83", + }, + "optionalPeers": [ + "effect", + ], + }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -147,7 +176,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -174,7 +203,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -196,7 +225,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -220,7 +249,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -240,7 +269,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.17.9", + "version": "1.17.13", "bin": { "opencode": "./bin/opencode", }, @@ -276,6 +305,8 @@ "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", "@opencode-ai/llm": "workspace:*", + "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@openrouter/ai-sdk-provider": "2.9.0", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", @@ -286,6 +317,7 @@ "ai-gateway-provider": "3.1.2", "bun-pty": "0.4.8", "cross-spawn": "catalog:", + "diff": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", @@ -302,7 +334,7 @@ "npm-package-arg": "13.0.2", "semver": "^7.6.3", "turndown": "7.2.0", - "venice-ai-sdk-provider": "2.0.2", + "venice-ai-sdk-provider": "2.1.1", "which": "6.0.1", "xdg-basedir": "5.1.0", "zod": "catalog:", @@ -331,7 +363,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -385,7 +417,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -399,7 +431,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "effect": "catalog:", }, @@ -411,10 +443,11 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", + "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", "@pierre/diffs": "catalog:", "@solidjs/meta": "catalog:", @@ -442,7 +475,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -458,10 +491,10 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { - "@effect/platform-node": "4.0.0-beta.74", - "@effect/platform-node-shared": "4.0.0-beta.74", + "@effect/platform-node": "4.0.0-beta.83", + "@effect/platform-node-shared": "4.0.0-beta.83", }, "devDependencies": { "@tsconfig/node22": "catalog:", @@ -472,13 +505,26 @@ "typescript": "catalog:", }, "peerDependencies": { - "effect": "4.0.0-beta.74", + "effect": "4.0.0-beta.83", + }, + }, + "packages/httpapi-codegen": { + "name": "@opencode-ai/httpapi-codegen", + "dependencies": { + "effect": "catalog:", + "prettier": "3.6.2", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", }, }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { + "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", @@ -495,7 +541,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.17.9", + "version": "1.17.13", "bin": { "opencode": "./bin/opencode", }, @@ -534,6 +580,8 @@ "@openauthjs/openauth": "catalog:", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", @@ -589,7 +637,7 @@ "tree-sitter-powershell": "0.25.10", "turndown": "7.2.0", "ulid": "catalog:", - "venice-ai-sdk-provider": "2.0.2", + "venice-ai-sdk-provider": "2.1.1", "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "ws": "8.21.0", @@ -623,8 +671,9 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { + "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", "zod": "catalog:", @@ -649,6 +698,29 @@ "@opentui/solid", ], }, + "packages/protocol": { + "name": "@opencode-ai/protocol", + "dependencies": { + "@opencode-ai/schema": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, + "packages/schema": { + "name": "@opencode-ai/schema", + "dependencies": { + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/script": { "name": "@opencode-ai/script", "dependencies": { @@ -659,9 +731,23 @@ "@types/semver": "^7.5.8", }, }, + "packages/sdk-next": { + "name": "@opencode-ai/sdk-next", + "dependencies": { + "@opencode-ai/client": "workspace:*", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/server": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "cross-spawn": "catalog:", }, @@ -676,9 +762,10 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "@opencode-ai/core": "workspace:*", + "@opencode-ai/protocol": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:", }, @@ -688,9 +775,53 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/session-ui": { + "name": "@opencode-ai/session-ui", + "version": "1.17.13", + "dependencies": { + "@kobalte/core": "catalog:", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/ui": "workspace:*", + "@pierre/diffs": "catalog:", + "@shikijs/stream": "catalog:", + "@shikijs/transformers": "3.9.2", + "@solid-primitives/bounds": "0.1.3", + "@solid-primitives/event-listener": "2.4.5", + "@solid-primitives/media": "2.3.3", + "@solid-primitives/resize-observer": "2.1.3", + "@solidjs/meta": "catalog:", + "@solidjs/router": "catalog:", + "diff": "catalog:", + "dompurify": "3.3.1", + "fuzzysort": "catalog:", + "katex": "0.16.27", + "luxon": "catalog:", + "marked": "catalog:", + "marked-katex-extension": "5.1.6", + "marked-shiki": "catalog:", + "morphdom": "2.7.8", + "motion": "12.34.5", + "remeda": "catalog:", + "remend": "catalog:", + "shiki": "catalog:", + "solid-js": "catalog:", + "solid-list": "catalog:", + "strip-ansi": "7.1.2", + }, + "devDependencies": { + "@tsconfig/node22": "catalog:", + "@types/bun": "catalog:", + "@types/katex": "0.16.7", + "@types/luxon": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + }, + }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -703,7 +834,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "@ibm/plex": "6.4.1", "@opencode-ai/stats-core": "workspace:*", @@ -736,7 +867,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -755,7 +886,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -774,6 +905,7 @@ "packages/storybook": { "name": "@opencode-ai/storybook", "devDependencies": { + "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", "@storybook/addon-a11y": "^10.2.13", @@ -795,7 +927,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -822,11 +954,9 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "@kobalte/core": "catalog:", - "@opencode-ai/core": "workspace:*", - "@opencode-ai/sdk": "workspace:*", "@pierre/diffs": "catalog:", "@shikijs/stream": "catalog:", "@shikijs/transformers": "3.9.2", @@ -834,8 +964,6 @@ "@solid-primitives/event-listener": "2.4.5", "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", - "@solidjs/meta": "catalog:", - "@solidjs/router": "catalog:", "diff": "catalog:", "dompurify": "3.3.1", "fuzzysort": "catalog:", @@ -851,27 +979,33 @@ "remeda": "catalog:", "remend": "catalog:", "shiki": "catalog:", - "solid-js": "catalog:", "solid-list": "catalog:", "strip-ansi": "7.1.2", }, "devDependencies": { + "@solidjs/meta": "catalog:", "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", "@types/katex": "0.16.7", "@types/luxon": "catalog:", "@typescript/native-preview": "catalog:", + "solid-js": "catalog:", "tailwindcss": "catalog:", + "tw-animate-css": "1.4.0", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-icons-spritesheet": "3.0.1", "vite-plugin-solid": "catalog:", }, + "peerDependencies": { + "@solidjs/meta": "^0.29.0", + "solid-js": "^1.9.0", + }, }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.17.9", + "version": "1.17.13", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -917,6 +1051,7 @@ "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", @@ -935,9 +1070,9 @@ }, "catalog": { "@cloudflare/workers-types": "4.20251008.0", - "@effect/opentelemetry": "4.0.0-beta.74", - "@effect/platform-node": "4.0.0-beta.74", - "@effect/sql-sqlite-bun": "4.0.0-beta.74", + "@effect/opentelemetry": "4.0.0-beta.83", + "@effect/platform-node": "4.0.0-beta.83", + "@effect/sql-sqlite-bun": "4.0.0-beta.83", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", "@kobalte/core": "0.13.11", @@ -973,7 +1108,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-rc.2", "drizzle-orm": "1.0.0-rc.2", - "effect": "4.0.0-beta.74", + "effect": "4.0.0-beta.83", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -1324,17 +1459,31 @@ "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], + "@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="], + + "@dnd-kit/collision": ["@dnd-kit/collision@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ=="], + + "@dnd-kit/dom": ["@dnd-kit/dom@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw=="], + + "@dnd-kit/geometry": ["@dnd-kit/geometry@0.5.0", "", { "dependencies": { "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A=="], + + "@dnd-kit/helpers": ["@dnd-kit/helpers@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg=="], + + "@dnd-kit/solid": ["@dnd-kit/solid@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-IKDqVZICS0jEeUzpJMIIF61w0WA4zisyx9U7K7Skbmkb/kQSDa3lB0cOc0947RwSO+ALoxytRNOuoNfyOIm3lQ=="], + + "@dnd-kit/state": ["@dnd-kit/state@0.5.0", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w=="], + "@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], - "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.74", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.74" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-flpyqLPyr+THSe6ZCGRZl6hi+FqxbIXNSkslKGiRJAjbPabam9mSp7R3aC8biIMt6xE4Fd0LNfo4p2GplUkm2Q=="], + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.83", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.83" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-cPfCfp/ghu0itbX6Dqjdr4N0rbjng5ON4sUpnLHV5JJySG8zZpWmuOZLWIrfrNKT2ctYR1BYmp1aYCgkItaJLw=="], - "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.74", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.74", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74", "ioredis": "^5.7.0" } }, "sha512-/W16mKqxvhWINLjufzc0log1sl57exXQfwd+em398/zKCbmU3S7snXTDMN6w0ju2TtgK35qrsoGBXEochij6Sg=="], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.83", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.83", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83", "ioredis": "^5.7.0" } }, "sha512-RmpVGu/+X/Bif3/g1Rzj8oFzTOknoVB3yHCa0b179vytPpKe+Kj9ZwKNcAnKWqHUDkbSPBq1Ca60mvOHr2/+LQ=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.74", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-C6C2hXixNcZXLaFF2u7B/FtOsqpdY7luaPuiGFBJza0P7EnYDkwaT3kB6lv7l/qctmkADc24qOsSCWIKRbC4jg=="], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.83", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-+yr/+PJmKTgmJq1QOINSBPgLu7Cjc4CZcotBXnGjyDEizOmimFgTkN2B8PBJAKIKUWYWfobjXqC+58/VhhPKAw=="], - "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-RVMRVY7NhSoAp9cAAyy4TT6dt6NNZjOpWeqticoho9HNBukxQSUcu/kjcz4Iq9eoQfXadmepu8kZqtdZULM/fg=="], + "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.83", "", { "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-6OaxLsWffxkh9pXYUSyj/AxjVb9URY2rG9U6atjxClWy30Jx77R9Pm3Rrc7cQ63kQurePavEw1bQbzQ/SILiQQ=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], @@ -1772,6 +1921,8 @@ "@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"], + "@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"], + "@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"], "@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"], @@ -1798,16 +1949,26 @@ "@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"], + "@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"], + "@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"], "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], + "@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"], + + "@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"], + "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"], "@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"], + "@opencode-ai/sdk-next": ["@opencode-ai/sdk-next@workspace:packages/sdk-next"], + "@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"], + "@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"], + "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], "@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"], @@ -2148,6 +2309,8 @@ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + "@preact/signals-core": ["@preact/signals-core@1.14.3", "", {}, "sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw=="], + "@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="], "@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="], @@ -3366,7 +3529,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], + "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], @@ -3642,7 +3805,7 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], - "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="], + "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="], "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], @@ -5156,6 +5319,8 @@ "turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="], + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], @@ -5264,7 +5429,7 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.2", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.47", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.27" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-aoa05nI3BTK5aGbjBflq+Gfln2AHAkwNbWuGGvCzUIsOfp5Y3iPD4O4PUGDAEiWVJWbjpPn0KfDa0H/HebwsaA=="], + "venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.1.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.51", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.30" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-w3OHkuzzKZ3r2TOxER6myBYzZJNoDqol+DUHu3NnfBN/GETnUVxecZJab0CHQQ8GZc0jjzpFymepjcLDPS4SQg=="], "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], @@ -5828,6 +5993,8 @@ "@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/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], + "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], @@ -5898,6 +6065,10 @@ "@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], + "@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], + + "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], + "@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -6278,11 +6449,11 @@ "unzipper/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], - "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="], + "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SoPSkrL5cbNQnAljRsJ7pOzJ2FmWgnhC0lfFOda873ycCdFJL1A+h3Ib7mX2spcv3XnNaO13y/45/0RyqNWlIQ=="], "venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - "venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.32", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Kwj499fTcN9bP/AfGoPU7JWIXeP6VZqKI6omsH062c9E2G4gdjeJczkz4z/tYSkzYjLE2AI3DtZbMfs6D7vn2Q=="], "verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], @@ -6700,6 +6871,10 @@ "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], + "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], @@ -6898,6 +7073,10 @@ "unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="], + + "venice-ai-sdk-provider/@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="], + "venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], diff --git a/nix/hashes.json b/nix/hashes.json index b7231c92ab..a55fa08399 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-LOxTad/iCquvJyonFOcz6/rDTPNDmwyBnykhWZJ5GC4=", - "aarch64-linux": "sha256-iO+0vYhp+2x6ACmh5lQJ/2Ac4uZTqRZE/KhG3u0o6D8=", - "aarch64-darwin": "sha256-tpBydRbrJ+4QxmkGUt/BhME8q6ysCW/CXrsNshYgqDU=", - "x86_64-darwin": "sha256-QQcI6SK7WJ7dSkX6xZuSQPoUdwfoCaimVgoHCnrO0wY=" + "x86_64-linux": "sha256-Ulflihjr8JDRVyNxSchoqaey6z/12256zs1FOiw+4vo=", + "aarch64-linux": "sha256-wP6p30a9f7s3tua5qbIq/10OHdbJb8xUni5KxE3F+/k=", + "aarch64-darwin": "sha256-7+4skFiJ+tLSjNG6WR53bT/IektA5B7gEcWLYpkK4z8=", + "x86_64-darwin": "sha256-NL945zD4J6HomH5SF0XIO8ZFCbIh2MTsJFtTyuxm2Q0=" } } diff --git a/package.json b/package.json index 6b7c3a8b84..3d6e7453ba 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "packages/sdk/js" ], "catalog": { - "@effect/opentelemetry": "4.0.0-beta.74", - "@effect/platform-node": "4.0.0-beta.74", + "@effect/opentelemetry": "4.0.0-beta.83", + "@effect/platform-node": "4.0.0-beta.83", "@anthropic-ai/sandbox-runtime": "0.0.63", "@npmcli/arborist": "9.4.0", "@types/bun": "1.3.14", @@ -53,7 +53,7 @@ "dompurify": "3.4.2", "drizzle-kit": "1.0.0-rc.2", "drizzle-orm": "1.0.0-rc.2", - "effect": "4.0.0-beta.74", + "effect": "4.0.0-beta.83", "ai": "6.0.168", "cross-spawn": "7.0.6", "hono": "4.12.12", @@ -82,7 +82,7 @@ "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.12", "@opentui/keymap": "0.3.4", - "@effect/sql-sqlite-bun": "4.0.0-beta.74", + "@effect/sql-sqlite-bun": "4.0.0-beta.83", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", "sst": "4.13.1", @@ -165,12 +165,13 @@ "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", + "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", + "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "virtua@0.49.1": "patches/virtua@0.49.1.patch", "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", - "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "pacote@21.5.1": "patches/pacote@21.5.1.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch" }, - "version": "7.4.15", + "version": "7.4.16", "peerDependencies": {} } diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000000..8c53e47c7e --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,27 @@ +# @opencode-ai/client + +Private generation target for clients derived directly from OpenCode's authoritative Effect `HttpApi`. + +## Entrypoints + +- `@opencode-ai/client`: zero-Effect Promise client using `fetch`. +- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`. + +The generated surface includes every standard HTTP group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Custom transports such as the PTY WebSocket connection remain outside the generic HTTP client. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift. + +The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API. + +The Promise root remains structural and has no Core or Effect runtime dependency. `/effect` depends only on Effect, Schema, and Protocol and is browser-bundle safe. Bundle-boundary tests enforce both import graphs. + +Effect consumers construct canonical decoded inputs: + +```ts +import { AbsolutePath, Location, OpenCode, Prompt } from "@opencode-ai/client/effect" + +const client = yield * OpenCode.make({ baseUrl: "https://opencode.example" }) +yield * + client.sessions.create({ + location: Location.Ref.make({ directory: AbsolutePath.make("/workspace") }), + }) +yield * client.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Hello" }) }) +``` diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000000..8021d76959 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/client", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": "./src/index.ts", + "./effect": "./src/effect.ts" + }, + "scripts": { + "generate": "bun run script/build.ts", + "check:generated": "bun run generate && git diff --exit-code -- src/generated src/generated-effect", + "test": "bun test --timeout 5000", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@opencode-ai/schema": "workspace:*", + "@opencode-ai/protocol": "workspace:*" + }, + "peerDependencies": { + "effect": "4.0.0-beta.83" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + } + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/httpapi-codegen": "workspace:*", + "@opencode-ai/server": "workspace:*", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "effect": "catalog:" + }, + "version": "7.4.16" +} diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts new file mode 100644 index 0000000000..aeec4b3e34 --- /dev/null +++ b/packages/client/script/build.ts @@ -0,0 +1,30 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen" +import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract" +import { Effect } from "effect" +import { fileURLToPath } from "url" + +const contract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints }) + +await Effect.runPromise( + Effect.all( + [ + write( + emitPromise(contract, { + outputTypes: { + "events.subscribe": { + name: "OpenCodeEventEncoded", + import: 'import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"', + }, + }, + }), + fileURLToPath(new URL("../src/generated", import.meta.url)), + ), + write( + emitEffectImported(contract, { module: "../contract", api: "ClientApi" }), + fileURLToPath(new URL("../src/generated-effect", import.meta.url)), + ), + ], + { concurrency: 2, discard: true }, + ).pipe(Effect.provide(NodeFileSystem.layer)), +) diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts new file mode 100644 index 0000000000..413fea9dc3 --- /dev/null +++ b/packages/client/src/contract.ts @@ -0,0 +1,53 @@ +import { makeDefaultApi } from "@opencode-ai/protocol/api" +import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors" +import { HttpApiMiddleware } from "effect/unstable/httpapi" + +class LocationMiddleware extends HttpApiMiddleware.Service()( + "@opencode-ai/client/LocationMiddleware", +) {} + +class SessionLocationMiddleware extends HttpApiMiddleware.Service()( + "@opencode-ai/client/SessionLocationMiddleware", + { error: [InvalidRequestError, SessionNotFoundError] }, +) {} + +export const ClientApi = makeDefaultApi({ + locationMiddleware: LocationMiddleware, + sessionLocationMiddleware: SessionLocationMiddleware, +}) + +export const groupNames = { + "server.health": "health", + "server.location": "location", + "server.agent": "agents", + "server.session": "sessions", + "server.message": "messages", + "server.model": "models", + "server.provider": "providers", + "server.integration": "integrations", + "server.credential": "credentials", + "server.permission": "permissions", + "server.fs": "files", + "server.command": "commands", + "server.skill": "skills", + "server.event": "events", + "server.pty": "ptys", + "server.question": "questions", + "server.reference": "references", + "server.projectCopy": "projectCopies", +} as const + +export const endpointNames = { + "session.messages": "list", + "integration.connect.key": "connectKey", + "integration.connect.oauth": "connectOauth", + "integration.attempt.status": "attemptStatus", + "integration.attempt.complete": "attemptComplete", + "integration.attempt.cancel": "attemptCancel", + "permission.request.list": "listRequests", + "permission.saved.list": "listSaved", + "permission.saved.remove": "removeSaved", + "question.request.list": "listRequests", +} as const + +export const omitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"]) diff --git a/packages/client/src/effect.ts b/packages/client/src/effect.ts new file mode 100644 index 0000000000..b580c7f48a --- /dev/null +++ b/packages/client/src/effect.ts @@ -0,0 +1,25 @@ +// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import +// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations. +export * from "./generated-effect/index" +export { Agent } from "@opencode-ai/schema/agent" +export { Command } from "@opencode-ai/schema/command" +export { Credential } from "@opencode-ai/schema/credential" +export { FileSystem } from "@opencode-ai/schema/filesystem" +export { Integration } from "@opencode-ai/schema/integration" +export { Location } from "@opencode-ai/schema/location" +export { Model } from "@opencode-ai/schema/model" +export { Permission } from "@opencode-ai/schema/permission" +export { PermissionSaved } from "@opencode-ai/schema/permission-saved" +export { Project } from "@opencode-ai/schema/project" +export { ProjectCopy } from "@opencode-ai/schema/project-copy" +export { Provider } from "@opencode-ai/schema/provider" +export { Pty } from "@opencode-ai/schema/pty" +export { Question } from "@opencode-ai/schema/question" +export { Reference } from "@opencode-ai/schema/reference" +export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema" +export { Session } from "@opencode-ai/schema/session" +export { SessionInput } from "@opencode-ai/schema/session-input" +export { SessionMessage } from "@opencode-ai/schema/session-message" +export { Skill } from "@opencode-ai/schema/skill" +export { Prompt } from "@opencode-ai/schema/prompt" +export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" diff --git a/packages/client/src/generated-effect/.httpapi-codegen.json b/packages/client/src/generated-effect/.httpapi-codegen.json new file mode 100644 index 0000000000..958eb566db --- /dev/null +++ b/packages/client/src/generated-effect/.httpapi-codegen.json @@ -0,0 +1,5 @@ +[ + "client-error.ts", + "client.ts", + "index.ts" +] diff --git a/packages/client/src/generated-effect/client-error.ts b/packages/client/src/generated-effect/client-error.ts new file mode 100644 index 0000000000..bcc65d9bdd --- /dev/null +++ b/packages/client/src/generated-effect/client-error.ts @@ -0,0 +1,5 @@ +import { Schema } from "effect" + +export class ClientError extends Schema.TaggedErrorClass()("ClientError", { + cause: Schema.Defect(), +}) {} diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts new file mode 100644 index 0000000000..024c978280 --- /dev/null +++ b/packages/client/src/generated-effect/client.ts @@ -0,0 +1,706 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect, Stream, Schema } from "effect" +import { Sse } from "effect/unstable/encoding" +import { HttpClientError } from "effect/unstable/http" +import { HttpApiClient } from "effect/unstable/httpapi" +import { ClientApi } from "../contract" +import { ClientError } from "./client-error" + +type RawClient = HttpApiClient.ForApi + +const mapClientError = (error: E) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : error + +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 Endpoint1_0Request = Parameters[0] +type Endpoint1_0Input = { readonly location?: Endpoint1_0Request["query"]["location"] } +const Endpoint1_0 = (raw: RawClient["server.location"]) => (input?: Endpoint1_0Input) => + raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup1 = (raw: RawClient["server.location"]) => ({ get: Endpoint1_0(raw) }) + +type Endpoint2_0Request = Parameters[0] +type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["location"] } +const Endpoint2_0 = (raw: RawClient["server.agent"]) => (input?: Endpoint2_0Input) => + raw["agent.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup2 = (raw: RawClient["server.agent"]) => ({ list: Endpoint2_0(raw) }) + +type Endpoint3_0Request = Parameters[0] +type Endpoint3_0Input = { + readonly workspace?: Endpoint3_0Request["query"]["workspace"] + readonly limit?: Endpoint3_0Request["query"]["limit"] + readonly order?: Endpoint3_0Request["query"]["order"] + readonly search?: Endpoint3_0Request["query"]["search"] + readonly directory?: Endpoint3_0Request["query"]["directory"] + readonly project?: Endpoint3_0Request["query"]["project"] + readonly subpath?: Endpoint3_0Request["query"]["subpath"] + readonly cursor?: Endpoint3_0Request["query"]["cursor"] +} +const Endpoint3_0 = (raw: RawClient["server.session"]) => (input?: Endpoint3_0Input) => + raw["session.list"]({ + query: { + workspace: input?.["workspace"], + limit: input?.["limit"], + order: input?.["order"], + search: input?.["search"], + directory: input?.["directory"], + project: input?.["project"], + subpath: input?.["subpath"], + cursor: input?.["cursor"], + }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_1Request = Parameters[0] +type Endpoint3_1Input = { + readonly id?: Endpoint3_1Request["payload"]["id"] + readonly agent?: Endpoint3_1Request["payload"]["agent"] + readonly model?: Endpoint3_1Request["payload"]["model"] + readonly location?: Endpoint3_1Request["payload"]["location"] +} +const Endpoint3_1 = (raw: RawClient["server.session"]) => (input?: Endpoint3_1Input) => + raw["session.create"]({ + payload: { id: input?.["id"], agent: input?.["agent"], model: input?.["model"], location: input?.["location"] }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +const Endpoint3_2 = (raw: RawClient["server.session"]) => () => + raw["session.active"]({}).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint3_3Request = Parameters[0] +type Endpoint3_3Input = { readonly sessionID: Endpoint3_3Request["params"]["sessionID"] } +const Endpoint3_3 = (raw: RawClient["server.session"]) => (input: Endpoint3_3Input) => + raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint3_4Request = Parameters[0] +type Endpoint3_4Input = { + readonly sessionID: Endpoint3_4Request["params"]["sessionID"] + readonly agent: Endpoint3_4Request["payload"]["agent"] +} +const Endpoint3_4 = (raw: RawClient["server.session"]) => (input: Endpoint3_4Input) => + raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint3_5Request = Parameters[0] +type Endpoint3_5Input = { + readonly sessionID: Endpoint3_5Request["params"]["sessionID"] + readonly model: Endpoint3_5Request["payload"]["model"] +} +const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Input) => + raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint3_6Request = Parameters[0] +type Endpoint3_6Input = { + readonly sessionID: Endpoint3_6Request["params"]["sessionID"] + readonly id?: Endpoint3_6Request["payload"]["id"] + readonly prompt: Endpoint3_6Request["payload"]["prompt"] + readonly delivery?: Endpoint3_6Request["payload"]["delivery"] + readonly resume?: Endpoint3_6Request["payload"]["resume"] +} +const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) => + raw["session.prompt"]({ + params: { sessionID: input["sessionID"] }, + payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint3_7Request = Parameters[0] +type Endpoint3_7Input = { readonly sessionID: Endpoint3_7Request["params"]["sessionID"] } +const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) => + raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_8Request = Parameters[0] +type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] } +const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) => + raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_9Request = Parameters[0] +type Endpoint3_9Input = { + readonly sessionID: Endpoint3_9Request["params"]["sessionID"] + readonly messageID: Endpoint3_9Request["payload"]["messageID"] + readonly files?: Endpoint3_9Request["payload"]["files"] +} +const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) => + raw["session.revert.stage"]({ + params: { sessionID: input["sessionID"] }, + payload: { messageID: input["messageID"], files: input["files"] }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint3_10Request = Parameters[0] +type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] } +const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) => + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_11Request = Parameters[0] +type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] } +const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) => + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_12Request = Parameters[0] +type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] } +const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) => + raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint3_13Request = Parameters[0] +type Endpoint3_13Input = { + readonly sessionID: Endpoint3_13Request["params"]["sessionID"] + readonly limit?: Endpoint3_13Request["query"]["limit"] + readonly after?: Endpoint3_13Request["query"]["after"] +} +const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) => + raw["session.history"]({ + params: { sessionID: input["sessionID"] }, + query: { limit: input["limit"], after: input["after"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_14Request = Parameters[0] +type Endpoint3_14Input = { + readonly sessionID: Endpoint3_14Request["params"]["sessionID"] + readonly after?: Endpoint3_14Request["query"]["after"] +} +const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) => + Stream.unwrap( + raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))), + ), + ) + +type Endpoint3_15Request = Parameters[0] +type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] } +const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) => + raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_16Request = Parameters[0] +type Endpoint3_16Input = { + readonly sessionID: Endpoint3_16Request["params"]["sessionID"] + readonly messageID: Endpoint3_16Request["params"]["messageID"] +} +const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) => + raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +const adaptGroup3 = (raw: RawClient["server.session"]) => ({ + list: Endpoint3_0(raw), + create: Endpoint3_1(raw), + active: Endpoint3_2(raw), + get: Endpoint3_3(raw), + switchAgent: Endpoint3_4(raw), + switchModel: Endpoint3_5(raw), + prompt: Endpoint3_6(raw), + compact: Endpoint3_7(raw), + wait: Endpoint3_8(raw), + stage: Endpoint3_9(raw), + clear: Endpoint3_10(raw), + commit: Endpoint3_11(raw), + context: Endpoint3_12(raw), + history: Endpoint3_13(raw), + events: Endpoint3_14(raw), + interrupt: Endpoint3_15(raw), + message: Endpoint3_16(raw), +}) + +type Endpoint4_0Request = Parameters[0] +type Endpoint4_0Input = { + readonly sessionID: Endpoint4_0Request["params"]["sessionID"] + readonly limit?: Endpoint4_0Request["query"]["limit"] + readonly order?: Endpoint4_0Request["query"]["order"] + readonly cursor?: Endpoint4_0Request["query"]["cursor"] +} +const Endpoint4_0 = (raw: RawClient["server.message"]) => (input: Endpoint4_0Input) => + raw["session.messages"]({ + params: { sessionID: input["sessionID"] }, + query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup4 = (raw: RawClient["server.message"]) => ({ list: Endpoint4_0(raw) }) + +type Endpoint5_0Request = Parameters[0] +type Endpoint5_0Input = { readonly location?: Endpoint5_0Request["query"]["location"] } +const Endpoint5_0 = (raw: RawClient["server.model"]) => (input?: Endpoint5_0Input) => + raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup5 = (raw: RawClient["server.model"]) => ({ list: Endpoint5_0(raw) }) + +type Endpoint6_0Request = Parameters[0] +type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["location"] } +const Endpoint6_0 = (raw: RawClient["server.provider"]) => (input?: Endpoint6_0Input) => + raw["provider.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint6_1Request = Parameters[0] +type Endpoint6_1Input = { + readonly providerID: Endpoint6_1Request["params"]["providerID"] + readonly location?: Endpoint6_1Request["query"]["location"] +} +const Endpoint6_1 = (raw: RawClient["server.provider"]) => (input: Endpoint6_1Input) => + raw["provider.get"]({ params: { providerID: input["providerID"] }, query: { location: input["location"] } }).pipe( + Effect.mapError(mapClientError), + ) + +const adaptGroup6 = (raw: RawClient["server.provider"]) => ({ list: Endpoint6_0(raw), get: Endpoint6_1(raw) }) + +type Endpoint7_0Request = Parameters[0] +type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] } +const Endpoint7_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint7_0Input) => + raw["integration.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint7_1Request = Parameters[0] +type Endpoint7_1Input = { + readonly integrationID: Endpoint7_1Request["params"]["integrationID"] + readonly location?: Endpoint7_1Request["query"]["location"] +} +const Endpoint7_1 = (raw: RawClient["server.integration"]) => (input: Endpoint7_1Input) => + raw["integration.get"]({ + params: { integrationID: input["integrationID"] }, + query: { location: input["location"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint7_2Request = Parameters[0] +type Endpoint7_2Input = { + readonly integrationID: Endpoint7_2Request["params"]["integrationID"] + readonly location?: Endpoint7_2Request["query"]["location"] + readonly key: Endpoint7_2Request["payload"]["key"] + readonly label?: Endpoint7_2Request["payload"]["label"] +} +const Endpoint7_2 = (raw: RawClient["server.integration"]) => (input: Endpoint7_2Input) => + raw["integration.connect.key"]({ + params: { integrationID: input["integrationID"] }, + query: { location: input["location"] }, + payload: { key: input["key"], label: input["label"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint7_3Request = Parameters[0] +type Endpoint7_3Input = { + readonly integrationID: Endpoint7_3Request["params"]["integrationID"] + readonly location?: Endpoint7_3Request["query"]["location"] + readonly methodID: Endpoint7_3Request["payload"]["methodID"] + readonly inputs: Endpoint7_3Request["payload"]["inputs"] + readonly label?: Endpoint7_3Request["payload"]["label"] +} +const Endpoint7_3 = (raw: RawClient["server.integration"]) => (input: Endpoint7_3Input) => + raw["integration.connect.oauth"]({ + params: { integrationID: input["integrationID"] }, + query: { location: input["location"] }, + payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint7_4Request = Parameters[0] +type Endpoint7_4Input = { + readonly attemptID: Endpoint7_4Request["params"]["attemptID"] + readonly location?: Endpoint7_4Request["query"]["location"] +} +const Endpoint7_4 = (raw: RawClient["server.integration"]) => (input: Endpoint7_4Input) => + raw["integration.attempt.status"]({ + params: { attemptID: input["attemptID"] }, + query: { location: input["location"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint7_5Request = Parameters[0] +type Endpoint7_5Input = { + readonly attemptID: Endpoint7_5Request["params"]["attemptID"] + readonly location?: Endpoint7_5Request["query"]["location"] + readonly code?: Endpoint7_5Request["payload"]["code"] +} +const Endpoint7_5 = (raw: RawClient["server.integration"]) => (input: Endpoint7_5Input) => + raw["integration.attempt.complete"]({ + params: { attemptID: input["attemptID"] }, + query: { location: input["location"] }, + payload: { code: input["code"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint7_6Request = Parameters[0] +type Endpoint7_6Input = { + readonly attemptID: Endpoint7_6Request["params"]["attemptID"] + readonly location?: Endpoint7_6Request["query"]["location"] +} +const Endpoint7_6 = (raw: RawClient["server.integration"]) => (input: Endpoint7_6Input) => + raw["integration.attempt.cancel"]({ + params: { attemptID: input["attemptID"] }, + query: { location: input["location"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup7 = (raw: RawClient["server.integration"]) => ({ + list: Endpoint7_0(raw), + get: Endpoint7_1(raw), + connectKey: Endpoint7_2(raw), + connectOauth: Endpoint7_3(raw), + attemptStatus: Endpoint7_4(raw), + attemptComplete: Endpoint7_5(raw), + attemptCancel: Endpoint7_6(raw), +}) + +type Endpoint8_0Request = Parameters[0] +type Endpoint8_0Input = { + readonly credentialID: Endpoint8_0Request["params"]["credentialID"] + readonly location?: Endpoint8_0Request["query"]["location"] + readonly label: Endpoint8_0Request["payload"]["label"] +} +const Endpoint8_0 = (raw: RawClient["server.credential"]) => (input: Endpoint8_0Input) => + raw["credential.update"]({ + params: { credentialID: input["credentialID"] }, + query: { location: input["location"] }, + payload: { label: input["label"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint8_1Request = Parameters[0] +type Endpoint8_1Input = { + readonly credentialID: Endpoint8_1Request["params"]["credentialID"] + readonly location?: Endpoint8_1Request["query"]["location"] +} +const Endpoint8_1 = (raw: RawClient["server.credential"]) => (input: Endpoint8_1Input) => + raw["credential.remove"]({ + params: { credentialID: input["credentialID"] }, + query: { location: input["location"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup8 = (raw: RawClient["server.credential"]) => ({ update: Endpoint8_0(raw), remove: Endpoint8_1(raw) }) + +type Endpoint9_0Request = Parameters[0] +type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] } +const Endpoint9_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_0Input) => + raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint9_1Request = Parameters[0] +type Endpoint9_1Input = { readonly projectID?: Endpoint9_1Request["query"]["projectID"] } +const Endpoint9_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_1Input) => + raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint9_2Request = Parameters[0] +type Endpoint9_2Input = { readonly id: Endpoint9_2Request["params"]["id"] } +const Endpoint9_2 = (raw: RawClient["server.permission"]) => (input: Endpoint9_2Input) => + raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint9_3Request = Parameters[0] +type Endpoint9_3Input = { + readonly sessionID: Endpoint9_3Request["params"]["sessionID"] + readonly id?: Endpoint9_3Request["payload"]["id"] + readonly action: Endpoint9_3Request["payload"]["action"] + readonly resources: Endpoint9_3Request["payload"]["resources"] + readonly save?: Endpoint9_3Request["payload"]["save"] + readonly metadata?: Endpoint9_3Request["payload"]["metadata"] + readonly source?: Endpoint9_3Request["payload"]["source"] + readonly agent?: Endpoint9_3Request["payload"]["agent"] +} +const Endpoint9_3 = (raw: RawClient["server.permission"]) => (input: Endpoint9_3Input) => + raw["session.permission.create"]({ + params: { sessionID: input["sessionID"] }, + payload: { + id: input["id"], + action: input["action"], + resources: input["resources"], + save: input["save"], + metadata: input["metadata"], + source: input["source"], + agent: input["agent"], + }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint9_4Request = Parameters[0] +type Endpoint9_4Input = { readonly sessionID: Endpoint9_4Request["params"]["sessionID"] } +const Endpoint9_4 = (raw: RawClient["server.permission"]) => (input: Endpoint9_4Input) => + raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint9_5Request = Parameters[0] +type Endpoint9_5Input = { + readonly sessionID: Endpoint9_5Request["params"]["sessionID"] + readonly requestID: Endpoint9_5Request["params"]["requestID"] +} +const Endpoint9_5 = (raw: RawClient["server.permission"]) => (input: Endpoint9_5Input) => + raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint9_6Request = Parameters[0] +type Endpoint9_6Input = { + readonly sessionID: Endpoint9_6Request["params"]["sessionID"] + readonly requestID: Endpoint9_6Request["params"]["requestID"] + readonly reply: Endpoint9_6Request["payload"]["reply"] + readonly message?: Endpoint9_6Request["payload"]["message"] +} +const Endpoint9_6 = (raw: RawClient["server.permission"]) => (input: Endpoint9_6Input) => + raw["session.permission.reply"]({ + params: { sessionID: input["sessionID"], requestID: input["requestID"] }, + payload: { reply: input["reply"], message: input["message"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup9 = (raw: RawClient["server.permission"]) => ({ + listRequests: Endpoint9_0(raw), + listSaved: Endpoint9_1(raw), + removeSaved: Endpoint9_2(raw), + create: Endpoint9_3(raw), + list: Endpoint9_4(raw), + get: Endpoint9_5(raw), + reply: Endpoint9_6(raw), +}) + +type Endpoint10_0Request = Parameters[0] +type Endpoint10_0Input = { + readonly location?: Endpoint10_0Request["query"]["location"] + readonly path?: Endpoint10_0Request["query"]["path"] +} +const Endpoint10_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint10_0Input) => + raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint10_1Request = Parameters[0] +type Endpoint10_1Input = { + readonly location?: Endpoint10_1Request["query"]["location"] + readonly query: Endpoint10_1Request["query"]["query"] + readonly type?: Endpoint10_1Request["query"]["type"] + readonly limit?: Endpoint10_1Request["query"]["limit"] +} +const Endpoint10_1 = (raw: RawClient["server.fs"]) => (input: Endpoint10_1Input) => + raw["fs.find"]({ + query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup10 = (raw: RawClient["server.fs"]) => ({ list: Endpoint10_0(raw), find: Endpoint10_1(raw) }) + +type Endpoint11_0Request = Parameters[0] +type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] } +const Endpoint11_0 = (raw: RawClient["server.command"]) => (input?: Endpoint11_0Input) => + raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup11 = (raw: RawClient["server.command"]) => ({ list: Endpoint11_0(raw) }) + +type Endpoint12_0Request = Parameters[0] +type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] } +const Endpoint12_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint12_0Input) => + raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup12 = (raw: RawClient["server.skill"]) => ({ list: Endpoint12_0(raw) }) + +const Endpoint13_0 = (raw: RawClient["server.event"]) => () => + Stream.unwrap( + raw["event.subscribe"]({}).pipe( + Effect.mapError(mapClientError), + Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))), + ), + ) + +const adaptGroup13 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint13_0(raw) }) + +type Endpoint14_0Request = Parameters[0] +type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } +const Endpoint14_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_0Input) => + raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint14_1Request = Parameters[0] +type Endpoint14_1Input = { + readonly location?: Endpoint14_1Request["query"]["location"] + readonly command?: Endpoint14_1Request["payload"]["command"] + readonly args?: Endpoint14_1Request["payload"]["args"] + readonly cwd?: Endpoint14_1Request["payload"]["cwd"] + readonly title?: Endpoint14_1Request["payload"]["title"] + readonly env?: Endpoint14_1Request["payload"]["env"] +} +const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Input) => + raw["pty.create"]({ + query: { location: input?.["location"] }, + payload: { + command: input?.["command"], + args: input?.["args"], + cwd: input?.["cwd"], + title: input?.["title"], + env: input?.["env"], + }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint14_2Request = Parameters[0] +type Endpoint14_2Input = { + readonly ptyID: Endpoint14_2Request["params"]["ptyID"] + readonly location?: Endpoint14_2Request["query"]["location"] +} +const Endpoint14_2 = (raw: RawClient["server.pty"]) => (input: Endpoint14_2Input) => + raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint14_3Request = Parameters[0] +type Endpoint14_3Input = { + readonly ptyID: Endpoint14_3Request["params"]["ptyID"] + readonly location?: Endpoint14_3Request["query"]["location"] + readonly title?: Endpoint14_3Request["payload"]["title"] + readonly size?: Endpoint14_3Request["payload"]["size"] +} +const Endpoint14_3 = (raw: RawClient["server.pty"]) => (input: Endpoint14_3Input) => + raw["pty.update"]({ + params: { ptyID: input["ptyID"] }, + query: { location: input["location"] }, + payload: { title: input["title"], size: input["size"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint14_4Request = Parameters[0] +type Endpoint14_4Input = { + readonly ptyID: Endpoint14_4Request["params"]["ptyID"] + readonly location?: Endpoint14_4Request["query"]["location"] +} +const Endpoint14_4 = (raw: RawClient["server.pty"]) => (input: Endpoint14_4Input) => + raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( + Effect.mapError(mapClientError), + ) + +const adaptGroup14 = (raw: RawClient["server.pty"]) => ({ + list: Endpoint14_0(raw), + create: Endpoint14_1(raw), + get: Endpoint14_2(raw), + update: Endpoint14_3(raw), + remove: Endpoint14_4(raw), +}) + +type Endpoint15_0Request = Parameters[0] +type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] } +const Endpoint15_0 = (raw: RawClient["server.question"]) => (input?: Endpoint15_0Input) => + raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint15_1Request = Parameters[0] +type Endpoint15_1Input = { readonly sessionID: Endpoint15_1Request["params"]["sessionID"] } +const Endpoint15_1 = (raw: RawClient["server.question"]) => (input: Endpoint15_1Input) => + raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint15_2Request = Parameters[0] +type Endpoint15_2Input = { + readonly sessionID: Endpoint15_2Request["params"]["sessionID"] + readonly requestID: Endpoint15_2Request["params"]["requestID"] + readonly answers: Endpoint15_2Request["payload"]["answers"] +} +const Endpoint15_2 = (raw: RawClient["server.question"]) => (input: Endpoint15_2Input) => + raw["session.question.reply"]({ + params: { sessionID: input["sessionID"], requestID: input["requestID"] }, + payload: { answers: input["answers"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint15_3Request = Parameters[0] +type Endpoint15_3Input = { + readonly sessionID: Endpoint15_3Request["params"]["sessionID"] + readonly requestID: Endpoint15_3Request["params"]["requestID"] +} +const Endpoint15_3 = (raw: RawClient["server.question"]) => (input: Endpoint15_3Input) => + raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( + Effect.mapError(mapClientError), + ) + +const adaptGroup15 = (raw: RawClient["server.question"]) => ({ + listRequests: Endpoint15_0(raw), + list: Endpoint15_1(raw), + reply: Endpoint15_2(raw), + reject: Endpoint15_3(raw), +}) + +type Endpoint16_0Request = Parameters[0] +type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] } +const Endpoint16_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint16_0Input) => + raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup16 = (raw: RawClient["server.reference"]) => ({ list: Endpoint16_0(raw) }) + +type Endpoint17_0Request = Parameters[0] +type Endpoint17_0Input = { + readonly projectID: Endpoint17_0Request["params"]["projectID"] + readonly location?: Endpoint17_0Request["query"]["location"] + readonly strategy: Endpoint17_0Request["payload"]["strategy"] + readonly directory: Endpoint17_0Request["payload"]["directory"] + readonly name?: Endpoint17_0Request["payload"]["name"] +} +const Endpoint17_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_0Input) => + raw["projectCopy.create"]({ + params: { projectID: input["projectID"] }, + query: { location: input["location"] }, + payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint17_1Request = Parameters[0] +type Endpoint17_1Input = { + readonly projectID: Endpoint17_1Request["params"]["projectID"] + readonly location?: Endpoint17_1Request["query"]["location"] + readonly directory: Endpoint17_1Request["payload"]["directory"] + readonly force: Endpoint17_1Request["payload"]["force"] +} +const Endpoint17_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_1Input) => + raw["projectCopy.remove"]({ + params: { projectID: input["projectID"] }, + query: { location: input["location"] }, + payload: { directory: input["directory"], force: input["force"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint17_2Request = Parameters[0] +type Endpoint17_2Input = { + readonly projectID: Endpoint17_2Request["params"]["projectID"] + readonly location?: Endpoint17_2Request["query"]["location"] +} +const Endpoint17_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_2Input) => + raw["projectCopy.refresh"]({ + params: { projectID: input["projectID"] }, + query: { location: input["location"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup17 = (raw: RawClient["server.projectCopy"]) => ({ + create: Endpoint17_0(raw), + remove: Endpoint17_1(raw), + refresh: Endpoint17_2(raw), +}) + +const adaptClient = (raw: RawClient) => ({ + health: adaptGroup0(raw["server.health"]), + location: adaptGroup1(raw["server.location"]), + agents: adaptGroup2(raw["server.agent"]), + sessions: adaptGroup3(raw["server.session"]), + messages: adaptGroup4(raw["server.message"]), + models: adaptGroup5(raw["server.model"]), + providers: adaptGroup6(raw["server.provider"]), + integrations: adaptGroup7(raw["server.integration"]), + credentials: adaptGroup8(raw["server.credential"]), + permissions: adaptGroup9(raw["server.permission"]), + files: adaptGroup10(raw["server.fs"]), + commands: adaptGroup11(raw["server.command"]), + skills: adaptGroup12(raw["server.skill"]), + events: adaptGroup13(raw["server.event"]), + ptys: adaptGroup14(raw["server.pty"]), + questions: adaptGroup15(raw["server.question"]), + references: adaptGroup16(raw["server.reference"]), + projectCopies: adaptGroup17(raw["server.projectCopy"]), +}) + +export const make = (options?: { readonly baseUrl?: URL | string }) => + HttpApiClient.make(ClientApi, options).pipe(Effect.map(adaptClient)) diff --git a/packages/client/src/generated-effect/index.ts b/packages/client/src/generated-effect/index.ts new file mode 100644 index 0000000000..bc0dbc9fa4 --- /dev/null +++ b/packages/client/src/generated-effect/index.ts @@ -0,0 +1,2 @@ +export { ClientError } from "./client-error" +export * as OpenCode from "./client" diff --git a/packages/client/src/generated/.httpapi-codegen.json b/packages/client/src/generated/.httpapi-codegen.json new file mode 100644 index 0000000000..25700fc72d --- /dev/null +++ b/packages/client/src/generated/.httpapi-codegen.json @@ -0,0 +1,6 @@ +[ + "client-error.ts", + "client.ts", + "index.ts", + "types.ts" +] diff --git a/packages/client/src/generated/client-error.ts b/packages/client/src/generated/client-error.ts new file mode 100644 index 0000000000..c278f0ddc8 --- /dev/null +++ b/packages/client/src/generated/client-error.ts @@ -0,0 +1,11 @@ +export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse" + +export class ClientError extends Error { + override readonly name = "ClientError" + constructor( + readonly reason: ClientErrorReason, + options?: ErrorOptions, + ) { + super(reason, options) + } +} diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts new file mode 100644 index 0000000000..27ec3d81ba --- /dev/null +++ b/packages/client/src/generated/client.ts @@ -0,0 +1,1029 @@ +import type { + HealthGetOutput, + LocationGetInput, + LocationGetOutput, + AgentsListInput, + AgentsListOutput, + SessionsListInput, + SessionsListOutput, + SessionsCreateInput, + SessionsCreateOutput, + SessionsActiveOutput, + SessionsGetInput, + SessionsGetOutput, + SessionsSwitchAgentInput, + SessionsSwitchAgentOutput, + SessionsSwitchModelInput, + SessionsSwitchModelOutput, + SessionsPromptInput, + SessionsPromptOutput, + SessionsCompactInput, + SessionsCompactOutput, + SessionsWaitInput, + SessionsWaitOutput, + SessionsStageInput, + SessionsStageOutput, + SessionsClearInput, + SessionsClearOutput, + SessionsCommitInput, + SessionsCommitOutput, + SessionsContextInput, + SessionsContextOutput, + SessionsHistoryInput, + SessionsHistoryOutput, + SessionsEventsInput, + SessionsEventsOutput, + SessionsInterruptInput, + SessionsInterruptOutput, + SessionsMessageInput, + SessionsMessageOutput, + MessagesListInput, + MessagesListOutput, + ModelsListInput, + ModelsListOutput, + ProvidersListInput, + ProvidersListOutput, + ProvidersGetInput, + ProvidersGetOutput, + IntegrationsListInput, + IntegrationsListOutput, + IntegrationsGetInput, + IntegrationsGetOutput, + IntegrationsConnectKeyInput, + IntegrationsConnectKeyOutput, + IntegrationsConnectOauthInput, + IntegrationsConnectOauthOutput, + IntegrationsAttemptStatusInput, + IntegrationsAttemptStatusOutput, + IntegrationsAttemptCompleteInput, + IntegrationsAttemptCompleteOutput, + IntegrationsAttemptCancelInput, + IntegrationsAttemptCancelOutput, + CredentialsUpdateInput, + CredentialsUpdateOutput, + CredentialsRemoveInput, + CredentialsRemoveOutput, + PermissionsListRequestsInput, + PermissionsListRequestsOutput, + PermissionsListSavedInput, + PermissionsListSavedOutput, + PermissionsRemoveSavedInput, + PermissionsRemoveSavedOutput, + PermissionsCreateInput, + PermissionsCreateOutput, + PermissionsListInput, + PermissionsListOutput, + PermissionsGetInput, + PermissionsGetOutput, + PermissionsReplyInput, + PermissionsReplyOutput, + FilesListInput, + FilesListOutput, + FilesFindInput, + FilesFindOutput, + CommandsListInput, + CommandsListOutput, + SkillsListInput, + SkillsListOutput, + EventsSubscribeOutput, + PtysListInput, + PtysListOutput, + PtysCreateInput, + PtysCreateOutput, + PtysGetInput, + PtysGetOutput, + PtysUpdateInput, + PtysUpdateOutput, + PtysRemoveInput, + PtysRemoveOutput, + QuestionsListRequestsInput, + QuestionsListRequestsOutput, + QuestionsListInput, + QuestionsListOutput, + QuestionsReplyInput, + QuestionsReplyOutput, + QuestionsRejectInput, + QuestionsRejectOutput, + ReferencesListInput, + ReferencesListOutput, + ProjectCopiesCreateInput, + ProjectCopiesCreateOutput, + ProjectCopiesRemoveInput, + ProjectCopiesRemoveOutput, + ProjectCopiesRefreshInput, + ProjectCopiesRefreshOutput, +} from "./types" +import { ClientError } from "./client-error" + +export interface ClientOptions { + readonly baseUrl: string + readonly fetch?: typeof globalThis.fetch + readonly headers?: HeadersInit +} + +export interface RequestOptions { + readonly signal?: AbortSignal + readonly headers?: HeadersInit +} + +interface RequestDescriptor { + readonly method: string + readonly path: string + readonly query?: Record + readonly headers?: Record + readonly body?: unknown + readonly successStatus: number + readonly declaredStatuses: ReadonlyArray + readonly empty: boolean +} + +export function make(options: ClientOptions) { + const fetch = options.fetch ?? globalThis.fetch + + const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => { + const url = new URL(descriptor.path, options.baseUrl) + for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value) + const headers = new Headers(options.headers) + for (const [key, value] of Object.entries(descriptor.headers ?? {})) { + if (value !== undefined && value !== null) headers.set(key, String(value)) + } + for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value) + if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json") + return { + url, + init: { + method: descriptor.method, + signal: requestOptions?.signal, + headers, + body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body), + } satisfies RequestInit, + } + } + + const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => { + try { + const prepared = prepare(descriptor, requestOptions) + return await fetch(prepared.url, prepared.init) + } catch (cause) { + throw new ClientError("Transport", { cause }) + } + } + + const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => { + if (descriptor.declaredStatuses.includes(response.status)) throw await json(response) + try { + await response.body?.cancel() + } catch {} + throw new ClientError("UnexpectedStatus", { cause: { status: response.status } }) + } + + const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => { + const response = await execute(descriptor, requestOptions) + if (response.status !== descriptor.successStatus) return responseError(response, descriptor) + if (descriptor.empty) { + try { + await response.body?.cancel() + } catch {} + return undefined as A + } + return (await json(response)) as A + } + + const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({ + async *[Symbol.asyncIterator]() { + const response = await execute(descriptor, requestOptions) + if (response.status !== descriptor.successStatus) await responseError(response, descriptor) + if (!isContentType(response, "text/event-stream")) { + try { + await response.body?.cancel() + } catch {} + throw new ClientError("UnsupportedContentType") + } + if (response.body === null) throw new ClientError("MalformedResponse") + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + try { + while (true) { + let next + try { + next = await reader.read() + } catch (cause) { + throw new ClientError("Transport", { cause }) + } + buffer += decoder.decode(next.value, { stream: !next.done }) + if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse") + const trailingCarriageReturn = !next.done && buffer.endsWith("\r") + if (trailingCarriageReturn) buffer = buffer.slice(0, -1) + buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n") + if (trailingCarriageReturn) buffer += "\r" + if (next.done && buffer !== "") buffer += "\n\n" + let boundary = buffer.indexOf("\n\n") + while (boundary >= 0) { + const block = buffer.slice(0, boundary) + buffer = buffer.slice(boundary + 2) + const data = block + .split("\n") + .flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trimStart()] : [])) + .join("\n") + if (data !== "") { + try { + yield JSON.parse(data) as A + } catch (cause) { + throw new ClientError("MalformedResponse", { cause }) + } + } + boundary = buffer.indexOf("\n\n") + } + if (next.done) return + } + } finally { + try { + await reader.cancel() + } catch {} + reader.releaseLock() + } + }, + }) + + return { + health: { + get: (requestOptions?: RequestOptions) => + request( + { method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, + requestOptions, + ), + }, + location: { + get: (input?: LocationGetInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/location`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, + agents: { + list: (input?: AgentsListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/agent`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, + sessions: { + list: (input?: SessionsListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/session`, + query: { + workspace: input?.["workspace"], + limit: input?.["limit"], + order: input?.["order"], + search: input?.["search"], + directory: input?.["directory"], + project: input?.["project"], + subpath: input?.["subpath"], + cursor: input?.["cursor"], + }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + create: (input?: SessionsCreateInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsCreateOutput }>( + { + method: "POST", + path: `/api/session`, + body: { + id: input?.["id"], + agent: input?.["agent"], + model: input?.["model"], + location: input?.["location"], + }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + active: (requestOptions?: RequestOptions) => + request<{ readonly data: SessionsActiveOutput }>( + { + method: "GET", + path: `/api/session/active`, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + get: (input: SessionsGetInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsGetOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}`, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + switchAgent: (input: SessionsSwitchAgentInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`, + body: { agent: input["agent"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + switchModel: (input: SessionsSwitchModelInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/model`, + body: { model: input["model"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsPromptOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`, + body: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, + successStatus: 200, + declaredStatuses: [409, 404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + compact: (input: SessionsCompactInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`, + successStatus: 204, + declaredStatuses: [404, 503, 400, 401], + empty: true, + }, + requestOptions, + ), + wait: (input: SessionsWaitInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/wait`, + successStatus: 204, + declaredStatuses: [404, 503, 400, 401], + empty: true, + }, + requestOptions, + ), + stage: (input: SessionsStageInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsStageOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, + body: { messageID: input["messageID"], files: input["files"] }, + successStatus: 200, + declaredStatuses: [404, 500, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + clear: (input: SessionsClearInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`, + successStatus: 204, + declaredStatuses: [404, 500, 400, 401], + empty: true, + }, + requestOptions, + ), + commit: (input: SessionsCommitInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + context: (input: SessionsContextInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsContextOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/context`, + successStatus: 200, + declaredStatuses: [404, 500, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + history: (input: SessionsHistoryInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/history`, + query: { limit: input["limit"], after: input["after"] }, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ), + events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable => + sse( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/event`, + query: { after: input["after"] }, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ), + interrupt: (input: SessionsInterruptInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + message: (input: SessionsMessageInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsMessageOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + }, + messages: { + list: (input: MessagesListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/message`, + query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] }, + successStatus: 200, + declaredStatuses: [400, 404, 500, 401], + empty: false, + }, + requestOptions, + ), + }, + models: { + list: (input?: ModelsListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/model`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [503, 401, 400], + empty: false, + }, + requestOptions, + ), + }, + providers: { + list: (input?: ProvidersListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/provider`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [503, 401, 400], + empty: false, + }, + requestOptions, + ), + get: (input: ProvidersGetInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/provider/${encodeURIComponent(input.providerID)}`, + query: { location: input["location"] }, + successStatus: 200, + declaredStatuses: [404, 503, 401, 400], + empty: false, + }, + requestOptions, + ), + }, + integrations: { + list: (input?: IntegrationsListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/integration`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + get: (input: IntegrationsGetInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/integration/${encodeURIComponent(input.integrationID)}`, + query: { location: input["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + connectKey: (input: IntegrationsConnectKeyInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`, + query: { location: input["location"] }, + body: { key: input["key"], label: input["label"] }, + successStatus: 204, + declaredStatuses: [400, 401], + empty: true, + }, + requestOptions, + ), + connectOauth: (input: IntegrationsConnectOauthInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`, + query: { location: input["location"] }, + body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + attemptStatus: (input: IntegrationsAttemptStatusInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, + query: { location: input["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + attemptComplete: (input: IntegrationsAttemptCompleteInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`, + query: { location: input["location"] }, + body: { code: input["code"] }, + successStatus: 204, + declaredStatuses: [400, 401], + empty: true, + }, + requestOptions, + ), + attemptCancel: (input: IntegrationsAttemptCancelInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, + query: { location: input["location"] }, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + }, + credentials: { + update: (input: CredentialsUpdateInput, requestOptions?: RequestOptions) => + request( + { + method: "PATCH", + path: `/api/credential/${encodeURIComponent(input.credentialID)}`, + query: { location: input["location"] }, + body: { label: input["label"] }, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + remove: (input: CredentialsRemoveInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/credential/${encodeURIComponent(input.credentialID)}`, + query: { location: input["location"] }, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + }, + permissions: { + listRequests: (input?: PermissionsListRequestsInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/permission/request`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + listSaved: (input?: PermissionsListSavedInput, requestOptions?: RequestOptions) => + request<{ readonly data: PermissionsListSavedOutput }>( + { + method: "GET", + path: `/api/permission/saved`, + query: { projectID: input?.["projectID"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + removeSaved: (input: PermissionsRemoveSavedInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/permission/saved/${encodeURIComponent(input.id)}`, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + create: (input: PermissionsCreateInput, requestOptions?: RequestOptions) => + request<{ readonly data: PermissionsCreateOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`, + body: { + id: input["id"], + action: input["action"], + resources: input["resources"], + save: input["save"], + metadata: input["metadata"], + source: input["source"], + agent: input["agent"], + }, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + list: (input: PermissionsListInput, requestOptions?: RequestOptions) => + request<{ readonly data: PermissionsListOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + get: (input: PermissionsGetInput, requestOptions?: RequestOptions) => + request<{ readonly data: PermissionsGetOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}`, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + reply: (input: PermissionsReplyInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`, + body: { reply: input["reply"], message: input["message"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + }, + files: { + list: (input?: FilesListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/fs/list`, + query: { location: input?.["location"], path: input?.["path"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + find: (input: FilesFindInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/fs/find`, + query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, + commands: { + list: (input?: CommandsListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/command`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, + skills: { + list: (input?: SkillsListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/skill`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, + events: { + subscribe: (requestOptions?: RequestOptions): AsyncIterable => + sse( + { method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, + requestOptions, + ), + }, + ptys: { + list: (input?: PtysListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/pty`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + create: (input?: PtysCreateInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/pty`, + query: { location: input?.["location"] }, + body: { + command: input?.["command"], + args: input?.["args"], + cwd: input?.["cwd"], + title: input?.["title"], + env: input?.["env"], + }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + get: (input: PtysGetInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/pty/${encodeURIComponent(input.ptyID)}`, + query: { location: input["location"] }, + successStatus: 200, + declaredStatuses: [404, 401, 400], + empty: false, + }, + requestOptions, + ), + update: (input: PtysUpdateInput, requestOptions?: RequestOptions) => + request( + { + method: "PUT", + path: `/api/pty/${encodeURIComponent(input.ptyID)}`, + query: { location: input["location"] }, + body: { title: input["title"], size: input["size"] }, + successStatus: 200, + declaredStatuses: [404, 401, 400], + empty: false, + }, + requestOptions, + ), + remove: (input: PtysRemoveInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/pty/${encodeURIComponent(input.ptyID)}`, + query: { location: input["location"] }, + successStatus: 204, + declaredStatuses: [404, 401, 400], + empty: true, + }, + requestOptions, + ), + }, + questions: { + listRequests: (input?: QuestionsListRequestsInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/question/request`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + list: (input: QuestionsListInput, requestOptions?: RequestOptions) => + request<{ readonly data: QuestionsListOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/question`, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + reply: (input: QuestionsReplyInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`, + body: { answers: input["answers"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + reject: (input: QuestionsRejectInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + }, + references: { + list: (input?: ReferencesListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/reference`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, + projectCopies: { + create: (input: ProjectCopiesCreateInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`, + query: { location: input["location"] }, + body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + remove: (input: ProjectCopiesRemoveInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`, + query: { location: input["location"] }, + body: { directory: input["directory"], force: input["force"] }, + successStatus: 204, + declaredStatuses: [400, 401], + empty: true, + }, + requestOptions, + ), + refresh: (input: ProjectCopiesRefreshInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`, + query: { location: input["location"] }, + successStatus: 204, + declaredStatuses: [400, 401], + empty: true, + }, + requestOptions, + ), + }, + } +} + +function appendQuery(params: URLSearchParams, key: string, value: unknown): void { + if (value === undefined || value === null) return + if (Array.isArray(value)) { + for (const item of value) appendQuery(params, key, item) + return + } + if (typeof value === "object") { + for (const [child, item] of Object.entries(value)) appendQuery(params, `${key}[${child}]`, item) + return + } + params.append(key, String(value)) +} + +async function json(response: Response): Promise { + if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) { + try { + await response.body?.cancel() + } catch {} + throw new ClientError("UnsupportedContentType") + } + let text: string + try { + text = await response.text() + } catch (cause) { + throw new ClientError("Transport", { cause }) + } + if (text === "") throw new ClientError("MalformedResponse") + try { + return JSON.parse(text) + } catch (cause) { + throw new ClientError("MalformedResponse", { cause }) + } +} + +function isContentType(response: Response, expected: string) { + return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected +} diff --git a/packages/client/src/generated/index.ts b/packages/client/src/generated/index.ts new file mode 100644 index 0000000000..2570372cf8 --- /dev/null +++ b/packages/client/src/generated/index.ts @@ -0,0 +1,3 @@ +export { ClientError, type ClientErrorReason } from "./client-error" +export * as OpenCode from "./client" +export * from "./types" diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts new file mode 100644 index 0000000000..3b3188c874 --- /dev/null +++ b/packages/client/src/generated/types.ts @@ -0,0 +1,2807 @@ +import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event" + +export type JsonValue = + | null + | boolean + | number + | string + | ReadonlyArray + | { readonly [key: string]: JsonValue } + +export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string } +export const isUnauthorizedError = (value: unknown): value is UnauthorizedError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError" + +export type InvalidRequestError = { + readonly _tag: "InvalidRequestError" + readonly message: string + readonly kind?: string | undefined + readonly field?: string | undefined +} +export const isInvalidRequestError = (value: unknown): value is InvalidRequestError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError" + +export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string } +export const isInvalidCursorError = (value: unknown): value is InvalidCursorError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError" + +export type SessionNotFoundError = { + readonly _tag: "SessionNotFoundError" + readonly sessionID: string + readonly message: string +} +export const isSessionNotFoundError = (value: unknown): value is SessionNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionNotFoundError" + +export type ConflictError = { + readonly _tag: "ConflictError" + readonly message: string + readonly resource?: string | undefined +} +export const isConflictError = (value: unknown): value is ConflictError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError" + +export type ServiceUnavailableError = { + readonly _tag: "ServiceUnavailableError" + readonly message: string + readonly service?: string | undefined +} +export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError" + +export type MessageNotFoundError = { + readonly _tag: "MessageNotFoundError" + readonly sessionID: string + readonly messageID: string + readonly message: string +} +export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "MessageNotFoundError" + +export type UnknownError = { + readonly _tag: "UnknownError" + readonly message: string + readonly ref?: string | undefined +} +export const isUnknownError = (value: unknown): value is UnknownError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnknownError" + +export type ProviderNotFoundError = { + readonly _tag: "ProviderNotFoundError" + readonly providerID: string + readonly message: string +} +export const isProviderNotFoundError = (value: unknown): value is ProviderNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProviderNotFoundError" + +export type PermissionNotFoundError = { + readonly _tag: "PermissionNotFoundError" + readonly requestID: string + readonly message: string +} +export const isPermissionNotFoundError = (value: unknown): value is PermissionNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PermissionNotFoundError" + +export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly ptyID: string; readonly message: string } +export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError" + +export type QuestionNotFoundError = { + readonly _tag: "QuestionNotFoundError" + readonly requestID: string + readonly message: string +} +export const isQuestionNotFoundError = (value: unknown): value is QuestionNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "QuestionNotFoundError" + +export type ProjectCopyError = { + readonly name: "ProjectCopyError" + readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined } +} +export const isProjectCopyError = (value: unknown): value is ProjectCopyError => + typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError" + +export type HealthGetOutput = { readonly healthy: true } + +export type LocationGetInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type LocationGetOutput = { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } +} + +export type AgentsListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type AgentsListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly id: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly request: { + readonly headers: { readonly [x: string]: string } + readonly body: { readonly [x: string]: JsonValue } + } + readonly system?: string + readonly description?: string + readonly mode: "subagent" | "primary" | "all" + readonly hidden: boolean + readonly color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + readonly steps?: number + readonly permissions: ReadonlyArray<{ + readonly action: string + readonly resource: string + readonly effect: "allow" | "deny" | "ask" + }> + }> +} + +export type SessionsListInput = { + readonly workspace?: { + readonly workspace?: string | undefined + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["workspace"] + readonly limit?: { + readonly workspace?: string | undefined + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["limit"] + readonly order?: { + readonly workspace?: string | undefined + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["order"] + readonly search?: { + readonly workspace?: string | undefined + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["search"] + readonly directory?: { + readonly workspace?: string | undefined + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["directory"] + readonly project?: { + readonly workspace?: string | undefined + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["project"] + readonly subpath?: { + readonly workspace?: string | undefined + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["subpath"] + readonly cursor?: { + readonly workspace?: string | undefined + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["cursor"] +} + +export type SessionsListOutput = { + readonly data: ReadonlyArray<{ + readonly id: string + readonly parentID?: string + readonly projectID: string + readonly agent?: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly time: { readonly created: number; readonly updated: number; readonly archived?: number } + readonly title: string + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subpath?: string + readonly revert?: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + } + }> + readonly cursor: { readonly previous?: string | null; readonly next?: string | null } +} + +export type SessionsCreateInput = { + readonly id?: { + readonly id?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly location?: { readonly directory: string; readonly workspaceID?: string } | null + }["id"] + readonly agent?: { + readonly id?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly location?: { readonly directory: string; readonly workspaceID?: string } | null + }["agent"] + readonly model?: { + readonly id?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly location?: { readonly directory: string; readonly workspaceID?: string } | null + }["model"] + readonly location?: { + readonly id?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly location?: { readonly directory: string; readonly workspaceID?: string } | null + }["location"] +} + +export type SessionsCreateOutput = { + readonly data: { + readonly id: string + readonly parentID?: string + readonly projectID: string + readonly agent?: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly time: { readonly created: number; readonly updated: number; readonly archived?: number } + readonly title: string + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subpath?: string + readonly revert?: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + } + } +}["data"] + +export type SessionsActiveOutput = { readonly data: { readonly [x: string]: { readonly type: "running" } } }["data"] + +export type SessionsGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsGetOutput = { + readonly data: { + readonly id: string + readonly parentID?: string + readonly projectID: string + readonly agent?: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly time: { readonly created: number; readonly updated: number; readonly archived?: number } + readonly title: string + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subpath?: string + readonly revert?: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + } + } +}["data"] + +export type SessionsSwitchAgentInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly agent: { readonly agent: string }["agent"] +} + +export type SessionsSwitchAgentOutput = void + +export type SessionsSwitchModelInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly model: { + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + }["model"] +} + +export type SessionsSwitchModelOutput = void + +export type SessionsPromptInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly id?: { + readonly id?: string | null + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["id"] + readonly prompt: { + readonly id?: string | null + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["prompt"] + readonly delivery?: { + readonly id?: string | null + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["delivery"] + readonly resume?: { + readonly id?: string | null + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["resume"] +} + +export type SessionsPromptOutput = { + readonly data: { + readonly admittedSeq: number + readonly id: string + readonly sessionID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + readonly timeCreated: number + readonly promotedSeq?: number + } +}["data"] + +export type SessionsCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsCompactOutput = void + +export type SessionsWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsWaitOutput = void + +export type SessionsStageInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly messageID: { readonly messageID: string; readonly files?: boolean | undefined }["messageID"] + readonly files?: { readonly messageID: string; readonly files?: boolean | undefined }["files"] +} + +export type SessionsStageOutput = { + readonly data: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + } +}["data"] + +export type SessionsClearInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsClearOutput = void + +export type SessionsCommitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsCommitOutput = void + +export type SessionsContextInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsContextOutput = { + readonly data: ReadonlyArray< + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "agent-switched" + readonly agent: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "model-switched" + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly type: "user" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly sessionID: string + readonly text: string + readonly type: "synthetic" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "system" + readonly text: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } + readonly type: "shell" + readonly callID: string + readonly command: string + readonly output: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } + readonly type: "assistant" + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly id: string; readonly text: string } + | { + readonly type: "reasoning" + readonly id: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly time?: { readonly created: number; readonly completed?: number } + } + | { + readonly type: "tool" + readonly id: string + readonly name: string + readonly provider?: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + readonly state: + | { readonly status: "pending"; readonly input: string } + | { + readonly status: "running" + readonly input: { readonly [x: string]: JsonValue } + readonly structured: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + } + | { + readonly status: "completed" + readonly input: { readonly [x: string]: JsonValue } + readonly attachments?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly outputPaths?: ReadonlyArray + readonly structured: { readonly [x: string]: JsonValue } + readonly result?: JsonValue + } + | { + readonly status: "error" + readonly input: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly structured: { readonly [x: string]: JsonValue } + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: JsonValue + } + readonly time: { + readonly created: number + readonly ran?: number + readonly completed?: number + readonly pruned?: number + } + } + > + readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } + readonly finish?: string + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly error?: { readonly type: "unknown"; readonly message: string } + } + | { + readonly type: "compaction" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + } + > +}["data"] + +export type SessionsHistoryInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly limit?: { readonly limit?: number | undefined; readonly after?: number | undefined }["limit"] + readonly after?: { readonly limit?: number | undefined; readonly after?: number | undefined }["after"] +} + +export type SessionsHistoryOutput = { + readonly data: ReadonlyArray< + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.agent.switched" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly agent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.model.switched" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.moved" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subdirectory?: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.prompted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.prompt.admitted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.context.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.synthetic" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.shell.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly callID: string + readonly command: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.shell.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly callID: string + readonly output: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.step.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly snapshot?: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.step.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly finish: string + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly snapshot?: string + readonly files?: ReadonlyArray + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.step.failed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly error: { readonly type: "unknown"; readonly message: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.text.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.text.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.input.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly name: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.input.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.called" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly tool: string + readonly input: { readonly [x: string]: JsonValue } + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.progress" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.success" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly outputPaths?: ReadonlyArray + readonly result?: JsonValue + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.failed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: JsonValue + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.reasoning.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.reasoning.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.retried" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly attempt: number + readonly error: { + readonly message: string + readonly statusCode?: number + readonly isRetryable: boolean + readonly responseHeaders?: { readonly [x: string]: string } + readonly responseBody?: string + readonly metadata?: { readonly [x: string]: string } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.compaction.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.compaction.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + readonly text: string + readonly recent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.revert.staged" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly revert: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.revert.cleared" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.revert.committed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string } + } + > + readonly hasMore: boolean +} + +export type SessionsEventsInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly after?: { readonly after?: number | undefined }["after"] +} + +export type SessionsEventsOutput = + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.agent.switched" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly agent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.model.switched" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.moved" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subdirectory?: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.prompted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.prompt.admitted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.context.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.synthetic" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.shell.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly callID: string + readonly command: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.shell.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly callID: string + readonly output: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.step.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly snapshot?: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.step.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly finish: string + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly snapshot?: string + readonly files?: ReadonlyArray + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.step.failed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly error: { readonly type: "unknown"; readonly message: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.text.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.text.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.input.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly name: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.input.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.called" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly tool: string + readonly input: { readonly [x: string]: unknown } + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.progress" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: unknown } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.success" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: unknown } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly outputPaths?: ReadonlyArray + readonly result?: unknown + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.failed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: unknown + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.reasoning.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.reasoning.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.retried" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly attempt: number + readonly error: { + readonly message: string + readonly statusCode?: number + readonly isRetryable: boolean + readonly responseHeaders?: { readonly [x: string]: string } + readonly responseBody?: string + readonly metadata?: { readonly [x: string]: string } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.compaction.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.compaction.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + readonly text: string + readonly recent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.revert.staged" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly revert: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.revert.cleared" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.revert.committed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string } + } + +export type SessionsInterruptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsInterruptOutput = void + +export type SessionsMessageInput = { + readonly sessionID: { readonly sessionID: string; readonly messageID: string }["sessionID"] + readonly messageID: { readonly sessionID: string; readonly messageID: string }["messageID"] +} + +export type SessionsMessageOutput = { + readonly data: + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "agent-switched" + readonly agent: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "model-switched" + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly type: "user" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly sessionID: string + readonly text: string + readonly type: "synthetic" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "system" + readonly text: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } + readonly type: "shell" + readonly callID: string + readonly command: string + readonly output: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } + readonly type: "assistant" + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly id: string; readonly text: string } + | { + readonly type: "reasoning" + readonly id: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly time?: { readonly created: number; readonly completed?: number } + } + | { + readonly type: "tool" + readonly id: string + readonly name: string + readonly provider?: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + readonly state: + | { readonly status: "pending"; readonly input: string } + | { + readonly status: "running" + readonly input: { readonly [x: string]: JsonValue } + readonly structured: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + } + | { + readonly status: "completed" + readonly input: { readonly [x: string]: JsonValue } + readonly attachments?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly outputPaths?: ReadonlyArray + readonly structured: { readonly [x: string]: JsonValue } + readonly result?: JsonValue + } + | { + readonly status: "error" + readonly input: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly structured: { readonly [x: string]: JsonValue } + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: JsonValue + } + readonly time: { + readonly created: number + readonly ran?: number + readonly completed?: number + readonly pruned?: number + } + } + > + readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } + readonly finish?: string + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly error?: { readonly type: "unknown"; readonly message: string } + } + | { + readonly type: "compaction" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + } +}["data"] + +export type MessagesListInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly limit?: { + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly cursor?: string | undefined + }["limit"] + readonly order?: { + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly cursor?: string | undefined + }["order"] + readonly cursor?: { + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly cursor?: string | undefined + }["cursor"] +} + +export type MessagesListOutput = { + readonly data: ReadonlyArray< + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "agent-switched" + readonly agent: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "model-switched" + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly type: "user" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly sessionID: string + readonly text: string + readonly type: "synthetic" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "system" + readonly text: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } + readonly type: "shell" + readonly callID: string + readonly command: string + readonly output: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } + readonly type: "assistant" + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly id: string; readonly text: string } + | { + readonly type: "reasoning" + readonly id: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly time?: { readonly created: number; readonly completed?: number } + } + | { + readonly type: "tool" + readonly id: string + readonly name: string + readonly provider?: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + readonly state: + | { readonly status: "pending"; readonly input: string } + | { + readonly status: "running" + readonly input: { readonly [x: string]: JsonValue } + readonly structured: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + } + | { + readonly status: "completed" + readonly input: { readonly [x: string]: JsonValue } + readonly attachments?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly outputPaths?: ReadonlyArray + readonly structured: { readonly [x: string]: JsonValue } + readonly result?: JsonValue + } + | { + readonly status: "error" + readonly input: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly structured: { readonly [x: string]: JsonValue } + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: JsonValue + } + readonly time: { + readonly created: number + readonly ran?: number + readonly completed?: number + readonly pruned?: number + } + } + > + readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } + readonly finish?: string + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly error?: { readonly type: "unknown"; readonly message: string } + } + | { + readonly type: "compaction" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + } + > + readonly cursor: { readonly previous?: string | null; readonly next?: string | null } +} + +export type ModelsListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ModelsListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly id: string + readonly providerID: string + readonly family?: string + readonly name: string + readonly api: + | { + readonly id: string + readonly type: "aisdk" + readonly package: string + readonly url?: string + readonly settings?: { readonly [x: string]: JsonValue } + } + | { + readonly id: string + readonly type: "native" + readonly url?: string + readonly settings: { readonly [x: string]: JsonValue } + } + readonly capabilities: { + readonly tools: boolean + readonly input: ReadonlyArray + readonly output: ReadonlyArray + } + readonly request: { + readonly headers: { readonly [x: string]: string } + readonly body: { readonly [x: string]: JsonValue } + readonly variant?: string + } + readonly variants: ReadonlyArray<{ + readonly id: string + readonly headers: { readonly [x: string]: string } + readonly body: { readonly [x: string]: JsonValue } + }> + readonly time: { readonly released: number } + readonly cost: ReadonlyArray<{ + readonly tier?: { readonly type: "context"; readonly size: number } + readonly input: number + readonly output: number + readonly cache: { readonly read: number; readonly write: number } + }> + readonly status: "alpha" | "beta" | "deprecated" | "active" + readonly enabled: boolean + readonly limit: { readonly context: number; readonly input?: number; readonly output: number } + }> +} + +export type ProvidersListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ProvidersListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly id: string + readonly integrationID?: string + readonly name: string + readonly disabled?: boolean + readonly api: + | { + readonly type: "aisdk" + readonly package: string + readonly url?: string + readonly settings?: { readonly [x: string]: JsonValue } + } + | { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } } + readonly request: { + readonly headers: { readonly [x: string]: string } + readonly body: { readonly [x: string]: JsonValue } + } + }> +} + +export type ProvidersGetInput = { + readonly providerID: { readonly providerID: string }["providerID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ProvidersGetOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly id: string + readonly integrationID?: string + readonly name: string + readonly disabled?: boolean + readonly api: + | { + readonly type: "aisdk" + readonly package: string + readonly url?: string + readonly settings?: { readonly [x: string]: JsonValue } + } + | { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } } + readonly request: { + readonly headers: { readonly [x: string]: string } + readonly body: { readonly [x: string]: JsonValue } + } + } +} + +export type IntegrationsListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type IntegrationsListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly id: string + readonly name: string + readonly methods: ReadonlyArray< + | { + readonly id: string + readonly type: "oauth" + readonly label: string + readonly prompts?: ReadonlyArray< + | { + readonly type: "text" + readonly key: string + readonly message: string + readonly placeholder?: string + readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string } + } + | { + readonly type: "select" + readonly key: string + readonly message: string + readonly options: ReadonlyArray<{ + readonly label: string + readonly value: string + readonly hint?: string + }> + readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string } + } + > + } + | { readonly type: "key"; readonly label?: string } + | { readonly type: "env"; readonly names: ReadonlyArray } + > + readonly connections: ReadonlyArray< + | { readonly type: "credential"; readonly id: string; readonly label: string } + | { readonly type: "env"; readonly name: string } + > + }> +} + +export type IntegrationsGetInput = { + readonly integrationID: { readonly integrationID: string }["integrationID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type IntegrationsGetOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly id: string + readonly name: string + readonly methods: ReadonlyArray< + | { + readonly id: string + readonly type: "oauth" + readonly label: string + readonly prompts?: ReadonlyArray< + | { + readonly type: "text" + readonly key: string + readonly message: string + readonly placeholder?: string + readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string } + } + | { + readonly type: "select" + readonly key: string + readonly message: string + readonly options: ReadonlyArray<{ + readonly label: string + readonly value: string + readonly hint?: string + }> + readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string } + } + > + } + | { readonly type: "key"; readonly label?: string } + | { readonly type: "env"; readonly names: ReadonlyArray } + > + readonly connections: ReadonlyArray< + | { readonly type: "credential"; readonly id: string; readonly label: string } + | { readonly type: "env"; readonly name: string } + > + } | null +} + +export type IntegrationsConnectKeyInput = { + readonly integrationID: { readonly integrationID: string }["integrationID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly key: { readonly key: string; readonly label?: string | undefined }["key"] + readonly label?: { readonly key: string; readonly label?: string | undefined }["label"] +} + +export type IntegrationsConnectKeyOutput = void + +export type IntegrationsConnectOauthInput = { + readonly integrationID: { readonly integrationID: string }["integrationID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly methodID: { + readonly methodID: string + readonly inputs: { readonly [x: string]: string } + readonly label?: string | undefined + }["methodID"] + readonly inputs: { + readonly methodID: string + readonly inputs: { readonly [x: string]: string } + readonly label?: string | undefined + }["inputs"] + readonly label?: { + readonly methodID: string + readonly inputs: { readonly [x: string]: string } + readonly label?: string | undefined + }["label"] +} + +export type IntegrationsConnectOauthOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly attemptID: string + readonly url: string + readonly instructions: string + readonly mode: "auto" | "code" + readonly time: { + readonly created: number | "Infinity" | "-Infinity" | "NaN" + readonly expires: number | "Infinity" | "-Infinity" | "NaN" + } + } +} + +export type IntegrationsAttemptStatusInput = { + readonly attemptID: { readonly attemptID: string }["attemptID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type IntegrationsAttemptStatusOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: + | { + readonly status: "pending" + readonly time: { + readonly created: number | "Infinity" | "-Infinity" | "NaN" + readonly expires: number | "Infinity" | "-Infinity" | "NaN" + } + } + | { + readonly status: "complete" + readonly time: { + readonly created: number | "Infinity" | "-Infinity" | "NaN" + readonly expires: number | "Infinity" | "-Infinity" | "NaN" + } + } + | { + readonly status: "failed" + readonly message: string + readonly time: { + readonly created: number | "Infinity" | "-Infinity" | "NaN" + readonly expires: number | "Infinity" | "-Infinity" | "NaN" + } + } + | { + readonly status: "expired" + readonly time: { + readonly created: number | "Infinity" | "-Infinity" | "NaN" + readonly expires: number | "Infinity" | "-Infinity" | "NaN" + } + } +} + +export type IntegrationsAttemptCompleteInput = { + readonly attemptID: { readonly attemptID: string }["attemptID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly code?: { readonly code?: string | undefined }["code"] +} + +export type IntegrationsAttemptCompleteOutput = void + +export type IntegrationsAttemptCancelInput = { + readonly attemptID: { readonly attemptID: string }["attemptID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type IntegrationsAttemptCancelOutput = void + +export type CredentialsUpdateInput = { + readonly credentialID: { readonly credentialID: string }["credentialID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly label: { readonly label: string }["label"] +} + +export type CredentialsUpdateOutput = void + +export type CredentialsRemoveInput = { + readonly credentialID: { readonly credentialID: string }["credentialID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type CredentialsRemoveOutput = void + +export type PermissionsListRequestsInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type PermissionsListRequestsOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly id: string + readonly sessionID: string + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + }> +} + +export type PermissionsListSavedInput = { + readonly projectID?: { readonly projectID?: string | undefined }["projectID"] +} + +export type PermissionsListSavedOutput = { + readonly data: ReadonlyArray<{ + readonly id: string + readonly projectID: string + readonly action: string + readonly resource: string + }> +}["data"] + +export type PermissionsRemoveSavedInput = { readonly id: { readonly id: string }["id"] } + +export type PermissionsRemoveSavedOutput = void + +export type PermissionsCreateInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly id?: { + readonly id?: string | null + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + readonly agent?: string | null + }["id"] + readonly action: { + readonly id?: string | null + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + readonly agent?: string | null + }["action"] + readonly resources: { + readonly id?: string | null + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + readonly agent?: string | null + }["resources"] + readonly save?: { + readonly id?: string | null + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + readonly agent?: string | null + }["save"] + readonly metadata?: { + readonly id?: string | null + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + readonly agent?: string | null + }["metadata"] + readonly source?: { + readonly id?: string | null + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + readonly agent?: string | null + }["source"] + readonly agent?: { + readonly id?: string | null + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + readonly agent?: string | null + }["agent"] +} + +export type PermissionsCreateOutput = { + readonly data: { readonly id: string; readonly effect: "allow" | "deny" | "ask" } +}["data"] + +export type PermissionsListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type PermissionsListOutput = { + readonly data: ReadonlyArray<{ + readonly id: string + readonly sessionID: string + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + }> +}["data"] + +export type PermissionsGetInput = { + readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"] + readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"] +} + +export type PermissionsGetOutput = { + readonly data: { + readonly id: string + readonly sessionID: string + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + } +}["data"] + +export type PermissionsReplyInput = { + readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"] + readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"] + readonly reply: { readonly reply: "once" | "always" | "reject"; readonly message?: string | undefined }["reply"] + readonly message?: { readonly reply: "once" | "always" | "reject"; readonly message?: string | undefined }["message"] +} + +export type PermissionsReplyOutput = void + +export type FilesListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly path?: string | undefined + }["location"] + readonly path?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly path?: string | undefined + }["path"] +} + +export type FilesListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ readonly path: string; readonly type: "file" | "directory" }> +} + +export type FilesFindInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly query: string + readonly type?: "file" | "directory" | undefined + readonly limit?: number | undefined + }["location"] + readonly query: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly query: string + readonly type?: "file" | "directory" | undefined + readonly limit?: number | undefined + }["query"] + readonly type?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly query: string + readonly type?: "file" | "directory" | undefined + readonly limit?: number | undefined + }["type"] + readonly limit?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly query: string + readonly type?: "file" | "directory" | undefined + readonly limit?: number | undefined + }["limit"] +} + +export type FilesFindOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ readonly path: string; readonly type: "file" | "directory" }> +} + +export type CommandsListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type CommandsListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly name: string + readonly template: string + readonly description?: string + readonly agent?: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly subtask?: boolean + }> +} + +export type SkillsListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type SkillsListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly name: string + readonly description?: string + readonly slash?: boolean + readonly location: string + readonly content: string + }> +} + +export type EventsSubscribeOutput = OpenCodeEventEncoded + +export type PtysListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type PtysListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly id: string + readonly title: string + readonly command: string + readonly args: ReadonlyArray + readonly cwd: string + readonly status: "running" | "exited" + readonly pid: number + readonly exitCode?: number + }> +} + +export type PtysCreateInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly command?: { + readonly command?: string + readonly args?: ReadonlyArray + readonly cwd?: string + readonly title?: string + readonly env?: { readonly [x: string]: string } + }["command"] + readonly args?: { + readonly command?: string + readonly args?: ReadonlyArray + readonly cwd?: string + readonly title?: string + readonly env?: { readonly [x: string]: string } + }["args"] + readonly cwd?: { + readonly command?: string + readonly args?: ReadonlyArray + readonly cwd?: string + readonly title?: string + readonly env?: { readonly [x: string]: string } + }["cwd"] + readonly title?: { + readonly command?: string + readonly args?: ReadonlyArray + readonly cwd?: string + readonly title?: string + readonly env?: { readonly [x: string]: string } + }["title"] + readonly env?: { + readonly command?: string + readonly args?: ReadonlyArray + readonly cwd?: string + readonly title?: string + readonly env?: { readonly [x: string]: string } + }["env"] +} + +export type PtysCreateOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly id: string + readonly title: string + readonly command: string + readonly args: ReadonlyArray + readonly cwd: string + readonly status: "running" | "exited" + readonly pid: number + readonly exitCode?: number + } +} + +export type PtysGetInput = { + readonly ptyID: { readonly ptyID: string }["ptyID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type PtysGetOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly id: string + readonly title: string + readonly command: string + readonly args: ReadonlyArray + readonly cwd: string + readonly status: "running" | "exited" + readonly pid: number + readonly exitCode?: number + } +} + +export type PtysUpdateInput = { + readonly ptyID: { readonly ptyID: string }["ptyID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly title?: { + readonly title?: string + readonly size?: { readonly rows: number; readonly cols: number } + }["title"] + readonly size?: { readonly title?: string; readonly size?: { readonly rows: number; readonly cols: number } }["size"] +} + +export type PtysUpdateOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly id: string + readonly title: string + readonly command: string + readonly args: ReadonlyArray + readonly cwd: string + readonly status: "running" | "exited" + readonly pid: number + readonly exitCode?: number + } +} + +export type PtysRemoveInput = { + readonly ptyID: { readonly ptyID: string }["ptyID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type PtysRemoveOutput = void + +export type QuestionsListRequestsInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type QuestionsListRequestsOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly id: string + readonly sessionID: string + readonly questions: ReadonlyArray<{ + readonly question: string + readonly header: string + readonly options: ReadonlyArray<{ readonly label: string; readonly description: string }> + readonly multiple?: boolean + readonly custom?: boolean + }> + readonly tool?: { readonly messageID: string; readonly callID: string } + }> +} + +export type QuestionsListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type QuestionsListOutput = { + readonly data: ReadonlyArray<{ + readonly id: string + readonly sessionID: string + readonly questions: ReadonlyArray<{ + readonly question: string + readonly header: string + readonly options: ReadonlyArray<{ readonly label: string; readonly description: string }> + readonly multiple?: boolean + readonly custom?: boolean + }> + readonly tool?: { readonly messageID: string; readonly callID: string } + }> +}["data"] + +export type QuestionsReplyInput = { + readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"] + readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"] + readonly answers: { readonly answers: ReadonlyArray> }["answers"] +} + +export type QuestionsReplyOutput = void + +export type QuestionsRejectInput = { + readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"] + readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"] +} + +export type QuestionsRejectOutput = void + +export type ReferencesListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ReferencesListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly name: string + readonly path: string + readonly description?: string + readonly hidden?: boolean + readonly source: + | { readonly type: "local"; readonly path: string; readonly description?: string; readonly hidden?: boolean } + | { + readonly type: "git" + readonly repository: string + readonly branch?: string + readonly description?: string + readonly hidden?: boolean + } + }> +} + +export type ProjectCopiesCreateInput = { + readonly projectID: { readonly projectID: string }["projectID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly strategy: { readonly strategy: string; readonly directory: string; readonly name?: string }["strategy"] + readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"] + readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"] +} + +export type ProjectCopiesCreateOutput = { readonly directory: string } + +export type ProjectCopiesRemoveInput = { + readonly projectID: { readonly projectID: string }["projectID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly directory: { readonly directory: string; readonly force: boolean }["directory"] + readonly force: { readonly directory: string; readonly force: boolean }["force"] +} + +export type ProjectCopiesRemoveOutput = void + +export type ProjectCopiesRefreshInput = { + readonly projectID: { readonly projectID: string }["projectID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ProjectCopiesRefreshOutput = void diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000000..6955d7d8c5 --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,2 @@ +export * from "./generated/index" +export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types" diff --git a/packages/client/sst-env.d.ts b/packages/client/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/client/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts new file mode 100644 index 0000000000..64a2e958ce --- /dev/null +++ b/packages/client/test/contract-identity.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test" +import { Schema } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Location as CoreLocation } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/input" +import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message" +import { Prompt as CorePrompt } from "@opencode-ai/core/session/prompt" +import { Agent } from "@opencode-ai/schema/agent" +import { Location } from "@opencode-ai/schema/location" +import { Model } from "@opencode-ai/schema/model" +import { Project } from "@opencode-ai/schema/project" +import { Provider } from "@opencode-ai/schema/provider" +import { Prompt } from "@opencode-ai/schema/prompt" +import { Session } from "@opencode-ai/schema/session" +import { SessionInput } from "@opencode-ai/schema/session-input" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Workspace } from "@opencode-ai/schema/workspace" +import { Api } from "@opencode-ai/server/api" +import { compile, emitPromise } from "@opencode-ai/httpapi-codegen" +import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract" + +test("Core and Server reuse the authoritative Schema and Protocol values", () => { + expect(AgentV2.ID).toBe(Agent.ID) + expect(CoreLocation.Ref).toBe(Location.Ref) + expect(ModelV2.Ref).toBe(Model.Ref) + expect(SessionV2.Info).toBe(Session.Info) + expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted) + expect(CoreSessionMessage.Message).toBe(SessionMessage.Message) + expect(CorePrompt).toBe(Prompt) + expect(Api.groups["server.session"].identifier).toBe("server.session") + expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups)) + expect(Session.ID.create()).toStartWith("ses_") + expect(Project.ID.global).toBe("global") + expect(Provider.ID.anthropic).toBe("anthropic") + expect(Workspace.ID.create()).toStartWith("wrk_") +}) + +test("client and Server contracts generate identically", () => { + const server = compile(Api, { groupNames, endpointNames, omitEndpoints }) + const client = compile(ClientApi, { groupNames, endpointNames, omitEndpoints }) + + expect(emitPromise(client)).toEqual(emitPromise(server)) +}) + +test("shared DTO schemas construct and decode plain objects", () => { + const made = Prompt.make({ text: "hello" }) + const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" }) + const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" }) + + expect(Object.getPrototypeOf(made)).toBe(Object.prototype) + expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype) + expect(Object.getPrototypeOf(content)).toBe(Object.prototype) + expect(Prompt.ast.annotations?.identifier).toBe("Prompt") + expect(SessionMessage.AssistantText.ast.annotations?.identifier).toBe("Session.Message.Assistant.Text") + expect(CoreSessionMessage.AssistantText).toBe(SessionMessage.AssistantText) +}) diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts new file mode 100644 index 0000000000..7bf4d26f8f --- /dev/null +++ b/packages/client/test/effect.test.ts @@ -0,0 +1,246 @@ +import { expect, test } from "bun:test" +import { DateTime, Effect, Stream } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect" + +test("sessions.get returns the decoded Effect projection", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))), + ) + const result = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client.sessions.get({ sessionID: Session.ID.make("ses_test") }) + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000) +}) + +test("events.subscribe exposes and decodes the native Effect event stream", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + `data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` + + `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ), + ), + ), + ) + const events = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client.events.subscribe().pipe(Stream.runCollect) + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"]) + const durable = events[1] + if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event") + expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000) + expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 }) +}) + +test("events.subscribe terminates on Effect protocol decode failures", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(`data: {"type":"server.connected"}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }), + ), + ), + ) + const error = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip) + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(error._tag).toBe("ClientError") +}) + +test("session methods retain decoded Effect inputs and outputs", async () => { + const historyQueries: Array> = [] + let historyPage = 0 + const httpClient = HttpClient.make((request) => { + const url = request.url + if (url.includes("/event")) { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }), + ), + ) + } + if (url.includes("/history")) { + historyPage++ + historyQueries.push(Object.fromEntries(request.urlParams.params)) + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false }, + ), + ), + ) + } + if (url.includes("/prompt")) { + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission))) + } + if (url.includes("/context")) { + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] }))) + } + if (url.includes("/message/")) { + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: modelSwitchedMessage }))) + } + if (url.endsWith("/api/session/active")) { + return Effect.succeed( + HttpClientResponse.fromWeb(request, Response.json({ data: { ses_test: { type: "running" } } })), + ) + } + if (request.method === "POST" && url.endsWith("/api/session")) { + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))) + } + if (request.method === "POST") { + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))) + } + return Effect.succeed( + HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })), + ) + }) + const result = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + const page = yield* client.sessions.list({ limit: 10 }) + const active = yield* client.sessions.active() + const created = yield* client.sessions.create({ + location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }), + }) + yield* client.sessions.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") }) + yield* client.sessions.switchModel({ + sessionID: Session.ID.make("ses_test"), + model: Model.Ref.make({ id: "claude", providerID: "anthropic" }), + }) + const admitted = yield* client.sessions.prompt({ + sessionID: Session.ID.make("ses_test"), + prompt: Prompt.make({ text: "Hello" }), + resume: false, + }) + yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") }) + yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") }) + const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") }) + const history = yield* client.sessions.history({ + sessionID: Session.ID.make("ses_test"), + after: 0, + limit: 1, + }) + const historyNext = history.hasMore + ? yield* client.sessions.history({ + sessionID: Session.ID.make("ses_test"), + after: history.data.at(-1)?.durable?.seq, + limit: 2, + }) + : undefined + const events = yield* client.sessions + .events({ sessionID: Session.ID.make("ses_test"), after: 0 }) + .pipe(Stream.runCollect) + yield* client.sessions.interrupt({ sessionID: Session.ID.make("ses_test") }) + const message = yield* client.sessions.message({ + sessionID: Session.ID.make("ses_test"), + messageID: SessionMessage.ID.make("msg_model"), + }) + return { page, active, created, admitted, context, history, historyNext, events, message } + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000) + expect(result.active).toEqual({ ses_test: { type: "running" } }) + expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype) + expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype) + expect(result.created.id).toBe("ses_test") + expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype) + expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype) + expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) + expect(result.context).toEqual([]) + expect(DateTime.toEpochMillis(result.history.data[0].data.timestamp)).toBe(1_717_171_717_000) + expect(result.history).toEqual(expect.objectContaining({ hasMore: true })) + expect(result.historyNext).toEqual({ data: [], hasMore: false }) + expect(historyQueries[0]).toEqual({ limit: "1", after: "0" }) + expect(historyQueries[1]).toEqual({ limit: "2", after: "1" }) + expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000) + expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" })) +}) + +test("sessions.history retains the typed SessionNotFoundError", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + { _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" }, + { status: 404 }, + ), + ), + ), + ) + const error = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client.sessions + .history({ + sessionID: Session.ID.make("ses_missing"), + }) + .pipe(Effect.flip) + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(error._tag).toBe("SessionNotFoundError") +}) + +const session = { + data: { + id: "ses_test", + projectID: "project", + cost: 0, + tokens: { + input: 1, + output: 2, + reasoning: 3, + cache: { read: 4, write: 5 }, + }, + time: { + created: 1_717_171_717_000, + updated: 1_717_171_717_000, + }, + title: "Test", + location: { directory: "/tmp/project" }, + }, +} + +const admission = { + data: { + admittedSeq: 0, + id: "msg_test", + sessionID: "ses_test", + prompt: { text: "Hello" }, + delivery: "steer", + timeCreated: 1_717_171_717_000, + }, +} + +const modelSwitchedMessage = { + id: "msg_model", + type: "model-switched", + time: { created: 1_717_171_717_000 }, + model: { id: "claude", providerID: "anthropic" }, +} + +const modelSwitchedEvent = { + id: "evt_model", + type: "session.next.model.switched", + durable: { aggregateID: "ses_test", seq: 1, version: 1 }, + data: { + timestamp: 1_717_171_717_000, + sessionID: "ses_test", + messageID: "msg_model", + model: { id: "claude", providerID: "anthropic" }, + }, +} diff --git a/packages/client/test/import-boundaries.test.ts b/packages/client/test/import-boundaries.test.ts new file mode 100644 index 0000000000..4875a3a5dc --- /dev/null +++ b/packages/client/test/import-boundaries.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test" +import { realpathSync } from "node:fs" +import { mkdtemp, rm } from "node:fs/promises" +import { join, resolve, sep } from "node:path" + +const directory = resolve(import.meta.dir, "..") +const effect = realpathSync(resolve(import.meta.dir, "../node_modules/effect")) +const schema = resolve(import.meta.dir, "../../schema") +const protocol = resolve(import.meta.dir, "../../protocol") +const core = resolve(import.meta.dir, "../../core") +const server = resolve(import.meta.dir, "../../server") + +describe("public import boundaries", () => { + test("isolates each public entrypoint", async () => { + const root = await bundleInputs("@opencode-ai/client", "browser") + + expect(within(root, effect)).toEqual([]) + expect(within(root, schema)).toEqual([]) + expect(within(root, protocol)).toEqual([]) + expect(within(root, core)).toEqual([]) + expect(within(root, server)).toEqual([]) + + 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([]) + }) +}) + +async function bundleInputs(specifier: string, target: "browser" | "bun") { + const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-")) + const entrypoint = join(temporary, "index.ts") + const metafile = join(temporary, "meta.json") + try { + await Bun.write(entrypoint, `export * from ${JSON.stringify(specifier)}`) + const child = Bun.spawn( + [ + process.execPath, + "build", + entrypoint, + `--target=${target}`, + "--format=esm", + "--packages=bundle", + `--metafile=${metafile}`, + `--outdir=${join(temporary, "out")}`, + ], + { cwd: directory, stdout: "pipe", stderr: "pipe" }, + ) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + if (exitCode !== 0) throw new Error(stdout + stderr) + const metadata = await Bun.file(metafile).json() + return Object.keys(metadata.inputs).map((input) => resolve(directory, input)) + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} + +function within(inputs: ReadonlyArray, directory: string) { + const prefix = directory.endsWith(sep) ? directory : directory + sep + return inputs.filter((input) => input === directory || input.startsWith(prefix)) +} diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts new file mode 100644 index 0000000000..322a39cd6b --- /dev/null +++ b/packages/client/test/promise.test.ts @@ -0,0 +1,255 @@ +import { expect, test } from "bun:test" +import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src" + +test("exposes every standard HTTP API group", () => { + const client = OpenCode.make({ baseUrl: "http://localhost:3000" }) + + expect(Object.keys(client)).toEqual([ + "health", + "location", + "agents", + "sessions", + "messages", + "models", + "providers", + "integrations", + "credentials", + "permissions", + "files", + "commands", + "skills", + "events", + "ptys", + "questions", + "references", + "projectCopies", + ]) + expect(Object.keys(client.messages)).toEqual(["list"]) + expect(Object.keys(client.integrations)).toEqual([ + "list", + "get", + "connectKey", + "connectOauth", + "attemptStatus", + "attemptComplete", + "attemptCancel", + ]) + expect(Object.keys(client.files)).toEqual(["list", "find"]) + expect(Object.keys(client.ptys)).toEqual(["list", "create", "get", "update", "remove"]) +}) + +test("sessions.get returns the wire projection", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input) => { + expect(typeof input === "string" ? input : input instanceof URL ? input.href : input.url).toBe( + "http://localhost:3000/api/session/ses_test", + ) + return Response.json(session) + }, + }) + + const result = await client.sessions.get({ sessionID: "ses_test" }) + + expect(result.time.created).toBe(1_717_171_717_000) +}) + +test("events.subscribe exposes the Promise event stream wire projection", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + new Response( + `: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` + + `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ), + }) + const events = [] + for await (const event of client.events.subscribe()) events.push(event) + + expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent]) + expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000) +}) + +test("events.subscribe terminates on malformed Promise SSE data", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }), + }) + + await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({ + name: "ClientError", + reason: "MalformedResponse", + }) +}) + +test("session methods use the public HTTP contract", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = [] + let historyPage = 0 + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + requests.push({ url, init }) + if (url.includes("/event")) { + return new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }) + } + if (url.includes("/history")) { + historyPage++ + return Response.json( + historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false }, + ) + } + if (url.includes("/prompt")) return Response.json(admission) + if (url.includes("/context")) return Response.json({ data: [] }) + if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage }) + if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } }) + if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session) + if (init?.method === "POST") return new Response(null, { status: 204 }) + return Response.json({ data: [session.data], cursor: { next: "next" } }) + }, + }) + + const page = await client.sessions.list({ limit: 10, order: "desc" }) + const active = await client.sessions.active() + const created = await client.sessions.create({ location: { directory: "/tmp/project" } }) + await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" }) + await client.sessions.switchModel({ + sessionID: "ses_test", + model: { id: "claude", providerID: "anthropic" }, + }) + const admitted = await client.sessions.prompt({ + sessionID: "ses_test", + prompt: { text: "Hello" }, + resume: false, + }) + await client.sessions.compact({ sessionID: "ses_test" }) + await client.sessions.wait({ sessionID: "ses_test" }) + const context = await client.sessions.context({ sessionID: "ses_test" }) + const history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 }) + const historyAfter = history.data.at(-1)?.durable?.seq + const historyNext = history.hasMore + ? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 }) + : undefined + const events = [] + for await (const event of client.sessions.events({ sessionID: "ses_test", after: 0 })) events.push(event) + await client.sessions.interrupt({ sessionID: "ses_test" }) + const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" }) + + expect(page.cursor.next).toBe("next") + expect(active).toEqual({ ses_test: { type: "running" } }) + expect(created.id).toBe("ses_test") + expect(admitted.id).toBe("msg_test") + expect(context).toEqual([]) + expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true }) + expect(historyNext).toEqual({ data: [], hasMore: false }) + expect(events).toEqual([modelSwitchedEvent]) + expect(message).toEqual(modelSwitchedMessage) + expect(requests.map((request) => [request.init?.method, request.url])).toEqual([ + ["GET", "http://localhost:3000/api/session?limit=10&order=desc"], + ["GET", "http://localhost:3000/api/session/active"], + ["POST", "http://localhost:3000/api/session"], + ["POST", "http://localhost:3000/api/session/ses_test/agent"], + ["POST", "http://localhost:3000/api/session/ses_test/model"], + ["POST", "http://localhost:3000/api/session/ses_test/prompt"], + ["POST", "http://localhost:3000/api/session/ses_test/compact"], + ["POST", "http://localhost:3000/api/session/ses_test/wait"], + ["GET", "http://localhost:3000/api/session/ses_test/context"], + ["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"], + ["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"], + ["GET", "http://localhost:3000/api/session/ses_test/event?after=0"], + ["POST", "http://localhost:3000/api/session/ses_test/interrupt"], + ["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"], + ]) + const body = requests.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body + if (typeof body !== "string") throw new Error("Expected JSON request body") + expect(JSON.parse(body)).toEqual({ + prompt: { text: "Hello" }, + resume: false, + }) +}) + +test("middleware errors remain declared client errors", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + Response.json({ _tag: "UnauthorizedError", message: "Authentication required" }, { status: 401 }), + }) + + try { + await client.sessions.create({}) + throw new Error("Expected request to fail") + } catch (error) { + expect(isUnauthorizedError(error)).toBe(true) + } +}) + +test("sessions.history decodes SessionNotFoundError", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + Response.json( + { _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" }, + { status: 404 }, + ), + }) + + try { + await client.sessions.history({ sessionID: "ses_missing" }) + throw new Error("Expected request to fail") + } catch (error) { + expect(isSessionNotFoundError(error)).toBe(true) + } +}) + +const session = { + data: { + id: "ses_test", + projectID: "project", + cost: 0, + tokens: { + input: 1, + output: 2, + reasoning: 3, + cache: { read: 4, write: 5 }, + }, + time: { + created: 1_717_171_717_000, + updated: 1_717_171_717_000, + }, + title: "Test", + location: { directory: "/tmp/project" }, + }, +} + +const admission = { + data: { + admittedSeq: 0, + id: "msg_test", + sessionID: "ses_test", + prompt: { text: "Hello" }, + delivery: "steer", + timeCreated: 1_717_171_717_000, + }, +} + +const modelSwitchedMessage = { + id: "msg_model", + type: "model-switched", + time: { created: 1_717_171_717_000 }, + model: { id: "claude", providerID: "anthropic" }, +} + +const modelSwitchedEvent = { + id: "evt_model", + type: "session.next.model.switched", + durable: { aggregateID: "ses_test", seq: 1, version: 1 }, + data: { + timestamp: 1_717_171_717_000, + sessionID: "ses_test", + messageID: "msg_model", + model: { id: "claude", providerID: "anthropic" }, + }, +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000000..47fc90bc55 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + }, + "include": ["src"] +} diff --git a/packages/core/bunfig.toml b/packages/core/bunfig.toml new file mode 100644 index 0000000000..786a377444 --- /dev/null +++ b/packages/core/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./test/preload.ts"] diff --git a/packages/core/package.json b/packages/core/package.json index fd3e5356cd..934146e1a9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.15", + "version": "7.4.16", "name": "@opencode-ai/core", "type": "module", "license": "MIT", @@ -17,7 +17,8 @@ "opencode": "./bin/opencode" }, "exports": { - "./public": "./src/public/index.ts", + "./effect/layer-node": "./src/effect/layer-node.ts", + "./effect/app-node": "./src/effect/app-node.ts", "./session/runner": "./src/session/runner/index.ts", "./system-context": "./src/system-context/index.ts", "./*": "./src/*.ts" @@ -108,7 +109,7 @@ "gitlab-ai-provider": "6.9.3", "google-auth-library": "10.5.0", "immer": "11.1.4", - "venice-ai-sdk-provider": "2.0.2", + "venice-ai-sdk-provider": "2.1.1", "jsonc-parser": "3.3.1", "@effect/sql-sqlite-bun": "catalog:", "@lydell/node-pty": "catalog:", @@ -125,7 +126,10 @@ "turndown": "7.2.0", "which": "6.0.1", "@ff-labs/fff-bun": "0.9.4", - "@silvia-odwyer/photon-node": "0.3.4" + "@silvia-odwyer/photon-node": "0.3.4", + "@opencode-ai/schema": "workspace:*", + "@kilocode/plugin": "workspace:*", + "diff": "catalog:" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/core/schema.json b/packages/core/schema.json index c041a4e011..d0eeeebd5c 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,8 +1,8 @@ { "version": "7", "dialect": "sqlite", - "id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db", - "prevIds": ["abd2f920-b822-49af-b8a7-2e48367d424f"], + "id": "f14a9b18-8207-487e-a3d3-227e629ba9ad", + "prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"], "ddl": [ { "name": "workspace", @@ -900,16 +900,6 @@ "entityType": "columns", "table": "session_context_epoch" }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": "'build'", - "generated": null, - "name": "agent", - "entityType": "columns", - "table": "session_context_epoch" - }, { "type": "text", "notNull": true, @@ -930,26 +920,6 @@ "entityType": "columns", "table": "session_context_epoch" }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "replacement_seq", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "revision", - "entityType": "columns", - "table": "session_context_epoch" - }, { "type": "text", "notNull": false, diff --git a/packages/core/script/migration.ts b/packages/core/script/migration.ts index 48b555b593..4f383f5f8f 100644 --- a/packages/core/script/migration.ts +++ b/packages/core/script/migration.ts @@ -45,15 +45,17 @@ async function generate() { if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`) await Bun.write( target, - renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()), + await formatTypescript( + renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()), + ), ) await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot) } await fs.mkdir(full) await drizzle(temporary, full, "schema") - await Bun.write(schema, renderSchema(await generatedSql(full))) - await Bun.write(registry, renderRegistry(await typescriptMigrations())) + await Bun.write(schema, await formatTypescript(renderSchema(await generatedSql(full)))) + await Bun.write(registry, await formatTypescript(renderRegistry(await typescriptMigrations()))) } finally { await fs.rm(temporary, { recursive: true, force: true }) } @@ -76,12 +78,12 @@ async function check() { await fs.mkdir(full) await drizzle(temporary, full, "schema") - if ((await Bun.file(schema).text()) !== renderSchema(await generatedSql(full))) { + if ((await Bun.file(schema).text()) !== (await formatTypescript(renderSchema(await generatedSql(full))))) { throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.") } const migrations = await typescriptMigrations() - if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) { + if ((await Bun.file(registry).text()) !== (await formatTypescript(renderRegistry(migrations)))) { throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.") } } finally { @@ -170,6 +172,18 @@ function escapeTemplate(line: string) { return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${") } +async function formatTypescript(input: string) { + const prettier = await import("prettier") + const typescript = await import("prettier/plugins/typescript") + const estree = await import("prettier/plugins/estree") + return prettier.format(input, { + parser: "typescript", + plugins: [typescript.default, estree.default], + semi: false, + printWidth: 120, + }) +} + function renderRegistry(names: string[]) { return `import type { DatabaseMigration } from "./migration" diff --git a/packages/core/src/account.ts b/packages/core/src/account.ts index 4de8176e4b..d364d6f344 100644 --- a/packages/core/src/account.ts +++ b/packages/core/src/account.ts @@ -35,19 +35,19 @@ export class Org extends Schema.Class("Org")({ export class AccountRepoError extends Schema.TaggedErrorClass()("AccountRepoError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class AccountServiceError extends Schema.TaggedErrorClass()("AccountServiceError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class AccountTransportError extends Schema.TaggedErrorClass()("AccountTransportError", { method: Schema.String, url: Schema.String, description: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) { static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError { return new AccountTransportError({ @@ -94,7 +94,7 @@ export class PollExpired extends Schema.TaggedClass()("PollExpired" export class PollDenied extends Schema.TaggedClass()("PollDenied", {}) {} export class PollError extends Schema.TaggedClass()("PollError", { - cause: Schema.Defect, + cause: Schema.Defect(), }) {} export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError]) diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index fabf7477d6..da86cf809b 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -1,47 +1,18 @@ export * as AgentV2 from "./agent" -import { Array, Context, Effect, Layer, Schema, Scope } from "effect" -import { castDraft, enableMapSet, type Draft } from "immer" -import { ModelV2 } from "./model" -import { PermissionSchema } from "./permission/schema" -import { ProviderV2 } from "./provider" -import { PositiveInt } from "./schema" +import { makeLocationNode } from "./effect/app-node" +import { Array, Context, Effect, Layer, Types } from "effect" +import { Agent } from "@opencode-ai/schema/agent" import { State } from "./state" -export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID")) +export const ID = Agent.ID export type ID = typeof ID.Type export const defaultID = ID.make("build") -export const Color = Schema.Union([ - Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), - Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), -]) +export const Color = Agent.Color -export class Info extends Schema.Class("AgentV2.Info")({ - id: ID, - model: ModelV2.Ref.pipe(Schema.optional), - request: ProviderV2.Request, - system: Schema.String.pipe(Schema.optional), - description: Schema.String.pipe(Schema.optional), - mode: Schema.Literals(["subagent", "primary", "all"]), - hidden: Schema.Boolean, - color: Color.pipe(Schema.optional), - steps: PositiveInt.pipe(Schema.optional), - permissions: PermissionSchema.Ruleset, -}) { - static empty(id: ID) { - return new Info({ - id, - request: { - headers: {}, - body: {}, - }, - mode: "all", - hidden: false, - permissions: [], - }) - } -} +export const Info = Agent.Info +export type Info = Agent.Info export interface Selection { readonly id: ID @@ -49,21 +20,19 @@ export interface Selection { } type Data = { - agents: Map + agents: Map> default?: ID } -export type Editor = { +export type Draft = { list: () => readonly Info[] get: (id: ID) => Info | undefined default: (id: ID | undefined) => void - update: (id: ID, fn: (agent: Draft) => void) => void + update: (id: ID, fn: (agent: Types.DeepMutable) => void) => void remove: (id: ID) => void } -export interface Interface { - readonly transform: State.Interface["transform"] - readonly update: State.Interface["update"] +export interface Interface extends State.Transformable { readonly get: (id: ID) => Effect.Effect readonly default: () => Effect.Effect readonly resolve: (id?: ID | string) => Effect.Effect @@ -73,21 +42,19 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/Agent") {} -enableMapSet() - -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { - const state = State.create({ + const state = State.create({ initial: () => ({ agents: new Map() }), - editor: (draft) => ({ + draft: (draft) => ({ list: () => Array.fromIterable(draft.agents.values()) as Info[], get: (id) => draft.agents.get(id), default: (id) => { draft.default = id }, update: (id, fn) => { - const current = draft.agents.get(id) ?? castDraft(Info.empty(id)) + const current = draft.agents.get(id) ?? (Info.empty(id) as Types.DeepMutable) if (!draft.agents.has(id)) draft.agents.set(id, current) fn(current) current.id = id @@ -113,7 +80,7 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, - update: state.update, + reload: state.reload, get: Effect.fn("AgentV2.get")(function* (id) { return state.get().agents.get(id) }), @@ -140,3 +107,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 9965ff930d..b604dac664 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -1,14 +1,28 @@ export * as AISDK from "./aisdk" +import { makeLocationNode } from "./effect/app-node" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Cause, Context, Effect, Layer, Schema } from "effect" +import { Cause, Context, Effect, Layer, Schema, Scope } from "effect" import { ModelV2 } from "./model" -import { EventV2 } from "./event" -import { PluginV2 } from "./plugin" import { ProviderV2 } from "./provider" +import { State } from "./state" type SDK = any +export interface SDKEvent { + readonly model: ModelV2.Info + readonly package: string + readonly options: Record + sdk?: SDK +} + +export interface LanguageEvent { + readonly model: ModelV2.Info + readonly sdk: SDK + readonly options: Record + language?: LanguageModelV3 +} + function wrapSSE(res: Response, ms: number, ctl: AbortController) { if (typeof ms !== "number" || ms <= 0) return res if (!res.body) return res @@ -109,7 +123,7 @@ function prepareOptions(model: ModelV2.Info, pkg: string) { export class InitError extends Schema.TaggedErrorClass()("AISDK.InitError", { providerID: ProviderV2.ID, - cause: Schema.Defect, + cause: Schema.Defect(), }) {} function initError(providerID: ProviderV2.ID) { @@ -117,19 +131,70 @@ function initError(providerID: ProviderV2.ID) { } export interface Interface { + readonly hook: { + readonly sdk: ( + callback: (event: SDKEvent) => Effect.Effect | void, + ) => Effect.Effect + readonly language: ( + callback: (event: LanguageEvent) => Effect.Effect | void, + ) => Effect.Effect + } + readonly runSDK: (event: SDKEvent) => Effect.Effect + readonly runLanguage: (event: LanguageEvent) => Effect.Effect readonly language: (model: ModelV2.Info) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/AISDK") {} -export const layer = Layer.effect( +export const locationLayer = Layer.effect( Service, Effect.gen(function* () { - const plugin = yield* PluginV2.Service + let sdkHooks: ((event: SDKEvent) => Effect.Effect | void)[] = [] + let languageHooks: ((event: LanguageEvent) => Effect.Effect | void)[] = [] const languages = new Map() const sdks = new Map() - return Service.of({ + const register = ( + hooks: () => ((event: Event) => Effect.Effect | void)[], + update: (hooks: ((event: Event) => Effect.Effect | void)[]) => void, + ) => + Effect.fn("AISDK.hook")(function* (callback: (event: Event) => Effect.Effect | void) { + const scope = yield* Scope.Scope + let active = true + update([...hooks(), callback]) + const dispose = Effect.sync(() => { + if (!active) return + active = false + update(hooks().filter((item) => item !== callback)) + }) + yield* Scope.addFinalizer(scope, dispose) + return { dispose } + }) + + const run = Effect.fnUntraced(function* ( + hooks: readonly ((event: Event) => Effect.Effect | void)[], + event: Event, + ) { + for (const hook of hooks) { + const result = hook(event) + if (Effect.isEffect(result)) yield* result + } + return event + }) + + const service = Service.of({ + hook: { + sdk: register( + () => sdkHooks, + (next) => (sdkHooks = next), + ), + language: register( + () => languageHooks, + (next) => (languageHooks = next), + ), + }, + runSDK: (event) => run(sdkHooks, event), + runLanguage: (event) => run(languageHooks, event), language: Effect.fn("AISDK.language")(function* (model) { const key = `${model.providerID}/${model.id}/${model.request.variant ?? "default"}` const existing = languages.get(key) @@ -148,26 +213,14 @@ export const layer = Layer.effect( }) const sdk = sdks.get(sdkKey) ?? - (yield* plugin - .trigger("aisdk.sdk", { model, package: model.api.package, options }, {}) - .pipe(initError(model.providerID))).sdk + (yield* service.runSDK({ model, package: model.api.package, options }).pipe(initError(model.providerID))).sdk if (!sdk) return yield* new InitError({ providerID: model.providerID, cause: new Error("No AISDK provider plugin returned an SDK"), }) sdks.set(sdkKey, sdk) - const result = yield* plugin - .trigger( - "aisdk.language", - { - model, - sdk, - options, - }, - {}, - ) - .pipe(initError(model.providerID)) + const result = yield* service.runLanguage({ model, sdk, options }).pipe(initError(model.providerID)) const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.api.id)).pipe( initError(model.providerID), ) @@ -175,7 +228,8 @@ export const layer = Layer.effect( return language }), }) + return service }), ) -export const defaultLayer = layer.pipe(Layer.provide(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))) +export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] }) diff --git a/packages/core/src/background-job.ts b/packages/core/src/background-job.ts index 35724eb8fd..cdffd212bc 100644 --- a/packages/core/src/background-job.ts +++ b/packages/core/src/background-job.ts @@ -2,6 +2,7 @@ export * as BackgroundJob from "./background-job" import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect" import { Identifier } from "./id/id" +import { makeGlobalNode } from "./effect/app-node" export type Status = "running" | "completed" | "error" | "cancelled" @@ -359,6 +360,6 @@ export const make = Effect.gen(function* () { return Service.of({ list, get, start, extend, wait, waitForPromotion, promote, cancel }) }) -export const layer = Layer.effect(Service, make) +const layer = Layer.effect(Service, make) -export const defaultLayer = layer +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 4156db5c36..1945024b54 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -1,57 +1,41 @@ export * as Catalog from "./catalog" -import { Array, Context, Effect, Layer, Option, Order, pipe, Schema, Scope, Stream } from "effect" -import { castDraft, enableMapSet, type Draft } from "immer" +import { makeLocationNode } from "./effect/app-node" +import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect" +import { Catalog } from "@opencode-ai/schema/catalog" import { ModelV2 } from "./model" -import { ModelRequest } from "./model-request" -import { PluginV2 } from "./plugin" import { ProviderV2 } from "./provider" -import { Location } from "./location" import { EventV2 } from "./event" import { Policy } from "./policy" import { State } from "./state" import { Integration } from "./integration" export type ProviderRecord = { - provider: ProviderV2.Info - models: Map + provider: ProviderV2.MutableInfo + models: Map } export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID } -export class ProviderNotFoundError extends Schema.TaggedErrorClass()( - "CatalogV2.ProviderNotFound", - { - providerID: ProviderV2.ID, - }, -) {} - -export class ModelNotFoundError extends Schema.TaggedErrorClass()("CatalogV2.ModelNotFound", { - providerID: ProviderV2.ID, - modelID: ModelV2.ID, -}) {} - export const PolicyActions = Schema.Literals(["provider.use"]) -export const Event = { - Updated: EventV2.define({ type: "catalog.updated", schema: {} }), -} +export const Event = Catalog.Event type Data = { providers: Map defaultModel?: DefaultModel } -export type Editor = { +export type Draft = { provider: { list: () => readonly ProviderRecord[] get: (providerID: ProviderV2.ID) => ProviderRecord | undefined - update: (providerID: ProviderV2.ID, fn: (provider: Draft) => void) => void + update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void remove: (providerID: ProviderV2.ID) => void } model: { get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined - update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft) => void) => void + update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: ModelV2.MutableInfo) => void) => void remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void default: { get: () => DefaultModel | undefined @@ -60,44 +44,35 @@ export type Editor = { } } -export interface Interface { - readonly transform: State.Interface["transform"] +export interface Interface extends State.Transformable { readonly provider: { - readonly get: (providerID: ProviderV2.ID) => Effect.Effect + readonly get: (providerID: ProviderV2.ID) => Effect.Effect readonly all: () => Effect.Effect readonly available: () => Effect.Effect } readonly model: { - readonly get: ( - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - ) => Effect.Effect + readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect readonly all: () => Effect.Effect readonly available: () => Effect.Effect - readonly default: () => Effect.Effect> - readonly small: (providerID: ProviderV2.ID) => Effect.Effect> + readonly default: () => Effect.Effect + readonly small: (providerID: ProviderV2.ID) => Effect.Effect } } export class Service extends Context.Service()("@opencode/v2/Catalog") {} -enableMapSet() - -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { - const location = yield* Location.Service - const plugin = yield* PluginV2.Service const events = yield* EventV2.Service const policy = yield* Policy.Service const integrations = yield* Integration.Service - const scope = yield* Scope.Scope - const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => { + const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => { if (provider.disabled) return false if (typeof provider.request.body.apiKey === "string") return true - if (connected) return true - return !integration + if (integration?.connections.length) return true + return provider.integrationID === undefined && !integration } const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => { @@ -110,42 +85,37 @@ export const layer = Layer.effect( ? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } } : model.api const request = { - ...ModelRequest.merge({ ...provider.request, generation: {}, options: {} }, model.request), + headers: { ...provider.request.headers, ...model.request.headers }, + body: { ...provider.request.body, ...model.request.body }, variant: model.request.variant, } - return new ModelV2.Info({ + return ModelV2.Info.make({ ...model, api, request, }) } - function* getRecord(providerID: ProviderV2.ID) { - const match = state.get().providers.get(providerID) - if (!match) return yield* new ProviderNotFoundError({ providerID }) - return match - } - - const normalizeApi = (item: Draft | Draft) => { + const normalizeApi = (item: ProviderV2.MutableInfo | ModelV2.MutableInfo) => { if (typeof item.request.body.baseURL !== "string") return item.api.url = item.request.body.baseURL delete item.request.body.baseURL } - const state = State.create({ + const state = State.create({ initial: () => ({ providers: new Map() }), - editor: (draft) => { - const result: Editor = { + draft: (draft) => { + const result: Draft = { provider: { list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[], get: (providerID) => draft.providers.get(providerID), update: (providerID, fn) => { let current = draft.providers.get(providerID) if (!current) { - current = castDraft({ - provider: ProviderV2.Info.empty(providerID), - models: new Map(), - }) + current = { + provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo, + models: new Map(), + } draft.providers.set(providerID, current) } fn(current.provider) @@ -160,13 +130,14 @@ export const layer = Layer.effect( update: (providerID, modelID, fn) => { let record = draft.providers.get(providerID) if (!record) { - record = castDraft({ - provider: ProviderV2.Info.empty(providerID), - models: new Map(), - }) + record = { + provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo, + models: new Map(), + } draft.providers.set(providerID, record) } - const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID)) + const model = + record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo) if (!record.models.has(modelID)) record.models.set(modelID, model) fn(model) model.id = modelID @@ -186,8 +157,7 @@ export const layer = Layer.effect( } return result }, - finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) { - if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid) + finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) { if (policy.hasStatements()) { for (const record of [...catalog.provider.list()]) { if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { @@ -198,25 +168,13 @@ export const layer = Layer.effect( yield* events.publish(Event.Updated, {}) }), }) - yield* events.subscribe(PluginV2.Event.Added).pipe( - // Plugin registries are location scoped even though the event bus is process scoped. - Stream.filter( - (event) => - event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID, - ), - Stream.runForEach((event) => - state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"), - ), - Effect.forkIn(scope, { startImmediately: true }), - ) - const result: Interface = { transform: state.transform, + reload: state.reload, provider: { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { - const record = yield* getRecord(providerID) - return record.provider + return state.get().providers.get(providerID)?.provider }), all: Effect.fn("CatalogV2.provider.all")(function* () { @@ -225,23 +183,18 @@ export const layer = Layer.effect( available: Effect.fn("CatalogV2.provider.available")(function* () { const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration])) - const connections = yield* integrations.connection.list() return (yield* result.provider.all()).filter((provider) => - available( - provider, - active.get(Integration.ID.make(provider.id)), - connections.has(Integration.ID.make(provider.id)), - ), + available(provider, active.get(provider.integrationID ?? Integration.ID.make(provider.id))), ) }), }, model: { get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) { - const record = yield* getRecord(providerID) + const record = state.get().providers.get(providerID) + if (!record) return const model = record.models.get(modelID) - if (!model) return yield* new ModelNotFoundError({ providerID, modelID }) - return projectModel(model, record.provider) + return model && projectModel(model, record.provider) }), all: Effect.fn("CatalogV2.model.all")(function* () { @@ -250,7 +203,7 @@ export const layer = Layer.effect( Array.flatMap((record) => { return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider)) }), - Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), + Array.sortWith((item) => item.time.released, Order.flip(Order.Number)), ) }), @@ -262,31 +215,35 @@ export const layer = Layer.effect( default: Effect.fn("CatalogV2.model.default")(function* () { const defaultModel = state.get().defaultModel if (defaultModel) { - const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option) - if ( - Option.isSome(provider) && - (yield* result.provider.available()).some((item) => item.id === provider.value.id) - ) { - const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option) - if (Option.isSome(model) && model.value.enabled) return model + const provider = yield* result.provider.get(defaultModel.providerID) + if (provider && (yield* result.provider.available()).some((item) => item.id === provider.id)) { + const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID) + if (model?.enabled) return model } } - return pipe( - yield* result.model.available(), - Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), - Array.head, + return Option.getOrUndefined( + pipe( + yield* result.model.available(), + Array.sortWith((item) => item.time.released, Order.flip(Order.Number)), + Array.head, + ), ) }), small: Effect.fn("CatalogV2.model.small")(function* (providerID) { const record = state.get().providers.get(providerID) - if (!record) return Option.none() + if (!record) return const provider = record.provider + // TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments. + if (providerID === ProviderV2.ID.azure || providerID === ProviderV2.ID.make("azure-cognitive-services")) { + return + } + if (providerID === ProviderV2.ID.opencode) { const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano")) - if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider)) + if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider) } const candidates = pipe( @@ -302,7 +259,7 @@ export const layer = Layer.effect( Array.map((model) => ({ model, cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999, - age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30), + age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30), small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()), })), Array.filter((item) => item.cost > 0 && item.age <= 18), @@ -319,10 +276,12 @@ export const layer = Layer.effect( ) } - return pipe( - candidates, - Array.filter((item) => item.small), - (items) => (items.length > 0 ? pick(items) : pick(candidates)), + return Option.getOrUndefined( + pipe( + candidates, + Array.filter((item) => item.small), + (items) => (items.length > 0 ? pick(items) : pick(candidates)), + ), ) }), }, @@ -336,6 +295,7 @@ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ export const locationLayer = layer.pipe( Layer.provideMerge(Integration.locationLayer), - Layer.provideMerge(PluginV2.locationLayer), Layer.provideMerge(Policy.locationLayer), ) + +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] }) diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index f6b8210be1..cb082a9f47 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -1,49 +1,41 @@ export * as CommandV2 from "./command" -import { Context, Effect, Layer, Schema } from "effect" -import { castDraft, type Draft } from "immer" -import { ModelV2 } from "./model" +import { makeLocationNode } from "./effect/app-node" +import { Context, Effect, Layer, Types } from "effect" +import { Command } from "@opencode-ai/schema/command" import { State } from "./state" -export class Info extends Schema.Class("CommandV2.Info")({ - name: Schema.String, - template: Schema.String, - description: Schema.String.pipe(Schema.optional), - agent: Schema.String.pipe(Schema.optional), - model: ModelV2.Ref.pipe(Schema.optional), - subtask: Schema.Boolean.pipe(Schema.optional), -}) {} +export const Info = Command.Info +export type Info = Command.Info export type Data = { - commands: Map + commands: Map> } -export type Editor = { +export type Draft = { list: () => readonly Info[] get: (name: string) => Info | undefined - update: (name: string, update: (command: Draft) => void) => void + update: (name: string, update: (command: Types.DeepMutable) => void) => void remove: (name: string) => void } -export interface Interface { - readonly transform: State.Interface["transform"] - readonly update: State.Interface["update"] +export interface Interface extends State.Transformable { readonly get: (name: string) => Effect.Effect readonly list: () => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Command") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.sync(() => { - const state = State.create({ + const state = State.create({ initial: () => ({ commands: new Map() }), - editor: (draft) => ({ + draft: (draft) => ({ list: () => Array.from(draft.commands.values()) as Info[], get: (name) => draft.commands.get(name), update: (name, update) => { - const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" })) + const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable) if (!draft.commands.has(name)) draft.commands.set(name, current) update(current) current.name = name @@ -55,7 +47,7 @@ export const layer = Layer.effect( }) return Service.of({ - update: state.update, + reload: state.reload, transform: state.transform, get: Effect.fn("CommandV2.get")(function* (name) { return state.get().commands.get(name) @@ -68,3 +60,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 26cd3720d9..c76486968b 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1,12 +1,13 @@ export * as Config from "./config" +import { makeLocationNode } from "./effect/app-node" import path from "path" import { type ParseError, parse } from "jsonc-parser" import { Context, Effect, Layer, Option, Schema } from "effect" +import { Permission } from "@opencode-ai/schema/permission" import { FSUtil } from "./fs-util" import { Global } from "./global" import { Location } from "./location" -import { PermissionSchema } from "./permission/schema" import { Policy } from "./policy" import { AbsolutePath } from "./schema" import { ConfigAgent } from "./config/agent" @@ -56,7 +57,7 @@ export class Info extends Schema.Class("Config.Info")({ username: Schema.String.pipe(Schema.optional).annotate({ description: "Username displayed in conversations and used for telemetry identity", }), - permissions: PermissionSchema.Ruleset.pipe(Schema.optional).annotate({ + permissions: Permission.Ruleset.pipe(Schema.optional).annotate({ description: "Ordered tool permission rules applied to agent tool use", }), agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({ @@ -131,14 +132,14 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/Config") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service const global = yield* Global.Service const location = yield* Location.Service const policy = yield* Policy.Service - const names = ["config.json", "opencode.json", "opencode.jsonc"] + const names = ["opencode.json", "opencode.jsonc"] const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions) @@ -218,3 +219,9 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer)) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [FSUtil.node, Global.node, Location.node, Policy.node], +}) diff --git a/packages/core/src/config/agent.ts b/packages/core/src/config/agent.ts index 1dea6044bc..63df995f85 100644 --- a/packages/core/src/config/agent.ts +++ b/packages/core/src/config/agent.ts @@ -1,7 +1,7 @@ export * as ConfigAgent from "./agent" import { Schema } from "effect" -import { PermissionSchema } from "../permission/schema" +import { Permission } from "@opencode-ai/schema/permission" import { ConfigProvider } from "./provider" import { PositiveInt } from "../schema" @@ -21,5 +21,5 @@ export class Info extends Schema.Class("ConfigV2.Agent")({ color: Color.pipe(Schema.optional), steps: PositiveInt.pipe(Schema.optional), disabled: Schema.Boolean.pipe(Schema.optional), - permissions: PermissionSchema.Ruleset.pipe(Schema.optional), + permissions: Permission.Ruleset.pipe(Schema.optional), }) {} diff --git a/packages/core/src/config/mcp.ts b/packages/core/src/config/mcp.ts index 54998e1850..f3a5ac9b25 100644 --- a/packages/core/src/config/mcp.ts +++ b/packages/core/src/config/mcp.ts @@ -3,6 +3,15 @@ export * as ConfigMCP from "./mcp" import { Schema } from "effect" import { PositiveInt } from "../schema" +export class Timeout extends Schema.Class("ConfigV2.MCP.Timeout")({ + startup: PositiveInt.pipe(Schema.optional).annotate({ + description: "Maximum time in milliseconds to establish and initialize the MCP server.", + }), + request: PositiveInt.pipe(Schema.optional).annotate({ + description: "Maximum time in milliseconds to wait for each MCP request after initialization.", + }), +}) {} + export class Local extends Schema.Class("ConfigV2.MCP.Local")({ type: Schema.Literal("local"), command: Schema.String.pipe(Schema.Array), @@ -11,7 +20,7 @@ export class Local extends Schema.Class("ConfigV2.MCP.Local")({ }), environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), disabled: Schema.Boolean.pipe(Schema.optional), - timeout: PositiveInt.pipe(Schema.optional), + timeout: Timeout.pipe(Schema.optional), }) {} export class OAuth extends Schema.Class("ConfigV2.MCP.OAuth")({ @@ -28,12 +37,12 @@ export class Remote extends Schema.Class("ConfigV2.MCP.Remote")({ headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional), disabled: Schema.Boolean.pipe(Schema.optional), - timeout: PositiveInt.pipe(Schema.optional), + timeout: Timeout.pipe(Schema.optional), }) {} export const Server = Schema.Union([Local, Remote]).pipe(Schema.toTaggedUnion("type")) export class Info extends Schema.Class("ConfigV2.MCP")({ - timeout: PositiveInt.pipe(Schema.optional), + timeout: Timeout.pipe(Schema.optional), servers: Schema.Record(Schema.String, Server).pipe(Schema.optional), }) {} diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 36534b0d38..48efe75804 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -1,5 +1,6 @@ export * as ConfigAgentPlugin from "./agent" +import { define } from "../../plugin/internal" import path from "path" import { Effect, Option, Schema } from "effect" import { AgentV2 } from "../../agent" @@ -8,7 +9,6 @@ import { ConfigAgent } from "../agent" import { ConfigMarkdown } from "../markdown" import { FSUtil } from "../../fs-util" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" import { ConfigAgentV1 } from "../../v1/config/agent" import { ConfigMigrateV1 } from "../../v1/config/migrate" @@ -33,70 +33,70 @@ const agentKeys = new Set([ "permissions", ]) -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("config-agent"), - effect: Effect.gen(function* () { - const agent = yield* AgentV2.Service +export const Plugin = define({ + id: "config-agent", + effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service - const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { - if (entry.type === "document") return Effect.succeed([entry]) - return Effect.gen(function* () { - const files = yield* discover(fs, entry.path) - return yield* Effect.forEach(files, (file) => - fs.readFileStringSafe(file.filepath).pipe( - Effect.map((content) => content && decode(file, content)), - Effect.catch(() => Effect.succeed(undefined)), - ), - ).pipe( - Effect.map((documents) => - documents.filter((document): document is Config.Document => document !== undefined), - ), - ) - }) - }).pipe(Effect.map((documents) => documents.flat())) - - yield* agent.update((editor) => { - const global = documents.flatMap((document) => document.info.permissions ?? []) - const configuredDefault = Config.latest(documents, "default_agent") - if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault)) - for (const current of editor.list()) { - editor.update(current.id, (agent) => agent.permissions.push(...global)) - } - - for (const document of documents) { - for (const [id, item] of Object.entries(document.info.agents ?? {})) { - const agentID = AgentV2.ID.make(id) - if (item.disabled) { - editor.remove(agentID) - continue - } - - const exists = editor.get(agentID) !== undefined - editor.update(agentID, (agent) => { - if (!exists) agent.permissions.push(...global) - if (item.model !== undefined) { - const model = ModelV2.parse(item.model) - agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant } - } - if (item.variant !== undefined && agent.model !== undefined) { - agent.model.variant = ModelV2.VariantID.make(item.variant) - } - if (item.request !== undefined) { - Object.assign(agent.request.headers, item.request.headers ?? {}) - Object.assign(agent.request.body, item.request.body ?? {}) - } - if (item.system !== undefined) agent.system = item.system - if (item.description !== undefined) agent.description = item.description - if (item.mode !== undefined) agent.mode = item.mode - if (item.hidden !== undefined) agent.hidden = item.hidden - if (item.color !== undefined) agent.color = item.color - if (item.steps !== undefined) agent.steps = item.steps - if (item.permissions !== undefined) agent.permissions.push(...item.permissions) + yield* ctx.agent.transform( + Effect.fn(function* (draft) { + const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") return Effect.succeed([entry]) + return Effect.gen(function* () { + const files = yield* discover(fs, entry.path) + return yield* Effect.forEach(files, (file) => + fs.readFileStringSafe(file.filepath).pipe( + Effect.map((content) => content && decode(file, content)), + Effect.catch(() => Effect.succeed(undefined)), + ), + ).pipe( + Effect.map((documents) => + documents.filter((document): document is Config.Document => document !== undefined), + ), + ) }) + }).pipe(Effect.map((documents) => documents.flat())) + const global = documents.flatMap((document) => document.info.permissions ?? []) + const configuredDefault = Config.latest(documents, "default_agent") + if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault)) + for (const current of draft.list()) { + draft.update(current.id, (agent) => agent.permissions.push(...global)) } - } - }) + + for (const document of documents) { + for (const [id, item] of Object.entries(document.info.agents ?? {})) { + const agentID = AgentV2.ID.make(id) + if (item.disabled) { + draft.remove(agentID) + continue + } + + const exists = draft.get(agentID) !== undefined + draft.update(agentID, (agent) => { + if (!exists) agent.permissions.push(...global) + if (item.model !== undefined) { + const model = ModelV2.parse(item.model) + agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant } + } + if (item.variant !== undefined && agent.model !== undefined) { + agent.model.variant = ModelV2.VariantID.make(item.variant) + } + if (item.request !== undefined) { + Object.assign(agent.request.headers, item.request.headers ?? {}) + Object.assign(agent.request.body, item.request.body ?? {}) + } + if (item.system !== undefined) agent.system = item.system + if (item.description !== undefined) agent.description = item.description + if (item.mode !== undefined) agent.mode = item.mode + if (item.hidden !== undefined) agent.hidden = item.hidden + if (item.color !== undefined) agent.color = item.color + if (item.steps !== undefined) agent.steps = item.steps + if (item.permissions !== undefined) agent.permissions.push(...item.permissions) + }) + } + } + }), + ) }), }) diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index 7e71f306e8..f9b31f8e45 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -1,52 +1,51 @@ export * as ConfigCommandPlugin from "./command" +import { define } from "../../plugin/internal" import path from "path" import { Effect, Option, Schema } from "effect" import { CommandV2 } from "../../command" import { Config } from "../../config" import { FSUtil } from "../../fs-util" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" import { ConfigCommand } from "../command" import { ConfigMarkdown } from "../markdown" const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info) -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("config-command"), - effect: Effect.gen(function* () { - const command = yield* CommandV2.Service +export const Plugin = define({ + id: "config-command", + effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service - const transform = yield* command.transform() - const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { - if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) - return loadDirectory(fs, entry.path).pipe( - Effect.map((commands) => [ - { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, - ]), - ) - }).pipe(Effect.map((documents) => documents.flat())) - - yield* transform((editor) => { - for (const document of documents) { - for (const [name, command] of Object.entries(document.commands ?? {})) { - editor.update(name, (item) => { - item.template = command.template - if (command.description !== undefined) item.description = command.description - if (command.agent !== undefined) item.agent = command.agent - if (command.model !== undefined) { - const model = ModelV2.parse(command.model) - item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant } - } - if (command.variant !== undefined && item.model !== undefined) { - item.model.variant = ModelV2.VariantID.make(command.variant) - } - if (command.subtask !== undefined) item.subtask = command.subtask - }) + yield* ctx.command.transform( + Effect.fn(function* (draft) { + const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) + return loadDirectory(fs, entry.path).pipe( + Effect.map((commands) => [ + { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, + ]), + ) + }).pipe(Effect.map((documents) => documents.flat())) + for (const document of documents) { + for (const [name, command] of Object.entries(document.commands ?? {})) { + draft.update(name, (item) => { + item.template = command.template + if (command.description !== undefined) item.description = command.description + if (command.agent !== undefined) item.agent = command.agent + if (command.model !== undefined) { + const model = ModelV2.parse(command.model) + item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant } + } + if (command.variant !== undefined && item.model !== undefined) { + item.model.variant = ModelV2.VariantID.make(command.variant) + } + if (command.subtask !== undefined) item.subtask = command.subtask + }) + } } - } - }) + }), + ) }), }) diff --git a/packages/core/src/config/plugin/external.ts b/packages/core/src/config/plugin/external.ts new file mode 100644 index 0000000000..045334e867 --- /dev/null +++ b/packages/core/src/config/plugin/external.ts @@ -0,0 +1,91 @@ +export * as ConfigExternalPlugin from "./external" + +import type { Plugin as EffectPlugin } from "@kilocode/plugin/v2/effect" +import type { Plugin as PromisePlugin } from "@kilocode/plugin/v2/promise" +import { Effect, Schema } from "effect" +import path from "path" +import { fileURLToPath, pathToFileURL } from "url" +import { Config } from "../../config" +import { FSUtil } from "../../fs-util" +import { Location } from "../../location" +import { Npm } from "../../npm" +import { define } from "../../plugin/internal" +import { PluginPromise } from "../../plugin/promise" + +const PluginModule = Schema.Struct({ + default: Schema.Union([ + Schema.Struct({ + id: Schema.String, + effect: Schema.declare( + (input): input is EffectPlugin["effect"] => typeof input === "function", + ), + }), + Schema.Struct({ + id: Schema.String, + setup: Schema.declare( + (input): input is PromisePlugin["setup"] => typeof input === "function", + ), + }), + ]), +}) + +export const Plugin = define({ + id: "config-plugin", + effect: Effect.fn(function* (ctx) { + const config = yield* Config.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + yield* Effect.gen(function* () { + const configured: { package: string; options?: Record }[] = [] + + for (const entry of yield* config.entries()) { + if (entry.type === "document") { + const directory = entry.path ? path.dirname(entry.path) : location.directory + for (const item of entry.info.plugins ?? []) { + const ref = typeof item === "string" ? { package: item } : item + const packageName = (() => { + if (ref.package.startsWith("file://")) return fileURLToPath(ref.package) + if (ref.package.startsWith("./") || ref.package.startsWith("../")) { + return path.resolve(directory, ref.package) + } + return ref.package + })() + configured.push({ package: packageName, options: ref.options }) + } + } + + if (entry.type === "directory") { + const files = yield* fs + .glob("{plugin,plugins}/*.{ts,js}", { + cwd: entry.path, + absolute: true, + include: "file", + dot: true, + symlink: true, + }) + .pipe(Effect.orElseSucceed(() => [])) + files.sort() + for (const file of files) configured.push({ package: file }) + } + } + + for (const ref of configured) { + yield* Effect.gen(function* () { + const entrypoint = path.isAbsolute(ref.package) + ? pathToFileURL(ref.package).href + : (yield* npm.add(ref.package)).entrypoint + if (!entrypoint) return + + const mod = yield* Effect.promise(() => import(entrypoint)) + const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default + const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) + yield* ctx.plugin.add({ + id: plugin.id, + effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }), + }) + }).pipe(Effect.ignoreCause) + } + }).pipe(Effect.forkScoped({ startImmediately: true })) + }), +}) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 47a3712e3a..6f6e0528da 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -1,123 +1,113 @@ export * as ConfigProviderPlugin from "./provider" +import { define } from "../../plugin/internal" import { Effect } from "effect" -import { Catalog } from "../../catalog" import { Config } from "../../config" -import { Integration } from "../../integration" import { ModelV2 } from "../../model" -import { ModelRequest } from "../../model-request" -import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("config-provider"), - effect: Effect.gen(function* () { - const catalog = yield* Catalog.Service +export const Plugin = define({ + id: "config-provider", + effect: Effect.fn(function* (ctx) { const config = yield* Config.Service - const integrations = yield* Integration.Service - const transform = yield* catalog.transform() - const integrationTransform = yield* integrations.transform() - const entries = yield* config.entries() - const files = entries.filter((entry): entry is Config.Document => entry.type === "document") - const configuredIntegrations = new Set( - files.flatMap((file) => - Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])), - ), - ) - yield* integrationTransform((integrations) => { - for (const file of files) { - for (const [id, item] of Object.entries(file.info.providers ?? {})) { - const integrationID = Integration.ID.make(id) - if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue - integrations.update(integrationID, (integration) => { - integration.name = item.name ?? integration.name - }) - if (item.env !== undefined) { - integrations.method.update({ - integrationID, - method: { type: "env", names: [...item.env] }, + yield* ctx.integration.transform( + Effect.fn(function* (integrations) { + const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document") + const configuredIntegrations = new Set( + files.flatMap((file) => + Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => + provider.env === undefined ? [] : [id], + ), + ), + ) + for (const file of files) { + for (const [id, item] of Object.entries(file.info.providers ?? {})) { + const integrationID = id + if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue + integrations.update(integrationID, (integration) => { + integration.name = item.name ?? integration.name }) - } - } - } - }) - - yield* transform((catalog) => { - const configuredDefault = Config.latest(entries, "model") - if (configuredDefault !== undefined) { - const model = ModelV2.parse(configuredDefault) - catalog.model.default.set(model.providerID, model.modelID) - } - for (const file of files) { - for (const [id, item] of Object.entries(file.info.providers ?? {})) { - const providerID = ProviderV2.ID.make(id) - catalog.provider.update(providerID, (provider) => { - if (item.name !== undefined) provider.name = item.name - if (item.api !== undefined) provider.api = { ...item.api } - if (item.request !== undefined) { - Object.assign(provider.request.headers, item.request.headers) - Object.assign(provider.request.body, item.request.body) + if (item.env !== undefined) { + integrations.method.update({ + integrationID, + method: { type: "env", names: [...item.env] }, + }) } - }) - const providerApi = catalog.provider.get(providerID)?.provider.api - const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined - - for (const [id, config] of Object.entries(item.models ?? {})) { - catalog.model.update(providerID, ModelV2.ID.make(id), (model) => { - if (config.family !== undefined) model.family = config.family - if (config.name !== undefined) model.name = config.name - if (config.api !== undefined) model.api = { ...model.api, ...config.api } - const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage - if (config.capabilities !== undefined) { - model.capabilities = { - tools: config.capabilities.tools, - input: [...config.capabilities.input], - output: [...config.capabilities.output], - } - } - if (config.request !== undefined) { - ModelRequest.assign(model.request, { - headers: config.request.headers, - ...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}), - }) - if (config.request.variant !== undefined) model.request.variant = config.request.variant - } - if (config.variants !== undefined) { - for (const variant of config.variants) { - let existing = model.variants.find((item) => item.id === variant.id) - if (!existing) { - existing = { - id: variant.id, - headers: {}, - body: {}, - generation: {}, - options: {}, - } - model.variants.push(existing) - } - ModelRequest.assign(existing, { - headers: variant.headers, - ...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}), - }) - } - } - if (config.cost !== undefined) { - model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({ - tier: cost.tier && { ...cost.tier }, - input: cost.input, - output: cost.output, - cache: { - read: cost.cache?.read ?? 0, - write: cost.cache?.write ?? 0, - }, - })) - } - if (config.disabled !== undefined) model.enabled = !config.disabled - if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } - }) } } - } - }) + }), + ) + + yield* ctx.catalog.transform( + Effect.fn(function* (catalog) { + const entries = yield* config.entries() + const files = entries.filter((entry): entry is Config.Document => entry.type === "document") + const configuredDefault = Config.latest(entries, "model") + if (configuredDefault !== undefined) { + const model = ModelV2.parse(configuredDefault) + catalog.model.default.set(model.providerID, model.modelID) + } + for (const file of files) { + for (const [id, item] of Object.entries(file.info.providers ?? {})) { + const providerID = id + catalog.provider.update(providerID, (provider) => { + if (item.name !== undefined) provider.name = item.name + if (item.api !== undefined) provider.api = { ...item.api } + if (item.request !== undefined) { + Object.assign(provider.request.headers, item.request.headers) + Object.assign(provider.request.body, item.request.body) + } + }) + for (const [id, config] of Object.entries(item.models ?? {})) { + catalog.model.update(providerID, id, (model) => { + if (config.family !== undefined) model.family = config.family + if (config.name !== undefined) model.name = config.name + if (config.api !== undefined) model.api = { ...model.api, ...config.api } + if (config.capabilities !== undefined) { + model.capabilities = { + tools: config.capabilities.tools, + input: [...config.capabilities.input], + output: [...config.capabilities.output], + } + } + if (config.request !== undefined) { + Object.assign(model.request.headers, config.request.headers) + Object.assign(model.request.body, config.request.body) + if (config.request.variant !== undefined) model.request.variant = config.request.variant + } + if (config.variants !== undefined) { + for (const variant of config.variants) { + let existing = model.variants.find((item) => item.id === variant.id) + if (!existing) { + existing = { + id: variant.id, + headers: {}, + body: {}, + } + model.variants.push(existing) + } + Object.assign(existing.headers, variant.headers) + Object.assign(existing.body, variant.body) + } + } + if (config.cost !== undefined) { + model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({ + tier: cost.tier && { ...cost.tier }, + input: cost.input, + output: cost.output, + cache: { + read: cost.cache?.read ?? 0, + write: cost.cache?.write ?? 0, + }, + })) + } + if (config.disabled !== undefined) model.enabled = !config.disabled + if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } + }) + } + } + } + }), + ) }), }) diff --git a/packages/core/src/config/plugin/reference.ts b/packages/core/src/config/plugin/reference.ts index 22c7664996..d5fa53c411 100644 --- a/packages/core/src/config/plugin/reference.ts +++ b/packages/core/src/config/plugin/reference.ts @@ -1,57 +1,58 @@ export * as ConfigReferencePlugin from "./reference" +import { define } from "../../plugin/internal" import path from "path" import { Effect } from "effect" import { Config } from "../../config" import { ConfigReference } from "../reference" -import { Global } from "../../global" -import { Location } from "../../location" -import { PluginV2 } from "../../plugin" import { Reference } from "../../reference" import { AbsolutePath } from "../../schema" +import { Global } from "../../global" +import { Location } from "../../location" -export const Plugin = { - id: PluginV2.ID.make("core/config-reference"), - effect: Effect.gen(function* () { +export const Plugin = define({ + id: "core/config-reference", + effect: Effect.fn(function* (ctx) { const config = yield* Config.Service - const global = yield* Global.Service const location = yield* Location.Service - const references = yield* Reference.Service - const update = yield* references.transform() - const entries = new Map() - for (const doc of (yield* config.entries()).filter( - (entry): entry is Config.Document => entry.type === "document", - )) { - const directory = doc.path ? path.dirname(doc.path) : location.directory - for (const [name, entry] of Object.entries(doc.info.references ?? {})) { - if (!validAlias(name)) continue - entries.set( - name, - local(entry) - ? new Reference.LocalSource({ - type: "local", - path: AbsolutePath.make( - localPath(directory, global.home, typeof entry === "string" ? entry : entry.path), - ), - description: typeof entry === "string" ? undefined : entry.description, - hidden: typeof entry === "string" ? undefined : entry.hidden, - }) - : new Reference.GitSource({ - type: "git", - repository: typeof entry === "string" ? entry : entry.repository, - branch: typeof entry === "string" ? undefined : entry.branch, - description: typeof entry === "string" ? undefined : entry.description, - hidden: typeof entry === "string" ? undefined : entry.hidden, - }), - ) - } - } - - yield* update((editor) => { - for (const [name, source] of entries) editor.add(name, source) - }) + const global = yield* Global.Service + yield* ctx.reference.transform( + Effect.fn(function* (draft) { + const entries = new Map() + for (const doc of (yield* config.entries()).filter( + (entry): entry is Config.Document => entry.type === "document", + )) { + const directory = doc.path ? path.dirname(doc.path) : location.directory + for (const [name, entry] of Object.entries(doc.info.references ?? {})) { + if (!validAlias(name)) continue + const description = typeof entry === "string" ? undefined : entry.description + const hidden = typeof entry === "string" ? undefined : entry.hidden + entries.set( + name, + local(entry) + ? Reference.LocalSource.make({ + type: "local", + path: AbsolutePath.make( + localPath(directory, global.home, typeof entry === "string" ? entry : entry.path), + ), + ...(description === undefined ? {} : { description }), + ...(hidden === undefined ? {} : { hidden }), + }) + : Reference.GitSource.make({ + type: "git", + repository: typeof entry === "string" ? entry : entry.repository, + ...(entry.branch === undefined ? {} : { branch: entry.branch }), + ...(description === undefined ? {} : { description }), + ...(hidden === undefined ? {} : { hidden }), + }), + ) + } + } + for (const [name, source] of entries) draft.add(name, source) + }), + ) }), -} +}) function validAlias(name: string) { return name.length > 0 && !/[\/\s`,]/.test(name) diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index 30b7a88276..765992a765 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -1,48 +1,50 @@ export * as ConfigSkillPlugin from "./skill" +import { define } from "../../plugin/internal" import path from "path" import { Effect } from "effect" import { Config } from "../../config" -import { Global } from "../../global" -import { Location } from "../../location" -import { PluginV2 } from "../../plugin" import { AbsolutePath } from "../../schema" import { SkillV2 } from "../../skill" +import { Global } from "../../global" +import { Location } from "../../location" -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("config-skill"), - effect: Effect.gen(function* () { +export const Plugin = define({ + id: "config-skill", + effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const global = yield* Global.Service const location = yield* Location.Service - const skill = yield* SkillV2.Service - const transform = yield* skill.transform() - const entries = yield* config.entries() - const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) - const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) - - yield* transform((editor) => { - for (const directory of directories) { - editor.source( - new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), - ) - editor.source( - new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), - ) - } - for (const item of items) { - if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { - editor.source(new SkillV2.UrlSource({ type: "url", url: item })) - continue + yield* ctx.skill.transform( + Effect.fn(function* (draft) { + const entries = yield* config.entries() + const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) + const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) + for (const directory of directories) { + draft.source( + SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), + ) + draft.source( + SkillV2.DirectorySource.make({ + type: "directory", + path: AbsolutePath.make(path.join(directory, "skills")), + }), + ) } - const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item - editor.source( - new SkillV2.DirectorySource({ - type: "directory", - path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)), - }), - ) - } - }) + for (const item of items) { + if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { + draft.source(SkillV2.UrlSource.make({ type: "url", url: item })) + continue + } + const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item + draft.source( + SkillV2.DirectorySource.make({ + type: "directory", + path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)), + }), + ) + } + }), + ) }), }) diff --git a/packages/core/src/control-plane/move-session.ts b/packages/core/src/control-plane/move-session.ts index 0239eecada..e227f88df5 100644 --- a/packages/core/src/control-plane/move-session.ts +++ b/packages/core/src/control-plane/move-session.ts @@ -1,14 +1,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 { Git } from "../git" import { Location } from "../location" import { ProjectV2 } from "../project" import { SessionV2 } from "../session" -import { SessionExecution } from "../session/execution" import { SessionEvent } from "../session/event" import { SessionSchema } from "../session/schema" +import { SessionStore } from "../session/store" import { AbsolutePath, RelativePath } from "../schema" import path from "path" @@ -48,7 +49,7 @@ export class ResetSourceChangesError extends Schema.TaggedErrorClass()("@opencode/ControlPlaneMoveSession") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const git = yield* Git.Service const events = yield* EventV2.Service const project = yield* ProjectV2.Service - const session = yield* SessionV2.Service + const sessions = yield* SessionStore.Service const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) { - const current = yield* session.get(input.sessionID) + const current = yield* sessions.get(input.sessionID) + if (!current) return yield* new SessionV2.NotFoundError({ sessionID: input.sessionID }) const directory = AbsolutePath.make(input.destination.directory) if (current.location.directory === directory) return @@ -84,15 +86,20 @@ export const layer = Layer.effect( return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id }) } - const patch = - input.moveChanges && source.directory !== destination.directory - ? yield* git - .patch(current.location.directory) - .pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message }))) - : "" + const moveChanges = input.moveChanges && source.directory !== destination.directory + const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined + if (moveChanges && !sourceRepository) + return yield* new CaptureChangesError({ message: "Source is not a Git repository" }) + const patch = sourceRepository + ? yield* git.change + .capture({ repository: sourceRepository, path: current.location.directory }) + .pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message }))) + : Git.ChangeSet.make("") if (patch) { - yield* git - .applyPatch({ directory, patch }) + const repository = yield* git.repo.discover(directory) + if (!repository) return yield* new ApplyChangesError({ message: "Destination is not a Git repository" }) + yield* git.change + .apply({ repository, path: directory, changes: patch }) .pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message }))) } @@ -104,16 +111,29 @@ export const layer = Layer.effect( }) if (patch) { - yield* git.softResetChanges(current.location.directory).pipe( - Effect.mapError( - (error) => - new ResetSourceChangesError({ - directory: current.location.directory, - message: error.message, - cause: error.cause, - }), - ), - ) + const repository = yield* git.repo.discover(current.location.directory) + if (!repository) + return yield* new ResetSourceChangesError({ + directory: current.location.directory, + message: "Source is not a Git repository", + }) + yield* git.change + .discard({ + repository, + path: current.location.directory, + index: "preserve", + untracked: "remove", + }) + .pipe( + Effect.mapError( + (error) => + new ResetSourceChangesError({ + directory: current.location.directory, + message: error.message, + cause: error.cause, + }), + ), + ) } }) @@ -121,10 +141,8 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Git.defaultLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.provide(SessionExecution.noopLayer), - Layer.provide(SessionV2.defaultLayer), -) +export const node = makeGlobalNode({ + service: Service, + layer, + deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node], +}) diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index 937ec4a51a..5540739709 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -2,74 +2,60 @@ export * as Credential from "./credential" import { asc, eq } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" +import { Credential } from "@opencode-ai/schema/credential" +import { Integration } from "@opencode-ai/schema/integration" import { Database } from "./database/database" -import { IntegrationSchema } from "./integration/schema" -import { NonNegativeInt, withStatics } from "./schema" -import { Identifier } from "./util/identifier" +import { makeGlobalNode } from "./effect/app-node" import { CredentialTable } from "./credential/sql" -export const ID = Schema.String.pipe( - Schema.brand("Credential.ID"), - withStatics((schema) => ({ create: () => schema.make("cred_" + Identifier.ascending()) })), -) -export type ID = typeof ID.Type +export const ID = Credential.ID +export type ID = Credential.ID -export class OAuth extends Schema.Class("Credential.OAuth")({ - type: Schema.Literal("oauth"), - methodID: IntegrationSchema.MethodID, - refresh: Schema.String, - access: Schema.String, - expires: NonNegativeInt, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}) {} +export const OAuth = Credential.OAuth +export type OAuth = Credential.OAuth -export class Key extends Schema.Class("Credential.Key")({ - type: Schema.Literal("key"), - key: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}) {} +export const Key = Credential.Key +export type Key = Credential.Key -export const Info = Schema.Union([OAuth, Key]) - .pipe(Schema.toTaggedUnion("type")) - .annotate({ identifier: "Credential.Info" }) -export type Info = Schema.Schema.Type +export const Value = Credential.Value +export type Value = Credential.Value -export class Stored extends Schema.Class("Credential.Stored")({ +export class Info extends Schema.Class("Credential.Info")({ id: ID, - integrationID: IntegrationSchema.ID, + integrationID: Integration.ID, label: Schema.String, - value: Info, + value: Value, }) {} export interface Interface { /** Returns every stored credential. */ - readonly all: () => Effect.Effect + readonly all: () => Effect.Effect /** Returns stored credentials belonging to one integration. */ - readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect + readonly list: (integrationID: Integration.ID) => Effect.Effect /** Returns one stored credential by ID. */ - readonly get: (id: ID) => Effect.Effect + readonly get: (id: ID) => Effect.Effect /** Replaces any credential for an integration and returns the new record. */ readonly create: (input: { - readonly integrationID: IntegrationSchema.ID - readonly value: Info + readonly integrationID: Integration.ID + readonly value: Value readonly label?: string - }) => Effect.Effect + }) => Effect.Effect /** Updates the label or secret value of a stored credential. */ - readonly update: (id: ID, updates: Partial>) => Effect.Effect + readonly update: (id: ID, updates: Partial>) => Effect.Effect /** Removes a stored credential. */ readonly remove: (id: ID) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Credential") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service - const decode = Schema.decodeUnknownSync(Info) + const decode = Schema.decodeUnknownSync(Value) const stored = (row: typeof CredentialTable.$inferSelect) => { if (!row.integration_id) return - return new Stored({ + return new Info({ id: row.id, integrationID: row.integration_id, label: row.label, @@ -106,7 +92,7 @@ export const layer = Layer.effect( return row ? stored(row) : undefined }), create: Effect.fn("Credential.create")(function* (input) { - const credential = new Stored({ + const credential = new Info({ id: ID.create(), integrationID: input.integrationID, label: input.label ?? "default", @@ -149,4 +135,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) +export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] }) diff --git a/packages/core/src/credential/sql.ts b/packages/core/src/credential/sql.ts index a849092ea0..f6b59197a3 100644 --- a/packages/core/src/credential/sql.ts +++ b/packages/core/src/credential/sql.ts @@ -1,13 +1,12 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" import { Timestamps } from "../database/schema.sql" -import type { IntegrationSchema } from "../integration/schema" import type { Credential } from "../credential" export const CredentialTable = sqliteTable("credential", { id: text().$type().primaryKey(), - integration_id: text().$type(), + integration_id: text().$type(), label: text().notNull(), - value: text({ mode: "json" }).$type().notNull(), + value: text({ mode: "json" }).$type().notNull(), connector_id: text(), method_id: text(), active: integer({ mode: "boolean" }), diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index d6e0f9f95d..6ea9022acf 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -24,8 +24,8 @@ import { import * as NodeChildProcess from "node:child_process" import { PassThrough } from "node:stream" import launch from "cross-spawn" -import { LayerNode } from "./effect/layer-node" -import { filesystem, path } from "./effect/layer-node-platform" +import { makeGlobalNode } from "./effect/app-node" +import { filesystem, path } from "./effect/app-node-platform" const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err))) @@ -497,12 +497,11 @@ export const make = Effect.gen(function* () { return makeSpawner(spawnCommand) }) -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( ChildProcessSpawner, make, ) -export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer)) -export const node = LayerNode.make(layer, [filesystem, path]) +export const node = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] }) export * as CrossSpawnSpawner from "./cross-spawn-spawner" diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index 71315a1fbc..d03ee6dbbd 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -8,7 +8,7 @@ import { Flag } from "../flag/flag" import { isAbsolute, join } from "path" import { DatabaseMigration } from "./migration" import { InstallationChannel } from "../installation/version" -import { LayerNode } from "../effect/layer-node" +import { makeGlobalNode } from "../effect/app-node" const makeDatabase = EffectDrizzleSqlite.makeWithDefaults() type DatabaseShape = Effect.Success @@ -19,7 +19,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/storage/Database") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const db = yield* makeDatabase @@ -54,10 +54,4 @@ export function path() { return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) } -export const defaultLayer = Layer.unwrap( - Effect.gen(function* () { - return layerFromPath(path()) - }), -).pipe(Layer.provide(Global.defaultLayer)) - -export const node = LayerNode.make(layerFromPath(path()), []) +export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] }) diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 1e915bb3cf..e6ea4eaa14 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -37,5 +37,8 @@ export const migrations = ( import("./migration/20260611035744_credential"), import("./migration/20260611192811_lush_chimera"), import("./migration/20260612174303_project_dir_strategy"), + import("./migration/20260622142730_simplify_session_context_epoch"), + import("./migration/20260622170816_reset_v2_session_state"), + import("./migration/20260622202450_simplify_session_input"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260622142730_simplify_session_context_epoch.ts b/packages/core/src/database/migration/20260622142730_simplify_session_context_epoch.ts new file mode 100644 index 0000000000..1520bac4c1 --- /dev/null +++ b/packages/core/src/database/migration/20260622142730_simplify_session_context_epoch.ts @@ -0,0 +1,13 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260622142730_simplify_session_context_epoch", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`agent\`;`) + yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`replacement_seq\`;`) + yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`revision\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts b/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts new file mode 100644 index 0000000000..b771a64bb7 --- /dev/null +++ b/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260622170816_reset_v2_session_state", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DELETE FROM \`session_context_epoch\`;`) + yield* tx.run(`DELETE FROM \`session_input\`;`) + yield* tx.run(`DELETE FROM \`session_message\`;`) + yield* tx.run(`DELETE FROM \`event\`;`) + yield* tx.run(`DELETE FROM \`event_sequence\`;`) + yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`) + yield* tx.run(`DELETE FROM \`workspace\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260622202450_simplify_session_input.ts b/packages/core/src/database/migration/20260622202450_simplify_session_input.ts new file mode 100644 index 0000000000..0b5ddd1bfd --- /dev/null +++ b/packages/core/src/database/migration/20260622202450_simplify_session_input.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260622202450_simplify_session_input", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DELETE FROM \`session_context_epoch\`;`) + yield* tx.run(`DELETE FROM \`session_input\`;`) + yield* tx.run(`DELETE FROM \`session_message\`;`) + yield* tx.run(`DELETE FROM \`event\`;`) + yield* tx.run(`DELETE FROM \`event_sequence\`;`) + yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`) + yield* tx.run(`DELETE FROM \`workspace\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 5c044ec60f..ed60fde6c5 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -149,11 +149,8 @@ export default { CREATE TABLE \`session_context_epoch\` ( \`session_id\` text PRIMARY KEY, \`baseline\` text NOT NULL, - \`agent\` text DEFAULT 'build' NOT NULL, \`snapshot\` text NOT NULL, \`baseline_seq\` integer NOT NULL, - \`replacement_seq\` integer, - \`revision\` integer DEFAULT 0 NOT NULL, CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE ); `) diff --git a/packages/core/src/effect/app-node-builder.ts b/packages/core/src/effect/app-node-builder.ts new file mode 100644 index 0000000000..5f01469dab --- /dev/null +++ b/packages/core/src/effect/app-node-builder.ts @@ -0,0 +1,23 @@ +import { buildLocationServiceMap } from "../location-services" +import { LocationServiceMap } from "../location-service-map" +import { LayerNode } from "./layer-node" +import { makeGlobalNode } from "./app-node" + +export function build(root: LayerNode.Node, replacements: LayerNode.Replacements = []) { + let allReplacements = replacements + + // Only build the location service map if it's actually needed + if (LayerNode.hasUnbound(root, LocationServiceMap.node) && !hasReplacement(replacements, LocationServiceMap.node)) { + const locationMap = buildLocationServiceMap(replacements) + const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] }) + allReplacements = replacements.concat([[LocationServiceMap.node, locationMapNode]]) + } + + return LayerNode.compile(root, allReplacements) +} + +function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node) { + return replacements.some(([source]) => source.name === node.name) +} + +export * as AppNodeBuilder from "./app-node-builder" diff --git a/packages/core/src/effect/app-node-platform.ts b/packages/core/src/effect/app-node-platform.ts new file mode 100644 index 0000000000..adba0eb9cc --- /dev/null +++ b/packages/core/src/effect/app-node-platform.ts @@ -0,0 +1,18 @@ +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" +import { FileSystem, Path } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { HttpClient } from "effect/unstable/http" +import { makeGlobalNode } from "./app-node" + +export const filesystem = makeGlobalNode({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] }) +export const path = makeGlobalNode({ service: Path.Path, layer: NodePath.layer, deps: [] }) +export const httpClient = makeGlobalNode({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] }) +export const requestExecutor = makeGlobalNode({ + service: RequestExecutor.Service, + layer: RequestExecutor.layer, + deps: [httpClient], +}) +export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] }) + +export * as LayerNodePlatform from "./app-node-platform" diff --git a/packages/core/src/effect/app-node.ts b/packages/core/src/effect/app-node.ts new file mode 100644 index 0000000000..e8112f921a --- /dev/null +++ b/packages/core/src/effect/app-node.ts @@ -0,0 +1,14 @@ +import { LayerNode } from "./layer-node" + +export const tags = LayerNode.tags({ + location: ["global"], + global: [], +}) + +export type GlobalNode = LayerNode.Node +export type LocationNode = LayerNode.Node + +export const makeGlobalNode = tags.make("global") +export const makeLocationNode = tags.make("location") + +export * as Node from "./app-node" diff --git a/packages/core/src/effect/dfdf b/packages/core/src/effect/dfdf new file mode 100644 index 0000000000..77f2d57968 --- /dev/null +++ b/packages/core/src/effect/dfdf @@ -0,0 +1 @@ +File to save in: ~/.local/share/opencode/worktree/012780/location-layer-tiers/packages/core/src/effect/ \ No newline at end of file diff --git a/packages/core/src/effect/layer-node-platform.ts b/packages/core/src/effect/layer-node-platform.ts deleted file mode 100644 index 2e63d29582..0000000000 --- a/packages/core/src/effect/layer-node-platform.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NodeFileSystem, NodePath } from "@effect/platform-node" -import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" -import { FetchHttpClient } from "effect/unstable/http" -import { LayerNode } from "./layer-node" - -export const filesystem = LayerNode.make(NodeFileSystem.layer, []) -export const path = LayerNode.make(NodePath.layer, []) -export const httpClient = LayerNode.make(FetchHttpClient.layer, []) -export const requestExecutor = LayerNode.make(RequestExecutor.layer, [httpClient]) -export const llmClient = LayerNode.make(LLMClient.layer, [requestExecutor]) - -export * as LayerNodePlatform from "./layer-node-platform" diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts index c6ee6b2369..b58692a155 100644 --- a/packages/core/src/effect/layer-node.ts +++ b/packages/core/src/effect/layer-node.ts @@ -1,10 +1,11 @@ -import { Layer } from "effect" +import { Brand, Context, Layer } from "effect" +type AnyNode = Node type RuntimeLayer = Layer.Layer -type AnyNode = Node -type NodeList = readonly [] | readonly [AnyNode, ...AnyNode[]] -type Output = [Item] extends [never] ? never : Item extends Node ? A : never -type Error = [Item] extends [never] ? never : Item extends Node ? E : never +type NodeList = readonly [] | readonly [Item, ...Item[]] +export type Output = [Item] extends [never] ? never : Item extends Node ? A : never +export type Error = [Item] extends [never] ? never : Item extends Node ? E : never +type NodeTag = [Item] extends [never] ? undefined : Item extends Node ? T : never type Missing = Exclude> type CheckDependencies = [ Missing, Dependencies>, @@ -14,89 +15,319 @@ type CheckDependencies = { - readonly kind: "layer" | "group" +export type Tag = Name & Brand.Brand<"LayerNode.Tag"> + +const makeTag = Brand.nominal() + +export interface Node { + readonly kind: "layer" | "unbound" | "group" + readonly name: string + readonly service?: Context.Service.Any readonly implementation?: Layer.Any readonly dependencies: readonly AnyNode[] + readonly tag?: T readonly [$OutputType]?: () => A readonly [$ErrorType]?: () => E } -export function make( - implementation: Implementation, - dependencies: Items & CheckDependencies>, -): Node, Layer.Error | Error> { - return { kind: "layer", implementation: implementation as Layer.Any, dependencies } +type NodeIdentity = + | { readonly service: Context.Service.Any; readonly name?: never } + | { readonly name: string; readonly service?: never } +type DistributiveOmit = A extends unknown ? Omit : never + +export type TagConfig = Readonly> +type TagNames = keyof Config & string +type NodeInTags = Node | undefined> +type CheckTags = [Exclude>] extends [ + never, +] + ? unknown + : { readonly "Invalid tag dependencies": Exclude> } + +export interface Tags { + readonly values: { readonly [Name in TagNames]: Tag } + readonly make: >( + name: Name, + ) => ( + input: DistributiveOmit>, "tag"> & + CheckTags>, + ) => Node, Layer.Error | Error, Tag> } -export function group( +export function tags( + config: Config, +): Tags { + const names = Object.keys(config) as TagNames[] + const values = Object.fromEntries(names.map((name) => [name, makeTag(name)])) as Tags["values"] + return { + values, + make: ((name: TagNames) => (input: DistributiveOmit, "tag">) => + make({ ...input, tag: values[name] })) as Tags["make"], + } +} + +// Nodes --------------------------------------------------------------------- + +type MakeInput< + Implementation extends Layer.Any, + Items extends NodeList, + T extends Tag | undefined = undefined, +> = NodeIdentity & { + readonly layer: Implementation + readonly deps: Items & CheckDependencies> + readonly tag?: T +} + +export function make< + const Implementation extends Layer.Any, + const Items extends NodeList, + const T extends Tag | undefined = undefined, +>( + input: MakeInput, +): Node, Layer.Error | Error, T> { + return { + kind: "layer", + name: input.service !== undefined ? input.service.key : input.name, + service: input.service, + implementation: input.layer, + dependencies: input.deps, + tag: input.tag, + } +} + +export function unbound(service: Context.Key, tag: T): Node { + return { + kind: "unbound", + name: service.key, + service, + dependencies: [], + tag, + } +} + +export function group( dependencies: Items, -): Node, Error> { - return { kind: "group", dependencies } +): Node, Error, NodeTag> { + return { kind: "group", name: "group", dependencies } } -export type Replacement = { - readonly source: Node - readonly replacement: Node -} +export type Replacement = readonly [source: AnyNode, replacement: AnyNode | Layer.Any] +export type Replacements = readonly Replacement[] type CheckReplacementErrors = [Exclude] extends [never] ? unknown : { readonly "New replacement errors": Exclude } -export function replaceWithNode( - source: Node, - replacement: Node, E2> & CheckReplacementErrors>, -): Replacement { - return { source, replacement } +type CheckReplacement = Item extends readonly [Node, infer Replacement] + ? Replacement extends Node, infer E2, T> + ? CheckReplacementErrors> + : Replacement extends Layer.Layer, infer E2, never> + ? CheckReplacementErrors> + : { readonly "Invalid replacement": Replacement } + : { readonly "Invalid replacement": Item } + +type CheckReplacements = { + readonly [K in keyof Items]: CheckReplacement } -export function replace( - source: Node, - replacement: Layer.Layer, E2, never> & CheckReplacementErrors>, -): Replacement { - return { source, replacement: make(replacement as Layer.Layer, []) } +type ValidReplacements = Items & CheckReplacements + +function replacementNode(source: AnyNode, replacement: AnyNode | Layer.Any) { + const replacementNode = isNode(replacement) + ? replacement + : make({ + ...nodeMakeIdentity(source), + layer: replacement as Layer.Layer, + deps: [], + tag: source.tag, + }) + if (source.name !== replacementNode.name) { + throw new Error(`Cannot replace ${source.name} with ${replacementNode.name}`) + } + if (source.tag !== replacementNode.tag) { + throw new Error(`Cannot replace ${source.name} across tags`) + } + return replacementNode } -export function buildLayer(node: Node, options?: { readonly replacements?: readonly Replacement[] }) { - const replacements = new Map(options?.replacements?.map((item) => [item.source, item.replacement])) - const cache = new Map() +function nodeMakeIdentity(node: AnyNode): NodeIdentity { + if (node.service !== undefined) return { service: node.service } + return { name: node.name } +} + +function isNode(input: Layer.Any | AnyNode): input is AnyNode { + return "kind" in input && "dependencies" in input +} + +// Tree ----------------------------------------------------------------------- + +type Visit = (node: AnyNode, context: VisitContext) => Result + +type VisitContext = { + readonly cache: Map + readonly visit: (node: AnyNode) => Result +} + +function walk( + root: AnyNode, + visit: Visit, + options: { + readonly cache?: Map + readonly resolve?: (node: AnyNode) => AnyNode + readonly detectCycles?: boolean + } = {}, +) { + const cache = options.cache ?? new Map() const visiting = new Set() const stack: AnyNode[] = [] - const ids = new Map() - const visit = (input: AnyNode): RuntimeLayer => { - const node = replacements.get(input) ?? input - const cached = cache.get(node) - if (cached) return cached - if (visiting.has(node)) { - const start = stack.indexOf(node) - const cycle = [...stack.slice(start), node].map((item) => `${item.kind}#${ids.get(item)}`).join(" -> ") - throw new Error(`Cycle detected in app graph: ${cycle}`) + const recur = (node: AnyNode): Result => { + const target = options.resolve?.(node) ?? node + const cached = cache.get(target) + if (cached !== undefined || cache.has(target)) return cached! + + if (options.detectCycles !== false && visiting.has(target)) { + const start = stack.indexOf(target) + throw new Error( + `Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`, + ) } - if (!ids.has(node)) ids.set(node, ids.size + 1) - visiting.add(node) - stack.push(node) + + visiting.add(target) + stack.push(target) try { - const dependencies = node.dependencies.map(visit) - const nonEmpty = dependencies as [RuntimeLayer, ...RuntimeLayer[]] - const result = - node.kind === "group" - ? dependencies.length === 0 - ? Layer.empty - : Layer.mergeAll(...nonEmpty) - : dependencies.length === 0 - ? (node.implementation as RuntimeLayer) - : Layer.provide(node.implementation as RuntimeLayer, nonEmpty) - cache.set(node, result) + const result = visit(target, { cache, visit: recur }) + if (!cache.has(target)) cache.set(target, result) return result } finally { stack.pop() - visiting.delete(node) + visiting.delete(target) } } - return visit(node) as unknown as Layer.Layer + return recur(root) +} + +export function hoist( + root: Node, + tag: T, + replacements?: ValidReplacements, +): { + readonly node: Node + readonly hoisted: Node +} { + const hoisted = new Map() + const replacementMap = replacementMapFrom(replacements) + + const node = walk( + root, + (node, context) => { + if (node.kind === "group") { + return { ...node, dependencies: node.dependencies.map(context.visit) } + } + if (node.tag === tag) { + const existing = hoisted.get(node.name) + if (existing && existing !== node) { + throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`) + } + hoisted.set(node.name, node) + return group([]) + } + if (node.kind === "unbound") { + return node + } + return { ...node, dependencies: node.dependencies.map(context.visit) } + }, + { resolve: (node) => replacementMap.get(node.name) ?? node }, + ) + + return { + node: node as Node, + hoisted: group(Array.from(hoisted.values())) as Node, + } +} + +export function compile( + root: Node, + replacements?: ValidReplacements, +): Layer.Layer { + const replacementMap = replacementMapFrom(replacements) + const cache = new Map() + const compileNode = (node: AnyNode) => + walk( + node, + (node, context) => { + if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`) + const dependencies = node.dependencies.flatMap(flatten).map(context.visit) + const implementation = node.implementation! as RuntimeLayer + return dependencies.length === 0 + ? implementation + : implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]])) + }, + { cache, resolve: (node) => replacementMap.get(node.name) ?? node }, + ) + const layers = flatten(root).map((node) => compileNode(node)) + const layer = layers.reduce((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty) + return layer as Layer.Layer +} + +function replacementMapFrom(replacements?: Replacements) { + return ( + replacements?.reduce((map, [source, replacement]) => { + const normalized = rewriteReplacementDependencies(replacementNode(source, replacement), map) + const current = new Map([[source.name, normalized]]) + for (const [name, node] of map) map.set(name, rewriteReplacementDependencies(node, current)) + map.set(source.name, normalized) + return map + }, new Map()) ?? new Map() + ) +} + +function rewriteReplacementDependencies(root: AnyNode, replacements: ReadonlyMap) { + if (replacements.size === 0) return root + const cache = new Map() + const visiting = new Set() + const stack: AnyNode[] = [] + + const recur = (node: AnyNode, isRoot = false): AnyNode => { + const target = isRoot ? node : (replacements.get(node.name) ?? node) + const cached = cache.get(target) + if (cached !== undefined || cache.has(target)) return cached! + if (visiting.has(target)) { + const start = stack.indexOf(target) + throw new Error( + `Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`, + ) + } + + visiting.add(target) + stack.push(target) + try { + const dependencies = target.dependencies.map((dependency) => recur(dependency)) + const result = dependencies.every((dependency, index) => dependency === target.dependencies[index]) + ? target + : { ...target, dependencies } + cache.set(target, result) + return result + } finally { + stack.pop() + visiting.delete(target) + } + } + + return recur(root, true) +} + +export function hasUnbound(root: Node, source: AnyNode): boolean { + if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`) + return walk(root, (node, context) => { + if (node === source) return true + return node.dependencies.some(context.visit) + }) +} + +function flatten(node: AnyNode): readonly AnyNode[] { + return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node] } export * as LayerNode from "./layer-node" diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 7a33eedc40..c92ac0ac2c 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -1,62 +1,36 @@ export * as EventV2 from "./event" -import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" -import { and, asc, eq, gt } from "drizzle-orm" +import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect" +import { Event } from "@opencode-ai/schema/event" +import type { Data, Definition, Payload } from "@opencode-ai/schema/event" +import { and, asc, eq, gt, inArray } from "drizzle-orm" import { Database } from "./database/database" import { EventSequenceTable, EventTable } from "./event/sql" import { Location } from "./location" -import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema" -import { Identifier } from "./util/identifier" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/app-node" import { isDeepStrictEqual } from "node:util" +import { Durable } from "@opencode-ai/schema/durable-event-manifest" -export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( - Schema.brand("Event.ID"), - withStatics((schema) => ({ - create: () => schema.make("evt_" + Identifier.ascending()), - fromExternal: (input: ExternalID) => schema.make(externalID("evt", input)), - })), -) -export type ID = typeof ID.Type +export const ID = Event.ID +export type ID = import("@opencode-ai/schema/event").ID +export type { Data, Definition, Payload } from "@opencode-ai/schema/event" -/** - * Durable aggregate continuation position for embedded replay streams. - * TODO: Decide whether a future HTTP / SDK surface should expose an opaque cursor instead. - */ -export const Cursor = NonNegativeInt.pipe(Schema.brand("EventV2.Cursor")) -export type Cursor = typeof Cursor.Type - -export type Definition = { - readonly type: Type - readonly sync?: { - readonly version: number - readonly aggregate: string - } - readonly data: DataSchema -} - -export type Data = Schema.Schema.Type - -export type Payload = { - readonly id: ID - readonly type: D["type"] - readonly data: Data - /** Durable aggregate order, populated while synchronized events are projected. */ - readonly seq?: number - readonly version?: number - readonly location?: Location.Ref - readonly metadata?: Record - /** Internal replay marker for projectors that own non-replicated operational state. */ - readonly replay?: boolean -} - -export type Projector = (event: Payload) => Effect.Effect -type AnyProjector = (event: Payload) => Effect.Effect -export type CommitGuard = (event: Payload) => Effect.Effect -export type Listener = (event: Payload) => Effect.Effect -export type Sync = (event: Payload) => Effect.Effect +export type Subscriber = (event: Payload) => Effect.Effect export type Unsubscribe = Effect.Effect +export const latestSequence = Effect.fn("EventV2.latestSequence")(function* ( + db: Database.Interface["db"], + aggregateID: string, +) { + const row = yield* db + .select({ seq: EventSequenceTable.seq }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .get() + .pipe(Effect.orDie) + return row?.seq ?? -1 +}) + export type SerializedEvent = { readonly id: ID readonly type: string @@ -65,82 +39,87 @@ export type SerializedEvent = { readonly data: Record } -export type CursorEvent = { - readonly cursor: Cursor - readonly event: E -} - -export class InvalidSyncEventError extends Schema.TaggedErrorClass()( - "EventV2.InvalidSyncEvent", +export class InvalidDurableEventError extends Schema.TaggedErrorClass()( + "EventV2.InvalidDurableEvent", { type: Schema.String, message: Schema.String, }, ) {} -export function versionedType(type: string, version: number) { - return `${type}.${version}` +const decodeSerializedEvent = (event: SerializedEvent): Payload => { + const definition = Durable.get(event.type) + if (!definition?.durable) { + throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }) + } + return { + id: event.id, + type: definition.type, + durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version }, + data: Schema.decodeUnknownSync(definition.data)(event.data), + } } -export const registry = new Map() -type SyncDefinition = Definition & { - readonly sync: NonNullable - readonly encode: (data: unknown) => unknown - readonly decode: (data: unknown) => unknown -} -const syncRegistry = new Map() - -// Synchronized events cross a JSON boundary, so their data schemas must encode and decode without services. -const syncCodec = (definition: Definition) => definition.data as Schema.Codec - -export function define(input: { - readonly type: Type - readonly sync?: { - readonly version: number - readonly aggregate: string - } - readonly schema: Fields -}): Schema.Schema>>> & Definition> { - const Data = Schema.Struct(input.schema) - const Payload = Schema.Struct({ - id: ID, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), - type: Schema.Literal(input.type), - version: Schema.optional(Schema.Number), - location: Schema.optional(Location.Ref), - data: Data, - }).annotate({ identifier: input.type }) - - const definition = Object.assign(Payload, { - type: input.type, - ...(input.sync === undefined ? {} : { sync: input.sync }), - data: Data, - }) - const existing = registry.get(input.type) - if (input.sync === undefined || existing?.sync === undefined || input.sync.version >= existing.sync.version) { - registry.set(input.type, definition) - } - if (input.sync) - syncRegistry.set( - versionedType(input.type, input.sync.version), - Object.assign(definition, { - encode: Schema.encodeUnknownSync(syncCodec(definition)), - decode: Schema.decodeUnknownSync(syncCodec(definition)), - }) as SyncDefinition, +export const readAggregate = Effect.fn("EventV2.readAggregate")(function* ( + db: Database.Interface["db"], + input: { + readonly aggregateID: string + readonly after?: number + readonly limit: number + readonly manifest: { + readonly definitions: ReadonlyMap + readonly schema: Schema.Decoder + } + }, +) { + const after = input.after ?? -1 + const rows = yield* db + .select() + .from(EventTable) + .where( + and( + eq(EventTable.aggregate_id, input.aggregateID), + gt(EventTable.seq, after), + inArray(EventTable.type, Array.from(input.manifest.definitions.keys())), + ), ) - return definition as Schema.Schema>>> & - Definition> -} + .orderBy(asc(EventTable.seq)) + .limit(input.limit + 1) + .all() + .pipe(Effect.orDie) + const page = rows.slice(0, input.limit) + const decode = Schema.decodeUnknownSync(input.manifest.schema) + const events = page.map((event) => + decode({ + id: event.id, + type: input.manifest.definitions.get(event.type)?.type ?? event.type, + durable: { + aggregateID: event.aggregate_id, + seq: event.seq, + version: input.manifest.definitions.get(event.type)?.durable?.version, + }, + data: event.data, + }), + ) + return { + events, + hasMore: rows.length > input.limit, + } +}) -export function definitions() { - return registry.values().toArray() -} +export class SubscriberOverflowError extends Schema.TaggedErrorClass()( + "EventV2.SubscriberOverflow", + { capacity: Schema.Int }, +) {} + +export const define = Event.define +export const versionedType = Event.versionedType export interface PublishOptions { readonly id?: ID readonly metadata?: Record readonly location?: Location.Ref - /** Local operational projection committed atomically with a new synchronized event. Not replayed or serialized. */ + /** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */ readonly commit?: (seq: number) => Effect.Effect } @@ -152,14 +131,10 @@ export interface Interface { ) => Effect.Effect> readonly subscribe: (definition: D) => Stream.Stream> readonly all: () => Stream.Stream - readonly aggregateEvents: (input: { - readonly aggregateID: string - readonly after?: Cursor - }) => Stream.Stream - readonly sync: (handler: Sync) => Effect.Effect - readonly listen: (listener: Listener) => Effect.Effect - readonly beforeCommit: (guard: CommitGuard) => Effect.Effect - readonly project: (definition: D, projector: Projector) => Effect.Effect + readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream + /** @deprecated Use `all()` and consume the returned stream. */ + readonly listen: (listener: Subscriber) => Effect.Effect + readonly project: (definition: D, projector: Subscriber) => Effect.Effect readonly replay: ( event: SerializedEvent, options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, @@ -174,6 +149,20 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Event") {} +export const allBounded = (events: Interface, capacity: number) => + Effect.gen(function* () { + const queue = yield* Queue.dropping(capacity) + const unsubscribe = yield* events.listen((event) => + Queue.offer(queue, event).pipe( + Effect.flatMap((accepted) => + accepted ? Effect.void : Queue.fail(queue, new SubscriberOverflowError({ capacity })).pipe(Effect.asVoid), + ), + ), + ) + yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid)) + return Stream.fromQueue(queue) + }) + export interface LayerOptions { readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect } @@ -182,37 +171,39 @@ export const layerWith = (options?: LayerOptions) => Layer.effect( Service, Effect.gen(function* () { - const all = yield* PubSub.unbounded() - const synchronized = new Map>>() - const typed = new Map>() - const projectors = new Map() - const commitGuards = new Array() - const listeners = new Array() - const syncHandlers = new Array() + const pubsub = { + all: yield* PubSub.unbounded(), + durable: new Map>>(), + typed: new Map>(), + } + const projectors = new Map() + // TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads. + const listeners = new Array() const { db } = yield* Database.Service const getOrCreate = (definition: Definition) => Effect.gen(function* () { - const existing = typed.get(definition.type) + const existing = pubsub.typed.get(definition.type) if (existing) return existing - const pubsub = yield* PubSub.unbounded() - typed.set(definition.type, pubsub) - return pubsub + const created = yield* PubSub.unbounded() + pubsub.typed.set(definition.type, created) + return created }) yield* Effect.addFinalizer(() => Effect.gen(function* () { - yield* PubSub.shutdown(all) + yield* PubSub.shutdown(pubsub.all) yield* Effect.forEach( - synchronized.values(), + pubsub.durable.values(), (pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }), { discard: true }, ) - yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true }) + yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true }) }), ) - function commitSyncEvent( + function commitDurableEvent( + definition: Definition, event: Payload, input?: { readonly seq: number @@ -223,29 +214,20 @@ export const layerWith = (options?: LayerOptions) => commit?: (seq: number) => Effect.Effect, ) { return Effect.gen(function* () { - const definition = registry.get(event.type) - const sync = definition?.sync - if (sync) { - if (event.version !== sync.version) { - yield* Effect.die( - new InvalidSyncEventError({ - type: event.type, - message: `Expected event version ${sync.version}, got ${event.version}`, - }), - ) - } - const aggregateID = (event.data as Record)[sync.aggregate] + const durable = definition?.durable + if (durable) { + const aggregateID = (event.data as Record)[durable.aggregate] if (typeof aggregateID !== "string") { yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: event.type, - message: `Expected string aggregate field ${sync.aggregate}`, + message: `Expected string aggregate field ${durable.aggregate}`, }), ) } else { if (input && input.aggregateID !== aggregateID) { yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: event.type, message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`, }), @@ -265,12 +247,13 @@ export const layerWith = (options?: LayerOptions) => .get() .pipe(Effect.orDie) const latest = row?.seq ?? -1 - const encoded = syncRegistry - .get(versionedType(definition.type, sync.version))! - .encode(event.data) as Record + const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record< + string, + unknown + > if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) { yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: event.type, message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`, }), @@ -285,7 +268,7 @@ export const layerWith = (options?: LayerOptions) => .pipe(Effect.orDie) if ( stored?.id === event.id && - stored.type === versionedType(definition.type, sync.version) && + stored.type === versionedType(definition.type, durable.version) && isDeepStrictEqual(stored.data, encoded) ) { if (input.ownerID && row?.ownerID == null) { @@ -299,7 +282,7 @@ export const layerWith = (options?: LayerOptions) => return } yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: event.type, message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`, }), @@ -311,7 +294,7 @@ export const layerWith = (options?: LayerOptions) => const seq = input?.seq ?? latest + 1 if (input && seq !== latest + 1) { yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: event.type, message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`, }), @@ -325,16 +308,17 @@ export const layerWith = (options?: LayerOptions) => .pipe(Effect.orDie) if (stored) yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: event.type, message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`, }), ) - for (const guard of commitGuards) { - yield* guard(event) - } + const committed = { + ...event, + durable: { aggregateID, seq, version: durable.version }, + } as Payload for (const projector of list) { - yield* projector({ ...event, seq } as Payload) + yield* projector(committed) } if (commit) yield* commit(seq) yield* db @@ -356,7 +340,7 @@ export const layerWith = (options?: LayerOptions) => id: event.id, aggregate_id: aggregateID, seq, - type: versionedType(definition.type, sync.version), + type: versionedType(definition.type, durable.version), data: encoded, }, ]) @@ -369,8 +353,8 @@ export const layerWith = (options?: LayerOptions) => .pipe(Effect.orDie) if (committed) { yield* Effect.forEach( - synchronized.get(committed.aggregateID) ?? [], - (pubsub) => PubSub.publish(pubsub, undefined), + pubsub.durable.get(committed.aggregateID) ?? [], + (wake) => PubSub.publish(wake, undefined), { discard: true }, ) } @@ -382,21 +366,26 @@ export const layerWith = (options?: LayerOptions) => }) } - function publishEvent(event: Payload, commit?: PublishOptions["commit"]) { + function publishEvent(definition: D, event: Payload, commit?: PublishOptions["commit"]) { return Effect.gen(function* () { - const durable = registry.get(event.type)?.sync !== undefined - if (!durable && commit) + if (!definition?.durable && commit) return yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: event.type, - message: "Local commit hooks require a synchronized event", + message: "Local commit hooks require a durable event", }), ) - if (durable) { - const committed = yield* commitSyncEvent(event as Payload, undefined, commit) + if (definition?.durable) { + const committed = yield* commitDurableEvent(definition, event as Payload, undefined, commit) if (committed) { - event = { ...event, seq: committed.seq } - yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true }) + event = { + ...event, + durable: { + aggregateID: committed.aggregateID, + seq: committed.seq, + version: definition.durable.version, + }, + } yield* notify(event as Payload, true) return event } @@ -406,12 +395,11 @@ export const layerWith = (options?: LayerOptions) => }) } - const observe = (event: Payload, kind: "sync" | "listener", observer: (event: Payload) => Effect.Effect) => + const observe = (event: Payload, observer: (event: Payload) => Effect.Effect) => Effect.suspend(() => observer(event)).pipe( Effect.catchCauseIf( (cause) => !Cause.hasInterrupts(cause), - (cause) => - Effect.logError("Event observer failed", { eventID: event.id, eventType: event.type, kind, cause }), + (cause) => Effect.logError("Event listener failed", { eventID: event.id, eventType: event.type, cause }), ), ) @@ -419,12 +407,12 @@ export const layerWith = (options?: LayerOptions) => return Effect.gen(function* () { yield* Effect.forEach( listeners, - (listener) => (isolateListeners ? observe(event, "listener", listener) : listener(event)), + (listener) => (isolateListeners ? observe(event, listener) : listener(event)), { discard: true }, ) - const pubsub = typed.get(event.type) - if (pubsub) yield* PubSub.publish(pubsub, event) - yield* PubSub.publish(all, event) + const typed = pubsub.typed.get(event.type) + if (typed) yield* PubSub.publish(typed, event) + yield* PubSub.publish(pubsub.all, event) }) } @@ -437,11 +425,11 @@ export const layerWith = (options?: LayerOptions) => ? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID } : undefined) return yield* publishEvent( + definition, { id: options?.id ?? ID.create(), ...(options?.metadata ? { metadata: options.metadata } : {}), type: definition.type, - ...(definition.sync === undefined ? {} : { version: definition.sync.version }), ...(location ? { location } : {}), data, } as Payload, @@ -455,27 +443,35 @@ export const layerWith = (options?: LayerOptions) => options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, ) { return Effect.gen(function* () { - const definition = syncRegistry.get(event.type) - if (!definition) { + const definition = Durable.get(event.type) + if (!definition?.durable) { yield* Effect.die( - new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }), + new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }), ) } else { const payload = { id: event.id, type: definition.type, - version: definition.sync.version, - data: definition.decode(event.data), - replay: true, + data: Schema.decodeUnknownSync(definition.data)(event.data), } as Payload - const committed = yield* commitSyncEvent(payload, { + const committed = yield* commitDurableEvent(definition, payload, { seq: event.seq, aggregateID: event.aggregateID, ownerID: options?.ownerID, strictOwner: options?.strictOwner, }) if (committed && options?.publish) { - yield* notify({ ...payload, seq: committed.seq }, true) + yield* notify( + { + ...payload, + durable: { + aggregateID: committed.aggregateID, + seq: committed.seq, + version: definition.durable.version, + }, + }, + true, + ) } } }) @@ -490,7 +486,7 @@ export const layerWith = (options?: LayerOptions) => if (!source) return undefined if (events.some((event) => event.aggregateID !== source)) { yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: events[0]?.type ?? "unknown", message: "Replay events must belong to the same aggregate", }), @@ -501,7 +497,7 @@ export const layerWith = (options?: LayerOptions) => const seq = start + index if (event.seq !== seq) { yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: event.type, message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`, }), @@ -540,24 +536,7 @@ export const layerWith = (options?: LayerOptions) => Stream.map((event) => event as Payload), ) - const streamAll = (): Stream.Stream => Stream.fromPubSub(all) - - const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => { - const definition = syncRegistry.get(event.type) - if (!definition) { - throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }) - } - return { - cursor: Cursor.make(event.seq), - event: { - id: event.id, - type: definition.type, - version: definition.sync.version, - seq: event.seq, - data: definition.decode(event.data), - }, - } - } + const streamAll = (): Stream.Stream => Stream.fromPubSub(pubsub.all) const readAfter = (aggregateID: string, after: number) => (options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe( @@ -583,43 +562,40 @@ export const layerWith = (options?: LayerOptions) => ), ) - const subscribeSynchronized = (aggregateID: string) => + const subscribeDurable = (aggregateID: string) => Effect.gen(function* () { - const pubsub = yield* PubSub.sliding(1) - const subscription = yield* PubSub.subscribe(pubsub) + const wake = yield* PubSub.sliding(1) + const subscription = yield* PubSub.subscribe(wake) yield* Effect.acquireRelease( Effect.sync(() => { - const pubsubs = synchronized.get(aggregateID) ?? new Set() - pubsubs.add(pubsub) - synchronized.set(aggregateID, pubsubs) + const wakes = pubsub.durable.get(aggregateID) ?? new Set() + wakes.add(wake) + pubsub.durable.set(aggregateID, wakes) }), () => Effect.sync(() => { - const pubsubs = synchronized.get(aggregateID) - pubsubs?.delete(pubsub) - if (pubsubs?.size === 0) synchronized.delete(aggregateID) - }).pipe(Effect.andThen(PubSub.shutdown(pubsub))), + const wakes = pubsub.durable.get(aggregateID) + wakes?.delete(wake) + if (wakes?.size === 0) pubsub.durable.delete(aggregateID) + }).pipe(Effect.andThen(PubSub.shutdown(wake))), ) return subscription }) - const streamEvents = (input: { - readonly aggregateID: string - readonly after?: Cursor - }): Stream.Stream => + const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream => Stream.unwrap( Effect.gen(function* () { - const synchronized = yield* subscribeSynchronized(input.aggregateID) - let cursor = input.after ?? -1 - const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe( + const wakes = yield* subscribeDurable(input.aggregateID) + let sequence = input.after ?? -1 + const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe( Effect.tap((events) => Effect.sync(() => { - cursor = events.at(-1)?.cursor ?? cursor + sequence = events.at(-1)?.durable?.seq ?? sequence }), ), ) const historical = yield* read - const live = Stream.fromSubscription(synchronized).pipe( + const live = Stream.fromSubscription(wakes).pipe( Stream.mapEffect(() => read), Stream.flattenIterable, ) @@ -627,7 +603,7 @@ export const layerWith = (options?: LayerOptions) => }), ) - const listen = (listener: Listener): Effect.Effect => + const listen = (listener: Subscriber): Effect.Effect => Effect.sync(() => { listeners.push(listener) return Effect.sync(() => { @@ -636,21 +612,7 @@ export const layerWith = (options?: LayerOptions) => }) }) - const sync = (handler: Sync): Effect.Effect => - Effect.sync(() => { - syncHandlers.push(handler) - return Effect.sync(() => { - const index = syncHandlers.indexOf(handler) - if (index >= 0) syncHandlers.splice(index, 1) - }) - }) - - const beforeCommit = (guard: CommitGuard): Effect.Effect => - Effect.sync(() => { - commitGuards.push(guard) - }) - - const project = (definition: D, projector: Projector): Effect.Effect => + const project = (definition: D, projector: Subscriber): Effect.Effect => Effect.sync(() => { const list = projectors.get(definition.type) ?? [] list.push((event) => projector(event as Payload)) @@ -661,10 +623,8 @@ export const layerWith = (options?: LayerOptions) => publish, subscribe, all: streamAll, - aggregateEvents: streamEvents, - sync, + durable, listen, - beforeCommit, project, replay, replayAll, @@ -674,7 +634,5 @@ export const layerWith = (options?: LayerOptions) => }), ) -export const layer = layerWith() -export const node = LayerNode.make(layer, [Database.node]) - -export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) +const layer = layerWith() +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] }) diff --git a/packages/core/src/file-mutation.ts b/packages/core/src/file-mutation.ts index a3c6f519a3..80a3a449f6 100644 --- a/packages/core/src/file-mutation.ts +++ b/packages/core/src/file-mutation.ts @@ -1,5 +1,6 @@ export * as FileMutation from "./file-mutation" +import { makeLocationNode } from "./effect/app-node" import { Context, Effect, Layer, Schema } from "effect" import { dirname } from "path" import { KeyedMutex } from "./effect/keyed-mutex" @@ -70,7 +71,7 @@ export class Service extends Context.Service()("@opencode/v2 * write under the same process-local lock so cooperating OpenCode mutations do * not overwrite changes made from the same stale content. */ -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -192,6 +193,8 @@ function sameBytes(left: Uint8Array, right: Uint8Array) { export const locationLayer = layer +export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] }) + /** * Deferred until the corresponding V2 integrations exist. */ diff --git a/packages/core/src/file.ts b/packages/core/src/file.ts new file mode 100644 index 0000000000..87745c01ee --- /dev/null +++ b/packages/core/src/file.ts @@ -0,0 +1,6 @@ +export * as File from "./file" + +import { Revert } from "@opencode-ai/schema/revert" + +export const Diff = Revert.FileDiff +export type Diff = typeof Diff.Type diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 3257fe8840..8470134ff7 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -1,14 +1,14 @@ export * as FileSystem from "./filesystem" +import { makeLocationNode } from "./effect/app-node" import path from "path" import { Context, Effect, Layer, Schema } from "effect" -import { EventV2 } from "./event" import { FSUtil } from "./fs-util" import { Location } from "./location" import { PositiveInt, RelativePath } from "./schema" import { FileSystemSearch } from "./filesystem/search" -import { Entry, Match } from "./filesystem/schema" -export { Entry, Match, Submatch } from "./filesystem/schema" +import { Entry, FileSystem, FindInput, Match } from "@opencode-ai/schema/filesystem" +export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem" export const ReadInput = Schema.Struct({ path: RelativePath, @@ -29,11 +29,7 @@ export const ListInput = Schema.Struct({ }) export type ListInput = typeof ListInput.Type -export class FindInput extends Schema.Class("FileSystem.FindInput")({ - query: Schema.String, - type: Schema.Literals(["file", "directory"]).pipe(Schema.optional), - limit: PositiveInt.pipe(Schema.optional), -}) {} +export { FindInput } export class GlobInput extends Schema.Class("FileSystem.GlobInput")({ pattern: Schema.String, @@ -48,14 +44,7 @@ export class GrepInput extends Schema.Class("FileSystem.GrepInput")({ limit: PositiveInt.pipe(Schema.optional), }) {} -export const Event = { - Edited: EventV2.define({ - type: "file.edited", - schema: { - file: Schema.String, - }, - }), -} +export const Event = FileSystem.Event export interface Interface { readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }> @@ -108,10 +97,9 @@ const baseLayer = Layer.effect( const absolute = path.join(target.absolute, item.name) const relative = path.relative(target.directory, absolute) return [ - new Entry({ + Entry.make({ path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")), type: item.type, - mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute), }), ] }) @@ -123,6 +111,8 @@ const baseLayer = Layer.effect( }), ) -export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer)) - -export const locationLayer = layer +export const node = makeLocationNode({ + service: Service, + layer: baseLayer, + deps: [FSUtil.node, Location.node, FileSystemSearch.node], +}) diff --git a/packages/core/src/filesystem/schema.ts b/packages/core/src/filesystem/schema.ts deleted file mode 100644 index 6a2cb48413..0000000000 --- a/packages/core/src/filesystem/schema.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Schema } from "effect" -import { NonNegativeInt, PositiveInt, RelativePath } from "../schema" - -export class Entry extends Schema.Class("FileSystem.Entry")({ - path: RelativePath, - type: Schema.Literals(["file", "directory"]), - mime: Schema.String, -}) {} - -export const Submatch = Schema.Struct({ - text: Schema.String, - start: NonNegativeInt, - end: NonNegativeInt, -}) -export type Submatch = typeof Submatch.Type - -export class Match extends Schema.Class("FileSystem.Match")({ - entry: Entry, - line: PositiveInt, - offset: NonNegativeInt, - text: Schema.String, - submatches: Schema.Array(Submatch), -}) {} diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 81094c440a..96a5c6f176 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -1,5 +1,6 @@ export * as FileSystemSearch from "./search" +import { makeLocationNode } from "../effect/app-node" import path from "path" import { Context, Effect, Layer, Scope } from "effect" import { Fff } from "#fff" @@ -59,12 +60,11 @@ export const ripgrepLayer = Layer.effect( }) .pipe( Effect.map((result) => - result.map( - (entry) => - new FileSystem.Entry({ - ...entry, - path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))), - }), + result.map((entry) => + FileSystem.Entry.make({ + ...entry, + path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))), + }), ), ), Effect.orDie, @@ -85,15 +85,14 @@ export const ripgrepLayer = Layer.effect( }) .pipe( Effect.map((result) => - result.map( - (match) => - new FileSystem.Match({ - ...match, - entry: new FileSystem.Entry({ - ...match.entry, - path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))), - }), + result.map((match) => + FileSystem.Match.make({ + ...match, + entry: FileSystem.Entry.make({ + ...match.entry, + path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))), }), + }), ), ), Effect.orDie, @@ -110,12 +109,9 @@ export const ripgrepLayer = Layer.effect( return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => { const relative = item.target const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const) - const clean = type === "directory" ? relative.slice(0, -path.sep.length) : relative - const absolute = path.resolve(location.directory, clean) - return new FileSystem.Entry({ + return FileSystem.Entry.make({ path: RelativePath.make(relative), type, - mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute), }) }) }), @@ -132,12 +128,19 @@ export const fffLayer = Layer.effect( Fff.create({ basePath: location.directory, aiMode: true, - enableFsRootScanning: true, - enableHomeDirScanning: true, }), catch: (cause) => cause, - }).pipe(Effect.orDie) - if (!result.ok) return yield* Effect.die(result.error) + }).pipe( + Effect.catch((error) => Effect.logWarning("failed to initialize fff", { error }).pipe(Effect.as(undefined))), + ) + if (!result?.ok) { + if (result) yield* Effect.logWarning("failed to initialize fff", { error: result.error }) + return Service.of({ + find: () => Effect.succeed([]), + glob: () => Effect.succeed([]), + grep: () => Effect.succeed([]), + }) + } yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore)) return Service.of({ glob: (input) => @@ -148,14 +151,12 @@ export const fffLayer = Layer.effect( pageSize: input.limit, }) if (!found.ok) throw found.error - return found.value.items.map((item) => { - const absolute = path.resolve(location.directory, item.relativePath) - return new FileSystem.Entry({ + return found.value.items.map((item) => + FileSystem.Entry.make({ path: RelativePath.make(item.relativePath.replaceAll("\\", "/")), type: "file", - mime: FSUtil.mimeType(absolute), - }) - }) + }), + ) }), grep: (input) => Effect.sync(() => { @@ -169,11 +170,10 @@ export const fffLayer = Layer.effect( if (!found.ok) throw found.error return found.value.items.map((match) => { const bytes = Buffer.from(match.lineContent) - return new FileSystem.Match({ - entry: new FileSystem.Entry({ + return FileSystem.Match.make({ + entry: FileSystem.Entry.make({ path: RelativePath.make(match.relativePath.replaceAll("\\", "/")), type: "file", - mime: FSUtil.mimeType(match.relativePath), }), line: match.lineNumber, offset: match.byteOffset, @@ -220,11 +220,9 @@ export const fffLayer = Layer.effect( .sort((a, b) => b.score - a.score || a.path.length - b.path.length) .map((item) => { const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "") - const absolute = path.resolve(location.directory, relative) - return new FileSystem.Entry({ + return FileSystem.Entry.make({ path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")), type: item.type, - mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute), }) }) }), @@ -232,6 +230,8 @@ export const fffLayer = Layer.effect( }), ) -export const defaultLayer = Layer.unwrap( - Effect.sync(() => (Flag.KILO_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)), -) +const layer = Layer.unwrap(Effect.sync(() => (Flag.KILO_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer))) + +export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] }) diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 69a4501fbc..1f7e0ed58f 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -3,7 +3,9 @@ export * as Watcher from "./watcher" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" -import { Cause, Context, Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" +import { Cause, Context, Effect, Layer } from "effect" +import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" import path from "path" import { Config } from "../config" import { EventV2 } from "../event" @@ -19,15 +21,7 @@ declare const KILO_LIBC: string | undefined const SUBSCRIBE_TIMEOUT_MS = 10_000 -export const Event = { - Updated: EventV2.define({ - type: "file.watcher.updated", - schema: { - file: Schema.String, - event: Schema.Literals(["add", "change", "unlink"]), - }, - }), -} +export const Event = FileSystemWatcher.Event const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { try { @@ -60,7 +54,7 @@ export interface Interface {} export class Service extends Context.Service()("@opencode/v2/FileWatcher") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { if (yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({}) @@ -119,7 +113,7 @@ export const layer = Layer.effect( } if (location.vcs?.type === "git") { - const resolved = yield* git.dir(location.directory) + const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) { const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap( @@ -139,4 +133,8 @@ export const layer = Layer.effect( ), ) -export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer)) +export const node = makeLocationNode({ + service: Service, + layer, + deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node], +}) diff --git a/packages/core/src/fs-util.ts b/packages/core/src/fs-util.ts index 24263cbadf..ff71477d70 100644 --- a/packages/core/src/fs-util.ts +++ b/packages/core/src/fs-util.ts @@ -7,14 +7,19 @@ import { Context, Effect, FileSystem, Layer, Schema } from "effect" import type { PlatformError } from "effect/PlatformError" import { Glob } from "./util/glob" import { serviceUse } from "./effect/service-use" -import { LayerNode } from "./effect/layer-node" -import { filesystem } from "./effect/layer-node-platform" +import { makeGlobalNode } from "./effect/app-node" +import { filesystem } from "./effect/app-node-platform" export namespace FSUtil { export class FileSystemError extends Schema.TaggedErrorClass()("FileSystemError", { method: Schema.String, - cause: Schema.optional(Schema.Defect), - }) {} + cause: Schema.optional(Schema.Defect()), + }) { + override get message() { + const detail = this.cause instanceof Error ? this.cause.message : this.cause && String(this.cause) + return `Filesystem operation failed: ${this.method}${detail ? `: ${detail}` : ""}` + } + } export type Error = PlatformError | FileSystemError @@ -44,7 +49,7 @@ export namespace FSUtil { export const use = serviceUse(Service) - export const layer = Layer.effect( + const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FileSystem.FileSystem @@ -195,8 +200,7 @@ export namespace FSUtil { }), ) - export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer)) - export const node = LayerNode.make(layer, [filesystem]) + export const node = makeGlobalNode({ service: Service, layer: layer, deps: [filesystem] }) // Pure helpers that don't need Effect (path manipulation, sync operations) export function mimeType(p: string): string { diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index 0041c3353f..19dcde8961 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -1,88 +1,187 @@ export * as Git from "./git" import path from "path" +import { randomUUID } from "crypto" import { Context, Effect, Layer, Schema, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" -import { AbsolutePath } from "./schema" +import { AbsolutePath, RelativePath } from "./schema" import { FSUtil } from "./fs-util" import { AppProcess } from "./process" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/app-node" +import { File } from "./file" +import { KeyedMutex } from "./effect/keyed-mutex" -export interface Repo { - /** - * The root directory of the working tree that contains the input path. - * - * For `/home/me/app/src/file.ts` in a normal clone, this is `/home/me/app`. - * For `/home/me/app-feature/src/file.ts` in a linked worktree, this is - * `/home/me/app-feature`. - */ - readonly directory: AbsolutePath - /** - * The shared Git storage directory used by this repo and any linked worktrees. - * - * For a normal clone at `/home/me/app`, this is usually `/home/me/app/.git`. - * For a linked worktree at `/home/me/app-feature` whose main checkout is - * `/home/me/app`, this is usually `/home/me/app/.git`. - */ - readonly store: AbsolutePath -} +export class Repository extends Schema.Class("Git.Repository")({ + worktree: AbsolutePath, + gitDirectory: AbsolutePath, + commonDirectory: AbsolutePath, +}) {} + +export const ChangeSet = Schema.String.pipe(Schema.brand("Git.ChangeSet")) +export type ChangeSet = typeof ChangeSet.Type + +export const TreeID = Schema.String.pipe(Schema.brand("Git.TreeID")) +export type TreeID = typeof TreeID.Type + +export class OperationError extends Schema.TaggedErrorClass()("Git.OperationError", { + operation: Schema.Literals([ + "clone", + "fetch", + "checkout", + "reset", + "create", + "refresh", + "write_tree", + "list_files", + "diff", + "restore", + ]), + message: Schema.String, + directory: Schema.optional(AbsolutePath), + cause: Schema.optional(Schema.Defect()), +}) {} + +export class Worktree extends Schema.Class("Git.Worktree")({ + directory: AbsolutePath, + kind: Schema.Literals(["main", "linked"]), +}) {} export class WorktreeError extends Schema.TaggedErrorClass()("Git.WorktreeError", { operation: Schema.Literals(["create", "remove", "list"]), message: Schema.String, directory: Schema.optional(AbsolutePath), forceRequired: Schema.optional(Schema.Boolean), - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class PatchError extends Schema.TaggedErrorClass()("Git.PatchError", { operation: Schema.Literals(["capture", "apply", "reset"]), directory: AbsolutePath, message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export interface Interface { - readonly find: (input: AbsolutePath) => Effect.Effect - readonly remote: (repo: Repo, name?: string) => Effect.Effect - readonly roots: (repo: Repo) => Effect.Effect - readonly origin: (directory: string) => Effect.Effect - readonly head: (directory: string) => Effect.Effect - readonly dir: (directory: string) => Effect.Effect - readonly branch: (directory: string) => Effect.Effect - readonly remoteHead: (directory: string) => Effect.Effect - readonly clone: (input: { - remote: string - target: string - branch?: string - depth?: number - }) => Effect.Effect - readonly fetch: (directory: string) => Effect.Effect - readonly fetchBranch: (directory: string, branch: string) => Effect.Effect - readonly checkout: (directory: string, branch: string) => Effect.Effect - readonly reset: (directory: string, target: string) => Effect.Effect - readonly patch: (directory: AbsolutePath) => Effect.Effect - readonly applyPatch: (input: { directory: AbsolutePath; patch: string }) => Effect.Effect - readonly resetChanges: (directory: AbsolutePath) => Effect.Effect - readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect - readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect - readonly worktreeRemove: (input: { - repo: Repo - directory: AbsolutePath - force: boolean - }) => Effect.Effect - readonly worktreeList: (repo: Repo) => Effect.Effect + readonly repo: { + readonly discover: (input: AbsolutePath) => Effect.Effect + readonly clone: (input: { + remote: string + directory: AbsolutePath + branch?: string + depth?: number + }) => Effect.Effect + readonly create: (input: { + worktree: AbsolutePath + gitDirectory: AbsolutePath + seed?: Repository + }) => Effect.Effect + } + readonly remote: { + readonly get: (repository: Repository, name?: string) => Effect.Effect + } + readonly history: { + readonly head: (repository: Repository) => Effect.Effect + readonly branch: (repository: Repository) => Effect.Effect + readonly defaultRemoteBranch: (repository: Repository, remote?: string) => Effect.Effect + readonly rootCommits: (repository: Repository) => Effect.Effect + } + readonly sync: { + readonly fetchRemotes: (repository: Repository, input?: { prune?: boolean }) => Effect.Effect + readonly fetchBranch: ( + repository: Repository, + input: { remote?: string; branch: string; force?: boolean }, + ) => Effect.Effect + readonly checkoutRemoteBranch: ( + repository: Repository, + input: { remote?: string; branch: string; reset?: boolean }, + ) => Effect.Effect + readonly resetHard: (repository: Repository, revision: string) => Effect.Effect + } + readonly change: { + readonly capture: (input: { repository: Repository; path: AbsolutePath }) => Effect.Effect + readonly apply: (input: { + repository: Repository + path: AbsolutePath + changes: ChangeSet + }) => Effect.Effect + readonly discard: (input: { + repository: Repository + path: AbsolutePath + index: "preserve" | "reset" + untracked: "preserve" | "remove" + }) => Effect.Effect + } + readonly worktree: { + readonly create: (input: { + repository: Repository + directory: AbsolutePath + }) => Effect.Effect + readonly remove: (input: { + repository: Repository + directory: AbsolutePath + force: boolean + }) => Effect.Effect + readonly list: (repository: Repository) => Effect.Effect + } + readonly index: { + /** Refresh only the requested project-relative scope, preserving all other entries. */ + readonly refresh: (input: { + repository: Repository + scope: RelativePath + ignores?: Repository + maximumUntrackedFileBytes?: number + }) => Effect.Effect<{ readonly skipped: readonly RelativePath[] }, OperationError> + readonly ignored: (input: { + repository: Repository + paths: readonly RelativePath[] + }) => Effect.Effect, OperationError> + } + readonly tree: { + readonly capture: (input: { + repository: Repository + scopes: readonly RelativePath[] + ignores?: Repository + maximumUntrackedFileBytes?: number + }) => Effect.Effect + readonly write: (repository: Repository) => Effect.Effect + readonly files: (input: { + repository: Repository + from: TreeID + to: TreeID + }) => Effect.Effect + readonly diff: (input: { + repository: Repository + from: TreeID + to: TreeID + context?: number + paths?: readonly RelativePath[] + }) => Effect.Effect + readonly preview: (input: { + repository: Repository + current: TreeID + files: ReadonlyMap + context?: number + }) => Effect.Effect + readonly restore: (input: { + repository: Repository + files: ReadonlyMap + }) => Effect.Effect + readonly checkout: (input: { repository: Repository; tree: TreeID }) => Effect.Effect + } } export class Service extends Context.Service()("@opencode/GitV2") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service const proc = yield* AppProcess.Service + const locks = KeyedMutex.makeUnsafe() + const locked = (repository: Repository, effect: Effect.Effect) => + locks.withLock(repository.gitDirectory)(effect) - const find = Effect.fn("Git.find")(function* (input: AbsolutePath) { + const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) { const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe( Effect.map((matches) => matches[0]), Effect.catch(() => Effect.succeed(undefined)), @@ -92,23 +191,25 @@ export const layer = Layer.effect( const cwd = path.dirname(dotgit) const git = run(cwd, proc) const topLevel = yield* git(["rev-parse", "--show-toplevel"]) + const gitDir = yield* git(["rev-parse", "--git-dir"]) const commonDir = yield* git(["rev-parse", "--git-common-dir"]) - if (commonDir.exitCode !== 0) return undefined + if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) return undefined - return { - directory: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd), - store: AbsolutePath.make(resolvePath(cwd, commonDir.text)), - } satisfies Repo + return new Repository({ + worktree: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd), + gitDirectory: AbsolutePath.make(resolvePath(cwd, gitDir.text)), + commonDirectory: AbsolutePath.make(resolvePath(cwd, commonDir.text)), + }) }) - const remote = Effect.fn("Git.remote")(function* (repo: Repo, name = "origin") { - const result = yield* run(repo.directory, proc)(["remote", "get-url", name]) + const remote = Effect.fn("Git.remote.get")(function* (repository: Repository, name = "origin") { + const result = yield* run(repository.worktree, proc)(["remote", "get-url", name]) if (result.exitCode !== 0) return undefined return result.text.trim() || undefined }) - const roots = Effect.fn("Git.roots")(function* (repo: Repo) { - const result = yield* run(repo.directory, proc)(["rev-list", "--max-parents=0", "HEAD"]) + const roots = Effect.fn("Git.history.rootCommits")(function* (repository: Repository) { + const result = yield* run(repository.worktree, proc)(["rev-list", "--max-parents=0", "HEAD"]) if (result.exitCode !== 0) return [] return result.text .split("\n") @@ -117,116 +218,555 @@ export const layer = Layer.effect( .toSorted() }) - const origin = Effect.fn("Git.origin")(function* (directory: string) { - const result = yield* run(directory, proc)(["config", "--get", "remote.origin.url"]) + const head = Effect.fn("Git.history.head")(function* (repository: Repository) { + const result = yield* run(repository.worktree, proc)(["rev-parse", "HEAD"]) if (result.exitCode !== 0) return undefined return result.text.trim() || undefined }) - const head = Effect.fn("Git.head")(function* (directory: string) { - const result = yield* run(directory, proc)(["rev-parse", "HEAD"]) + const branch = Effect.fn("Git.history.branch")(function* (repository: Repository) { + const result = yield* run(repository.worktree, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"]) if (result.exitCode !== 0) return undefined return result.text.trim() || undefined }) - const dir = Effect.fn("Git.dir")(function* (directory: string) { - const result = yield* run(directory, proc)(["rev-parse", "--git-dir"]) + const remoteHead = Effect.fn("Git.history.defaultRemoteBranch")(function* ( + repository: Repository, + remoteName = "origin", + ) { + const result = yield* run(repository.worktree, proc)(["symbolic-ref", `refs/remotes/${remoteName}/HEAD`]) if (result.exitCode !== 0) return undefined - return AbsolutePath.make(resolvePath(directory, result.text)) + return result.text.trim().replace(new RegExp(`^refs/remotes/${remoteName}/`), "") || undefined }) - const branch = Effect.fn("Git.branch")(function* (directory: string) { - const result = yield* run(directory, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"]) - if (result.exitCode !== 0) return undefined - return result.text.trim() || undefined - }) - - const remoteHead = Effect.fn("Git.remoteHead")(function* (directory: string) { - const result = yield* run(directory, proc)(["symbolic-ref", "refs/remotes/origin/HEAD"]) - if (result.exitCode !== 0) return undefined - return result.text.trim().replace(/^refs\/remotes\//, "") || undefined - }) - - const clone = Effect.fn("Git.clone")((input: { remote: string; target: string; branch?: string; depth?: number }) => - execute( - path.dirname(input.target), + const operation = Effect.fnUntraced(function* ( + operation: OperationError["operation"], + directory: AbsolutePath, + args: string[], + ) { + const result = yield* execute( + directory, proc, - )([ + )(args).pipe( + Effect.mapError((cause) => new OperationError({ operation, directory, message: cause.message, cause })), + ) + if (result.exitCode === 0) return + return yield* new OperationError({ + operation, + directory, + message: result.stderr.trim() || result.text.trim() || `Git ${operation} failed`, + }) + }) + + const clone = Effect.fn("Git.repo.clone")(function* (input: { + remote: string + directory: AbsolutePath + branch?: string + depth?: number + }) { + yield* operation("clone", AbsolutePath.make(path.dirname(input.directory)), [ "clone", "--depth", String(input.depth ?? 100), ...(input.branch ? ["--branch", input.branch] : []), "--", input.remote, - input.target, - ]), - ) + input.directory, + ]) + const repository = yield* discover(input.directory) + if (repository) return repository + return yield* new OperationError({ + operation: "clone", + directory: input.directory, + message: "Cloned repository could not be opened", + }) + }) - const fetch = Effect.fn("Git.fetch")((directory: string) => execute(directory, proc)(["fetch", "--all", "--prune"])) + const fetch = Effect.fn("Git.sync.fetchRemotes")(function* ( + repository: Repository, + input: { prune?: boolean } = {}, + ) { + yield* operation("fetch", repository.worktree, ["fetch", "--all", ...(input.prune === false ? [] : ["--prune"])]) + }) - const fetchBranch = Effect.fn("Git.fetchBranch")((directory: string, branch: string) => - execute(directory, proc)(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]), - ) + const fetchBranch = Effect.fn("Git.sync.fetchBranch")(function* ( + repository: Repository, + input: { remote?: string; branch: string; force?: boolean }, + ) { + const remoteName = input.remote ?? "origin" + const spec = `refs/heads/${input.branch}:refs/remotes/${remoteName}/${input.branch}` + yield* operation("fetch", repository.worktree, ["fetch", remoteName, input.force === false ? spec : `+${spec}`]) + }) - const checkout = Effect.fn("Git.checkout")((directory: string, branch: string) => - execute(directory, proc)(["checkout", "-B", branch, `origin/${branch}`]), - ) + const checkout = Effect.fn("Git.sync.checkoutRemoteBranch")(function* ( + repository: Repository, + input: { remote?: string; branch: string; reset?: boolean }, + ) { + const remoteName = input.remote ?? "origin" + yield* operation("checkout", repository.worktree, [ + "checkout", + ...(input.reset === false ? [input.branch] : ["-B", input.branch, `${remoteName}/${input.branch}`]), + ]) + }) - const reset = Effect.fn("Git.reset")((directory: string, target: string) => - execute(directory, proc)(["reset", "--hard", target]), - ) + const reset = Effect.fn("Git.sync.resetHard")(function* (repository: Repository, revision: string) { + yield* operation("reset", repository.worktree, ["reset", "--hard", revision]) + }) - const patch = Effect.fn("Git.patch")(function* (directory: AbsolutePath) { - const root = yield* execute( - directory, - proc, - )(["rev-parse", "--show-toplevel"]).pipe( - Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })), + const repositoryArgs = (repository: Repository, args: string[]) => [ + "--git-dir", + repository.gitDirectory, + "--work-tree", + repository.worktree, + ...args, + ] + + const repositoryOperation = Effect.fnUntraced(function* ( + operationName: OperationError["operation"], + repository: Repository, + args: string[], + options?: { stdin?: string; env?: Record }, + ) { + const result = yield* proc + .run( + ChildProcess.make("git", repositoryArgs(repository, args), { + cwd: repository.worktree, + env: options?.env, + extendEnv: true, + }), + { stdin: options?.stdin }, + ) + .pipe( + Effect.mapError( + (cause) => + new OperationError({ + operation: operationName, + directory: repository.worktree, + message: cause.message, + cause, + }), + ), + ) + const text = result.stdout.toString("utf8") + if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") } + return yield* new OperationError({ + operation: operationName, + directory: repository.worktree, + message: result.stderr.toString("utf8").trim() || text.trim() || `Git ${operationName} failed`, + }) + }) + + const create = Effect.fn("Git.repo.create")(function* (input: { + worktree: AbsolutePath + gitDirectory: AbsolutePath + seed?: Repository + }) { + yield* fs.ensureDir(input.gitDirectory).pipe( + Effect.mapError( + (cause) => + new OperationError({ + operation: "create", + directory: input.gitDirectory, + message: "Failed to create Git storage", + cause, + }), + ), ) - if (root.exitCode !== 0) { - return yield* new PatchError({ - operation: "capture", - directory, - message: root.stderr.trim() || root.text.trim() || "Failed to locate repository root", + const repository = new Repository({ + worktree: input.worktree, + gitDirectory: input.gitDirectory, + commonDirectory: input.gitDirectory, + }) + yield* repositoryOperation("create", repository, ["init"]) + yield* Effect.forEach( + [ + ["core.autocrlf", "false"], + ["core.longpaths", "true"], + ["core.symlinks", "true"], + ["core.fsmonitor", "false"], + ["feature.manyFiles", "true"], + ["index.version", "4"], + ["index.threads", "true"], + ["core.untrackedCache", "true"], + ], + ([key, value]) => repositoryOperation("create", repository, ["config", key, value]), + { discard: true }, + ) + if (!input.seed) return repository + yield* fs.ensureDir(path.join(input.gitDirectory, "objects", "info")).pipe( + Effect.mapError( + (cause) => + new OperationError({ + operation: "create", + directory: input.gitDirectory, + message: "Failed to configure shared Git objects", + cause, + }), + ), + ) + yield* fs + .writeFileString( + path.join(input.gitDirectory, "objects", "info", "alternates"), + path.join(input.seed.commonDirectory, "objects") + "\n", + ) + .pipe( + Effect.mapError( + (cause) => + new OperationError({ + operation: "create", + directory: input.gitDirectory, + message: "Failed to configure shared Git objects", + cause, + }), + ), + ) + yield* fs + .copyFile(path.join(input.seed.gitDirectory, "index"), path.join(input.gitDirectory, "index")) + .pipe(Effect.catch(() => Effect.void)) + return repository + }) + + const refresh = Effect.fn("Git.index.refresh")(function* (input: { + repository: Repository + scope: RelativePath + ignores?: Repository + maximumUntrackedFileBytes?: number + }) { + const list = (args: string[]) => + repositoryOperation("refresh", input.repository, args).pipe( + Effect.map((result) => result.text.split("\0").filter(Boolean)), + ) + const [tracked, untracked] = yield* Effect.all( + [ + list(["diff-files", "--name-only", "-z", "--", input.scope]), + list(["ls-files", "--others", "--exclude-standard", "-z", "--", input.scope]), + ], + { concurrency: 2 }, + ) + const candidates = Array.from(new Set([...tracked, ...untracked])) + if (!candidates.length) return { skipped: [] } + const ignored = input.ignores + ? new Set( + (yield* repositoryOperation("refresh", input.ignores, ["check-ignore", "--no-index", "--stdin", "-z"], { + stdin: candidates.join("\0") + "\0", + }).pipe(Effect.catch(() => Effect.succeed({ text: "", stderr: "" })))).text + .split("\0") + .filter(Boolean), + ) + : new Set() + const allowed = candidates.filter((item) => !ignored.has(item)) + const maximum = input.maximumUntrackedFileBytes + const skipped = maximum + ? (yield* Effect.forEach( + untracked.filter((item) => allowed.includes(item)), + (item) => + fs.stat(path.join(input.repository.worktree, item)).pipe( + Effect.map((info) => + info.type === "File" && Number(info.size) > maximum ? RelativePath.make(item) : undefined, + ), + Effect.catch(() => Effect.succeed(undefined)), + ), + { concurrency: 8 }, + )).filter((item): item is RelativePath => item !== undefined) + : [] + const stage = allowed.filter((item) => !skipped.includes(RelativePath.make(item))) + const remove = [...ignored, ...skipped] + if (remove.length) + yield* repositoryOperation( + "refresh", + input.repository, + ["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"], + { stdin: remove.join("\0") + "\0" }, + ) + if (stage.length) + yield* repositoryOperation( + "refresh", + input.repository, + ["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"], + { stdin: stage.join("\0") + "\0" }, + ) + return { skipped } + }) + + const ignored = Effect.fn("Git.index.ignored")(function* (input: { + repository: Repository + paths: readonly RelativePath[] + }) { + if (!input.paths.length) return new Set() + const result = yield* proc + .run( + ChildProcess.make("git", repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]), { + cwd: input.repository.worktree, + extendEnv: true, + }), + { stdin: input.paths.join("\0") + "\0" }, + ) + .pipe( + Effect.mapError( + (cause) => + new OperationError({ + operation: "list_files", + directory: input.repository.worktree, + message: cause.message, + cause, + }), + ), + ) + if (result.exitCode !== 0 && result.exitCode !== 1) + return yield* new OperationError({ + operation: "list_files", + directory: input.repository.worktree, + message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths", }) - } - const repo = AbsolutePath.make(resolvePath(directory, root.text)) - const scope = path.relative(repo, directory).replaceAll("\\", "/") || "." + return new Set( + result.stdout + .toString("utf8") + .split("\0") + .filter(Boolean) + .map((file) => RelativePath.make(file)), + ) + }) + + const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) { + return TreeID.make((yield* repositoryOperation("write_tree", repository, ["write-tree"])).text.trim()) + }) + + const captureTree = Effect.fn("Git.tree.capture")( + (input: { + repository: Repository + scopes: readonly RelativePath[] + ignores?: Repository + maximumUntrackedFileBytes?: number + }) => + locked( + input.repository, + Effect.gen(function* () { + yield* Effect.forEach(input.scopes, (scope) => refresh({ ...input, scope }), { discard: true }) + return yield* writeTree(input.repository) + }), + ), + ) + + const treeFiles = Effect.fn("Git.tree.files")(function* (input: { + repository: Repository + from: TreeID + to: TreeID + }) { + return (yield* repositoryOperation("list_files", input.repository, [ + "diff", + "--name-only", + "-z", + input.from, + input.to, + ])).text + .split("\0") + .filter(Boolean) + .map((file) => RelativePath.make(file)) + }) + + const treeDiff = Effect.fn("Git.tree.diff")(function* (input: { + repository: Repository + from: TreeID + to: TreeID + context?: number + paths?: readonly RelativePath[] + }) { + const paths = input.paths ?? (yield* treeFiles(input)) + return yield* Effect.forEach(paths, (file) => + Effect.gen(function* () { + const statusText = (yield* repositoryOperation("diff", input.repository, [ + "diff", + "--name-status", + "--no-renames", + input.from, + input.to, + "--", + file, + ])).text.trim() + const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified" + const stats = (yield* repositoryOperation("diff", input.repository, [ + "diff", + "--numstat", + "--no-renames", + input.from, + input.to, + "--", + file, + ])).text.split("\t") + const binary = stats[0] === "-" || stats[1] === "-" + const patch = binary + ? "" + : (yield* repositoryOperation("diff", input.repository, [ + "diff", + `--unified=${input.context ?? 3}`, + "--no-renames", + input.from, + input.to, + "--", + file, + ])).text + return { + path: file, + status, + additions: binary ? 0 : Number(stats[0] ?? 0), + deletions: binary ? 0 : Number(stats[1] ?? 0), + patch, + } satisfies File.Diff + }), + ) + }) + + const entry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) { + const text = (yield* repositoryOperation("restore", repository, [ + "ls-tree", + "-z", + tree, + "--", + file, + ])).text.replace(/\0$/, "") + if (!text) return + const match = text.match(/^(\d+)\s+\w+\s+([0-9a-f]+)\t/) + if (!match) + return yield* new OperationError({ + operation: "restore", + directory: repository.worktree, + message: `Invalid tree entry for ${file}`, + }) + return { mode: match[1], object: match[2] } + }) + + const preview = Effect.fn("Git.tree.preview")( + (input: { + repository: Repository + current: TreeID + files: ReadonlyMap + context?: number + }) => + locked( + input.repository, + Effect.gen(function* () { + const index = path.join(input.repository.gitDirectory, `preview-${randomUUID()}.index`) + const env = { GIT_INDEX_FILE: index } + return yield* Effect.gen(function* () { + yield* repositoryOperation("diff", input.repository, ["read-tree", input.current], { env }) + yield* Effect.forEach( + input.files, + ([file, tree]) => + Effect.gen(function* () { + const source = yield* entry(input.repository, tree, file) + if (!source) { + yield* repositoryOperation( + "diff", + input.repository, + ["update-index", "--force-remove", "--", file], + { env }, + ) + return + } + yield* repositoryOperation( + "diff", + input.repository, + ["update-index", "--add", "--cacheinfo", source.mode, source.object, file], + { env }, + ) + }), + { discard: true }, + ) + const target = TreeID.make( + (yield* repositoryOperation("diff", input.repository, ["write-tree"], { env })).text.trim(), + ) + return yield* treeDiff({ + repository: input.repository, + from: input.current, + to: target, + context: input.context, + paths: Array.from(input.files.keys()), + }) + }).pipe(Effect.ensuring(fs.remove(index).pipe(Effect.catch(() => Effect.void)))) + }), + ), + ) + + const restore = Effect.fn("Git.tree.restore")( + (input: { repository: Repository; files: ReadonlyMap }) => + locked( + input.repository, + Effect.forEach( + input.files, + ([file, tree]) => + Effect.gen(function* () { + if (yield* entry(input.repository, tree, file)) { + yield* repositoryOperation("restore", input.repository, ["checkout", tree, "--", file]) + return + } + yield* fs.remove(path.join(input.repository.worktree, file), { recursive: true, force: true }).pipe( + Effect.mapError( + (cause) => + new OperationError({ + operation: "restore", + directory: input.repository.worktree, + message: `Failed to remove ${file}`, + cause, + }), + ), + ) + }), + { discard: true }, + ), + ), + ) + + const checkoutTree = Effect.fn("Git.tree.checkout")((input: { repository: Repository; tree: TreeID }) => + locked( + input.repository, + Effect.gen(function* () { + yield* repositoryOperation("restore", input.repository, ["read-tree", input.tree]) + yield* repositoryOperation("restore", input.repository, ["checkout-index", "--all", "--force"]) + }), + ), + ) + + const capture = Effect.fn("Git.change.capture")(function* (input: { repository: Repository; path: AbsolutePath }) { + const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "." const tracked = yield* execute( - repo, + input.repository.worktree, proc, )(["diff", "--binary", "HEAD", "--", scope]).pipe( - Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })), + Effect.mapError( + (cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }), + ), ) if (tracked.exitCode !== 0) { return yield* new PatchError({ operation: "capture", - directory, + directory: input.path, message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes", }) } const untracked = yield* execute( - repo, + input.repository.worktree, proc, )(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe( - Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })), + Effect.mapError( + (cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }), + ), ) if (untracked.exitCode !== 0) { return yield* new PatchError({ operation: "capture", - directory, + directory: input.path, message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes", }) } const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) => execute( - repo, + input.repository.worktree, proc, )(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe( Effect.mapError( - (cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause }), + (cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }), ), Effect.flatMap((result) => // git diff --no-index returns 1 when differences were found. @@ -235,7 +775,7 @@ export const layer = Layer.effect( : Effect.fail( new PatchError({ operation: "capture", - directory, + directory: input.path, message: result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`, }), @@ -243,95 +783,81 @@ export const layer = Layer.effect( ), ), ) - return [tracked.text, ...created].filter(Boolean).join("\n") + return ChangeSet.make([tracked.text, ...created].filter(Boolean).join("\n")) }) - const applyPatch = Effect.fn("Git.applyPatch")(function* (input: { directory: AbsolutePath; patch: string }) { + const apply = Effect.fn("Git.change.apply")(function* (input: { + repository: Repository + path: AbsolutePath + changes: ChangeSet + }) { const result = yield* proc .run( ChildProcess.make("git", ["apply", "-"], { - cwd: input.directory, + cwd: input.path, extendEnv: true, - stdin: Stream.make(new TextEncoder().encode(input.patch)), + stdin: Stream.make(new TextEncoder().encode(input.changes)), }), ) .pipe( Effect.mapError( - (cause) => - new PatchError({ operation: "apply", directory: input.directory, message: cause.message, cause }), + (cause) => new PatchError({ operation: "apply", directory: input.path, message: cause.message, cause }), ), ) if (result.exitCode === 0) return return yield* new PatchError({ operation: "apply", - directory: input.directory, + directory: input.path, message: result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes", }) }) - const resetChanges = Effect.fn("Git.resetChanges")(function* (directory: AbsolutePath) { - const reset = yield* execute( - directory, + const discard = Effect.fn("Git.change.discard")(function* (input: { + repository: Repository + path: AbsolutePath + index: "preserve" | "reset" + untracked: "preserve" | "remove" + }) { + const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "." + const restore = yield* execute( + input.repository.worktree, proc, - )(["reset", "--hard", "HEAD"]).pipe( - Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })), + )(input.index === "reset" ? ["checkout", "HEAD", "--", scope] : ["checkout", "--", scope]).pipe( + Effect.mapError( + (cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }), + ), ) - if (reset.exitCode !== 0) { + if (restore.exitCode !== 0) { return yield* new PatchError({ operation: "reset", - directory, - message: reset.stderr.trim() || reset.text.trim() || "Failed to reset tracked changes", + directory: input.path, + message: restore.stderr.trim() || restore.text.trim() || "Failed to restore tracked changes", }) } + if (input.untracked === "preserve") return const clean = yield* execute( - directory, + input.repository.worktree, proc, - )(["clean", "-fd"]).pipe( - Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })), + )(["clean", "-fd", "--", scope]).pipe( + Effect.mapError( + (cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }), + ), ) if (clean.exitCode === 0) return return yield* new PatchError({ operation: "reset", - directory, + directory: input.path, message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes", }) }) - const softResetChanges = Effect.fn("Git.softResetChanges")(function* (directory: AbsolutePath) { - const checkout = yield* execute( - directory, - proc, - )(["checkout", "--", "."]).pipe( - Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })), - ) - if (checkout.exitCode !== 0) { - return yield* new PatchError({ - operation: "reset", - directory, - message: checkout.stderr.trim() || checkout.text.trim() || "Failed to restore tracked changes", - }) - } - const clean = yield* execute( - directory, - proc, - )(["clean", "-fd", "--", "."]).pipe( - Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })), - ) - if (clean.exitCode === 0) return - return yield* new PatchError({ - operation: "reset", - directory, - message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes", - }) - }) - - const worktree = Effect.fnUntraced(function* ( + const worktreeRun = Effect.fnUntraced(function* ( operation: "create" | "remove" | "list", - repo: Repo, + repository: Repository, args: string[], worktreeDirectory?: AbsolutePath, - cwd = repo.directory, + cwd = repository.worktree, ) { const result = yield* proc .run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" })) @@ -350,60 +876,76 @@ export const layer = Layer.effect( }) }) - const worktreeCreate = Effect.fn("Git.worktreeCreate")(function* (input: { repo: Repo; directory: AbsolutePath }) { - yield* worktree("create", input.repo, ["worktree", "add", "--detach", input.directory, "HEAD"], input.directory) + const worktreeCreate = Effect.fn("Git.worktree.create")(function* (input: { + repository: Repository + directory: AbsolutePath + }) { + yield* worktreeRun( + "create", + input.repository, + ["worktree", "add", "--detach", input.directory, "HEAD"], + input.directory, + ) + const repository = yield* discover(input.directory) + if (repository) return repository + return yield* new WorktreeError({ + operation: "create", + directory: input.directory, + message: "Created worktree could not be opened", + }) }) - const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: { - repo: Repo + const worktreeRemove = Effect.fn("Git.worktree.remove")(function* (input: { + repository: Repository directory: AbsolutePath force: boolean }) { - yield* worktree( + yield* worktreeRun( "remove", - input.repo, + input.repository, ["worktree", "remove", ...(input.force ? ["--force"] : []), input.directory], input.directory, - input.repo.store, + input.repository.commonDirectory, ) }) - const worktreeList = Effect.fn("Git.worktreeList")(function* (repo: Repo) { - return (yield* worktree("list", repo, ["worktree", "list", "--porcelain"])) + const worktreeList = Effect.fn("Git.worktree.list")(function* (repository: Repository) { + return (yield* worktreeRun("list", repository, ["worktree", "list", "--porcelain"])) .split("\n") .filter((line) => line.startsWith("worktree ")) - .map((line) => AbsolutePath.make(resolvePath(repo.directory, line.slice("worktree ".length).trim()))) + .map( + (line, index) => + new Worktree({ + directory: AbsolutePath.make(resolvePath(repository.worktree, line.slice("worktree ".length).trim())), + kind: index === 0 ? "main" : "linked", + }), + ) }) return Service.of({ - find, - remote, - roots, - origin, - head, - dir, - branch, - remoteHead, - clone, - fetch, - fetchBranch, - checkout, - reset, - patch, - applyPatch, - resetChanges, - softResetChanges, - worktreeCreate, - worktreeRemove, - worktreeList, + repo: { discover, clone, create }, + remote: { get: remote }, + history: { head, branch, defaultRemoteBranch: remoteHead, rootCommits: roots }, + sync: { fetchRemotes: fetch, fetchBranch, checkoutRemoteBranch: checkout, resetHard: reset }, + change: { capture, apply, discard }, + worktree: { create: worktreeCreate, remove: worktreeRemove, list: worktreeList }, + index: { refresh, ignored }, + tree: { + capture: captureTree, + write: writeTree, + files: treeFiles, + diff: treeDiff, + preview, + restore, + checkout: checkoutTree, + }, }) }), ) -export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer)) -export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node]) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] }) -export interface Result { +interface Result { readonly exitCode: number readonly text: string readonly stderr: string diff --git a/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts b/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts index 83e46015dd..1e4f86d933 100644 --- a/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts +++ b/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts @@ -86,7 +86,7 @@ export async function convertToOpenAIResponsesInput({ : { image_url: `data:${mediaType};base64,${convertToBase64(part.data)}`, }), - detail: part.providerOptions?.openai?.imageDetail, + detail: part.providerOptions?.copilot?.imageDetail, } } else if (part.mediaType === "application/pdf") { if (part.data instanceof URL) { @@ -127,7 +127,7 @@ export async function convertToOpenAIResponsesInput({ input.push({ role: "assistant", content: [{ type: "output_text", text: part.text }], - id: (part.providerOptions?.openai?.itemId as string) ?? undefined, + id: (part.providerOptions?.copilot?.itemId as string) ?? undefined, }) break } @@ -143,7 +143,7 @@ export async function convertToOpenAIResponsesInput({ input.push({ type: "local_shell_call", call_id: part.toolCallId, - id: (part.providerOptions?.openai?.itemId as string) ?? undefined, + id: (part.providerOptions?.copilot?.itemId as string) ?? undefined, action: { type: "exec", command: parsedInput.action.command, @@ -162,7 +162,7 @@ export async function convertToOpenAIResponsesInput({ call_id: part.toolCallId, name: part.toolName, arguments: JSON.stringify(part.input), - id: (part.providerOptions?.openai?.itemId as string) ?? undefined, + id: (part.providerOptions?.copilot?.itemId as string) ?? undefined, }) break } @@ -275,7 +275,7 @@ export async function convertToOpenAIResponsesInput({ const output = part.output if (output.type === "execution-denied") { - const approvalId = (output.providerOptions?.openai as { approvalId?: string } | undefined)?.approvalId + const approvalId = (output.providerOptions?.copilot as { approvalId?: string } | undefined)?.approvalId if (approvalId) { continue diff --git a/packages/core/src/github-copilot/responses/openai-responses-language-model.ts b/packages/core/src/github-copilot/responses/openai-responses-language-model.ts index 250d1f6f34..8df1dcedad 100644 --- a/packages/core/src/github-copilot/responses/openai-responses-language-model.ts +++ b/packages/core/src/github-copilot/responses/openai-responses-language-model.ts @@ -525,7 +525,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { type: "reasoning" as const, text: summary.text, providerMetadata: { - openai: { + copilot: { itemId: part.id, reasoningEncryptedContent: part.encrypted_content ?? null, }, @@ -563,7 +563,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { toolName: "local_shell", input: JSON.stringify({ action: part.action } satisfies z.infer), providerMetadata: { - openai: { + copilot: { itemId: part.id, }, }, @@ -574,7 +574,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { case "message": { for (const contentPart of part.content) { - if (options.providerOptions?.openai?.logprobs && contentPart.logprobs) { + if (options.providerOptions?.copilot?.logprobs && contentPart.logprobs) { logprobs.push(contentPart.logprobs) } @@ -582,7 +582,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { type: "text", text: contentPart.text, providerMetadata: { - openai: { + copilot: { itemId: part.id, }, }, @@ -622,7 +622,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { toolName: part.name, input: part.arguments, providerMetadata: { - openai: { + copilot: { itemId: part.id, }, }, @@ -724,15 +724,15 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { } const providerMetadata: SharedV3ProviderMetadata = { - openai: { responseId: response.id }, + copilot: { responseId: response.id }, } if (logprobs.length > 0) { - providerMetadata.openai.logprobs = logprobs + providerMetadata.copilot.logprobs = logprobs } if (typeof response.service_tier === "string") { - providerMetadata.openai.serviceTier = response.service_tier + providerMetadata.copilot.serviceTier = response.service_tier } return { @@ -954,7 +954,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { type: "text-start", id: value.item.id, providerMetadata: { - openai: { + copilot: { itemId: value.item.id, }, }, @@ -971,7 +971,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { type: "reasoning-start", id: `${value.item.id}:0`, providerMetadata: { - openai: { + copilot: { itemId: value.item.id, reasoningEncryptedContent: value.item.encrypted_content ?? null, }, @@ -994,7 +994,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { toolName: value.item.name, input: value.item.arguments, providerMetadata: { - openai: { + copilot: { itemId: value.item.id, }, }, @@ -1103,7 +1103,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { }, } satisfies z.infer), providerMetadata: { - openai: { itemId: value.item.id }, + copilot: { itemId: value.item.id }, }, }) } else if (value.item.type === "message") { @@ -1122,7 +1122,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { type: "reasoning-end", id: `${activeReasoningPart.canonicalId}:${summaryIndex}`, providerMetadata: { - openai: { + copilot: { itemId: activeReasoningPart.canonicalId, reasoningEncryptedContent: value.item.encrypted_content ?? null, }, @@ -1209,7 +1209,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { type: "text-start", id: currentTextId, providerMetadata: { - openai: { itemId: value.item_id }, + copilot: { itemId: value.item_id }, }, }) } @@ -1220,7 +1220,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { delta: value.delta, }) - if (options.providerOptions?.openai?.logprobs && value.logprobs) { + if (options.providerOptions?.copilot?.logprobs && value.logprobs) { logprobs.push(value.logprobs) } } else if (isResponseReasoningSummaryPartAddedChunk(value)) { @@ -1235,7 +1235,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { type: "reasoning-start", id: `${activeItem.canonicalId}:${value.summary_index}`, providerMetadata: { - openai: { + copilot: { itemId: activeItem.canonicalId, reasoningEncryptedContent: activeItem.encryptedContent ?? null, }, @@ -1252,7 +1252,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { id: `${activeItem.canonicalId}:${value.summary_index}`, delta: value.delta, providerMetadata: { - openai: { + copilot: { itemId: activeItem.canonicalId, }, }, @@ -1306,17 +1306,17 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { } const providerMetadata: SharedV3ProviderMetadata = { - openai: { + copilot: { responseId, }, } if (logprobs.length > 0) { - providerMetadata.openai.logprobs = logprobs + providerMetadata.copilot.logprobs = logprobs } if (serviceTier !== undefined) { - providerMetadata.openai.serviceTier = serviceTier + providerMetadata.copilot.serviceTier = serviceTier } controller.enqueue({ diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index ef8da6ac06..21996504c5 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -5,7 +5,7 @@ import os from "os" import { Context, Effect, Layer } from "effect" import { Flock } from "./util/flock" import { Flag } from "./flag/flag" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/app-node" const app = "opencode" const data = path.join(xdgData!, app) @@ -71,13 +71,12 @@ export function make(input: Partial = {}): Interface { } } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.sync(() => Service.of(make())), ) -export const defaultLayer = layer -export const node = LayerNode.make(layer, []) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] }) export const layerWith = (input: Partial) => Layer.effect( diff --git a/packages/core/src/id/id.ts b/packages/core/src/id/id.ts index 847a5c0329..be1efc446a 100644 --- a/packages/core/src/id/id.ts +++ b/packages/core/src/id/id.ts @@ -1,4 +1,4 @@ -import { randomBytes } from "crypto" +import { create as createIdentifier } from "@opencode-ai/schema/identifier" const prefixes = { job: "job", @@ -13,12 +13,6 @@ const prefixes = { workspace: "wrk", } as const -const LENGTH = 26 - -// State for monotonic ID generation -let lastTimestamp = 0 -let counter = 0 - export function ascending(prefix: keyof typeof prefixes, given?: string) { return generateID(prefix, "ascending", given) } @@ -38,35 +32,8 @@ function generateID(prefix: keyof typeof prefixes, direction: "descending" | "as return given } -function randomBase62(length: number): string { - const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - let result = "" - const bytes = randomBytes(length) - for (let i = 0; i < length; i++) { - result += chars[bytes[i] % 62] - } - return result -} - export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string { - const currentTimestamp = timestamp ?? Date.now() - - if (currentTimestamp !== lastTimestamp) { - lastTimestamp = currentTimestamp - counter = 0 - } - counter++ - - let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) - - now = direction === "descending" ? ~now : now - - const timeBytes = Buffer.alloc(6) - for (let i = 0; i < 6; i++) { - timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) - } - - return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12) + return prefix + "_" + createIdentifier(direction === "descending", timestamp) } /** Extract timestamp from an ascending ID. Does not work with descending IDs. */ diff --git a/packages/core/src/image.ts b/packages/core/src/image.ts index 9c69d6aeb6..304277669e 100644 --- a/packages/core/src/image.ts +++ b/packages/core/src/image.ts @@ -1,5 +1,6 @@ export * as Image from "./image" +import { makeLocationNode } from "./effect/app-node" import { Context, Effect, Layer, Schema } from "effect" import { Config } from "./config" import { FileSystem } from "./filesystem" @@ -43,7 +44,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Image") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -76,3 +77,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer)) + +export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] }) diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts index 0a8866341f..31f36c9e60 100644 --- a/packages/core/src/instruction-context.ts +++ b/packages/core/src/instruction-context.ts @@ -9,6 +9,7 @@ import { Location } from "./location" import { AbsolutePath } from "./schema" import { SystemContext } from "./system-context/index" import { SystemContextRegistry } from "./system-context/registry" +import { makeLocationNode } from "./effect/app-node" class File extends Schema.Class("InstructionContext.File")({ path: AbsolutePath, @@ -18,7 +19,7 @@ class File extends Schema.Class("InstructionContext.File")({ const Files = Schema.Array(File) const key = SystemContext.Key.make("core/instructions") -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const fs = yield* FSUtil.Service const global = yield* Global.Service @@ -87,6 +88,12 @@ export const layer = Layer.effectDiscard( }), ) +export const node = makeLocationNode({ + name: "instruction-context", + layer, + deps: [FSUtil.node, Global.node, Location.node, SystemContextRegistry.node], +}) + function render(files: ReadonlyArray) { return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n") } diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index ca626111a0..f61bac393a 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -1,92 +1,64 @@ export * as Integration from "./integration" -import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Schedule, Schema, Scope, SynchronizedRef } from "effect" -import { castDraft, enableMapSet, type Draft } from "immer" +import { makeLocationNode } from "./effect/app-node" +import { + Cause, + Clock, + Context, + Duration, + Effect, + Exit, + Layer, + Schedule, + Schema, + Scope, + SynchronizedRef, + Types, +} from "effect" +import { Integration } from "@opencode-ai/schema/integration" import { Credential } from "./credential" -import { IntegrationSchema } from "./integration/schema" -import { withStatics } from "./schema" import { State } from "./state" -import { Identifier } from "./util/identifier" import { EventV2 } from "./event" import { IntegrationConnection } from "./integration/connection" -export const ID = IntegrationSchema.ID -export type ID = IntegrationSchema.ID +export const ID = Integration.ID +export type ID = Integration.ID -export const MethodID = IntegrationSchema.MethodID -export type MethodID = IntegrationSchema.MethodID +export const MethodID = Integration.MethodID +export type MethodID = Integration.MethodID -export const AttemptID = Schema.String.pipe( - Schema.brand("Integration.AttemptID"), - withStatics((schema) => ({ create: () => schema.make("con_" + Identifier.ascending()) })), -) +export const AttemptID = Integration.AttemptID export type AttemptID = typeof AttemptID.Type -export const When = Schema.Struct({ - key: Schema.String, - op: Schema.Literals(["eq", "neq"]), - value: Schema.String, -}).annotate({ identifier: "Integration.When" }) -export type When = typeof When.Type +export const When = Integration.When +export type When = Integration.When -export const TextPrompt = Schema.Struct({ - type: Schema.Literal("text"), - key: Schema.String, - message: Schema.String, - placeholder: Schema.optional(Schema.String), - when: Schema.optional(When), -}).annotate({ identifier: "Integration.TextPrompt" }) -export type TextPrompt = typeof TextPrompt.Type +export const TextPrompt = Integration.TextPrompt +export type TextPrompt = Integration.TextPrompt -export const SelectPrompt = Schema.Struct({ - type: Schema.Literal("select"), - key: Schema.String, - message: Schema.String, - options: Schema.Array( - Schema.Struct({ - label: Schema.String, - value: Schema.String, - hint: Schema.optional(Schema.String), - }), - ), - when: Schema.optional(When), -}).annotate({ identifier: "Integration.SelectPrompt" }) -export type SelectPrompt = typeof SelectPrompt.Type +export const SelectPrompt = Integration.SelectPrompt +export type SelectPrompt = Integration.SelectPrompt -export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type")) -export type Prompt = typeof Prompt.Type +export const Prompt = Integration.Prompt +export type Prompt = Integration.Prompt -export const OAuthMethod = Schema.Struct({ - id: MethodID, - type: Schema.Literal("oauth"), - label: Schema.String, - prompts: Schema.optional(Schema.Array(Prompt)), -}).annotate({ identifier: "Integration.OAuthMethod" }) -export type OAuthMethod = typeof OAuthMethod.Type +export const OAuthMethod = Integration.OAuthMethod +export type OAuthMethod = Integration.OAuthMethod -export const KeyMethod = Schema.Struct({ - type: Schema.Literal("key"), - label: Schema.optional(Schema.String), -}).annotate({ identifier: "Integration.KeyMethod" }) -export type KeyMethod = typeof KeyMethod.Type +export const KeyMethod = Integration.KeyMethod +export type KeyMethod = Integration.KeyMethod -export const EnvMethod = Schema.Struct({ - type: Schema.Literal("env"), - names: Schema.Array(Schema.String), -}).annotate({ identifier: "Integration.EnvMethod" }) -export type EnvMethod = typeof EnvMethod.Type +export const EnvMethod = Integration.EnvMethod +export type EnvMethod = Integration.EnvMethod -export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type")) -export type Method = typeof Method.Type +export const Method = Integration.Method +export type Method = Integration.Method -export class Info extends Schema.Class("Integration.Info")({ - id: ID, - name: Schema.String, - methods: Schema.Array(Method), - connections: Schema.Array(IntegrationConnection.Info), -}) {} +export const Info = Integration.Info +export type Info = Integration.Info -export type Inputs = Readonly<{ [key: string]: string }> +export const Inputs = Integration.Inputs +export type Inputs = Integration.Inputs export type OAuthAuthorization = { readonly url: string @@ -94,11 +66,11 @@ export type OAuthAuthorization = { } & ( | { readonly mode: "auto" - readonly callback: Effect.Effect + readonly callback: Effect.Effect } | { readonly mode: "code" - readonly callback: (code: string) => Effect.Effect + readonly callback: (code: string) => Effect.Effect } ) @@ -107,6 +79,7 @@ export interface OAuthImplementation { readonly method: OAuthMethod readonly authorize: (inputs: Inputs) => Effect.Effect readonly refresh?: (credential: Credential.OAuth) => Effect.Effect + readonly label?: (credential: Credential.OAuth) => string | undefined } export interface KeyImplementation { @@ -121,32 +94,10 @@ export interface EnvImplementation { export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation -function isOAuthImplementation(implementation: Implementation): implementation is OAuthImplementation { - return implementation.method.type === "oauth" -} +export const Attempt = Integration.Attempt +export type Attempt = Integration.Attempt -export class Attempt extends Schema.Class("Integration.Attempt")({ - attemptID: AttemptID, - url: Schema.String, - instructions: Schema.String, - mode: Schema.Literals(["auto", "code"]), - time: Schema.Struct({ - created: Schema.Number, - expires: Schema.Number, - }), -}) {} - -const Time = Schema.Struct({ - created: Schema.Number, - expires: Schema.Number, -}) - -export const AttemptStatus = Schema.Union([ - Schema.Struct({ status: Schema.Literal("pending"), time: Time }), - Schema.Struct({ status: Schema.Literal("complete"), time: Time }), - Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: Time }), - Schema.Struct({ status: Schema.Literal("expired"), time: Time }), -]).pipe(Schema.toTaggedUnion("status")) +export const AttemptStatus = Integration.AttemptStatus export type AttemptStatus = typeof AttemptStatus.Type export class CodeRequiredError extends Schema.TaggedErrorClass()("Integration.CodeRequired", { @@ -154,37 +105,30 @@ export class CodeRequiredError extends Schema.TaggedErrorClass()("Integration.Authorization", { - cause: Schema.Defect, + cause: Schema.Defect(), }) {} export type Error = CodeRequiredError | AuthorizationError -export const Event = { - Updated: EventV2.define({ - type: "integration.updated", - schema: {}, - }), -} +export const Event = Integration.Event -export type Ref = { - id: ID - name: string -} +export const Ref = Integration.Ref +export type Ref = Integration.Ref type Entry = { - ref: Ref - methods: Method[] - implementations: Map + ref: Types.DeepMutable + methods: Types.DeepMutable[] + implementations: Map> } type Data = { integrations: Map } -export type Editor = { +export type Draft = { list: () => readonly Ref[] get: (id: ID) => Ref | undefined - update: (id: ID, update: (integration: Draft) => void) => void + update: (id: ID, update: (integration: Types.DeepMutable) => void) => void remove: (id: ID) => void method: { list: (integrationID: ID) => readonly Method[] @@ -193,20 +137,19 @@ export type Editor = { } } -export interface Interface { +export interface Interface extends State.Transformable { /** Registers a scoped transform over the integration registry. */ - readonly transform: State.Interface["transform"] - /** Registers and immediately applies a scoped integration registry update. */ - readonly update: State.Interface["update"] /** Returns one integration with its methods and current connections. */ readonly get: (id: ID) => Effect.Effect /** Returns all integrations with their methods and current connections. */ readonly list: () => Effect.Effect readonly connection: { - /** Returns active connections for every registered or credential-backed integration. */ - readonly list: () => Effect.Effect> /** Returns the active connection for one integration. */ - readonly forIntegration: (id: ID) => Effect.Effect + readonly active: (id: ID) => Effect.Effect + /** Resolves a connection into usable credential material. */ + readonly resolve: ( + connection: IntegrationConnection.Info, + ) => Effect.Effect /** Runs a key method and stores the resulting credential. */ readonly key: (input: { /** Integration receiving the credential. */ @@ -230,7 +173,7 @@ export interface Interface { /** Updates a stored credential exposed as a connection. */ readonly update: ( credentialID: Credential.ID, - updates: Partial>, + updates: Partial>, ) => Effect.Effect /** Removes a stored credential connection. */ readonly remove: (credentialID: Credential.ID) => Effect.Effect @@ -252,8 +195,6 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/Integration") {} -enableMapSet() - const attemptLifetime = Duration.toMillis(Duration.minutes(10)) const terminalRetention = Duration.toMillis(Duration.minutes(1)) const scrubInterval = Duration.seconds(30) @@ -284,15 +225,17 @@ export const locationLayer = Layer.effect( const events = yield* EventV2.Service const scope = yield* Scope.Scope const attempts = SynchronizedRef.makeUnsafe(new Map()) - const state = State.create({ + const state = State.create({ initial: () => ({ integrations: new Map() }), - editor: (draft) => ({ + draft: (draft) => ({ list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[], get: (id) => draft.integrations.get(id)?.ref as Ref | undefined, update: (id, update) => { - const current = - draft.integrations.get(id) ?? - castDraft({ ref: { id, name: id } as Ref, methods: [], implementations: new Map() }) + const current = draft.integrations.get(id) ?? { + ref: { id, name: id }, + methods: [], + implementations: new Map(), + } if (!draft.integrations.has(id)) draft.integrations.set(id, current) update(current.ref) current.ref.id = id @@ -301,16 +244,14 @@ export const locationLayer = Layer.effect( method: { list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [], update: (implementation) => { - const current = - draft.integrations.get(implementation.integrationID) ?? - castDraft({ - ref: { - id: implementation.integrationID, - name: implementation.integrationID, - } as Ref, - methods: [], - implementations: new Map(), - }) + const current = draft.integrations.get(implementation.integrationID) ?? { + ref: { + id: implementation.integrationID, + name: implementation.integrationID, + }, + methods: [], + implementations: new Map>(), + } if (!draft.integrations.has(implementation.integrationID)) { draft.integrations.set(implementation.integrationID, current) } @@ -319,10 +260,13 @@ export const locationLayer = Layer.effect( if (method.type !== "oauth" || implementation.method.type !== "oauth") return true return method.id === implementation.method.id }) - if (index === -1) current.methods.push(castDraft(implementation.method)) - else current.methods[index] = castDraft(implementation.method) - if (isOAuthImplementation(implementation)) { - current.implementations.set(implementation.method.id, castDraft(implementation)) + if (index === -1) current.methods.push(implementation.method as Types.DeepMutable) + else current.methods[index] = implementation.method as Types.DeepMutable + if (implementation.method.type === "oauth") { + current.implementations.set( + implementation.method.id, + implementation as Types.DeepMutable, + ) } }, remove: (integrationID, method) => { @@ -341,39 +285,27 @@ export const locationLayer = Layer.effect( finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), }) - const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => { - const connected = saved.map((credential) => ({ - type: "credential" as const, - id: credential.id, - label: credential.label, - })) - const detected = entry.methods + const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => { + const credentials = saved + .map((credential) => ({ + type: "credential" as const, + id: credential.id, + label: credential.label, + })) + .toReversed() + const env = (entry?.methods ?? []) .filter((method) => method.type === "env") .flatMap((method) => method.names.filter((name) => process.env[name])) .map((name) => ({ type: "env" as const, name })) - return [...connected, ...detected] + return [...credentials, ...env] } - const activeConnection = ( - entry: Entry | undefined, - saved: readonly Credential.Stored[], - ): IntegrationConnection.Info | undefined => { - const credential = saved.at(-1) - if (credential) return { type: "credential", id: credential.id, label: credential.label } - if (!entry) return - const name = entry.methods - .filter((method) => method.type === "env") - .flatMap((method) => method.names) - .find((name) => process.env[name]) - if (name) return { type: "env", name } - } - - const project = (entry: Entry, saved: readonly Credential.Stored[]) => + const project = (entry: Entry, connections: IntegrationConnection.Info[]) => new Info({ id: entry.ref.id, name: entry.ref.name, methods: entry.methods, - connections: connections(entry, saved), + connections, }) const authorize = (effect: Effect.Effect) => @@ -387,7 +319,7 @@ export const locationLayer = Layer.effect( return error instanceof Error ? error.message : String(error) } - const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit) { + const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit) { const now = yield* Clock.currentTimeMillis const result = yield* SynchronizedRef.modify(attempts, (current) => { const attempt = current.get(attemptID) @@ -399,14 +331,13 @@ export const locationLayer = Layer.effect( }) if (!result) return if (Exit.isSuccess(exit)) { + const implementation = state.get().integrations.get(result.integrationID)?.implementations.get(result.methodID) yield* credentials.create({ integrationID: result.integrationID, - label: result.label, - value: - exit.value.type === "oauth" - ? new Credential.OAuth({ ...exit.value, methodID: result.methodID }) - : exit.value, + label: result.label ?? implementation?.label?.(exit.value), + value: exit.value, }) + yield* events.publish(Event.ConnectionUpdated, { integrationID: result.integrationID }) yield* events.publish(Event.Updated, {}) } yield* close(result.scope) @@ -434,32 +365,41 @@ export const locationLayer = Layer.effect( return Service.of({ transform: state.transform, - update: state.update, + reload: state.reload, get: Effect.fn("Integration.get")(function* (id) { const entry = state.get().integrations.get(id) if (!entry) return undefined - return project(entry, yield* credentials.list(id)) + return project(entry, resolveConnections(entry, yield* credentials.list(id))) }), list: Effect.fn("Integration.list")(function* () { - return (yield* Effect.forEach(state.get().integrations.values(), (entry) => - Effect.gen(function* () { - return project(entry, yield* credentials.list(entry.ref.id)) - }), - )).toSorted((a, b) => a.name.localeCompare(b.name)) + const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID) + return Array.from(state.get().integrations.values(), (entry) => + project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])), + ).toSorted((a, b) => a.name.localeCompare(b.name)) }), connection: { - list: Effect.fn("Integration.connection.list")(function* () { - const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID) - return new Map( - new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => { - const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? []) - return connection ? [[id, connection] as const] : [] - }), - ) - }), - forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) { + active: Effect.fn("Integration.connection.active")(function* (id) { const entry = state.get().integrations.get(id) - return activeConnection(entry, yield* credentials.list(id)) + return resolveConnections(entry, yield* credentials.list(id))[0] + }), + resolve: Effect.fn("Integration.connection.resolve")(function* (connection) { + if (connection.type === "env") { + const key = process.env[connection.name] + return key ? Credential.Key.make({ type: "key", key }) : undefined + } + const credential = yield* credentials.get(connection.id) + if (!credential) return undefined + if (credential.value.type === "key") return credential.value + const implementation = state + .get() + .integrations.get(credential.integrationID) + ?.implementations.get(credential.value.methodID) + if (!implementation?.refresh) return credential.value + const now = yield* Clock.currentTimeMillis + if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value + const value = yield* authorize(implementation.refresh(credential.value)) + yield* credentials.update(credential.id, { value }) + return value }), key: Effect.fn("Integration.connection.key")(function* (input) { const method = state @@ -470,8 +410,9 @@ export const locationLayer = Layer.effect( yield* credentials.create({ integrationID: input.integrationID, label: input.label, - value: new Credential.Key({ type: "key", key: input.key }), + value: Credential.Key.make({ type: "key", key: input.key }), }) + yield* events.publish(Event.ConnectionUpdated, { integrationID: input.integrationID }) yield* events.publish(Event.Updated, {}) }), oauth: Effect.fn("Integration.connection.oauth")(function* (input) { @@ -515,11 +456,19 @@ export const locationLayer = Layer.effect( }) }), update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) { + const credential = yield* credentials.get(credentialID) yield* credentials.update(credentialID, updates) + if (credential) { + yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID }) + } yield* events.publish(Event.Updated, {}) }), remove: Effect.fn("Integration.connection.remove")(function* (credentialID) { + const credential = yield* credentials.get(credentialID) yield* credentials.remove(credentialID) + if (credential) { + yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID }) + } yield* events.publish(Event.Updated, {}) }), }, @@ -567,3 +516,5 @@ export const locationLayer = Layer.effect( }) }), ) + +export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Credential.node, EventV2.node] }) diff --git a/packages/core/src/integration/connection.ts b/packages/core/src/integration/connection.ts index 200cf26580..91ab7e9e40 100644 --- a/packages/core/src/integration/connection.ts +++ b/packages/core/src/integration/connection.ts @@ -1,22 +1,12 @@ export * as IntegrationConnection from "./connection" -import { Schema } from "effect" -import { Credential } from "../credential" +import { Connection } from "@opencode-ai/schema/connection" -export const CredentialInfo = Schema.Struct({ - type: Schema.Literal("credential"), - id: Credential.ID, - label: Schema.String, -}).annotate({ identifier: "Connection.CredentialInfo" }) -export type CredentialInfo = typeof CredentialInfo.Type +export const CredentialInfo = Connection.CredentialInfo +export type CredentialInfo = Connection.CredentialInfo -export const EnvInfo = Schema.Struct({ - type: Schema.Literal("env"), - name: Schema.String, -}).annotate({ identifier: "Connection.EnvInfo" }) -export type EnvInfo = typeof EnvInfo.Type +export const EnvInfo = Connection.EnvInfo +export type EnvInfo = Connection.EnvInfo -export const Info = Schema.Union([CredentialInfo, EnvInfo]) - .pipe(Schema.toTaggedUnion("type")) - .annotate({ identifier: "Connection.Info" }) -export type Info = typeof Info.Type +export const Info = Connection.Info +export type Info = Connection.Info diff --git a/packages/core/src/integration/schema.ts b/packages/core/src/integration/schema.ts deleted file mode 100644 index 472f4e0609..0000000000 --- a/packages/core/src/integration/schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * as IntegrationSchema from "./schema" - -import { Schema } from "effect" - -export const ID = Schema.String.pipe(Schema.brand("Integration.ID")) -export type ID = typeof ID.Type - -export const MethodID = Schema.String.pipe(Schema.brand("Integration.MethodID")) -export type MethodID = typeof MethodID.Type diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts deleted file mode 100644 index cdaefe2cff..0000000000 --- a/packages/core/src/location-layer.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Effect, Layer, LayerMap } from "effect" -import { Location } from "./location" -import { Policy } from "./policy" -import { Config } from "./config" -import { PluginV2 } from "./plugin" -import { Catalog } from "./catalog" -import { Integration } from "./integration" -import { CommandV2 } from "./command" -import { AgentV2 } from "./agent" -import { PluginBoot } from "./plugin/boot" -import { Project } from "./project" -import { ProjectCopy } from "./project/copy" -import { ProjectDirectories } from "./project/directories" -import { EventV2 } from "./event" -import { Credential } from "./credential" -import { Npm } from "./npm" -import { ModelsDev } from "./models-dev" -import { FSUtil } from "./fs-util" -import { Git } from "./git" -import { Global } from "./global" -import { Database } from "./database/database" -import { PermissionV2 } from "./permission" -import { PermissionSaved } from "./permission/saved" -import { FileSystem } from "./filesystem" -import { Ripgrep } from "./ripgrep" -import { Watcher } from "./filesystem/watcher" -import { LocationMutation } from "./location-mutation" -import { FileMutation } from "./file-mutation" -import { Reference } from "./reference" -import { ReferenceGuidance } from "./reference/guidance" -import { RepositoryCache } from "./repository-cache" -import { Pty } from "./pty" -import { SkillV2 } from "./skill" -import { SkillGuidance } from "./skill/guidance" -import { BuiltInTools } from "./tool/builtins" -import { Image } from "./image" -import { ToolRegistry } from "./tool/registry" -import { ApplicationTools } from "./tool/application-tools" -import { ToolOutputStore } from "./tool-output-store" -import { AppProcess } from "./process" -import { SessionStore } from "./session/store" -import { SessionTodo } from "./session/todo" -import { QuestionV2 } from "./question" -import { LLMClient } from "@opencode-ai/llm" -import { RequestExecutor } from "@opencode-ai/llm/route" -import * as SessionRunnerLLM from "./session/runner/llm" -import { SessionRunnerModel } from "./session/runner/model" -import { SystemContextBuiltIns } from "./system-context/builtins" -import { FetchHttpClient } from "effect/unstable/http" - -export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { - lookup: (ref: Location.Ref) => { - const boot = Layer.effectDiscard( - Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }), - ) - const location = Location.layer(ref) - const systemContext = SystemContextBuiltIns.locationLayer - const base = Layer.mergeAll( - location, - Policy.locationLayer, - Config.locationLayer, - Reference.locationLayer, - PluginV2.locationLayer, - Catalog.locationLayer, - Integration.locationLayer, - CommandV2.locationLayer, - AgentV2.locationLayer, - PluginBoot.locationLayer, - ProjectCopy.locationLayer, - FileSystem.locationLayer, - Watcher.locationLayer, - Pty.locationLayer, - SkillV2.locationLayer, - systemContext, - LocationMutation.locationLayer.pipe(Layer.orDie), - ).pipe(Layer.provideMerge(location)) - const resources = ToolOutputStore.layer.pipe(Layer.provide(base)) - const permissionsAndTools = ToolRegistry.layer.pipe( - Layer.provideMerge(PermissionV2.locationLayer), - Layer.provide(resources), - Layer.provide(base), - ) - const services = Layer.mergeAll(base, resources, permissionsAndTools) - const image = Image.layer.pipe(Layer.provide(services)) - const mutation = FileMutation.locationLayer.pipe(Layer.provide(services)) - const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services)) - const referenceGuidance = ReferenceGuidance.locationLayer.pipe(Layer.provide(services)) - const todos = SessionTodo.layer.pipe(Layer.provide(services)) - const questions = QuestionV2.locationLayer.pipe(Layer.provide(services)) - const builtInTools = BuiltInTools.locationLayer.pipe( - Layer.provide(services), - Layer.provide(mutation), - Layer.provide(resources), - Layer.provide(todos), - Layer.provide(questions), - Layer.provide(image), - ) - const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services)) - const runner = SessionRunnerLLM.defaultLayer.pipe( - Layer.provide(services), - Layer.provide(model), - Layer.provide(skillGuidance), - Layer.provide(referenceGuidance), - ) - - // Kick off a background project copy refresh to update locations now that we - // have a location - const projectCopyRefresh = Layer.effectDiscard(ProjectCopy.refreshAfterBoot).pipe(Layer.provide(services)) - - return Layer.mergeAll( - boot, - services, - image, - mutation, - resources, - todos, - questions, - model, - runner, - builtInTools, - referenceGuidance, - projectCopyRefresh, - ).pipe(Layer.fresh) - }, - idleTimeToLive: "60 minutes", - dependencies: [ - Project.defaultLayer, - EventV2.defaultLayer, - Credential.defaultLayer, - Npm.defaultLayer, - ModelsDev.defaultLayer, - FSUtil.defaultLayer, - Git.defaultLayer, - AppProcess.defaultLayer, - Global.defaultLayer, - Ripgrep.defaultLayer, - Database.defaultLayer, - ProjectDirectories.defaultLayer, - SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)), - PermissionSaved.defaultLayer, - RepositoryCache.defaultLayer, - LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)), - FetchHttpClient.layer, - ToolOutputStore.defaultCleanupLayer, - ApplicationTools.layer, - ], -}) {} diff --git a/packages/core/src/location-mutation.ts b/packages/core/src/location-mutation.ts index a620c66e31..5f410b95b1 100644 --- a/packages/core/src/location-mutation.ts +++ b/packages/core/src/location-mutation.ts @@ -1,5 +1,6 @@ export * as LocationMutation from "./location-mutation" +import { makeLocationNode } from "./effect/app-node" import path from "path" import { Context, Effect, Layer, Schema } from "effect" import { FSUtil } from "./fs-util" @@ -75,7 +76,7 @@ interface ResolvedPath { const slash = (value: string) => value.replaceAll("\\", "/") -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -153,3 +154,9 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ + service: Service, + layer: layer.pipe(Layer.orDie), + deps: [FSUtil.node, Location.node], +}) diff --git a/packages/core/src/location-service-map.ts b/packages/core/src/location-service-map.ts new file mode 100644 index 0000000000..1a6c26737d --- /dev/null +++ b/packages/core/src/location-service-map.ts @@ -0,0 +1,18 @@ +import { Context, Effect, Layer, LayerMap } from "effect" +import { LayerNode } from "./effect/layer-node" +import { Node } from "./effect/app-node" +import { Location } from "./location" +import type { LocationError, LocationServices } from "./location-services" + +export class Service extends Context.Service< + Service, + LayerMap.LayerMap +>()("@opencode/example/LocationServiceMap") { + static get(ref: Location.Ref) { + return Layer.unwrap(Effect.map(Service, (locations) => locations.get(ref))) + } +} + +export const node = LayerNode.unbound(Service, Node.tags.values.global) + +export * as LocationServiceMap from "./location-service-map" diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts new file mode 100644 index 0000000000..7a4678a32c --- /dev/null +++ b/packages/core/src/location-services.ts @@ -0,0 +1,111 @@ +import { Effect, Layer, LayerMap } from "effect" +import { AgentV2 } from "./agent" +import { AISDK } from "./aisdk" +import { Catalog } from "./catalog" +import { CommandV2 } from "./command" +import { Config } from "./config" +import { LayerNode } from "./effect/layer-node" +import { Node } from "./effect/app-node" +import { FileMutation } from "./file-mutation" +import { FileSystem } from "./filesystem" +import { FileSystemSearch } from "./filesystem/search" +import { Watcher } from "./filesystem/watcher" +import { Image } from "./image" +import { Integration } from "./integration" +import { Location } from "./location" +import { LocationMutation } from "./location-mutation" +import { LocationServiceMap } from "./location-service-map" +import { PermissionV2 } from "./permission" +import { PluginV2 } from "./plugin" +import { PluginInternal } from "./plugin/internal" +import { Policy } from "./policy" +import { ProjectCopy } from "./project/copy" +import { Pty } from "./pty" +import { QuestionV2 } from "./question" +import { Reference } from "./reference" +import { ReferenceGuidance } from "./reference/guidance" +import * as SessionRunnerLLM from "./session/runner/llm" +import { SessionRunnerModel } from "./session/runner/model" +import { SessionTodo } from "./session/todo" +import { SkillV2 } from "./skill" +import { SkillGuidance } from "./skill/guidance" +import { Snapshot } from "./snapshot" +import { SystemContextBuiltIns } from "./system-context/builtins" +import { SystemContextRegistry } from "./system-context/registry" +import { BuiltInTools } from "./tool/builtins" +import { ReadToolFileSystem } from "./tool/read-filesystem" +import { ToolRegistry } from "./tool/registry" +import { ToolOutputStore } from "./tool-output-store" + +export { LocationServiceMap } from "./location-service-map" + +export const locationServices = LayerNode.group([ + Location.node, + Policy.node, + Config.node, + AgentV2.node, + CommandV2.node, + Reference.node, + Integration.node, + Catalog.node, + AISDK.node, + PluginV2.node, + PluginInternal.node, + ProjectCopy.node, + ProjectCopy.refreshNode, + FileSystemSearch.node, + FileSystem.node, + Watcher.node, + Pty.node, + SkillV2.node, + SystemContextRegistry.node, + SystemContextBuiltIns.node, + LocationMutation.node, + FileMutation.node, + PermissionV2.node, + ToolOutputStore.node, + ToolRegistry.node, + ToolRegistry.toolsNode, + Image.node, + SkillGuidance.node, + ReferenceGuidance.node, + SessionTodo.node, + QuestionV2.node, + ReadToolFileSystem.node, + BuiltInTools.node, + SessionRunnerModel.node, + Snapshot.node, + SessionRunnerLLM.node, +]) + +export type LocationServices = LayerNode.Output +export type LocationError = LayerNode.Error + +export function buildLocationServiceMap( + replacements: LayerNode.Replacements = [], +): Layer.Layer { + return Layer.effect( + LocationServiceMap.Service, + LayerMap.make( + (ref: Location.Ref) => { + const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) + const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements) + + return LayerNode.compile(location.node).pipe( + Layer.fresh, + Layer.tap(() => + Effect.logInfo("booting location services", { + directory: ref.directory, + workspaceID: ref.workspaceID, + }), + ), + Layer.provide(LayerNode.compile(location.hoisted)), + ) + }, + { idleTimeToLive: "60 minutes" }, + ), + ) +} + +// This is temporary for backwards compatibility +export const locationServiceMapLayer = buildLocationServiceMap() diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 9225e4a01a..8228b8599e 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -1,35 +1,22 @@ -import { Context, Effect, Layer, Schema } from "effect" +import { Context, Effect, Layer } from "effect" +import { Info, Ref, response } from "@opencode-ai/schema/location" import { Project } from "./project" -import { AbsolutePath, optionalOmitUndefined } from "./schema" -import { WorkspaceV2 } from "./workspace" +import { LayerNode } from "./effect/layer-node" +import { makeLocationNode, tags } from "./effect/app-node" export * as Location from "./location" -export class Ref extends Schema.Class("Location.Ref")({ - directory: AbsolutePath, - workspaceID: Schema.optional(WorkspaceV2.ID).pipe(Schema.withConstructorDefault(Effect.succeed(undefined))), -}) {} - -export class Info extends Schema.Class("Location.Info")({ - directory: AbsolutePath, - workspaceID: optionalOmitUndefined(WorkspaceV2.ID), - project: Schema.Struct({ - id: Project.ID, - directory: AbsolutePath, - }), -}) {} +export { Info, Ref, response } export interface Interface extends Info { readonly vcs?: Project.Vcs } -export function response(data: S) { - return Schema.Struct({ location: Info, data }) -} - export class Service extends Context.Service()("@opencode/Location") {} -export const layer = (ref: Ref) => +export const node = LayerNode.unbound(Service, tags.values.location) + +const layer = (ref: Ref) => Layer.effect( Service, Effect.gen(function* () { @@ -43,3 +30,10 @@ export const layer = (ref: Ref) => }) }), ) + +export const boundNode = (ref: Ref) => + makeLocationNode({ + service: Service, + layer: layer(ref), + deps: [Project.node], + }) diff --git a/packages/core/src/model-request.ts b/packages/core/src/model-request.ts deleted file mode 100644 index f9f4f56936..0000000000 --- a/packages/core/src/model-request.ts +++ /dev/null @@ -1,124 +0,0 @@ -export * as ModelRequest from "./model-request" - -import { Effect, Schema } from "effect" - -export const Generation = Schema.Struct({ - maxTokens: Schema.Number.pipe(Schema.optional), - temperature: Schema.Number.pipe(Schema.optional), - topP: Schema.Number.pipe(Schema.optional), - topK: Schema.Number.pipe(Schema.optional), - frequencyPenalty: Schema.Number.pipe(Schema.optional), - presencePenalty: Schema.Number.pipe(Schema.optional), - seed: Schema.Number.pipe(Schema.optional), - stop: Schema.String.pipe(Schema.Array, Schema.mutable, Schema.optional), -}) -export type Generation = typeof Generation.Type - -export const Request = Schema.Struct({ - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.Record(Schema.String, Schema.Any), - generation: Generation.pipe( - Schema.optionalKey, - Schema.withConstructorDefault(Effect.succeed({})), - Schema.withDecodingDefaultKey(Effect.succeed({})), - ), - options: Schema.Record(Schema.String, Schema.Any).pipe( - Schema.optionalKey, - Schema.withConstructorDefault(Effect.succeed({})), - Schema.withDecodingDefaultKey(Effect.succeed({})), - ), -}) -export type Request = typeof Request.Type - -interface MutableRequest { - headers: Record - body: Record - generation?: Generation - options?: Record -} - -const generationKeys = new Map([ - ["maxOutputTokens", "maxTokens"], - ["maxTokens", "maxTokens"], - ["temperature", "temperature"], - ["topP", "topP"], - ["topK", "topK"], - ["frequencyPenalty", "frequencyPenalty"], - ["presencePenalty", "presencePenalty"], - ["seed", "seed"], - ["stopSequences", "stop"], - ["stop", "stop"], -]) - -interface Profile { - readonly namespace: string - readonly semantics: ReadonlyMap -} - -const profiles = new Map([ - [ - "@ai-sdk/openai", - { - namespace: "openai", - semantics: new Map([ - ["store", "store"], - ["promptCacheKey", "promptCacheKey"], - ["reasoningEffort", "reasoningEffort"], - ["reasoningSummary", "reasoningSummary"], - ["include", "include"], - ["textVerbosity", "textVerbosity"], - ["serviceTier", "serviceTier"], - ["service_tier", "serviceTier"], - ]), - }, - ], - [ - "@ai-sdk/openai-compatible", - { - namespace: "openai", - semantics: new Map([ - ["store", "store"], - ["promptCacheKey", "promptCacheKey"], - ["reasoningEffort", "reasoningEffort"], - ["reasoning_effort", "reasoningEffort"], - ]), - }, - ], - ["@ai-sdk/anthropic", { namespace: "anthropic", semantics: new Map([["thinking", "thinking"]]) }], -]) - -export const namespace = (packageName: string) => profiles.get(packageName)?.namespace - -export const merge = (base: Request, override: Partial) => ({ - headers: { ...base.headers, ...override.headers }, - body: { ...base.body, ...override.body }, - generation: { ...base.generation, ...override.generation }, - options: { ...base.options, ...override.options }, -}) - -export const assign = (target: MutableRequest, override: Partial) => { - Object.assign(target.headers, override.headers) - Object.assign(target.body, override.body) - Object.assign((target.generation ??= {}), override.generation) - Object.assign((target.options ??= {}), override.options) -} - -/** Partitions AI-SDK-shaped request options before they enter the Catalog. */ -export function normalizeAiSdkOptions(packageName: string | undefined, input: Readonly>) { - const generation: Record> = {} - const options: Record = {} - const body: Record = {} - const semantics = profiles.get(packageName ?? "")?.semantics - - for (const [key, value] of Object.entries(input)) { - const generationKey = generationKeys.get(key) - if (generationKey === "stop" && Array.isArray(value) && value.every((item) => typeof item === "string")) - generation[generationKey] = value - else if (generationKey !== undefined && generationKey !== "stop" && typeof value === "number") - generation[generationKey] = value - else if (semantics?.has(key)) options[semantics.get(key)!] = value - else body[key] = value - } - - return { generation, options, body } -} diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 3b0beece55..52fff98733 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -1,119 +1,33 @@ -import { DateTime, Schema } from "effect" -import { DateTimeUtcFromMillis } from "effect/Schema" +import { Types } from "effect" +import { Model } from "@opencode-ai/schema/model" import { ProviderV2 } from "./provider" -import { ModelRequest } from "./model-request" -export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID")) +export const ID = Model.ID export type ID = typeof ID.Type -export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) +export const VariantID = Model.VariantID export type VariantID = typeof VariantID.Type // Grouping of models, eg claude opus, claude sonnet -export const Family = Schema.String.pipe(Schema.brand("Family")) -export type Family = typeof Family.Type +export const Family = Model.Family +export type Family = Model.Family -export const Capabilities = Schema.Struct({ - tools: Schema.Boolean, - // mime patterns, image, audio, video/*, text/* - input: Schema.String.pipe(Schema.Array), - output: Schema.String.pipe(Schema.Array), -}) -export type Capabilities = typeof Capabilities.Type +export const Capabilities = Model.Capabilities +export type Capabilities = Model.Capabilities -export const Cost = Schema.Struct({ - tier: Schema.Struct({ - type: Schema.Literal("context"), - size: Schema.Int, - }).pipe(Schema.optional), - input: Schema.Finite, - output: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), -}) +export const Cost = Model.Cost -export const Ref = Schema.Struct({ - id: ID, - providerID: ProviderV2.ID, - variant: VariantID.pipe(Schema.optional), -}) +export const Ref = Model.Ref export type Ref = typeof Ref.Type -export const Api = Schema.Union([ - Schema.Struct({ - id: ID, - ...ProviderV2.AISDK.fields, - }), - Schema.Struct({ - id: ID, - ...ProviderV2.Native.fields, - }), -]).pipe(Schema.toTaggedUnion("type")) -export type Api = typeof Api.Type +export const Api = Model.Api +export type Api = Model.Api -export class Info extends Schema.Class("ModelV2.Info")({ - id: ID, - providerID: ProviderV2.ID, - family: Family.pipe(Schema.optional), - name: Schema.String, - api: Api, - capabilities: Capabilities, - request: Schema.Struct({ - ...ModelRequest.Request.fields, - variant: Schema.String.pipe(Schema.optional), - }), - variants: Schema.Struct({ - id: VariantID, - ...ModelRequest.Request.fields, - }).pipe(Schema.Array), - time: Schema.Struct({ - released: DateTimeUtcFromMillis, - }), - cost: Cost.pipe(Schema.Array), - status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), - enabled: Schema.Boolean, - limit: Schema.Struct({ - context: Schema.Int, - input: Schema.Int.pipe(Schema.optional), - output: Schema.Int, - }), -}) { - static empty(providerID: ProviderV2.ID, modelID: ID): Info { - return new Info({ - id: modelID, - providerID, - name: modelID, - api: { - id: modelID, - type: "native", - settings: {}, - }, - capabilities: { - tools: false, - input: [], - output: [], - }, - request: { - headers: {}, - body: {}, - generation: {}, - options: {}, - }, - variants: [], - time: { - released: DateTime.makeUnsafe(0), - }, - cost: [], - status: "active", - enabled: true, - limit: { - context: 0, - output: 0, - }, - }) - } +export const Info = Model.Info +export type Info = Model.Info + +export type MutableInfo = Omit, "api"> & { + api: ProviderV2.MutableApi } export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 821e9924fa..20a7e89acb 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -1,6 +1,7 @@ import path from "path" import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { ModelsDev } from "@opencode-ai/schema/models-dev" import { Global } from "./global" import { Flag } from "./flag/flag" import { Flock } from "./util/flock" @@ -8,8 +9,8 @@ import { Hash } from "./util/hash" import { FSUtil } from "./fs-util" import { InstallationChannel, InstallationVersion } from "./installation/version" import { EventV2 } from "./event" -import { LayerNode } from "./effect/layer-node" -import { httpClient } from "./effect/layer-node-platform" +import { makeGlobalNode } from "./effect/app-node" +import { httpClient } from "./effect/app-node-platform" export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) export type CatalogModelStatus = typeof CatalogModelStatus.Type @@ -108,12 +109,7 @@ export const Provider = Schema.Struct({ export type Provider = Schema.Schema.Type -export const Event = { - Refreshed: EventV2.define({ - type: "models-dev.refreshed", - schema: {}, - }), -} +export const Event = ModelsDev.Event declare const KILO_MODELS_DEV: Record | undefined @@ -124,7 +120,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ModelsDev") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -243,11 +239,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(FetchHttpClient.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(EventV2.defaultLayer), -) -export const node = LayerNode.make(layer, [FSUtil.node, EventV2.node, httpClient]) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] }) export * as ModelsDev from "./models-dev" diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index f3398e8391..30e12cff12 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -7,20 +7,21 @@ import { NodeFileSystem } from "@effect/platform-node" import { FSUtil } from "./fs-util" import { Global } from "./global" import { EffectFlock } from "./util/effect-flock" +import { makeGlobalNode } from "./effect/app-node" +import { filesystem } from "./effect/app-node-platform" import { LayerNode } from "./effect/layer-node" -import { filesystem } from "./effect/layer-node-platform" import { makeRuntime } from "./effect/runtime" import { NpmConfig } from "./npm-config" export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", { add: Schema.Array(Schema.String).pipe(Schema.optional), dir: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export interface EntryPoint { readonly directory: string - readonly entrypoint: Option.Option + readonly entrypoint?: string } export interface Interface { @@ -34,7 +35,7 @@ export interface Interface { }[] }, ) => Effect.Effect - readonly which: (pkg: string, bin?: string) => Effect.Effect> + readonly which: (pkg: string, bin?: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/Npm") {} @@ -47,12 +48,11 @@ export function sanitize(pkg: string) { } const resolveEntryPoint = (name: string, dir: string): EntryPoint => { - let entrypoint: Option.Option + let entrypoint: string | undefined try { - const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) - entrypoint = Option.some(resolved) + entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) } catch { - entrypoint = Option.none() + entrypoint = undefined } return { directory: dir, @@ -69,7 +69,7 @@ interface ArboristTree { edgesOut: Map } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const afs = yield* FSUtil.Service @@ -130,7 +130,7 @@ export const layer = Layer.effect( const first = tree.edgesOut.values().next().value?.to if (!first) { const result = resolveEntryPoint(name, path.join(dir, "node_modules", name)) - if (Option.isSome(result.entrypoint)) return result + if (result.entrypoint) return result return yield* new InstallFailedError({ add: [pkg], dir }) } return resolveEntryPoint(first.name, first.path) @@ -219,22 +219,24 @@ export const layer = Layer.effect( return Option.some(files[0]) }) - return yield* Effect.gen(function* () { - const bin = yield* pick() - if (Option.isSome(bin)) { - return Option.some(path.join(binDir, bin.value)) - } + return Option.getOrUndefined( + yield* Effect.gen(function* () { + const bin = yield* pick() + if (Option.isSome(bin)) { + return Option.some(path.join(binDir, bin.value)) + } - yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {})) + yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {})) - yield* add(pkg) + yield* add(pkg) - const resolved = yield* pick() - if (Option.isNone(resolved)) return Option.none() - return Option.some(path.join(binDir, resolved.value)) - }).pipe( - Effect.scoped, - Effect.orElseSucceed(() => Option.none()), + const resolved = yield* pick() + if (Option.isNone(resolved)) return Option.none() + return Option.some(path.join(binDir, resolved.value)) + }).pipe( + Effect.scoped, + Effect.orElseSucceed(() => Option.none()), + ), ) }) @@ -246,29 +248,22 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(EffectFlock.layer), - Layer.provide(FSUtil.layer), - Layer.provide(Global.layer), - Layer.provide(NodeFileSystem.layer), -) -export const node = LayerNode.make(layer, [FSUtil.node, Global.node, filesystem, EffectFlock.node]) +export const node = makeGlobalNode({ + service: Service, + layer: layer, + deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node], +}) -const { runPromise } = makeRuntime(Service, defaultLayer) +const { runPromise } = makeRuntime(Service, LayerNode.compile(node)) export async function install(...args: Parameters) { return runPromise((svc) => svc.install(...args)) } export async function add(...args: Parameters) { - const entry = await runPromise((svc) => svc.add(...args)) - return { - directory: entry.directory, - entrypoint: Option.getOrUndefined(entry.entrypoint), - } + return runPromise((svc) => svc.add(...args)) } export async function which(...args: Parameters) { - const resolved = await runPromise((svc) => svc.which(...args)) - return Option.getOrUndefined(resolved) + return runPromise((svc) => svc.which(...args)) } diff --git a/packages/core/src/oauth/page.ts b/packages/core/src/oauth/page.ts new file mode 100644 index 0000000000..5d3e29f67b --- /dev/null +++ b/packages/core/src/oauth/page.ts @@ -0,0 +1,276 @@ +// Branded HTML pages for local OAuth callback servers. +// +// These are served by the loopback HTTP servers that finish an OAuth exchange +// (MCP, Codex/ChatGPT, xAI, Snowflake, DigitalOcean, ...). The functions return +// a fully self-contained HTML string with no external assets, so they work +// offline and drop into any transport (`res.end(...)`, Effect `response.end`, +// etc.). +// +// The visual language mirrors the OpenCode app: the design tokens are a curated +// subset of the OC-2 semantic tokens in `packages/ui/src/styles/theme.css`, and +// the wordmark is the same geometry as `packages/ui/src/components/logo.tsx`. +// Keep this file in sync with those sources when the brand changes. + +export interface CallbackPageOptions { + /** Friendly integration name shown as a subtitle, e.g. "xAI", "Snowflake", "MCP". */ + provider?: string + /** Attempt to close the window shortly after success. Defaults to true. */ + autoClose?: boolean +} + +export function success(options?: CallbackPageOptions) { + const provider = options?.provider + return renderDocument({ + title: "Authorization successful", + body: renderCard({ + status: "success", + headline: "Authorization successful", + message: provider ? `OpenCode is now connected to ${escapeHtml(provider)}.` : "OpenCode is now authorized.", + footnote: "You can close this window.", + }), + script: options?.autoClose === false ? undefined : AUTO_CLOSE_SCRIPT, + }) +} + +export function error(detail: string, options?: CallbackPageOptions) { + const provider = options?.provider + return renderDocument({ + title: "Authorization failed", + body: renderCard({ + status: "error", + headline: "Authorization failed", + message: provider + ? `OpenCode couldn't finish connecting to ${escapeHtml(provider)}.` + : "OpenCode couldn't complete authorization.", + detail, + footnote: "Close this window and try again from OpenCode.", + }), + }) +} + +export interface BootstrapOptions { + /** Same-origin path the in-browser script POSTs the parsed callback to. */ + tokenPath: string + provider?: string +} + +// For flows where the credential arrives in the URL fragment (implicit grant), +// the browser must relay it back to the loopback server. This renders a pending +// page whose script reads the fragment, POSTs it to `tokenPath`, then resolves +// to the success or error state in place. +export function bootstrap(options: BootstrapOptions) { + return renderDocument({ + title: "Finishing sign-in", + body: renderCard({ + status: "pending", + headline: "Finishing sign-in", + message: options.provider + ? `Completing your ${escapeHtml(options.provider)} authorization.` + : "Completing authorization.", + footnote: "You can close this window once sign-in finishes.", + }), + script: bootstrapScript(options), + }) +} + +export * as OauthCallbackPage from "./page" + +type Status = "pending" | "success" | "error" + +function renderCard(input: { status: Status; headline: string; message: string; detail?: string; footnote: string }) { + const detail = input.detail?.trim() + return `
+
${WORDMARK}
+ +

${escapeHtml(input.headline)}

+

${input.message}

+
${detail ? escapeHtml(detail) : ""}
+

${escapeHtml(input.footnote)}

+
` +} + +function renderDocument(input: { title: string; body: string; script?: string }) { + return ` + + + + + + ${escapeHtml(input.title)} · OpenCode + + + + ${input.body}${input.script ? `\n ` : ""} + +` +} + +const AUTO_CLOSE_SCRIPT = `setTimeout(function(){try{window.close()}catch(e){}},2500)` + +function bootstrapScript(options: BootstrapOptions) { + return `var PROVIDER=${scriptString(options.provider ?? "")}; +var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href; +(function(){ + var card=document.getElementById("oc-card"),headline=document.getElementById("oc-headline"),message=document.getElementById("oc-message"),detail=document.getElementById("oc-detail"),footnote=document.getElementById("oc-footnote"); + function fail(text){card.dataset.status="error";headline.textContent="Authorization failed";message.textContent=PROVIDER?("OpenCode couldn't finish connecting to "+PROVIDER+"."):"OpenCode couldn't complete authorization.";if(text){detail.textContent=text;detail.hidden=false}footnote.textContent="Close this window and try again from OpenCode."} + function ok(){card.dataset.status="success";headline.textContent="Authorization successful";message.textContent=PROVIDER?("OpenCode is now connected to "+PROVIDER+"."):"OpenCode is now authorized.";detail.hidden=true;footnote.textContent="You can close this window.";setTimeout(function(){try{window.close()}catch(e){}},2500)} + try{ + var hash=new URLSearchParams((window.location.hash||"").slice(1)); + var search=new URLSearchParams(window.location.search||""); + var err=hash.get("error")||search.get("error"); + var errDescription=hash.get("error_description")||search.get("error_description"); + var body=err?{error:err,error_description:errDescription||""}:{access_token:hash.get("access_token")||"",expires_in:hash.get("expires_in")||"0",state:hash.get("state")||""}; + fetch(TOKEN_URL,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}).then(function(res){ + if(!res.ok)return res.text().catch(function(){return""}).then(function(t){throw new Error(t||("callback failed ("+res.status+")"))}); + if(err){fail(errDescription||err);return} + ok(); + }).catch(function(e){fail(String(e&&e.message?e.message:e))}); + }catch(e){fail(String(e&&e.message?e.message:e))} +})()` +} + +function scriptString(value: string) { + return JSON.stringify(value).replaceAll("<", "\\u003c") +} + +function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") +} + +// Curated subset of OC-2 tokens (packages/ui/src/styles/theme.css). Default is +// light; dark applies via prefers-color-scheme. The [data-theme] selectors let a +// host force a scheme without changing the default. +const LIGHT_VARS = ` + --oc-bg: #f8f8f8; + --oc-card: #fcfcfc; + --oc-text-strong: #171717; + --oc-text-base: #6f6f6f; + --oc-text-weak: #8f8f8f; + --oc-border-weak: #e5e5e5; + --oc-icon-strong: #171717; + --oc-icon-base: #8f8f8f; + --oc-icon-weak: #dbdbdb; + --oc-success: #2dba26; + --oc-error: #ed4831; + --oc-detail-bg: #fff8f6; + --oc-detail-border: #fdc3b7; + --oc-shadow: 0 16px 48px -6px rgba(0,0,0,.10), 0 6px 12px -2px rgba(0,0,0,.05), 0 1px 2px rgba(0,0,0,.06);` + +const DARK_VARS = ` + --oc-bg: #101010; + --oc-card: #161616; + --oc-text-strong: rgba(255,255,255,.936); + --oc-text-base: rgba(255,255,255,.618); + --oc-text-weak: rgba(255,255,255,.422); + --oc-border-weak: #282828; + --oc-icon-strong: #ededed; + --oc-icon-base: #7e7e7e; + --oc-icon-weak: #343434; + --oc-success: #12c905; + --oc-error: #fc533a; + --oc-detail-bg: #28110c; + --oc-detail-border: #6a1206; + --oc-shadow: 0 16px 48px -6px rgba(0,0,0,.55), 0 6px 12px -2px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.4);` + +const STYLES = ` + :root { color-scheme: light dark;${LIGHT_VARS} + --oc-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --oc-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + } + @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {${DARK_VARS} } } + :root[data-theme="dark"] {${DARK_VARS} } + :root[data-theme="light"] {${LIGHT_VARS} } + + * { box-sizing: border-box; } + html, body { margin: 0; height: 100%; } + body { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: var(--oc-bg); + color: var(--oc-text-base); + font-family: var(--oc-font-sans); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + .card { + width: min(100%, 28rem); + padding: 2.25rem 2rem 1.75rem; + background: var(--oc-card); + border: 1px solid var(--oc-border-weak); + border-radius: 14px; + box-shadow: var(--oc-shadow); + text-align: center; + } + .brand { display: flex; justify-content: center; margin-bottom: 1.75rem; } + .brand svg { height: 19px; width: auto; } + .status { display: flex; justify-content: center; margin-bottom: 1.125rem; } + .icon { display: none; line-height: 0; } + .icon svg { display: block; } + .card[data-status="pending"] .icon-pending, + .card[data-status="success"] .icon-success, + .card[data-status="error"] .icon-error { display: block; } + .icon-success { color: var(--oc-success); } + .icon-error { color: var(--oc-error); } + .icon-pending { color: var(--oc-text-weak); } + .headline { margin: 0; font-size: 1.1875rem; font-weight: 500; line-height: 1.3; letter-spacing: -0.012em; color: var(--oc-text-strong); } + .message { margin: 0.5rem 0 0; font-size: 0.9375rem; color: var(--oc-text-base); } + .detail { + margin: 1.25rem 0 0; + padding: 0.75rem 0.875rem; + text-align: left; + font-family: var(--oc-font-mono); + font-size: 0.8125rem; + line-height: 1.55; + color: var(--oc-text-strong); + background: var(--oc-detail-bg); + border: 1px solid var(--oc-detail-border); + border-radius: 8px; + white-space: pre-wrap; + word-break: break-word; + max-height: 9.5rem; + overflow: auto; + } + .detail[hidden] { display: none; } + .footnote { margin: 1.5rem 0 0; font-size: 0.8125rem; color: var(--oc-text-weak); } + .spinner { animation: oc-spin 0.8s linear infinite; transform-origin: center; } + @keyframes oc-spin { to { transform: rotate(360deg); } } + @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } +` + +// OpenCode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo). +const WORDMARK = ` + + + + + + + + + + + + + + + + + ` + +const ICON_CHECK = `` + +const ICON_CROSS = `` + +const ICON_SPINNER = `` diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts index faffb27333..22285974d8 100644 --- a/packages/core/src/observability.ts +++ b/packages/core/src/observability.ts @@ -1,6 +1,7 @@ export * as Observability from "./observability" import { NodeFileSystem } from "@effect/platform-node" +import { LayerNode } from "./effect/layer-node" import { Effect, Layer, Logger, References } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { OtlpSerialization } from "effect/unstable/observability" @@ -19,3 +20,5 @@ export const layer = Layer.unwrap( return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer)) }), ) + +export const node = LayerNode.make({ name: "observability", layer, deps: [] }) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index bbfc6014e8..95219e1514 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -1,54 +1,38 @@ export * as PermissionV2 from "./permission" +import { makeLocationNode } from "./effect/app-node" import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect" +import { Permission } from "@opencode-ai/schema/permission" import { EventV2 } from "./event" import { Location } from "./location" import { AgentV2 } from "./agent" import { SessionV2 } from "./session" import { SessionStore } from "./session/store" -import { withStatics } from "./schema" -import { Identifier } from "./util/identifier" import { Wildcard } from "./util/wildcard" -import { PermissionSchema } from "./permission/schema" import { PermissionSaved } from "./permission/saved" -export { Effect, Rule, Ruleset } from "./permission/schema" -type Effect = PermissionSchema.Effect -type Rule = PermissionSchema.Rule -type Ruleset = PermissionSchema.Ruleset -const missingAgentPermissions: Ruleset = [{ action: "*", resource: "*", effect: "deny" }] +export { Effect, Rule, Ruleset } from "@opencode-ai/schema/permission" +const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }] -export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( - Schema.brand("PermissionV2.ID"), - withStatics((schema) => ({ create: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })), -) +export const ID = Permission.ID export type ID = typeof ID.Type -export const Source = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("tool"), - messageID: Schema.String, - callID: Schema.String, - }), -]).annotate({ identifier: "PermissionV2.Source" }) +export const Source = Permission.Source export type Source = typeof Source.Type const RequestFields = { - sessionID: SessionV2.ID, - action: Schema.String, - resources: Schema.Array(Schema.String), - save: Schema.Array(Schema.String).pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - source: Source.pipe(Schema.optional), + sessionID: Permission.Request.fields.sessionID, + action: Permission.Request.fields.action, + resources: Permission.Request.fields.resources, + save: Permission.Request.fields.save, + metadata: Permission.Request.fields.metadata, + source: Permission.Request.fields.source, } -export const Request = Schema.Struct({ - id: ID, - ...RequestFields, -}).annotate({ identifier: "PermissionV2.Request" }) +export const Request = Permission.Request export type Request = typeof Request.Type -export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" }) +export const Reply = Permission.Reply export type Reply = typeof Reply.Type export const AssertInput = Schema.Struct({ @@ -67,21 +51,11 @@ export type ReplyInput = typeof ReplyInput.Type export const AskResult = Schema.Struct({ id: ID, - effect: PermissionSchema.Effect, + effect: Permission.Effect, }).annotate({ identifier: "PermissionV2.AskResult" }) export type AskResult = typeof AskResult.Type -export const Event = { - Asked: EventV2.define({ type: "permission.v2.asked", schema: Request.fields }), - Replied: EventV2.define({ - type: "permission.v2.replied", - schema: { - sessionID: SessionV2.ID, - requestID: ID, - reply: Reply, - }, - }), -} +export const Event = Permission.Event export class RejectedError extends Schema.TaggedErrorClass()("PermissionV2.RejectedError", {}) {} @@ -90,7 +64,7 @@ export class CorrectedError extends Schema.TaggedErrorClass()("P }) {} export class DeniedError extends Schema.TaggedErrorClass()("PermissionV2.DeniedError", { - rules: PermissionSchema.Ruleset, + rules: Permission.Ruleset, }) {} export class NotFoundError extends Schema.TaggedErrorClass()("PermissionV2.NotFoundError", { @@ -99,7 +73,7 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Per export type Error = DeniedError | RejectedError | CorrectedError -export function evaluate(action: string, resource: string, ...rulesets: Ruleset[]): Rule { +export function evaluate(action: string, resource: string, ...rulesets: Permission.Ruleset[]): Permission.Rule { return ( rulesets .flat() @@ -111,7 +85,7 @@ export function evaluate(action: string, resource: string, ...rulesets: Ruleset[ ) } -export function merge(...rulesets: Ruleset[]): Ruleset { +export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset { return rulesets.flat() } @@ -132,7 +106,7 @@ interface Pending { readonly deferred: Deferred.Deferred } -export const layer = Layer.effect( +const layer = Layer.effect( Service, EffectRuntime.gen(function* () { const events = yield* EventV2.Service @@ -156,7 +130,7 @@ export const layer = Layer.effect( const savedRules = EffectRuntime.fnUntraced(function* () { return (yield* saved.list({ projectID: location.project.id })).map( - (item): Rule => ({ action: item.action, resource: item.resource, effect: "allow" }), + (item): Permission.Rule => ({ action: item.action, resource: item.resource, effect: "allow" }), ) }) @@ -170,11 +144,11 @@ export const layer = Layer.effect( return agent?.permissions ?? missingAgentPermissions }) - function denied(input: AssertInput, rules: Ruleset) { + function denied(input: AssertInput, rules: Permission.Ruleset) { return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny") } - function relevant(input: AssertInput, rules: Ruleset) { + function relevant(input: AssertInput, rules: Permission.Ruleset) { return rules.filter((rule) => Wildcard.match(input.action, rule.action)) } @@ -183,7 +157,7 @@ export const layer = Layer.effect( if (denied(input, rules)) return { effect: "deny" as const, rules } const all = [...rules, ...(yield* savedRules())] const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect) - const effect: Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow" + const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow" return { effect, rules: all } }) @@ -327,3 +301,9 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer)) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [EventV2.node, Location.node, AgentV2.node, SessionStore.node, PermissionSaved.node], +}) diff --git a/packages/core/src/permission/saved.ts b/packages/core/src/permission/saved.ts index 4c57ef2aa0..ffc4559afe 100644 --- a/packages/core/src/permission/saved.ts +++ b/packages/core/src/permission/saved.ts @@ -3,23 +3,15 @@ export * as PermissionSaved from "./saved" import { eq } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" +import { makeGlobalNode } from "../effect/app-node" import { ProjectV2 } from "../project" -import { withStatics } from "../schema" -import { Identifier } from "../util/identifier" import { PermissionTable } from "./sql" +import { PermissionSaved } from "@opencode-ai/schema/permission-saved" -export const ID = Schema.String.pipe( - Schema.brand("PermissionSaved.ID"), - withStatics((schema) => ({ create: () => schema.make("psv_" + Identifier.ascending()) })), -) +export const ID = PermissionSaved.ID export type ID = typeof ID.Type -export const Info = Schema.Struct({ - id: ID, - projectID: ProjectV2.ID, - action: Schema.String, - resource: Schema.String, -}).annotate({ identifier: "PermissionSaved.Info" }) +export const Info = PermissionSaved.Info export type Info = typeof Info.Type export const ListInput = Schema.Struct({ @@ -42,7 +34,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/PermissionSaved") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service @@ -84,4 +76,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) +export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] }) diff --git a/packages/core/src/permission/schema.ts b/packages/core/src/permission/schema.ts deleted file mode 100644 index 2d806dbd8c..0000000000 --- a/packages/core/src/permission/schema.ts +++ /dev/null @@ -1,16 +0,0 @@ -export * as PermissionSchema from "./schema" - -import { Schema } from "effect" - -export const Effect = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Effect" }) -export type Effect = typeof Effect.Type - -export const Rule = Schema.Struct({ - action: Schema.String, - resource: Schema.String, - effect: Effect, -}).annotate({ identifier: "PermissionV2.Rule" }) -export type Rule = typeof Rule.Type - -export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" }) -export type Ruleset = typeof Ruleset.Type diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index aaef65d322..f6c071bca6 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,186 +1,167 @@ export * as PluginV2 from "./plugin" -import { createDraft, finishDraft, type Draft } from "immer" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Context, Effect, Exit, Layer, Schema, Scope } from "effect" -import type { ModelV2 } from "./model" -import type { Catalog } from "./catalog" +import { makeLocationNode } from "./effect/app-node" +import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect" +import type { Plugin as PluginRuntime } from "@kilocode/plugin/v2/effect" +import { Plugin } from "@opencode-ai/schema/plugin" +import { AgentV2 } from "./agent" +import { AISDK } from "./aisdk" +import { Catalog } from "./catalog" +import { CommandV2 } from "./command" import { EventV2 } from "./event" +import { Integration } from "./integration" import { KeyedMutex } from "./effect/keyed-mutex" +import { PluginHost } from "./plugin/host" +import { Reference } from "./reference" +import { SkillV2 } from "./skill" +import { State } from "./state" -export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) +export const ID = Plugin.ID export type ID = typeof ID.Type - -export const Event = { - Added: EventV2.define({ - type: "plugin.added", - schema: { - id: ID, - }, - }), -} - -type HookSpec = { - "catalog.transform": { - input: Catalog.Editor - output: {} - } - "aisdk.language": { - input: { - model: ModelV2.Info - sdk: any - options: Record - } - output: { - language?: LanguageModelV3 - } - } - "aisdk.sdk": { - input: { - model: ModelV2.Info - package: string - options: Record - } - output: { - sdk?: any - } - } -} - -export type Hooks = { - [Name in keyof HookSpec]: Readonly & { - -readonly [Field in keyof HookSpec[Name]["output"]]: HookSpec[Name]["output"][Field] extends object - ? Draft - : HookSpec[Name]["output"][Field] - } -} - -export type HookFunctions = { - [key in keyof Hooks]?: (input: Hooks[key]) => Effect.Effect -} - -export type HookInput = HookSpec[Name]["input"] -export type HookOutput = HookSpec[Name]["output"] - -export type Effect = Effect.Effect - -export function define(input: { id: ID; effect: Effect.Effect }) { - return input -} +export const Event = Plugin.Event export interface Interface { - readonly add: (input: { - id: ID - effect: Effect.Effect - }) => Effect.Effect + readonly add: (id: ID, effect: PluginRuntime["effect"]) => Effect.Effect readonly remove: (id: ID) => Effect.Effect - readonly triggerFor: ( - id: ID, - name: Name, - input: HookInput, - output: HookOutput, - ) => Effect.Effect & HookOutput> - readonly trigger: ( - name: Name, - input: HookInput, - output: HookOutput, - ) => Effect.Effect & HookOutput> + readonly wait: (id: ID) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Plugin") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { - let hooks: { - id: ID - hooks: HookFunctions - scope: Scope.Closeable - }[] = [] const events = yield* EventV2.Service - const scope = yield* Scope.Scope const locks = KeyedMutex.makeUnsafe() + const scope = yield* Scope.make() + const active = new Map() + const loading = new Set() + const waiters = new Map>>() + const failures = new Map>() + let host: Parameters[0] - const svc = Service.of({ - add: Effect.fn("Plugin.add")(function* (input) { - yield* locks.withLock(input.id)( - Effect.gen(function* () { - const existing = hooks.find((item) => item.id === input.id) - if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore) - const childScope = yield* Scope.fork(scope) - const result = yield* input.effect.pipe( - Scope.provide(childScope), - Effect.withSpan("Plugin.load", { - attributes: { - "plugin.id": input.id, - }, + const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginRuntime["effect"]) { + if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`) + + yield* locks.withLock(id)( + Effect.sync(() => { + loading.add(id) + failures.delete(id) + }).pipe( + Effect.andThen( + State.batch( + Effect.gen(function* () { + const existing = active.get(id) + active.delete(id) + if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore) + + const child = yield* Scope.fork(scope) + yield* effect(host).pipe( + Scope.provide(child), + Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), + Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), + ) + yield* events.publish(Event.Added, { id }) + active.set(id, child) + yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), { + discard: true, + }) + waiters.delete(id) }), - Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)), - ) - hooks = [ - ...hooks.filter((item) => item.id !== input.id), - { - id: input.id, - hooks: result ?? {}, - scope: childScope, - }, - ] - yield* events.publish(Event.Added, { id: input.id }) + ), + ), + Effect.onExit((exit) => { + if (Exit.isSuccess(exit)) return Effect.void + failures.set(id, exit) + return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), { + discard: true, + }).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id)))) }), - ) - }), - trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) { - return yield* svc.triggerFor(ID.make("*"), name, input, output) - }), - triggerFor: Effect.fn("Plugin.triggerFor")(function* (id, name, input, output) { - const draftEntries = new Map>() - const event = { - ...input, - ...output, - } as Record - - for (const [field, value] of Object.entries(output)) { - if (value && typeof value === "object") { - draftEntries.set(field, createDraft(value)) - event[field] = draftEntries.get(field) - } - } - - for (const item of hooks) { - if (id !== ID.make("*") && item.id !== id) continue - const match = item.hooks[name] - if (!match) continue - yield* match(event as any).pipe( - Effect.withSpan(`Plugin.hook.${name}`, { - attributes: { - plugin: item.id, - hook: name, - }, - }), - ) - } - - for (const [field, draft] of draftEntries) { - event[field] = finishDraft(draft) - } - - return event as any - }), - remove: Effect.fn("Plugin.remove")(function* (id) { - yield* locks.withLock(id)( - Effect.gen(function* () { - const existing = hooks.find((item) => item.id === id) - hooks = hooks.filter((item) => item.id !== id) - if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore) - }), - ) - }), + Effect.ensuring(Effect.sync(() => loading.delete(id))), + ), + ) }) - return svc + + const remove = Effect.fn("Plugin.remove")(function* (id: ID) { + if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`) + + yield* locks.withLock(id)( + State.batch( + Effect.gen(function* () { + const current = active.get(id) + active.delete(id) + failures.delete(id) + if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore) + }), + ), + ) + }) + + const wait = Effect.fn("Plugin.wait")(function* (id: ID) { + const waiter = yield* Deferred.make() + const pending = yield* locks.withLock(id)( + Effect.sync(() => { + if (active.has(id)) return false + const failure = failures.get(id) + if (failure) return failure + const current = waiters.get(id) ?? new Set() + current.add(waiter) + waiters.set(id, current) + return true + }), + ) + if (!pending) return + if (typeof pending !== "boolean") return yield* pending + yield* Deferred.await(waiter).pipe( + Effect.ensuring( + locks.withLock(id)( + Effect.sync(() => { + const current = waiters.get(id) + current?.delete(waiter) + if (current?.size === 0) waiters.delete(id) + }), + ), + ), + ) + }) + + yield* Effect.addFinalizer((exit) => + Effect.gen(function* () { + active.clear() + yield* State.batch(Scope.close(scope, exit)) + }), + ) + + const service = Service.of({ + add, + remove, + wait, + }) + host = yield* PluginHost.make(service) + return service }), ) -export const locationLayer = layer +export const locationLayer = layer.pipe( + Layer.provideMerge(AgentV2.locationLayer), + Layer.provideMerge(AISDK.locationLayer), + Layer.provideMerge(Catalog.locationLayer), + Layer.provideMerge(CommandV2.locationLayer), + Layer.provideMerge(Integration.locationLayer), + Layer.provideMerge(Reference.locationLayer), +) -// opencode -// sdcok +export const node = makeLocationNode({ + service: Service, + layer, + deps: [ + EventV2.node, + AgentV2.node, + AISDK.node, + Catalog.node, + CommandV2.node, + Integration.node, + Reference.node, + SkillV2.node, + ], +}) diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index e8a8d8bc9d..9a763c7ea9 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -1,12 +1,12 @@ export * as AgentPlugin from "./agent" import path from "path" +import { define } from "./internal" import { Effect } from "effect" import { AgentV2 } from "../agent" import { Global } from "../global" import { Location } from "../location" import { PermissionV2 } from "../permission" -import { PluginV2 } from "../plugin" const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*") const BUILD_SYSTEM = @@ -97,10 +97,9 @@ Rules: - If the conversation ends with an unanswered question to the user, preserve that exact question - If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary` -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("agent"), - effect: Effect.gen(function* () { - const agent = yield* AgentV2.Service +export const Plugin = define({ + id: "agent", + effect: Effect.fn(function* (ctx) { const location = yield* Location.Service const worktree = location.directory const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")] @@ -122,8 +121,8 @@ export const Plugin = PluginV2.define({ { action: "read", resource: "*.env.example", effect: "allow" }, ] - yield* agent.update((editor) => { - editor.update(AgentV2.defaultID, (item) => { + yield* ctx.agent.transform((draft) => { + draft.update(AgentV2.defaultID, (item) => { item.description = "The default agent. Executes tools based on configured permissions." item.system ??= BUILD_SYSTEM item.mode = "primary" @@ -135,7 +134,7 @@ export const Plugin = PluginV2.define({ ) }) - editor.update(AgentV2.ID.make("plan"), (item) => { + draft.update(AgentV2.ID.make("plan"), (item) => { item.description = "Plan mode. Disallows all edit tools." item.mode = "primary" item.permissions.push( @@ -154,14 +153,14 @@ export const Plugin = PluginV2.define({ ) }) - editor.update(AgentV2.ID.make("general"), (item) => { + draft.update(AgentV2.ID.make("general"), (item) => { item.description = "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." item.mode = "subagent" item.permissions.push(...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }])) }) - editor.update(AgentV2.ID.make("explore"), (item) => { + draft.update(AgentV2.ID.make("explore"), (item) => { item.description = 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.' item.system = PROMPT_EXPLORE @@ -182,21 +181,21 @@ export const Plugin = PluginV2.define({ ) }) - editor.update(AgentV2.ID.make("compaction"), (item) => { + draft.update(AgentV2.ID.make("compaction"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_COMPACTION item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) }) - editor.update(AgentV2.ID.make("title"), (item) => { + draft.update(AgentV2.ID.make("title"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_TITLE item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) }) - editor.update(AgentV2.ID.make("summary"), (item) => { + draft.update(AgentV2.ID.make("summary"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_SUMMARY diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts deleted file mode 100644 index cc7b0c247a..0000000000 --- a/packages/core/src/plugin/boot.ts +++ /dev/null @@ -1,135 +0,0 @@ -export * as PluginBoot from "./boot" - -import { Context, Deferred, Effect, Layer } from "effect" -import { Credential } from "../credential" -import { Integration } from "../integration" -import { AgentV2 } from "../agent" -import { Catalog } from "../catalog" -import { CommandV2 } from "../command" -import { Config } from "../config" -import { ConfigAgentPlugin } from "../config/plugin/agent" -import { ConfigCommandPlugin } from "../config/plugin/command" -import { ConfigSkillPlugin } from "../config/plugin/skill" -import { ConfigReferencePlugin } from "../config/plugin/reference" -import { EventV2 } from "../event" -import { FSUtil } from "../fs-util" -import { Global } from "../global" -import { Location } from "../location" -import { ModelsDev } from "../models-dev" -import { Npm } from "../npm" -import { PluginV2 } from "../plugin" -import { AgentPlugin } from "./agent" -import { CommandPlugin } from "./command" -import { SkillPlugin } from "./skill" -import { ConfigProviderPlugin } from "../config/plugin/provider" -import { ModelsDevPlugin } from "./models-dev" -import { ProviderPlugins } from "./provider" -import { SkillV2 } from "../skill" -import { Reference } from "../reference" - -type Plugin = { - id: PluginV2.ID - effect: PluginV2.Effect< - | Catalog.Service - | CommandV2.Service - | Credential.Service - | Integration.Service - | AgentV2.Service - | Npm.Service - | EventV2.Service - | FSUtil.Service - | Global.Service - | Location.Service - | PluginV2.Service - | Config.Service - | ModelsDev.Service - | SkillV2.Service - | Reference.Service - > -} - -export interface Interface { - readonly wait: () => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/PluginBoot") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const commands = yield* CommandV2.Service - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const integrations = yield* Integration.Service - const agents = yield* AgentV2.Service - const config = yield* Config.Service - const location = yield* Location.Service - const modelsDev = yield* ModelsDev.Service - const npm = yield* Npm.Service - const events = yield* EventV2.Service - const fs = yield* FSUtil.Service - const global = yield* Global.Service - const skill = yield* SkillV2.Service - const references = yield* Reference.Service - const done = yield* Deferred.make() - - const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) { - yield* plugin.add({ - id: input.id, - effect: input.effect.pipe( - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(CommandV2.Service, commands), - Effect.provideService(Credential.Service, credentials), - Effect.provideService(Integration.Service, integrations), - Effect.provideService(AgentV2.Service, agents), - Effect.provideService(Config.Service, config), - Effect.provideService(Location.Service, location), - Effect.provideService(ModelsDev.Service, modelsDev), - Effect.provideService(Npm.Service, npm), - Effect.provideService(EventV2.Service, events), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(Global.Service, global), - Effect.provideService(SkillV2.Service, skill), - Effect.provideService(Reference.Service, references), - Effect.provideService(PluginV2.Service, plugin), - ), - }) - }) - - const boot = Effect.gen(function* () { - yield* add(AgentPlugin.Plugin) - yield* add(CommandPlugin.Plugin) - yield* add(SkillPlugin.Plugin) - for (const item of ProviderPlugins) { - yield* add(item) - } - yield* add(ModelsDevPlugin) - yield* add(ConfigProviderPlugin.Plugin) - yield* add(ConfigAgentPlugin.Plugin) - yield* add(ConfigCommandPlugin.Plugin) - yield* add(ConfigSkillPlugin.Plugin) - yield* add(ConfigReferencePlugin.Plugin) - }).pipe(Effect.withSpan("PluginBoot.boot")) - - yield* boot.pipe( - Effect.exit, - Effect.flatMap((exit) => Deferred.done(done, exit)), - Effect.forkScoped, - ) - - return Service.of({ - wait: () => Deferred.await(done), - }) - }), -) - -export const locationLayer = layer.pipe( - Layer.provideMerge(Integration.locationLayer), - Layer.provideMerge(Catalog.locationLayer), - Layer.provideMerge(CommandV2.locationLayer), - Layer.provideMerge(Config.locationLayer), - Layer.provideMerge(AgentV2.locationLayer), - Layer.provideMerge(SkillV2.locationLayer), - Layer.provideMerge(Reference.locationLayer), -) diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 66386a2128..cbafd68b50 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -1,25 +1,21 @@ export * as CommandPlugin from "./command" +import { define } from "./internal" import { Effect } from "effect" -import { CommandV2 } from "../command" import { Location } from "../location" -import { PluginV2 } from "../plugin" import PROMPT_INITIALIZE from "./command/initialize.txt" import PROMPT_REVIEW from "./command/review.txt" -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("command"), - effect: Effect.gen(function* () { - const command = yield* CommandV2.Service +export const Plugin = define({ + id: "command", + effect: Effect.fn(function* (ctx) { const location = yield* Location.Service - const transform = yield* command.transform() - - yield* transform((editor) => { - editor.update("init", (command) => { + yield* ctx.command.transform((draft) => { + draft.update("init", (command) => { command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory) command.description = "guided AGENTS.md setup" }) - editor.update("review", (command) => { + draft.update("review", (command) => { command.template = PROMPT_REVIEW.replace("${path}", location.project.directory) command.description = "review changes [commit|branch|pr], defaults to uncommitted" command.subtask = true diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts new file mode 100644 index 0000000000..13663e33e5 --- /dev/null +++ b/packages/core/src/plugin/host.ts @@ -0,0 +1,219 @@ +export * as PluginHost from "./host" + +import type { PluginContext as Interface } from "@kilocode/plugin/v2/effect" +import { Effect, Schema } from "effect" +import { AgentV2 } from "../agent" +import { AISDK } from "../aisdk" +import { Catalog } from "../catalog" +import { CommandV2 } from "../command" +import { Credential } from "../credential" +import { Integration } from "../integration" +import { ModelV2 } from "../model" +import { PluginV2 } from "../plugin" +import { ProviderV2 } from "../provider" +import { Reference } from "../reference" +import type { DeepMutable } from "../schema" +import { SkillV2 } from "../skill" + +const mutable = (value: T) => value as DeepMutable + +export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { + const agents = yield* AgentV2.Service + const aisdk = yield* AISDK.Service + const catalog = yield* Catalog.Service + const commands = yield* CommandV2.Service + const integration = yield* Integration.Service + const reference = yield* Reference.Service + const skill = yield* SkillV2.Service + + return { + options: {}, + agent: { + reload: agents.reload, + transform: (callback) => + agents.transform((draft) => + callback({ + list: () => mutable(draft.list()), + get: (id) => mutable(draft.get(AgentV2.ID.make(id))), + default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)), + update: (id, update) => draft.update(AgentV2.ID.make(id), update), + remove: (id) => draft.remove(AgentV2.ID.make(id)), + }), + ), + }, + aisdk: { + sdk: (callback) => + aisdk.hook.sdk((event) => { + const output = { + model: mutable(event.model), + package: event.package, + options: event.options, + sdk: event.sdk, + } + const result = callback(output) + return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), + ) + }), + language: (callback) => + aisdk.hook.language((event) => { + const output = { + model: mutable(event.model), + sdk: event.sdk, + options: event.options, + language: event.language, + } + const result = callback(output) + return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + Effect.tap(() => Effect.sync(() => (event.language = output.language))), + ) + }), + }, + catalog: { + reload: catalog.reload, + transform: (callback) => + catalog.transform((draft) => + callback({ + provider: { + list: () => mutable(draft.provider.list()), + get: (id) => mutable(draft.provider.get(ProviderV2.ID.make(id))), + update: (id, update) => draft.provider.update(ProviderV2.ID.make(id), update), + remove: (id) => draft.provider.remove(ProviderV2.ID.make(id)), + }, + model: { + get: (providerID, modelID) => + mutable(draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID))), + update: (providerID, modelID, update) => + draft.model.update(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID), update), + remove: (providerID, modelID) => + draft.model.remove(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + default: { + get: draft.model.default.get, + set: (providerID, modelID) => + draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + }, + }, + }), + ), + }, + command: { + reload: commands.reload, + transform: commands.transform, + }, + integration: { + reload: integration.reload, + connection: { + active: (id) => integration.connection.active(Integration.ID.make(id)), + resolve: (connection) => + integration.connection.resolve( + connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection, + ), + }, + transform: (callback) => + integration.transform((draft) => + callback({ + list: () => mutable(draft.list()), + get: (id) => mutable(draft.get(Integration.ID.make(id))), + update: (id, update) => draft.update(Integration.ID.make(id), update), + remove: (id) => draft.remove(Integration.ID.make(id)), + method: { + list: (id) => mutable(draft.method.list(Integration.ID.make(id))), + update: (input) => { + if ("authorize" in input) { + const methodID = Integration.MethodID.make(input.method.id) + const refresh = input.refresh + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, id: methodID }, + authorize: (inputs) => + input.authorize(inputs).pipe( + Effect.map((authorization) => { + if (authorization.mode === "auto") { + return { + ...authorization, + callback: authorization.callback.pipe( + Effect.map((credential) => + Credential.OAuth.make({ + ...credential, + methodID: Integration.MethodID.make(credential.methodID), + }), + ), + ), + } + } + return { + ...authorization, + callback: (code: string) => + authorization.callback(code).pipe( + Effect.map((credential) => + Credential.OAuth.make({ + ...credential, + methodID: Integration.MethodID.make(credential.methodID), + }), + ), + ), + } + }), + ), + ...(refresh + ? { + refresh: (value: Credential.OAuth) => + refresh(value).pipe( + Effect.map((next) => + Credential.OAuth.make({ + ...next, + methodID: Integration.MethodID.make(next.methodID), + }), + ), + ), + } + : {}), + ...(input.label ? { label: input.label } : {}), + }) + return + } + if (input.method.type === "env") { + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { type: "env", names: input.method.names }, + }) + return + } + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { type: "key", label: input.method.label }, + }) + }, + remove: (id, method) => + draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)), + }, + }), + ), + }, + plugin: { + add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect), + remove: (id) => plugin.remove(PluginV2.ID.make(id)), + }, + reference: { + reload: reference.reload, + transform: (callback) => + reference.transform((draft) => + callback({ + add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)), + remove: draft.remove, + list: draft.list, + }), + ), + }, + skill: { + reload: skill.reload, + transform: (callback) => + skill.transform((draft) => + callback({ + source: (source) => draft.source(Schema.decodeUnknownSync(SkillV2.Source)(source)), + list: draft.list, + }), + ), + }, + } satisfies Interface +}) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts new file mode 100644 index 0000000000..2b8df0e51a --- /dev/null +++ b/packages/core/src/plugin/internal.ts @@ -0,0 +1,153 @@ +export * as PluginInternal from "./internal" + +import { makeLocationNode } from "../effect/app-node" +import { httpClient } from "../effect/app-node-platform" +import type { PluginContext } from "@kilocode/plugin/v2/effect" +import { Effect, Layer, Scope } from "effect" +import { AgentV2 } from "../agent" +import { Catalog } from "../catalog" +import { CommandV2 } from "../command" +import { Config } from "../config" +import { ConfigAgentPlugin } from "../config/plugin/agent" +import { ConfigCommandPlugin } from "../config/plugin/command" +import { ConfigExternalPlugin } from "../config/plugin/external" +import { ConfigProviderPlugin } from "../config/plugin/provider" +import { ConfigReferencePlugin } from "../config/plugin/reference" +import { ConfigSkillPlugin } from "../config/plugin/skill" +import { EventV2 } from "../event" +import { FileSystem } from "../filesystem" +import { FSUtil } from "../fs-util" +import { Global } from "../global" +import { Integration } from "../integration" +import { Location } from "../location" +import { ModelsDev } from "../models-dev" +import { Npm } from "../npm" +import { PluginV2 } from "../plugin" +import { Reference } from "../reference" +import { SkillV2 } from "../skill" +import { State } from "../state" +import { FetchHttpClient, HttpClient } from "effect/unstable/http" +import { AgentPlugin } from "./agent" +import { CommandPlugin } from "./command" +import { ModelsDevPlugin } from "./models-dev" +import { ProviderPlugins } from "./provider" +import { SkillPlugin } from "./skill" +import { VariantPlugin } from "./variant" + +export type Requirements = + | AgentV2.Service + | Catalog.Service + | CommandV2.Service + | Config.Service + | EventV2.Service + | FileSystem.Service + | FSUtil.Service + | Global.Service + | HttpClient.HttpClient + | Integration.Service + | Location.Service + | ModelsDev.Service + | Npm.Service + | Reference.Service + | SkillV2.Service + +export interface Plugin { + readonly id: string + readonly effect: (context: PluginContext) => Effect.Effect +} + +export function define(plugin: Plugin) { + return plugin +} + +const layer = Layer.effectDiscard( + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const commands = yield* CommandV2.Service + const plugin = yield* PluginV2.Service + const integration = yield* Integration.Service + const agents = yield* AgentV2.Service + const config = yield* Config.Service + const location = yield* Location.Service + const modelsDev = yield* ModelsDev.Service + const npm = yield* Npm.Service + const events = yield* EventV2.Service + const fs = yield* FSUtil.Service + const filesystem = yield* FileSystem.Service + const global = yield* Global.Service + const http = yield* HttpClient.HttpClient + const skill = yield* SkillV2.Service + const reference = yield* Reference.Service + const add = (input: Plugin) => { + const loaded = { + id: input.id, + effect: (context: PluginContext) => + input + .effect(context) + .pipe( + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(CommandV2.Service, commands), + Effect.provideService(Integration.Service, integration), + Effect.provideService(AgentV2.Service, agents), + Effect.provideService(Config.Service, config), + Effect.provideService(Location.Service, location), + Effect.provideService(ModelsDev.Service, modelsDev), + Effect.provideService(Npm.Service, npm), + Effect.provideService(EventV2.Service, events), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(FileSystem.Service, filesystem), + Effect.provideService(Global.Service, global), + Effect.provideService(HttpClient.HttpClient, http), + Effect.provideService(SkillV2.Service, skill), + Effect.provideService(Reference.Service, reference), + ), + } + return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect) + } + + yield* State.batch( + Effect.gen(function* () { + yield* add(ConfigReferencePlugin.Plugin) + yield* add(AgentPlugin.Plugin) + yield* add(CommandPlugin.Plugin) + yield* add(SkillPlugin.Plugin) + yield* add(ModelsDevPlugin) + yield* add(ConfigAgentPlugin.Plugin) + yield* add(ConfigCommandPlugin.Plugin) + yield* add(ConfigSkillPlugin.Plugin) + for (const item of ProviderPlugins) yield* add(item) + yield* add(ConfigExternalPlugin.Plugin) + yield* add(ConfigProviderPlugin.Plugin) + yield* add(VariantPlugin.Plugin) + }), + ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) + }), +) + +export const locationLayer = layer.pipe( + Layer.provideMerge(Config.locationLayer), + Layer.provideMerge(FetchHttpClient.layer), +) + +export const node = makeLocationNode({ + name: "plugin-internal", + layer, + deps: [ + Catalog.node, + CommandV2.node, + PluginV2.node, + Integration.node, + AgentV2.node, + Config.node, + Location.node, + ModelsDev.node, + Npm.node, + EventV2.node, + FSUtil.node, + FileSystem.node, + Global.node, + httpClient, + SkillV2.node, + Reference.node, + ], +}) diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index a212d013ad..eda21e39b0 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,19 +1,16 @@ -import { DateTime, Effect, Scope, Stream } from "effect" -import { Catalog } from "../catalog" -import { Integration } from "../integration" +import { define } from "./internal" +import type { ModelV2Info } from "@kilocode/sdk/v2/types" +import { Effect, Stream } from "effect" import { EventV2 } from "../event" -import { ModelV2 } from "../model" -import { ModelRequest } from "../model-request" import { ModelsDev } from "../models-dev" -import { PluginV2 } from "../plugin" import { ProviderV2 } from "../provider" function released(date: string) { const time = Date.parse(date) - return DateTime.makeUnsafe(Number.isFinite(time) ? time : 0) + return Number.isFinite(time) ? time : 0 } -function cost(input: ModelsDev.Model["cost"]) { +function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] { const base = { input: input?.input ?? 0, output: input?.output ?? 0, @@ -22,51 +19,114 @@ function cost(input: ModelsDev.Model["cost"]) { write: input?.cache_write ?? 0, }, } - if (!input?.context_over_200k) return [base] return [ base, - { - tier: { - type: "context" as const, - size: 200_000, - }, - input: input.context_over_200k.input, - output: input.context_over_200k.output, + ...(input?.tiers?.map((item) => ({ + tier: item.tier, + input: item.input, + output: item.output, cache: { - read: input.context_over_200k.cache_read ?? 0, - write: input.context_over_200k.cache_write ?? 0, + read: item.cache_read ?? 0, + write: item.cache_write ?? 0, }, - }, + })) ?? []), + ...(input?.context_over_200k + ? [ + { + tier: { + type: "context" as const, + size: 200_000, + }, + input: input.context_over_200k.input, + output: input.context_over_200k.output, + cache: { + read: input.context_over_200k.cache_read ?? 0, + write: input.context_over_200k.cache_write ?? 0, + }, + }, + ] + : []), ] } -function variants(model: ModelsDev.Model, packageName?: string) { - return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => { - const request = ModelRequest.normalizeAiSdkOptions(packageName, item.provider?.body ?? {}) - return { - id: ModelV2.VariantID.make(id), - headers: { ...(item.provider?.headers ?? {}) }, - ...request, - } +function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] | undefined) { + if (!override) return base + const next = cost(override) + const [baseDefault, ...baseTiers] = base + const [nextDefault, ...nextTiers] = next + const tierKey = (item: ModelV2Info["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}` + const merge = (left: ModelV2Info["cost"][number], right: ModelV2Info["cost"][number]) => ({ + ...left, + ...right, + tier: right.tier ?? left.tier, + cache: { ...left.cache, ...right.cache }, }) + const tiers = new Map(baseTiers.map((item) => [tierKey(item), item])) + for (const item of nextTiers) { + const current = tiers.get(tierKey(item)) + tiers.set(tierKey(item), current ? merge(current, item) : item) + } + return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()] } -export const ModelsDevPlugin = PluginV2.define({ - id: PluginV2.ID.make("models-dev"), - effect: Effect.gen(function* () { - const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service +function modeName(model: ModelsDev.Model, mode: string) { + return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` +} + +function applyModel( + draft: ModelV2Info, + model: ModelsDev.Model, + input: { + readonly name?: string + readonly cost?: ModelV2Info["cost"] + readonly request?: NonNullable["modes"]>[string]["provider"] + } = {}, +) { + draft.name = input.name ?? model.name + draft.family = model.family + draft.api = model.provider?.npm + ? { + id: model.id, + type: "aisdk", + package: model.provider.npm, + url: model.provider.api, + } + : { + id: model.id, + type: "native", + url: model.provider?.api, + settings: {}, + } + draft.capabilities = { + tools: model.tool_call, + input: [...(model.modalities?.input ?? [])], + output: [...(model.modalities?.output ?? [])], + } + draft.variants = [] + draft.time.released = released(model.release_date) + draft.cost = input.cost ?? cost(model.cost) + draft.status = model.status ?? "active" + draft.enabled = true + draft.limit = { + context: model.limit.context, + input: model.limit.input, + output: model.limit.output, + } + Object.assign(draft.request.headers, input.request?.headers ?? {}) + Object.assign(draft.request.body, input.request?.body ?? {}) +} + +export const ModelsDevPlugin = define({ + id: "models-dev", + effect: Effect.fn(function* (ctx) { const modelsDev = yield* ModelsDev.Service const events = yield* EventV2.Service - const scope = yield* Scope.Scope - const transform = yield* catalog.transform() - const integrationTransform = yield* integrations.transform() - const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () { - const data = yield* modelsDev.get() - yield* integrationTransform((integrations) => { + yield* ctx.integration.transform( + Effect.fn(function* (integrations) { + const data = yield* modelsDev.get() for (const item of Object.values(data)) { if (item.env.length === 0) continue - const integrationID = Integration.ID.make(item.id) + const integrationID = item.id integrations.update(integrationID, (integration) => (integration.name = item.name)) integrations.method.update({ integrationID, @@ -77,8 +137,11 @@ export const ModelsDevPlugin = PluginV2.define({ method: { type: "env", names: [...item.env] }, }) } - }) - yield* transform((catalog) => { + }), + ) + yield* ctx.catalog.transform( + Effect.fn(function* (catalog) { + const data = yield* modelsDev.get() for (const item of Object.values(data)) { const providerID = ProviderV2.ID.make(item.id) catalog.provider.update(providerID, (provider) => { @@ -97,46 +160,23 @@ export const ModelsDevPlugin = PluginV2.define({ }) for (const model of Object.values(item.models)) { - const modelID = ModelV2.ID.make(model.id) - catalog.model.update(providerID, modelID, (draft) => { - draft.name = model.name - draft.family = model.family ? ModelV2.Family.make(model.family) : undefined - draft.api = model.provider?.npm - ? { - id: draft.api.id, - type: "aisdk", - package: model.provider?.npm, - url: model.provider.api, - } - : { - id: draft.api.id, - type: "native", - url: model.provider?.api, - settings: {}, - } - draft.capabilities = { - tools: model.tool_call, - input: [...(model.modalities?.input ?? [])], - output: [...(model.modalities?.output ?? [])], - } - draft.variants = variants(model, model.provider?.npm ?? item.npm) - draft.time.released = released(model.release_date) - draft.cost = cost(model.cost) - draft.status = model.status ?? "active" - draft.enabled = true - draft.limit = { - context: model.limit.context, - input: model.limit.input, - output: model.limit.output, - } - }) + const baseCost = cost(model.cost) + catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost })) + for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { + catalog.model.update(providerID, `${model.id}-${mode}`, (draft) => + applyModel(draft, model, { + name: modeName(model, mode), + cost: mergeCost(baseCost, options.cost), + request: options.provider, + }), + ) + } } } - }) - }) - yield* refresh() + }), + ) yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( - Stream.runForEach(() => refresh()), + Stream.runForEach(() => ctx.integration.reload().pipe(Effect.andThen(ctx.catalog.reload()))), Effect.forkScoped({ startImmediately: true }), ) }), diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts new file mode 100644 index 0000000000..01928b4165 --- /dev/null +++ b/packages/core/src/plugin/promise.ts @@ -0,0 +1,93 @@ +export * as PluginPromise from "./promise" + +import { define } from "@kilocode/plugin/v2/effect" +import type { Plugin, PluginContext, Registration } from "@kilocode/plugin/v2/promise" +import { Effect, Scope } from "effect" + +// The Effect host hands back this registration shape; mirror it structurally so +// we do not have to alias the Effect package's `Registration` against the Promise one. +type HostRegistration = { readonly dispose: Effect.Effect } + +/** + * Adapts a Promise plugin into an Effect plugin so the existing Effect-only + * loader (`PluginV2` / `PluginInternal`) can run it unchanged. + * + * Hook registrations created during the async `setup` attach to the plugin's + * scope, so unloading the plugin disposes them. The captured fiber context + * preserves boot-time batching, so Promise-plugin transforms still coalesce + * into one reload per domain. + */ +export function fromPromise(plugin: Plugin) { + return define({ + id: plugin.id, + effect: (host) => + Effect.gen(function* () { + const scope = yield* Scope.Scope + const context = yield* Effect.context() + + // Run a hook registration on the plugin scope and resolve once it is registered. + const register = (effect: Effect.Effect): Promise => + Effect.runPromiseWith(context)(Scope.provide(scope)(effect)).then((registration) => ({ + dispose: () => Effect.runPromiseWith(context)(registration.dispose), + })) + + const run = (effect: Effect.Effect) => Effect.runPromiseWith(context)(effect) + + const transform = + (domain: { + transform: ( + callback: (draft: Draft) => Effect.Effect | void, + ) => Effect.Effect + }) => + (callback: (draft: Draft) => Promise | void) => + register(domain.transform((draft) => Effect.promise(() => Promise.resolve(callback(draft))))) + + const context2: PluginContext = { + options: host.options, + agent: { + transform: transform(host.agent), + reload: () => run(host.agent.reload()), + }, + aisdk: { + sdk: (callback) => + register(host.aisdk.sdk((event) => Effect.promise(() => Promise.resolve(callback(event))))), + language: (callback) => + register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))), + }, + catalog: { + transform: transform(host.catalog), + reload: () => run(host.catalog.reload()), + }, + command: { + transform: transform(host.command), + reload: () => run(host.command.reload()), + }, + integration: { + transform: transform(host.integration), + reload: () => run(host.integration.reload()), + connection: { + active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)), + resolve: (connection) => Effect.runPromiseWith(context)(host.integration.connection.resolve(connection)), + }, + }, + plugin: { + add: (input) => { + const child = fromPromise(input) + return run(host.plugin.add(child)) + }, + remove: (id) => run(host.plugin.remove(id)), + }, + reference: { + transform: transform(host.reference), + reload: () => run(host.reference.reload()), + }, + skill: { + transform: transform(host.skill), + reload: () => run(host.skill.reload()), + }, + } + + yield* Effect.promise(() => Promise.resolve(plugin.setup(context2))) + }), + }) +} diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index ea3939b750..1749b474ed 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -30,8 +30,10 @@ import { VercelPlugin } from "./provider/vercel" import { VenicePlugin } from "./provider/venice" import { XAIPlugin } from "./provider/xai" import { ZenmuxPlugin } from "./provider/zenmux" +import type { PluginInternal } from "./internal" +import type { Scope } from "effect" -export const ProviderPlugins = [ +export const ProviderPlugins: PluginInternal.Plugin[] = [ AlibabaPlugin, AmazonBedrockPlugin, AnthropicPlugin, diff --git a/packages/core/src/plugin/provider/alibaba.ts b/packages/core/src/plugin/provider/alibaba.ts index fa5c0a91cf..c5c4be0d0b 100644 --- a/packages/core/src/plugin/provider/alibaba.ts +++ b/packages/core/src/plugin/provider/alibaba.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const AlibabaPlugin = PluginV2.define({ - id: PluginV2.ID.make("alibaba"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const AlibabaPlugin = define({ + id: "alibaba", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/alibaba") return const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba")) evt.sdk = mod.createAlibaba(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index 9c7fd65665..0995cf1c17 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" type MantleSDK = { @@ -59,11 +59,11 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) { return sdk.responses(modelID) } -export const AmazonBedrockPlugin = PluginV2.define({ - id: PluginV2.ID.make("amazon-bedrock"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AmazonBedrockPlugin = define({ + id: "amazon-bedrock", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue @@ -77,7 +77,9 @@ export const AmazonBedrockPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return const options = { ...evt.options } const profile = typeof options.profile === "string" ? options.profile : process.env.AWS_PROFILE @@ -108,7 +110,9 @@ export const AmazonBedrockPlugin = PluginV2.define({ const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock")) evt.sdk = mod.createAmazonBedrock(options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") { evt.language = selectMantleModel(evt.sdk, evt.model.api.id) @@ -117,6 +121,6 @@ export const AmazonBedrockPlugin = PluginV2.define({ const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION evt.language = evt.sdk.languageModel(resolveModelID(evt.model.api.id, region)) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index 9bd69fe036..cf883a0687 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const AnthropicPlugin = PluginV2.define({ - id: PluginV2.ID.make("anthropic"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AnthropicPlugin = define({ + id: "anthropic", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/anthropic") continue @@ -15,11 +15,13 @@ export const AnthropicPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) evt.sdk = mod.createAnthropic(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 173fd36621..2e1f9d9b48 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" function selectLanguage(sdk: any, modelID: string, useChat: boolean) { @@ -10,11 +10,11 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) { return sdk.languageModel(modelID) } -export const AzurePlugin = PluginV2.define({ - id: PluginV2.ID.make("azure"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AzurePlugin = define({ + id: "azure", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/azure") continue @@ -27,7 +27,9 @@ export const AzurePlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/azure") return if (evt.model.providerID === ProviderV2.ID.azure) { if ( @@ -43,19 +45,21 @@ export const AzurePlugin = PluginV2.define({ const mod = yield* Effect.promise(() => import("@ai-sdk/azure")) evt.sdk = mod.createAzure(evt.options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.azure) return evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) }), - } + ) }), }) -export const AzureCognitiveServicesPlugin = PluginV2.define({ - id: PluginV2.ID.make("azure-cognitive-services"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AzureCognitiveServicesPlugin = define({ + id: "azure-cognitive-services", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME if (!resourceName) return for (const item of evt.provider.list()) { @@ -67,10 +71,12 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({ }) } }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index f871943687..0fd651160f 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -1,24 +1,26 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const CerebrasPlugin = PluginV2.define({ - id: PluginV2.ID.make("cerebras"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (ctx) { - for (const item of ctx.provider.list()) { +export const CerebrasPlugin = define({ + id: "cerebras", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { + for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/cerebras") continue - ctx.provider.update(item.provider.id, (provider) => { + evt.provider.update(item.provider.id, (provider) => { provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cerebras") return const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras")) evt.sdk = mod.createCerebras(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index ba7856b635..d416f6f19d 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -1,13 +1,13 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect, Option, Schema } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const CloudflareAIGatewayPlugin = PluginV2.define({ - id: PluginV2.ID.make("cloudflare-ai-gateway"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const CloudflareAIGatewayPlugin = define({ + id: "cloudflare-ai-gateway", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "ai-gateway-provider") return if (evt.options.baseURL) return @@ -31,7 +31,7 @@ export const CloudflareAIGatewayPlugin = PluginV2.define({ }, } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 10f3f5200a..1a1c533eb5 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -1,16 +1,16 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" const providerID = ProviderV2.ID.make("cloudflare-workers-ai") -export const CloudflareWorkersAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("cloudflare-workers-ai"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const CloudflareWorkersAIPlugin = define({ + id: "cloudflare-workers-ai", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { const item = evt.provider.get(providerID) if (!item) return evt.provider.update(item.provider.id, (provider) => { @@ -20,7 +20,9 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({ if (accountId) provider.api.url = workersEndpoint(accountId) }) }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return if (evt.package !== "@ai-sdk/openai-compatible") return @@ -34,11 +36,13 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({ }) as any, ) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return evt.language = evt.sdk.languageModel(evt.model.api.id) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cohere.ts b/packages/core/src/plugin/provider/cohere.ts index 991c370d17..0ca0708577 100644 --- a/packages/core/src/plugin/provider/cohere.ts +++ b/packages/core/src/plugin/provider/cohere.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const CoherePlugin = PluginV2.define({ - id: PluginV2.ID.make("cohere"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const CoherePlugin = define({ + id: "cohere", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cohere") return const mod = yield* Effect.promise(() => import("@ai-sdk/cohere")) evt.sdk = mod.createCohere(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/deepinfra.ts b/packages/core/src/plugin/provider/deepinfra.ts index bbd42f6e28..1b23e08ba4 100644 --- a/packages/core/src/plugin/provider/deepinfra.ts +++ b/packages/core/src/plugin/provider/deepinfra.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const DeepInfraPlugin = PluginV2.define({ - id: PluginV2.ID.make("deepinfra"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const DeepInfraPlugin = define({ + id: "deepinfra", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/deepinfra") return const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra")) evt.sdk = mod.createDeepInfra(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/dynamic.ts b/packages/core/src/plugin/provider/dynamic.ts index e5abc7009e..c84a6ed51f 100644 --- a/packages/core/src/plugin/provider/dynamic.ts +++ b/packages/core/src/plugin/provider/dynamic.ts @@ -1,19 +1,19 @@ -import { Npm } from "../../npm" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { pathToFileURL } from "url" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" +import { Npm } from "../../npm" -export const DynamicProviderPlugin = PluginV2.define({ - id: PluginV2.ID.make("dynamic-provider"), - effect: Effect.gen(function* () { +export const DynamicProviderPlugin = define({ + id: "dynamic-provider", + effect: Effect.fn(function* (ctx) { const npm = yield* Npm.Service - return { - "aisdk.sdk": Effect.fn(function* (evt) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.sdk) return const installedPath = evt.package.startsWith("file://") ? evt.package - : Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint) + : (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) const mod = yield* Effect.promise(async () => { @@ -26,6 +26,6 @@ export const DynamicProviderPlugin = PluginV2.define({ evt.sdk = mod[match](evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/gateway.ts b/packages/core/src/plugin/provider/gateway.ts index 5b08ad9ef5..f097dcaca3 100644 --- a/packages/core/src/plugin/provider/gateway.ts +++ b/packages/core/src/plugin/provider/gateway.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const GatewayPlugin = PluginV2.define({ - id: PluginV2.ID.make("gateway"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GatewayPlugin = define({ + id: "gateway", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/gateway") return const mod = yield* Effect.promise(() => import("@ai-sdk/gateway")) evt.sdk = mod.createGateway(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 1fc7c0c799..682579d7a9 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" function shouldUseResponses(modelID: string) { @@ -11,16 +11,29 @@ function shouldUseResponses(modelID: string) { return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini") } -export const GithubCopilotPlugin = PluginV2.define({ - id: PluginV2.ID.make("github-copilot"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GithubCopilotPlugin = define({ + id: "github-copilot", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { + const item = evt.provider.get(ProviderV2.ID.githubCopilot) + if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return + evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { + // This chat-only alias conflicts with the Copilot GPT-5 Responses route, + // so hide it only for Copilot rather than for every provider catalog. + model.enabled = false + }) + }), + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/github-copilot") return const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) evt.sdk = mod.createOpenaiCompatible(evt.options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) { evt.language = evt.sdk.languageModel(evt.model.api.id) @@ -30,15 +43,6 @@ export const GithubCopilotPlugin = PluginV2.define({ ? evt.sdk.responses(evt.model.api.id) : evt.sdk.chat(evt.model.api.id) }), - "catalog.transform": Effect.fn(function* (evt) { - const item = evt.provider.get(ProviderV2.ID.githubCopilot) - if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return - evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { - // This chat-only alias conflicts with the Copilot GPT-5 Responses route, - // so hide it only for Copilot rather than for every provider catalog. - model.enabled = false - }) - }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 9de090a95d..8723cdaac2 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -1,14 +1,14 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" -export const GitLabPlugin = PluginV2.define({ - id: PluginV2.ID.make("gitlab"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GitLabPlugin = define({ + id: "gitlab", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "gitlab-ai-provider") return const mod = yield* Effect.promise(() => import("gitlab-ai-provider")) evt.sdk = mod.createGitLab({ @@ -30,7 +30,9 @@ export const GitLabPlugin = PluginV2.define({ }, }) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.gitlab) return const featureFlags = typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {} @@ -58,6 +60,6 @@ export const GitLabPlugin = PluginV2.define({ featureFlags, }) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index a7168d59ad..4e643c9f51 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" function resolveProject(options: Record) { @@ -54,11 +54,11 @@ function authFetch(fetchWithRuntimeOptions?: unknown) { } } -export const GoogleVertexPlugin = PluginV2.define({ - id: PluginV2.ID.make("google-vertex"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const GoogleVertexPlugin = define({ + id: "google-vertex", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if ( @@ -83,7 +83,9 @@ export const GoogleVertexPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { evt.options.fetch = authFetch(evt.options.fetch) return @@ -100,19 +102,21 @@ export const GoogleVertexPlugin = PluginV2.define({ location, }) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.googleVertex) return evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) }), - } + ) }), }) -export const GoogleVertexAnthropicPlugin = PluginV2.define({ - id: PluginV2.ID.make("google-vertex-anthropic"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const GoogleVertexAnthropicPlugin = define({ + id: "google-vertex-anthropic", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue @@ -132,7 +136,9 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google-vertex/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) const project = @@ -156,10 +162,12 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({ : {}), }) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/google.ts b/packages/core/src/plugin/provider/google.ts index 47e29c6b5d..476af5b912 100644 --- a/packages/core/src/plugin/provider/google.ts +++ b/packages/core/src/plugin/provider/google.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const GooglePlugin = PluginV2.define({ - id: PluginV2.ID.make("google"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GooglePlugin = define({ + id: "google", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google") return const mod = yield* Effect.promise(() => import("@ai-sdk/google")) evt.sdk = mod.createGoogleGenerativeAI(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/groq.ts b/packages/core/src/plugin/provider/groq.ts index f2052afd1a..0bddb44309 100644 --- a/packages/core/src/plugin/provider/groq.ts +++ b/packages/core/src/plugin/provider/groq.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const GroqPlugin = PluginV2.define({ - id: PluginV2.ID.make("groq"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GroqPlugin = define({ + id: "groq", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/groq") return const mod = yield* Effect.promise(() => import("@ai-sdk/groq")) evt.sdk = mod.createGroq(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index e293a66dad..6ee6670ee5 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const KiloPlugin = PluginV2.define({ - id: PluginV2.ID.make("kilo"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const KiloPlugin = define({ + id: "kilo", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue @@ -16,6 +16,6 @@ export const KiloPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index 613f589ba5..eafc5edd6c 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -1,19 +1,19 @@ import { Effect } from "effect" +import { define } from "../internal" import { Integration } from "../../integration" -import { PluginV2 } from "../../plugin" -export const LLMGatewayPlugin = PluginV2.define({ - id: PluginV2.ID.make("llmgateway"), - effect: Effect.gen(function* () { +export const LLMGatewayPlugin = define({ + id: "llmgateway", + effect: Effect.fn(function* (ctx) { const integrations = yield* Integration.Service - return { - "catalog.transform": Effect.fn(function* (evt) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.disabled) continue - if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue + if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue evt.provider.update(item.provider.id, (provider) => { provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" provider.request.headers["X-Title"] = "opencode" @@ -21,6 +21,6 @@ export const LLMGatewayPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/mistral.ts b/packages/core/src/plugin/provider/mistral.ts index e7f0decb79..a731975659 100644 --- a/packages/core/src/plugin/provider/mistral.ts +++ b/packages/core/src/plugin/provider/mistral.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const MistralPlugin = PluginV2.define({ - id: PluginV2.ID.make("mistral"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const MistralPlugin = define({ + id: "mistral", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/mistral") return const mod = yield* Effect.promise(() => import("@ai-sdk/mistral")) evt.sdk = mod.createMistral(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 837fce2c09..449599727c 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const NvidiaPlugin = PluginV2.define({ - id: PluginV2.ID.make("nvidia"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const NvidiaPlugin = define({ + id: "nvidia", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue @@ -17,6 +17,6 @@ export const NvidiaPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/openai-auth.ts b/packages/core/src/plugin/provider/openai-auth.ts deleted file mode 100644 index a654f56e62..0000000000 --- a/packages/core/src/plugin/provider/openai-auth.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { createServer } from "node:http" -import { Deferred, Effect } from "effect" -import { Integration } from "../../integration" -import { Credential } from "../../credential" -import { InstallationVersion } from "../../installation/version" - -const clientID = "app_EMoamEEZ73f0CkXaXp7hrann" -const issuer = "https://auth.openai.com" -const callbackPort = 1455 -const pollingSafetyMargin = 3000 - -type Pkce = { - verifier: string - challenge: string -} - -type TokenResponse = { - id_token: string - access_token: string - refresh_token: string - expires_in?: number -} - -type Claims = { - chatgpt_account_id?: string - organizations?: Array<{ id: string }> - "https://api.openai.com/auth"?: { chatgpt_account_id?: string } -} - -const browserMethodID = Integration.MethodID.make("chatgpt-browser") -const headlessMethodID = Integration.MethodID.make("chatgpt-headless") - -export const browser = { - integrationID: Integration.ID.make("openai"), - method: { - id: browserMethodID, - type: "oauth", - label: "ChatGPT Pro/Plus (browser)", - }, - authorize: () => - Effect.gen(function* () { - const pkce = yield* Effect.promise(generatePKCE) - const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) - const code = yield* Deferred.make() - const redirect = `http://localhost:${callbackPort}/auth/callback` - const server = createServer((request, response) => { - const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`) - if (url.pathname !== "/auth/callback") { - 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(errorPage(error)) - 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(errorPage(message)) - return - } - Effect.runFork(Deferred.succeed(code, value)) - response.writeHead(200, { "Content-Type": "text/html" }).end(successPage) - }) - yield* Effect.callback((resume) => { - server.once("error", (error) => resume(Effect.fail(error))) - server.listen(callbackPort, "localhost", () => resume(Effect.void)) - }) - yield* Effect.addFinalizer(() => - Effect.sync(() => { - server.close() - }), - ) - return { - mode: "auto" as const, - url: authorizeURL(redirect, pkce, state), - instructions: "Complete authorization in your browser. This window will close automatically.", - callback: Deferred.await(code).pipe( - Effect.flatMap((value) => exchange(value, redirect, pkce)), - Effect.map((tokens) => credential(browserMethodID, tokens)), - ), - } - }), - refresh: (value) => refresh(value), -} satisfies Integration.OAuthImplementation - -export const headless = { - integrationID: Integration.ID.make("openai"), - method: { - id: headlessMethodID, - type: "oauth", - label: "ChatGPT Pro/Plus (headless)", - }, - authorize: () => - Effect.gen(function* () { - const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>( - `${issuer}/api/accounts/deviceauth/usercode`, - { - method: "POST", - headers: headers("application/json"), - body: JSON.stringify({ client_id: clientID }), - }, - ) - const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000 - return { - mode: "auto" as const, - url: `${issuer}/codex/device`, - instructions: `Enter code: ${device.user_code}`, - callback: Effect.gen(function* () { - while (true) { - const response = yield* Effect.tryPromise({ - try: (signal) => - fetch(`${issuer}/api/accounts/deviceauth/token`, { - method: "POST", - headers: headers("application/json"), - body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }), - signal, - }), - catch: (cause) => cause, - }) - if (response.ok) { - const data = (yield* Effect.promise(() => response.json())) as { - authorization_code: string - code_verifier: string - } - return credential( - headlessMethodID, - yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, { - verifier: data.code_verifier, - challenge: "", - }), - ) - } - if (response.status !== 403 && response.status !== 404) { - return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`)) - } - yield* Effect.sleep(interval + pollingSafetyMargin) - } - }), - } - }), - refresh: (value) => refresh(value), -} satisfies Integration.OAuthImplementation - -function headers(contentType: string) { - return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` } -} - -function exchange(code: string, redirect: string, pkce: Pkce) { - return request(`${issuer}/oauth/token`, { - method: "POST", - headers: headers("application/x-www-form-urlencoded"), - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: redirect, - client_id: clientID, - code_verifier: pkce.verifier, - }).toString(), - }) -} - -function refresh(value: Credential.OAuth) { - return request(`${issuer}/oauth/token`, { - method: "POST", - headers: headers("application/x-www-form-urlencoded"), - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: value.refresh, - client_id: clientID, - }).toString(), - }).pipe( - Effect.map((tokens) => { - const next = credential(value.methodID, tokens) - return new Credential.OAuth({ - ...next, - metadata: next.metadata ?? value.metadata, - }) - }), - ) -} - -function request
(url: string, init: RequestInit) { - return Effect.tryPromise({ - try: async (signal) => { - const response = await fetch(url, { ...init, signal }) - if (!response.ok) throw new Error(`Request failed: ${response.status}`) - return response.json() as Promise - }, - catch: (cause) => cause, - }) -} - -function credential(methodID: Integration.MethodID, tokens: TokenResponse) { - const accountID = extractAccountID(tokens) - return new Credential.OAuth({ - type: "oauth", - methodID, - refresh: tokens.refresh_token, - access: tokens.access_token, - expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, - metadata: accountID ? { accountID } : undefined, - }) -} - -async function generatePKCE(): Promise { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" - const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("") - const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))) - return { verifier, challenge } -} - -function base64UrlEncode(buffer: ArrayBuffer) { - return Buffer.from(buffer).toString("base64url") -} - -function authorizeURL(redirect: string, pkce: Pkce, state: string) { - return `${issuer}/oauth/authorize?${new URLSearchParams({ - response_type: "code", - client_id: clientID, - redirect_uri: redirect, - scope: "openid profile email offline_access", - code_challenge: pkce.challenge, - code_challenge_method: "S256", - id_token_add_organizations: "true", - codex_cli_simplified_flow: "true", - state, - originator: "opencode", - })}` -} - -function extractAccountID(tokens: TokenResponse) { - return claim(tokens.id_token) ?? claim(tokens.access_token) -} - -function claim(token: string) { - const part = token.split(".")[1] - if (!part) return - try { - const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims - return ( - claims.chatgpt_account_id ?? - claims["https://api.openai.com/auth"]?.chatgpt_account_id ?? - claims.organizations?.[0]?.id - ) - } catch { - return - } -} - -const successPage = - "OpenCode

Authorization successful

You can close this window.

" -const errorPage = (message: string) => - `OpenCode

Authorization failed

${message.replace(/[&<>"']/g, "")}

` diff --git a/packages/core/src/plugin/provider/openai-compatible.ts b/packages/core/src/plugin/provider/openai-compatible.ts index 76c3373706..d602ed0ff9 100644 --- a/packages/core/src/plugin/provider/openai-compatible.ts +++ b/packages/core/src/plugin/provider/openai-compatible.ts @@ -1,17 +1,17 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const OpenAICompatiblePlugin = PluginV2.define({ - id: PluginV2.ID.make("openai-compatible"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const OpenAICompatiblePlugin = define({ + id: "openai-compatible", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.sdk) return if (!evt.package.includes("@ai-sdk/openai-compatible")) return if (evt.options.includeUsage !== false) evt.options.includeUsage = true const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) evt.sdk = mod.createOpenAICompatible(evt.options as any) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index d58bd784f5..9b009cff2d 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -1,29 +1,165 @@ -import { Effect } from "effect" -import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" -import { ProviderV2 } from "../../provider" +import { createServer } from "node:http" +import type { IntegrationOAuthMethodRegistration } from "@kilocode/plugin/v2/effect/integration" +import { define } from "@kilocode/plugin/v2/effect/plugin" +import { Deferred, Effect } from "effect" +import type { Scope } from "effect" +import { Credential } from "../../credential" +import { InstallationVersion } from "../../installation/version" import { Integration } from "../../integration" -import { browser, headless } from "./openai-auth" +import { ModelV2 } from "../../model" +import { OauthCallbackPage } from "../../oauth/page" +import { ProviderV2 } from "../../provider" +import type { PluginInternal } from "../internal" -export const OpenAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("openai"), - effect: Effect.gen(function* () { - const integrations = yield* Integration.Service - yield* integrations.update((editor) => { - editor.method.update(browser) - editor.method.update(headless) +const clientID = "app_EMoamEEZ73f0CkXaXp7hrann" +const issuer = "https://auth.openai.com" +const callbackPort = 1455 +const pollingSafetyMargin = 3000 +const browserMethodID = Integration.MethodID.make("chatgpt-browser") +const headlessMethodID = Integration.MethodID.make("chatgpt-headless") + +type Pkce = { + verifier: string + challenge: string +} + +type TokenResponse = { + id_token: string + access_token: string + refresh_token: string + expires_in?: number +} + +type Claims = { + chatgpt_account_id?: string + organizations?: Array<{ id: string }> + "https://api.openai.com/auth"?: { chatgpt_account_id?: string } +} + +const browser = { + integrationID: Integration.ID.make("openai"), + method: { + id: browserMethodID, + type: "oauth", + label: "ChatGPT Pro/Plus (browser)", + }, + authorize: () => + Effect.gen(function* () { + const pkce = yield* Effect.promise(generatePKCE) + const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) + const code = yield* Deferred.make() + const redirect = `http://localhost:${callbackPort}/auth/callback` + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`) + if (url.pathname !== "/auth/callback") { + 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: "ChatGPT" })) + 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: "ChatGPT" })) + return + } + Effect.runFork(Deferred.succeed(code, value)) + response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "ChatGPT" })) + }) + yield* Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(callbackPort, "localhost", () => resume(Effect.void)) + }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())) + return { + mode: "auto" as const, + url: authorizeURL(redirect, pkce, state), + instructions: "Complete authorization in your browser. This window will close automatically.", + callback: Deferred.await(code).pipe( + Effect.flatMap((value) => exchange(value, redirect, pkce)), + Effect.map((tokens) => credential(browserMethodID, tokens)), + ), + } + }), + refresh: (value) => refresh(browserMethodID, value), +} satisfies IntegrationOAuthMethodRegistration + +const headless = { + integrationID: Integration.ID.make("openai"), + method: { + id: headlessMethodID, + type: "oauth", + label: "ChatGPT Pro/Plus (headless)", + }, + authorize: () => + Effect.gen(function* () { + const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>( + `${issuer}/api/accounts/deviceauth/usercode`, + { + method: "POST", + headers: headers("application/json"), + body: JSON.stringify({ client_id: clientID }), + }, + ) + const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000 + return { + mode: "auto" as const, + url: `${issuer}/codex/device`, + instructions: `Enter code: ${device.user_code}`, + callback: Effect.gen(function* () { + while (true) { + const response = yield* Effect.tryPromise({ + try: (signal) => + fetch(`${issuer}/api/accounts/deviceauth/token`, { + method: "POST", + headers: headers("application/json"), + body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }), + signal, + }), + catch: (cause) => cause, + }) + if (response.ok) { + const data = (yield* Effect.promise(() => response.json())) as { + authorization_code: string + code_verifier: string + } + return credential( + headlessMethodID, + yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, { + verifier: data.code_verifier, + challenge: "", + }), + ) + } + if (response.status !== 403 && response.status !== 404) { + return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`)) + } + yield* Effect.sleep(interval + pollingSafetyMargin) + } + }), + } + }), + refresh: (value) => refresh(headlessMethodID, value), +} satisfies IntegrationOAuthMethodRegistration + +export const OpenAIPlugin = define({ + id: "openai", + effect: Effect.fn(function* (ctx) { + yield* ctx.integration.transform((draft) => { + draft.method.update(browser) + draft.method.update(headless) }) - return { - "aisdk.sdk": Effect.fn(function* (evt) { - if (evt.package !== "@ai-sdk/openai") return - const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) - evt.sdk = mod.createOpenAI(evt.options) - }), - "aisdk.language": Effect.fn(function* (evt) { - if (evt.model.providerID !== ProviderV2.ID.openai) return - evt.language = evt.sdk.responses(evt.model.api.id) - }), - "catalog.transform": Effect.fn(function* (evt) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai") continue @@ -35,6 +171,122 @@ export const OpenAIPlugin = PluginV2.define({ }) } }), - } + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/openai") return + const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) + evt.sdk = mod.createOpenAI(evt.options) + }), + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.openai) return + evt.language = evt.sdk.responses(evt.model.api.id) + }), + ) }), -}) +} satisfies PluginInternal.Plugin) + +function headers(contentType: string) { + return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` } +} + +function exchange(code: string, redirect: string, pkce: Pkce) { + return request(`${issuer}/oauth/token`, { + method: "POST", + headers: headers("application/x-www-form-urlencoded"), + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirect, + client_id: clientID, + code_verifier: pkce.verifier, + }).toString(), + }) +} + +function refresh(methodID: Integration.MethodID, value: Pick) { + return request(`${issuer}/oauth/token`, { + method: "POST", + headers: headers("application/x-www-form-urlencoded"), + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: value.refresh, + client_id: clientID, + }).toString(), + }).pipe( + Effect.map((tokens) => { + const next = credential(methodID, tokens) + return Credential.OAuth.make({ ...next, metadata: next.metadata ?? value.metadata }) + }), + ) +} + +function request
(url: string, init: RequestInit) { + return Effect.tryPromise({ + try: async (signal) => { + const response = await fetch(url, { ...init, signal }) + if (!response.ok) throw new Error(`Request failed: ${response.status}`) + return response.json() as Promise + }, + catch: (cause) => cause, + }) +} + +function credential(methodID: Integration.MethodID, tokens: TokenResponse) { + const accountID = extractAccountID(tokens) + return Credential.OAuth.make({ + type: "oauth", + methodID, + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + metadata: accountID ? { accountID } : undefined, + }) +} + +async function generatePKCE(): Promise { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("") + const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))) + return { verifier, challenge } +} + +function base64UrlEncode(buffer: ArrayBuffer) { + return Buffer.from(buffer).toString("base64url") +} + +function authorizeURL(redirect: string, pkce: Pkce, state: string) { + return `${issuer}/oauth/authorize?${new URLSearchParams({ + response_type: "code", + client_id: clientID, + redirect_uri: redirect, + scope: "openid profile email offline_access", + code_challenge: pkce.challenge, + code_challenge_method: "S256", + id_token_add_organizations: "true", + codex_cli_simplified_flow: "true", + state, + originator: "opencode", + })}` +} + +function extractAccountID(tokens: TokenResponse) { + return claim(tokens.id_token) ?? claim(tokens.access_token) +} + +function claim(token: string) { + const part = token.split(".")[1] + if (!part) return + try { + const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims + return ( + claims.chatgpt_account_id ?? + claims["https://api.openai.com/auth"]?.chatgpt_account_id ?? + claims.organizations?.[0]?.id + ) + } catch { + return + } +} diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 56e71f822d..7262ba1edc 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -1,32 +1,311 @@ -import { Effect } from "effect" +import { Duration, Effect, Schema, Semaphore, Stream } from "effect" +import type { Scope } from "effect" +import type { IntegrationOAuthMethodRegistration } from "@kilocode/plugin/v2/effect/integration" +import { define } from "@kilocode/plugin/v2/effect/plugin" +import type { CredentialValue } from "@kilocode/sdk/v2/types" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { EventV2 } from "../../event" +import { Credential } from "../../credential" import { Integration } from "../../integration" -import { PluginV2 } from "../../plugin" +import { ModelV2 } from "../../model" import { ProviderV2 } from "../../provider" +import { ConfigProviderV1 } from "../../v1/config/provider" +import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options" +import { ConfigV1 } from "../../v1/config/config" -export const OpencodePlugin = PluginV2.define({ - id: PluginV2.ID.make("opencode"), - effect: Effect.gen(function* () { - const integrations = yield* Integration.Service - let hasKey = false - return { - "catalog.transform": Effect.fn(function* (evt) { - const item = evt.provider.get(ProviderV2.ID.opencode) - if (!item) return - const integration = yield* integrations.get(Integration.ID.make(item.provider.id)) - hasKey = Boolean( - process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey, - ) - evt.provider.update(item.provider.id, (provider) => { - if (!hasKey) provider.request.body.apiKey = "public" - }) - if (hasKey) return - for (const model of item.models.values()) { - if (!model.cost.some((cost) => cost.input > 0)) continue - evt.model.update(item.provider.id, model.id, (draft) => { - draft.enabled = false - }) +const defaultServer = "https://console.opencode.ai" +const clientID = "opencode-cli" +const methodID = Integration.MethodID.make("device") +const RemoteResponse = Schema.Struct({ config: ConfigV1.Info }) +const Device = Schema.Struct({ + device_code: Schema.String, + user_code: Schema.String, + verification_uri_complete: Schema.String, + expires_in: Schema.Number, + interval: Schema.Number, +}) +const Token = Schema.Struct({ + access_token: Schema.String, + refresh_token: Schema.String, + expires_in: Schema.Number, +}) +const TokenPending = Schema.Struct({ error: Schema.String }) +const DeviceToken = Schema.Union([Token, TokenPending]) +const User = Schema.Struct({ id: Schema.String, email: Schema.String }) +const Org = Schema.Struct({ id: Schema.String, name: Schema.String }) + +function oauth(http: HttpClient.HttpClient) { + return { + integrationID: Integration.ID.make("opencode"), + method: { + id: methodID, + type: "oauth", + label: "OpenCode Console account", + }, + authorize: () => + Effect.gen(function* () { + const device = yield* post(http, `${defaultServer}/auth/device/code`, { client_id: clientID }, Device) + return { + mode: "auto" as const, + url: `${defaultServer}${device.verification_uri_complete}`, + instructions: `Enter code: ${device.user_code}`, + callback: poll(http, defaultServer, device.device_code, Duration.seconds(device.interval)), } }), - } + refresh: (credential) => + Effect.gen(function* () { + const server = typeof credential.metadata?.server === "string" ? credential.metadata.server : defaultServer + const token = yield* post( + http, + `${server}/auth/device/token`, + { grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID }, + Token, + ) + return { + ...credential, + access: token.access_token, + refresh: token.refresh_token, + expires: Date.now() + token.expires_in * 1000, + } + }), + label: (credential) => { + return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined + }, + } satisfies IntegrationOAuthMethodRegistration +} + +export const OpencodePlugin = define({ + id: "opencode", + effect: Effect.fn(function* (ctx) { + const events = yield* EventV2.Service + const http = yield* HttpClient.HttpClient + const loading = Semaphore.makeUnsafe(1) + let connected = false + let providers: typeof ConfigV1.Info.Type.provider | undefined + + const load = Effect.fn("OpencodePlugin.load")(function* () { + const connection = yield* ctx.integration.connection.active("opencode") + const credential = connection + ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined))) + : undefined + connected = connection !== undefined + providers = credential + ? yield* fetchProviders(http, credential).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)), + ), + ) + : undefined + }) + + yield* ctx.integration.transform((draft) => { + draft.update("opencode", (integration) => { + integration.name = "OpenCode" + }) + draft.method.update(oauth(http)) + draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } }) + }) + + connected = (yield* ctx.integration.connection.active("opencode")) !== undefined + yield* ctx.catalog.transform((catalog) => { + for (const [providerID, item] of Object.entries(providers ?? {})) { + catalog.provider.update(providerID, (provider) => { + provider.integrationID = Integration.ID.make("opencode") + if (item.name !== undefined) provider.name = item.name + provider.api = item.npm + ? { type: "aisdk", package: item.npm, url: item.api } + : { type: "native", url: item.api, settings: {} } + Object.assign(provider.request.headers, item.options?.headers) + Object.assign(provider.request.body, withoutCredentials(item.options)) + }) + + for (const [modelID, config] of Object.entries(item.models ?? {})) { + catalog.model.update(providerID, modelID, (model) => { + if (config.family !== undefined) model.family = config.family + if (config.name !== undefined) model.name = config.name + if (config.id !== undefined) model.api.id = config.id + if (config.provider !== undefined) { + model.api = config.provider.npm + ? { + id: model.api.id, + type: "aisdk", + package: config.provider.npm, + url: config.provider.api, + } + : { id: model.api.id, type: "native", url: config.provider.api, settings: {} } + } + if (config.tool_call !== undefined) model.capabilities.tools = config.tool_call + if (config.modalities?.input !== undefined) model.capabilities.input = [...config.modalities.input] + if (config.modalities?.output !== undefined) model.capabilities.output = [...config.modalities.output] + const packageName = config.provider?.npm ?? item.npm + const lowerer = ConfigProviderOptionsV1.get(packageName) + Object.assign(model.request.headers, config.headers) + Object.assign(model.request.body, lowerer.request(withoutCredentials(config.options))) + if (config.variants !== undefined) { + model.variants = Object.entries(config.variants).map(([id, options]) => ({ + id: ModelV2.VariantID.make(id), + headers: { ...(options.headers ?? {}) }, + body: lowerer.request(withoutCredentials(options)), + })) + } + if (config.release_date !== undefined) { + const released = Date.parse(config.release_date) + model.time.released = Number.isFinite(released) ? released : 0 + } + if (config.cost !== undefined) { + model.cost = remoteCost(config.cost) + } + model.status = config.status ?? "active" + model.enabled = config.status !== "deprecated" + if (config.limit !== undefined) model.limit = { ...config.limit } + }) + } + } + + const item = catalog.provider.get(ProviderV2.ID.opencode) + if (!item) return + const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.request.body.apiKey) + catalog.provider.update(item.provider.id, (provider) => { + if (!hasKey) provider.request.body.apiKey = "public" + }) + if (hasKey) return + for (const model of item.models.values()) { + if (!model.cost.some((cost) => cost.input > 0)) continue + catalog.model.update(item.provider.id, model.id, (draft) => { + draft.enabled = false + }) + } + }) + + const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) + yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( + Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")), + Stream.runForEach(refresh), + Effect.forkScoped({ startImmediately: true }), + ) + yield* refresh().pipe(Effect.forkScoped) }), }) + +function fetchProviders(http: HttpClient.HttpClient, value: CredentialValue) { + const metadata = value.metadata + const server = typeof metadata?.server === "string" ? metadata.server : defaultServer + const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined + const token = value.type === "oauth" ? value.access : value.key + return http + .execute( + HttpClientRequest.get(`${server}/api/config`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(token), + HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}), + ), + ) + .pipe( + Effect.flatMap((response) => { + if (response.status === 404) return Effect.succeed(undefined) + return HttpClientResponse.filterStatusOk(response).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)), + Effect.map((remote) => remote.config.provider), + ) + }), + ) +} + +function withoutCredentials(body: Readonly> | undefined) { + return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers")) +} + +function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) { + const base = { + input: input.input, + output: input.output, + cache: { read: input.cache_read ?? 0, write: input.cache_write ?? 0 }, + } + if (!input.context_over_200k) return [base] + return [ + base, + { + tier: { type: "context" as const, size: 200_000 }, + input: input.context_over_200k.input, + output: input.context_over_200k.output, + cache: { + read: input.context_over_200k.cache_read ?? 0, + write: input.context_over_200k.cache_write ?? 0, + }, + }, + ] +} + +function poll(http: HttpClient.HttpClient, server: string, deviceCode: string, interval: Duration.Duration) { + const loop = (wait: Duration.Duration): Effect.Effect => + Effect.gen(function* () { + yield* Effect.sleep(wait) + const result = yield* post( + http, + `${server}/auth/device/token`, + { + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: deviceCode, + client_id: clientID, + }, + DeviceToken, + false, + ) + if ("access_token" in result) return yield* credential(http, server, result) + if (result.error === "authorization_pending") return yield* loop(wait) + if (result.error === "slow_down") { + return yield* loop(Duration.sum(wait, Duration.seconds(5))) + } + return yield* Effect.fail(new Error(`Device authorization failed: ${result.error}`)) + }) + return loop(interval) +} + +function credential(http: HttpClient.HttpClient, server: string, token: typeof Token.Type) { + return Effect.gen(function* () { + const [user, orgs] = yield* Effect.all( + [ + get(http, `${server}/api/user`, token.access_token, User), + get(http, `${server}/api/orgs`, token.access_token, Schema.Array(Org)), + ], + { concurrency: 2 }, + ) + const org = orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0] + return Credential.OAuth.make({ + type: "oauth" as const, + methodID, + access: token.access_token, + refresh: token.refresh_token, + expires: Date.now() + token.expires_in * 1000, + metadata: { + server, + accountID: user.id, + email: user.email, + orgID: org?.id, + orgName: org?.name, + }, + }) + }) +} + +function get(http: HttpClient.HttpClient, url: string, token: string, schema: S) { + return HttpClient.filterStatusOk(http) + .execute(HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(token))) + .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema))) +} + +function post( + http: HttpClient.HttpClient, + url: string, + body: Record, + schema: S, + statusOk = true, +) { + return HttpClientRequest.post(url).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.String))(body), + Effect.flatMap((request) => http.execute(request)), + Effect.flatMap((response) => (statusOk ? HttpClientResponse.filterStatusOk(response) : Effect.succeed(response))), + Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), + ) +} diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index bc56a11b54..0f295fb095 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -1,12 +1,12 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const OpenRouterPlugin = PluginV2.define({ - id: PluginV2.ID.make("openrouter"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const OpenRouterPlugin = define({ + id: "openrouter", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue @@ -24,11 +24,13 @@ export const OpenRouterPlugin = PluginV2.define({ } } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@openrouter/ai-sdk-provider") return const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) evt.sdk = mod.createOpenRouter(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/perplexity.ts b/packages/core/src/plugin/provider/perplexity.ts index 2415ab7c1a..44c1ef2fc0 100644 --- a/packages/core/src/plugin/provider/perplexity.ts +++ b/packages/core/src/plugin/provider/perplexity.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const PerplexityPlugin = PluginV2.define({ - id: PluginV2.ID.make("perplexity"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const PerplexityPlugin = define({ + id: "perplexity", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/perplexity") return const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity")) evt.sdk = mod.createPerplexity(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index 47c8b7eaa8..8c668d8b41 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -1,15 +1,15 @@ -import { Npm } from "../../npm" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { pathToFileURL } from "url" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" +import { Npm } from "../../npm" import { ProviderV2 } from "../../provider" -export const SapAICorePlugin = PluginV2.define({ - id: PluginV2.ID.make("sap-ai-core"), - effect: Effect.gen(function* () { +export const SapAICorePlugin = define({ + id: "sap-ai-core", + effect: Effect.fn(function* (ctx) { const npm = yield* Npm.Service - return { - "aisdk.sdk": Effect.fn(function* (evt) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return const serviceKey = process.env.AICORE_SERVICE_KEY ?? @@ -18,7 +18,7 @@ export const SapAICorePlugin = PluginV2.define({ const installedPath = evt.package.startsWith("file://") ? evt.package - : Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint) + : (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) const mod = yield* Effect.promise(async () => { @@ -35,10 +35,12 @@ export const SapAICorePlugin = PluginV2.define({ : {}, ) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return evt.language = evt.sdk(evt.model.api.id) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts index 0971f3518d..788ac63eb0 100644 --- a/packages/core/src/plugin/provider/snowflake-cortex.ts +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise @@ -64,11 +64,11 @@ export function cortexFetch(upstream: FetchLike = fetch) { } } -export const SnowflakeCortexPlugin = PluginV2.define({ - id: PluginV2.ID.make("snowflake-cortex"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const SnowflakeCortexPlugin = define({ + id: "snowflake-cortex", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return const token = process.env.SNOWFLAKE_CORTEX_TOKEN ?? @@ -84,6 +84,6 @@ export const SnowflakeCortexPlugin = PluginV2.define({ fetch: cortexFetch(upstream) as typeof fetch, } as any) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/togetherai.ts b/packages/core/src/plugin/provider/togetherai.ts index b1870f2662..8022e0de66 100644 --- a/packages/core/src/plugin/provider/togetherai.ts +++ b/packages/core/src/plugin/provider/togetherai.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const TogetherAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("togetherai"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const TogetherAIPlugin = define({ + id: "togetherai", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/togetherai") return const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai")) evt.sdk = mod.createTogetherAI(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/venice.ts b/packages/core/src/plugin/provider/venice.ts index 8a3b950245..1a602ffd50 100644 --- a/packages/core/src/plugin/provider/venice.ts +++ b/packages/core/src/plugin/provider/venice.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const VenicePlugin = PluginV2.define({ - id: PluginV2.ID.make("venice"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const VenicePlugin = define({ + id: "venice", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "venice-ai-sdk-provider") return const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider")) evt.sdk = mod.createVenice(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index a7e0bdf5a8..00f5601430 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const VercelPlugin = PluginV2.define({ - id: PluginV2.ID.make("vercel"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const VercelPlugin = define({ + id: "vercel", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/vercel") continue @@ -15,11 +15,13 @@ export const VercelPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/vercel") return const mod = yield* Effect.promise(() => import("@ai-sdk/vercel")) evt.sdk = mod.createVercel(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index 4e9d53e47a..8145a3480a 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -1,20 +1,22 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" -export const XAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("xai"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const XAIPlugin = define({ + id: "xai", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/xai") return const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) evt.sdk = mod.createXai(evt.options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("xai")) return evt.language = evt.sdk.responses(evt.model.api.id) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index a4f6a0ea01..29adebc0ee 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const ZenmuxPlugin = PluginV2.define({ - id: PluginV2.ID.make("zenmux"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const ZenmuxPlugin = define({ + id: "zenmux", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue @@ -16,6 +16,6 @@ export const ZenmuxPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 620fdc8b9a..ea723dd89d 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -2,25 +2,22 @@ export * as SkillPlugin from "./skill" +import { define } from "./internal" import { Effect } from "effect" -import { PluginV2 } from "../plugin" import { AbsolutePath } from "../schema" import { SkillV2 } from "../skill" import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" } export const CustomizeOpencodeContent = customizeOpencodeContent -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("skill"), - effect: Effect.gen(function* () { - const skill = yield* SkillV2.Service - const transform = yield* skill.transform() - - yield* transform((editor) => { - editor.source( - new SkillV2.EmbeddedSource({ +export const Plugin = define({ + id: "skill", + effect: Effect.fn(function* (ctx) { + yield* ctx.skill.transform((draft) => { + draft.source( + SkillV2.EmbeddedSource.make({ type: "embedded", - skill: new SkillV2.Info({ + skill: SkillV2.Info.make({ name: "customize-opencode", description: "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.", diff --git a/packages/core/src/plugin/variant.ts b/packages/core/src/plugin/variant.ts new file mode 100644 index 0000000000..8576c4304b --- /dev/null +++ b/packages/core/src/plugin/variant.ts @@ -0,0 +1,39 @@ +export * as VariantPlugin from "./variant" + +import type { ModelV2Info } from "@kilocode/sdk/v2/types" +import { Effect } from "effect" +import { define } from "./internal" + +export const Plugin = define({ + id: "variant", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform((catalog) => { + for (const record of catalog.provider.list()) { + for (const model of record.models.values()) { + catalog.model.update(model.providerID, model.id, (draft) => { + const generated = generate(draft) + if (generated.length === 0) return + + const explicit = new Map(draft.variants.map((variant) => [variant.id, variant])) + const generatedIDs = new Set(generated.map((variant) => variant.id)) + draft.variants = [ + ...generated.map((variant) => explicit.get(variant.id) ?? variant), + ...draft.variants.filter((variant) => !generatedIDs.has(variant.id)), + ] + }) + } + } + }) + }), +}) + +export function generate(model: ModelV2Info): ModelV2Info["variants"] { + if (model.api.type !== "aisdk" || model.api.package !== "@ai-sdk/openai-compatible") return [] + const ids = `${model.id} ${model.api.id}`.toLowerCase() + if (!["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => ids.includes(name))) return [] + return ["high", "max"].map((id) => ({ + id, + headers: {}, + body: { reasoning_effort: id }, + })) +} diff --git a/packages/core/src/policy.ts b/packages/core/src/policy.ts index 9b7438f4ff..a2adebb54e 100644 --- a/packages/core/src/policy.ts +++ b/packages/core/src/policy.ts @@ -1,5 +1,6 @@ export * as Policy from "./policy" +import { makeLocationNode } from "./effect/app-node" import { Context, Effect as EffectRuntime, Layer, Schema } from "effect" import { Wildcard } from "./util/wildcard" import { Location } from "./location" @@ -21,7 +22,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/Policy") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, EffectRuntime.gen(function* () { let statements: Info[] = [] @@ -44,3 +45,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] }) diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts index 44418d74c1..16e9118d1f 100644 --- a/packages/core/src/process.ts +++ b/packages/core/src/process.ts @@ -3,16 +3,24 @@ import type { PlatformError } from "effect/PlatformError" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "./cross-spawn-spawner" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/app-node" export class AppProcessError extends Schema.TaggedErrorClass()("AppProcessError", { command: Schema.String, exitCode: Schema.optional(Schema.Number), stderr: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Defect), -}) {} + cause: Schema.optional(Schema.Defect()), +}) { + override get message() { + const detail = + this.stderr?.trim() || (this.cause instanceof Error ? this.cause.message : this.cause && String(this.cause)) + const status = this.exitCode === undefined ? "" : ` (exit ${this.exitCode})` + return `Command failed${status}: ${this.command}${detail ? `: ${detail}` : ""}` + } +} export interface RunOptions { + readonly combineOutput?: boolean readonly maxOutputBytes?: number readonly maxErrorBytes?: number readonly signal?: AbortSignal @@ -30,8 +38,10 @@ export interface RunStreamOptions { export interface RunResult { readonly command: string readonly exitCode: number + readonly output?: Buffer readonly stdout: Buffer readonly stderr: Buffer + readonly outputTruncated?: boolean readonly stdoutTruncated: boolean readonly stderrTruncated: boolean } @@ -126,7 +136,7 @@ export const collectStream = (stream: Stream.Stream, }, ).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated }))) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const spawner = yield* ChildProcessSpawner @@ -136,6 +146,22 @@ export const layer = Layer.effect( const collect = Effect.scoped( Effect.gen(function* () { const handle = yield* spawner.spawn(command) + if (options?.combineOutput) { + const [output, exitCode] = yield* Effect.all( + [collectStream(handle.all, options.maxOutputBytes), handle.exitCode], + { concurrency: "unbounded" }, + ) + return { + command: description, + exitCode, + output: output.buffer, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + outputTruncated: output.truncated, + stdoutTruncated: false, + stderrTruncated: false, + } satisfies RunResult + } const [stdout, stderr, exitCode] = yield* Effect.all( [ collectStream(handle.stdout, options?.maxOutputBytes), @@ -230,7 +256,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) -export const node = LayerNode.make(layer, [CrossSpawnSpawner.node]) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] }) export * as AppProcess from "./process" diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index c439da0fdb..49e054d7cc 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -6,7 +6,7 @@ import path from "path" import { AbsolutePath } from "./schema" import { FSUtil } from "./fs-util" import { Git } from "./git" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/app-node" import { Hash } from "./util/hash" import { ProjectDirectories } from "./project/directories" import { ProjectSchema } from "./project/schema" @@ -51,7 +51,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ProjectV2") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -70,8 +70,8 @@ export const layer = Layer.effect( ) }) - const remote = Effect.fnUntraced(function* (repo: Git.Repo) { - const origin = yield* git.remote(repo) + const remote = Effect.fnUntraced(function* (repo: Git.Repository) { + const origin = yield* git.remote.get(repo) if (!origin) return undefined const normalized = url(origin) if (!normalized) return undefined @@ -102,22 +102,22 @@ export const layer = Layer.effect( return `${host.toLowerCase()}/${pathname}` } - const root = Effect.fnUntraced(function* (repo: Git.Repo) { - const root = (yield* git.roots(repo))[0] + const root = Effect.fnUntraced(function* (repo: Git.Repository) { + const root = (yield* git.history.rootCommits(repo))[0] return root ? ID.make(root) : undefined }) const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) { - const repo = yield* git.find(input) + const repo = yield* git.repo.discover(input) if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined } - const previous = yield* cached(repo.store) + const previous = yield* cached(repo.commonDirectory) const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo)) return { previous, id: id ?? ID.global, - directory: repo.directory, - vcs: { type: "git" as const, store: repo.store }, + directory: repo.worktree, + vcs: { type: "git" as const, store: repo.commonDirectory }, } }) @@ -129,9 +129,8 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provideMerge(ProjectDirectories.defaultLayer), -) -export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node]) +export const node = makeGlobalNode({ + service: Service, + layer: layer, + deps: [FSUtil.node, Git.node, ProjectDirectories.node], +}) diff --git a/packages/core/src/project/copy-strategies.ts b/packages/core/src/project/copy-strategies.ts index 1199964f6c..59466b9901 100644 --- a/packages/core/src/project/copy-strategies.ts +++ b/packages/core/src/project/copy-strategies.ts @@ -1,4 +1,3 @@ -import path from "path" import { Effect } from "effect" import { AbsolutePath } from "../schema" import { Git } from "../git" @@ -8,28 +7,26 @@ export function makeGitWorktreeStrategy(input: { git: Git.Interface canonical: (directory: AbsolutePath) => Effect.Effect }) { - const repo = (sourceDirectory: AbsolutePath) => - ({ directory: sourceDirectory, store: sourceDirectory }) satisfies Git.Repo - return { id: StrategyID.make("git_worktree"), create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) { - yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory }) + const repository = yield* input.git.repo.discover(options.sourceDirectory) + if (!repository) return yield* new DirectoryUnavailableError({ directory: options.sourceDirectory }) + yield* input.git.worktree.create({ repository, directory: options.directory }) return { directory: yield* input.canonical(options.directory) } }), remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) { - const found = yield* input.git.find(options.directory) + const found = yield* input.git.repo.discover(options.directory) if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory }) - yield* input.git.worktreeRemove({ repo: found, directory: options.directory, force: options.force }) + yield* input.git.worktree.remove({ repository: found, directory: options.directory, force: options.force }) }), list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) { - const found = yield* input.git.find(directory) + const found = yield* input.git.repo.discover(directory) if (!found) return yield* new DirectoryUnavailableError({ directory }) - const core = path.basename(found.store) === ".git" ? path.dirname(found.store) : found.store - const entries = yield* input.git.worktreeList(found) + const entries = yield* input.git.worktree.list(found) return yield* Effect.forEach(entries, (entry) => - input.canonical(entry).pipe( - Effect.map((directory) => ({ directory, type: entry === core ? "root" : "copy" }) as const), + input.canonical(entry.directory).pipe( + Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "copy" }) as const), Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)), ), ).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined))) diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index 0e3246b3b2..5b2f95655a 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -5,7 +5,7 @@ import path from "path" import { AbsolutePath } from "../schema" import { FSUtil } from "../fs-util" import { Git } from "../git" -import { LayerNode } from "../effect/layer-node" +import { makeLocationNode } from "../effect/app-node" import { Project } from "../project" import { ProjectDirectories } from "./directories" import { makeGitWorktreeStrategy } from "./copy-strategies" @@ -13,25 +13,16 @@ import { Slug } from "../util/slug" import { EventV2 } from "../event" import { Database } from "../database/database" import { Location } from "../location" -import { PluginBoot } from "../plugin/boot" +import { Event } from "@opencode-ai/schema/project-directories" +import { ProjectCopy } from "@opencode-ai/schema/project-copy" -export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID")) +export const StrategyID = ProjectCopy.StrategyID export type StrategyID = typeof StrategyID.Type -export const CreateInput = Schema.Struct({ - projectID: Project.ID, - strategy: StrategyID, - sourceDirectory: AbsolutePath, - directory: AbsolutePath, - name: Schema.optional(Schema.String), -}).annotate({ identifier: "ProjectCopy.CreateInput" }) +export const CreateInput = ProjectCopy.CreateInput export type CreateInput = typeof CreateInput.Type -export const RemoveInput = Schema.Struct({ - projectID: Project.ID, - directory: AbsolutePath, - force: Schema.Boolean, -}).annotate({ identifier: "ProjectCopy.RemoveInput" }) +export const RemoveInput = ProjectCopy.RemoveInput export type RemoveInput = typeof RemoveInput.Type export const RefreshInput = Schema.Struct({ @@ -45,9 +36,7 @@ export const RefreshResult = Schema.Struct({ }).annotate({ identifier: "ProjectCopy.RefreshResult" }) export type RefreshResult = typeof RefreshResult.Type -export const Copy = Schema.Struct({ - directory: AbsolutePath, -}).annotate({ identifier: "ProjectCopy.Copy" }) +export const Copy = ProjectCopy.Copy export type Copy = typeof Copy.Type export const ListEntry = Schema.Struct({ @@ -107,12 +96,7 @@ export interface Strategy { readonly list: (directory: AbsolutePath) => Effect.Effect } -export const Event = { - Updated: EventV2.define({ - type: "project.directories.updated", - schema: { projectID: Project.ID }, - }), -} +export { Event } export interface Interface { readonly register: (strategy: Strategy) => Effect.Effect @@ -125,10 +109,8 @@ export class Service extends Context.Service()("@opencode/Pr export const refreshAfterBoot = Effect.gen(function* () { const location = yield* Location.Service - const boot = yield* PluginBoot.Service const copies = yield* Service yield* Effect.gen(function* () { - yield* boot.wait() yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id }) const result = yield* copies.refresh({ projectID: location.project.id }) yield* Effect.logInfo("project copy refresh done", { @@ -143,7 +125,7 @@ export const refreshAfterBoot = Effect.gen(function* () { ) }) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -297,4 +279,14 @@ export const layer = Layer.effect( ) export const locationLayer = layer -export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node]) +export const node = makeLocationNode({ + service: Service, + layer: layer, + deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node], +}) + +export const refreshNode = makeLocationNode({ + name: "project-copy-refresh", + layer: Layer.effectDiscard(refreshAfterBoot), + deps: [node, Location.node], +}) diff --git a/packages/core/src/project/directories.ts b/packages/core/src/project/directories.ts index 7c0522107a..6c9ad2e515 100644 --- a/packages/core/src/project/directories.ts +++ b/packages/core/src/project/directories.ts @@ -3,8 +3,8 @@ export * as ProjectDirectories from "./directories" import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" -import { LayerNode } from "../effect/layer-node" -import { AbsolutePath, optionalOmitUndefined } from "../schema" +import { makeGlobalNode } from "../effect/app-node" +import { AbsolutePath, optional } from "../schema" import { ProjectSchema } from "./schema" import { ProjectDirectoryTable } from "./sql" import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" @@ -39,7 +39,7 @@ export type ListInput = typeof ListInput.Type export const ListOutput = Schema.Array( Schema.Struct({ directory: AbsolutePath, - strategy: optionalOmitUndefined(Schema.String), + strategy: optional(Schema.String), }), ).annotate({ identifier: "Project.Directories" }) export type ListOutput = typeof ListOutput.Type @@ -57,7 +57,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ProjectDirectories") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const db = (yield* Database.Service).db @@ -155,5 +155,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) -export const node = LayerNode.make(layer, [Database.node]) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] }) diff --git a/packages/core/src/project/schema.ts b/packages/core/src/project/schema.ts index 51d9581cc6..eed359abad 100644 --- a/packages/core/src/project/schema.ts +++ b/packages/core/src/project/schema.ts @@ -1,14 +1,10 @@ export * as ProjectSchema from "./schema" import { Schema } from "effect" -import { AbsolutePath, withStatics } from "../schema" +import { Project } from "@opencode-ai/schema/project" +import { AbsolutePath } from "../schema" -export const ID = Schema.String.pipe( - Schema.brand("Project.ID"), - withStatics((schema) => ({ - global: schema.make("global"), - })), -) +export const ID = Project.ID export type ID = typeof ID.Type export const Vcs = Schema.Union([ diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 3f5424a47f..03f7d7eef3 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -1,68 +1,25 @@ export * as ProviderV2 from "./provider" -import { withStatics } from "./schema" -import { Schema } from "effect" +import { Types } from "effect" +import { Provider } from "@opencode-ai/schema/provider" -export const ID = Schema.String.pipe( - Schema.brand("ProviderV2.ID"), - withStatics((schema) => ({ - // Well-known providers - opencode: schema.make("opencode"), - anthropic: schema.make("anthropic"), - openai: schema.make("openai"), - google: schema.make("google"), - googleVertex: schema.make("google-vertex"), - githubCopilot: schema.make("github-copilot"), - amazonBedrock: schema.make("amazon-bedrock"), - azure: schema.make("azure"), - openrouter: schema.make("openrouter"), - mistral: schema.make("mistral"), - gitlab: schema.make("gitlab"), - })), -) +export const ID = Provider.ID export type ID = typeof ID.Type -export const AISDK = Schema.Struct({ - type: Schema.Literal("aisdk"), - package: Schema.String, - url: Schema.String.pipe(Schema.optional), - settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), -}) +export const AISDK = Provider.AISDK -export const Native = Schema.Struct({ - type: Schema.Literal("native"), - url: Schema.String.pipe(Schema.optional), - settings: Schema.Record(Schema.String, Schema.Unknown), -}) +export const Native = Provider.Native -export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type")) -export type Api = typeof Api.Type +export const Api = Provider.Api +export type Api = Provider.Api +export type MutableApi = T extends Api + ? Omit, "settings"> & (undefined extends T["settings"] ? { settings?: any } : { settings: any }) + : never -export const Request = Schema.Struct({ - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.Record(Schema.String, Schema.Any), -}) -export type Request = typeof Request.Type +export const Request = Provider.Request +export type Request = Provider.Request -export class Info extends Schema.Class("ProviderV2.Info")({ - id: ID, - name: Schema.String, - disabled: Schema.Boolean.pipe(Schema.optional), - api: Api, - request: Request, -}) { - static empty(providerID: ID): Info { - return new Info({ - id: providerID, - name: providerID, - api: { - type: "native", - settings: {}, - }, - request: { - headers: {}, - body: {}, - }, - }) - } -} +export const Info = Provider.Info +export type Info = Provider.Info + +export type MutableInfo = Omit, "api"> & { api: MutableApi } diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index 9068db1558..023c5b28cd 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -1,11 +1,12 @@ export * as Pty from "./pty" +import { makeLocationNode } from "./effect/app-node" import type { Disp, Proc } from "#pty" import { Context, Effect, Layer, Schema, Types } from "effect" +import { Pty } from "@opencode-ai/schema/pty" import { Config } from "./config" import { EventV2 } from "./event" import { Location } from "./location" -import { NonNegativeInt, PositiveInt } from "./schema" import { PtyID } from "./pty/schema" import { Shell } from "./shell" import { lazy } from "./util/lazy" @@ -35,43 +36,19 @@ type Active = { listeners: Disp[] } -export const Info = Schema.Struct({ - id: PtyID, - title: Schema.String, - command: Schema.String, - args: Schema.Array(Schema.String), - cwd: Schema.String, - status: Schema.Literals(["running", "exited"]), - // Windows ConPTY assigns the child pid asynchronously, so 0 is valid at spawn time. - pid: NonNegativeInt, - // Present once status is "exited". - exitCode: Schema.optional(NonNegativeInt), -}).annotate({ identifier: "Pty" }) - +export const Info = Pty.Info export type Info = Types.DeepMutable -export const CreateInput = Schema.Struct({ - command: Schema.optional(Schema.String), - args: Schema.optional(Schema.Array(Schema.String)), - cwd: Schema.optional(Schema.String), - title: Schema.optional(Schema.String), - env: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}) +export const CreateInput = Pty.CreateInput export type CreateInput = Types.DeepMutable -export const UpdateInput = Schema.Struct({ - title: Schema.optional(Schema.String), - size: Schema.optional( - Schema.Struct({ - rows: PositiveInt, - cols: PositiveInt, - }), - ), -}) +export const UpdateInput = Pty.UpdateInput export type UpdateInput = Types.DeepMutable +export const Event = Pty.Event + export type AttachInput = { // Absolute output cursor to replay from. -1 tails from the current end; omitted replays the full retained buffer. readonly cursor?: number @@ -100,13 +77,6 @@ export class ExitedError extends Schema.TaggedErrorClass()("Pty.Exi ptyID: PtyID, }) {} -export const Event = { - Created: EventV2.define({ type: "pty.created", schema: { info: Info } }), - Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }), - Exited: EventV2.define({ type: "pty.exited", schema: { id: PtyID, exitCode: NonNegativeInt } }), - Deleted: EventV2.define({ type: "pty.deleted", schema: { id: PtyID } }), -} - export interface Interface { readonly list: () => Effect.Effect readonly get: (id: PtyID) => Effect.Effect @@ -119,7 +89,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/Pty") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service @@ -344,3 +314,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer)) + +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Location.node, Config.node] }) diff --git a/packages/core/src/pty/schema.ts b/packages/core/src/pty/schema.ts index b8c973862f..ab0c40521b 100644 --- a/packages/core/src/pty/schema.ts +++ b/packages/core/src/pty/schema.ts @@ -1,13 +1 @@ -import { Schema } from "effect" -import { Identifier } from "../id/id" -import { withStatics } from "../schema" - -const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID")) - -export type PtyID = typeof ptyIdSchema.Type - -export const PtyID = ptyIdSchema.pipe( - withStatics((schema: typeof ptyIdSchema) => ({ - ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)), - })), -) +export { ID as PtyID } from "@opencode-ai/schema/pty" diff --git a/packages/core/src/pty/ticket.ts b/packages/core/src/pty/ticket.ts index c625390be0..07838b1415 100644 --- a/packages/core/src/pty/ticket.ts +++ b/packages/core/src/pty/ticket.ts @@ -1,18 +1,15 @@ export * as PtyTicket from "./ticket" import { WorkspaceV2 } from "../workspace" -import { PositiveInt } from "../schema" +import { PtyTicket } from "@opencode-ai/schema/pty-ticket" import { PtyID } from "./schema" -import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" -import { LayerNode } from "../effect/layer-node" +import { Cache, Context, Duration, Effect, Layer } from "effect" +import { makeGlobalNode } from "../effect/app-node" const DEFAULT_TTL = Duration.seconds(60) const CAPACITY = 10_000 -export const ConnectToken = Schema.Struct({ - ticket: Schema.String, - expires_in: PositiveInt, -}) +export const ConnectToken = PtyTicket.ConnectToken export type Scope = { readonly ptyID: PtyID @@ -54,7 +51,6 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) => }) }) -export const layer = Layer.effect(Service, make()) +const layer = Layer.effect(Service, make()) -export const defaultLayer = layer -export const node = LayerNode.make(layer, []) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] }) diff --git a/packages/core/src/public-event-manifest.ts b/packages/core/src/public-event-manifest.ts new file mode 100644 index 0000000000..11b84f7905 --- /dev/null +++ b/packages/core/src/public-event-manifest.ts @@ -0,0 +1,7 @@ +export * as PublicEventManifest from "./public-event-manifest" + +import { Event } from "@opencode-ai/schema/event" +import { EventManifest } from "@opencode-ai/schema/event-manifest" + +export const Definitions = EventManifest.ServerDefinitions +export const Latest = Event.latest(Definitions) diff --git a/packages/core/src/public/agent.ts b/packages/core/src/public/agent.ts deleted file mode 100644 index ade2096f89..0000000000 --- a/packages/core/src/public/agent.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * as Agent from "./agent" - -import { AgentV2 } from "../agent" - -export const ID = AgentV2.ID -export type ID = AgentV2.ID diff --git a/packages/core/src/public/index.ts b/packages/core/src/public/index.ts deleted file mode 100644 index 2229039b9a..0000000000 --- a/packages/core/src/public/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Intentional supported native API. Other core subpaths remain internal implementation surfaces. */ -export { Agent } from "./agent" -export { Model } from "./model" -export { OpenCode } from "./opencode" -export { Session } from "./session" -export { Tool } from "./tool" -export { Location } from "./location" -export { Prompt } from "../session/prompt" -export { AbsolutePath } from "../schema" diff --git a/packages/core/src/public/location.ts b/packages/core/src/public/location.ts deleted file mode 100644 index aab15181d1..0000000000 --- a/packages/core/src/public/location.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * as Location from "./location" - -import { Location } from "../location" - -export const Ref = Location.Ref -export type Ref = Location.Ref diff --git a/packages/core/src/public/model.ts b/packages/core/src/public/model.ts deleted file mode 100644 index ab92b8dfe7..0000000000 --- a/packages/core/src/public/model.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * as Model from "./model" - -import { ModelV2 } from "../model" - -export const ID = ModelV2.ID -export type ID = ModelV2.ID - -export const Ref = ModelV2.Ref -export type Ref = ModelV2.Ref diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts deleted file mode 100644 index 7388705d8c..0000000000 --- a/packages/core/src/public/opencode.ts +++ /dev/null @@ -1,129 +0,0 @@ -export * as OpenCode from "./opencode" - -import { Context, Effect, Layer } from "effect" -import { Catalog } from "../catalog" -import { Database } from "../database/database" -import { EventV2 } from "../event" -import { LocationServiceMap } from "../location-layer" -import { PluginBoot } from "../plugin/boot" -import { ProjectV2 } from "../project" -import { SessionV2 } from "../session" -import * as SessionExecutionLocal from "../session/execution/local" -import { SessionProjector } from "../session/projector" -import { SessionStore } from "../session/store" -import { ApplicationTools } from "../tool/application-tools" -import { Session } from "./session" -import { Tool } from "./tool" - -export interface Interface { - readonly sessions: Session.Interface - readonly tools: Tool.Interface -} - -/** Intentional public native API for Effect applications embedding OpenCode. */ -export class Service extends Context.Service()("@opencode/public/OpenCode") {} - -class SessionModelValidation extends Context.Service< - SessionModelValidation, - { - readonly validate: ( - input: Session.SwitchModelInput & { readonly location: Session.Info["location"] }, - ) => Effect.Effect - } ->()("@opencode/public/OpenCode/SessionModelValidation") {} - -const ApplicationToolsLayer = ApplicationTools.layer -const LocationServicesLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationToolsLayer)) -const SessionModelValidationLayer = Layer.effect( - SessionModelValidation, - Effect.gen(function* () { - const locations = yield* LocationServiceMap - return SessionModelValidation.of({ - validate: Effect.fn("OpenCode.sessions.validateModel")(function* (input) { - yield* Effect.gen(function* () { - yield* (yield* PluginBoot.Service).wait() - const catalog = yield* Catalog.Service - const model = (yield* catalog.model.available()).find( - (model) => model.providerID === input.model.providerID && model.id === input.model.id, - ) - if (!model) - return yield* new Session.ModelUnavailableError({ - providerID: input.model.providerID, - modelID: input.model.id, - }) - if ( - input.model.variant !== undefined && - input.model.variant !== "default" && - !model.variants.some((variant) => variant.id === input.model.variant) - ) - return yield* new Session.VariantUnavailableError({ - providerID: input.model.providerID, - modelID: input.model.id, - variant: input.model.variant, - }) - }).pipe(Effect.provide(locations.get(input.location))) - }), - }) - }), -) - -const SessionsLayer = Layer.merge( - SessionV2.layer.pipe( - Layer.provide(SessionProjector.layer), - Layer.provide(SessionExecutionLocal.layer), - Layer.provide(SessionStore.layer), - Layer.provide(EventV2.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.orDie, - ), - SessionModelValidationLayer, -).pipe(Layer.provide(LocationServicesLayer)) -// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence. -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const sessions = yield* SessionV2.Service - const tools = yield* ApplicationTools.Service - const validation = yield* SessionModelValidation - return Service.of({ - tools: { register: tools.register }, - sessions: { - create: (input) => - sessions.create({ - id: input.id, - agent: input.agent, - model: input.model, - location: input.location, - }), - get: sessions.get, - list: sessions.list, - switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) { - const session = yield* sessions.get(input.sessionID) - yield* validation.validate({ ...input, location: session.location }) - yield* sessions.switchModel(input) - }), - interrupt: sessions.interrupt, - prompt: (input) => - sessions.prompt({ - id: input.id, - sessionID: input.sessionID, - prompt: input.prompt, - delivery: input.delivery, - }), - messages: (input) => - sessions.messages({ - sessionID: input.sessionID, - limit: input.limit, - order: input.order, - cursor: input.cursor, - }), - message: (input) => sessions.message({ sessionID: input.sessionID, messageID: input.messageID }), - context: sessions.context, - events: (input) => sessions.events({ sessionID: input.sessionID, after: input.after }), - }, - }) - }), -).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer))) - -// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics. diff --git a/packages/core/src/public/session.ts b/packages/core/src/public/session.ts deleted file mode 100644 index 6c61aff3b6..0000000000 --- a/packages/core/src/public/session.ts +++ /dev/null @@ -1,119 +0,0 @@ -export * as Session from "./session" - -import { Effect, Schema, Stream } from "effect" -import { EventV2 } from "../event" -import { ModelV2 } from "../model" -import { SessionV2 } from "../session" -import { MessageDecodeError } from "../session/error" -import { SessionEvent } from "../session/event" -import { SessionInput } from "../session/input" -import { SessionMessage } from "../session/message" -import { Prompt } from "../session/prompt" -import { Agent } from "./agent" -import { Location } from "./location" -import { Model } from "./model" - -export const ID = SessionV2.ID -export type ID = SessionV2.ID - -export const Info = SessionV2.Info -export type Info = SessionV2.Info - -export const MessageID = SessionMessage.ID -export type MessageID = SessionMessage.ID - -export const Message = SessionMessage.Message -export type Message = SessionMessage.Message - -export const Admission = SessionInput.Admitted -export type Admission = SessionInput.Admitted - -export const Delivery = SessionInput.Delivery -export type Delivery = SessionInput.Delivery - -export const ListInput = SessionV2.ListInput -export type ListInput = SessionV2.ListInput - -export const EventCursor = EventV2.Cursor -export type EventCursor = EventV2.Cursor -export type Event = EventV2.CursorEvent - -export const NotFoundError = SessionV2.NotFoundError -export type NotFoundError = SessionV2.NotFoundError - -export const PromptConflictError = SessionV2.PromptConflictError -export type PromptConflictError = SessionV2.PromptConflictError - -export class ModelUnavailableError extends Schema.TaggedErrorClass()( - "Session.ModelUnavailableError", - { - providerID: Model.Ref.fields.providerID, - modelID: Model.Ref.fields.id, - }, -) {} - -export class VariantUnavailableError extends Schema.TaggedErrorClass()( - "Session.VariantUnavailableError", - { - providerID: Model.Ref.fields.providerID, - modelID: Model.Ref.fields.id, - variant: ModelV2.VariantID, - }, -) {} - -export { MessageDecodeError } - -export interface CreateInput { - readonly id?: ID - readonly agent?: Agent.ID - readonly model?: Model.Ref - readonly location: Location.Ref -} - -export interface PromptInput { - readonly id?: MessageID - readonly sessionID: ID - readonly prompt: Prompt - readonly delivery?: Delivery -} - -export interface SwitchModelInput { - readonly sessionID: ID - readonly model: Model.Ref -} - -export interface MessagesInput { - readonly sessionID: ID - readonly limit?: number - readonly order?: "asc" | "desc" - readonly cursor?: { - readonly id: MessageID - readonly direction: "previous" | "next" - } -} - -export interface MessageInput { - readonly sessionID: ID - readonly messageID: MessageID -} - -export interface EventsInput { - readonly sessionID: ID - readonly after?: EventCursor -} - -export interface Interface { - readonly create: (input: CreateInput) => Effect.Effect - readonly get: (sessionID: ID) => Effect.Effect - readonly list: (input?: ListInput) => Effect.Effect - readonly prompt: (input: PromptInput) => Effect.Effect - readonly switchModel: ( - input: SwitchModelInput, - ) => Effect.Effect - /** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */ - readonly interrupt: (sessionID: ID) => Effect.Effect - readonly messages: (input: MessagesInput) => Effect.Effect - readonly message: (input: MessageInput) => Effect.Effect - readonly context: (sessionID: ID) => Effect.Effect - readonly events: (input: EventsInput) => Stream.Stream -} diff --git a/packages/core/src/public/tool.ts b/packages/core/src/public/tool.ts deleted file mode 100644 index 97b436fed9..0000000000 --- a/packages/core/src/public/tool.ts +++ /dev/null @@ -1,17 +0,0 @@ -export * as Tool from "./tool" - -import { Effect, Scope } from "effect" -import type { AnyTool, RegistrationError } from "../tool/tool" - -export { Failure, RegistrationError, make } from "../tool/tool" -export type { AnyTool, Content, Context, Definition } from "../tool/tool" - -export interface Interface { - /** - * Register same-process tools on this OpenCode instance for the current Scope. - * Location tools with the same name take precedence where they are installed. - * Closing the Scope removes the tools immediately, so calls that have not - * started settling may fail because the tool is no longer available. - */ - readonly register: (tools: Readonly>) => Effect.Effect -} diff --git a/packages/core/src/question.ts b/packages/core/src/question.ts index a489fb9aac..79e0ea5e03 100644 --- a/packages/core/src/question.ts +++ b/packages/core/src/question.ts @@ -1,83 +1,36 @@ export * as QuestionV2 from "./question" +import { makeLocationNode } from "./effect/app-node" import { Context, Deferred, Effect, Layer, Schema } from "effect" +import { Question } from "@opencode-ai/schema/question" import { EventV2 } from "./event" -import { Identifier } from "./id/id" -import { withStatics } from "./schema" import { SessionSchema } from "./session/schema" -export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe( - Schema.brand("QuestionV2.ID"), - withStatics((schema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("question", id)) })), -) +export const ID = Question.ID export type ID = typeof ID.Type -export const Option = Schema.Struct({ - label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }), - description: Schema.String.annotate({ description: "Explanation of choice" }), -}).annotate({ identifier: "QuestionV2.Option" }) +export const Option = Question.Option export type Option = typeof Option.Type -const base = { - question: Schema.String.annotate({ description: "Complete question" }), - header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }), - options: Schema.Array(Option).annotate({ description: "Available choices" }), - multiple: Schema.Boolean.pipe(Schema.optional).annotate({ description: "Allow selecting multiple choices" }), -} - -export const Info = Schema.Struct({ - ...base, - custom: Schema.Boolean.pipe(Schema.optional).annotate({ - description: "Allow typing a custom answer (default: true)", - }), -}).annotate({ identifier: "QuestionV2.Info" }) +export const Info = Question.Info export type Info = typeof Info.Type -export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionV2.Prompt" }) +export const Prompt = Question.Prompt export type Prompt = typeof Prompt.Type -export const Tool = Schema.Struct({ - messageID: Schema.String, - callID: Schema.String, -}).annotate({ identifier: "QuestionV2.Tool" }) +export const Tool = Question.Tool export type Tool = typeof Tool.Type -export const Request = Schema.Struct({ - id: ID, - sessionID: SessionSchema.ID, - questions: Schema.Array(Info).annotate({ description: "Questions to ask" }), - tool: Tool.pipe(Schema.optional), -}).annotate({ identifier: "QuestionV2.Request" }) +export const Request = Question.Request export type Request = typeof Request.Type -export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionV2.Answer" }) +export const Answer = Question.Answer export type Answer = typeof Answer.Type -export const Reply = Schema.Struct({ - answers: Schema.Array(Answer).annotate({ - description: "User answers in order of questions (each answer is an array of selected labels)", - }), -}).annotate({ identifier: "QuestionV2.Reply" }) +export const Reply = Question.Reply export type Reply = typeof Reply.Type -export const Event = { - Asked: EventV2.define({ type: "question.v2.asked", schema: Request.fields }), - Replied: EventV2.define({ - type: "question.v2.replied", - schema: { - sessionID: SessionSchema.ID, - requestID: ID, - answers: Schema.Array(Answer), - }, - }), - Rejected: EventV2.define({ - type: "question.v2.rejected", - schema: { - sessionID: SessionSchema.ID, - requestID: ID, - }, - }), -} +export const Event = Question.Event export class RejectedError extends Schema.TaggedErrorClass()("QuestionV2.RejectedError", {}) { override get message() { @@ -119,7 +72,7 @@ interface Pending { * layer once per embedded Location so replies cannot settle another Location's * deferred request. */ -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service @@ -196,3 +149,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] }) diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index 66eb160eb4..5303dbd955 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -1,7 +1,8 @@ export * as Reference from "./reference" -import { Context, Effect, Layer, Schema, Scope } from "effect" -import { castDraft } from "immer" +import { makeLocationNode } from "./effect/app-node" +import { Context, Effect, Layer, Scope, Types } from "effect" +import { Reference } from "@opencode-ai/schema/reference" import { Global } from "./global" import { EventV2 } from "./event" import { Repository } from "./repository" @@ -9,54 +10,37 @@ import { RepositoryCache } from "./repository-cache" import { AbsolutePath } from "./schema" import { State } from "./state" -export class LocalSource extends Schema.Class("Reference.LocalSource")({ - type: Schema.Literal("local"), - path: AbsolutePath, - description: Schema.String.pipe(Schema.optional), - hidden: Schema.Boolean.pipe(Schema.optional), -}) {} +export const LocalSource = Reference.LocalSource +export type LocalSource = Reference.LocalSource -export class GitSource extends Schema.Class("Reference.GitSource")({ - type: Schema.Literal("git"), - repository: Schema.String, - branch: Schema.String.pipe(Schema.optional), - description: Schema.String.pipe(Schema.optional), - hidden: Schema.Boolean.pipe(Schema.optional), -}) {} +export const GitSource = Reference.GitSource +export type GitSource = Reference.GitSource -export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type")) -export type Source = typeof Source.Type +export const Source = Reference.Source +export type Source = Reference.Source -export const Event = { - Updated: EventV2.define({ type: "reference.updated", schema: {} }), -} +export const Event = Reference.Event -export class Info extends Schema.Class("Reference.Info")({ - name: Schema.String, - path: AbsolutePath, - description: Schema.String.pipe(Schema.optional), - hidden: Schema.Boolean.pipe(Schema.optional), - source: Source, -}) {} +export const Info = Reference.Info +export type Info = Reference.Info type Data = { - sources: Map + sources: Map> } -type Editor = { +type Draft = { add(name: string, source: Source): void remove(name: string): void list(): readonly [string, Source][] } -export interface Interface { - readonly transform: State.Interface["transform"] +export interface Interface extends State.Transformable { readonly list: () => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Reference") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const global = yield* Global.Service @@ -64,26 +48,26 @@ export const layer = Layer.effect( const cache = yield* RepositoryCache.Service const scope = yield* Scope.Scope const materialized = new Map() - const state = State.create({ + const state = State.create({ initial: () => ({ sources: new Map() }), - editor: (draft) => ({ - add: (name, source) => draft.sources.set(name, castDraft(source)), + draft: (draft) => ({ + add: (name, source) => draft.sources.set(name, source as Types.DeepMutable), remove: (name) => draft.sources.delete(name), list: () => Array.from(draft.sources.entries()) as [string, Source][], }), - finalize: (editor) => + finalize: (draft) => Effect.gen(function* () { materialized.clear() const seen = new Map() - for (const [name, source] of editor.list()) { + for (const [name, source] of draft.list()) { if (source.type === "local") { materialized.set( name, new Info({ name, path: source.path, - description: source.description, - hidden: source.hidden, + ...(source.description === undefined ? {} : { description: source.description }), + ...(source.hidden === undefined ? {} : { hidden: source.hidden }), source, }), ) @@ -106,8 +90,8 @@ export const layer = Layer.effect( new Info({ name, path: AbsolutePath.make(target), - description: source.description, - hidden: source.hidden, + ...(source.description === undefined ? {} : { description: source.description }), + ...(source.hidden === undefined ? {} : { hidden: source.hidden }), source, }), ) @@ -128,6 +112,7 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, + reload: state.reload, list: Effect.fn("Reference.list")(function* () { return Array.from(materialized.values()) }), @@ -136,3 +121,9 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Global.node, EventV2.node, RepositoryCache.node], +}) diff --git a/packages/core/src/reference/guidance.ts b/packages/core/src/reference/guidance.ts index f567264768..25566e2f2f 100644 --- a/packages/core/src/reference/guidance.ts +++ b/packages/core/src/reference/guidance.ts @@ -1,7 +1,7 @@ export * as ReferenceGuidance from "./guidance" +import { makeLocationNode } from "../effect/app-node" import { Context, Effect, Layer, Schema } from "effect" -import { PluginBoot } from "../plugin/boot" import { Reference } from "../reference" import { SystemContext } from "../system-context/index" @@ -31,15 +31,13 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/ReferenceGuidance") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { - const boot = yield* PluginBoot.Service const references = yield* Reference.Service return Service.of({ load: Effect.fn("ReferenceGuidance.load")(function* () { - yield* boot.wait() const available = (yield* references.list()) .filter((reference) => reference.description !== undefined) .map((reference) => ({ @@ -67,3 +65,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [Reference.node] }) diff --git a/packages/core/src/repository-cache.ts b/packages/core/src/repository-cache.ts index 894dc38faa..988236743b 100644 --- a/packages/core/src/repository-cache.ts +++ b/packages/core/src/repository-cache.ts @@ -4,6 +4,8 @@ import { FSUtil } from "./fs-util" import { Git } from "./git" import { Global } from "./global" import { Repository } from "./repository" +import { AbsolutePath } from "./schema" +import { makeGlobalNode } from "./effect/app-node" import { EffectFlock } from "./util/effect-flock" export type Result = { @@ -119,7 +121,7 @@ export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(functi }) }) -export const layer: Layer.Layer = +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { @@ -142,15 +144,15 @@ export const layer: Layer.Layer new CloneFailedError({ repository, message: errorMessage(error) })), - ) - if (result.exitCode !== 0) { - return yield* new CloneFailedError({ - repository, - message: resultMessage(result, `Failed to clone ${repository}`), + yield* git.repo + .clone({ + remote: input.reference.remote, + directory: AbsolutePath.make(localPath), + branch: input.branch, }) - } + .pipe(Effect.mapError((error) => new CloneFailedError({ repository, message: error.message }))) } if (status === "refreshed") { - const fetch = yield* git - .fetch(localPath) - .pipe( - Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })), - ) - if (fetch.exitCode !== 0) { - return yield* new FetchFailedError({ - repository, - message: resultMessage(fetch, `Failed to refresh ${repository}`), - }) - } + if (!existing) + return yield* new FetchFailedError({ repository, message: "Repository is unavailable" }) + yield* git.sync + .fetchRemotes(existing) + .pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message }))) if (input.branch) { const requestedBranch = input.branch - const fetchBranch = yield* git - .fetchBranch(localPath, requestedBranch) - .pipe( - Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })), - ) - if (fetchBranch.exitCode !== 0) { - return yield* new FetchFailedError({ - repository, - message: resultMessage(fetchBranch, `Failed to fetch ${requestedBranch}`), - }) - } + yield* git.sync + .fetchBranch(existing, { branch: requestedBranch }) + .pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message }))) - const checkout = yield* git.checkout(localPath, requestedBranch).pipe( + yield* git.sync.checkoutRemoteBranch(existing, { branch: requestedBranch }).pipe( Effect.mapError( (error) => new CheckoutFailedError({ repository, branch: requestedBranch, - message: errorMessage(error), + message: error.message, }), ), ) - if (checkout.exitCode !== 0) { - return yield* new CheckoutFailedError({ - repository, - branch: requestedBranch, - message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`), - }) - } } - const reset = yield* git - .reset(localPath, yield* resetTarget(git, localPath, input.branch)) - .pipe( - Effect.mapError((error) => new ResetFailedError({ repository, message: errorMessage(error) })), - ) - if (reset.exitCode !== 0) { - return yield* new ResetFailedError({ - repository, - message: resultMessage(reset, `Failed to reset ${repository}`), - }) - } + yield* git.sync + .resetHard(existing, yield* resetTarget(git, existing, input.branch)) + .pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message }))) } + const checkout = yield* git.repo.discover(AbsolutePath.make(localPath)) + return { repository, host: input.reference.host, remote: input.reference.remote, localPath, status, - head: yield* git.head(localPath), - branch: yield* git.branch(localPath), + head: checkout ? yield* git.history.head(checkout) : undefined, + branch: checkout ? yield* git.history.branch(checkout) : undefined, } satisfies Result }), `repository-cache:${localPath}`, @@ -252,12 +223,11 @@ export const layer: Layer.Layer = layer.pipe( - Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(Global.defaultLayer), -) +export const node = makeGlobalNode({ + service: Service, + layer, + deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node], +}) function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { if (!input.reuse) return "cloned" as const @@ -275,17 +245,17 @@ function cacheOperation(effect: Effect.Effect, operation: stri ) } -const resetTarget = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, requestedBranch?: string) { +const resetTarget = Effect.fnUntraced(function* ( + git: Git.Interface, + repository: Git.Repository, + requestedBranch?: string, +) { if (requestedBranch) return `origin/${requestedBranch}` - const remoteHead = yield* git.remoteHead(cwd) - if (remoteHead) return remoteHead - const currentBranch = yield* git.branch(cwd) + const remoteHead = yield* git.history.defaultRemoteBranch(repository) + if (remoteHead) return `origin/${remoteHead}` + const currentBranch = yield* git.history.branch(repository) if (currentBranch) return `origin/${currentBranch}` return "HEAD" }) -function resultMessage(result: Git.Result, fallback: string) { - return result.stderr.trim() || result.text.trim() || fallback -} - export * as RepositoryCache from "./repository-cache" diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 5a8a94f337..ac8ea52d93 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -2,10 +2,8 @@ export * as Ripgrep from "./ripgrep" import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" -import path from "path" -import { LayerNode } from "./effect/layer-node" -import { Entry, Match } from "./filesystem/schema" -import { FSUtil } from "./fs-util" +import { Entry, Match } from "@opencode-ai/schema/filesystem" +import { makeGlobalNode } from "./effect/app-node" import { AppProcess, collectStream, waitForAbort } from "./process" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" import { RipgrepBinary } from "./ripgrep/binary" @@ -42,7 +40,7 @@ type RawMatchData = (typeof RawMatch.Type)["data"] export class Error extends Schema.TaggedErrorClass()("Ripgrep.Error", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class InvalidPatternError extends Schema.TaggedErrorClass()("Ripgrep.InvalidPatternError", { @@ -91,7 +89,7 @@ const failure = (message: string, cause?: unknown) => new Error({ message, cause const isInvalidPattern = (stderr: string) => stderr.includes("regex parse error") || stderr.includes("error parsing regex") -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const process = yield* AppProcess.Service @@ -177,14 +175,12 @@ export const layer = Layer.effect( ), }).pipe( Effect.map((result) => - result.items.map((relative) => { - const absolute = path.resolve(input.cwd, relative) - return new Entry({ + result.items.map((relative) => + Entry.make({ path: RelativePath.make(relative), type: "file", - mime: FSUtil.mimeType(absolute), - }) - }), + }), + ), ), Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))), ), @@ -208,10 +204,9 @@ export const layer = Layer.effect( .replace(/^[\\/]+/u, "") .replaceAll("\\", "/") return Effect.succeed( - new Entry({ + Entry.make({ path: RelativePath.make(relative), type: "file", - mime: FSUtil.mimeType(path.resolve(input.cwd, relative)), }), ) }, @@ -262,12 +257,10 @@ export const layer = Layer.effect( .replace(/^(?:\.[\\/])+/u, "") .replace(/^[\\/]+/u, "") .replaceAll("\\", "/") - const absolute = path.resolve(input.cwd, relative) - return new Match({ - entry: new Entry({ + return Match.make({ + entry: Entry.make({ path: RelativePath.make(relative), type: "file", - mime: FSUtil.mimeType(absolute), }), line: match.line_number, offset: match.absolute_offset, @@ -285,5 +278,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer))) -export const node = LayerNode.make(layer, [RipgrepBinary.node, AppProcess.node]) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] }) diff --git a/packages/core/src/ripgrep/binary.ts b/packages/core/src/ripgrep/binary.ts index 99fa8a2fd0..762a8e613e 100644 --- a/packages/core/src/ripgrep/binary.ts +++ b/packages/core/src/ripgrep/binary.ts @@ -4,8 +4,8 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "../cross-spawn-spawner" -import { LayerNode } from "../effect/layer-node" -import { httpClient } from "../effect/layer-node-platform" +import { makeGlobalNode } from "../effect/app-node" +import { httpClient } from "../effect/app-node-platform" import { FSUtil } from "../fs-util" import { Global } from "../global" import { which } from "../util/which" @@ -28,7 +28,7 @@ export namespace RipgrepBinary { export class Service extends Context.Service()("@opencode/RipgrepBinary") {} - export const layer = Layer.effect( + const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -124,11 +124,9 @@ export namespace RipgrepBinary { }), ) - export const defaultLayer = layer.pipe( - Layer.provide(FetchHttpClient.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - ) - - export const node = LayerNode.make(layer, [FSUtil.node, httpClient, CrossSpawnSpawner.node]) + export const node = makeGlobalNode({ + service: Service, + layer: layer, + deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node], + }) } diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 97b24dbda8..9c25bee19b 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -1,47 +1,15 @@ -import { Option, Schema, SchemaGetter } from "effect" -import { Hash } from "./util/hash" +import { Schema } from "effect" +import { + AbsolutePath, + DateTimeUtcFromMillis, + NonNegativeInt, + optional, + PositiveInt, + RelativePath, + statics, +} from "@opencode-ai/schema/schema" -export type ExternalID = { - readonly namespace: string - readonly key: string -} - -export const externalID = (prefix: string, input: ExternalID) => - `${prefix}_${Hash.sha256(JSON.stringify([input.namespace, input.key]))}` - -/** - * Integer greater than zero. - */ -export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) - -/** - * Integer greater than or equal to zero. - */ -export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) - -/** - * Relative file path (e.g., `src/components/Button.tsx`). - */ -export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath")) -export type RelativePath = Schema.Schema.Type - -/** - * Absolute file path (e.g., `/home/user/projects/myapp/src/main.ts`). - */ -export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath")) -export type AbsolutePath = Schema.Schema.Type - -/** - * Optional public JSON field that can hold explicit `undefined` on the type - * side but encodes it as an omitted key, matching legacy `JSON.stringify`. - */ -export const optionalOmitUndefined = (schema: S) => - Schema.optionalKey(schema).pipe( - Schema.decodeTo(Schema.optional(schema), { - decode: SchemaGetter.passthrough({ strict: false }), - encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)), - }), - ) +export { AbsolutePath, DateTimeUtcFromMillis, NonNegativeInt, optional, PositiveInt, RelativePath, statics } /** * Strip `readonly` from a nested type. Stand-in for `effect`'s `Types.DeepMutable` @@ -71,22 +39,6 @@ export type DeepMutable = T extends string | number | boolean | bigint | symb ? { -readonly [K in keyof T]: DeepMutable } : T -/** - * Attach static methods to a schema object. Designed to be used with `.pipe()`: - * - * @example - * export const Foo = fooSchema.pipe( - * withStatics((schema) => ({ - * zero: schema.make(0), - * from: Schema.decodeUnknownOption(schema), - * })) - * ) - */ -export const withStatics = - >(methods: (schema: S) => M) => - (schema: S): S & M => - Object.assign(schema, methods(schema)) - /** * Nominal wrapper for scalar types. The class itself is a valid schema — * pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc. diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index d5163cf883..2dabfb2d6f 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,7 +1,8 @@ export * as SessionV2 from "./session" export * from "./session/schema" -import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect" +import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect" +import { ListAnchor } from "@opencode-ai/schema/session" import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm" import { ProjectV2 } from "./project" import { WorkspaceV2 } from "./workspace" @@ -9,6 +10,7 @@ import { ModelV2 } from "./model" import { Location } from "./location" import { SessionMessage } from "./session/message" import { Prompt } from "./session/prompt" +import { PromptInput } from "@opencode-ai/schema/prompt-input" import { EventV2 } from "./event" import { Database } from "./database/database" import { SessionProjector } from "./session/projector" @@ -25,10 +27,19 @@ import { fromRow } from "./session/info" import { SessionRunner } from "./session/runner/index" import { SessionStore } from "./session/store" import { SessionExecution } from "./session/execution" -import { logFailure } from "./session/logging" +import { makeGlobalNode } from "./effect/app-node" +import { LocationServiceMap } from "./location-service-map" import { MessageDecodeError } from "./session/error" import { SessionEvent } from "./session/event" import { SessionInput } from "./session/input" +import { Snapshot } from "./snapshot" +import { SessionRevert } from "./session/revert" +import { Revert } from "@opencode-ai/schema/revert" +import { FSUtil } from "./fs-util" +import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest" + +export const RevertState = Revert.State +export type RevertState = Revert.State // get project -> project.locations // @@ -39,12 +50,7 @@ import { SessionInput } from "./session/input" // - by subpath // - by workspace (home is special) -export const ListAnchor = Schema.Struct({ - id: SessionSchema.ID, - time: Schema.Finite, - direction: Schema.Literals(["previous", "next"]), -}) -export type ListAnchor = typeof ListAnchor.Type +export { ListAnchor } const ListInputBase = { workspaceID: WorkspaceV2.ID.pipe(Schema.optional), @@ -99,6 +105,8 @@ export class PromptConflictError extends Schema.TaggedErrorClass Effect.Effect readonly events: (input: { sessionID: SessionSchema.ID - after?: EventV2.Cursor - }) => Stream.Stream, NotFoundError> - readonly switchAgent: (input: { + after?: number + }) => Stream.Stream + readonly history: (input: { sessionID: SessionSchema.ID - agent: string - }) => Effect.Effect + after?: number + limit: number + }) => Effect.Effect<{ events: ReadonlyArray; hasMore: boolean }, NotFoundError> + readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID model: ModelV2.Ref @@ -137,7 +147,7 @@ export interface Interface { readonly prompt: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID - prompt: Prompt + prompt: PromptInput.Prompt delivery?: SessionInput.Delivery resume?: boolean }) => Effect.Effect @@ -155,36 +165,34 @@ export interface Interface { }) => Effect.Effect readonly compact: (input: CompactInput) => Effect.Effect readonly wait: (id: SessionSchema.ID) => Effect.Effect + readonly active: Effect.Effect> readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect + readonly revert: { + readonly stage: (input: { + sessionID: SessionSchema.ID + messageID: SessionMessage.ID + files?: boolean + }) => Effect.Effect + readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect + readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect + } } export class Service extends Context.Service()("@opencode/v2/Session") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { - const db = (yield* Database.Service).db + const database = yield* Database.Service + const db = database.db const events = yield* EventV2.Service const projects = yield* ProjectV2.Service const execution = yield* SessionExecution.Service const store = yield* SessionStore.Service + const locations = yield* LocationServiceMap.Service const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) const isDurableSessionEvent = Schema.is(SessionEvent.Durable) - const scope = yield* Effect.scope - - const enqueueWake = (admitted: SessionInput.Admitted) => - execution.wake(admitted.sessionID, admitted.admittedSeq).pipe( - Effect.tapCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.void - : logFailure("Failed to wake Session", admitted.sessionID, cause), - ), - Effect.ignore, - Effect.forkIn(scope, { startImmediately: true }), - Effect.asVoid, - ) - const decode = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( Effect.mapError( @@ -339,27 +347,28 @@ export const layer = Layer.effect( Stream.unwrap( result .get(input.sessionID) - .pipe(Effect.as(events.aggregateEvents({ aggregateID: input.sessionID, after: input.after }))), - ).pipe( - Stream.filter((event): event is EventV2.CursorEvent => - isDurableSessionEvent(event.event), - ), - ), + .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))), + ).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))), + history: Effect.fn("V2Session.history")(function* (input) { + yield* result.get(input.sessionID) + return yield* EventV2.readAggregate(db, { + ...input, + aggregateID: input.sessionID, + manifest: SessionDurable, + }) + }), prompt: Effect.fn("V2Session.prompt")((input) => Effect.uninterruptible( Effect.gen(function* () { yield* result.get(input.sessionID) - const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) { - if (input.resume !== false) yield* enqueueWake(admitted) - return admitted - }, Effect.uninterruptible) + const prompt = resolvePrompt(input.prompt) const messageID = input.id ?? SessionMessage.ID.create() const delivery = input.delivery ?? "steer" - const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery } + const expected = { sessionID: input.sessionID, messageID, prompt, delivery } const admitted = yield* SessionInput.admit(db, events, { id: messageID, sessionID: input.sessionID, - prompt: input.prompt, + prompt, delivery, }).pipe( Effect.catchDefect((defect) => @@ -370,7 +379,8 @@ export const layer = Layer.effect( ) if (!SessionInput.equivalent(admitted, expected)) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) - return yield* returnPrompt(admitted) + if (input.resume !== false) yield* execution.wake(admitted.sessionID) + return admitted }), ), ), @@ -380,11 +390,23 @@ export const layer = Layer.effect( skill: Effect.fn("V2Session.skill")(function* () { return yield* new OperationUnavailableError({ operation: "skill" }) }), - switchAgent: Effect.fn("V2Session.switchAgent")(function* () { - return yield* new OperationUnavailableError({ operation: "switchAgent" }) + switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { + yield* result.get(input.sessionID) + yield* events.publish(SessionEvent.AgentSwitched, { + sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + agent: input.agent, + }) }), switchModel: Effect.fn("V2Session.switchModel")(function* (input) { - yield* result.get(input.sessionID) + const session = yield* result.get(input.sessionID) + if ( + session.model?.providerID === input.model.providerID && + session.model.id === input.model.id && + (session.model.variant ?? "default") === (input.model.variant ?? "default") + ) + return yield* events.publish(SessionEvent.ModelSwitched, { sessionID: input.sessionID, messageID: SessionMessage.ID.create(), @@ -400,37 +422,65 @@ export const layer = Layer.effect( yield* result.get(sessionID) return yield* new OperationUnavailableError({ operation: "wait" }) }), + active: execution.active, resume: Effect.fn("V2Session.resume")(function* (sessionID) { yield* result.get(sessionID) yield* execution.resume(sessionID) }), interrupt: Effect.fn("V2Session.interrupt")((sessionID) => - Effect.uninterruptible( - Effect.gen(function* () { - const session = yield* store.get(sessionID) - if (!session) return yield* execution.interrupt(sessionID) - const event = yield* events.publish(SessionEvent.InterruptRequested, { - sessionID, - timestamp: yield* DateTime.now, - }) - if (event.seq === undefined) - return yield* Effect.die("Interrupt request event is missing aggregate sequence") - yield* execution.interrupt(sessionID, event.seq) - }), - ), + Effect.uninterruptible(execution.interrupt(sessionID)), ), + revert: { + stage: Effect.fn("V2Session.revert.stage")(function* (input) { + const session = yield* result.get(input.sessionID) + return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe( + Effect.provideService(Database.Service, database), + Effect.provideService(EventV2.Service, events), + Effect.provide(locations.get(session.location)), + ) + }), + clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) { + const session = yield* result.get(sessionID) + yield* SessionRevert.clear(session).pipe( + Effect.provideService(EventV2.Service, events), + Effect.provide(locations.get(session.location)), + ) + }), + commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) { + const session = yield* result.get(sessionID) + yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events)) + }), + }, }) return result }), ) -export const defaultLayer = layer.pipe( - Layer.provide(SessionExecution.noopLayer), - Layer.provide(SessionStore.defaultLayer), - Layer.provide(SessionProjector.defaultLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.orDie, -) +const resolvePrompt = (input: PromptInput.Prompt) => + Prompt.make({ + text: input.text, + agents: input.agents, + files: input.files?.map((file) => { + const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1] + const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri) + return { + ...file, + mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)), + } + }), + }) + +export const node = makeGlobalNode({ + service: Service, + layer: layer.pipe(Layer.orDie), + deps: [ + Database.node, + EventV2.node, + ProjectV2.node, + SessionExecution.node, + SessionStore.node, + LocationServiceMap.node, + SessionProjector.node, + ], +}) diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 1fb8df92e6..65b17a86a4 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -1,54 +1,31 @@ export * as SessionContextEpoch from "./context-epoch" -import { and, eq, isNull, lt, or, sql } from "drizzle-orm" +import { eq } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" -import { AgentV2 } from "../agent" import type { Database } from "../database/database" import { EventV2 } from "../event" -import { Location } from "../location" import { SystemContext } from "../system-context/index" import { ContextSnapshotDecodeError } from "./error" import { SessionEvent } from "./event" +import { SessionHistory } from "./history" import { SessionInput } from "./input" -import { SessionMessageID } from "./message-id" +import { SessionMessage } from "./message" import { SessionSchema } from "./schema" -import { SessionContextEpochTable, SessionTable } from "./sql" +import { SessionContextEpochTable } from "./sql" type DatabaseService = Database.Interface["db"] -class RevisionMismatch extends Error {} -class LocationMismatch extends Error {} -export class AgentMismatch extends Error {} -export class AgentReplacementBlocked extends Schema.TaggedErrorClass()( - "SessionContextEpoch.AgentReplacementBlocked", - { sessionID: SessionSchema.ID, previous: AgentV2.ID, current: AgentV2.ID }, -) {} - -const retryRevisionMismatch = (attempt: () => Effect.Effect): Effect.Effect => - attempt().pipe( - Effect.catchDefect((defect) => - defect instanceof RevisionMismatch - ? Effect.yieldNow.pipe(Effect.andThen(retryRevisionMismatch(attempt))) - : Effect.die(defect), - ), - ) - interface Prepared { readonly baseline: string readonly baselineSeq: number - readonly revision: number } export function initialize( db: DatabaseService, context: Effect.Effect, sessionID: SessionSchema.ID, - location: Location.Ref, - agent: AgentV2.ID, ): Effect.Effect { - return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location, agent)).pipe( - Effect.withSpan("SessionContextEpoch.initialize"), - ) + return initializeOnce(db, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.initialize")) } export function prepare( @@ -56,12 +33,8 @@ export function prepare( events: EventV2.Interface, context: Effect.Effect, sessionID: SessionSchema.ID, - location: Location.Ref, - agent: AgentV2.ID, -): Effect.Effect { - return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location, agent)).pipe( - Effect.withSpan("SessionContextEpoch.prepare"), - ) +): Effect.Effect { + return prepareOnce(db, events, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.prepare")) } const prepareOnce = Effect.fnUntraced(function* ( @@ -69,57 +42,50 @@ const prepareOnce = Effect.fnUntraced(function* ( events: EventV2.Interface, context: Effect.Effect, sessionID: SessionSchema.ID, - location: Location.Ref, - agent: AgentV2.ID, ) { - const [value, stored] = yield* Effect.all([context, find(db, sessionID)], { concurrency: "unbounded" }) + const [value, stored, compaction] = yield* Effect.all( + [context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)], + { concurrency: "unbounded" }, + ) if (!stored) { const generation = yield* SystemContext.initialize(value) - const baselineSeq = yield* insert(db, sessionID, location, agent, generation) - return { baseline: generation.baseline, baselineSeq, revision: 0 } + const baselineSeq = yield* insert(db, sessionID, generation) + return { baseline: generation.baseline, baselineSeq } } const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe( Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })), ) - const replacingAgent = stored.agent !== agent - const result = - stored.replacement_seq === null && !replacingAgent - ? yield* SystemContext.reconcile(value, snapshot) - : yield* SystemContext.replace(value, snapshot) - if (result._tag === "ReplacementBlocked" && replacingAgent) { - yield* fence(db, sessionID, agent, stored.revision) - return yield* new AgentReplacementBlocked({ sessionID, previous: stored.agent, current: agent }) - } + const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined + const result = replacementSeq + ? yield* SystemContext.replace(value, snapshot) + : yield* SystemContext.reconcile(value, snapshot) if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") { - yield* fence(db, sessionID, agent, stored.revision) - return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision } + return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } } if (result._tag === "ReplacementReady") { - const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID)) - yield* replace(db, sessionID, agent, stored.revision, replacementSeq, result.generation) - return { baseline: result.generation.baseline, baselineSeq: replacementSeq, revision: stored.revision + 1 } + const baselineSeq = replacementSeq ?? (yield* EventV2.latestSequence(db, sessionID)) + yield* replace(db, sessionID, baselineSeq, result.generation) + return { baseline: result.generation.baseline, baselineSeq } } yield* events.publish( SessionEvent.ContextUpdated, - { sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text }, - { commit: () => advance(db, sessionID, stored.revision, result.snapshot).pipe(Effect.orDie) }, + { sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text }, + { commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) }, ) - return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision + 1 } + return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } }) const initializeOnce = Effect.fnUntraced(function* ( db: DatabaseService, context: Effect.Effect, sessionID: SessionSchema.ID, - location: Location.Ref, - agent: AgentV2.ID, ) { if (yield* exists(db, sessionID)) return const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize)) - const baselineSeq = yield* insert(db, sessionID, location, agent, generation) - return { baseline: generation.baseline, baselineSeq, revision: 0 } + const baselineSeq = yield* insert(db, sessionID, generation) + return { baseline: generation.baseline, baselineSeq } }) const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { @@ -142,39 +108,6 @@ const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseServic .pipe(Effect.orDie) }) -const requireAgentSelection = Effect.fnUntraced(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - agent: AgentV2.ID, -) { - const selected = yield* db - .select({ agent: SessionTable.agent }) - .from(SessionTable) - .where(eq(SessionTable.id, sessionID)) - .get() - .pipe(Effect.orDie) - if (!selected || (selected.agent !== null && selected.agent !== agent)) return yield* Effect.die(new AgentMismatch()) -}) - -export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - seq: number, -) { - return yield* db - .update(SessionContextEpochTable) - .set({ replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` }) - .where( - and( - eq(SessionContextEpochTable.session_id, sessionID), - lt(SessionContextEpochTable.baseline_seq, seq), - or(isNull(SessionContextEpochTable.replacement_seq), lt(SessionContextEpochTable.replacement_seq, seq)), - ), - ) - .run() - .pipe(Effect.orDie) -}) - export const reset = Effect.fn("SessionContextEpoch.reset")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, @@ -189,155 +122,53 @@ export const reset = Effect.fn("SessionContextEpoch.reset")(function* ( const insert = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, - location: Location.Ref, - agent: AgentV2.ID, generation: SystemContext.Generation, ) { - return yield* db - .transaction( - () => - Effect.gen(function* () { - const placed = yield* db - .select({ agent: SessionTable.agent }) - .from(SessionTable) - .where( - and( - eq(SessionTable.id, sessionID), - eq(SessionTable.directory, location.directory), - location.workspaceID === undefined - ? isNull(SessionTable.workspace_id) - : eq(SessionTable.workspace_id, location.workspaceID), - ), - ) - .get() - .pipe(Effect.orDie) - if (!placed) return yield* Effect.die(new LocationMismatch()) - if (placed.agent !== null && placed.agent !== agent) return yield* Effect.die(new AgentMismatch()) - const baselineSeq = yield* SessionInput.latestSeq(db, sessionID) - yield* db - .insert(SessionContextEpochTable) - .values({ - session_id: sessionID, - baseline: generation.baseline, - agent, - snapshot: generation.snapshot, - baseline_seq: baselineSeq, - revision: 0, - }) - .onConflictDoNothing() - .returning({ sessionID: SessionContextEpochTable.session_id }) - .get() - .pipe( - Effect.orDie, - Effect.flatMap((inserted) => (inserted ? Effect.void : Effect.die(new RevisionMismatch()))), - ) - return baselineSeq - }), - { behavior: "immediate" }, - ) + const baselineSeq = yield* EventV2.latestSequence(db, sessionID) + yield* db + .insert(SessionContextEpochTable) + .values({ + session_id: sessionID, + baseline: generation.baseline, + snapshot: generation.snapshot, + baseline_seq: baselineSeq, + }) + .run() .pipe(Effect.orDie) + return baselineSeq }) const replace = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, - agent: AgentV2.ID, - expectedRevision: number, baselineSeq: number, generation: SystemContext.Generation, ) { - yield* db - .transaction( - () => - Effect.gen(function* () { - yield* requireAgentSelection(db, sessionID, agent) - const updated = yield* db - .update(SessionContextEpochTable) - .set({ - baseline: generation.baseline, - agent, - snapshot: generation.snapshot, - baseline_seq: baselineSeq, - replacement_seq: null, - revision: expectedRevision + 1, - }) - .where( - and( - eq(SessionContextEpochTable.session_id, sessionID), - eq(SessionContextEpochTable.revision, expectedRevision), - ), - ) - .returning({ revision: SessionContextEpochTable.revision }) - .get() - .pipe(Effect.orDie) - if (!updated) return yield* Effect.die(new RevisionMismatch()) - }), - { behavior: "immediate" }, - ) - .pipe(Effect.orDie) -}) - -const fence = Effect.fnUntraced(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - agent: AgentV2.ID, - expectedRevision: number, -) { - const current = yield* db - .select({ selected: SessionTable.agent, revision: SessionContextEpochTable.revision }) - .from(SessionContextEpochTable) - .innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id)) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get() - .pipe(Effect.orDie) - if (!current || (current.selected !== null && current.selected !== agent)) - return yield* Effect.die(new AgentMismatch()) - if (current.revision !== expectedRevision) return yield* Effect.die(new RevisionMismatch()) -}) - -export const current = Effect.fn("SessionContextEpoch.current")(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - agent: AgentV2.ID, - revision: number, -) { - const value = yield* db - .select({ - agent: SessionContextEpochTable.agent, - selected: SessionTable.agent, - revision: SessionContextEpochTable.revision, + const updated = yield* db + .update(SessionContextEpochTable) + .set({ + baseline: generation.baseline, + snapshot: generation.snapshot, + baseline_seq: baselineSeq, }) - .from(SessionContextEpochTable) - .innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id)) .where(eq(SessionContextEpochTable.session_id, sessionID)) + .returning({ sessionID: SessionContextEpochTable.session_id }) .get() .pipe(Effect.orDie) - return ( - value !== undefined && - value.agent === agent && - (value.selected === null || value.selected === agent) && - value.revision === revision - ) + if (!updated) return yield* Effect.die("Context Epoch not found") }) const advance = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, - expectedRevision: number, snapshot: SystemContext.Snapshot, ) { const updated = yield* db .update(SessionContextEpochTable) - .set({ snapshot, revision: expectedRevision + 1 }) - .where( - and( - eq(SessionContextEpochTable.session_id, sessionID), - eq(SessionContextEpochTable.revision, expectedRevision), - isNull(SessionContextEpochTable.replacement_seq), - ), - ) - .returning({ revision: SessionContextEpochTable.revision }) + .set({ snapshot }) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .returning({ sessionID: SessionContextEpochTable.session_id }) .get() .pipe(Effect.orDie) - if (!updated) return yield* Effect.die(new RevisionMismatch()) + if (!updated) return yield* Effect.die("Context Epoch not found") }) diff --git a/packages/core/src/session/error.ts b/packages/core/src/session/error.ts index 16b784c30d..158e46dd07 100644 --- a/packages/core/src/session/error.ts +++ b/packages/core/src/session/error.ts @@ -5,7 +5,11 @@ import { SessionSchema } from "./schema" export class MessageDecodeError extends Schema.TaggedErrorClass()("Session.MessageDecodeError", { sessionID: SessionSchema.ID, messageID: SessionMessage.ID, -}) {} +}) { + override get message() { + return `Failed to decode message ${this.messageID} in session ${this.sessionID}` + } +} export class ContextSnapshotDecodeError extends Schema.TaggedErrorClass()( "Session.ContextSnapshotDecodeError", diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index 3472cc114a..78e40a6994 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -1,511 +1,2 @@ -import { Schema } from "effect" -import { ProviderMetadata, ToolContent } from "@opencode-ai/llm" -import { EventV2 } from "../event" -import { ModelV2 } from "../model" -import { NonNegativeInt } from "../schema" -import { V2Schema } from "../v2-schema" -import { FileAttachment, Prompt } from "./prompt" -import { SessionSchema } from "./schema" -import { Location } from "../location" -import { RelativePath } from "../schema" -import { SessionMessageID } from "./message-id" - -export { FileAttachment } - -export const Source = Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, - text: Schema.String, -}).annotate({ - identifier: "session.next.event.source", -}) -export type Source = typeof Source.Type - -const Base = { - timestamp: V2Schema.DateTimeUtcFromMillis, - sessionID: SessionSchema.ID, -} - -const options = { - sync: { - aggregate: "sessionID", - version: 1, - }, -} as const -const stepSettlementOptions = { - sync: { - aggregate: "sessionID", - version: 2, - }, -} as const - -export const UnknownError = Schema.Struct({ - type: Schema.Literal("unknown"), - message: Schema.String, -}).annotate({ - identifier: "Session.Error.Unknown", -}) -export type UnknownError = typeof UnknownError.Type - -export const AgentSwitched = EventV2.define({ - type: "session.next.agent.switched", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - agent: Schema.String, - }, -}) -export type AgentSwitched = typeof AgentSwitched.Type - -export const ModelSwitched = EventV2.define({ - type: "session.next.model.switched", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - model: ModelV2.Ref, - }, -}) -export type ModelSwitched = typeof ModelSwitched.Type - -export const Moved = EventV2.define({ - type: "session.next.moved", - ...options, - schema: { - ...Base, - location: Location.Ref, - subdirectory: RelativePath.pipe(Schema.optional), - }, -}) -export type Moved = typeof Moved.Type - -export const Prompted = EventV2.define({ - type: "session.next.prompted", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - prompt: Prompt, - delivery: Schema.Literals(["steer", "queue"]), - }, -}) -export type Prompted = typeof Prompted.Type - -export namespace PromptLifecycle { - export const Admitted = EventV2.define({ - type: "session.next.prompt.admitted", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - prompt: Prompt, - delivery: Schema.Literals(["steer", "queue"]), - }, - }) - export type Admitted = typeof Admitted.Type - - export const Promoted = EventV2.define({ - type: "session.next.prompt.promoted", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - prompt: Prompt, - timeCreated: V2Schema.DateTimeUtcFromMillis, - }, - }) - export type Promoted = typeof Promoted.Type -} - -export const InterruptRequested = EventV2.define({ - type: "session.next.interrupt.requested", - ...options, - schema: Base, -}) -export type InterruptRequested = typeof InterruptRequested.Type - -export const ContextUpdated = EventV2.define({ - type: "session.next.context.updated", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - text: Schema.String, - }, -}) -export type ContextUpdated = typeof ContextUpdated.Type - -export const Synthetic = EventV2.define({ - type: "session.next.synthetic", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - text: Schema.String, - }, -}) -export type Synthetic = typeof Synthetic.Type - -export namespace Shell { - export const Started = EventV2.define({ - type: "session.next.shell.started", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - callID: Schema.String, - command: Schema.String, - }, - }) - export type Started = typeof Started.Type - - export const Ended = EventV2.define({ - type: "session.next.shell.ended", - ...options, - schema: { - ...Base, - callID: Schema.String, - output: Schema.String, - }, - }) - export type Ended = typeof Ended.Type -} - -export namespace Step { - export const Started = EventV2.define({ - type: "session.next.step.started", - ...options, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - agent: Schema.String, - model: ModelV2.Ref, - snapshot: Schema.String.pipe(Schema.optional), - }, - }) - export type Started = typeof Started.Type - - export const Ended = EventV2.define({ - type: "session.next.step.ended", - ...stepSettlementOptions, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - finish: Schema.String, - cost: Schema.Finite, - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), - snapshot: Schema.String.pipe(Schema.optional), - }, - }) - export type Ended = typeof Ended.Type - - export const Failed = EventV2.define({ - type: "session.next.step.failed", - ...stepSettlementOptions, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - error: UnknownError, - }, - }) - export type Failed = typeof Failed.Type -} - -export namespace Text { - export const Started = EventV2.define({ - type: "session.next.text.started", - ...options, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - textID: Schema.String, - }, - }) - export type Started = typeof Started.Type - - // Stream fragments are live-only; Text.Ended is the replayable full-value boundary. - export const Delta = EventV2.define({ - type: "session.next.text.delta", - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - textID: Schema.String, - delta: Schema.String, - }, - }) - export type Delta = typeof Delta.Type - - export const Ended = EventV2.define({ - type: "session.next.text.ended", - ...options, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - textID: Schema.String, - text: Schema.String, - }, - }) - export type Ended = typeof Ended.Type -} - -export namespace Reasoning { - export const Started = EventV2.define({ - type: "session.next.reasoning.started", - ...options, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - reasoningID: Schema.String, - providerMetadata: ProviderMetadata.pipe(Schema.optional), - }, - }) - export type Started = typeof Started.Type - - // Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary. - export const Delta = EventV2.define({ - type: "session.next.reasoning.delta", - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - reasoningID: Schema.String, - delta: Schema.String, - }, - }) - export type Delta = typeof Delta.Type - - export const Ended = EventV2.define({ - type: "session.next.reasoning.ended", - ...options, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - reasoningID: Schema.String, - text: Schema.String, - providerMetadata: ProviderMetadata.pipe(Schema.optional), - }, - }) - export type Ended = typeof Ended.Type -} - -export namespace Tool { - const ToolBase = { - ...Base, - assistantMessageID: SessionMessageID.ID, - callID: Schema.String, - } - - export namespace Input { - export const Started = EventV2.define({ - type: "session.next.tool.input.started", - ...options, - schema: { - ...ToolBase, - name: Schema.String, - }, - }) - export type Started = typeof Started.Type - - // Stream fragments are live-only; Input.Ended is the replayable raw-input boundary. - export const Delta = EventV2.define({ - type: "session.next.tool.input.delta", - schema: { - ...ToolBase, - delta: Schema.String, - }, - }) - export type Delta = typeof Delta.Type - - export const Ended = EventV2.define({ - type: "session.next.tool.input.ended", - ...options, - schema: { - ...ToolBase, - text: Schema.String, - }, - }) - export type Ended = typeof Ended.Type - } - - export const Called = EventV2.define({ - type: "session.next.tool.called", - ...options, - schema: { - ...ToolBase, - tool: Schema.String, - input: Schema.Record(Schema.String, Schema.Unknown), - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(Schema.optional), - }), - }, - }) - export type Called = typeof Called.Type - - /** - * Replayable bounded running-tool state. Tools should checkpoint semantic - * transitions or at a bounded cadence, not persist every stdout/stderr chunk. - */ - export const Progress = EventV2.define({ - type: "session.next.tool.progress", - ...options, - schema: { - ...ToolBase, - structured: Schema.Record(Schema.String, Schema.Any), - content: Schema.Array(ToolContent), - }, - }) - export type Progress = typeof Progress.Type - - export const Success = EventV2.define({ - type: "session.next.tool.success", - ...options, - schema: { - ...ToolBase, - structured: Schema.Record(Schema.String, Schema.Any), - content: Schema.Array(ToolContent), - outputPaths: Schema.Array(Schema.String).pipe(Schema.optional), - result: Schema.Unknown.pipe(Schema.optional), - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(Schema.optional), - }), - }, - }) - export type Success = typeof Success.Type - - export const Failed = EventV2.define({ - type: "session.next.tool.failed", - ...options, - schema: { - ...ToolBase, - error: UnknownError, - result: Schema.Unknown.pipe(Schema.optional), - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(Schema.optional), - }), - }, - }) - export type Failed = typeof Failed.Type -} - -export const RetryError = Schema.Struct({ - message: Schema.String, - statusCode: Schema.Finite.pipe(Schema.optional), - isRetryable: Schema.Boolean, - responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - responseBody: Schema.String.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), -}).annotate({ - identifier: "session.next.retry_error", -}) -export type RetryError = typeof RetryError.Type - -export const Retried = EventV2.define({ - type: "session.next.retried", - ...options, - schema: { - ...Base, - attempt: Schema.Finite, - error: RetryError, - }, -}) -export type Retried = typeof Retried.Type - -export namespace Compaction { - export const Started = EventV2.define({ - type: "session.next.compaction.started", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), - }, - }) - export type Started = typeof Started.Type - - export const Delta = EventV2.define({ - type: "session.next.compaction.delta", - schema: { - ...Base, - messageID: SessionMessageID.ID, - text: Schema.String, - }, - }) - export type Delta = typeof Delta.Type - - // Retain the unpublished v1 decoder so stored beta events remain replayable. - export const EndedV1 = EventV2.define({ - type: "session.next.compaction.ended", - ...options, - schema: { - ...Base, - text: Schema.String, - include: Schema.String.pipe(Schema.optional), - }, - }) - - export const Ended = EventV2.define({ - type: "session.next.compaction.ended", - sync: { aggregate: "sessionID", version: 2 }, - schema: { - ...Base, - messageID: SessionMessageID.ID, - reason: Started.data.fields.reason, - text: Schema.String, - recent: Schema.String, - }, - }) - export type Ended = typeof Ended.Type -} - -const DurableDefinitions = [ - AgentSwitched, - ModelSwitched, - Moved, - Prompted, - PromptLifecycle.Admitted, - PromptLifecycle.Promoted, - InterruptRequested, - ContextUpdated, - Synthetic, - Shell.Started, - Shell.Ended, - Step.Started, - Step.Ended, - Step.Failed, - Text.Started, - Text.Ended, - Tool.Input.Started, - Tool.Input.Ended, - Tool.Called, - Tool.Progress, - Tool.Success, - Tool.Failed, - Reasoning.Started, - Reasoning.Ended, - Retried, - Compaction.Started, - Compaction.Ended, -] as const -const EphemeralDefinitions = [Text.Delta, Tool.Input.Delta, Reasoning.Delta, Compaction.Delta] as const - -export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type")) -export type DurableEvent = typeof Durable.Type - -export const All = Schema.Union([...DurableDefinitions, ...EphemeralDefinitions], { mode: "oneOf" }).pipe( - Schema.toTaggedUnion("type"), -) -export type Event = typeof All.Type -export type Type = Event["type"] - -export * as SessionEvent from "./event" +export * from "@opencode-ai/schema/session-event" +export * as SessionEvent from "@opencode-ai/schema/session-event" diff --git a/packages/core/src/session/execution.ts b/packages/core/src/session/execution.ts index 9a99145bfb..5938c37726 100644 --- a/packages/core/src/session/execution.ts +++ b/packages/core/src/session/execution.ts @@ -1,23 +1,34 @@ export * as SessionExecution from "./execution" import { Context, Effect, Layer } from "effect" +import { LayerNode } from "../effect/layer-node" +import { Node } from "../effect/app-node" import { SessionRunner } from "./runner/index" import { SessionSchema } from "./schema" export interface Interface { - /** Explicitly drain one Session, making at least one provider attempt. */ + /** Snapshots active execution owned by this process. */ + readonly active: Effect.Effect> + /** Starts execution while idle or joins the active execution. */ readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect - /** Schedule a drain after durable work is recorded. Repeated wakeups may coalesce. */ - readonly wake: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect + /** Registers newly recorded work. Repeated wakeups may coalesce. */ + readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect /** Interrupt active work owned by this process. Idle interruption is a no-op. */ - readonly interrupt: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect + readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect } /** Routes execution from a Session ID to the runner owned by that Session's Location. */ export class Service extends Context.Service()("@opencode/v2/SessionExecution") {} +export const node = LayerNode.unbound(Service, Node.tags.values.global) + /** Low-level compatibility layer for callers that only need durable Session recording. */ export const noopLayer = Layer.succeed( Service, - Service.of({ resume: () => Effect.void, wake: () => Effect.void, interrupt: () => Effect.void }), + Service.of({ + active: Effect.succeed(new Set()), + resume: () => Effect.void, + wake: () => Effect.void, + interrupt: () => Effect.void, + }), ) diff --git a/packages/core/src/session/execution/local.ts b/packages/core/src/session/execution/local.ts index 8f1b1763a0..d874c40832 100644 --- a/packages/core/src/session/execution/local.ts +++ b/packages/core/src/session/execution/local.ts @@ -1,30 +1,35 @@ -import { Effect, Layer } from "effect" -import { LocationServiceMap } from "../../location-layer" +import { Cause, Effect, Layer } from "effect" +import { LocationServiceMap } from "../../location-service-map" +import { makeGlobalNode } from "../../effect/app-node" import { SessionRunCoordinator } from "../run-coordinator" import { SessionRunner } from "../runner" import { SessionSchema } from "../schema" import { SessionStore } from "../store" import { SessionExecution } from "../execution" -import { logFailure } from "../logging" /** Current-process routing for implicit-local Locations. Future remote placement belongs here. */ -export const layer = Layer.effect( +const layer = Layer.effect( SessionExecution.Service, Effect.gen(function* () { const store = yield* SessionStore.Service - const locations = yield* LocationServiceMap - const coordinator = yield* SessionRunCoordinator.make({ - drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, mode) { + const locations = yield* LocationServiceMap.Service + const coordinator = yield* SessionRunCoordinator.make({ + drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) { const session = yield* store.get(sessionID) if (!session) return yield* Effect.die(`Session not found: ${sessionID}`) - return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force: mode === "run" })).pipe( + return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force })).pipe( Effect.provide(locations.get(session.location)), + Effect.tapCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })), + ), ) }), - onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause), }) return SessionExecution.Service.of({ + active: coordinator.active, interrupt: coordinator.interrupt, resume: coordinator.run, wake: coordinator.wake, @@ -32,4 +37,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer)) +export const node = makeGlobalNode({ + service: SessionExecution.Service, + layer, + deps: [SessionStore.node, LocationServiceMap.node], +}) + +export * as SessionExecutionLocal from "./local" diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index 285c1bcd5c..fb55ab0756 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -10,9 +10,9 @@ type DatabaseService = Database.Interface["db"] const decode = Schema.decodeUnknownEffect(SessionMessage.Message) -const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { +export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { return yield* db - .select() + .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) .orderBy(desc(SessionMessageTable.seq)) diff --git a/packages/core/src/session/info.ts b/packages/core/src/session/info.ts index 2308d06460..66832750fd 100644 --- a/packages/core/src/session/info.ts +++ b/packages/core/src/session/info.ts @@ -8,6 +8,8 @@ import { AbsolutePath, RelativePath } from "../schema" import { WorkspaceV2 } from "../workspace" import { SessionSchema } from "./schema" import { SessionTable } from "./sql" +import { SessionMessage } from "./message" +import { Snapshot } from "../snapshot" export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info { return SessionSchema.Info.make({ @@ -38,6 +40,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined, }), subpath: row.path ? RelativePath.make(row.path) : undefined, + revert: row.revert ? { ...row.revert, messageID: SessionMessage.ID.make(row.revert.messageID) } : undefined, time: { created: DateTime.makeUnsafe(row.time_created), updated: DateTime.makeUnsafe(row.time_updated), diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index 041c629988..14b613678d 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -2,11 +2,9 @@ export * as SessionInput from "./input" import { and, asc, eq, isNull, lte } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" +import { Admitted, Delivery } from "@opencode-ai/schema/session-input" import type { Database } from "../database/database" import type { EventV2 } from "../event" -import { EventSequenceTable } from "../event/sql" -import { NonNegativeInt } from "../schema" -import { V2Schema } from "../v2-schema" import { SessionEvent } from "./event" import { SessionMessage } from "./message" import { Prompt } from "./prompt" @@ -15,24 +13,13 @@ import { SessionInputTable, SessionMessageTable } from "./sql" type DatabaseService = Database.Interface["db"] -export const Delivery = Schema.Literals(["steer", "queue"]) -export type Delivery = typeof Delivery.Type - -export class Admitted extends Schema.Class("SessionInput.Admitted")({ - admittedSeq: NonNegativeInt, - id: SessionMessage.ID, - sessionID: SessionSchema.ID, - prompt: Prompt, - delivery: Delivery, - timeCreated: V2Schema.DateTimeUtcFromMillis, - promotedSeq: NonNegativeInt.pipe(Schema.optional), -}) {} +export { Admitted, Delivery } const decodePrompt = Schema.decodeUnknownSync(Prompt) const encodePrompt = Schema.encodeSync(Prompt) const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted => - new Admitted({ + Admitted.make({ admittedSeq: row.admitted_seq, id: SessionMessage.ID.make(row.id), sessionID: SessionSchema.ID.make(row.session_id), @@ -65,7 +52,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( if (existing !== undefined) return existing const timestamp = yield* DateTime.now return yield* events - .publish(SessionEvent.PromptLifecycle.Admitted, { + .publish(SessionEvent.PromptAdmitted, { messageID: input.id, sessionID: input.sessionID, timestamp, @@ -74,11 +61,11 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( }) .pipe( Effect.flatMap((event) => - event.seq === undefined + event.durable === undefined ? Effect.die("Prompt admission event is missing aggregate sequence") : Effect.succeed( - new Admitted({ - admittedSeq: event.seq, + Admitted.make({ + admittedSeq: event.durable.seq, id: input.id, sessionID: input.sessionID, prompt: input.prompt, @@ -93,19 +80,6 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( ) }) -export const latestSeq = Effect.fn("SessionInput.latestSeq")(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, -) { - const row = yield* db - .select({ seq: EventSequenceTable.seq }) - .from(EventSequenceTable) - .where(eq(EventSequenceTable.aggregate_id, sessionID)) - .get() - .pipe(Effect.orDie) - return row?.seq ?? -1 -}) - export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* ( db: DatabaseService, input: { @@ -123,7 +97,7 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio .where(eq(SessionMessageTable.id, input.id)) .get() .pipe(Effect.orDie) - if (message) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id })) const stored = yield* db .insert(SessionInputTable) .values({ @@ -141,12 +115,13 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id })) }) -export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(function* ( +export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(function* ( db: DatabaseService, input: { readonly id: SessionMessage.ID readonly sessionID: SessionSchema.ID readonly prompt: Prompt + readonly delivery: Delivery readonly timeCreated: DateTime.Utc readonly promotedSeq: number }, @@ -164,14 +139,32 @@ export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(functio .returning() .get() .pipe(Effect.orDie) - if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id })) - const stored = fromRow(updated) - if ( - !matchesPrompt(stored, input) || - DateTime.toEpochMillis(stored.timeCreated) !== DateTime.toEpochMillis(input.timeCreated) - ) - return yield* Effect.die(new LifecycleConflict({ id: input.id })) - return toMessage(stored) + if (updated) { + const stored = fromRow(updated) + if (!matchesProjection(stored, input)) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + return + } + + const stored = yield* find(db, input.id) + if (stored) { + if (!matchesProjection(stored, input) || stored.promotedSeq !== input.promotedSeq) + return yield* Effect.die(new LifecycleConflict({ id: input.id })) + return + } + + yield* db + .insert(SessionInputTable) + .values({ + id: input.id, + session_id: input.sessionID, + prompt: encodePrompt(input.prompt), + delivery: input.delivery, + admitted_seq: input.promotedSeq, + promoted_seq: input.promotedSeq, + time_created: DateTime.toEpochMillis(input.timeCreated), + }) + .run() + .pipe(Effect.orDie) }) export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( @@ -208,66 +201,17 @@ const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionS input.sessionID === expected.sessionID && JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt)) -export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(function* ( - db: DatabaseService, - event: EventV2.Payload, -) { - if ( - Schema.is(SessionEvent.PromptLifecycle.Admitted)(event) || - Schema.is(SessionEvent.PromptLifecycle.Promoted)(event) - ) - return - const id = reservedID(event) - if (id === undefined) return - const admitted = yield* db - .select({ id: SessionInputTable.id }) - .from(SessionInputTable) - .where(eq(SessionInputTable.id, id)) - .get() - .pipe(Effect.orDie) - if (admitted === undefined) return - return yield* Effect.die(new LifecycleConflict({ id })) -}) - -const reservedID = (event: EventV2.Payload) => { - if (Schema.is(SessionEvent.Step.Started)(event)) return event.data.assistantMessageID - if (Schema.is(SessionEvent.AgentSwitched)(event)) return event.data.messageID - if (Schema.is(SessionEvent.ModelSwitched)(event)) return event.data.messageID - if (Schema.is(SessionEvent.Prompted)(event)) return event.data.messageID - if (Schema.is(SessionEvent.Synthetic)(event)) return event.data.messageID - if (Schema.is(SessionEvent.Shell.Started)(event)) return event.data.messageID - if (Schema.is(SessionEvent.Compaction.Started)(event)) return event.data.messageID -} - -export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* ( - db: DatabaseService, - input: { - readonly id: SessionMessage.ID +const matchesProjection = ( + input: Admitted, + expected: { readonly sessionID: SessionSchema.ID readonly prompt: Prompt readonly delivery: Delivery readonly timeCreated: DateTime.Utc - readonly promotedSeq: number }, -) { - const inserted = yield* db - .insert(SessionInputTable) - .values({ - id: input.id, - session_id: input.sessionID, - admitted_seq: input.promotedSeq, - prompt: encodePrompt(input.prompt), - delivery: input.delivery, - promoted_seq: input.promotedSeq, - time_created: DateTime.toEpochMillis(input.timeCreated), - }) - .onConflictDoNothing() - .returning() - .get() - .pipe(Effect.orDie) - if (!inserted) return yield* Effect.die("Prompt projection conflicts with admitted input") - return fromRow(inserted) -}) +) => + equivalent(input, expected) && + DateTime.toEpochMillis(input.timeCreated) === DateTime.toEpochMillis(expected.timeCreated) const publish = Effect.fn("SessionInput.publish")(function* ( db: DatabaseService, @@ -276,18 +220,19 @@ const publish = Effect.fn("SessionInput.publish")(function* ( rows: ReadonlyArray, ) { for (const row of rows) { + const id = SessionMessage.ID.make(row.id) yield* events - .publish(SessionEvent.PromptLifecycle.Promoted, { + .publish(SessionEvent.Prompted, { sessionID, - timestamp: yield* DateTime.now, - messageID: SessionMessage.ID.make(row.id), + timestamp: DateTime.makeUnsafe(row.time_created), + messageID: id, prompt: decodePrompt(row.prompt), - timeCreated: DateTime.makeUnsafe(row.time_created), + delivery: row.delivery, }) .pipe( Effect.catchDefect((defect) => defect instanceof LifecycleConflict - ? find(db, SessionMessage.ID.make(row.id)).pipe( + ? find(db, id).pipe( Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)), ) : Effect.die(defect), @@ -341,13 +286,3 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun .pipe(Effect.orDie) return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true)) }) - -const toMessage = (input: Admitted) => - new SessionMessage.User({ - id: input.id, - type: "user", - text: input.prompt.text, - files: input.prompt.files, - agents: input.prompt.agents, - time: { created: input.timeCreated }, - }) diff --git a/packages/core/src/session/logging.ts b/packages/core/src/session/logging.ts deleted file mode 100644 index c579ec15dc..0000000000 --- a/packages/core/src/session/logging.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Cause, Effect } from "effect" -import { SessionSchema } from "./schema" - -export const logFailure = ( - message: "Failed to drain Session" | "Failed to wake Session", - sessionID: SessionSchema.ID, - cause: Cause.Cause, -) => Effect.logError(message, cause).pipe(Effect.annotateLogs({ sessionID })) diff --git a/packages/core/src/session/message-id.ts b/packages/core/src/session/message-id.ts deleted file mode 100644 index f06fc0fcd5..0000000000 --- a/packages/core/src/session/message-id.ts +++ /dev/null @@ -1,13 +0,0 @@ -export * as SessionMessageID from "./message-id" - -import { Schema } from "effect" -import { withStatics } from "../schema" -import { Identifier } from "../util/identifier" - -export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe( - Schema.brand("Session.Message.ID"), - withStatics((schema) => ({ - create: () => schema.make("msg_" + Identifier.ascending()), - })), -) -export type ID = typeof ID.Type diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index cf1eb2cedf..46118a89fe 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -102,7 +102,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { yield* SessionEvent.All.match(event, { "session.next.agent.switched": (event) => { return adapter.appendMessage( - new SessionMessage.AgentSwitched({ + SessionMessage.AgentSwitched.make({ id: event.data.messageID, type: "agent-switched", metadata: event.metadata, @@ -113,7 +113,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }, "session.next.model.switched": (event) => { return adapter.appendMessage( - new SessionMessage.ModelSwitched({ + SessionMessage.ModelSwitched.make({ id: event.data.messageID, type: "model-switched", metadata: event.metadata, @@ -125,7 +125,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.moved": () => Effect.void, "session.next.prompted": (event) => { return adapter.appendMessage( - new SessionMessage.User({ + SessionMessage.User.make({ id: event.data.messageID, type: "user", metadata: event.metadata, @@ -137,11 +137,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ) }, "session.next.prompt.admitted": () => Effect.void, - "session.next.prompt.promoted": () => Effect.void, - "session.next.interrupt.requested": () => Effect.void, "session.next.context.updated": (event) => adapter.appendMessage( - new SessionMessage.System({ + SessionMessage.System.make({ id: event.data.messageID, type: "system", text: event.data.text, @@ -150,7 +148,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ), "session.next.synthetic": (event) => { return adapter.appendMessage( - new SessionMessage.Synthetic({ + SessionMessage.Synthetic.make({ sessionID: event.data.sessionID, text: event.data.text, id: event.data.messageID, @@ -161,7 +159,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }, "session.next.shell.started": (event) => { return adapter.appendMessage( - new SessionMessage.Shell({ + SessionMessage.Shell.make({ id: event.data.messageID, type: "shell", metadata: event.metadata, @@ -196,7 +194,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ) } yield* adapter.appendMessage( - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: event.data.assistantMessageID, type: "assistant", agent: event.data.agent, @@ -214,7 +212,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { draft.finish = event.data.finish draft.cost = event.data.cost draft.tokens = event.data.tokens - if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, end: event.data.snapshot } + if (event.data.snapshot || event.data.files) + draft.snapshot = { + ...draft.snapshot, + end: event.data.snapshot, + files: event.data.files ? Array.from(event.data.files) : undefined, + } }) }, "session.next.step.failed": (event) => { @@ -227,7 +230,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.text.started": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( - castDraft(new SessionMessage.AssistantText({ type: "text", id: event.data.textID, text: "" })), + castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })), ) }) }, @@ -247,12 +250,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( castDraft( - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: event.data.callID, name: event.data.name, time: { created: event.data.timestamp }, - state: new SessionMessage.ToolStatePending({ status: "pending", input: "" }), + state: SessionMessage.ToolStatePending.make({ status: "pending", input: "" }), }), ), ) @@ -272,7 +275,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { match.provider = event.data.provider match.time.ran = event.data.timestamp match.state = castDraft( - new SessionMessage.ToolStateRunning({ + SessionMessage.ToolStateRunning.make({ status: "running", input: event.data.input, structured: {}, @@ -302,7 +305,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } match.time.completed = event.data.timestamp match.state = castDraft( - new SessionMessage.ToolStateCompleted({ + SessionMessage.ToolStateCompleted.make({ status: "completed", input: match.state.input, structured: event.data.structured, @@ -325,7 +328,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } match.time.completed = event.data.timestamp match.state = castDraft( - new SessionMessage.ToolStateError({ + SessionMessage.ToolStateError.make({ status: "error", error: event.data.error, input: typeof match.state.input === "string" ? {} : match.state.input, @@ -341,11 +344,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( castDraft( - new SessionMessage.AssistantReasoning({ + SessionMessage.AssistantReasoning.make({ type: "reasoning", id: event.data.reasoningID, text: "", providerMetadata: event.data.providerMetadata, + time: { created: event.data.timestamp }, }), ), ) @@ -362,6 +366,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { const match = latestReasoning(draft, event.data.reasoningID) if (match) { match.text = event.data.text + match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp } if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata } }) @@ -371,7 +376,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.compaction.delta": () => Effect.void, "session.next.compaction.ended": (event) => { return adapter.appendMessage( - new SessionMessage.Compaction({ + SessionMessage.Compaction.make({ id: event.data.messageID, type: "compaction", metadata: event.metadata, @@ -382,6 +387,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }), ) }, + "session.next.revert.staged": () => Effect.void, + "session.next.revert.cleared": () => Effect.void, + "session.next.revert.committed": () => Effect.void, }) }) } diff --git a/packages/core/src/session/message.ts b/packages/core/src/session/message.ts index c5b621a08d..47f1baac05 100644 --- a/packages/core/src/session/message.ts +++ b/packages/core/src/session/message.ts @@ -1,193 +1,2 @@ export * as SessionMessage from "./message" - -import { Schema } from "effect" -import { ProviderMetadata, ToolContent } from "@opencode-ai/llm" -import { ModelV2 } from "../model" -import { V2Schema } from "../v2-schema" -import { SessionEvent } from "./event" -import { Prompt } from "./prompt" -import { SessionMessageID } from "./message-id" - -export const ID = SessionMessageID.ID -export type ID = typeof ID.Type - -const Base = { - id: ID, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - time: Schema.Struct({ - created: V2Schema.DateTimeUtcFromMillis, - }), -} - -export class AgentSwitched extends Schema.Class("Session.Message.AgentSwitched")({ - ...Base, - type: Schema.Literal("agent-switched"), - agent: SessionEvent.AgentSwitched.data.fields.agent, -}) {} - -export class ModelSwitched extends Schema.Class("Session.Message.ModelSwitched")({ - ...Base, - type: Schema.Literal("model-switched"), - model: ModelV2.Ref, -}) {} - -export class User extends Schema.Class("Session.Message.User")({ - ...Base, - text: Prompt.fields.text, - files: Prompt.fields.files, - agents: Prompt.fields.agents, - type: Schema.Literal("user"), - time: Schema.Struct({ - created: V2Schema.DateTimeUtcFromMillis, - }), -}) {} - -export class Synthetic extends Schema.Class("Session.Message.Synthetic")({ - ...Base, - sessionID: SessionEvent.Synthetic.data.fields.sessionID, - text: SessionEvent.Synthetic.data.fields.text, - type: Schema.Literal("synthetic"), -}) {} - -export class System extends Schema.Class("Session.Message.System")({ - ...Base, - type: Schema.Literal("system"), - text: SessionEvent.ContextUpdated.data.fields.text, -}) {} - -export class Shell extends Schema.Class("Session.Message.Shell")({ - ...Base, - type: Schema.Literal("shell"), - callID: SessionEvent.Shell.Started.data.fields.callID, - command: SessionEvent.Shell.Started.data.fields.command, - output: Schema.String, - time: Schema.Struct({ - created: V2Schema.DateTimeUtcFromMillis, - completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), - }), -}) {} - -export class ToolStatePending extends Schema.Class("Session.Message.ToolState.Pending")({ - status: Schema.Literal("pending"), - input: Schema.String, -}) {} - -export class ToolStateRunning extends Schema.Class("Session.Message.ToolState.Running")({ - status: Schema.Literal("running"), - input: Schema.Record(Schema.String, Schema.Unknown), - structured: Schema.Record(Schema.String, Schema.Any), - content: ToolContent.pipe(Schema.Array), -}) {} - -export class ToolStateCompleted extends Schema.Class("Session.Message.ToolState.Completed")({ - status: Schema.Literal("completed"), - input: Schema.Record(Schema.String, Schema.Unknown), - attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), - content: ToolContent.pipe(Schema.Array), - outputPaths: SessionEvent.Tool.Success.data.fields.outputPaths, - structured: Schema.Record(Schema.String, Schema.Any), - result: SessionEvent.Tool.Success.data.fields.result, -}) {} - -export class ToolStateError extends Schema.Class("Session.Message.ToolState.Error")({ - status: Schema.Literal("error"), - input: Schema.Record(Schema.String, Schema.Unknown), - content: ToolContent.pipe(Schema.Array), - structured: Schema.Record(Schema.String, Schema.Any), - error: SessionEvent.UnknownError, - result: SessionEvent.Tool.Failed.data.fields.result, -}) {} - -export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( - Schema.toTaggedUnion("status"), -) -export type ToolState = Schema.Schema.Type - -export class AssistantTool extends Schema.Class("Session.Message.Assistant.Tool")({ - type: Schema.Literal("tool"), - id: Schema.String, - name: Schema.String, - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(Schema.optional), - resultMetadata: ProviderMetadata.pipe(Schema.optional), - }).pipe(Schema.optional), - state: ToolState, - time: Schema.Struct({ - created: V2Schema.DateTimeUtcFromMillis, - ran: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), - completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), - pruned: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), - }), -}) {} - -export class AssistantText extends Schema.Class("Session.Message.Assistant.Text")({ - type: Schema.Literal("text"), - id: Schema.String, - text: Schema.String, -}) {} - -export class AssistantReasoning extends Schema.Class("Session.Message.Assistant.Reasoning")({ - type: Schema.Literal("reasoning"), - id: Schema.String, - text: Schema.String, - providerMetadata: ProviderMetadata.pipe(Schema.optional), -}) {} - -export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( - Schema.toTaggedUnion("type"), -) -export type AssistantContent = Schema.Schema.Type - -export class Assistant extends Schema.Class("Session.Message.Assistant")({ - ...Base, - type: Schema.Literal("assistant"), - agent: Schema.String, - model: SessionEvent.Step.Started.data.fields.model, - content: AssistantContent.pipe(Schema.Array), - snapshot: Schema.Struct({ - start: Schema.String.pipe(Schema.optional), - end: Schema.String.pipe(Schema.optional), - }).pipe(Schema.optional), - finish: Schema.String.pipe(Schema.optional), - cost: Schema.Finite.pipe(Schema.optional), - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }).pipe(Schema.optional), - error: SessionEvent.Step.Failed.data.fields.error.pipe(Schema.optional), - time: Schema.Struct({ - created: V2Schema.DateTimeUtcFromMillis, - completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), - }), -}) {} - -export class Compaction extends Schema.Class("Session.Message.Compaction")({ - type: Schema.Literal("compaction"), - reason: SessionEvent.Compaction.Started.data.fields.reason, - summary: Schema.String, - recent: Schema.String, - ...Base, -}) {} - -export const Message = Schema.Union([ - AgentSwitched, - ModelSwitched, - User, - Synthetic, - System, - Shell, - Assistant, - Compaction, -]) - .pipe(Schema.toTaggedUnion("type")) - .annotate({ identifier: "Session.Message" }) - -export type Message = Schema.Schema.Type - -export type Type = Message["type"] +export * from "@opencode-ai/schema/session-message" diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index caf63de78a..afa60dfa88 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -1,10 +1,10 @@ export * as SessionProjector from "./projector" -import { and, desc, eq, sql } from "drizzle-orm" +import { and, desc, eq, gt, or, sql } from "drizzle-orm" import { DateTime, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" import { EventV2 } from "../event" -import { LayerNode } from "../effect/layer-node" +import { makeGlobalNode } from "../effect/app-node" import { SessionEvent } from "./event" import { SessionV1 } from "../v1/session" import { WorkspaceTable } from "../control-plane/workspace.sql" @@ -13,7 +13,7 @@ import { SessionMessageUpdater } from "./message-updater" import { SessionInput } from "./input" import { WorkspaceV2 } from "../workspace" import { SessionContextEpoch } from "./context-epoch" -import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql" +import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql" import type { DeepMutable } from "../schema" type DatabaseService = Database.Interface["db"] @@ -21,7 +21,6 @@ type DatabaseService = Database.Interface["db"] const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const encodeMessage = Schema.encodeSync(SessionMessage.Message) -class PromptAlreadyProjected extends Error {} export class SessionAlreadyProjected extends Error {} type Usage = { @@ -67,7 +66,7 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning, tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read, tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write, - revert: info.revert ?? null, + revert: info.revert ? { ...info.revert, messageID: SessionMessage.ID.make(info.revert.messageID) } : null, permission: info.permission ? [...info.permission] : undefined, time_created: info.time.created, time_updated: info.time.updated, @@ -115,7 +114,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) { const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }) const updateMessage = (message: SessionMessage.Message) => { - if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") + if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence") const encoded = encodeMessage(message) const { id, type, ...data } = encoded return db @@ -192,7 +191,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) { } function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: SessionMessage.Message) { - if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") + if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence") const encoded = encodeMessage(message) const { id, type, ...data } = encoded return db @@ -201,7 +200,7 @@ function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: id: SessionMessage.ID.make(id), session_id: event.data.sessionID, type, - seq: event.seq, + seq: event.durable.seq, time_created: DateTime.toEpochMillis(message.time.created), data, }) @@ -209,11 +208,10 @@ function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: .pipe(Effect.orDie) } -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - yield* events.beforeCommit((event) => SessionInput.guardReservedID(db, event)) yield* events.project(SessionV1.Event.Created, (event) => Effect.gen(function* () { const stored = yield* db @@ -330,19 +328,14 @@ export const layer = Layer.effectDiscard( if (next) yield* applyUsage(db, sessionID, next) }), ) - yield* events.project(SessionEvent.AgentSwitched, (event) => { - if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") - return db + yield* events.project(SessionEvent.AgentSwitched, (event) => + db .update(SessionTable) .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) .where(eq(SessionTable.id, event.data.sessionID)) .run() - .pipe( - Effect.orDie, - Effect.andThen(run(db, event)), - Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)), - ) - }) + .pipe(Effect.orDie, Effect.andThen(run(db, event))), + ) yield* events.project(SessionEvent.ModelSwitched, (event) => Effect.gen(function* () { yield* db @@ -352,40 +345,27 @@ export const layer = Layer.effectDiscard( .run() .pipe(Effect.orDie) yield* run(db, event) - if (event.seq === undefined) - return yield* Effect.die("Synchronized Session event is missing aggregate sequence") - yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq) }), ) yield* events.project(SessionEvent.Prompted, (event) => Effect.gen(function* () { - const messageID = event.data.messageID - const existing = yield* db - .select({ id: SessionMessageTable.id }) - .from(SessionMessageTable) - .where(eq(SessionMessageTable.id, messageID)) - .get() - .pipe(Effect.orDie) - if (existing) return yield* Effect.die(new PromptAlreadyProjected()) - yield* run(db, event) - if (event.seq === undefined) - return yield* Effect.die("Synchronized Session event is missing aggregate sequence") - yield* SessionInput.projectLegacyPrompted(db, { - id: messageID, + if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") + yield* SessionInput.projectPrompted(db, { + id: event.data.messageID, sessionID: event.data.sessionID, prompt: event.data.prompt, delivery: event.data.delivery, timeCreated: event.data.timestamp, - promotedSeq: event.seq, + promotedSeq: event.durable.seq, }) + yield* run(db, event) }), ) - yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) => + yield* events.project(SessionEvent.PromptAdmitted, (event) => Effect.gen(function* () { - if (event.seq === undefined) - return yield* Effect.die("Synchronized Session event is missing aggregate sequence") + if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") yield* SessionInput.projectAdmitted(db, { - admittedSeq: event.seq, + admittedSeq: event.durable.seq, id: event.data.messageID, sessionID: event.data.sessionID, prompt: event.data.prompt, @@ -394,30 +374,7 @@ export const layer = Layer.effectDiscard( }) }), ) - yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) => - Effect.gen(function* () { - if (event.seq === undefined) - return yield* Effect.die("Synchronized Session event is missing aggregate sequence") - yield* insertMessage( - db, - event, - yield* SessionInput.projectPromoted(db, { - id: event.data.messageID, - sessionID: event.data.sessionID, - prompt: event.data.prompt, - timeCreated: event.data.timeCreated, - promotedSeq: event.seq, - }), - ) - }), - ) - yield* events.project(SessionEvent.InterruptRequested, () => Effect.void) - yield* events.project(SessionEvent.ContextUpdated, (event) => { - if (!event.replay || event.seq === undefined) return run(db, event) - return run(db, event).pipe( - Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)), - ) - }) + yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event)) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event)) @@ -435,17 +392,67 @@ export const layer = Layer.effectDiscard( yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event)) // yield* events.project(SessionEvent.Retried, (event) => run(db, event)) - yield* events.project(SessionEvent.Compaction.Ended, (event) => { - if (event.version === 1) return Effect.void - const seq = event.seq - if (seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") - return Effect.gen(function* () { - yield* run(db, event) - yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq) - }) - }) + yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event)) + yield* events.project(SessionEvent.RevertEvent.Staged, (event) => + db + .update(SessionTable) + .set({ + revert: { ...event.data.revert, files: event.data.revert.files ? [...event.data.revert.files] : undefined }, + time_updated: DateTime.toEpochMillis(event.data.timestamp), + }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie, Effect.asVoid), + ) + yield* events.project(SessionEvent.RevertEvent.Cleared, (event) => + db + .update(SessionTable) + .set({ revert: null, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie, Effect.asVoid), + ) + yield* events.project(SessionEvent.RevertEvent.Committed, (event) => + Effect.gen(function* () { + const boundary = yield* db + .select({ seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, event.data.sessionID), + eq(SessionMessageTable.id, event.data.messageID), + ), + ) + .get() + .pipe(Effect.orDie) + if (!boundary) return yield* Effect.die(`Revert boundary message not found: ${event.data.messageID}`) + yield* db + .delete(SessionMessageTable) + .where( + and(eq(SessionMessageTable.session_id, event.data.sessionID), gt(SessionMessageTable.seq, boundary.seq)), + ) + .run() + .pipe(Effect.orDie) + yield* db + .delete(SessionInputTable) + .where( + and( + eq(SessionInputTable.session_id, event.data.sessionID), + or(gt(SessionInputTable.admitted_seq, boundary.seq), gt(SessionInputTable.promoted_seq, boundary.seq)), + ), + ) + .run() + .pipe(Effect.orDie) + yield* db + .update(SessionTable) + .set({ revert: null, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie) + yield* SessionContextEpoch.reset(db, event.data.sessionID) + }), + ) }), ) -export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer)) -export const node = LayerNode.make(layer, [EventV2.node, Database.node]) +export const node = makeGlobalNode({ name: "session-projector", layer, deps: [EventV2.node, Database.node] }) diff --git a/packages/core/src/session/prompt.ts b/packages/core/src/session/prompt.ts index a0653c51e6..b079349c56 100644 --- a/packages/core/src/session/prompt.ts +++ b/packages/core/src/session/prompt.ts @@ -1,46 +1 @@ -import * as Schema from "effect/Schema" - -export class Source extends Schema.Class("Prompt.Source")({ - start: Schema.Finite, - end: Schema.Finite, - text: Schema.String, -}) {} - -export class FileAttachment extends Schema.Class("Prompt.FileAttachment")({ - uri: Schema.String, - mime: Schema.String, - name: Schema.String.pipe(Schema.optional), - description: Schema.String.pipe(Schema.optional), - source: Source.pipe(Schema.optional), -}) { - static create(input: FileAttachment) { - return new FileAttachment({ - uri: input.uri, - mime: input.mime, - name: input.name, - description: input.description, - source: input.source, - }) - } -} - -export class AgentAttachment extends Schema.Class("Prompt.AgentAttachment")({ - name: Schema.String, - source: Source.pipe(Schema.optional), -}) {} - -export class Prompt extends Schema.Class("Prompt")({ - text: Schema.String, - files: Schema.Array(FileAttachment).pipe(Schema.optional), - agents: Schema.Array(AgentAttachment).pipe(Schema.optional), -}) { - static readonly equivalence = Schema.toEquivalence(Prompt) - - static fromUserMessage(input: Pick) { - return new Prompt({ - text: input.text, - ...(input.files === undefined ? {} : { files: input.files }), - ...(input.agents === undefined ? {} : { agents: input.agents }), - }) - } -} +export { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt" diff --git a/packages/core/src/session/revert.ts b/packages/core/src/session/revert.ts new file mode 100644 index 0000000000..9999d5da5a --- /dev/null +++ b/packages/core/src/session/revert.ts @@ -0,0 +1,121 @@ +export * as SessionRevert from "./revert" + +import { and, asc, eq, gt } from "drizzle-orm" +import { DateTime, Effect, Schema } from "effect" +import { Database } from "../database/database" +import { EventV2 } from "../event" +import { RelativePath } from "../schema" +import { Snapshot } from "../snapshot" +import { SessionEvent } from "./event" +import { SessionMessage } from "./message" +import { SessionSchema } from "./schema" +import { SessionMessageTable } from "./sql" + +export class MessageNotFoundError extends Schema.TaggedErrorClass()( + "Session.MessageNotFoundError", + { + sessionID: SessionSchema.ID, + messageID: SessionMessage.ID, + }, +) {} + +interface BoundaryInput { + readonly sessionID: SessionSchema.ID + readonly messageID: SessionMessage.ID +} + +const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) { + const db = (yield* Database.Service).db + const boundary = yield* db + .select({ seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID))) + .get() + .pipe(Effect.orDie) + if (!boundary) return yield* new MessageNotFoundError(input) + const rows = yield* db + .select() + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, input.sessionID), + eq(SessionMessageTable.type, "assistant"), + gt(SessionMessageTable.seq, boundary.seq), + ), + ) + .orderBy(asc(SessionMessageTable.seq)) + .all() + .pipe(Effect.orDie) + const decode = Schema.decodeUnknownEffect(SessionMessage.Message) + const files = new Map() + for (const row of rows) { + const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie) + if (message.type !== "assistant" || !message.snapshot?.start) continue + for (const file of message.snapshot.files ?? []) + if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start)) + } + return files +}) + +export const stage = Effect.fn("SessionRevert.stage")(function* (input: { + readonly session: SessionSchema.Info + readonly messageID: SessionMessage.ID + readonly files?: boolean +}) { + const snapshot = yield* Snapshot.Service + const events = yield* EventV2.Service + const original = input.session.revert?.snapshot + ? Snapshot.ID.make(input.session.revert.snapshot) + : yield* snapshot.capture() + const next = yield* plan({ sessionID: input.session.id, messageID: input.messageID }) + const restore = new Map() + if (original) { + for (const file of input.session.revert?.files ?? []) restore.set(file.path, original) + } + if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree) + if (restore.size) yield* snapshot.restore({ files: restore }) + const paths = input.files === false ? [] : Array.from(next.keys()) + const files = original + ? yield* snapshot.diff({ from: original, to: (yield* snapshot.capture()) ?? original, paths }) + : [] + const revert = { + messageID: input.messageID, + snapshot: original, + diff: files + .map((file) => file.patch) + .join("") + .trim(), + files, + } satisfies SessionSchema.Info["revert"] + yield* events.publish(SessionEvent.RevertEvent.Staged, { + sessionID: input.session.id, + timestamp: yield* DateTime.now, + revert, + }) + return revert +}) + +export const clear = Effect.fn("SessionRevert.clear")(function* (session: SessionSchema.Info) { + if (!session.revert) return + const snapshot = yield* Snapshot.Service + const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined + if (original) + yield* snapshot.restore({ + files: new Map((session.revert.files ?? []).map((file) => [file.path, original])), + }) + const events = yield* EventV2.Service + yield* events.publish(SessionEvent.RevertEvent.Cleared, { + sessionID: session.id, + timestamp: yield* DateTime.now, + }) +}) + +export const commit = Effect.fn("SessionRevert.commit")(function* (session: SessionSchema.Info) { + if (!session.revert) return + const events = yield* EventV2.Service + yield* events.publish(SessionEvent.RevertEvent.Committed, { + sessionID: session.id, + messageID: session.revert.messageID, + timestamp: yield* DateTime.now, + }) +}) diff --git a/packages/core/src/session/run-coordinator.ts b/packages/core/src/session/run-coordinator.ts index d52b63e8f1..2f89aff9e3 100644 --- a/packages/core/src/session/run-coordinator.ts +++ b/packages/core/src/session/run-coordinator.ts @@ -1,106 +1,45 @@ export * as SessionRunCoordinator from "./run-coordinator" -import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer, Scope } from "effect" -import { SessionRunner } from "./runner" -import { logFailure } from "./logging" -import { SessionSchema } from "./schema" +import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect" -export type Mode = "run" | "wake" - -/** Why one drain generation should run. Explicit runs dominate advisory wakes when demands coalesce. */ -type Demand = { readonly _tag: "run" } | { readonly _tag: "wake"; readonly seq?: number } - -/** - * Runs at most one drain chain per key while allowing different keys to drain concurrently. - * - * For each key: - * - * idle --run/wake--> draining --run/wake--> draining + one coalesced rerun --> idle - * - * `run` is an explicit drain request. It starts a chain or joins the current chain and - * upgrades a pending follow-up so the caller receives explicit-run semantics. - * - * `wake` reports that durable work may now be available. It starts a chain while idle or - * requests one coalesced follow-up while draining. Repeated wakes collapse together. - * - * `interrupt` stops the current ownership chain. Advisory wakes from before the interrupt - * boundary are suppressed; advisory wakes after the boundary run after cleanup. - */ -export interface Coordinator { - /** Starts or joins one explicit drain generation. */ - readonly run: (key: Key) => Effect.Effect - /** Coalesces one wake-up after durable work is recorded. */ - readonly wake: (key: Key, seq?: number) => Effect.Effect - /** Waits until the current ownership chain settles. */ - readonly awaitIdle: (key: Key) => Effect.Effect - /** Interrupts the active ownership chain without automatically draining pending wakes. */ - readonly interrupt: (key: Key, seq?: number) => Effect.Effect +/** Serializes execution for each key while allowing different keys to run concurrently. */ +export interface Coordinator { + /** Snapshots keys with an execution owned by this coordinator. */ + readonly active: Effect.Effect> + /** Starts execution while idle or joins the active execution. */ + readonly run: (key: Key) => Effect.Effect + /** Registers one coalesced follow-up after newly recorded work. */ + readonly wake: (key: Key) => Effect.Effect + /** Stops active execution and waits for its cleanup. */ + readonly interrupt: (key: Key) => Effect.Effect } -/** One Session's process-local execution lane: one active demand and at most one coalesced follow-up. */ -type Entry = { - readonly done: Deferred.Deferred - readonly settled: Deferred.Deferred> - current: Demand - pending?: Demand - explicitWaiter?: Deferred.Deferred - interruptSeq?: number +type Entry = { + readonly done: Deferred.Deferred owner?: Fiber.Fiber + pendingWake: boolean stopping: boolean } -/** Combines follow-up demand: runs dominate, while wakes retain the newest durable admission sequence. */ -const coalesce = (left: Demand | undefined, right: Demand): Demand => { - if (left?._tag === "run" || right._tag === "run") return { _tag: "run" } - return { _tag: "wake", seq: maxSeq(left?.seq, right.seq) } -} - -const maxSeq = (left: number | undefined, right: number | undefined) => { - if (left === undefined) return right - if (right === undefined) return left - return Math.max(left, right) -} - -/** Constructs a scoped coordinator. Every in-memory transition is synchronous. */ -export const make = (options: { - readonly drain: (key: Key, mode: Mode) => Effect.Effect - readonly onFailure?: (key: Key, cause: Cause.Cause) => Effect.Effect -}): Effect.Effect, never, Scope.Scope> => +export const make = (options: { + readonly drain: (key: Key, force: boolean) => Effect.Effect +}): Effect.Effect, never, Scope.Scope> => Effect.gen(function* () { - const active = new Map>() - const interruptSeq = new Map() - const report = yield* FiberSet.makeRuntime() + const active = new Map>() const fork = yield* FiberSet.makeRuntime() - const shutdown = Deferred.makeUnsafe() - let closed = false - yield* Effect.addFinalizer(() => - Effect.sync(() => { - closed = true - Deferred.doneUnsafe(shutdown, Effect.void) - active.clear() - interruptSeq.clear() - }), - ) - const makeEntry = (current: Demand, explicitWaiter?: Deferred.Deferred): Entry => ({ - done: Deferred.makeUnsafe(), - settled: Deferred.makeUnsafe>(), - current, - explicitWaiter, + const makeEntry = (): Entry => ({ + done: Deferred.makeUnsafe(), + pendingWake: false, stopping: false, }) - const start = (key: Key, entry: Entry, demand: Demand, successor = false) => { + const start = (key: Key, entry: Entry, force: boolean, successor = false) => { const ready = Deferred.makeUnsafe() - const drain = Effect.suspend(() => options.drain(key, demand._tag)) - // Initial work retains immediate-start behavior but cannot run before ownership is published. - // Observer-started successors yield once so synchronous drains cannot recurse on the JS stack. const owner = fork( - (successor - ? Effect.yieldNow.pipe(Effect.andThen(drain)) - : Deferred.await(ready).pipe(Effect.andThen(drain)) - ).pipe( - Effect.onExit((exit) => Effect.sync(() => settle(key, entry, demand, exit))), + (successor ? Effect.yieldNow : Deferred.await(ready)).pipe( + Effect.andThen(Effect.suspend(() => options.drain(key, force))), + Effect.onExit((exit) => Effect.sync(() => settle(key, entry, exit))), Effect.exit, Effect.asVoid, ), @@ -109,176 +48,57 @@ export const make = (options: { if (!successor) Deferred.doneUnsafe(ready, Effect.void) } - const settle = (key: Key, entry: Entry, demand: Demand, exit: Exit.Exit) => { - if (closed) { - Deferred.doneUnsafe(entry.done, exit) - Deferred.doneUnsafe(entry.settled, Effect.succeed(exit)) - return - } - if (demand._tag === "run" && entry.explicitWaiter !== undefined) { - Deferred.doneUnsafe(entry.explicitWaiter, exit) - entry.explicitWaiter = undefined - } - if (entry.stopping && demand._tag === "wake" && entry.explicitWaiter !== undefined) { - Deferred.doneUnsafe(entry.explicitWaiter, exit) - entry.explicitWaiter = undefined - } - if (active.get(key) !== entry) { - Deferred.doneUnsafe(entry.done, exit) - Deferred.doneUnsafe(entry.settled, Effect.succeed(exit)) - return - } - if (exit._tag === "Success" && !entry.stopping) { - if (entry.pending !== undefined) { - const pending = entry.pending - entry.pending = undefined - entry.current = pending - start(key, entry, pending, true) - return - } - active.delete(key) - Deferred.doneUnsafe(entry.done, exit) - Deferred.doneUnsafe(entry.settled, Effect.succeed(exit)) + const settle = (key: Key, entry: Entry, exit: Exit.Exit) => { + if (Exit.isSuccess(exit) && !entry.stopping && entry.pendingWake) { + entry.pendingWake = false + start(key, entry, false, true) return } - const successor = entry.pending !== undefined ? makeEntry(entry.pending, entry.explicitWaiter) : undefined + const successor = entry.pendingWake ? makeEntry() : undefined if (successor === undefined) active.delete(key) - else active.set(key, successor) - if (successor !== undefined) start(key, successor, successor.current, true) - Deferred.doneUnsafe(entry.done, exit) - Deferred.doneUnsafe(entry.settled, Effect.succeed(exit)) - if ( - exit._tag === "Failure" && - !(entry.stopping && Cause.hasInterruptsOnly(exit.cause)) && - demand._tag === "wake" && - options.onFailure !== undefined - ) { - report(Effect.suspend(() => options.onFailure!(key, exit.cause))) + else { + active.set(key, successor) + start(key, successor, false, true) } + Deferred.doneUnsafe(entry.done, exit) } - const wake = (key: Key, seq?: number) => - Effect.sync(() => { - if (closed) return - if (!isAfterInterrupt(key, seq)) return + const run = (key: Key): Effect.Effect => + Effect.uninterruptibleMask((restore) => { const entry = active.get(key) if (entry !== undefined) { - if (!acceptsWake(entry, seq)) return - entry.pending = coalesce(entry.pending, { _tag: "wake", seq }) + if (entry.stopping) return restore(Deferred.await(entry.done).pipe(Effect.andThen(run(key)))) + return restore(Deferred.await(entry.done)) + } + + const next = makeEntry() + active.set(key, next) + start(key, next, true) + return restore(Deferred.await(next.done)) + }) + + const wake = (key: Key) => + Effect.sync(() => { + const entry = active.get(key) + if (entry !== undefined) { + entry.pendingWake = true return } - const next = makeEntry({ _tag: "wake", seq }) + const next = makeEntry() active.set(key, next) - start(key, next, next.current) + start(key, next, false) }) - const awaitIdle = (key: Key): Effect.Effect => - Effect.gen(function* () { - let firstFailure: Cause.Cause | undefined - while (!closed) { - const entry = active.get(key) - if (entry === undefined) break - const exit = yield* Effect.raceFirst( - Deferred.await(entry.settled), - Deferred.await(shutdown).pipe(Effect.as(Exit.void)), - ) - if (closed) break - if (exit._tag === "Failure" && firstFailure === undefined) firstFailure = exit.cause - } - if (firstFailure !== undefined) return yield* Effect.failCause(firstFailure) - }) - - const interrupt = (key: Key, seq?: number): Effect.Effect => + const interrupt = (key: Key): Effect.Effect => Effect.suspend(() => { const entry = active.get(key) - const latest = interruptSeq.get(key) - if (seq !== undefined && latest !== undefined && seq <= latest) - return entry?.stopping && entry.owner !== undefined ? Fiber.interrupt(entry.owner) : Effect.void - if (seq !== undefined) interruptSeq.set(key, seq) if (entry?.owner === undefined) return Effect.void - if ( - seq !== undefined && - entry.current._tag === "wake" && - entry.current.seq !== undefined && - entry.current.seq > seq - ) - return Effect.void - if (entry.stopping) { - entry.interruptSeq = maxSeq(entry.interruptSeq, seq) - suppressPendingAtOrBefore(entry, seq) - return Fiber.interrupt(entry.owner) - } entry.stopping = true - entry.interruptSeq = seq - suppressPendingAtOrBefore(entry, seq) + entry.pendingWake = false return Fiber.interrupt(entry.owner) }) - return { run, wake, awaitIdle, interrupt } - - function run(key: Key): Effect.Effect { - return Effect.uninterruptibleMask((restore) => { - if (closed) return Effect.interrupt - const entry = active.get(key) - if (entry !== undefined) { - if (entry.stopping) { - return restore(Deferred.await(entry.settled).pipe(Effect.andThen(run(key)))) - } - if (entry.current._tag === "wake") { - entry.pending = coalesce(entry.pending, { _tag: "run" }) - entry.explicitWaiter ??= Deferred.makeUnsafe() - return restore(awaitRun(entry.explicitWaiter)) - } - return restore(awaitRun(entry.done)) - } - - const next = makeEntry({ _tag: "run" }) - active.set(key, next) - start(key, next, next.current) - return restore(awaitRun(next.done)) - }) - } - - function awaitRun(done: Deferred.Deferred): Effect.Effect { - return Effect.raceFirst(Deferred.await(done), Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt))) - } - - function acceptsWake(entry: Entry, seq: number | undefined) { - return !entry.stopping || (entry.interruptSeq !== undefined && seq !== undefined && seq > entry.interruptSeq) - } - - function isAfterInterrupt(key: Key, seq: number | undefined) { - const latest = interruptSeq.get(key) - return latest === undefined || (seq !== undefined && seq > latest) - } - - function suppressPendingAtOrBefore(entry: Entry, seq: number | undefined) { - if ( - entry.pending?._tag === "wake" && - seq !== undefined && - entry.pending.seq !== undefined && - entry.pending.seq > seq - ) - return - entry.pending = undefined - } + return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt } }) - -export interface Interface extends Coordinator {} - -export class Service extends Context.Service()("@opencode/v2/SessionRunCoordinator") {} - -export const layer = Layer.effect( - Service, - SessionRunner.Service.pipe( - Effect.flatMap((runner) => - make({ - drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }), - onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause), - }), - ), - Effect.map(Service.of), - ), -) diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 4060cc6b04..634075dd91 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -6,7 +6,6 @@ import { SessionSchema } from "../schema" import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error" import { SessionRunnerModel } from "./model" import type { SystemContext } from "../../system-context/index" -import type { SessionContextEpoch } from "../context-epoch" import type { ToolOutputStore } from "../../tool-output-store" export type RunError = @@ -15,7 +14,6 @@ export type RunError = | MessageDecodeError | ContextSnapshotDecodeError | SystemContext.InitializationBlocked - | SessionContextEpoch.AgentReplacementBlocked | ToolOutputStore.Error /** Runs one local continuation from already-recorded Session history. */ @@ -23,7 +21,7 @@ export interface Interface { /** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */ readonly run: (input: { readonly sessionID: SessionSchema.ID - readonly force?: boolean + readonly force: boolean }) => Effect.Effect } diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 233a4aa4d8..94c77d19e9 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -8,7 +8,7 @@ import { isContextOverflowFailure, type ProviderErrorEvent, } from "@opencode-ai/llm" -import { Cause, DateTime, Effect, FiberSet, Layer, Option, Schema, Semaphore, Stream } from "effect" +import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { AgentV2 } from "../../agent" import { Config } from "../../config" import { Database } from "../../database/database" @@ -35,6 +35,9 @@ import { SessionRunnerModel } from "./model" import { createLLMEventPublisher } from "./publish-llm-event" import { toLLMMessages } from "./to-llm-message" import { MAX_STEPS_PROMPT } from "./max-steps" +import { Snapshot } from "../../snapshot" +import { makeLocationNode } from "../../effect/app-node" +import { llmClient } from "../../effect/app-node-platform" /** * Runs one durable coding-agent Session until it settles. @@ -79,14 +82,14 @@ import { MAX_STEPS_PROMPT } from "./max-steps" * - [ ] Update title, summaries, compaction state, and cleanup in bounded background work. * * Use `llm.stream(request)` for each provider turn. Keep tool execution and continuation here. - * Durable activity recovery remains a separate future slice with an explicit retry policy. + * Durable continuation recovery remains a separate future slice with an explicit retry policy. * * The current slice loads V2 history, translates it, resolves a model through a core service, and persists one * provider turn. Registry definitions are advertised, local tool calls are settled durably, and an * explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop. */ -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service @@ -100,6 +103,7 @@ export const layer = Layer.effect( const skillGuidance = yield* SkillGuidance.Service const referenceGuidance = yield* ReferenceGuidance.Service const config = yield* Config.Service + const snapshots = yield* Snapshot.Service const db = (yield* Database.Service).db const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() }) const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) { @@ -141,10 +145,10 @@ export const layer = Layer.effect( cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError) type TurnTransition = - // Request preparation observed a concurrent Session change and must restart from durable state. - | { readonly _tag: "RebuildPreparedTurn"; readonly promotion?: SessionInput.Delivery } + // Automatic compaction completed; rebuild the request from compacted history. + | { readonly _tag: "ContinueAfterCompaction"; readonly step: number } // Overflow compaction completed; rebuild once through the path without overflow recovery. - | { readonly _tag: "ContinueAfterOverflowCompaction" } + | { readonly _tag: "ContinueAfterOverflowCompaction"; readonly step: number } class TurnTransitionError extends Error { constructor(readonly transition: TurnTransition) { @@ -152,20 +156,10 @@ export const layer = Layer.effect( } } - const rebuildPreparedTurn = (promotion?: SessionInput.Delivery) => - new TurnTransitionError({ _tag: "RebuildPreparedTurn", promotion }) - const continueAfterOverflowCompaction = new TurnTransitionError({ - _tag: "ContinueAfterOverflowCompaction", - }) + const continueAfterCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterCompaction", step }) + const continueAfterOverflowCompaction = (step: number) => + new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step }) - const retryAgentMismatch = (promotion: SessionInput.Delivery | undefined) => - Effect.catchDefect((defect) => - defect instanceof SessionContextEpoch.AgentMismatch - ? Effect.die(rebuildPreparedTurn(promotion)) - : Effect.die(defect), - ) - - const sameModel = Schema.toEquivalence(Schema.UndefinedOr(ModelV2.Ref)) const loadSystemContext = (agent: AgentV2.Selection) => Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], { concurrency: "unbounded", @@ -181,40 +175,26 @@ export const layer = Layer.effect( if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID) return yield* Effect.interrupt const agent = yield* agents.select(session.agent) - const initialized = yield* SessionContextEpoch.initialize( - db, - loadSystemContext(agent), - session.id, - session.location, - agent.id, - ).pipe(retryAgentMismatch(promotion)) + const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id) const toolFibers = yield* FiberSet.make() let needsContinuation = false + let currentStep = step if (promotion) { - const cutoff = yield* SessionInput.latestSeq(db, session.id) - if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id, cutoff) + const cutoff = yield* EventV2.latestSequence(db, session.id) + let promoted = 0 + if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id, cutoff) if (promotion === "queue") { - yield* SessionInput.promoteNextQueued(db, events, session.id) - yield* SessionInput.promoteSteers(db, events, session.id, cutoff) + promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id)) + promoted += yield* SessionInput.promoteSteers(db, events, session.id, cutoff) } + if (promoted > 0) currentStep = 1 } const system = - initialized ?? - (yield* SessionContextEpoch.prepare( - db, - events, - loadSystemContext(agent), - session.id, - session.location, - agent.id, - ).pipe(retryAgentMismatch(undefined))) - const current = yield* getSession(sessionID) - if ((yield* agents.select(current.agent)).id !== agent.id || !sameModel(current.model, session.model)) - return yield* Effect.die(rebuildPreparedTurn()) + initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id)) const model = yield* models.resolve(session) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const context = entries.map((entry) => entry.message) - const isLastStep = agent.info?.steps !== undefined && step >= agent.info.steps + const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ @@ -228,7 +208,8 @@ export const layer = Layer.effect( toolChoice: isLastStep ? "none" : undefined, }) if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) - return yield* Effect.die(rebuildPreparedTurn()) + return yield* Effect.die(continueAfterCompaction(currentStep)) + const startSnapshot = yield* snapshots.capture() const publisher = createLLMEventPublisher(events, { sessionID: session.id, agent: agent.id, @@ -237,13 +218,12 @@ export const layer = Layer.effect( providerID: ProviderV2.ID.make(model.provider), ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), }, + snapshot: startSnapshot, }) const withPublication = Semaphore.makeUnsafe(1).withPermit const publish = (event: LLMEvent, outputPaths: ReadonlyArray = []) => withPublication(publisher.publish(event, outputPaths)) let overflowFailure: ProviderErrorEvent | undefined - if (!(yield* SessionContextEpoch.current(db, session.id, agent.id, system.revision))) - return yield* Effect.die(rebuildPreparedTurn()) const providerStream = llm.stream(request).pipe( Stream.runForEach((event) => Effect.gen(function* () { @@ -300,19 +280,12 @@ export const layer = Layer.effect( isContextOverflowFailure(overflowFailure ?? failure) && (yield* restore(recoverOverflow({ sessionID: session.id, entries, model, request }))) ) - return yield* Effect.die(continueAfterOverflowCompaction) + return yield* Effect.die(continueAfterOverflowCompaction(currentStep)) if (overflowFailure) yield* publish(overflowFailure) const llmFailure = failure instanceof LLMError ? failure : undefined if (llmFailure && !publisher.hasProviderError()) { yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) - yield* withPublication( - events.publish(SessionEvent.Step.Failed, { - sessionID: session.id, - timestamp: yield* DateTime.now, - assistantMessageID: yield* publisher.startAssistant(), - error: { type: "unknown", message: llmFailure.reason.message }, - }), - ) + yield* withPublication(publisher.failAssistant(llmFailure.reason.message)) } if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers) const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit) @@ -327,19 +300,44 @@ export const layer = Layer.effect( ) { yield* FiberSet.clear(toolFibers) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) + if (publisher.hasActiveAssistant()) + yield* withPublication(publisher.failAssistant("Provider turn interrupted")) } if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) { const failure = Cause.squash(settled.cause) const message = failure instanceof Error ? failure.message : String(failure) yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`)) } + const stepSettlement = publisher.stepSettlement() + if (stepSettlement && !publisher.hasProviderError()) { + const endSnapshot = yield* snapshots.capture() + const files = + startSnapshot && endSnapshot + ? yield* snapshots + .files({ from: startSnapshot, to: endSnapshot }) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + : undefined + yield* withPublication( + events.publish(SessionEvent.Step.Ended, { + sessionID: session.id, + timestamp: yield* DateTime.now, + assistantMessageID: yield* publisher.startAssistant(), + finish: stepSettlement.finish, + cost: 0, + tokens: stepSettlement.tokens, + snapshot: endSnapshot, + files, + }), + ) + } if (publisher.hasProviderError()) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) if (stream._tag === "Success" && !publisher.hasProviderError()) yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause) - if (settled._tag === "Failure") return yield* Effect.failCause(settled.cause) - return !publisher.hasProviderError() && needsContinuation + if (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)) + return yield* Effect.failCause(settled.cause) + return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep } }), ) }, Effect.scoped) @@ -347,7 +345,7 @@ export const layer = Layer.effect( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, step: number, - ) => Effect.Effect + ) => Effect.Effect<{ readonly needsContinuation: boolean; readonly step: number }, RunError> const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) { return yield* runTurnAttempt(sessionID, promotion, step).pipe( @@ -357,7 +355,7 @@ export const layer = Layer.effect( if (defect.transition._tag === "ContinueAfterOverflowCompaction") return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow") yield* Effect.yieldNow - return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step) + return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step) }), ), ) @@ -370,8 +368,8 @@ export const layer = Layer.effect( if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect) yield* Effect.yieldNow if (defect.transition._tag === "ContinueAfterOverflowCompaction") - return yield* runAfterOverflowCompaction(sessionID, undefined, step) - return yield* runTurn(sessionID, defect.transition.promotion, step) + return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step) + return yield* runTurn(sessionID, undefined, defect.transition.step) }), ), ) @@ -379,23 +377,26 @@ export const layer = Layer.effect( const run = Effect.fn("SessionRunner.run")(function* (input: { readonly sessionID: SessionSchema.ID - readonly force?: boolean + readonly force: boolean }) { const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer") const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") - if (input.force !== true && !hasSteer && !hasQueue) return + if (!input.force && !hasSteer && !hasQueue) return yield* failInterruptedTools(input.sessionID) let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined - let openActivity = input.force === true || hasSteer || hasQueue - while (openActivity) { + let shouldRun = input.force || hasSteer || hasQueue + while (shouldRun) { let needsContinuation = true - for (let step = 1; needsContinuation; step++) { - needsContinuation = yield* runTurn(input.sessionID, promotion, step) + let step = 1 + while (needsContinuation) { + const result = yield* runTurn(input.sessionID, promotion, step) + needsContinuation = result.needsContinuation + step = result.step + 1 promotion = "steer" if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") } - openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue") - promotion = openActivity ? "queue" : undefined + shouldRun = yield* SessionInput.hasPending(db, input.sessionID, "queue") + promotion = shouldRun ? "queue" : undefined } }) @@ -405,4 +406,22 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer +export const node = makeLocationNode({ + service: Service, + layer, + deps: [ + EventV2.node, + llmClient, + AgentV2.node, + ToolRegistry.node, + SessionRunnerModel.node, + SessionStore.node, + Location.node, + SystemContextRegistry.node, + SkillGuidance.node, + ReferenceGuidance.node, + Config.node, + Snapshot.node, + Database.node, + ], +}) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 3d93a89997..74e78120c2 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -1,19 +1,17 @@ export * as SessionRunnerModel from "./model" +import { makeLocationNode } from "../../effect/app-node" import { type Model } from "@opencode-ai/llm" import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat" import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses" import { Auth, type AnyRoute } from "@opencode-ai/llm/route" -import { Context, Effect, Layer, Option, Schema } from "effect" +import { Context, Effect, Layer, Schema } from "effect" import { produce } from "immer" import { Catalog } from "../../catalog" import { Credential } from "../../credential" import { Integration } from "../../integration" -import { IntegrationConnection } from "../../integration/connection" import { ModelV2 } from "../../model" -import { ModelRequest } from "../../model-request" -import { PluginBoot } from "../../plugin/boot" import { ProviderV2 } from "../../provider" import { SessionSchema } from "../schema" @@ -22,7 +20,36 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass()( + "SessionRunnerModel.ModelUnavailableError", + { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + }, +) { + override get message() { + return `Model unavailable: ${this.providerID}/${this.modelID}` + } +} + +export class VariantUnavailableError extends Schema.TaggedErrorClass()( + "SessionRunnerModel.VariantUnavailableError", + { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + variant: ModelV2.VariantID, + }, +) { + override get message() { + return `Variant unavailable for ${this.providerID}/${this.modelID}: ${this.variant}` + } +} export class UnsupportedApiError extends Schema.TaggedErrorClass()( "SessionRunnerModel.UnsupportedApiError", @@ -31,13 +58,18 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass Effect.Effect @@ -48,17 +80,14 @@ export class Service extends Context.Service()("@opencode/v2 /** Test or embedding seam for supplying a model resolver directly. */ export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) -const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => { - if (credential?.value.type === "key") return Auth.value(credential.value.key) - if (credential?.value.type === "oauth") return Auth.value(credential.value.access) +const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { + if (credential?.type === "key") return Auth.value(credential.key) + if (credential?.type === "oauth") return Auth.value(credential.access) const value = model.request.body.apiKey ?? model.api.settings?.apiKey if (typeof value === "string") return Auth.value(value) - return connection?.type === "env" ? Auth.config(connection.name) : undefined } const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { - const options = model.request.options ?? {} - const namespace = model.api.type === "aisdk" ? ModelRequest.namespace(model.api.package) : undefined const body = model.request.body const httpBody = Object.hasOwn(body, "apiKey") ? Object.fromEntries(Object.entries(body).filter(([key]) => key !== "apiKey")) @@ -67,20 +96,33 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { provider: model.providerID, endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url }, headers: model.request.headers, - generation: model.request.generation, - providerOptions: namespace && Object.keys(options).length > 0 ? { [namespace]: options } : undefined, http: { body: httpBody }, limits: { context: model.limit.context, output: model.limit.output }, }) } -const withVariant = (model: ModelV2.Info, variantID: ModelV2.VariantID | undefined) => { +const withVariant = ( + model: ModelV2.Info, + variantID: ModelV2.VariantID | undefined, +): Effect.Effect => { const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID const variant = model.variants.find((item) => item.id === id) - if (!variant) return model - return produce(model, (draft) => { - ModelRequest.assign(draft.request, variant) - }) + if (!variant && variantID !== undefined && variantID !== "default") + return Effect.fail( + new VariantUnavailableError({ + providerID: model.providerID, + modelID: model.id, + variant: variantID, + }), + ) + return Effect.succeed( + variant + ? produce(model, (draft) => { + Object.assign(draft.request.headers, variant.headers) + Object.assign(draft.request.body, variant.body) + }) + : model, + ) } const apiName = (model: ModelV2.Info) => @@ -88,16 +130,15 @@ const apiName = (model: ModelV2.Info) => export const fromCatalogModel = ( model: ModelV2.Info, - connection?: IntegrationConnection.Info, - credential?: Credential.Stored, + credential?: Credential.Value, ): Effect.Effect => { const resolved = - credential?.value.metadata === undefined + credential?.type !== "key" || credential.metadata === undefined ? model : produce(model, (draft) => { - Object.assign(draft.request.body, credential.value.metadata) + Object.assign(draft.request.body, credential.metadata) }) - const key = apiKey(resolved, connection, credential) + const key = apiKey(resolved, credential) if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") { return Effect.succeed( withDefaults(resolved, OpenAIResponses.route) @@ -128,8 +169,8 @@ export const fromCatalogModel = ( ) } -export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) => - fromCatalogModel(withVariant(model, session.model?.variant)) +export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, credential?: Credential.Value) => + withVariant(model, session.model?.variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential))) export const supported = (model: ModelV2.Info) => model.api.type === "aisdk" && @@ -142,25 +183,36 @@ export const locationLayer = Layer.effect( Service, Effect.gen(function* () { const catalog = yield* Catalog.Service - const credentials = yield* Credential.Service const integrations = yield* Integration.Service - const boot = yield* PluginBoot.Service return Service.of({ resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { // Location plugins populate and filter the catalog asynchronously during layer startup. - yield* boot.wait() + const defaultModel = session.model ? undefined : yield* catalog.model.default() const selected = session.model - ? yield* catalog.model.get(session.model.providerID, session.model.id) - : (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ?? - (yield* catalog.model.available()).find(supported)) + ? (yield* catalog.model.available()).find( + (model) => model.providerID === session.model?.providerID && model.id === session.model.id, + ) + : defaultModel && supported(defaultModel) + ? defaultModel + : (yield* catalog.model.available()).find(supported) + if (!selected && session.model) + return yield* new ModelUnavailableError({ + providerID: session.model.providerID, + modelID: session.model.id, + }) if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id }) - const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID)) - return yield* fromCatalogModel( - withVariant(selected, session.model?.variant), - connection, - connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined, + const provider = yield* catalog.provider.get(selected.providerID) + const connection = yield* integrations.connection.active( + provider?.integrationID ?? Integration.ID.make(selected.providerID), + ) + return yield* resolve( + session, + selected, + connection ? yield* integrations.connection.resolve(connection) : undefined, ) }), }) }), ) + +export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Catalog.node, Integration.node] }) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 5390a26e3b..33652a618c 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -10,6 +10,7 @@ type Input = { readonly sessionID: SessionSchema.ID readonly agent: string readonly model: ModelV2.Ref + readonly snapshot?: string } const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0) @@ -65,15 +66,20 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) >() const timestamp = DateTime.now let assistantMessageID: SessionMessage.ID | undefined + let assistantActive = false + let assistantFailed = false let providerFailed = false + let stepSettlement: { readonly finish: string; readonly tokens: ReturnType } | undefined const startAssistant = Effect.fnUntraced(function* () { if (assistantMessageID !== undefined) return assistantMessageID assistantMessageID = SessionMessage.ID.create() + assistantActive = true yield* events.publish(SessionEvent.Step.Started, { ...input, assistantMessageID, timestamp: yield* timestamp, + snapshot: input.snapshot, }) return assistantMessageID }) @@ -190,6 +196,20 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* flushFragments() }) + const failAssistant = Effect.fnUntraced(function* (message: string) { + if (assistantFailed) return + yield* flush() + const assistantMessageID = yield* startAssistant() + assistantActive = false + assistantFailed = true + yield* events.publish(SessionEvent.Step.Failed, { + sessionID: input.sessionID, + timestamp: yield* timestamp, + assistantMessageID, + error: { type: "unknown", message }, + }) + }) + const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* ( message: string, hostedOnly = false, @@ -375,26 +395,15 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) } case "step-finish": yield* flush() - yield* events.publish(SessionEvent.Step.Ended, { - sessionID: input.sessionID, - timestamp: yield* timestamp, - assistantMessageID: yield* startAssistant(), - finish: event.reason, - cost: 0, - tokens: tokens(event.usage), - }) + assistantActive = false + if (stepSettlement) return yield* Effect.die("Duplicate step finish") + stepSettlement = { finish: event.reason, tokens: tokens(event.usage) } return case "finish": return case "provider-error": providerFailed = true - yield* flush() - yield* events.publish(SessionEvent.Step.Failed, { - sessionID: input.sessionID, - timestamp: yield* timestamp, - assistantMessageID: yield* startAssistant(), - error: { type: "unknown", message: event.message }, - }) + yield* failAssistant(event.message) return } }) @@ -402,9 +411,12 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return { publish, flush, + failAssistant, failUnsettledTools, + hasActiveAssistant: () => assistantActive, hasAssistantStarted: () => assistantMessageID !== undefined, hasProviderError: () => providerFailed, + stepSettlement: () => stepSettlement, startAssistant, assistantMessageID: assistantMessageIDForTool, } diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index ae36f205b1..b2b1af5d30 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -70,24 +70,46 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid const assistant = (message: SessionMessage.Assistant, model: Model) => { const sameModel = String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id) + const reuseProviderMetadata = sameModel && message.error === undefined const content = message.content.flatMap((item): ContentPart[] => { if (item.type === "text") return [{ type: "text", text: item.text }] if (item.type === "reasoning") return sameModel - ? [{ type: "reasoning", text: item.text, providerMetadata: item.providerMetadata }] + ? [ + { + type: "reasoning", + text: item.text, + providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined, + }, + ] : item.text.length > 0 ? [{ type: "text", text: item.text }] : [] - const call = toolCall(item, sameModel ? item.provider?.metadata : undefined) - const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined) - return item.provider?.executed === true && result ? [call, result] : [call] + const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined) + if (item.provider?.executed !== true) return [call] + const result = toolResult( + item, + reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined, + ) + return result ? [call, result] : [call] + }) + const meaningful = content.filter((part) => { + if (part.type === "text") return part.text !== "" + if (part.type !== "reasoning") return true + return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0) }) const results = message.content .filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true) - .map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)) + .map((item) => + toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined), + ) .filter((message) => message !== undefined) .map(Message.tool) - return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results] + if (meaningful.length === 0) return results + return [ + Message.make({ id: message.id, role: "assistant", content: meaningful, metadata: message.metadata }), + ...results, + ] } function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] { diff --git a/packages/core/src/session/schema.ts b/packages/core/src/session/schema.ts index 8509cabee4..05808a2799 100644 --- a/packages/core/src/session/schema.ts +++ b/packages/core/src/session/schema.ts @@ -1,49 +1,9 @@ export * as SessionSchema from "./schema" -import { Schema } from "effect" -import { Location } from "../location" -import { ModelV2 } from "../model" -import { ProjectV2 } from "../project" -import { externalID, type ExternalID, RelativePath, optionalOmitUndefined, withStatics } from "../schema" -import { Identifier } from "../util/identifier" -import { V2Schema } from "../v2-schema" -import { AgentV2 } from "../agent" +import { Session } from "@opencode-ai/schema/session" -export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe( - Schema.brand("SessionID"), - withStatics((schema) => { - const create = () => schema.make("ses_" + Identifier.descending()) - return { - create, - descending: (id?: string) => (id === undefined ? create() : schema.make(id)), - fromExternal: (input: ExternalID) => schema.make(externalID("ses", input)), - } - }), -) +export const ID = Session.ID export type ID = typeof ID.Type -export class Info extends Schema.Class("SessionV2.Info")({ - id: ID, - parentID: ID.pipe(optionalOmitUndefined), - projectID: ProjectV2.ID, - agent: AgentV2.ID.pipe(Schema.optional), - model: ModelV2.Ref.pipe(Schema.optional), - cost: Schema.Finite, - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), - time: Schema.Struct({ - created: V2Schema.DateTimeUtcFromMillis, - updated: V2Schema.DateTimeUtcFromMillis, - archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), - }), - title: Schema.String, - location: Location.Ref, - subpath: RelativePath.pipe(Schema.optional), -}) {} +export const Info = Session.Info +export type Info = Session.Info diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index ca3d8e1b53..264a1d2cca 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -13,6 +13,7 @@ import { WorkspaceV2 } from "../workspace" import { Timestamps } from "../database/schema.sql" import type { SystemContext } from "../system-context/index" import { AgentV2 } from "../agent" +import type { Revert } from "@opencode-ai/schema/revert" type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> type V1MessageData = Omit @@ -37,7 +38,7 @@ export const SessionTable = sqliteTable( summary_additions: integer(), summary_deletions: integer(), summary_files: integer(), - summary_diffs: text({ mode: "json" }).$type(), + summary_diffs: text({ mode: "json" }).$type(), metadata: text({ mode: "json" }).$type>(), cost: real().notNull().default(0), tokens_input: integer().notNull().default(0), @@ -45,7 +46,7 @@ export const SessionTable = sqliteTable( tokens_reasoning: integer().notNull().default(0), tokens_cache_read: integer().notNull().default(0), tokens_cache_write: integer().notNull().default(0), - revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), + revert: text({ mode: "json" }).$type(), permission: text({ mode: "json" }).$type(), agent: text(), model: text({ mode: "json" }).$type<{ @@ -170,9 +171,6 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", { .primaryKey() .references(() => SessionTable.id, { onDelete: "cascade" }), baseline: text().notNull(), - agent: text().$type().notNull().default(AgentV2.defaultID), snapshot: text({ mode: "json" }).notNull().$type(), baseline_seq: integer().notNull(), - replacement_seq: integer(), - revision: integer().notNull().default(0), }) diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index 1cff231306..273444d25c 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -3,6 +3,7 @@ export * as SessionStore from "./store" import { eq } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" +import { makeGlobalNode } from "../effect/app-node" import { SessionHistory } from "./history" import { MessageDecodeError } from "./error" import { SessionMessage } from "./message" @@ -24,7 +25,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/SessionStore") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service @@ -59,4 +60,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) +export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] }) diff --git a/packages/core/src/session/todo.ts b/packages/core/src/session/todo.ts index 7b3c3be3f6..56c73042c4 100644 --- a/packages/core/src/session/todo.ts +++ b/packages/core/src/session/todo.ts @@ -1,30 +1,17 @@ export * as SessionTodo from "./todo" import { asc, eq } from "drizzle-orm" -import { Context, Effect, Layer, Schema } from "effect" +import { Context, Effect, Layer } from "effect" +import { SessionTodo } from "@opencode-ai/schema/session-todo" import { Database } from "../database/database" +import { makeLocationNode } from "../effect/app-node" import { EventV2 } from "../event" import { SessionSchema } from "./schema" import { TodoTable } from "./sql" -export const Info = Schema.Struct({ - content: Schema.String.annotate({ description: "Brief description of the task" }), - status: Schema.String.annotate({ - description: "Current status of the task: pending, in_progress, completed, cancelled", - }), - priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }), -}).annotate({ identifier: "SessionTodo.Info" }) +export const Info = SessionTodo.Info export type Info = typeof Info.Type - -export const Event = { - Updated: EventV2.define({ - type: "todo.updated", - schema: { - sessionID: SessionSchema.ID, - todos: Schema.Array(Info), - }, - }), -} +export const Event = SessionTodo.Event export interface Interface { readonly update: (input: { @@ -36,7 +23,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/SessionTodo") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service @@ -88,4 +75,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer)) +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Database.node] }) diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index 259c8aff5e..be1cd1d49a 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -1,58 +1,31 @@ export * as SkillV2 from "./skill" +import { makeLocationNode } from "./effect/app-node" import path from "path" -import { Context, Effect, Layer, Schema } from "effect" -import { castDraft } from "immer" +import { Context, Effect, Layer, Schema, Types } from "effect" +import { Skill } from "@opencode-ai/schema/skill" import { AgentV2 } from "./agent" import { ConfigMarkdown } from "./config/markdown" import { FSUtil } from "./fs-util" import { PermissionV2 } from "./permission" -import { AbsolutePath, withStatics } from "./schema" +import { AbsolutePath } from "./schema" import { SkillDiscovery } from "./skill/discovery" import { State } from "./state" -export class DirectorySource extends Schema.Class("SkillV2.DirectorySource")({ - type: Schema.Literal("directory"), - path: AbsolutePath, -}) {} +export const DirectorySource = Skill.DirectorySource +export type DirectorySource = Skill.DirectorySource -export class UrlSource extends Schema.Class("SkillV2.UrlSource")({ - type: Schema.Literal("url"), - url: Schema.String, -}) {} +export const UrlSource = Skill.UrlSource +export type UrlSource = Skill.UrlSource -export class EmbeddedSource extends Schema.Class("SkillV2.EmbeddedSource")({ - type: Schema.Literal("embedded"), - skill: Schema.suspend(() => Info), -}) {} +export const EmbeddedSource = Skill.EmbeddedSource +export type EmbeddedSource = Skill.EmbeddedSource -export const Source = Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe( - Schema.toTaggedUnion("type"), - withStatics(() => ({ - equals: (a: DirectorySource | UrlSource | EmbeddedSource, b: DirectorySource | UrlSource | EmbeddedSource) => { - if (a.type !== b.type) return false - if (a.type === "directory" && b.type === "directory") return a.path === b.path - if (a.type === "url" && b.type === "url") return a.url === b.url - if (a.type === "embedded" && b.type === "embedded") return a.skill.name === b.skill.name - return false - }, - key: (source: DirectorySource | UrlSource | EmbeddedSource) => - source.type === "directory" - ? `directory:${source.path}` - : source.type === "url" - ? `url:${source.url}` - : `embedded:${source.skill.name}`, - })), -) +export const Source = Skill.Source export type Source = typeof Source.Type -export class Info extends Schema.Class("SkillV2.Info")({ - name: Schema.String, - description: Schema.String.pipe(Schema.optional), - slash: Schema.Boolean.pipe(Schema.optional), - location: AbsolutePath, - content: Schema.String, -}) {} +export const Info = Skill.Info +export type Info = Skill.Info export const available = (skills: ReadonlyArray, agent: AgentV2.Info) => skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny") @@ -65,34 +38,33 @@ const Frontmatter = Schema.Struct({ const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter) export type Data = { - sources: Source[] + sources: Types.DeepMutable[] } -export type Editor = { +export type Draft = { source: (source: Source) => void list: () => readonly Source[] } -export interface Interface { - readonly transform: State.Interface["transform"] +export interface Interface extends State.Transformable { readonly sources: () => Effect.Effect readonly list: () => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Skill") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const discovery = yield* SkillDiscovery.Service const fs = yield* FSUtil.Service - const state = State.create({ + const state = State.create({ initial: () => ({ sources: [] }), - editor: (draft) => ({ + draft: (draft) => ({ source: (source) => { if (draft.sources.some((item) => Source.equals(item, source))) return - draft.sources.push(castDraft(source)) + draft.sources.push(source as Types.DeepMutable) }, list: () => draft.sources as Source[], }), @@ -120,15 +92,13 @@ export const layer = Layer.effect( ? path.basename(filepath, ".md") : undefined if (!name) continue - skills.push( - new Info({ - name, - description: frontmatter.description, - slash: frontmatter.slash, - location: AbsolutePath.make(filepath), - content: markdown.content, - }), - ) + skills.push({ + name, + description: frontmatter.description, + slash: frontmatter.slash, + location: AbsolutePath.make(filepath), + content: markdown.content, + }) } } return skills @@ -150,6 +120,7 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, + reload: state.reload, sources: Effect.fn("SkillV2.sources")(function* () { return state.get().sources }), @@ -158,4 +129,4 @@ export const layer = Layer.effect( }), ) -export const locationLayer = layer.pipe(Layer.provide(SkillDiscovery.defaultLayer)) +export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node] }) diff --git a/packages/core/src/skill/discovery.ts b/packages/core/src/skill/discovery.ts index 6402dd3b71..a192ba1023 100644 --- a/packages/core/src/skill/discovery.ts +++ b/packages/core/src/skill/discovery.ts @@ -5,6 +5,8 @@ import { Context, Effect, Layer, Schedule, Schema } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FSUtil } from "../fs-util" import { Global } from "../global" +import { makeGlobalNode } from "../effect/app-node" +import { httpClient } from "../effect/app-node-platform" import { AbsolutePath } from "../schema" const skillConcurrency = 4 @@ -52,6 +54,7 @@ function isSafeRelativePath(value: string) { class IndexSkill extends Schema.Class("SkillDiscovery.IndexSkill")({ name: Schema.String, + version: Schema.optional(Schema.String), files: Schema.Array(Schema.String), }) {} @@ -65,7 +68,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/SkillDiscovery") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -80,12 +83,15 @@ export const layer = Layer.effect( ) const download = Effect.fn("SkillDiscovery.download")(function* (url: string, destination: string) { - if (yield* fs.exists(destination).pipe(Effect.orDie)) return - yield* HttpClientRequest.get(url).pipe( + if (yield* fs.exists(destination).pipe(Effect.orDie)) return true + return yield* HttpClientRequest.get(url).pipe( http.execute, Effect.flatMap((response) => response.arrayBuffer), Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))), - Effect.catch((error) => Effect.logError("failed to download skill file", { url, error })), + Effect.as(true), + Effect.catch((error) => + Effect.logError("failed to download skill file", { url, error }).pipe(Effect.as(false)), + ), ) }) @@ -120,6 +126,7 @@ export const layer = Layer.effect( } const skillUrl = new URL(`${encodeURIComponent(skill.name)}/`, source) + const versionFile = path.join(root, ".opencode-version") const files = skill.files.map((file) => { if (!isSafeRelativePath(file)) return undefined let resource: URL @@ -135,23 +142,66 @@ export const layer = Layer.effect( return { url: resource.href, destination, + file, } }) if (files.some((file) => file === undefined)) { return [] } - return [{ skill, root, files: files as { url: string; destination: string }[] }] + return [{ skill, root, versionFile, files: files as { url: string; destination: string; file: string }[] }] }), - ({ skill, root, files }) => + ({ skill, root, versionFile, files }) => Effect.gen(function* () { - yield* Effect.forEach(files, (file) => download(file.url, file.destination), { - concurrency: fileConcurrency, - discard: true, - }) - return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) || + const version = skill.version + const current = + version === undefined + ? undefined + : yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (version === undefined || current === version) { + yield* Effect.forEach(files, (file) => download(file.url, file.destination), { + concurrency: fileConcurrency, + discard: true, + }) + } else { + const token = crypto.randomUUID() + const staging = `${root}.tmp-${token}` + const backup = `${root}.old-${token}` + yield* Effect.gen(function* () { + const downloaded = yield* Effect.forEach( + files, + (file) => download(file.url, path.resolve(staging, file.file)), + { concurrency: fileConcurrency }, + ) + if (!downloaded.every(Boolean)) return + const exists = + (yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie)) || + (yield* fs.exists(path.join(staging, `${skill.name}.md`)).pipe(Effect.orDie)) + if (!exists) return + yield* fs.writeFileString(path.join(staging, ".opencode-version"), version) + yield* Effect.uninterruptible( + Effect.gen(function* () { + const cached = yield* fs.exists(root).pipe(Effect.orDie) + if (cached) yield* fs.rename(root, backup) + yield* fs.rename(staging, root).pipe( + Effect.catch((error) => + Effect.gen(function* () { + if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore) + return yield* Effect.fail(error) + }), + ), + ) + if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore) + }), + ) + }).pipe( + Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })), + Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)), + ) + } + const exists = + (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) || (yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie)) - ? [AbsolutePath.make(root)] - : [] + return exists ? [AbsolutePath.make(root)] : [] }), { concurrency: skillConcurrency }, ).pipe(Effect.map((directories) => directories.flat())) @@ -160,8 +210,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(FetchHttpClient.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.defaultLayer), -) +export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, FSUtil.node, Global.node] }) diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index 92fb4c0a62..347179dbfb 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -1,9 +1,9 @@ export * as SkillGuidance from "./guidance" +import { makeLocationNode } from "../effect/app-node" import { Context, Effect, Layer, Schema } from "effect" import { AgentV2 } from "../agent" import { PermissionV2 } from "../permission" -import { PluginBoot } from "../plugin/boot" import { SkillV2 } from "../skill" import { SystemContext } from "../system-context/index" @@ -37,15 +37,13 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/SkillGuidance") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { - const boot = yield* PluginBoot.Service const skills = yield* SkillV2.Service return Service.of({ load: Effect.fn("SkillGuidance.load")(function* (selection) { - yield* boot.wait() const agent = selection.info if (!agent) return SystemContext.empty const permitted = SkillV2.available(yield* skills.list(), agent) @@ -74,3 +72,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [SkillV2.node] }) diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index b39c0f7f01..de5962b483 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -1,9 +1,266 @@ -export namespace Snapshot { - export type FileDiff = { - file?: string - patch?: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" - } +export * as Snapshot from "./snapshot" + +import { makeLocationNode } from "./effect/app-node" +import path from "path" +import { Context, Effect, Layer, Schema } from "effect" +import { Config } from "./config" +import { File } from "./file" +import { FSUtil } from "./fs-util" +import { Git } from "./git" +import { Global } from "./global" +import { Location } from "./location" +import { AbsolutePath, RelativePath } from "./schema" +import { Hash } from "./util/hash" + +export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID")) +export type ID = typeof ID.Type + +export class Error extends Schema.TaggedErrorClass()("Snapshot.Error", { + operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]), + message: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + +export interface CompareInput { + readonly from: ID + readonly to: ID +} + +export interface DiffInput extends CompareInput { + readonly context?: number + readonly paths?: readonly RelativePath[] +} + +export interface RestoreInput { + /** Paths are relative to the project root. */ + readonly files: ReadonlyMap +} + +export interface PreviewInput extends RestoreInput { + readonly context?: number +} + +export interface Interface { + /** + * Capture the current Location-scoped filesystem state as a content-addressed + * tree. Returns `undefined` when snapshots are disabled, unsupported, or the + * best-effort capture fails. + */ + readonly capture: () => Effect.Effect + + /** + * List project-relative paths changed between two captured trees without + * loading file contents or generating patches. + */ + readonly files: (input: CompareInput) => Effect.Effect + + /** + * Generate structured per-file diffs between two captured trees. `context` + * controls unchanged lines around each unified diff hunk. + */ + readonly diff: (input: DiffInput) => Effect.Effect + + /** + * Preview the filesystem result of a selective restore without modifying the + * worktree. Each project-relative path maps to the tree it would be restored + * from. + */ + readonly preview: (input: PreviewInput) => Effect.Effect + + /** + * Restore selected project-relative paths from their associated trees. A path + * absent from its selected tree is removed; paths outside the map are untouched. + */ + readonly restore: (input: RestoreInput) => Effect.Effect + + /** + * Replace the snapshot index with a captured tree and check out all its entries. + * Files absent from the tree remain untouched. Prefer selective `restore` when + * only known paths should change. + */ + readonly checkout: (snapshot: ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Snapshot") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const fs = yield* FSUtil.Service + const git = yield* Git.Service + const global = yield* Global.Service + const location = yield* Location.Service + const source = yield* git.repo.discover(location.project.directory) + const worktree = source + ? AbsolutePath.make(yield* fs.realPath(source.worktree).pipe(Effect.orDie)) + : location.project.directory + const gitDirectory = AbsolutePath.make(path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree))) + + const scope = Effect.fnUntraced(function* () { + const relative = path.relative(worktree, location.directory) + if (relative.startsWith("..") || path.isAbsolute(relative)) + return yield* new Error({ operation: "capture", message: "Location is outside the project" }) + return RelativePath.make(relative.replaceAll("\\", "/") || ".") + }) + + const repository = Effect.fnUntraced(function* () { + if (!source) return yield* new Error({ operation: "capture", message: "Project is not a Git repository" }) + if (yield* fs.existsSafe(path.join(gitDirectory, "HEAD"))) + return new Git.Repository({ + worktree, + gitDirectory, + commonDirectory: gitDirectory, + }) + return yield* git.repo + .create({ + worktree, + gitDirectory, + seed: source, + }) + .pipe(Effect.mapError((cause) => failure("capture", cause))) + }) + + const enabled = Effect.fnUntraced(function* () { + if (location.vcs?.type !== "git") return false + return Config.latest(yield* config.entries(), "snapshots") !== false + }) + + const capture = Effect.fn("Snapshot.capture")(function* () { + if (!(yield* enabled())) return undefined + return yield* Effect.gen(function* () { + const repo = yield* repository() + return ID.make( + yield* git.tree.capture({ + repository: repo, + scopes: [yield* scope()], + ignores: source, + maximumUntrackedFileBytes: 2 * 1024 * 1024, + }), + ) + }).pipe( + Effect.catch((cause) => Effect.logWarning("failed to capture snapshot", { cause }).pipe(Effect.as(undefined))), + ) + }) + + const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) { + const repo = yield* repository().pipe(Effect.mapError((cause) => failure(operation, cause))) + return { repository: repo, from: Git.TreeID.make(input.from), to: Git.TreeID.make(input.to) } + }) + + const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) { + const comparison = yield* compare("files", input) + const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure("files", cause))) + if (!source) return files + const ignored = yield* git.index + .ignored({ repository: source, paths: files }) + .pipe(Effect.mapError((cause) => failure("files", cause))) + return files.filter((file) => !ignored.has(file)) + }) + + const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) { + const comparison = yield* compare("diff", input) + const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure("diff", cause))) + const ignored = source + ? yield* git.index + .ignored({ repository: source, paths: files }) + .pipe(Effect.mapError((cause) => failure("diff", cause))) + : new Set() + return yield* git.tree + .diff({ + ...comparison, + context: input.context, + paths: (input.paths ?? files).filter((file) => !ignored.has(file)), + }) + .pipe(Effect.mapError((cause) => failure("diff", cause))) + }) + + const plan = Effect.fnUntraced(function* (operation: "preview" | "restore", input: RestoreInput) { + const files = new Map() + for (const [file, snapshot] of input.files) { + const absolute = path.resolve(worktree, file) + if (!FSUtil.contains(worktree, absolute)) + return yield* new Error({ operation, message: `Path escapes the project: ${file}` }) + files.set(file, Git.TreeID.make(snapshot)) + } + return files + }) + + const preview = Effect.fn("Snapshot.preview")(function* (input: PreviewInput) { + if (!(yield* enabled())) return yield* new Error({ operation: "preview", message: "Snapshots are disabled" }) + const repo = yield* repository().pipe(Effect.mapError((cause) => failure("preview", cause))) + const files = yield* plan("preview", input) + const current = yield* git.tree + .capture({ + repository: repo, + scopes: Array.from(files.keys()), + ignores: source, + maximumUntrackedFileBytes: 2 * 1024 * 1024, + }) + .pipe(Effect.mapError((cause) => failure("preview", cause))) + return yield* git.tree + .preview({ + repository: repo, + current, + files, + context: input.context, + }) + .pipe(Effect.mapError((cause) => failure("preview", cause))) + }) + + const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) { + if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" }) + const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause))) + yield* git.tree + .restore({ repository: repo, files: yield* plan("restore", input) }) + .pipe(Effect.mapError((cause) => failure("restore", cause))) + }) + + const checkout = Effect.fn("Snapshot.checkout")(function* (snapshot: ID) { + const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause))) + yield* git.tree + .checkout({ repository: repo, tree: Git.TreeID.make(snapshot) }) + .pipe(Effect.mapError((cause) => failure("restore", cause))) + }) + + return Service.of({ capture, files, diff, preview, restore, checkout }) + }), +) + +export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer)) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Config.node, FSUtil.node, Git.node, Global.node, Location.node], +}) + +export const noopLayer = Layer.succeed( + Service, + Service.of({ + capture: () => Effect.succeed(undefined), + files: () => Effect.succeed([]), + diff: () => Effect.succeed([]), + preview: () => Effect.succeed([]), + restore: () => Effect.void, + checkout: () => Effect.void, + }), +) + +function failure(operation: Error["operation"], cause: unknown) { + if (cause instanceof Error && cause.operation === operation) return cause + return new Error({ + operation, + message: cause instanceof globalThis.Error ? cause.message : String(cause), + cause, + }) +} + +/** Legacy persisted session diff shape. */ +export type LegacyFileDiff = { + file?: string + patch?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" } diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index 7f1ae58d24..ab3457fc18 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -1,112 +1,128 @@ export * as State from "./state" -import { Effect, Scope, Semaphore } from "effect" -import type { Draft, Objectish } from "immer" +import { Context, Effect, Scope, Semaphore } from "effect" /** - * A replayable transform applied to an editor during rebuild. + * A replayable transform applied to a draft during reload. * - * Transforms are intentionally synchronous and mutation-shaped: domain editors - * hide the draft representation while preserving concise plugin/config code. + * Domain drafts expose readable and writable state while preserving concise + * plugin/config code. Transforms may perform Effects before returning. */ -export type Transform = (editor: Editor) => void -export type MakeEditor = (draft: Draft) => Editor +type TransformCallback = (draft: DraftApi) => Effect.Effect | void +export type MakeDraft = (state: State) => DraftApi -export interface Options { - /** Creates the base value for initial state and every scoped-transform rebuild. */ - readonly initial: () => State - /** Wraps the mutable draft in a domain-specific editor. */ - readonly editor: MakeEditor - /** - * Completes every committed edit. - * - * For rebuilds, this runs after all active transforms have been replayed and - * before the rebuilt state becomes visible. For direct updates, this runs - * after the current state has already been edited. The optional reason is - * caller-defined metadata for exceptional update origins. - */ - readonly finalize?: (editor: Editor, reason?: string) => Effect.Effect +export interface Registration { + readonly dispose: Effect.Effect } -export interface Interface { +export type Transform = ( + transform: TransformCallback, +) => Effect.Effect + +export type Reload = () => Effect.Effect + +export interface Transformable { + readonly transform: Transform + readonly reload: Reload +} + +const CurrentBatch = Context.Reference | undefined>("@opencode/State/CurrentBatch", { + defaultValue: () => undefined, +}) + +export function batch(effect: Effect.Effect) { + return Effect.gen(function* () { + const current = yield* CurrentBatch + if (current) return yield* effect + const reloads = new Set() + const result = yield* effect.pipe(Effect.provideService(CurrentBatch, reloads)) + yield* Effect.forEach(reloads, (reload) => reload(), { discard: true }) + return result + }) +} + +export interface Options { + /** Creates the base value for initial state and every scoped-transform reload. */ + readonly initial: () => State + /** Wraps mutable state in a domain-specific draft API. */ + readonly draft: MakeDraft + /** Runs after all active transforms and before the rebuilt state becomes visible. */ + readonly finalize?: (draft: DraftApi) => Effect.Effect +} + +export interface Interface extends Transformable { readonly get: () => State /** - * Registers a scoped transform slot and returns the slot updater. - * - * Acquiring the slot has no visible effect until the returned updater is - * called. Each updater call replaces that slot's transform, then rebuilds the - * materialized state from `initial()` by replaying all active transforms in - * registration order. Closing the owning Scope removes the slot and rebuilds. + * Registers and applies a scoped transform. Closing the owning Scope removes + * the transform and reloads the materialized state. */ - readonly transform: () => Effect.Effect<(transform: Transform) => Effect.Effect, never, Scope.Scope> - /** Registers and applies a replayable transform in the current Scope. */ - readonly update: (update: Transform) => Effect.Effect - /** - * Mutates the current materialized state directly, once. - * - * This is not replayable transform state: a later rebuild starts again - * from `initial()` plus active transforms, so direct edits must be reserved - * for current-state adjustments that are intentionally outside the transform - * fold. - */ - readonly mutate: (update: (editor: Editor) => Effect.Effect, reason?: string) => Effect.Effect } -export function create(options: Options): Interface { +export function create(options: Options): Interface { let state = options.initial() - let transforms: { update: Transform }[] = [] + let transforms: { run: TransformCallback }[] = [] const semaphore = Semaphore.makeUnsafe(1) - const commit = Effect.fn("State.commit")(function* (next: State, reason?: string) { - const api = options.editor(next as Draft) - if (options.finalize) yield* options.finalize(api, reason) + const commit = Effect.fn("State.commit")(function* (next: State) { + const api = options.draft(next) + if (options.finalize) yield* options.finalize(api) state = next }) - const rebuild = Effect.fnUntraced(function* () { + const apply = (transform: TransformCallback, draft: DraftApi) => + Effect.suspend(() => { + const result = transform(draft) + return Effect.isEffect(result) ? Effect.asVoid(result).pipe(Effect.orDie) : Effect.void + }) + + const materialize = Effect.fnUntraced(function* () { const next = options.initial() - const api = options.editor(next as Draft) - for (const transform of transforms) - yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {})) + const api = options.draft(next) + for (const transform of transforms) yield* apply(transform.run, api).pipe(Effect.withSpan("State.reload.update")) yield* commit(next) }) - const result: Interface = { + const reload = () => semaphore.withPermit(materialize()) + + const result: Interface = { get: () => state, - transform: Effect.fn("State.transform")(function* () { + transform: Effect.fn("State.transform")(function* (update) { const scope = yield* Scope.Scope return yield* Effect.uninterruptible( Effect.gen(function* () { - const transform = { update: (_editor: Editor) => {} } - transforms = [...transforms, transform] - yield* Scope.addFinalizer( - scope, + const transform = { run: update } + let active = true + const dispose = Effect.uninterruptible( semaphore.withPermit( - Effect.sync(() => { + Effect.suspend(() => { + if (!active) return Effect.void + active = false transforms = transforms.filter((item) => item !== transform) - }).pipe(Effect.andThen(rebuild())), + return Effect.gen(function* () { + const batch = yield* CurrentBatch + if (batch) { + batch.add(reload) + return + } + yield* materialize() + }) + }), ), ) - return (update: Transform) => - Effect.uninterruptible( - semaphore.withPermit( - Effect.sync(() => { - transform.update = update - }).pipe(Effect.andThen(rebuild())), - ), - ) + yield* semaphore.withPermit( + Effect.sync(() => { + transforms = [...transforms, transform] + }), + ) + yield* Scope.addFinalizer(scope, dispose) + const batch = yield* CurrentBatch + if (batch) batch.add(reload) + else yield* reload() + return { dispose } }), ) }), - update: Effect.fn("State.update")(function* (update) { - const transform = yield* result.transform() - yield* transform(update) - }), - mutate: Effect.fn("State.mutate")(function* (update, reason) { - const api = options.editor(state as Draft) - yield* update(api) - if (options.finalize) yield* options.finalize(api, reason) - }, semaphore.withPermit), + reload, } return result } diff --git a/packages/core/src/system-context/builtins.ts b/packages/core/src/system-context/builtins.ts index 42cba27b92..b8b50577cc 100644 --- a/packages/core/src/system-context/builtins.ts +++ b/packages/core/src/system-context/builtins.ts @@ -1,10 +1,13 @@ export * as SystemContextBuiltIns from "./builtins" +import { makeLocationNode } from "../effect/app-node" import { DateTime, Effect, Layer, Schema } from "effect" import { Location } from "../location" import { SystemContext } from "./index" import { InstructionContext } from "../instruction-context" import { SystemContextRegistry } from "./registry" +import { FSUtil } from "../fs-util" +import { Global } from "../global" const builtIns = Layer.effectDiscard( Effect.gen(function* () { @@ -40,8 +43,8 @@ const builtIns = Layer.effectDiscard( }), ) -export const layer = Layer.mergeAll(builtIns, InstructionContext.layer).pipe( - Layer.provideMerge(SystemContextRegistry.layer), -) - -export const locationLayer = layer +export const node = makeLocationNode({ + name: "system-context-builtins", + layer: builtIns, + deps: [Location.node, SystemContextRegistry.node, InstructionContext.node, FSUtil.node, Global.node], +}) diff --git a/packages/core/src/system-context/index.ts b/packages/core/src/system-context/index.ts index 9fd4ca119f..c0a583c08c 100644 --- a/packages/core/src/system-context/index.ts +++ b/packages/core/src/system-context/index.ts @@ -82,7 +82,11 @@ export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated | Replace export class InitializationBlocked extends Schema.TaggedErrorClass()( "SystemContext.InitializationBlocked", { keys: Schema.Array(Key) }, -) {} +) { + override get message() { + return `System context initialization blocked by unavailable sources: ${this.keys.join(", ")}` + } +} export class DuplicateKeyError extends Schema.TaggedErrorClass()("SystemContext.DuplicateKeyError", { key: Key, diff --git a/packages/core/src/system-context/registry.ts b/packages/core/src/system-context/registry.ts index a2a7ca7e62..c1e7ca5e85 100644 --- a/packages/core/src/system-context/registry.ts +++ b/packages/core/src/system-context/registry.ts @@ -2,6 +2,7 @@ export * as SystemContextRegistry from "./registry" import { Context, Effect, Layer, Ref, Scope } from "effect" import { SystemContext } from "./index" +import { makeLocationNode } from "../effect/app-node" export interface Entry { readonly key: SystemContext.Key @@ -15,7 +16,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/SystemContextRegistry") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const entries = yield* Ref.make>([]) @@ -44,3 +45,5 @@ export const layer = Layer.effect( }) }), ) + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts index 2d15ee8d0d..1030ff22ff 100644 --- a/packages/core/src/tool-output-store.ts +++ b/packages/core/src/tool-output-store.ts @@ -5,6 +5,7 @@ import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effe import { Config } from "./config" import { FSUtil } from "./fs-util" 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" @@ -28,8 +29,13 @@ export interface BoundResult { export class StorageError extends Schema.TaggedErrorClass()("ToolOutputStore.StorageError", { operation: Schema.Literals(["encode", "write"]), - cause: Schema.Defect, -}) {} + cause: Schema.Defect(), +}) { + override get message() { + const detail = this.cause instanceof Error ? this.cause.message : String(this.cause) + return `Failed to ${this.operation} tool output${detail ? `: ${detail}` : ""}` + } +} export type Error = StorageError @@ -103,7 +109,7 @@ const lineCount = (text: string) => { return count } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -186,7 +192,9 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.defaultLayer)) +export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Config.node] }) + +export const nodeWithoutConfig = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node] }) /** Runs retention scanning once globally rather than once per active Location. */ export const cleanupLayer = Layer.effectDiscard( @@ -196,4 +204,8 @@ export const cleanupLayer = Layer.effectDiscard( }), ) -export const defaultCleanupLayer = Layer.merge(defaultLayer, cleanupLayer.pipe(Layer.provide(defaultLayer))) +export const cleanupNode = makeGlobalNode({ + name: "tool-output-cleanup", + layer: Layer.merge(layer, cleanupLayer.pipe(Layer.provide(layer))), + deps: [FSUtil.node, Global.node], +}) diff --git a/packages/core/src/tool/application-tools.ts b/packages/core/src/tool/application-tools.ts index 024c2006d0..4d541691b2 100644 --- a/packages/core/src/tool/application-tools.ts +++ b/packages/core/src/tool/application-tools.ts @@ -1,15 +1,15 @@ export * as ApplicationTools from "./application-tools" import { Context, Effect, Layer, Scope } from "effect" -import { enableMapSet } from "immer" import { State } from "../state" import { Tool } from "./tool" +import { makeGlobalNode } from "../effect/app-node" type Data = { readonly entries: Map } -type Editor = { +type Draft = { readonly set: (name: string, entry: Entry) => void } @@ -27,14 +27,12 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ApplicationTools") {} -enableMapSet() - -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { - const state = State.create({ + const state = State.create({ initial: () => ({ entries: new Map() }), - editor: (draft) => ({ + draft: (draft) => ({ set: (name, tool) => { draft.entries.set(name, tool) }, @@ -47,12 +45,13 @@ export const layer = Layer.effect( if (entries.length === 0) return yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true }) const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const) - const transform = yield* state.transform() - yield* transform((editor) => { - for (const [name, entry] of registrations) editor.set(name, entry) + yield* state.transform((draft) => { + for (const [name, entry] of registrations) draft.set(name, entry) }) }), entries: () => state.get().entries, }) }), ) + +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 78e5b0f40d..3d7105a31a 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -1,12 +1,16 @@ export * as ApplyPatchTool from "./apply-patch" import { ToolFailure } from "@opencode-ai/llm" +import { FileDiff } from "@opencode-ai/schema/file-diff" +import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" import { Patch } from "../patch" import { PermissionV2 } from "../permission" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -24,7 +28,10 @@ export const Applied = Schema.Struct({ target: Schema.String, }) -export const Output = Schema.Struct({ applied: Schema.Array(Applied) }) +export const Output = Schema.Struct({ + applied: Schema.Array(Applied), + files: Schema.Array(FileDiff.Info), +}) export type Output = typeof Output.Type export const toModelOutput = (output: Output) => @@ -36,14 +43,20 @@ export const toModelOutput = (output: Output) => ].join("\n") type Prepared = - | (Extract & { readonly target: LocationMutation.Target }) + | (Extract & { + readonly target: LocationMutation.Target + readonly before: string + readonly after: string + }) | (Extract & { readonly target: LocationMutation.Target readonly source: Uint8Array readonly content: string + readonly before: string + readonly after: string }) -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const mutation = yield* LocationMutation.Service @@ -113,29 +126,36 @@ export const layer = Layer.effectDiscard( for (const { hunk, target } of targets) { yield* Effect.gen(function* () { if (hunk.type === "add") { - prepared.push({ ...hunk, target }) + prepared.push({ + ...hunk, + target, + before: "", + after: + hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`, + }) return } if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path) + const source = yield* fs.readFile(target.canonical) + const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source) + const before = original.replace(/^\uFEFF/, "") if (hunk.type === "delete") { - prepared.push({ ...hunk, target }) + prepared.push({ ...hunk, target, before, after: "" }) return } - const source = yield* fs.readFile(target.canonical) - const update = Patch.derive( - hunk.path, - hunk.chunks, - new TextDecoder("utf-8", { ignoreBOM: true }).decode(source), - ) + const update = Patch.derive(hunk.path, hunk.chunks, original) prepared.push({ ...hunk, target, source, content: Patch.joinBom(update.content, update.bom), + before, + after: update.content, }) }).pipe(Effect.mapError(() => fail(hunk.path))) } + const patchFiles = prepared.map(patchFile) yield* Effect.forEach( prepared, (change) => @@ -165,7 +185,7 @@ export const layer = Layer.effectDiscard( }).pipe(Effect.mapError(() => fail(change.path))), { discard: true }, ) - return { applied } + return { applied, files: patchFiles } }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch")))) }, }), @@ -175,3 +195,25 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/apply-patch", + layer, + deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node], +}) + +function patchFile(change: Prepared): typeof FileDiff.Info.Type { + const counts = diffLines(change.before, change.after).reduce( + (result, item) => ({ + additions: result.additions + (item.added ? (item.count ?? 0) : 0), + deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), + }), + { additions: 0, deletions: 0 }, + ) + return { + file: change.target.resource, + patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after), + status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified", + ...counts, + } +} diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/bash.ts index bd6f175ada..228818c2dd 100644 --- a/packages/core/src/tool/bash.ts +++ b/packages/core/src/tool/bash.ts @@ -5,11 +5,13 @@ import { ToolFailure } from "@opencode-ai/llm" import { Duration, Effect, Layer, Schema } from "effect" import { ChildProcess } from "effect/unstable/process" import { Config } from "../config" +import { makeLocationNode } from "../effect/app-node" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" import { AppProcess } from "../process" import { PermissionV2 } from "../permission" import { PositiveInt } from "../schema" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -28,21 +30,17 @@ export const Input = Schema.Struct({ .annotate({ description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`, }), - description: Schema.String.pipe(Schema.optional).annotate({ - description: "Concise description of the command's purpose", - }), +}) + +const StructuredOutput = Schema.Struct({ + exit: Schema.Number.pipe(Schema.optional), + truncated: Schema.Boolean, + timeout: Schema.Boolean.pipe(Schema.optional), }) const Output = Schema.Struct({ - command: Schema.String, - cwd: Schema.String, - exitCode: Schema.Number.pipe(Schema.optional), - /** Bounded compact equivalent of stdout/stderr: stderr is labeled when present. */ + ...StructuredOutput.fields, output: Schema.String, - truncated: Schema.Boolean, - stdoutTruncated: Schema.Boolean.pipe(Schema.optional), - stderrTruncated: Schema.Boolean.pipe(Schema.optional), - timedOut: Schema.Boolean.pipe(Schema.optional), warnings: Schema.Array(Schema.String).pipe(Schema.optional), }) @@ -50,24 +48,12 @@ type Output = typeof Output.Type const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh") -const compactOutput = (stdout: string, stderr: string) => { - const output = stdout && stderr ? `${stdout}\n\nstderr:\n${stderr}` : stderr ? `stderr:\n${stderr}` : stdout - return output || "(no output)" -} - -const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => { - if (stdoutTruncated && stderrTruncated) return "[stdout and stderr capture truncated at the in-memory safety limit]" - if (stdoutTruncated) return "[stdout capture truncated at the in-memory safety limit]" - if (stderrTruncated) return "[stderr capture truncated at the in-memory safety limit]" - return undefined -} - const modelOutput = (output: Output) => { const warnings = output.warnings?.length ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` : "" - if (output.timedOut) return `${output.output}${warnings}\n\nCommand timed out before completion.` - return `${output.output}${warnings}\n\nCommand exited with code ${output.exitCode}.` + if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.` + return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.` } const isTimeout = (error: AppProcess.AppProcessError) => @@ -104,7 +90,7 @@ const externalCommandDirectories = (command: string, cwd: string) => { return [...directories] } -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const mutation = yield* LocationMutation.Service @@ -119,7 +105,16 @@ export const layer = Layer.effectDiscard( description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`, input: Input, output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: modelOutput(output) }], + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ + truncated: output.truncated, + ...(output.exit === undefined ? {} : { exit: output.exit }), + ...(output.timeout === undefined ? {} : { timeout: output.timeout }), + }), + toModelOutput: ({ output }) => [ + { type: "text", text: output.output }, + { type: "text", text: modelOutput(output) }, + ], execute: (input, context) => Effect.gen(function* () { const source = { @@ -166,9 +161,9 @@ export const layer = Layer.effectDiscard( const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS const result = yield* appProcess .run(command, { + combineOutput: true, timeout: Duration.millis(timeout), maxOutputBytes: MAX_CAPTURE_BYTES, - maxErrorBytes: MAX_CAPTURE_BYTES, }) .pipe( Effect.catchTag("AppProcessError", (error) => @@ -177,26 +172,22 @@ export const layer = Layer.effectDiscard( ) if (!result) { return { - command: input.command, - cwd: target.canonical, output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, truncated: false, - timedOut: true, + timeout: true, ...(warnings.length ? { warnings } : {}), } } - const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8")) - const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated) + const output = result.output?.toString("utf8") || "(no output)" + const notice = result.outputTruncated + ? "[output capture truncated at the in-memory safety limit]" + : undefined return { - command: input.command, - cwd: target.canonical, - exitCode: result.exitCode, - output: notice ? `${compact}\n\n${notice}` : compact, - truncated: result.stdoutTruncated || result.stderrTruncated, + exit: result.exitCode, + output: notice ? `${output}\n\n${notice}` : output, + truncated: result.outputTruncated === true, ...(warnings.length ? { warnings } : {}), - ...(result.stdoutTruncated ? { stdoutTruncated: true } : {}), - ...(result.stderrTruncated ? { stderrTruncated: true } : {}), } }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))), }), @@ -204,3 +195,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/bash", + layer, + deps: [ToolRegistry.node, LocationMutation.node, FSUtil.node, AppProcess.node, Config.node, PermissionV2.node], +}) diff --git a/packages/core/src/tool/builtins.ts b/packages/core/src/tool/builtins.ts index 62926202dc..a558124378 100644 --- a/packages/core/src/tool/builtins.ts +++ b/packages/core/src/tool/builtins.ts @@ -1,5 +1,6 @@ export * as BuiltInTools from "./builtins" +import { makeLocationNode } from "../effect/app-node" import { Layer } from "effect" import { BashTool } from "./bash" import { ApplyPatchTool } from "./apply-patch" @@ -8,7 +9,6 @@ import { GlobTool } from "./glob" import { GrepTool } from "./grep" import { QuestionTool } from "./question" import { ReadTool } from "./read" -import { ReadToolFileSystem } from "./read-filesystem" import { SkillTool } from "./skill" import { TodoWriteTool } from "./todowrite" import { WebFetchTool } from "./webfetch" @@ -28,17 +28,21 @@ import { WriteTool } from "./write" * repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin * transforms separate from this static built-in list. */ -export const locationLayer = Layer.mergeAll( - ApplyPatchTool.layer, - BashTool.layer, - EditTool.layer, - GlobTool.layer, - GrepTool.layer, - QuestionTool.layer, - ReadTool.layer.pipe(Layer.provide(ReadToolFileSystem.layer)), - SkillTool.layer, - TodoWriteTool.layer, - WebFetchTool.layer, - WebSearchTool.layer.pipe(Layer.provide(WebSearchTool.defaultConfigLayer)), - WriteTool.layer, -) +export const node = makeLocationNode({ + name: "built-in-tools", + layer: Layer.empty, + deps: [ + ApplyPatchTool.node, + BashTool.node, + EditTool.node, + GlobTool.node, + GrepTool.node, + QuestionTool.node, + ReadTool.node, + SkillTool.node, + TodoWriteTool.node, + WebFetchTool.node, + WebSearchTool.node, + WriteTool.node, + ], +}) diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 9b12704a22..f0bdb488a0 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -7,11 +7,15 @@ export * as EditTool from "./edit" import { ToolFailure } from "@opencode-ai/llm" +import { FileDiff } from "@opencode-ai/schema/file-diff" +import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -30,10 +34,7 @@ export const Input = Schema.Struct({ }) export const Output = Schema.Struct({ - operation: Schema.Literal("write"), - target: Schema.String, - resource: Schema.String, - existed: Schema.Boolean, + files: Schema.Array(FileDiff.Info), replacements: Schema.Number, }) export type Output = typeof Output.Type @@ -71,7 +72,7 @@ const previewLines = (value: string, prefix: "+" | "-") => { export const toModelOutput = (output: Output, oldString: string, newString: string) => [ - `Edited file successfully: ${output.resource}`, + `Edited file successfully: ${output.files[0]?.file}`, `Replacements: ${output.replacements}`, "```diff", ...previewLines(oldString, "-"), @@ -86,7 +87,7 @@ export const toModelOutput = (output: Output, oldString: string, newString: stri // TODO: Add snapshots / undo after design exists. // TODO: Add LSP notification and diagnostics after V2 LSP runtime exists. -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const mutation = yield* LocationMutation.Service @@ -179,6 +180,13 @@ export const layer = Layer.effectDiscard( input.replaceAll === true ? source.text.replaceAll(oldString, newString) : source.text.replace(oldString, newString) + const counts = diffLines(source.text, replaced).reduce( + (result, item) => ({ + additions: result.additions + (item.added ? (item.count ?? 0) : 0), + deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), + }), + { additions: 0, deletions: 0 }, + ) const next = splitBom(replaced) const result = yield* unableToEdit( files.writeIfUnchanged({ @@ -187,7 +195,17 @@ export const layer = Layer.effectDiscard( content: joinBom(next.text, source.bom || next.bom), }), ) - return { ...result, replacements } satisfies Output + return { + files: [ + { + file: result.resource, + patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced), + status: "modified" as const, + ...counts, + }, + ], + replacements, + } satisfies Output }) }, }), @@ -197,3 +215,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/edit", + layer, + deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node], +}) diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index af0838b9af..f8bd1869e1 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -3,11 +3,13 @@ export * as GlobTool from "./glob" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" import path from "path" +import { makeLocationNode } from "../effect/app-node" import { FileSystem } from "../filesystem" import { Location } from "../location" import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" import { PermissionV2 } from "../permission" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -33,7 +35,7 @@ export const toModelOutput = (output: ModelOutput) => { } /** Glob leaf that defaults its filesystem root to the active Location. */ -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const ripgrep = yield* Ripgrep.Service @@ -79,12 +81,11 @@ export const layer = Layer.effectDiscard( }) .pipe( Effect.map((result) => - result.map( - (entry) => - new FileSystem.Entry({ - ...entry, - path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))), - }), + result.map((entry) => + FileSystem.Entry.make({ + ...entry, + path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))), + }), ), ), ) @@ -96,3 +97,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/glob", + layer, + deps: [ToolRegistry.node, Ripgrep.node, Location.node, PermissionV2.node], +}) diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index dd8df87251..f455bd4c8a 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -3,12 +3,14 @@ export * as GrepTool from "./grep" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" import path from "path" +import { makeLocationNode } from "../effect/app-node" import { FileSystem } from "../filesystem" import { FSUtil } from "../fs-util" import { Location } from "../location" import { PermissionV2 } from "../permission" import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -48,7 +50,7 @@ export const toModelOutput = (output: ModelOutput) => { } /** Grep leaf that defaults its filesystem root to the active Location. */ -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const fs = yield* FSUtil.Service @@ -102,23 +104,22 @@ export const layer = Layer.effectDiscard( }) .pipe( Effect.map((result) => - result.map( - (match) => - new FileSystem.Match({ - ...match, - entry: new FileSystem.Entry({ - ...match.entry, - path: RelativePath.make( - path.relative( - location.directory, - path.resolve( - info?.type === "Directory" ? target : path.dirname(target), - match.entry.path, - ), + result.map((match) => + FileSystem.Match.make({ + ...match, + entry: FileSystem.Entry.make({ + ...match.entry, + path: RelativePath.make( + path.relative( + location.directory, + path.resolve( + info?.type === "Directory" ? target : path.dirname(target), + match.entry.path, ), ), - }), + ), }), + }), ), ), ) @@ -128,3 +129,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/grep", + layer, + deps: [ToolRegistry.node, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node], +}) diff --git a/packages/core/src/tool/http-body.ts b/packages/core/src/tool/http-body.ts new file mode 100644 index 0000000000..7cb534a444 --- /dev/null +++ b/packages/core/src/tool/http-body.ts @@ -0,0 +1,30 @@ +import { Effect, Stream } from "effect" +import { HttpClientResponse } from "effect/unstable/http" + +export const collectBoundedResponseBody = ( + response: HttpClientResponse.HttpClientResponse, + maximumBytes: number, + tooLarge: () => Error, +) => + Effect.gen(function* () { + const contentLength = response.headers["content-length"] + const parsedSize = contentLength ? Number.parseInt(contentLength, 10) : undefined + const declaredSize = + parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined + if (declaredSize !== undefined && declaredSize > maximumBytes) return yield* Effect.fail(tooLarge()) + let body = Buffer.allocUnsafe(Math.min(maximumBytes, declaredSize || 64 * 1024)) + let size = 0 + yield* Stream.runForEach(response.stream, (chunk) => { + if (chunk.byteLength === 0) return Effect.void + if (size + chunk.byteLength > maximumBytes) return Effect.fail(tooLarge()) + if (size + chunk.byteLength > body.byteLength) { + const grown = Buffer.allocUnsafe(Math.min(maximumBytes, Math.max(size + chunk.byteLength, body.byteLength * 2))) + body.copy(grown, 0, 0, size) + body = grown + } + body.set(chunk, size) + size += chunk.byteLength + return Effect.void + }) + return body.subarray(0, size) + }) diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index 6c50a809ec..e5ae0d7426 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -2,8 +2,10 @@ export * as QuestionTool from "./question" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" import { PermissionV2 } from "../permission" import { QuestionV2 } from "../question" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -42,7 +44,7 @@ export const toModelOutput = ( return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.` } -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const question = yield* QuestionV2.Service @@ -84,3 +86,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/question", + layer, + deps: [ToolRegistry.node, PermissionV2.node, QuestionV2.node], +}) diff --git a/packages/core/src/tool/read-filesystem.ts b/packages/core/src/tool/read-filesystem.ts index c27bdae6de..e325a83edd 100644 --- a/packages/core/src/tool/read-filesystem.ts +++ b/packages/core/src/tool/read-filesystem.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from "url" import { Context, Effect, Layer, Option, Schema } from "effect" import { FileSystem } from "../filesystem" import { FSUtil } from "../fs-util" +import { makeLocationNode } from "../effect/app-node" import { AbsolutePath, PositiveInt, RelativePath } from "../schema" export const MAX_READ_LINES = 2_000 @@ -13,23 +14,61 @@ export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024 const MAX_LINE_LENGTH = 2_000 const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` -export class BinaryFileError extends Error { - constructor(readonly resource: string) { - super(`Cannot read binary file: ${resource}`) - this.name = "BinaryFileError" +export class BinaryFileError extends Schema.TaggedErrorClass()("ReadTool.BinaryFileError", { + resource: Schema.String, +}) { + override get message() { + return `Cannot read binary file: ${this.resource}` } } -export class MediaIngestLimitError extends Error { - constructor( - readonly resource: string, - readonly maximumBytes: number, - ) { - super(`Media exceeds ${maximumBytes} byte ingestion limit: ${resource}`) - this.name = "MediaIngestLimitError" +export class MediaIngestLimitError extends Schema.TaggedErrorClass()( + "ReadTool.MediaIngestLimitError", + { + resource: Schema.String, + maximumBytes: Schema.Number, + }, +) { + override get message() { + return `Media exceeds ${this.maximumBytes} byte ingestion limit: ${this.resource}` } } +export class MalformedUtf8Error extends Schema.TaggedErrorClass()("ReadTool.MalformedUtf8Error", { + resource: Schema.String, +}) { + override get message() { + return `File is not valid UTF-8: ${this.resource}` + } +} + +export class OffsetOutOfRangeError extends Schema.TaggedErrorClass()( + "ReadTool.OffsetOutOfRangeError", + { offset: Schema.Number }, +) { + override get message() { + return `Offset ${this.offset} is out of range` + } +} + +export class PathKindError extends Schema.TaggedErrorClass()("ReadTool.PathKindError", { + resource: Schema.String, + expected: Schema.Literals(["a file", "a file or directory"]), +}) { + override get message() { + return `Path is not ${this.expected}: ${this.resource}` + } +} + +export type InspectError = FSUtil.Error | PathKindError +export type ReadError = + | FSUtil.Error + | BinaryFileError + | MediaIngestLimitError + | MalformedUtf8Error + | OffsetOutOfRangeError + | PathKindError + export const PageInput = Schema.Struct({ offset: PositiveInt.pipe(Schema.optional), limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional), @@ -52,13 +91,13 @@ export class ListPage extends Schema.Class("ReadTool.ListPage")({ }) {} export interface Interface { - readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory"> + readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory", InspectError> readonly read: ( path: AbsolutePath, resource: string, page?: PageInput, - ) => Effect.Effect - readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect + ) => Effect.Effect + readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/ReadToolFileSystem") {} @@ -111,11 +150,21 @@ const binary = (resource: string, bytes: Uint8Array) => { } return nonPrintable / bytes.length > 0.3 } +const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array) => + Effect.try({ + try: () => decoder.decode(bytes, { stream: bytes !== undefined }), + catch: (error) => { + if (error instanceof TypeError) return new MalformedUtf8Error({ resource }) + throw error + }, + }) +const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) => + bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes) export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) { - const info = yield* fs.stat(input).pipe(Effect.orDie) + const info = yield* fs.stat(input) const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined - if (!type) return yield* Effect.die(new Error("Path is not a file or directory")) + if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" })) return type }) @@ -125,32 +174,30 @@ export const read = Effect.fn("ReadTool.read")(function* ( resource: string, page: PageInput = {}, ) { - const real = yield* fs.realPath(input).pipe(Effect.orDie) + const real = yield* fs.realPath(input) return yield* Effect.scoped( Effect.gen(function* () { - const file = yield* fs.open(real, { flag: "r" }).pipe(Effect.orDie) - const info = yield* file.stat.pipe(Effect.orDie) - if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) + const file = yield* fs.open(real, { flag: "r" }) + const info = yield* file.stat + if (info.type !== "File") return yield* Effect.fail(new PathKindError({ resource, expected: "a file" })) const first = Option.getOrElse( - yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)).pipe(Effect.orDie), + yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)), () => new Uint8Array(), ) const mime = imageMime(first) if (mime) { if (info.size > MAX_MEDIA_INGEST_BYTES) - return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES)) + return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })) const chunks = [first] let total = first.length while (total <= MAX_MEDIA_INGEST_BYTES) { - const chunk = yield* file - .readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total)) - .pipe(Effect.orDie) + const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total)) if (Option.isNone(chunk)) break chunks.push(chunk.value) total += chunk.value.length } if (total > MAX_MEDIA_INGEST_BYTES) - return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES)) + return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })) return { uri: pathToFileURL(real).href, name: path.basename(real), @@ -162,19 +209,19 @@ export const read = Effect.fn("ReadTool.read")(function* ( mime, } } - if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || binary(resource, first)) - return yield* Effect.die(new BinaryFileError(resource)) + if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || extensions.has(path.extname(resource).toLowerCase())) + return yield* Effect.fail(new BinaryFileError({ resource })) const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined if (!paged) { + if (binary(resource, first)) return yield* Effect.fail(new BinaryFileError({ resource })) const decoder = new TextDecoder("utf-8", { fatal: true }) - const text = [yield* Effect.sync(() => decoder.decode(first, { stream: true }))] + const text = [yield* decodeUtf8(resource, decoder, first)] while (true) { - const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie) + const chunk = yield* file.readAlloc(64 * 1024) if (Option.isNone(chunk)) break - if (chunk.value.includes(0)) return yield* Effect.die(new BinaryFileError(resource)) - text.push(yield* Effect.sync(() => decoder.decode(chunk.value, { stream: true }))) + text.push(yield* decodeChunk(resource, decoder, chunk.value)) } - text.push(yield* Effect.sync(() => decoder.decode())) + text.push(yield* decodeUtf8(resource, decoder)) return { uri: pathToFileURL(real).href, name: path.basename(real), @@ -191,34 +238,29 @@ export const read = Effect.fn("ReadTool.read")(function* ( let discard = false let line = 1 let bytes = 0 - let found = false - let truncated = false let next: number | undefined const append = (input: string) => { if (line < offset) { line++ - return + return true } if (lines.length >= limit || bytes >= MAX_READ_BYTES) { - truncated = true - next ??= line++ - return + next = line + return false } - found = true const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0) if (bytes + size > MAX_READ_BYTES) { - truncated = true - next ??= line++ - return + next = line + return false } lines.push(text) bytes += size line++ + return true } - const consume = (chunk: Uint8Array) => { - if (chunk.includes(0)) throw new BinaryFileError(resource) - let text = decoder.decode(chunk, { stream: true }) + const consume = (input: string) => { + let text = input while (true) { const index = text.indexOf("\n") if (index === -1) { @@ -235,25 +277,44 @@ export const read = Effect.fn("ReadTool.read")(function* ( pending = "" discard = false text = text.slice(index + 1) - append(current.endsWith("\r") ? current.slice(0, -1) : current) + if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) return false } + return true } - yield* Effect.sync(() => consume(first)) - while (true) { - const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie) + const consumeChunk = Effect.fnUntraced(function* (chunk: Uint8Array) { + let start = 0 + while (start < chunk.length) { + if (lines.length >= limit || bytes >= MAX_READ_BYTES) { + next = line + return false + } + const newline = chunk.indexOf(10, start) + const end = newline === -1 ? chunk.length : newline + 1 + const segment = chunk.subarray(start, end) + if (binary(resource, segment)) return yield* Effect.fail(new BinaryFileError({ resource })) + if (!consume(yield* decodeUtf8(resource, decoder, segment))) return false + start = end + } + return true + }) + let done = !(yield* consumeChunk(first)) + while (!done) { + const chunk = yield* file.readAlloc(64 * 1024) if (Option.isNone(chunk)) break - yield* Effect.sync(() => consume(chunk.value)) + done = !(yield* consumeChunk(chunk.value)) } - const tail = yield* Effect.sync(() => decoder.decode()) - if (!discard) pending += tail - if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending) - if (!found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`)) + if (!done) { + const tail = yield* decodeUtf8(resource, decoder) + if (!discard) pending += tail + if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending) + } + if (lines.length === 0 && offset !== 1) return yield* Effect.fail(new OffsetOutOfRangeError({ offset })) return new TextPage({ type: "text-page", content: lines.join("\n"), mime: FSUtil.mimeType(real), offset, - truncated, + truncated: next !== undefined, ...(next === undefined ? {} : { next }), }) }), @@ -261,8 +322,8 @@ export const read = Effect.fn("ReadTool.read")(function* ( }) export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) { - const real = yield* fs.realPath(input).pipe(Effect.orDie) - const items = yield* fs.readDirectoryEntries(real).pipe(Effect.orDie) + const real = yield* fs.realPath(input) + const items = yield* fs.readDirectoryEntries(real) const offset = page.offset ?? 1 const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES) const entries = yield* Effect.forEach( @@ -275,10 +336,9 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.void)) const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined if (!type) return - return new FileSystem.Entry({ + return FileSystem.Entry.make({ path: RelativePath.make(item.name + (type === "directory" ? path.sep : "")), type, - mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(target), }) }), { concurrency: 16 }, @@ -291,7 +351,7 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, return new ListPage({ entries: selected, truncated, ...(truncated ? { next: offset + selected.length } : {}) }) }) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -302,3 +362,5 @@ export const layer = Layer.effect( }) }), ) + +export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] }) diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 64f02d813f..6961a86091 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -1,15 +1,15 @@ export * as ReadTool from "./read" import { ToolFailure } from "@opencode-ai/llm" -import path from "path" import { Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" import { FileSystem } from "../filesystem" -import { FSUtil } from "../fs-util" import { Image } from "../image" -import { Location } from "../location" +import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" import { AbsolutePath } from "../schema" import { ReadToolFileSystem } from "./read-filesystem" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -27,12 +27,11 @@ const LocationInput = Schema.Struct({ const Input = LocationInput const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage]) -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service - const fs = yield* FSUtil.Service const reader = yield* ReadToolFileSystem.Service - const location = yield* Location.Service + const mutation = yield* LocationMutation.Service const image = yield* Image.Service const permission = yield* PermissionV2.Service @@ -40,7 +39,7 @@ export const layer = Layer.effectDiscard( .register({ [name]: Tool.make({ description: - "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths are read directly.", + "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.", input: Input, output: Output, toModelOutput: ({ input, output }) => { @@ -53,27 +52,34 @@ export const layer = Layer.effectDiscard( }, execute: (input, context) => { return Effect.gen(function* () { - const absolute = path.resolve(location.directory, input.path) - const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory - if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute)) - return yield* Effect.die(new Error("Path escapes the allowed read root")) - const real = yield* fs.realPath(absolute).pipe(Effect.orDie) - const root = yield* fs.realPath(selected).pipe(Effect.orDie) - if (!FSUtil.contains(root, real)) - return yield* Effect.die(new Error("Path escapes the allowed read root")) - const resource = path.relative(root, real).replaceAll("\\", "/") || "." - const target = AbsolutePath.make(real) - const type = yield* reader.inspect(target) + const source = { + type: "tool" as const, + messageID: context.assistantMessageID, + callID: context.toolCallID, + } + const target = yield* mutation.resolve({ path: input.path, kind: "directory" }) + const external = target.externalDirectory + if (external) + yield* permission.assert({ + ...LocationMutation.externalDirectoryPermission(external), + sessionID: context.sessionID, + agent: context.agent, + source, + }) + const resource = target.resource + const absolute = AbsolutePath.make(target.canonical) + const type = yield* reader.inspect(absolute) yield* permission.assert({ action: name, resources: [resource], save: ["*"], sessionID: context.sessionID, agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + source, }) - if (type === "directory") return yield* reader.list(target, { offset: input.offset, limit: input.limit }) - const content = yield* reader.read(target, resource, { + if (type === "directory") + return yield* reader.list(absolute, { offset: input.offset, limit: input.limit }) + const content = yield* reader.read(absolute, resource, { offset: input.offset, limit: input.limit, }) @@ -83,7 +89,7 @@ export const layer = Layer.effectDiscard( .pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content))) } if ("encoding" in content && content.encoding === "base64") - return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError(resource)) + return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource })) return content }).pipe( Effect.mapError((error) => { @@ -103,3 +109,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/read", + layer, + deps: [ToolRegistry.node, ReadToolFileSystem.node, LocationMutation.node, Image.node, PermissionV2.node], +}) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index d99cc9014c..1c2dfe7ab4 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -11,6 +11,7 @@ import { Wildcard } from "../util/wildcard" import { ApplicationTools } from "./application-tools" import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool" import { Tools } from "./tools" +import { makeLocationNode } from "../effect/app-node" export type ExecuteInput = { readonly sessionID: SessionSchema.ID @@ -123,7 +124,7 @@ const registryLayer = Layer.effect( }), ) -export const layer = Layer.effect( +const layer = Layer.effect( Tools.Service, Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))), ).pipe(Layer.provideMerge(registryLayer)) @@ -133,7 +134,14 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) { return rule?.resource === "*" && rule.effect === "deny" } -export const defaultLayer = layer.pipe( - Layer.provide(ApplicationTools.layer), - Layer.provide(ToolOutputStore.defaultLayer), -) +export const node = makeLocationNode({ + service: Service, + layer, + deps: [ApplicationTools.node, ToolOutputStore.node], +}) + +export const toolsNode = makeLocationNode({ + service: Tools.Service, + layer, + deps: [ApplicationTools.node, ToolOutputStore.node], +}) diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 589a99d462..1f8b122903 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -1,13 +1,13 @@ export * as SkillTool from "./skill" import path from "path" -import { pathToFileURL } from "url" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" import { FSUtil } from "../fs-util" -import { PluginBoot } from "../plugin/boot" import { SkillV2 } from "../skill" import { PermissionV2 } from "../permission" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -40,7 +40,7 @@ export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray) "", skill.content.trim(), "", - `Base directory for this skill: ${pathToFileURL(directory).href}`, + `Base directory for this skill: ${directory}`, "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", "Note: file list is sampled.", "", @@ -54,14 +54,12 @@ export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray) const unableToLoad = (name: string, error?: unknown) => new ToolFailure({ message: `Unable to load skill ${name}`, error }) -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const fs = yield* FSUtil.Service - const boot = yield* PluginBoot.Service const skills = yield* SkillV2.Service const permission = yield* PermissionV2.Service - yield* boot.wait() yield* tools .register({ [name]: Tool.make({ @@ -103,3 +101,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/skill", + layer, + deps: [ToolRegistry.node, FSUtil.node, SkillV2.node, PermissionV2.node], +}) diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index a746d524f0..bc1ba1fbb3 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/packages/core/src/tool/todowrite.ts @@ -2,8 +2,10 @@ export * as TodoWriteTool from "./todowrite" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" import { PermissionV2 } from "../permission" import { SessionTodo } from "../session/todo" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -20,7 +22,7 @@ export type Output = typeof Output.Type export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2) -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const todos = yield* SessionTodo.Service @@ -52,3 +54,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/todowrite", + layer, + deps: [ToolRegistry.node, PermissionV2.node, SessionTodo.node], +}) diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index eb70f2cb47..1d9a82e952 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -37,10 +37,19 @@ export type Content = | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string } -type Config, Output extends SchemaType> = { +type Config< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +> = { readonly description: string readonly input: Input readonly output: Output + readonly structured?: Structured + readonly toStructuredOutput?: (input: { + readonly input: Schema.Schema.Type + readonly output: Output["Encoded"] + }) => Schema.Schema.Type readonly execute: ( input: Schema.Schema.Type, context: Context, @@ -59,10 +68,12 @@ type Runtime = { const runtimes = new WeakMap() -export function make, Output extends SchemaType>( - config: Config, -): Definition { - const tool = Object.freeze({}) as Definition +export function make< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +>(config: Config): Definition { + const tool = Object.freeze({}) as Definition const definitions = new Map() runtimes.set(tool, { definition: (name) => { @@ -72,7 +83,7 @@ export function make, Output extends SchemaType, Output extends SchemaType Schema.encodeEffect(config.output)(output).pipe( + Effect.flatMap((output) => { + if (!config.structured || !config.toStructuredOutput) + return Effect.succeed({ output, structured: output }) + return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe( + Effect.map((structured) => ({ output, structured })), + ) + }), Effect.mapError( (error) => new ToolFailure({ @@ -92,8 +110,8 @@ export function make, Output extends SchemaType ({ - structured: output, + Effect.map(({ output, structured }) => ({ + structured, content: config.toModelOutput?.({ input, output }).map((part) => part.type === "text" diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index 1e209e5008..d3889d6a7a 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -1,11 +1,15 @@ export * as WebFetchTool from "./webfetch" import { ToolFailure } from "@opencode-ai/llm" -import { Duration, Effect, Layer, Schema, Stream } from "effect" +import { Duration, Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Parser } from "htmlparser2" import TurndownService from "turndown" +import { makeLocationNode } from "../effect/app-node" +import { LayerNodePlatform } from "../effect/app-node-platform" import { PermissionV2 } from "../permission" +import { collectBoundedResponseBody } from "./http-body" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -86,24 +90,11 @@ const execute = (http: HttpClient.HttpClient, url: string, format: Format, userA http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)) const collectBody = (response: HttpClientResponse.HttpClientResponse) => - Effect.gen(function* () { - const contentLength = response.headers["content-length"] - if (contentLength && Number.parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) { - return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)) - } - const chunks: Uint8Array[] = [] - let size = 0 - yield* Stream.runForEach(response.stream, (chunk) => - Effect.gen(function* () { - size += chunk.byteLength - if (size > MAX_RESPONSE_BYTES) - return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)) - chunks.push(chunk) - return undefined - }), - ) - return Buffer.concat(chunks, size) - }) + collectBoundedResponseBody( + response, + MAX_RESPONSE_BYTES, + () => new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`), + ) const mimeFrom = (contentType: string) => contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "" const isImageAttachment = (mime: string) => @@ -124,7 +115,7 @@ const convert = (content: string, contentType: string, format: Format) => { return content } -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const http = yield* HttpClient.HttpClient @@ -171,12 +162,16 @@ export const layer = Layer.effectDiscard( orElse: () => Effect.fail(new Error("Request timed out")), }), ) - const content = convert(new TextDecoder().decode(body), contentType, input.format) + const content = new TextDecoder().decode(body) + const output = yield* Effect.try({ + try: () => convert(content, contentType, input.format), + catch: (error) => error, + }) return { url: input.url, contentType, format: input.format, - output: content, + output, } }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))), }), @@ -185,6 +180,12 @@ export const layer = Layer.effectDiscard( }), ) +export const node = makeLocationNode({ + name: "tool/webfetch", + layer, + deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient], +}) + export function extractTextFromHTML(html: string) { let text = "" let skipDepth = 0 diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 6513eefbda..f3de0c1a48 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -3,13 +3,17 @@ export * as WebSearchTool from "./websearch" import { ToolFailure } from "@opencode-ai/llm" import { Context, Duration, Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { makeLocationNode } from "../effect/app-node" +import { LayerNodePlatform } from "../effect/app-node-platform" import { truthy } from "../flag/flag" import { InstallationVersion } from "../installation/version" import { PositiveInt } from "../schema" import { PermissionV2 } from "../permission" import { Tool } from "./tool" import { Tools } from "./tools" +import { collectBoundedResponseBody } from "./http-body" import { checksum } from "../util/encode" +import { ToolRegistry } from "./registry" export const name = "websearch" export const NO_RESULTS = "No search results found. Please try a different query." @@ -79,6 +83,8 @@ export const defaultConfigLayer = Layer.sync(ConfigService, () => }), ) +export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] }) + export function selectProvider( sessionID: string, flags: Pick = { enableExa: false, enableParallel: false }, @@ -164,10 +170,12 @@ const callMcp = ( ) return yield* Effect.gen(function* () { const response = yield* HttpClient.filterStatusOk(http).execute(request) - const body = yield* response.text - if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES) - return yield* Effect.fail(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`)) - return yield* parseResponse(body) + const body = yield* collectBoundedResponseBody( + response, + MAX_RESPONSE_BYTES, + () => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`), + ) + return yield* parseResponse(body.toString("utf8")) }).pipe( Effect.timeoutOrElse({ duration: Duration.seconds(25), @@ -181,7 +189,7 @@ const Output = Schema.Struct({ text: Schema.String, }) -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const http = yield* HttpClient.HttpClient @@ -244,3 +252,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/websearch", + layer, + deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient, configNode], +}) diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index 1438e52247..39ad0b20fb 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -8,9 +8,11 @@ export * as WriteTool from "./write" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" import { FileMutation } from "../file-mutation" import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -42,7 +44,7 @@ export const toModelOutput = (output: Output) => // TODO: Add snapshots / undo after design exists. // TODO: Add LSP notification and diagnostics after V2 LSP runtime exists. -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const mutation = yield* LocationMutation.Service @@ -91,3 +93,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/write", + layer, + deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, PermissionV2.node], +}) diff --git a/packages/core/src/util/effect-flock.ts b/packages/core/src/util/effect-flock.ts index 2ba5ef0d75..b85900118a 100644 --- a/packages/core/src/util/effect-flock.ts +++ b/packages/core/src/util/effect-flock.ts @@ -6,7 +6,7 @@ import type { FileSystem, Scope } from "effect" import type { PlatformError } from "effect/PlatformError" import { FSUtil } from "../fs-util" import { Global } from "../global" -import { LayerNode } from "../effect/layer-node" +import { makeGlobalNode } from "../effect/app-node" import { Hash } from "./hash" export namespace EffectFlock { @@ -24,7 +24,7 @@ export namespace EffectFlock { class ReleaseError extends Schema.TaggedErrorClass()("ReleaseError", { detail: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) { override get message() { return this.detail @@ -94,7 +94,7 @@ export namespace EffectFlock { const isPathGone = (e: PlatformError) => e.reason._tag === "NotFound" || e.reason._tag === "Unknown" - export const layer: Layer.Layer = Layer.effect( + const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const global = yield* Global.Service @@ -280,6 +280,5 @@ export namespace EffectFlock { }), ) - export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.layer)) - export const node = LayerNode.make(layer, [Global.node, FSUtil.node]) + export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Global.node, FSUtil.node] }) } diff --git a/packages/core/src/util/identifier.ts b/packages/core/src/util/identifier.ts index ba28a351ba..442fbb3983 100644 --- a/packages/core/src/util/identifier.ts +++ b/packages/core/src/util/identifier.ts @@ -1,48 +1 @@ -import { randomBytes } from "crypto" - -export namespace Identifier { - const LENGTH = 26 - - // State for monotonic ID generation - let lastTimestamp = 0 - let counter = 0 - - export function ascending() { - return create(false) - } - - export function descending() { - return create(true) - } - - function randomBase62(length: number): string { - const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - let result = "" - const bytes = randomBytes(length) - for (let i = 0; i < length; i++) { - result += chars[bytes[i] % 62] - } - return result - } - - export function create(descending: boolean, timestamp?: number): string { - const currentTimestamp = timestamp ?? Date.now() - - if (currentTimestamp !== lastTimestamp) { - lastTimestamp = currentTimestamp - counter = 0 - } - counter++ - - let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) - - now = descending ? ~now : now - - const timeBytes = Buffer.alloc(6) - for (let i = 0; i < 6; i++) { - timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) - } - - return timeBytes.toString("hex") + randomBase62(LENGTH - 12) - } -} +export * as Identifier from "@opencode-ai/schema/identifier" diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index c474cac51a..ebe1b905ff 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -6,7 +6,6 @@ import { ConfigMCPV1 } from "./mcp" import { ConfigPermissionV1 } from "./permission" import { ConfigProviderV1 } from "./provider" import { ConfigProviderOptionsV1 } from "./provider-options" -import { ModelRequest } from "../../model-request" const keys = new Set([ "logLevel", @@ -133,7 +132,7 @@ function mcp(info: typeof ConfigV1.Info.Type) { ) const timeout = info.experimental?.mcp_timeout if (!timeout && !Object.keys(servers).length) return undefined - return { timeout, servers } + return { timeout: timeout === undefined ? undefined : { request: timeout }, servers } } function migrateMcp(info: ConfigMCPV1.Info) { @@ -145,7 +144,7 @@ function migrateMcp(info: ConfigMCPV1.Info) { cwd: info.cwd, environment: info.environment, disabled, - timeout: info.timeout, + timeout: info.timeout === undefined ? undefined : { request: info.timeout }, } return { type: info.type, @@ -159,7 +158,7 @@ function migrateMcp(info: ConfigMCPV1.Info) { redirect_uri: info.oauth.redirectUri, }, disabled, - timeout: info.timeout, + timeout: info.timeout === undefined ? undefined : { request: info.timeout }, } } @@ -171,6 +170,7 @@ function providers(info?: Readonly>) { function migrateProvider(info: ConfigProviderV1.Info) { const lowerer = ConfigProviderOptionsV1.get(info.npm) const options = lowerer.provider(info.options ?? {}) + const url = info.api ?? options.url return { name: info.name, env: info.env, @@ -178,7 +178,7 @@ function migrateProvider(info: ConfigProviderV1.Info) { ? { type: "aisdk" as const, package: info.npm, - url: info.api ?? options.url, + ...(url === undefined ? {} : { url }), settings: options.settings ?? {}, } : undefined, @@ -192,11 +192,7 @@ function migrateProvider(info: ConfigProviderV1.Info) { function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: string) { const packageID = info.provider?.npm ?? packageName const lowerer = ConfigProviderOptionsV1.get(packageID) - const ingest = (options: Readonly>) => { - const request = ModelRequest.normalizeAiSdkOptions(packageID, options) - return { ...lowerer.request(request.body), ...request.generation, ...request.options } - } - const request = info.options && ingest(info.options) + const request = info.options && lowerer.request(info.options) const costs = info.cost && [ { input: info.cost.input, @@ -226,7 +222,7 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st ...(info.id === undefined ? {} : { id: info.id }), type: "aisdk" as const, package: info.provider.npm, - url: info.provider.api, + ...(info.provider.api === undefined ? {} : { url: info.provider.api }), settings: {}, } : info.id === undefined @@ -241,7 +237,7 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st info.variants && Object.entries(info.variants).map(([id, options]) => ({ id, - body: ingest(options), + body: lowerer.request(options), })), cost: costs, disabled: info.status === "deprecated" ? true : undefined, diff --git a/packages/core/src/v1/config/provider-options.ts b/packages/core/src/v1/config/provider-options.ts index a441a1a211..6bf0bd9e1a 100644 --- a/packages/core/src/v1/config/provider-options.ts +++ b/packages/core/src/v1/config/provider-options.ts @@ -40,7 +40,23 @@ const openai: Lowerer = { settings: omit(options, ["apiKey", "baseURL", "organization", "project", "headers", "body"]), } }, - request: snake, + request(options) { + const result = snake(options) + if (options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) { + result.reasoning = { + ...(isRecord(result.reasoning) ? result.reasoning : {}), + ...(options.reasoningEffort !== undefined ? { effort: options.reasoningEffort } : {}), + ...(options.reasoningSummary !== undefined ? { summary: options.reasoningSummary } : {}), + } + delete result.reasoning_effort + delete result.reasoning_summary + } + if (options.textVerbosity !== undefined) { + result.text = { ...(isRecord(result.text) ? result.text : {}), verbosity: options.textVerbosity } + delete result.text_verbosity + } + return result + }, } const anthropic: Lowerer = { diff --git a/packages/core/src/v1/permission.ts b/packages/core/src/v1/permission.ts index b241ccd907..c289196c18 100644 --- a/packages/core/src/v1/permission.ts +++ b/packages/core/src/v1/permission.ts @@ -1,71 +1,8 @@ export * as PermissionV1 from "./permission" import { Schema } from "effect" -import { ProjectV2 } from "../project" -import { withStatics } from "../schema" -import { SessionSchema } from "../session/schema" -import { Identifier } from "../util/identifier" - -export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( - Schema.brand("PermissionID"), - withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })), -) -export type ID = typeof ID.Type - -export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" }) -export type Action = typeof Action.Type - -export const Rule = Schema.Struct({ - permission: Schema.String, - pattern: Schema.String, - action: Action, -}).annotate({ identifier: "PermissionRule" }) -export type Rule = typeof Rule.Type - -export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" }) -export type Ruleset = typeof Ruleset.Type - -export const Request = Schema.Struct({ - id: ID, - sessionID: SessionSchema.ID, - permission: Schema.String, - patterns: Schema.Array(Schema.String), - metadata: Schema.Record(Schema.String, Schema.Unknown), - always: Schema.Array(Schema.String), - tool: Schema.Struct({ - messageID: Schema.String, - callID: Schema.String, - }).pipe(Schema.optional), -}).annotate({ identifier: "PermissionRequest" }) -export type Request = typeof Request.Type - -export const Reply = Schema.Literals(["once", "always", "reject"]) -export type Reply = typeof Reply.Type - -export const ReplyBody = Schema.Struct({ - reply: Reply, - message: Schema.String.pipe(Schema.optional), -}).annotate({ identifier: "PermissionReplyBody" }) -export type ReplyBody = typeof ReplyBody.Type - -export const Approval = Schema.Struct({ - projectID: ProjectV2.ID, - patterns: Schema.Array(Schema.String), -}).annotate({ identifier: "PermissionApproval" }) -export type Approval = typeof Approval.Type - -export const AskInput = Schema.Struct({ - ...Request.fields, - id: ID.pipe(Schema.optional), - ruleset: Ruleset, -}).annotate({ identifier: "PermissionAskInput" }) -export type AskInput = typeof AskInput.Type - -export const ReplyInput = Schema.Struct({ - requestID: ID, - ...ReplyBody.fields, -}).annotate({ identifier: "PermissionReplyInput" }) -export type ReplyInput = typeof ReplyInput.Type +export * from "@opencode-ai/schema/permission-v1" +import { ID } from "@opencode-ai/schema/permission-v1" export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { override get message() { diff --git a/packages/core/src/v1/session.ts b/packages/core/src/v1/session.ts index 34bb729683..b4ce31e5f0 100644 --- a/packages/core/src/v1/session.ts +++ b/packages/core/src/v1/session.ts @@ -1,39 +1,52 @@ export * as SessionV1 from "./session" -import { Effect, Schema, Types } from "effect" -import { EventV2 } from "../event" -import { PermissionV1 } from "./permission" -import { ProjectV2 } from "../project" -import { ProviderV2 } from "../provider" -import { ModelV2 } from "../model" -import { optionalOmitUndefined, withStatics } from "../schema" -import { Identifier } from "../util/identifier" +import { Schema } from "effect" import { NonNegativeInt } from "../schema" import { NamedError } from "../util/error" -import { SessionSchema } from "../session/schema" -import { WorkspaceV2 } from "../workspace" -const Timestamp = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) - -export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( - Schema.brand("MessageID"), - withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "msg_" + Identifier.ascending()) })), -) -export type MessageID = typeof MessageID.Type - -export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( - Schema.brand("PartID"), - withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "prt_" + Identifier.ascending()) })), -) -export type PartID = typeof PartID.Type +export { + AgentPart, + AgentPartInput, + Assistant, + CompactionPart, + Event, + FilePart, + FilePartInput, + FilePartSource, + FileSource, + Format, + Info, + MessageID, + OutputFormatJsonSchema, + OutputFormatText, + Part, + PartID, + PatchPart, + Range, + ReasoningPart, + ResourceSource, + RetryPart, + SessionInfo, + SnapshotPart, + StepFinishPart, + StepStartPart, + SubtaskPart, + SubtaskPartInput, + SymbolSource, + TextPart, + TextPartInput, + ToolPart, + ToolState, + ToolStateCompleted, + ToolStateError, + ToolStatePending, + ToolStateRunning, + User, + WithParts, +} from "@opencode-ai/schema/session-v1" export const OutputLengthError = NamedError.create("MessageOutputLengthError", {}) - -export const AuthError = NamedError.create("ProviderAuthError", { - providerID: Schema.String, - message: Schema.String, -}) - +export const AuthError = NamedError.create("ProviderAuthError", { providerID: Schema.String, message: Schema.String }) export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String }) export const StructuredOutputError = NamedError.create("StructuredOutputError", { message: Schema.String, @@ -52,581 +65,4 @@ export const ContextOverflowError = NamedError.create("ContextOverflowError", { message: Schema.String, responseBody: Schema.optional(Schema.String), }) -export const ContentFilterError = NamedError.create("ContentFilterError", { - message: Schema.String, -}) - -export class OutputFormatText extends Schema.Class("OutputFormatText")({ - type: Schema.Literal("text"), -}) {} - -export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ - type: Schema.Literal("json_schema"), - schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), - retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), -}) {} - -export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ - discriminator: "type", - identifier: "OutputFormat", -}) -export type OutputFormat = Schema.Schema.Type - -const partBase = { - id: PartID, - sessionID: SessionSchema.ID, - messageID: MessageID, -} - -export const SnapshotPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("snapshot"), - snapshot: Schema.String, -}).annotate({ identifier: "SnapshotPart" }) -export type SnapshotPart = Types.DeepMutable> - -export const PatchPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("patch"), - hash: Schema.String, - files: Schema.Array(Schema.String), -}).annotate({ identifier: "PatchPart" }) -export type PatchPart = Types.DeepMutable> - -export const TextPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("text"), - text: Schema.String, - synthetic: Schema.optional(Schema.Boolean), - ignored: Schema.optional(Schema.Boolean), - time: Schema.optional( - Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), - ), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "TextPart" }) -export type TextPart = Types.DeepMutable> - -export const ReasoningPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("reasoning"), - text: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), -}).annotate({ identifier: "ReasoningPart" }) -export type ReasoningPart = Types.DeepMutable> - -const filePartSourceBase = { - text: Schema.Struct({ - value: Schema.String, - start: Schema.Finite, - end: Schema.Finite, - }).annotate({ identifier: "FilePartSourceText" }), -} - -export const Range = Schema.Struct({ - start: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), - end: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), -}).annotate({ identifier: "Range" }) -export type Range = typeof Range.Type - -export const FileSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("file"), - path: Schema.String, -}).annotate({ identifier: "FileSource" }) - -export const SymbolSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("symbol"), - path: Schema.String, - range: Range, - name: Schema.String, - kind: NonNegativeInt, -}).annotate({ identifier: "SymbolSource" }) - -export const ResourceSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("resource"), - clientName: Schema.String, - uri: Schema.String, -}).annotate({ identifier: "ResourceSource" }) - -export const FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({ - discriminator: "type", - identifier: "FilePartSource", -}) - -export const FilePart = Schema.Struct({ - ...partBase, - type: Schema.Literal("file"), - mime: Schema.String, - filename: Schema.optional(Schema.String), - url: Schema.String, - source: Schema.optional(FilePartSource), -}).annotate({ identifier: "FilePart" }) -export type FilePart = Types.DeepMutable> - -export const AgentPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("agent"), - name: Schema.String, - source: Schema.optional( - Schema.Struct({ - value: Schema.String, - start: NonNegativeInt, - end: NonNegativeInt, - }), - ), -}).annotate({ identifier: "AgentPart" }) -export type AgentPart = Types.DeepMutable> - -export const CompactionPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("compaction"), - auto: Schema.Boolean, - overflow: Schema.optional(Schema.Boolean), - tail_start_id: Schema.optional(MessageID), -}).annotate({ identifier: "CompactionPart" }) -export type CompactionPart = Types.DeepMutable> - -export const SubtaskPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("subtask"), - prompt: Schema.String, - description: Schema.String, - agent: Schema.String, - model: Schema.optional( - Schema.Struct({ - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - }), - ), - command: Schema.optional(Schema.String), -}).annotate({ identifier: "SubtaskPart" }) -export type SubtaskPart = Types.DeepMutable> - -export const RetryPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("retry"), - attempt: NonNegativeInt, - error: APIError.EffectSchema, - time: Schema.Struct({ - created: NonNegativeInt, - }), -}).annotate({ identifier: "RetryPart" }) -export type RetryPart = Omit>, "error"> & { - error: APIError -} - -export const StepStartPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("step-start"), - snapshot: Schema.optional(Schema.String), -}).annotate({ identifier: "StepStartPart" }) -export type StepStartPart = Types.DeepMutable> - -export const StepFinishPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("step-finish"), - reason: Schema.String, - snapshot: Schema.optional(Schema.String), - cost: Schema.Finite, - tokens: Schema.Struct({ - total: Schema.optional(Schema.Finite), - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), -}).annotate({ identifier: "StepFinishPart" }) -export type StepFinishPart = Types.DeepMutable> - -export const ToolStatePending = Schema.Struct({ - status: Schema.Literal("pending"), - input: Schema.Record(Schema.String, Schema.Any), - raw: Schema.String, -}).annotate({ identifier: "ToolStatePending" }) -export type ToolStatePending = Types.DeepMutable> - -export const ToolStateRunning = Schema.Struct({ - status: Schema.Literal("running"), - input: Schema.Record(Schema.String, Schema.Any), - title: Schema.optional(Schema.String), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - }), -}).annotate({ identifier: "ToolStateRunning" }) -export type ToolStateRunning = Types.DeepMutable> - -export const ToolStateCompleted = Schema.Struct({ - status: Schema.Literal("completed"), - input: Schema.Record(Schema.String, Schema.Any), - output: Schema.String, - title: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Any), - time: Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, - compacted: Schema.optional(NonNegativeInt), - }), - attachments: Schema.optional(Schema.Array(FilePart)), -}).annotate({ identifier: "ToolStateCompleted" }) -export type ToolStateCompleted = Types.DeepMutable> - -export const ToolStateError = Schema.Struct({ - status: Schema.Literal("error"), - input: Schema.Record(Schema.String, Schema.Any), - error: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, - }), -}).annotate({ identifier: "ToolStateError" }) -export type ToolStateError = Types.DeepMutable> - -export const ToolState = Schema.Union([ - ToolStatePending, - ToolStateRunning, - ToolStateCompleted, - ToolStateError, -]).annotate({ - discriminator: "status", - identifier: "ToolState", -}) -export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError - -export const ToolPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("tool"), - callID: Schema.String, - tool: Schema.String, - state: ToolState, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "ToolPart" }) -export type ToolPart = Omit>, "state"> & { - state: ToolState -} - -const messageBase = { - id: MessageID, - sessionID: partBase.sessionID, -} - -const FileDiff = Schema.Struct({ - file: Schema.optional(Schema.String), - patch: Schema.optional(Schema.String), - additions: Schema.Finite, - deletions: Schema.Finite, - status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), -}).annotate({ identifier: "SnapshotFileDiff" }) - -export const User = Schema.Struct({ - ...messageBase, - role: Schema.Literal("user"), - time: Schema.Struct({ - created: Timestamp, - }), - format: Schema.optional(Format), - summary: Schema.optional( - Schema.Struct({ - title: Schema.optional(Schema.String), - body: Schema.optional(Schema.String), - diffs: Schema.Array(FileDiff), - }), - ), - agent: Schema.String, - model: Schema.Struct({ - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - variant: Schema.optional(Schema.String), - }), - system: Schema.optional(Schema.String), - tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), -}).annotate({ identifier: "UserMessage" }) -export type User = Types.DeepMutable> - -export const Part = Schema.Union([ - TextPart, - SubtaskPart, - ReasoningPart, - FilePart, - ToolPart, - StepStartPart, - StepFinishPart, - SnapshotPart, - PatchPart, - AgentPart, - RetryPart, - CompactionPart, -]).annotate({ discriminator: "type", identifier: "Part" }) -export type Part = - | TextPart - | SubtaskPart - | ReasoningPart - | FilePart - | ToolPart - | StepStartPart - | StepFinishPart - | SnapshotPart - | PatchPart - | AgentPart - | RetryPart - | CompactionPart - -const AssistantErrorSchema = Schema.Union([ - AuthError.EffectSchema, - NamedError.Unknown.EffectSchema, - OutputLengthError.EffectSchema, - AbortedError.EffectSchema, - StructuredOutputError.EffectSchema, - ContextOverflowError.EffectSchema, - ContentFilterError.EffectSchema, - APIError.EffectSchema, -]).annotate({ discriminator: "name" }) -type AssistantError = Schema.Schema.Type - -export const TextPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("text"), - text: Schema.String, - synthetic: Schema.optional(Schema.Boolean), - ignored: Schema.optional(Schema.Boolean), - time: Schema.optional( - Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), - ), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "TextPartInput" }) -export type TextPartInput = Types.DeepMutable> - -export const FilePartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("file"), - mime: Schema.String, - filename: Schema.optional(Schema.String), - url: Schema.String, - source: Schema.optional(FilePartSource), -}).annotate({ identifier: "FilePartInput" }) -export type FilePartInput = Types.DeepMutable> - -export const AgentPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("agent"), - name: Schema.String, - source: Schema.optional( - Schema.Struct({ - value: Schema.String, - start: NonNegativeInt, - end: NonNegativeInt, - }), - ), -}).annotate({ identifier: "AgentPartInput" }) -export type AgentPartInput = Types.DeepMutable> - -export const SubtaskPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("subtask"), - prompt: Schema.String, - description: Schema.String, - agent: Schema.String, - model: Schema.optional( - Schema.Struct({ - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - }), - ), - command: Schema.optional(Schema.String), -}).annotate({ identifier: "SubtaskPartInput" }) -export type SubtaskPartInput = Types.DeepMutable> - -export const Assistant = Schema.Struct({ - ...messageBase, - role: Schema.Literal("assistant"), - time: Schema.Struct({ - created: NonNegativeInt, - completed: Schema.optional(NonNegativeInt), - }), - error: Schema.optional(AssistantErrorSchema), - parentID: MessageID, - modelID: ModelV2.ID, - providerID: ProviderV2.ID, - mode: Schema.String, - agent: Schema.String, - path: Schema.Struct({ - cwd: Schema.String, - root: Schema.String, - }), - summary: Schema.optional(Schema.Boolean), - cost: Schema.Finite, - tokens: Schema.Struct({ - total: Schema.optional(Schema.Finite), - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), - structured: Schema.optional(Schema.Any), - variant: Schema.optional(Schema.String), - finish: Schema.optional(Schema.String), -}).annotate({ identifier: "AssistantMessage" }) -export type Assistant = Omit>, "error"> & { - error?: AssistantError -} - -export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) -export type Info = User | Assistant - -export const WithParts = Schema.Struct({ - info: Info, - parts: Schema.Array(Part), -}) -export type WithParts = { - info: Info - parts: Part[] -} - -const options = { - sync: { - aggregate: "sessionID", - version: 1, - }, -} as const - -const SessionSummary = Schema.Struct({ - additions: Schema.Finite, - deletions: Schema.Finite, - files: Schema.Finite, - diffs: optionalOmitUndefined(Schema.Array(FileDiff)), -}) - -const SessionTokens = Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), -}) - -const SessionShare = Schema.Struct({ - url: Schema.String, -}) - -const SessionRevert = Schema.Struct({ - messageID: MessageID, - partID: optionalOmitUndefined(PartID), - snapshot: optionalOmitUndefined(Schema.String), - diff: optionalOmitUndefined(Schema.String), -}) - -const SessionModel = Schema.Struct({ - id: ModelV2.ID, - providerID: ProviderV2.ID, - variant: optionalOmitUndefined(Schema.String), -}) - -export const SessionInfo = Schema.Struct({ - id: SessionSchema.ID, - slug: Schema.String, - projectID: ProjectV2.ID, - workspaceID: optionalOmitUndefined(WorkspaceV2.ID), - directory: Schema.String, - path: optionalOmitUndefined(Schema.String), - parentID: optionalOmitUndefined(SessionSchema.ID), - summary: optionalOmitUndefined(SessionSummary), - cost: optionalOmitUndefined(Schema.Finite), - tokens: optionalOmitUndefined(SessionTokens), - share: optionalOmitUndefined(SessionShare), - title: Schema.String, - agent: optionalOmitUndefined(Schema.String), - model: optionalOmitUndefined(SessionModel), - version: Schema.String, - metadata: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - created: NonNegativeInt, - updated: NonNegativeInt, - compacting: optionalOmitUndefined(NonNegativeInt), - archived: optionalOmitUndefined(Schema.Finite), - }), - permission: optionalOmitUndefined(PermissionV1.Ruleset), - revert: optionalOmitUndefined(SessionRevert), -}).annotate({ identifier: "Session" }) -export type SessionInfo = typeof SessionInfo.Type - -export const Event = { - Created: EventV2.define({ - type: "session.created", - ...options, - schema: { - sessionID: SessionSchema.ID, - info: SessionInfo, - }, - }), - Updated: EventV2.define({ - type: "session.updated", - ...options, - schema: { - sessionID: SessionSchema.ID, - info: SessionInfo, - }, - }), - Deleted: EventV2.define({ - type: "session.deleted", - ...options, - schema: { - sessionID: SessionSchema.ID, - info: SessionInfo, - }, - }), - MessageUpdated: EventV2.define({ - type: "message.updated", - ...options, - schema: { - sessionID: SessionSchema.ID, - info: Info, - }, - }), - MessageRemoved: EventV2.define({ - type: "message.removed", - ...options, - schema: { - sessionID: SessionSchema.ID, - messageID: MessageID, - }, - }), - PartUpdated: EventV2.define({ - type: "message.part.updated", - ...options, - schema: { - sessionID: SessionSchema.ID, - part: Part, - time: Schema.Finite, - }, - }), - PartRemoved: EventV2.define({ - type: "message.part.removed", - ...options, - schema: { - sessionID: SessionSchema.ID, - messageID: MessageID, - partID: PartID, - }, - }), -} +export const ContentFilterError = NamedError.create("ContentFilterError", { message: Schema.String }) diff --git a/packages/core/src/v2-schema.ts b/packages/core/src/v2-schema.ts index a34b0b1516..4dfd51d92b 100644 --- a/packages/core/src/v2-schema.ts +++ b/packages/core/src/v2-schema.ts @@ -1,10 +1,3 @@ -import { DateTime, Schema, SchemaGetter } from "effect" - -export const DateTimeUtcFromMillis = Schema.Finite.pipe( - Schema.decodeTo(Schema.DateTimeUtc, { - decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)), - encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)), - }), -) - export * as V2Schema from "./v2-schema" + +export { DateTimeUtcFromMillis } from "@opencode-ai/schema/schema" diff --git a/packages/core/src/workspace.ts b/packages/core/src/workspace.ts index 30d33abbee..d85bbe4ba4 100644 --- a/packages/core/src/workspace.ts +++ b/packages/core/src/workspace.ts @@ -1,18 +1,6 @@ export * as WorkspaceV2 from "./workspace" -import { Schema } from "effect" -import { withStatics } from "./schema" -import { Identifier } from "./util/identifier" +import { Workspace } from "@opencode-ai/schema/workspace" -export const ID = Schema.String.check(Schema.isStartsWith("wrk")).pipe( - Schema.brand("WorkspaceV2.ID"), - withStatics((schema) => ({ - ascending: (id?: string) => { - if (!id) return schema.make("wrk_" + Identifier.ascending()) - if (!id.startsWith("wrk")) throw new Error(`ID ${id} does not start with wrk`) - return schema.make(id) - }, - create: () => schema.make("wrk_" + Identifier.ascending()), - })), -) +export const ID = Workspace.ID export type ID = typeof ID.Type diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index 9f46eca4e9..f4c023e5d5 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -1,13 +1,15 @@ import { describe, expect } from "bun:test" import { Effect, Exit, Scope } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Location } from "@opencode-ai/core/location" import { AgentPlugin } from "@opencode-ai/core/plugin/agent" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" +import { agentHost, host } from "./plugin/host" -const it = testEffect(AgentV2.locationLayer) +const it = testEffect(AppNodeBuilder.build(AgentV2.node)) describe("AgentV2", () => { it.effect("starts without agents", () => @@ -23,9 +25,7 @@ describe("AgentV2", () => { Effect.gen(function* () { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("reviewer") - const transform = yield* agent.transform() - - yield* transform((editor) => + yield* agent.transform((editor) => editor.update(id, (info) => { info.description = "Reviews code" info.mode = "subagent" @@ -41,19 +41,17 @@ describe("AgentV2", () => { Effect.gen(function* () { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("reviewer") - const transform = yield* agent.transform() - - yield* transform((editor) => + let description = "Old description" + let hidden = true + yield* agent.transform((editor) => editor.update(id, (info) => { - info.description = "Old description" - info.hidden = true - }), - ) - yield* transform((editor) => - editor.update(id, (info) => { - info.description = "New description" + info.description = description + info.hidden = hidden }), ) + description = "New description" + hidden = false + yield* agent.reload() expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false }) }), @@ -64,9 +62,7 @@ describe("AgentV2", () => { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("scoped") const scope = yield* Scope.make() - const transform = yield* agent.transform().pipe(Scope.provide(scope)) - - yield* transform((editor) => editor.update(id, () => {})) + yield* agent.transform((editor) => editor.update(id, () => {})).pipe(Scope.provide(scope)) expect(yield* agent.get(id)).toBeDefined() yield* Scope.close(scope, Exit.void) @@ -79,7 +75,7 @@ describe("AgentV2", () => { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("build") - yield* agent.update((editor) => + yield* agent.transform((editor) => editor.update(id, (info) => { info.mode = "primary" info.hidden = true @@ -95,10 +91,10 @@ describe("AgentV2", () => { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("custom") - yield* agent.update((editor) => editor.update(id, () => {})) + yield* agent.transform((editor) => editor.update(id, () => {})) expect(yield* agent.get(id)).toEqual(AgentV2.Info.empty(id)) - yield* agent.update((editor) => editor.remove(id)) + yield* agent.transform((editor) => editor.remove(id)) expect(yield* agent.get(id)).toBeUndefined() }), ) @@ -106,7 +102,11 @@ describe("AgentV2", () => { it.effect("does not ambiently opt built-in agents into bash", () => Effect.gen(function* () { const agent = yield* AgentV2.Service - yield* AgentPlugin.Plugin.effect.pipe( + yield* AgentPlugin.Plugin.effect( + host({ + agent: agentHost(agent), + }), + ).pipe( Effect.provideService( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/project") })), diff --git a/packages/core/test/application-tools.test.ts b/packages/core/test/application-tools.test.ts index 38b0c95704..93feeeae3c 100644 --- a/packages/core/test/application-tools.test.ts +++ b/packages/core/test/application-tools.test.ts @@ -1,7 +1,8 @@ import { describe, expect } from "bun:test" -import { Tool } from "@opencode-ai/core/public" +import { Tool } from "@opencode-ai/core/tool/tool" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" -import { PermissionV2 } from "@opencode-ai/core/permission" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { AgentV2 } from "@opencode-ai/core/agent" @@ -9,19 +10,14 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { executeTool, settleTool, toolDefinitions } from "./lib/tool" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { Tools } from "@opencode-ai/core/tool/tools" -import { Deferred, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect" +import { Deferred, Effect, Exit, Fiber, Schema, Scope } from "effect" import { testEffect } from "./lib/effect" -const permission = Layer.mock(PermissionV2.Service, { - assert: () => Effect.void, -}) -const applications = ApplicationTools.layer -const registry = ToolRegistry.layer.pipe( - Layer.provide(permission), - Layer.provide(applications), - Layer.provide(ToolOutputStore.defaultLayer), +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, ToolRegistry.node, ToolRegistry.toolsNode]), [ + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]), ) -const it = testEffect(Layer.mergeAll(applications, registry)) const sessionID = SessionV2.ID.make("ses_application_tool") const agent = AgentV2.ID.make("build") diff --git a/packages/core/test/background-job.test.ts b/packages/core/test/background-job.test.ts index 1c4f93f019..5ad1e061a5 100644 --- a/packages/core/test/background-job.test.ts +++ b/packages/core/test/background-job.test.ts @@ -1,8 +1,11 @@ import { describe, expect } from "bun:test" import { BackgroundJob } from "@opencode-ai/core/background-job" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Exit, Scope } from "effect" import { it } from "./lib/effect" +const jobsLayer = LayerNode.compile(BackgroundJob.node) + describe("BackgroundJob", () => { it.live("tracks process-local work through explicit observation", () => Effect.gen(function* () { @@ -25,7 +28,7 @@ describe("BackgroundJob", () => { timedOut: false, info: { status: "completed", output: "done" }, }) - }).pipe(Effect.provide(BackgroundJob.layer)), + }).pipe(Effect.provide(jobsLayer)), ) it.live("publishes jobs before starting immediately settling work", () => @@ -55,7 +58,7 @@ describe("BackgroundJob", () => { }) }) }) - }).pipe(Effect.provide(BackgroundJob.layer)), + }).pipe(Effect.provide(jobsLayer)), ) it.live("increments pending work before starting immediately settling extensions", () => @@ -80,7 +83,7 @@ describe("BackgroundJob", () => { }) }), ) - }).pipe(Effect.provide(BackgroundJob.layer)), + }).pipe(Effect.provide(jobsLayer)), ) it.live("interrupts live work without promising settlement after the owning process-local scope closes", () => diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 77a18e79ae..6c736cde1e 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -1,35 +1,33 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect" +import { Effect, Fiber, Layer, Stream } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" import { Policy } from "@opencode-ai/core/policy" -import { Project } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") })), ) -const it = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - Layer.provideMerge( - Layer.mock(Credential.Service)({ - all: () => Effect.succeed([]), - list: () => Effect.succeed([]), - }), - ), - ), +const catalogLayer = AppNodeBuilder.build( + LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node, Policy.node]), + [[Location.node, locationLayer]], ) +const it = testEffect(catalogLayer) describe("CatalogV2", () => { it.effect("publishes an updated event after catalog changes", () => @@ -41,7 +39,7 @@ describe("CatalogV2", () => { .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow - yield* (yield* catalog.transform())((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) expect((yield* Fiber.join(updated)).length).toBe(1) }), @@ -49,42 +47,58 @@ describe("CatalogV2", () => { it.effect("derives availability from active credentials without changing provider state", () => { const integrationID = Integration.ID.make("test") - const first = { - id: Credential.ID.create(), - integrationID, - label: "First", - value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }), - } - const second = { - id: Credential.ID.create(), - integrationID, - label: "Second", - value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }), - } - let active = first - const layer = Catalog.locationLayer.pipe( - Layer.fresh, - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - Layer.provideMerge( - Layer.mock(Credential.Service)({ - all: () => Effect.sync(() => [active]), - list: () => Effect.sync(() => [active]), - }), - ), + const localCatalogLayer = Layer.fresh( + AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [[Location.node, locationLayer]]), ) return Effect.gen(function* () { const catalog = yield* Catalog.Service - const transform = yield* catalog.transform() - yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + const credentials = yield* Credential.Service + yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* credentials.create({ + integrationID, + label: "First", + value: Credential.Key.make({ type: "key", key: "first", metadata: { tenant: "one" } }), + }) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) - expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) - active = second + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) + yield* credentials.create({ + integrationID, + label: "Second", + value: Credential.Key.make({ type: "key", key: "second", metadata: { tenant: "two" } }), + }) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) - expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) - }).pipe(Effect.provide(layer)) + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) + }).pipe(Effect.provide(localCatalogLayer)) + }) + + it.effect("derives availability from a provider's integration", () => { + const integrationID = Integration.ID.make("gateway") + const providerID = ProviderV2.ID.make("remote") + const localCatalogLayer = Layer.fresh( + AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [ + [Location.node, locationLayer], + ]), + ) + + return Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {})) + yield* catalog.transform((editor) => + editor.provider.update(providerID, (provider) => { + provider.integrationID = integrationID + }), + ) + expect(yield* catalog.provider.available()).toEqual([]) + + yield* (yield* Credential.Service).create({ + integrationID, + value: Credential.Key.make({ type: "key", key: "secret" }), + }) + + expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID]) + }).pipe(Effect.provide(localCatalogLayer)) }) it.effect("projects environment connections without a catalog plugin", () => @@ -99,13 +113,13 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const integrations = yield* Integration.Service const providerID = ProviderV2.ID.make("test") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID: Integration.ID.make(providerID), method: { type: "env", names: ["CATALOG_TEST_API_KEY"] }, }), ) - yield* (yield* catalog.transform())((editor) => editor.provider.update(providerID, () => {})) + yield* catalog.transform((editor) => editor.provider.update(providerID, () => {})) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID) }), @@ -121,9 +135,7 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const transform = yield* catalog.transform() - - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(providerID, (provider) => { provider.api = { type: "aisdk", @@ -134,7 +146,7 @@ describe("CatalogV2", () => { }), ) - expect((yield* catalog.provider.get(providerID)).api).toEqual({ + expect(required(yield* catalog.provider.get(providerID)).api).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://override.example.com", @@ -147,9 +159,7 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.api = { type: "aisdk", @@ -168,7 +178,7 @@ describe("CatalogV2", () => { }) }) - expect((yield* catalog.model.get(providerID, modelID)).api).toEqual({ + expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({ id: modelID, type: "aisdk", package: "@ai-sdk/openai-compatible", @@ -183,9 +193,7 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.api = { type: "aisdk", @@ -196,7 +204,7 @@ describe("CatalogV2", () => { catalog.model.update(providerID, modelID, () => {}) }) - expect((yield* catalog.model.get(providerID, modelID)).api).toEqual({ + expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({ id: modelID, type: "aisdk", package: "@ai-sdk/openai-compatible", @@ -205,106 +213,12 @@ describe("CatalogV2", () => { }), ) - it.effect("runs catalog transform hooks after baseURL is normalized", () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const plugin = yield* PluginV2.Service - const providerID = ProviderV2.ID.make("test") - const seen: unknown[] = [] - const transform = yield* catalog.transform() - - yield* plugin.add({ - id: PluginV2.ID.make("test"), - effect: Effect.succeed({ - "catalog.transform": (evt) => - Effect.sync(() => { - const item = evt.provider.get(providerID) - if (!item) return - seen.push(item.provider.api.type) - if (item?.provider.api.type === "aisdk") seen.push(item.provider.api.url) - seen.push(item?.provider.request.body.baseURL) - }), - }), - }) - yield* transform((catalog) => - catalog.provider.update(providerID, (provider) => { - provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } - provider.request.body.baseURL = "https://provider.example.com" - }), - ) - - expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined]) - }), - ) - - it.effect("runs catalog transform when a plugin is added", () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const plugin = yield* PluginV2.Service - const providerID = ProviderV2.ID.make("test") - const transform = yield* catalog.transform() - - yield* transform((catalog) => - catalog.provider.update(providerID, (provider) => { - provider.name = "Before" - }), - ) - yield* plugin.add({ - id: PluginV2.ID.make("test-transform"), - effect: Effect.succeed({ - "catalog.transform": (evt) => - Effect.sync(() => - evt.provider.update(providerID, (provider) => { - provider.name = "After" - }), - ), - }), - }) - yield* Effect.yieldNow - - expect((yield* catalog.provider.get(providerID)).name).toBe("After") - }), - ) - - it.effect("ignores plugin additions from another location", () => - Effect.gen(function* () { - const events = yield* EventV2.Service - const plugin = yield* PluginV2.Service - let invoked = 0 - - yield* plugin.add({ - id: PluginV2.ID.make("test-transform"), - effect: Effect.succeed({ - "catalog.transform": () => Effect.sync(() => invoked++), - }), - }) - yield* Effect.yieldNow - expect(invoked).toBe(1) - - yield* events.publish( - PluginV2.Event.Added, - { id: PluginV2.ID.make("test-transform") }, - { - location: new Location.Info({ - directory: AbsolutePath.make("other"), - project: { id: Project.ID.global, directory: AbsolutePath.make("other") }, - }), - }, - ) - yield* Effect.yieldNow - - expect(invoked).toBe(1) - }), - ) - it.effect("resolves provider and model request merges", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.request.headers.provider = "provider" provider.request.headers.shared = "provider" @@ -315,16 +229,13 @@ describe("CatalogV2", () => { model.request.headers.shared = "model" model.request.body.model = true model.request.body.request = true - const options = (model.request.options ??= {}) - options.shared = "model" - options.model = true + model.request.body.shared = "model" }) }) - const model = yield* catalog.model.get(providerID, modelID) + const model = required(yield* catalog.model.get(providerID, modelID)) expect(model.request.headers).toEqual({ provider: "provider", shared: "model", model: "model" }) - expect(model.request.body).toEqual({ provider: true, model: true, request: true }) - expect(model.request.options).toEqual({ shared: "model", model: true }) + expect(model.request.body).toEqual({ provider: true, model: true, request: true, shared: "model" }) }), ) @@ -332,19 +243,17 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => { - model.time.released = DateTime.makeUnsafe(1000) + model.time.released = 1000 }) catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => { - model.time.released = DateTime.makeUnsafe(2000) + model.time.released = 2000 }) }) - expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toMatch("new") + expect((yield* catalog.model.default())?.id).toMatch("new") }), ) @@ -354,26 +263,26 @@ describe("CatalogV2", () => { const providerID = ProviderV2.ID.make("test") const old = ModelV2.ID.make("old") const newest = ModelV2.ID.make("new") - const transform = yield* catalog.transform() - - const models = (catalog: Catalog.Editor) => { + const models = (catalog: Catalog.Draft) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, old, (model) => { - model.time.released = DateTime.makeUnsafe(1000) + model.time.released = 1000 }) catalog.model.update(providerID, newest, (model) => { - model.time.released = DateTime.makeUnsafe(2000) + model.time.released = 2000 }) } - yield* transform((catalog) => { + let configured = true + yield* catalog.transform((catalog) => { models(catalog) - catalog.model.default.set(providerID, old) + if (configured) catalog.model.default.set(providerID, old) }) - expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(old) + expect((yield* catalog.model.default())?.id).toBe(old) - yield* transform(models) - expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(newest) + configured = false + yield* catalog.reload() + expect((yield* catalog.model.default())?.id).toBe(newest) }), ) @@ -384,9 +293,7 @@ describe("CatalogV2", () => { const enabledProvider = ProviderV2.ID.make("enabled") const disabledModel = ModelV2.ID.make("configured") const fallbackModel = ModelV2.ID.make("fallback") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(disabledProvider, (provider) => { provider.disabled = true }) @@ -396,7 +303,7 @@ describe("CatalogV2", () => { catalog.model.default.set(disabledProvider, disabledModel) }) - expect(Option.getOrUndefined(yield* catalog.model.default())).toMatchObject({ + expect(yield* catalog.model.default()).toMatchObject({ providerID: enabledProvider, id: fallbackModel, }) @@ -407,25 +314,23 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }] - model.time.released = DateTime.makeUnsafe(Date.now()) + model.time.released = Date.now() }) catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }] - model.time.released = DateTime.makeUnsafe(Date.now()) + model.time.released = Date.now() }) }) - expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini") + expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini") }), ) @@ -434,17 +339,15 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const policy = yield* Policy.Service const providerID = ProviderV2.ID.make("blocked") - const transform = yield* catalog.transform() - yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })]) - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("model"), () => {}) }) expect(yield* catalog.provider.all()).toEqual([]) expect(yield* catalog.model.all()).toEqual([]) - expect(yield* catalog.provider.get(providerID).pipe(Effect.option)).toEqual(Option.none()) + expect(yield* catalog.provider.get(providerID)).toBeUndefined() }), ) }) diff --git a/packages/core/test/command.test.ts b/packages/core/test/command.test.ts index f2175743e4..3da8523c5b 100644 --- a/packages/core/test/command.test.ts +++ b/packages/core/test/command.test.ts @@ -1,18 +1,18 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { CommandV2 } from "@opencode-ai/core/command" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "./lib/effect" -const it = testEffect(CommandV2.locationLayer) +const it = testEffect(AppNodeBuilder.build(CommandV2.node)) describe("CommandV2", () => { it.effect("applies command transforms and preserves later overrides", () => Effect.gen(function* () { const command = yield* CommandV2.Service - const transform = yield* command.transform() - yield* transform((editor) => { + yield* command.transform((editor) => { editor.update("review", (command) => { command.template = "First" command.description = "Review code" @@ -28,7 +28,7 @@ describe("CommandV2", () => { }) expect(yield* command.get("review")).toEqual( - new CommandV2.Info({ + CommandV2.Info.make({ name: "review", template: "Second", description: "Review code", @@ -40,7 +40,7 @@ describe("CommandV2", () => { }), ) expect(yield* command.list()).toEqual([ - new CommandV2.Info({ + CommandV2.Info.make({ name: "review", template: "Second", description: "Review code", diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index 79e872f74e..4a28fb7877 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -1,17 +1,20 @@ import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" +import { agentHost, host } from "../plugin/host" -const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer)) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node]))) const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigAgentPlugin.Plugin", () => { @@ -19,9 +22,7 @@ describe("ConfigAgentPlugin.Plugin", () => { Effect.gen(function* () { const agents = yield* AgentV2.Service const build = AgentV2.ID.make("build") - const defaults = yield* agents.transform() - - yield* defaults((editor) => + yield* agents.transform((editor) => editor.update(build, (agent) => { agent.mode = "primary" agent.permissions.push({ action: "bash", resource: "*", effect: "allow" }) @@ -68,9 +69,8 @@ describe("ConfigAgentPlugin.Plugin", () => { ]), }) - yield* ConfigAgentPlugin.Plugin.effect.pipe( + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(AgentV2.Service, agents), ) const buildAgent = yield* agents.get(build) @@ -150,9 +150,8 @@ describe("ConfigAgentPlugin.Plugin", () => { ]), }) - yield* ConfigAgentPlugin.Plugin.effect.pipe( + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(AgentV2.Service, agents), ) const reviewer = yield* agents.get(AgentV2.ID.make("reviewer")) @@ -177,8 +176,7 @@ describe("ConfigAgentPlugin.Plugin", () => { Effect.gen(function* () { const agents = yield* AgentV2.Service const build = AgentV2.ID.make("build") - const defaults = yield* agents.transform() - yield* defaults((editor) => editor.update(build, () => {})) + yield* agents.transform((editor) => editor.update(build, () => {})) const config = Config.Service.of({ entries: () => @@ -190,9 +188,8 @@ describe("ConfigAgentPlugin.Plugin", () => { ]), }) - yield* ConfigAgentPlugin.Plugin.effect.pipe( + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(AgentV2.Service, agents), ) expect(yield* agents.get(build)).toBeUndefined() @@ -251,9 +248,8 @@ Use native v2 fields.`, ]), }) - yield* ConfigAgentPlugin.Plugin.effect.pipe( + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(AgentV2.Service, agents), ) expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({ diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index da3bb749b4..f5a08aab63 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -1,18 +1,21 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" import { CommandV2 } from "@opencode-ai/core/command" import { Config } from "@opencode-ai/core/config" import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" +import { host } from "../plugin/host" -const it = testEffect(Layer.mergeAll(CommandV2.locationLayer, FSUtil.defaultLayer)) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]))) const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigCommandPlugin.Plugin", () => { @@ -41,8 +44,7 @@ Review files`, }) const command = yield* CommandV2.Service - yield* ConfigCommandPlugin.Plugin.effect.pipe( - Effect.provideService(CommandV2.Service, command), + yield* ConfigCommandPlugin.Plugin.effect(host({ command: { ...command, reload: command.reload } })).pipe( Effect.provideService( Config.Service, Config.Service.of({ @@ -59,7 +61,7 @@ Review files`, ) expect(yield* command.list()).toEqual([ - new CommandV2.Info({ + CommandV2.Info.make({ name: "review", template: "Review files", description: "File review", @@ -71,8 +73,8 @@ Review files`, }, subtask: true, }), - new CommandV2.Info({ name: "empty", template: "" }), - new CommandV2.Info({ name: "nested/docs", template: "Write docs" }), + CommandV2.Info.make({ name: "empty", template: "" }), + CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }), ]) }), ), diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 6275d8fed3..c3c42cab30 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -5,6 +5,8 @@ import { Effect, Layer, Schema } from "effect" import { FastCheck } from "effect/testing" import { Config } from "@opencode-ai/core/config" import { ConfigProvider } from "@opencode-ai/core/config/provider" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -25,21 +27,19 @@ function testLayer( projectDirectory = directory, vcs?: Project.Vcs, ) { - return Config.locationLayer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layerWith({ config: globalDirectory })), - Layer.provide( - Layer.succeed( - Location.Service, - Location.Service.of( - location( - { directory: AbsolutePath.make(directory) }, - { projectDirectory: AbsolutePath.make(projectDirectory), vcs }, - ), - ), + const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(projectDirectory), vcs }, ), ), ) + return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [ + [Location.node, locationLayer], + [Global.node, Global.layerWith({ config: globalDirectory })], + ]) } const provider = { @@ -106,7 +106,6 @@ describe("Config", () => { expect(migrated.providers?.bedrock?.api).toEqual({ type: "aisdk", package: "@ai-sdk/amazon-bedrock", - url: undefined, settings: { region: "us-east-1", profile: "dev" }, }) expect(migrated.providers?.bedrock?.request).toEqual({ @@ -162,7 +161,7 @@ describe("Config", () => { ), ) - it.live("loads JSON and JSONC files from lowest to highest priority", () => + it.live("loads opencode JSON and JSONC files from lowest to highest priority", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), @@ -171,13 +170,9 @@ describe("Config", () => { Effect.gen(function* () { yield* Effect.promise(() => Promise.all([ - fs.writeFile( - path.join(tmp.path, "config.json"), - JSON.stringify({ $schema: "base", providers: { base: provider } }), - ), fs.writeFile( path.join(tmp.path, "opencode.json"), - JSON.stringify({ $schema: "middle", providers: { middle: provider } }), + JSON.stringify({ $schema: "base", providers: { base: provider } }), ), fs.writeFile( path.join(tmp.path, "opencode.jsonc"), @@ -193,12 +188,12 @@ describe("Config", () => { const config = yield* Config.Service const documents = (yield* config.entries()).filter((entry) => entry.type === "document") - expect(documents).toHaveLength(3) - expect(documents.map((document) => document.type)).toEqual(["document", "document", "document"]) - expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"]) + expect(documents).toHaveLength(2) + expect(documents.map((document) => document.type)).toEqual(["document", "document"]) + expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"]) expect(documents[0]).toBeInstanceOf(Config.Document) - expect(documents[0]?.path).toBe(path.join(tmp.path, "config.json")) - expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info) + expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json")) + expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info) yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })), @@ -207,7 +202,29 @@ describe("Config", () => { (yield* config.entries()) .filter((entry) => entry.type === "document") .map((document) => document.info.$schema), - ).toEqual(["base", "middle", "last"]) + ).toEqual(["base", "last"]) + }).pipe(Effect.provide(testLayer(tmp.path))) + }), + ), + ), + ) + + it.live("does not load legacy config.json files", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "legacy" })), + ) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const documents = (yield* config.entries()).filter((entry) => entry.type === "document") + + expect(documents).toHaveLength(0) }).pipe(Effect.provide(testLayer(tmp.path))) }), ), @@ -299,14 +316,14 @@ describe("Config", () => { }, tool_output: { max_lines: 1000, max_bytes: 32768 }, mcp: { - timeout: 5000, + timeout: { startup: 5000, request: 60000 }, servers: { local: { type: "local", command: ["node", "./mcp/server.js"], environment: { API_KEY: "secret" }, disabled: false, - timeout: 10000, + timeout: { request: 10000 }, }, remote: { type: "remote", @@ -314,6 +331,7 @@ describe("Config", () => { headers: { Authorization: "Bearer token" }, oauth: { client_id: "client", scope: "read write", callback_port: 19876 }, disabled: true, + timeout: { startup: 15000 }, }, }, }, @@ -384,14 +402,14 @@ describe("Config", () => { }) expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 }) expect(documents[0]?.info.mcp).toEqual({ - timeout: 5000, + timeout: { startup: 5000, request: 60000 }, servers: { local: { type: "local", command: ["node", "./mcp/server.js"], environment: { API_KEY: "secret" }, disabled: false, - timeout: 10000, + timeout: { request: 10000 }, }, remote: { type: "remote", @@ -399,6 +417,7 @@ describe("Config", () => { headers: { Authorization: "Bearer token" }, oauth: { client_id: "client", scope: "read write", callback_port: 19876 }, disabled: true, + timeout: { startup: 15000 }, }, }, }) @@ -542,11 +561,12 @@ describe("Config", () => { compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 }, experimental: { mcp_timeout: 5000 }, mcp: { - local: { type: "local", command: ["node", "server.js"], enabled: false }, + local: { type: "local", command: ["node", "server.js"], enabled: false, timeout: 10000 }, remote: { type: "remote", url: "https://mcp.example.com", oauth: { clientId: "client", callbackPort: 19876 }, + timeout: 20000, }, }, }), @@ -599,9 +619,9 @@ describe("Config", () => { models: { model: { request: { - body: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" }, + body: { temperature: 0.3, reasoning: { effort: "high" }, service_tier: "priority" }, }, - variants: [{ id: "high", body: { reasoningEffort: "high", reasoningSummary: "auto" } }], + variants: [{ id: "high", body: { reasoning: { effort: "high", summary: "auto" } } }], }, }, }) @@ -624,13 +644,19 @@ describe("Config", () => { buffer: 10000, }) expect(documents[0]?.info.mcp).toMatchObject({ - timeout: 5000, + timeout: { request: 5000 }, servers: { - local: { type: "local", command: ["node", "server.js"], disabled: true }, + local: { + type: "local", + command: ["node", "server.js"], + disabled: true, + timeout: { request: 10000 }, + }, remote: { type: "remote", url: "https://mcp.example.com", oauth: { client_id: "client", callback_port: 19876 }, + timeout: { request: 20000 }, }, }, }) @@ -640,7 +666,7 @@ describe("Config", () => { ), ) - it.live("ignores invalid files while loading valid config values", () => + it.live("ignores an invalid file while loading valid config values", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), @@ -649,9 +675,8 @@ describe("Config", () => { Effect.gen(function* () { yield* Effect.promise(() => Promise.all([ - fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "base" })), - fs.writeFile(path.join(tmp.path, "opencode.json"), "{ invalid"), - fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ providers: { invalid: true } })), + fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "base" })), + fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{ invalid"), ]), ) return yield* Effect.gen(function* () { @@ -720,7 +745,7 @@ describe("Config", () => { fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })), fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })), fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })), - fs.writeFile(path.join(directory, "config.json"), JSON.stringify({ $schema: "directory" })), + fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ $schema: "directory" })), fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })), fs.writeFile( path.join(directory, ".opencode", "opencode.jsonc"), diff --git a/packages/core/test/config/fixtures/plugin/directory-plugin.ts b/packages/core/test/config/fixtures/plugin/directory-plugin.ts new file mode 100644 index 0000000000..8bb2566209 --- /dev/null +++ b/packages/core/test/config/fixtures/plugin/directory-plugin.ts @@ -0,0 +1,13 @@ +import { define } from "@kilocode/plugin/v2/promise" + +export default define({ + id: "directory-plugin", + setup: async (ctx) => { + await ctx.agent.transform((agents) => { + agents.update("directory", (agent) => { + agent.description = "Loaded from plugin directory" + agent.mode = "subagent" + }) + }) + }, +}) diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts new file mode 100644 index 0000000000..2e134c55e1 --- /dev/null +++ b/packages/core/test/config/plugin.test.ts @@ -0,0 +1,248 @@ +import path from "path" +import { describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Config } from "@opencode-ai/core/config" +import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Location } from "@opencode-ai/core/location" +import { Npm } from "@opencode-ai/core/npm" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(PluginTestLayer) +const decode = Schema.decodeUnknownSync(Config.Info) + +describe("ConfigExternalPlugin", () => { + it.live("resolves and loads a configured Promise plugin with options", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const host = yield* PluginHost.make(plugins) + const document = path.join(import.meta.dir, "opencode.json") + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + path: document, + info: decode({ + plugins: [ + { + package: "../plugin/fixtures/config-promise-plugin.ts", + options: { description: "Loaded from config" }, + }, + ], + }), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "configured")).toMatchObject({ + description: "Loaded from config", + mode: "subagent", + }) + }), + ) + + it.live("loads a configured Effect plugin with options", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const host = yield* PluginHost.make(plugins) + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + path: path.join(import.meta.dir, "opencode.json"), + info: decode({ + plugins: [ + { + package: "../plugin/fixtures/config-effect-plugin.ts", + options: { description: "Effect plugin from config" }, + }, + ], + }), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "effect-configured")).toMatchObject({ + description: "Effect plugin from config", + mode: "subagent", + }) + }), + ) + + it.live("ignores invalid plugins and continues loading", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const host = yield* PluginHost.make(plugins) + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + path: path.join(import.meta.dir, "opencode.json"), + info: decode({ + plugins: [ + "../plugin/fixtures/missing-plugin.ts", + "../plugin/fixtures/invalid-plugin.ts", + { + package: "../plugin/fixtures/config-promise-plugin.ts", + options: { description: "Loaded after invalid plugins" }, + }, + ], + }), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "configured")).toMatchObject({ + description: "Loaded after invalid plugins", + }) + }), + ) + + it.live("installs and resolves npm plugin packages", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const host = yield* PluginHost.make(plugins) + let installed: string | undefined + const npm = Npm.Service.of({ + add: (spec) => + Effect.sync(() => { + installed = spec + return { + directory: import.meta.dir, + entrypoint: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"), + } + }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), + }) + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ + plugins: [ + { + package: "example-plugin@1.0.0", + options: { description: "Installed from npm" }, + }, + ], + }), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "configured")).toMatchObject({ + description: "Installed from npm", + }) + expect(installed).toBe("example-plugin@1.0.0") + }), + ) + + it.live("loads plugin files from config directories", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const host = yield* PluginHost.make(plugins) + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Directory({ + type: "directory", + path: AbsolutePath.make(path.join(import.meta.dir, "fixtures")), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "directory")).toMatchObject({ + description: "Loaded from plugin directory", + mode: "subagent", + }) + }), + ) +}) + +const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) { + for (let attempt = 0; attempt < 100; attempt++) { + const agent = yield* agents.get(AgentV2.ID.make(id)) + if (agent) return agent + yield* Effect.sleep("10 millis") + } + return yield* Effect.die(`Timed out waiting for agent ${id}`) +}) diff --git a/packages/core/test/config/provider-options.test.ts b/packages/core/test/config/provider-options.test.ts index a407353d09..44a1c0c9db 100644 --- a/packages/core/test/config/provider-options.test.ts +++ b/packages/core/test/config/provider-options.test.ts @@ -39,8 +39,18 @@ describe("ConfigProviderOptionsV1", () => { body: { store: true }, settings: { timeout: 1000 }, }) - expect(lowerer.request({ reasoningEffort: "high", nestedValue: { camelCase: true } })).toEqual({ - reasoning_effort: "high", + expect( + lowerer.request({ + reasoningEffort: "high", + reasoningSummary: "auto", + reasoning: { encryptedContent: true }, + textVerbosity: "low", + text: { outputFormat: "plain" }, + nestedValue: { camelCase: true }, + }), + ).toEqual({ + reasoning: { encrypted_content: true, effort: "high", summary: "auto" }, + text: { output_format: "plain", verbosity: "low" }, nested_value: { camel_case: true }, }) }) @@ -130,7 +140,10 @@ describe("ConfigProviderOptionsV1", () => { body: { trace: true }, settings: { resourceName: "resource" }, }) - expect(lowerer.request({ reasoningEffort: "high" })).toEqual({ reasoning_effort: "high" }) + expect(lowerer.request({ reasoningEffort: "high", reasoningSummary: "auto", textVerbosity: "low" })).toEqual({ + reasoning: { effort: "high", summary: "auto" }, + text: { verbosity: "low" }, + }) }) test("lowers Amazon Bedrock provider and request options", () => { diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index a2ecc9954b..605bcd63cf 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -1,13 +1,49 @@ import { describe, expect } from "bun:test" -import { Effect, Option, Schema } from "effect" +import { Effect, Schema } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Config } from "@opencode-ai/core/config" import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" 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" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, withEnv } from "../plugin/provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* (config: Config.Interface) { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provideService(Config.Service, config)) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} function request(headers: Record, variant?: string) { return { @@ -19,11 +55,9 @@ function request(headers: Record, variant?: string) { const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigProviderPlugin.Plugin", () => { - it.effect("partitions existing model variant bodies without changing config shape", () => + it.effect("keeps configured model variant bodies unchanged", () => Effect.gen(function* () { const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service - const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.opencode const modelID = ModelV2.ID.make("alpha-gpt-next") const config = Config.Service.of({ @@ -56,21 +90,13 @@ describe("ConfigProviderPlugin.Plugin", () => { ]), }) - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect.pipe( - Effect.provideService(Config.Service, config), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(Integration.Service, integrations), - ), - }) + yield* addPlugin(config) - const model = yield* catalog.model.get(providerID, modelID) + const model = required(yield* catalog.model.get(providerID, modelID)) expect(model.variants).toMatchObject([ { id: "high", - body: {}, - options: { + body: { reasoningEffort: "high", reasoningSummary: "auto", include: ["reasoning.encrypted_content"], @@ -80,11 +106,9 @@ describe("ConfigProviderPlugin.Plugin", () => { }), ) - it.effect("uses the effective provider package across layered config", () => + it.effect("keeps layered model variant bodies unchanged", () => Effect.gen(function* () { const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service - const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.opencode const modelID = ModelV2.ID.make("alpha-gpt-next") const config = Config.Service.of({ @@ -117,20 +141,12 @@ describe("ConfigProviderPlugin.Plugin", () => { ]), }) - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect.pipe( - Effect.provideService(Config.Service, config), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(Integration.Service, integrations), - ), - }) + yield* addPlugin(config) - const model = yield* catalog.model.get(providerID, modelID) + const model = required(yield* catalog.model.get(providerID, modelID)) expect(model.variants[0]).toMatchObject({ id: "high", - body: {}, - options: { reasoningEffort: "high" }, + body: { reasoningEffort: "high" }, }) }), ) @@ -140,7 +156,6 @@ describe("ConfigProviderPlugin.Plugin", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const integrations = yield* Integration.Service - const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.make("custom") const modelID = ModelV2.ID.make("chat") const config = Config.Service.of({ @@ -220,18 +235,11 @@ describe("ConfigProviderPlugin.Plugin", () => { ]), }) - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect.pipe( - Effect.provideService(Config.Service, config), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(Integration.Service, integrations), - ), - }) + yield* addPlugin(config) - const provider = yield* catalog.provider.get(providerID) - const model = yield* catalog.model.get(providerID, modelID) - expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default")) + const provider = required(yield* catalog.provider.get(providerID)) + const model = required(yield* catalog.model.get(providerID, modelID)) + expect((yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default")) expect(provider.name).toBe("Renamed") expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({ type: "env", diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 52b9c0bb66..0dc5e3b4d7 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -9,6 +9,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" +import { host } from "../plugin/host" const it = testEffect(Layer.empty) const decode = Schema.decodeUnknownSync(Config.Info) @@ -18,16 +19,28 @@ describe("ConfigSkillPlugin.Plugin", () => { Effect.gen(function* () { const directory = AbsolutePath.make("/repo/packages/app") const sources: SkillV2.Source[] = [] - const transform = Effect.fnUntraced(function* () { - return Effect.fnUntraced(function* (update: (editor: SkillV2.Editor) => void) { - update({ - source: (source) => sources.push(source), - list: () => sources, - }) + const transform = Effect.fnUntraced(function* (update: (draft: SkillV2.Draft) => void | Effect.Effect) { + const result = update({ + source: (source) => { + sources.push(source) + }, + list: () => sources, }) + if (Effect.isEffect(result)) yield* result + const dispose = Effect.sync(() => { + sources.length = 0 + }) + yield* Effect.addFinalizer(() => dispose) + return { dispose } }) - yield* ConfigSkillPlugin.Plugin.effect.pipe( + yield* ConfigSkillPlugin.Plugin.effect( + host({ + skill: { transform, reload: () => Effect.void }, + }), + ).pipe( + Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })), + Effect.provideService(Location.Service, Location.Service.of(location({ directory }))), Effect.provideService( Config.Service, Config.Service.of({ @@ -43,34 +56,24 @@ describe("ConfigSkillPlugin.Plugin", () => { ]), }), ), - Effect.provideService(Global.Service, Global.Service.of(Global.make({ home: "/home/test" }))), - Effect.provideService(Location.Service, Location.Service.of(location({ directory }))), - Effect.provideService( - SkillV2.Service, - SkillV2.Service.of({ - transform, - sources: () => Effect.succeed(sources), - list: () => Effect.succeed([]), - }), - ), ) expect(sources).toEqual([ - new SkillV2.DirectorySource({ + SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join("/repo/.opencode", "skill")), }), - new SkillV2.DirectorySource({ + SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join("/repo/.opencode", "skills")), }), - new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), - new SkillV2.DirectorySource({ + SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), + SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join("/home/test", "shared-skills")), }), - new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make("/opt/skills") }), - new SkillV2.UrlSource({ type: "url", url: "https://example.test/skills/" }), + SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/opt/skills") }), + SkillV2.UrlSource.make({ type: "url", url: "https://example.test/skills/" }), ]) }), ) diff --git a/packages/core/test/credential.test.ts b/packages/core/test/credential.test.ts index c038598543..c6070145d1 100644 --- a/packages/core/test/credential.test.ts +++ b/packages/core/test/credential.test.ts @@ -1,49 +1,36 @@ -import path from "path" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Credential } from "@opencode-ai/core/credential" -import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Integration } from "@opencode-ai/core/integration" -import { tmpdir } from "./fixture/tmpdir" -import { it } from "./lib/effect" +import { testEffect } from "./lib/effect" -function layer(directory: string) { - return Credential.layer.pipe( - Layer.provide(Database.layerFromPath(path.join(directory, "credential.db")).pipe(Layer.fresh)), - ) -} +const it = testEffect(LayerNode.compile(Credential.node)) describe("Credential", () => { - it.live("stores, updates, lists, and removes credentials", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - const credentials = yield* Credential.Service - const integrationID = Integration.ID.make("openai") - const created = yield* credentials.create({ - integrationID, - label: "Work", - value: new Credential.Key({ type: "key", key: "secret" }), - }) + it.effect("stores, updates, lists, and removes credentials", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const integrationID = Integration.ID.make("openai") + const created = yield* credentials.create({ + integrationID, + label: "Work", + value: Credential.Key.make({ type: "key", key: "secret" }), + }) - expect(yield* credentials.list(integrationID)).toEqual([created]) - yield* credentials.update(created.id, { label: "Personal" }) - expect((yield* credentials.list(integrationID))[0]?.label).toBe("Personal") + expect(yield* credentials.list(integrationID)).toEqual([created]) + yield* credentials.update(created.id, { label: "Personal" }) + expect((yield* credentials.list(integrationID))[0]?.label).toBe("Personal") - const replacement = yield* credentials.create({ - integrationID, - label: "Replacement", - value: new Credential.Key({ type: "key", key: "replacement" }), - }) - expect(yield* credentials.list(integrationID)).toEqual([replacement]) + const replacement = yield* credentials.create({ + integrationID, + label: "Replacement", + value: Credential.Key.make({ type: "key", key: "replacement" }), + }) + expect(yield* credentials.list(integrationID)).toEqual([replacement]) - yield* credentials.remove(replacement.id) - expect(yield* credentials.list(integrationID)).toEqual([]) - }).pipe(Effect.provide(layer(tmp.path))), - ), - ), + yield* credentials.remove(replacement.id) + expect(yield* credentials.list(integrationID)).toEqual([]) + }), ) }) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index d7126f76e2..b381cc7418 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -14,6 +14,10 @@ import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/m import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input" import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent" import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera" +import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -22,6 +26,8 @@ import { SessionTable } from "@opencode-ai/core/session/sql" import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata" import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" import { Database } from "@opencode-ai/core/database/database" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionV1 } from "@opencode-ai/core/v1/session" import { tmpdir } from "./fixture/tmpdir" const run = (effect: Effect.Effect) => @@ -71,9 +77,9 @@ describe("DatabaseMigration", () => { ).toEqual({ name: "session_context_epoch" }) expect( yield* db.get( - sql`SELECT name, dflt_value FROM pragma_table_info('session_context_epoch') WHERE name = 'agent'`, + sql`SELECT name FROM pragma_table_info('session_context_epoch') WHERE name IN ('agent', 'replacement_seq', 'revision')`, ), - ).toEqual({ name: "agent", dflt_value: "'build'" }) + ).toBeUndefined() expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length }) expect( yield* db.all( @@ -226,6 +232,93 @@ describe("DatabaseMigration", () => { ) }) + test("preserves canonical V1 state and restarts its event stream", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`PRAGMA foreign_keys = ON`) + yield* DatabaseMigration.apply(db) + yield* db.run( + sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/project', 1, 1, '[]')`, + ) + yield* db.run( + sql`INSERT INTO workspace (id, type, project_id, time_used) VALUES ('workspace', 'local', 'global', 1)`, + ) + yield* db.run( + sql`INSERT INTO session (id, project_id, workspace_id, slug, directory, title, version, time_created, time_updated) VALUES ('session', 'global', 'workspace', 'session', '/project', 'Before', 'test', 1, 1)`, + ) + yield* db.run( + sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('message', 'session', 1, 1, '{}')`, + ) + yield* db.run( + sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('part', 'message', 'session', 1, 1, '{}')`, + ) + yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 9)`) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('event', 'session', 9, 'session.updated.1', '{}')`, + ) + yield* db.run( + sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`, + ) + yield* db.run( + sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`, + ) + yield* db.run( + sql`INSERT INTO session_context_epoch (session_id, baseline, snapshot, baseline_seq) VALUES ('session', 'baseline', '{}', 9)`, + ) + yield* db.run(sql`DELETE FROM migration WHERE id = ${simplifySessionInputMigration.id}`) + yield* DatabaseMigration.applyOnly(db, [simplifySessionInputMigration]) + + const database = Layer.succeed(Database.Service, { db }) + yield* EventV2.Service.use((service) => + service.publish(SessionV1.Event.Updated, { + sessionID: SessionSchema.ID.make("session"), + info: { + id: SessionSchema.ID.make("session"), + slug: "session", + projectID: ProjectV2.ID.global, + directory: "/project", + title: "After", + version: "test", + time: { created: 1, updated: 2 }, + }, + }), + ).pipe( + Effect.provide( + AppNodeBuilder.build(LayerNode.group([EventV2.node, SessionProjector.node]), [[Database.node, database]]), + ), + ) + + expect( + yield* db.get(sql` + SELECT + (SELECT title FROM session WHERE id = 'session') AS title, + (SELECT workspace_id FROM session WHERE id = 'session') AS workspaceID, + (SELECT COUNT(*) FROM message WHERE id = 'message') AS messages, + (SELECT COUNT(*) FROM part WHERE id = 'part') AS parts, + (SELECT COUNT(*) FROM workspace) AS workspaces, + (SELECT COUNT(*) FROM session_input) AS sessionInputs, + (SELECT COUNT(*) FROM session_message) AS sessionMessages, + (SELECT COUNT(*) FROM session_context_epoch) AS contextEpochs, + (SELECT seq FROM event_sequence WHERE aggregate_id = 'session') AS seq, + (SELECT type FROM event WHERE aggregate_id = 'session') AS eventType + `), + ).toEqual({ + title: "After", + workspaceID: null, + messages: 1, + parts: 1, + workspaces: 0, + sessionInputs: 0, + sessionMessages: 0, + contextEpochs: 0, + seq: 0, + eventType: "session.updated.1", + }) + }), + ) + }) + test("resets incompatible projected Session messages before adding sequence order", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/effect/cross-spawn-spawner.test.ts b/packages/core/test/effect/cross-spawn-spawner.test.ts index a3f74f671d..884051d3ef 100644 --- a/packages/core/test/effect/cross-spawn-spawner.test.ts +++ b/packages/core/test/effect/cross-spawn-spawner.test.ts @@ -6,9 +6,10 @@ import { Effect, Exit, Stream } from "effect" import type * as PlatformError from "effect/PlatformError" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { testEffect } from "../lib/effect" -const live = CrossSpawnSpawner.defaultLayer +const live = LayerNode.compile(CrossSpawnSpawner.node) const fx = testEffect(live) function js(code: string, opts?: ChildProcess.CommandOptions) { diff --git a/packages/core/test/effect/layer-node/layer-node-types.test.ts b/packages/core/test/effect/layer-node/layer-node-types.test.ts new file mode 100644 index 0000000000..366bf746da --- /dev/null +++ b/packages/core/test/effect/layer-node/layer-node-types.test.ts @@ -0,0 +1,143 @@ +import { test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { makeGlobalNode, makeLocationNode } from "@opencode-ai/core/effect/app-node" + +class A extends Context.Service()("test/LayerNodeA") {} +class B extends Context.Service()("test/LayerNodeB") {} +class C extends Context.Service()("test/LayerNodeC") {} +class LayerError { + readonly _tag = "LayerError" +} +class OtherError { + readonly _tag = "OtherError" +} + +const tags = LayerNode.tags({ app: [] }) +const make = tags.make("app") +const build = (root: LayerNode.Node) => LayerNode.compile(root) as Layer.Layer +const aLayer = Layer.succeed(A, A.of({})) +const bLayer = Layer.effect(B, Effect.as(A, B.of({}))) +const cLayer = Layer.effect( + C, + Effect.gen(function* () { + yield* A + yield* B + return C.of({}) + }), +) +const failingA = Layer.effect(A, Effect.fail(new LayerError())) +const a = make({ service: A, layer: aLayer, deps: [] }) +const b = make({ service: B, layer: bLayer, deps: [a] }) +const c = make({ service: C, layer: cLayer, deps: [a, b] }) +const failing = make({ service: A, layer: failingA, deps: [] }) +const dependent = make({ service: B, layer: bLayer, deps: [failing] }) +const inputA = LayerNode.unbound(A, tags.values.app) +const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] }) + +make({ name: "manual-a", layer: aLayer, deps: [] }) + +// @ts-expect-error A node must have a service or name +make({ layer: aLayer, deps: [] }) + +// @ts-expect-error Service and name are mutually exclusive +make({ service: A, name: "a", layer: aLayer, deps: [] }) + +// @ts-expect-error B requires A +make({ service: B, layer: bLayer, deps: [] }) + +// @ts-expect-error C requires A and B +make({ service: C, layer: cLayer, deps: [a] }) + +const closed = build(LayerNode.group([c])) +const closedWithError = build(LayerNode.group([dependent])) +const checkClosed: Layer.Layer = closed +const checkError: Layer.Layer = closedWithError +void checkClosed +void checkError + +LayerNode.compile(a, [[a, Layer.succeed(A, A.of({}))]]) +LayerNode.compile(a, [[a, make({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })]]) + +// @ts-expect-error Replacement must provide A +LayerNode.compile(a, [[a, Layer.succeed(B, B.of({}))]]) + +// @ts-expect-error Node replacement must provide A +const invalidNodeReplacement = () => LayerNode.compile(a, [[a, b]]) +void invalidNodeReplacement + +// @ts-expect-error Replacement cannot introduce a new error +LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]]) + +const invalidNodeErrorReplacement = () => + // @ts-expect-error Node replacement cannot introduce a new error + LayerNode.compile(a, [[a, make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })]]) +void invalidNodeErrorReplacement + +class TagA extends Context.Service()("test/TagA") {} +class TagB extends Context.Service()("test/TagB") {} +class TagC extends Context.Service()("test/TagC") {} + +const scopedTags = LayerNode.tags({ request: ["global"], global: [] }) +const request = scopedTags.make("request") +const global = scopedTags.make("global") +const globalA = global({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] }) +const requestA = request({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] }) +const requestB = request({ service: TagB, layer: Layer.succeed(TagB, TagB.of({})), deps: [] }) +const tagBLayer = Layer.effect(TagB, Effect.as(TagA, TagB.of({}))) +const tagCLayer = Layer.effect( + TagC, + Effect.gen(function* () { + yield* TagA + yield* TagB + return TagC.of({}) + }), +) + +request({ service: TagB, layer: tagBLayer, deps: [globalA] }) +request({ service: TagC, layer: tagCLayer, deps: [globalA, requestB] }) +request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA, requestB])] }) + +// @ts-expect-error Tag configuration can only reference declared tags +LayerNode.tags({ request: ["missing"], global: [] }) + +// @ts-expect-error An unrelated dependency cannot satisfy TagA +request({ service: TagB, layer: tagBLayer, deps: [requestB] }) + +// @ts-expect-error Providing only TagA leaves TagB missing +request({ service: TagC, layer: tagCLayer, deps: [globalA] }) + +// @ts-expect-error Providing only TagB leaves TagA missing +request({ service: TagC, layer: tagCLayer, deps: [requestB] }) + +// @ts-expect-error Duplicate TagA providers still leave TagB missing +request({ service: TagC, layer: tagCLayer, deps: [globalA, requestA] }) + +// @ts-expect-error A group with only TagA still leaves TagB missing +request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA])] }) + +// @ts-expect-error Global cannot depend on request +global({ service: TagB, layer: tagBLayer, deps: [requestA] }) + +// @ts-expect-error Groups preserve their child tags +global({ service: TagB, layer: tagBLayer, deps: [LayerNode.group([requestA])] }) + +class ScopedA extends Context.Service()("test/ScopedA") {} +class ScopedB extends Context.Service()("test/ScopedB") {} + +const scopedA = Layer.succeed(ScopedA, ScopedA.of({})) +const scopedB = Layer.effect(ScopedB, Effect.as(ScopedA, ScopedB.of({}))) +const globalScopedA = makeGlobalNode({ service: ScopedA, layer: scopedA, deps: [] }) +const locationScopedA = makeLocationNode({ service: ScopedA, layer: scopedA, deps: [] }) + +makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] }) +makeLocationNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] }) +makeLocationNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] }) + +// @ts-expect-error Global nodes cannot depend on location nodes +makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] }) + +// @ts-expect-error ScopedB requires ScopedA +makeLocationNode({ service: ScopedB, layer: scopedB, deps: [] }) + +test("type exploration compiles", () => {}) diff --git a/packages/core/test/effect/layer-node/layer-node.test.ts b/packages/core/test/effect/layer-node/layer-node.test.ts new file mode 100644 index 0000000000..b671792c59 --- /dev/null +++ b/packages/core/test/effect/layer-node/layer-node.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" + +class Value extends Context.Service()("test/LayerNodeValue") {} +class Greeting extends Context.Service()("test/LayerNodeGreeting") {} +class Left extends Context.Service()("test/LayerNodeLeft") {} +class Right extends Context.Service()("test/LayerNodeRight") {} +class Database extends Context.Service()("test/GraphDatabase") {} +class Users extends Context.Service }>()("test/GraphUsers") {} +class App extends Context.Service }>()("test/GraphApp") {} + +const tags = LayerNode.tags({ app: [] }) +const make = tags.make("app") +const build = (root: LayerNode.Node, replacements?: readonly LayerNode.Replacement[]) => + LayerNode.compile(root, replacements) as Layer.Layer +const valueLayer = Layer.succeed(Value, Value.of({ value: "production" })) +const greetingLayer = Layer.effect( + Greeting, + Effect.map(Value, (value) => Greeting.of({ value: `hello ${value.value}` })), +) +const value = make({ service: Value, layer: valueLayer, deps: [] }) +const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] }) + +describe("layer node", () => { + test("builds an untagged graph", async () => { + const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] }) + const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] }) + const program = Effect.map(Greeting, (item) => item.value).pipe( + Effect.provide(LayerNode.compile(LayerNode.group([greeting]))), + ) + expect(await Effect.runPromise(program)).toBe("hello production") + }) + + test("builds a dependency graph", async () => { + const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(build(LayerNode.group([greeting])))) + expect(await Effect.runPromise(program)).toBe("hello production") + }) + + test("exposes roots but hides transitive dependencies", () => { + const layer = build(LayerNode.group([greeting])) + const check: Layer.Layer = layer + void check + }) + + test("preserves branch-specific implementations across roots", async () => { + const firstValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] }) + const secondValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] }) + const leftLayer = Layer.effect( + Left, + Effect.map(Value, (item) => Left.of({ value: item.value })), + ) + const rightLayer = Layer.effect( + Right, + Effect.map(Value, (item) => Right.of({ value: item.value })), + ) + const left = make({ service: Left, layer: leftLayer, deps: [firstValue] }) + const right = make({ service: Right, layer: rightLayer, deps: [secondValue] }) + const layer = build(LayerNode.group([left, right])) + const program = Effect.gen(function* () { + return [(yield* Left).value, (yield* Right).value] + }).pipe(Effect.provide(layer)) + expect(await Effect.runPromise(program)).toEqual(["first", "second"]) + }) + + test("requires unbound nodes to be replaced before compilation", async () => { + const unbound = LayerNode.unbound(Value, tags.values.app) + const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] }) + const tree = LayerNode.group([greeting]) + expect(() => LayerNode.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue") + const layer = LayerNode.compile(tree, [[unbound, value]]) as Layer.Layer + const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer)) + expect(await Effect.runPromise(program)).toBe("hello production") + }) + + test("replaces a node with a closed layer", async () => { + const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) + const program = Effect.map(Greeting, (item) => item.value).pipe( + Effect.provide(build(LayerNode.group([greeting]), [[value, replacement]])), + ) + expect(await Effect.runPromise(program)).toBe("hello simulation") + }) + + test("replaces every use of the same layer", async () => { + const leftLayer = Layer.effect( + Left, + Effect.map(Value, (item) => Left.of({ value: item.value })), + ) + const rightLayer = Layer.effect( + Right, + Effect.map(Value, (item) => Right.of({ value: item.value })), + ) + const left = make({ service: Left, layer: leftLayer, deps: [value] }) + const right = make({ service: Right, layer: rightLayer, deps: [value] }) + const replacement = Layer.succeed(Value, Value.of({ value: "replaced" })) + const layer = build(LayerNode.group([left, right]), [[value, replacement]]) + const program = Effect.gen(function* () { + return [(yield* Left).value, (yield* Right).value] + }).pipe(Effect.provide(layer)) + expect(await Effect.runPromise(program)).toEqual(["replaced", "replaced"]) + }) + + test("does not acquire an unused replacement", async () => { + let acquisitions = 0 + const other = make({ service: Left, layer: Layer.succeed(Left, Left.of({ value: "other" })), deps: [] }) + const replacement = Layer.effect( + Left, + Effect.sync(() => { + acquisitions++ + return Left.of({ value: "replacement" }) + }), + ) + await Effect.runPromise( + Effect.map(Greeting, (item) => item.value).pipe( + Effect.provide(build(LayerNode.group([greeting]), [[other, replacement]])), + ), + ) + expect(acquisitions).toBe(0) + }) + + test("replaces a node without acquiring its dependencies", async () => { + let acquisitions = 0 + const dependencyLayer = Layer.effect( + Value, + Effect.sync(() => { + acquisitions++ + return Value.of({ value: "dependency" }) + }), + ) + const dependency = make({ service: Value, layer: dependencyLayer, deps: [] }) + const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] }) + const replacement = make({ + service: Greeting, + layer: Layer.succeed(Greeting, Greeting.of({ value: "replacement" })), + deps: [], + }) + + const program = Effect.map(Greeting, (item) => item.value).pipe( + Effect.provide(build(LayerNode.group([original]), [[original, replacement]])), + ) + + expect(await Effect.runPromise(program)).toBe("replacement") + expect(acquisitions).toBe(0) + }) + + test("applies later replacements inside earlier replacement nodes", async () => { + const original = make({ service: Greeting, layer: greetingLayer, deps: [value] }) + const replacement = make({ service: Greeting, layer: greetingLayer, deps: [value] }) + const program = Effect.map(Greeting, (item) => item.value).pipe( + Effect.provide( + build(LayerNode.group([original]), [ + [original, replacement], + [value, Layer.succeed(Value, Value.of({ value: "replacement dependency" }))], + ]), + ), + ) + + expect(await Effect.runPromise(program)).toBe("hello replacement dependency") + }) + + test("hoists and compiles tagged graphs", async () => { + const tags = LayerNode.tags({ location: ["global"], global: [] }) + const global = tags.make("global") + const location = tags.make("location") + const database = global({ + service: Database, + layer: Layer.succeed(Database, Database.of({ name: "Alice" })), + deps: [], + }) + const users = location({ + service: Users, + layer: Layer.effect( + Users, + Effect.gen(function* () { + const db = yield* Database + return Users.of({ list: Effect.succeed([db.name]) }) + }), + ), + deps: [database], + }) + const app = location({ + service: App, + layer: Layer.effect( + App, + Effect.gen(function* () { + const service = yield* Users + return App.of({ run: service.list }) + }), + ), + deps: [users], + }) + + const result = LayerNode.hoist(LayerNode.group([app]), tags.values.global) + expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({ + kind: "group", + dependencies: [], + }) + expect(result.hoisted.dependencies).toEqual([database]) + + const layer = LayerNode.compile(result.node).pipe( + Layer.provide(LayerNode.compile(result.hoisted)), + ) as unknown as Layer.Layer + const program = Effect.gen(function* () { + return yield* (yield* App).run + }).pipe(Effect.provide(layer)) + + expect(await Effect.runPromise(program)).toEqual(["Alice"]) + }) + + test("rejects conflicting hoisted implementations", () => { + const tags = LayerNode.tags({ location: ["global"], global: [] }) + const global = tags.make("global") + const location = tags.make("location") + const first = global({ + service: Database, + layer: Layer.succeed(Database, Database.of({ name: "first" })), + deps: [], + }) + const second = global({ + service: Database, + layer: Layer.succeed(Database, Database.of({ name: "second" })), + deps: [], + }) + const left = location({ + service: Users, + layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))), + deps: [first], + }) + const right = location({ + service: App, + layer: Layer.effect(App, Effect.as(Database, App.of({ run: Effect.succeed([]) }))), + deps: [second], + }) + + expect(() => LayerNode.hoist(LayerNode.group([left, right]), tags.values.global)).toThrow( + "Tag global has conflicting implementations for test/GraphDatabase", + ) + }) + + test("treats dependency groups as transparent while hoisting", () => { + const tags = LayerNode.tags({ location: ["global"], global: [] }) + const global = tags.make("global") + const location = tags.make("location") + const database = global({ + service: Database, + layer: Layer.succeed(Database, Database.of({ name: "Alice" })), + deps: [], + }) + const users = location({ + service: Users, + layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))), + deps: [LayerNode.group([database])], + }) + const result = LayerNode.hoist(LayerNode.group([users]), tags.values.global) + + expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({ + kind: "group", + dependencies: [], + }) + }) +}) diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts new file mode 100644 index 0000000000..e9ff2fc149 --- /dev/null +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test" +import { Context, Effect, Layer, LayerMap, Option } from "effect" +import { Node } from "@opencode-ai/core/effect/app-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import type { LocationError, LocationServices } from "@opencode-ai/core/location-services" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { tmpdir } from "../../fixture/tmpdir" + +class Value extends Context.Service()("test/TagValue") {} +class Result extends Context.Service()("test/TagResult") {} +class CycleA extends Context.Service()("test/NodeBuildA") {} +class CycleB extends Context.Service()("test/NodeBuildB") {} + +describe("node build", () => { + test("does not build a location service map when the graph does not require it", async () => { + const result = Node.makeGlobalNode({ + service: Result, + layer: Layer.succeed(Result, Result.of({ value: "plain" })), + deps: [], + }) + const layer = AppNodeBuilder.build(result) + const program = Effect.gen(function* () { + expect(Option.isNone(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true) + return (yield* Result).value + }).pipe(Effect.provide(layer)) + + expect(await Effect.runPromise(program)).toBe("plain") + }) + + test("detects cycles through a replaced location service map", async () => { + const a = Node.makeGlobalNode({ + service: CycleA, + layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))), + deps: [LocationServiceMap.node], + }) + const b = Node.makeGlobalNode({ + service: CycleB, + layer: Layer.effect( + CycleB, + Effect.map(CycleA, () => CycleB.of({ directory: AbsolutePath.make(process.cwd()) })), + ), + deps: [a], + }) + const mapLayer = Layer.effect( + LocationServiceMap.Service, + Effect.gen(function* () { + const service = yield* CycleB + return yield* LayerMap.make( + (ref: Location.Ref) => + Layer.succeed( + Location.Service, + Location.Service.of({ + directory: ref.directory, + workspaceID: ref.workspaceID, + project: { id: Project.ID.global, directory: service.directory }, + }), + ), + { idleTimeToLive: "1 minute" }, + ) + }) as unknown as Effect.Effect, never, CycleB>, + ) + const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] }) + expect(() => AppNodeBuilder.build(LayerNode.group([a]), [[LocationServiceMap.node, map]])).toThrow( + "Cycle detected in layer tree", + ) + }) + + test("shares top-level project with location services", async () => { + await using tmp = await tmpdir() + let acquisitions = 0 + const projectLayer = Layer.effect( + Project.Service, + Effect.sync(() => { + acquisitions++ + return Project.Service.of({ + directories: () => Effect.succeed([]), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + commit: () => Effect.void, + }) + }), + ) + const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }) + const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [ + [Project.node, projectLayer], + ]) + const program = Effect.gen(function* () { + yield* Project.Service + const locations = yield* LocationServiceMap.Service + expect(Option.isSome(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true) + return yield* Location.Service.pipe(Effect.provide(locations.get(ref))) + }).pipe(Effect.provide(layer)) + + expect((await Effect.runPromise(program)).directory).toBe(ref.directory) + expect(acquisitions).toBe(1) + }) + + test("returns a composed application layer", async () => { + const value = Node.makeGlobalNode({ + service: Value, + layer: Layer.succeed(Value, Value.of({ value: "value" })), + deps: [], + }) + const result = Node.makeGlobalNode({ + service: Result, + layer: Layer.effect( + Result, + Effect.gen(function* () { + return Result.of({ value: (yield* Value).value }) + }), + ), + deps: [value], + }) + const serviceLayer = AppNodeBuilder.build(result) + const program = Effect.gen(function* () { + expect(Option.isNone(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true) + return (yield* Result).value + }).pipe(Effect.provide(serviceLayer)) + + expect(await Effect.runPromise(program)).toBe("value") + }) +}) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index cd8ba69253..e4329a2dde 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -1,12 +1,17 @@ import { describe, expect } from "bun:test" -import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" +import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Stream } from "effect" import { EventV2 } from "@opencode-ai/core/event" +import { Event } from "@opencode-ai/schema/event" +import { Session } from "@opencode-ai/schema/session" +import { SessionEvent } from "@opencode-ai/schema/session-event" +import { SessionV1 } from "@opencode-ai/schema/session-v1" 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" import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { WorkspaceV2 } from "@opencode-ai/core/workspace" -import { V2Schema } from "@opencode-ai/core/v2-schema" import { eq } from "drizzle-orm" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" @@ -17,10 +22,6 @@ const locationLayer = Layer.succeed( location({ directory: AbsolutePath.make("project"), workspaceID: WorkspaceV2.ID.make("wrk_test") }), ), ) -const eventLayer = Layer.mergeAll(EventV2.defaultLayer, Database.defaultLayer) -const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer))) -const itWithoutLocation = testEffect(eventLayer) - const Message = EventV2.define({ type: "test.message", schema: { @@ -30,7 +31,7 @@ const Message = EventV2.define({ const SyncMessage = EventV2.define({ type: "test.sync", - sync: { + durable: { version: 1, aggregate: "id", }, @@ -42,7 +43,7 @@ const SyncMessage = EventV2.define({ const SyncSent = EventV2.define({ type: "test.sent", - sync: { + durable: { version: 1, aggregate: "messageID", }, @@ -61,7 +62,7 @@ const GlobalMessage = EventV2.define({ const VersionedMessage = EventV2.define({ type: "test.versioned", - sync: { + durable: { version: 2, aggregate: "id", }, @@ -71,32 +72,18 @@ const VersionedMessage = EventV2.define({ }, }) -const SyncTimestamp = EventV2.define({ - type: "test.timestamp", - sync: { - version: 1, - aggregate: "id", - }, - schema: { - id: Schema.String, - timestamp: V2Schema.DateTimeUtcFromMillis, - }, +const DurableMessage = SessionV1.Event.MessageRemoved +const durableData = (sessionID: Session.ID, text: string) => ({ + sessionID, + messageID: SessionV1.MessageID.ascending(`msg_${text}`), }) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, Location.node]), [[Location.node, locationLayer]]), +) +const itWithoutLocation = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node]))) + describe("EventV2", () => { - it.effect("derives stable namespaced external IDs", () => - Effect.sync(() => { - const input = { namespace: "opencord.agent-input", key: "input-1" } - - expect(EventV2.ID.fromExternal(input)).toBe(EventV2.ID.fromExternal(input)) - expect(EventV2.ID.fromExternal(input)).toMatch(/^evt_[a-f0-9]{64}$/) - expect(EventV2.ID.fromExternal({ ...input, namespace: "another-app" })).not.toBe(EventV2.ID.fromExternal(input)) - expect(EventV2.ID.fromExternal({ namespace: "a:b", key: "c" })).not.toBe( - EventV2.ID.fromExternal({ namespace: "a", key: "b:c" }), - ) - }), - ) - it.effect("publishes events with the current location", () => Effect.gen(function* () { const events = yield* EventV2.Service @@ -132,30 +119,25 @@ describe("EventV2", () => { const event = yield* events.publish(VersionedMessage, { id: "one", text: "hello" }) expect(event.type).toBe("test.versioned") - expect(event.version).toBe(2) + expect(event.durable?.version).toBe(2) }), ) - it.effect("stores definitions in the exported registry", () => - Effect.sync(() => { - expect(EventV2.registry.get(Message.type)).toBe(Message) - }), - ) - - it.effect("keeps the latest sync definition in the registry", () => + it.effect("selects the latest durable definition independent of declaration order", () => Effect.sync(() => { const latest = EventV2.define({ type: "test.out-of-order", - sync: { version: 2, aggregate: "id" }, + durable: { version: 2, aggregate: "id" }, schema: { id: Schema.String }, }) - EventV2.define({ + const historical = EventV2.define({ type: "test.out-of-order", - sync: { version: 1, aggregate: "id" }, + durable: { version: 1, aggregate: "id" }, schema: { id: Schema.String }, }) - expect(EventV2.registry.get("test.out-of-order")).toBe(latest) + expect(Event.latest([latest, historical]).get("test.out-of-order")).toBe(latest) + expect(Event.latest([historical, latest]).get("test.out-of-order")).toBe(latest) }), ) @@ -190,7 +172,7 @@ describe("EventV2", () => { }), ) - it.effect("commits local operational state inside a new synchronized event transaction", () => + it.effect("commits local operational state inside a new durable event transaction", () => Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() @@ -207,7 +189,7 @@ describe("EventV2", () => { }), ) - it.effect("rolls back the synchronized event and projector when the local commit fails", () => + it.effect("rolls back the durable event and projector when the local commit fails", () => Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service @@ -236,7 +218,7 @@ describe("EventV2", () => { const events = yield* EventV2.Service const exit = yield* events.publish(Message, { text: "hello" }, { commit: () => Effect.void }).pipe(Effect.exit) - expect(String(exit)).toContain("Local commit hooks require a synchronized event") + expect(String(exit)).toContain("Local commit hooks require a durable event") }), ) @@ -290,7 +272,6 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - yield* events.sync(() => Effect.die("sync defect")) yield* events.listen(() => { throw new Error("listener defect") }) @@ -303,7 +284,70 @@ describe("EventV2", () => { const event = yield* events.publish(SyncMessage, { id: "one", text: "hello" }) expect(received).toEqual([SyncMessage.type]) - expect(event.seq).toBeNumber() + expect(event.durable?.seq).toBeNumber() + }), + ) + + it.effect("notifies global listeners only after a durable event is committed", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + const observed = new Array<{ id: string; seq: number }>() + yield* events.listen((event) => + event.type !== SyncMessage.type + ? Effect.void + : db + .select({ id: EventTable.id, seq: EventTable.seq }) + .from(EventTable) + .where(eq(EventTable.id, event.id)) + .get() + .pipe( + Effect.orDie, + Effect.tap((row) => + Effect.sync(() => { + if (row) observed.push(row) + }), + ), + Effect.asVoid, + ), + ) + + const event = yield* events.publish(SyncMessage, { id: aggregateID, text: "committed" }) + if (!event.durable) throw new Error("Expected durable event metadata") + + expect(observed).toEqual([{ id: event.id, seq: event.durable.seq }]) + }), + ) + + it.effect("ends only an overflowing bounded subscriber without blocking other listeners", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const consuming = yield* Deferred.make() + const release = yield* Deferred.make() + const slowStream = yield* EventV2.allBounded(events, 1) + const fastStream = yield* EventV2.allBounded(events, 8) + const slow = yield* slowStream.pipe( + Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))), + Effect.forkScoped, + ) + const fast = yield* fastStream.pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped) + + yield* events.publish(Message, { text: "one" }) + yield* Deferred.await(consuming) + yield* events.publish(Message, { text: "two" }) + yield* events.publish(Message, { text: "overflow" }) + const last = yield* events.publish(Message, { text: "still delivered" }) + yield* Deferred.succeed(release, undefined) + + const slowExit = yield* Fiber.await(slow) + expect(Exit.findErrorOption(slowExit).pipe(Option.getOrUndefined)).toBeInstanceOf(EventV2.SubscriberOverflowError) + expect(Array.from(yield* Fiber.join(fast))).toEqual([ + expect.objectContaining({ data: { text: "one" } }), + expect.objectContaining({ data: { text: "two" } }), + expect.objectContaining({ data: { text: "overflow" } }), + last, + ]) }), ) @@ -336,49 +380,7 @@ describe("EventV2", () => { }), ) - it.effect("does not synchronize live-only events", () => - Effect.gen(function* () { - const events = yield* EventV2.Service - const synchronized = new Array() - const unsubscribe = yield* events.sync((event) => - Effect.sync(() => { - synchronized.push(event.type) - }), - ) - yield* Effect.addFinalizer(() => unsubscribe) - - yield* events.publish(Message, { text: "live only" }) - yield* events.publish(SyncMessage, { id: "one", text: "durable" }) - - expect(synchronized).toEqual([SyncMessage.type]) - }), - ) - - it.effect("synchronizes only after the durable event commits", () => - Effect.gen(function* () { - const events = yield* EventV2.Service - const { db } = yield* Database.Service - const synchronized = new Array() - yield* events.sync((event) => - db - .select({ id: EventTable.id }) - .from(EventTable) - .where(eq(EventTable.id, event.id)) - .get() - .pipe( - Effect.orDie, - Effect.map((row) => synchronized.push(row !== undefined)), - Effect.asVoid, - ), - ) - - yield* events.publish(SyncMessage, { id: EventV2.ID.create(), text: "durable" }) - - expect(synchronized).toEqual([true]) - }), - ) - - it.effect("inserts sync event rows on publish", () => + it.effect("inserts durable event rows on publish", () => Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service @@ -398,7 +400,7 @@ describe("EventV2", () => { }), ) - it.effect("increments sync event seq per aggregate", () => + it.effect("increments durable event seq per aggregate", () => Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service @@ -417,22 +419,22 @@ describe("EventV2", () => { }), ) - it.effect("replays durable aggregate events after a cursor and tails new events", () => + it.effect("replays durable aggregate events after a sequence and tails new events", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() - yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" }) - yield* events.publish(SyncMessage, { id: aggregateID, text: "one" }) + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "zero")) + yield* events.publish(DurableMessage, durableData(aggregateID, "one")) const fiber = yield* events - .aggregateEvents({ aggregateID, after: EventV2.Cursor.make(0) }) + .durable({ aggregateID, after: 0 }) .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow - yield* events.publish(SyncMessage, { id: aggregateID, text: "two" }) + yield* events.publish(DurableMessage, durableData(aggregateID, "two")) - expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([ - [EventV2.Cursor.make(1), { id: aggregateID, text: "one" }], - [EventV2.Cursor.make(2), { id: aggregateID, text: "two" }], + expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([ + [1, durableData(aggregateID, "one")], + [2, durableData(aggregateID, "two")], ]) }), ) @@ -440,22 +442,15 @@ describe("EventV2", () => { it.effect("catches durable aggregate events published during replay handoff", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() - yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" }) - const fiber = yield* events - .aggregateEvents({ aggregateID }) - .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "zero")) + const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) - yield* events.publish(SyncMessage, { id: aggregateID, text: "one" }) + yield* events.publish(DurableMessage, durableData(aggregateID, "one")) - expect( - Array.from(yield* Fiber.join(fiber)).map((event) => [ - event.cursor, - (event.event.data as { text: string }).text, - ]), - ).toEqual([ - [EventV2.Cursor.make(0), "zero"], - [EventV2.Cursor.make(1), "one"], + expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([ + [0, durableData(aggregateID, "zero")], + [1, durableData(aggregateID, "one")], ]) }), ) @@ -465,52 +460,46 @@ describe("EventV2", () => { const readStarted = yield* Deferred.make() const continueRead = yield* Deferred.make() let pause = true - const database = Database.layerFromPath(":memory:") const eventLayer = EventV2.layerWith({ beforeAggregateRead: () => pause ? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead))) : Effect.void, - }).pipe(Layer.provide(database)) + }).pipe(Layer.provide(LayerNode.compile(Database.node))) yield* Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() - const fiber = yield* events - .aggregateEvents({ aggregateID }) - .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + const aggregateID = Session.ID.create() + const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Deferred.await(readStarted) pause = false - yield* events.publish(SyncMessage, { id: aggregateID, text: "during handoff" }) + yield* events.publish(DurableMessage, durableData(aggregateID, "during handoff")) yield* Deferred.succeed(continueRead, undefined) - expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([ - [EventV2.Cursor.make(0), { id: aggregateID, text: "during handoff" }], + expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([ + [0, durableData(aggregateID, "during handoff")], ]) - }).pipe(Effect.provide(Layer.mergeAll(database, eventLayer))) + }).pipe(Effect.provide(Layer.merge(LayerNode.compile(Database.node), eventLayer))) }), ) it.effect("coalesces durable aggregate wakes while draining every committed event", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const count = 64 const fiber = yield* events - .aggregateEvents({ aggregateID }) + .durable({ aggregateID }) .pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow for (let index = 0; index < count; index++) { - yield* events.publish(SyncMessage, { id: aggregateID, text: String(index) }) + yield* events.publish(DurableMessage, durableData(aggregateID, String(index))) } - expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual( - Array.from({ length: count }, (_, index) => [ - EventV2.Cursor.make(index), - { id: aggregateID, text: String(index) }, - ]), + expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual( + Array.from({ length: count }, (_, index) => [index, durableData(aggregateID, String(index))]), ) }), ) @@ -518,16 +507,14 @@ describe("EventV2", () => { it.effect("omits live-only events from durable aggregate streams", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() - const fiber = yield* events - .aggregateEvents({ aggregateID }) - .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + const aggregateID = Session.ID.create() + const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow yield* events.publish(Message, { text: "live only" }) - yield* events.publish(SyncMessage, { id: aggregateID, text: "durable" }) + yield* events.publish(DurableMessage, durableData(aggregateID, "durable")) - expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.event.type)).toEqual([SyncMessage.type]) + expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type]) }), ) @@ -550,27 +537,27 @@ describe("EventV2", () => { }), ) - it.effect("replays sync events through projectors", () => + it.effect("replays durable events through projectors", () => Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - yield* events.project(SyncMessage, (event) => + yield* events.project(DurableMessage, (event) => Effect.sync(() => { received.push(event) }), ) - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "hello" }, + data: durableData(aggregateID, "hello"), }) - expect(received[0]?.type).toBe(SyncMessage.type) - expect(received[0]?.data).toEqual({ id: aggregateID, text: "hello" }) + expect(received[0]?.type).toBe(DurableMessage.type) + expect(received[0]?.data).toEqual(durableData(aggregateID, "hello")) }), ) @@ -578,14 +565,14 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "replayed" }, + data: durableData(aggregateID, "replayed"), }) const rows = yield* db .select() @@ -605,11 +592,11 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const envelopeAggregateID = EventV2.ID.create() - const payloadAggregateID = EventV2.ID.create() + const envelopeAggregateID = Session.ID.create() + const payloadAggregateID = Session.ID.create() const received = new Array() - yield* events.publish(SyncMessage, { id: payloadAggregateID, text: "seed" }) - yield* events.project(SyncMessage, (event) => + yield* events.publish(DurableMessage, durableData(payloadAggregateID, "seed")) + yield* events.project(DurableMessage, (event) => Effect.sync(() => { received.push(event) }), @@ -618,10 +605,10 @@ describe("EventV2", () => { const exit = yield* events .replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID: envelopeAggregateID, - data: { id: payloadAggregateID, text: "replayed" }, + data: durableData(payloadAggregateID, "replayed"), }) .pipe(Effect.exit) const rows = yield* db @@ -647,22 +634,22 @@ describe("EventV2", () => { it.effect("replay defects on sequence mismatch", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "first" }, + data: durableData(aggregateID, "first"), }) const exit = yield* events .replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 5, aggregateID, - data: { id: aggregateID, text: "bad" }, + data: durableData(aggregateID, "bad"), }) .pipe(Effect.exit) @@ -673,9 +660,9 @@ describe("EventV2", () => { it.effect("replay decodes synchronized transformed values before projection", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() - const received = new Array() - yield* events.project(SyncTimestamp, (event) => + const aggregateID = Session.ID.create() + const received = new Array() + yield* events.project(SessionEvent.ContextUpdated, (event) => Effect.sync(() => { received.push(event) }), @@ -683,10 +670,10 @@ describe("EventV2", () => { yield* events.replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncTimestamp.type, 1), + type: EventV2.versionedType(SessionEvent.ContextUpdated.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, timestamp: 0 }, + data: { sessionID: aggregateID, messageID: "msg_context", timestamp: 0, text: "context" }, }) expect(received[0]?.data.timestamp).toEqual(DateTime.makeUnsafe(0)) @@ -706,28 +693,28 @@ describe("EventV2", () => { }) .pipe(Effect.exit) - expect(String(exit)).toContain("Unknown sync event type") + expect(String(exit)).toContain("Unknown durable event type") }), ) it.effect("replayAll validates contiguous aggregate events", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const source = yield* events.replayAll([ { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "one" }, + data: durableData(aggregateID, "one"), }, { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "two" }, + data: durableData(aggregateID, "two"), }, ]) @@ -739,38 +726,38 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const one = yield* events.replayAll([ { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "one" }, + data: durableData(aggregateID, "one"), }, { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "two" }, + data: durableData(aggregateID, "two"), }, ]) const two = yield* events.replayAll([ { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 2, aggregateID, - data: { id: aggregateID, text: "three" }, + data: durableData(aggregateID, "three"), }, { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 3, aggregateID, - data: { id: aggregateID, text: "four" }, + data: durableData(aggregateID, "four"), }, ]) const rows = yield* db @@ -790,10 +777,10 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - const aggregateID = EventV2.ID.create() - yield* events.publish(SyncMessage, { id: aggregateID, text: "seed" }) + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "seed")) yield* events.claim(aggregateID, "owner-a") - yield* events.project(SyncMessage, (event) => + yield* events.project(DurableMessage, (event) => Effect.sync(() => { received.push(event) }), @@ -802,10 +789,10 @@ describe("EventV2", () => { yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "ignored" }, + data: durableData(aggregateID, "ignored"), }, { ownerID: "owner-b" }, ) @@ -817,14 +804,14 @@ describe("EventV2", () => { it.effect("strict owner fences exact replay", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const id = EventV2.ID.create() const replayed = { id, - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "owned" }, + data: durableData(aggregateID, "owned"), } yield* events.replay(replayed, { ownerID: "owner-a" }) @@ -838,12 +825,12 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() - const published = yield* events.publish(SyncMessage, { id: aggregateID, text: "owned" }) + const aggregateID = Session.ID.create() + const published = yield* events.publish(DurableMessage, durableData(aggregateID, "owned")) const replayed = { id: published.id, - type: EventV2.versionedType(SyncMessage.type, 1), - seq: published.seq!, + type: EventV2.versionedType(DurableMessage.type, 1), + seq: published.durable!.seq, aggregateID, data: published.data, } @@ -859,7 +846,7 @@ describe("EventV2", () => { expect(row?.ownerID).toBe("owner-a") const exit = yield* events .replay( - { ...replayed, id: EventV2.ID.create(), seq: 1, data: { id: aggregateID, text: "conflict" } }, + { ...replayed, id: EventV2.ID.create(), seq: 1, data: durableData(aggregateID, "conflict") }, { ownerID: "owner-b", strictOwner: true }, ) .pipe(Effect.exit) @@ -871,15 +858,15 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "owned" }, + data: durableData(aggregateID, "owned"), }, { ownerID: "owner-1" }, ) @@ -898,26 +885,26 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() - yield* events.publish(SyncMessage, { id: aggregateID, text: "local" }) + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "local")) yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "claimed" }, + data: durableData(aggregateID, "claimed"), }, { ownerID: "owner-1" }, ) yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 2, aggregateID, - data: { id: aggregateID, text: "fenced" }, + data: durableData(aggregateID, "fenced"), }, { ownerID: "owner-2" }, ) @@ -942,14 +929,14 @@ describe("EventV2", () => { it.effect("strict replay rejects an owner conflict instead of silently skipping it", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "claimed" }, + data: durableData(aggregateID, "claimed"), }, { ownerID: "owner-1" }, ) @@ -958,10 +945,10 @@ describe("EventV2", () => { .replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "conflict" }, + data: durableData(aggregateID, "conflict"), }, { ownerID: "owner-2", strictOwner: true }, ) @@ -975,20 +962,20 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.listen((event) => Effect.sync(() => received.push(event))) const replayed = { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "replayed" }, + data: durableData(aggregateID, "replayed"), } yield* events.replay(replayed, { publish: true }) yield* events.replay(replayed, { publish: true }) - expect(received).toMatchObject([{ id: replayed.id, seq: 0, data: replayed.data }]) + expect(received).toMatchObject([{ id: replayed.id, durable: { seq: 0, version: 1 }, data: replayed.data }]) }), ) @@ -996,19 +983,19 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const replayed = { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "original" }, + data: durableData(aggregateID, "original"), } yield* events.listen((event) => Effect.sync(() => received.push(event))) yield* events.replay(replayed, { publish: true }) const exit = yield* events - .replay({ ...replayed, data: { id: aggregateID, text: "divergent" } }, { publish: true }) + .replay({ ...replayed, data: durableData(aggregateID, "divergent") }, { publish: true }) .pipe(Effect.exit) expect(String(exit)).toContain("Replay diverged") @@ -1019,23 +1006,23 @@ describe("EventV2", () => { it.effect("rejects an event ID reused at another aggregate position", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const id = EventV2.ID.create() yield* events.replay({ id, - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "first" }, + data: durableData(aggregateID, "first"), }) const exit = yield* events .replay({ id, - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "second" }, + data: durableData(aggregateID, "second"), }) .pipe(Effect.exit) @@ -1047,27 +1034,27 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const received = new Array() yield* events.listen((event) => Effect.sync(() => received.push(event))) yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "first" }, + data: durableData(aggregateID, "first"), }, { ownerID: "owner-1" }, ) yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "ignored" }, + data: durableData(aggregateID, "ignored"), }, { ownerID: "owner-2", publish: true }, ) @@ -1110,14 +1097,14 @@ describe("EventV2", () => { }), ) - it.effect("remove clears sync event sequence", () => + it.effect("remove clears durable event sequence", () => Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - const aggregateID = EventV2.ID.create() - yield* events.publish(SyncMessage, { id: aggregateID, text: "seed" }) + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "seed")) yield* events.remove(aggregateID) - yield* events.project(SyncMessage, (event) => + yield* events.project(DurableMessage, (event) => Effect.sync(() => { received.push(event) }), @@ -1125,13 +1112,13 @@ describe("EventV2", () => { yield* events.replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "replayed" }, + data: durableData(aggregateID, "replayed"), }) - expect(received[0]?.data).toEqual({ id: aggregateID, text: "replayed" }) + expect(received[0]?.data).toEqual(durableData(aggregateID, "replayed")) }), ) }) diff --git a/packages/core/test/file-mutation.test.ts b/packages/core/test/file-mutation.test.ts index ba12d7c13c..bcd6ce97ed 100644 --- a/packages/core/test/file-mutation.test.ts +++ b/packages/core/test/file-mutation.test.ts @@ -2,6 +2,8 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" import { Deferred, Effect, Fiber, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FileMutation } from "@opencode-ai/core/file-mutation" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" @@ -11,14 +13,17 @@ import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { it } from "./lib/effect" -function provide(directory: string, filesystem = FSUtil.defaultLayer) { +function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.node)) { const activeLocation = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) })), ) - const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation)) - const mutation = FileMutation.layer.pipe(Layer.provide(filesystem)) - return Effect.provide(Layer.mergeAll(resolution, mutation)) + return Effect.provide( + AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [ + [Location.node, activeLocation], + [FSUtil.node, filesystemLayer], + ]), + ) } function withTmp(f: (directory: string) => Effect.Effect) { @@ -359,5 +364,5 @@ function instrumentWrites(run: (write: Effect.Effect, target: string run(filesystem.writeFileString(target, content, options), target), }) }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) } diff --git a/packages/core/test/filesystem/filesystem.test.ts b/packages/core/test/filesystem/filesystem.test.ts index 10f61d8a97..fdce1b4476 100644 --- a/packages/core/test/filesystem/filesystem.test.ts +++ b/packages/core/test/filesystem/filesystem.test.ts @@ -1,11 +1,12 @@ import { describe, test, expect } from "bun:test" -import { Effect, Layer, FileSystem } from "effect" -import { NodeFileSystem } from "@effect/platform-node" +import { Effect, FileSystem } from "effect" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { testEffect } from "../lib/effect" import path from "path" -const live = FSUtil.layer.pipe(Layer.provideMerge(NodeFileSystem.layer)) +const live = LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem])) const { effect: it } = testEffect(live) describe("FSUtil", () => { diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index cdc8344de5..6c47c85e96 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -2,12 +2,13 @@ import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" import { Effect } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" -const it = testEffect(Ripgrep.defaultLayer) +const it = testEffect(LayerNode.compile(Ripgrep.node)) const withTmp = (f: (directory: AbsolutePath) => Effect.Effect) => Effect.acquireRelease( @@ -22,7 +23,7 @@ describe("Ripgrep", () => { yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src"))) yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n")) const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 }) - expect(result.map((item) => item.path)).toEqual([RelativePath.make(path.join("src", "match.ts"))]) + expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")]) }), ), ) @@ -35,7 +36,7 @@ describe("Ripgrep", () => { yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n")) const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 }) expect(result).toHaveLength(1) - expect(result[0]?.entry.path).toBe(RelativePath.make(path.join("src", "match.ts"))) + expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts")) expect(result[0]?.submatches[0]?.text).toBe("needle") }), ), diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index 3390772c1f..e920d41d3a 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -4,10 +4,11 @@ import fs from "fs/promises" import path from "path" import { ConfigProvider, Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect" import { Config } from "@opencode-ai/core/config" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { FSUtil } from "@opencode-ai/core/fs-util" import { Watcher } from "@opencode-ai/core/filesystem/watcher" -import { Git } from "@opencode-ai/core/git" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" @@ -18,7 +19,7 @@ const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } -const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer)) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node]))) const configLayer = Layer.succeed( Config.Service, @@ -40,12 +41,10 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) { Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })), ) return Effect.provide( - Watcher.layer.pipe( - Layer.provide(configLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(locationLayer), - Layer.provide(flagsLayer), - ), + AppNodeBuilder.build(Watcher.node, [ + [Config.node, configLayer], + [Location.node, locationLayer], + ]).pipe(Layer.provide(flagsLayer)), ) } @@ -196,7 +195,7 @@ describeWatcher("Watcher", () => { yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe( Effect.provideService(EventV2.Service, events), ) - }).pipe(Effect.provide(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))), + }).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))), ) it.live("ignores .git/index changes", () => @@ -228,10 +227,7 @@ describeWatcher("Watcher", () => { yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet()) expect( yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)), - ).toEqual({ - file: head, - event: "change", - }) + ).toMatchObject({ file: head }) }), { git: true }, ), diff --git a/packages/core/test/fixture/effect-flock-worker.ts b/packages/core/test/fixture/effect-flock-worker.ts index 3b3f74711d..7e42e80098 100644 --- a/packages/core/test/fixture/effect-flock-worker.ts +++ b/packages/core/test/fixture/effect-flock-worker.ts @@ -1,7 +1,7 @@ import fs from "fs/promises" import os from "os" -import { Effect, Layer } from "effect" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Global } from "@opencode-ai/core/global" @@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({ log: os.tmpdir(), }) -const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(FSUtil.defaultLayer)) +const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]]) async function job() { if (msg.ready) await fs.writeFile(msg.ready, String(process.pid)) diff --git a/packages/core/test/fixture/location.ts b/packages/core/test/fixture/location.ts index 00b3ffbd13..40d8ed9dc3 100644 --- a/packages/core/test/fixture/location.ts +++ b/packages/core/test/fixture/location.ts @@ -1,6 +1,8 @@ import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" +import { Effect, Layer } from "effect" +import { tmpdir } from "./tmpdir" export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) { return { @@ -10,3 +12,15 @@ export function location(ref: Location.Ref, input: { projectDirectory?: Absolute vcs: input.vcs, } satisfies Location.Interface } + +export const tempLocationLayer = Layer.unwrap( + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.map((tmp) => { + const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }) + return Layer.succeed(Location.Service, Location.Service.of(location(ref))) + }), + ), +) diff --git a/packages/core/test/git.test.ts b/packages/core/test/git.test.ts index e09e3e1a5d..f77e32fd8b 100644 --- a/packages/core/test/git.test.ts +++ b/packages/core/test/git.test.ts @@ -3,27 +3,30 @@ import { $ } from "bun" import fs from "fs/promises" import path from "path" import { Effect } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Git } from "@opencode-ai/core/git" -import { AbsolutePath } from "@opencode-ai/core/schema" +import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { branch, commit, gitRemote } from "./fixture/git" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const it = testEffect(Git.defaultLayer) +const it = testEffect(LayerNode.compile(Git.node)) describe("Git", () => { it.live("clones a remote and reads checkout metadata", () => withRemote((fixture) => Effect.gen(function* () { const git = yield* Git.Service - const target = path.join(fixture.root, "checkout") - const result = yield* git.clone({ remote: fixture.remote, target }) + const target = AbsolutePath.make(path.join(fixture.root, "checkout")) + const repository = yield* git.repo.clone({ remote: fixture.remote, directory: target }) - expect(result.exitCode).toBe(0) - expect(yield* git.origin(target)).toBe(fixture.remote) - expect(yield* git.head(target)).toBeString() - expect(yield* git.branch(target)).toBe("main") - expect(yield* git.remoteHead(target)).toBe("origin/main") + expect(yield* git.remote.get(repository)).toBe(fixture.remote) + expect(yield* git.history.head(repository)).toBeString() + expect(yield* git.history.branch(repository)).toBe("main") + expect(yield* git.history.defaultRemoteBranch(repository)).toBe("main") + expect(repository.worktree).toBe(target) + expect(repository.gitDirectory).toBe(AbsolutePath.make(path.join(target, ".git"))) + expect(repository.commonDirectory).toBe(repository.gitDirectory) expect(yield* read(path.join(target, "README.md"))).toBe("one\n") }), ), @@ -33,19 +36,19 @@ describe("Git", () => { withRemote((fixture) => Effect.gen(function* () { const git = yield* Git.Service - const target = path.join(fixture.root, "checkout") - yield* git.clone({ remote: fixture.remote, target }) + const target = AbsolutePath.make(path.join(fixture.root, "checkout")) + const repository = yield* git.repo.clone({ remote: fixture.remote, directory: target }) yield* Effect.promise(() => commit(fixture.source, "two\n", "second")) - expect((yield* git.fetch(target)).exitCode).toBe(0) - expect((yield* git.reset(target, "origin/main")).exitCode).toBe(0) + yield* git.sync.fetchRemotes(repository) + yield* git.sync.resetHard(repository, "origin/main") expect(yield* read(path.join(target, "README.md"))).toBe("two\n") yield* Effect.promise(() => branch(fixture.source, "feature/docs", "feature\n")) - expect((yield* git.fetchBranch(target, "feature/docs")).exitCode).toBe(0) - expect((yield* git.checkout(target, "feature/docs")).exitCode).toBe(0) - expect((yield* git.reset(target, "origin/feature/docs")).exitCode).toBe(0) - expect(yield* git.branch(target)).toBe("feature/docs") + yield* git.sync.fetchBranch(repository, { branch: "feature/docs" }) + yield* git.sync.checkoutRemoteBranch(repository, { branch: "feature/docs" }) + yield* git.sync.resetHard(repository, "origin/feature/docs") + expect(yield* git.history.branch(repository)).toBe("feature/docs") expect(yield* read(path.join(target, "README.md"))).toBe("feature\n") }), ), @@ -90,17 +93,72 @@ describe("Git worktrees", () => { Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })).pipe(Effect.ignore), ) const git = yield* Git.Service - const repo = { directory, store: AbsolutePath.make(path.join(directory, ".git")) } + const repo = yield* git.repo.discover(directory) + if (!repo) throw new Error("Repository not found") - yield* git.worktreeCreate({ repo, directory: worktree }) + yield* git.worktree.create({ repository: repo, directory: worktree }) - expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(true) - const linked = yield* git.find(worktree) - expect(linked?.directory).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree)))) - expect(linked?.store).toBe(repo.store) + expect((yield* git.worktree.list(repo)).some((entry) => entry.directory.endsWith("-git-worktree"))).toBe(true) + const linked = yield* git.repo.discover(worktree) + expect(linked?.worktree).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree)))) + expect(linked?.commonDirectory).toBe(repo.commonDirectory) + expect(linked?.gitDirectory).not.toBe(repo.gitDirectory) if (!linked) throw new Error("Linked worktree not found") - yield* git.worktreeRemove({ repo: linked, directory: worktree, force: false }) - expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(false) + yield* git.worktree.remove({ repository: linked, directory: worktree, force: false }) + expect((yield* git.worktree.list(repo)).some((entry) => entry.directory.endsWith("-git-worktree"))).toBe(false) + }), + ) +}) + +describe("Git trees", () => { + it.live("captures, compares, previews, and restores scoped trees", () => + Effect.gen(function* () { + const root = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ) + yield* Effect.promise(async () => { + await initRepo(root.path) + await fs.mkdir(path.join(root.path, "scope")) + await fs.writeFile(path.join(root.path, "scope", "tracked.txt"), "one\n") + await fs.writeFile(path.join(root.path, "outside.txt"), "outside\n") + await $`git add .`.cwd(root.path).quiet() + await $`git commit -m initial`.cwd(root.path).quiet() + }) + const git = yield* Git.Service + const source = yield* git.repo.discover(AbsolutePath.make(root.path)) + if (!source) throw new Error("Repository not found") + const storage = AbsolutePath.make(path.join(root.path, ".snapshot")) + const repository = yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source }) + yield* git.index.refresh({ repository, scope: RelativePath.make("scope") }) + const before = yield* git.tree.write(repository) + + yield* Effect.promise(async () => { + await fs.writeFile(path.join(root.path, "scope", "tracked.txt"), "two\n") + await fs.writeFile(path.join(root.path, "scope", "added.txt"), "added\n") + await fs.writeFile(path.join(root.path, "outside.txt"), "changed outside\n") + }) + yield* git.index.refresh({ repository, scope: RelativePath.make("scope") }) + const after = yield* git.tree.write(repository) + + expect(yield* git.tree.files({ repository, from: before, to: after })).toEqual([ + RelativePath.make("scope/added.txt"), + RelativePath.make("scope/tracked.txt"), + ]) + const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 1 }) + expect(diffs.map((item) => [item.path, item.status])).toEqual([ + [RelativePath.make("scope/added.txt"), "added"], + [RelativePath.make("scope/tracked.txt"), "modified"], + ]) + + const files = new Map([[RelativePath.make("scope/tracked.txt"), before]]) + const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 }) + expect(preview).toHaveLength(1) + expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt")) + yield* git.tree.restore({ repository, files }) + expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n") + expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n") + expect(yield* read(path.join(root.path, "outside.txt"))).toBe("changed outside\n") }), ) }) diff --git a/packages/core/test/github-copilot/openai-responses-language-model.test.ts b/packages/core/test/github-copilot/openai-responses-language-model.test.ts new file mode 100644 index 0000000000..ab047d04eb --- /dev/null +++ b/packages/core/test/github-copilot/openai-responses-language-model.test.ts @@ -0,0 +1,206 @@ +import { OpenAIResponsesLanguageModel } from "@opencode-ai/core/github-copilot/responses/openai-responses-language-model" +import { convertToOpenAIResponsesInput } from "@opencode-ai/core/github-copilot/responses/convert-to-openai-responses-input" +import { describe, test, expect, mock } from "bun:test" +import type { LanguageModelV3Prompt } from "@ai-sdk/provider" + +const TEST_PROMPT: LanguageModelV3Prompt = [{ role: "user", content: [{ type: "text", text: "Hello" }] }] + +function createMockFetch(body: unknown) { + return mock( + async () => new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }), + ) +} + +function createModel(fetchFn: ReturnType) { + return new OpenAIResponsesLanguageModel("test-model", { + provider: "copilot", + url: () => "https://api.test.com/responses", + headers: () => ({ Authorization: "Bearer test-token" }), + fetch: fetchFn as any, + }) +} + +// GitHub Copilot's Responses model echoes item metadata (itemId, reasoningEncryptedContent, +// responseId, ...) under the "copilot" providerOptions/providerMetadata namespace, matching the +// namespace request options already use. It used to echo this metadata under "openai" (a leftover +// from forking the OpenAI Responses model), which left it unreachable by anything reading the +// "copilot" namespace and let stale itemIds slip past stripping meant for that namespace. +describe("doGenerate", () => { + test("attaches item metadata under the copilot namespace, not openai", async () => { + const mockFetch = createMockFetch({ + id: "resp_1", + created_at: 0, + model: "gpt-5.5", + output: [ + { + type: "reasoning", + id: "rs_1", + encrypted_content: "enc_1", + summary: [{ type: "summary_text", text: "thinking..." }], + }, + { + type: "message", + role: "assistant", + id: "msg_1", + content: [{ type: "output_text", text: "Hello there", annotations: [] }], + }, + { + type: "function_call", + call_id: "call_1", + name: "bash", + arguments: "{}", + id: "fc_1", + }, + ], + usage: { input_tokens: 10, output_tokens: 5 }, + }) + const model = createModel(mockFetch) + + const { content, providerMetadata } = await model.doGenerate({ + prompt: TEST_PROMPT, + includeRawChunks: false, + } as any) + + const reasoning = content.find((part: any) => part.type === "reasoning") as any + expect(reasoning.providerMetadata?.copilot?.itemId).toBe("rs_1") + expect(reasoning.providerMetadata?.copilot?.reasoningEncryptedContent).toBe("enc_1") + expect(reasoning.providerMetadata?.openai).toBeUndefined() + + const text = content.find((part: any) => part.type === "text") as any + expect(text.providerMetadata?.copilot?.itemId).toBe("msg_1") + expect(text.providerMetadata?.openai).toBeUndefined() + + const toolCall = content.find((part: any) => part.type === "tool-call") as any + expect(toolCall.providerMetadata?.copilot?.itemId).toBe("fc_1") + expect(toolCall.providerMetadata?.openai).toBeUndefined() + + expect(providerMetadata?.copilot?.responseId).toBe("resp_1") + expect(providerMetadata?.openai).toBeUndefined() + }) +}) + +describe("convertToOpenAIResponsesInput", () => { + test("echoes a stale tool-call itemId from the copilot namespace as the function_call id", async () => { + const { input } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "bash", + input: { command: "ls" }, + providerOptions: { copilot: { itemId: "fc_999" } }, + }, + ], + }, + ], + systemMessageMode: "system", + store: false, + }) + + expect(input).toEqual([ + { + type: "function_call", + call_id: "call_1", + name: "bash", + arguments: JSON.stringify({ command: "ls" }), + id: "fc_999", + }, + ]) + }) + + test("omits the function_call id once the stale copilot itemId has been stripped", async () => { + const { input } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "bash", + input: { command: "ls" }, + providerOptions: {}, + }, + ], + }, + ], + systemMessageMode: "system", + store: false, + }) + + expect((input[0] as any).id).toBeUndefined() + }) + + test("preserves reasoning items keyed by the copilot namespace instead of dropping them", async () => { + const { input, warnings } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thinking...", + providerOptions: { copilot: { itemId: "rs_1", reasoningEncryptedContent: "enc_1" } }, + }, + ], + }, + ], + systemMessageMode: "system", + store: false, + }) + + expect(warnings).toEqual([]) + expect(input).toEqual([ + { + type: "reasoning", + id: "rs_1", + encrypted_content: "enc_1", + summary: [{ type: "summary_text", text: "thinking..." }], + }, + ]) + }) + + test("drops reasoning items with no copilot itemId and warns, as before", async () => { + const { input, warnings } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "assistant", + content: [{ type: "reasoning", text: "thinking...", providerOptions: {} }], + }, + ], + systemMessageMode: "system", + store: false, + }) + + expect(input).toEqual([]) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toMatchObject({ + message: expect.stringContaining("Non-OpenAI reasoning parts are not supported"), + }) + }) + + test("reads imageDetail from the copilot namespace on user file parts", async () => { + const { input } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "user", + content: [ + { + type: "file", + mediaType: "image/png", + data: "aGVsbG8=", + providerOptions: { copilot: { imageDetail: "high" } }, + }, + ], + }, + ], + systemMessageMode: "system", + store: false, + }) + + expect((input[0] as any).content[0].detail).toBe("high") + }) +}) diff --git a/packages/core/test/instruction-context.test.ts b/packages/core/test/instruction-context.test.ts index ae182faccf..a6699d219a 100644 --- a/packages/core/test/instruction-context.test.ts +++ b/packages/core/test/instruction-context.test.ts @@ -2,6 +2,8 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import fs from "fs/promises" import path from "path" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { InstructionContext } from "@opencode-ai/core/instruction-context" @@ -15,6 +17,17 @@ import { testEffect } from "./lib/effect" const it = testEffect(Layer.empty) +const instructionLayer = (input: { + config: string + locationServiceLayer: Layer.Layer + filesystemLayer?: Layer.Layer +}) => + AppNodeBuilder.build(LayerNode.group([SystemContextRegistry.node, InstructionContext.node]), [ + [Global.node, Global.layerWith({ config: input.config })], + [Location.node, input.locationServiceLayer], + ...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []), + ]) + describe("InstructionContext", () => { it.live("loads global and upward project AGENTS.md files as one aggregate context", () => Effect.acquireRelease( @@ -41,19 +54,19 @@ describe("InstructionContext", () => { const load = SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), - Effect.provide(FSUtil.defaultLayer), - Effect.provide(Global.layerWith({ config: global })), Effect.provide( - Layer.succeed( - Location.Service, - Location.Service.of( - location( - { directory: AbsolutePath.make(directory) }, - { projectDirectory: AbsolutePath.make(project) }, + instructionLayer({ + config: global, + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(project) }, + ), ), ), - ), + }), ), ) @@ -107,14 +120,14 @@ describe("InstructionContext", () => { yield* Effect.promise(() => fs.writeFile(file, "")) const context = yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), - Effect.provide(FSUtil.defaultLayer), - Effect.provide(Global.layerWith({ config: path.join(tmp.path, "global") })), Effect.provide( - Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })), - ), + instructionLayer({ + config: path.join(tmp.path, "global"), + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })), + ), + }), ), ) @@ -133,14 +146,18 @@ describe("InstructionContext", () => { FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }), ), ), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) const context = yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), - Effect.provide(failingFS), - Effect.provide(Global.layerWith({ config: "/global" })), Effect.provide( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))), + instructionLayer({ + config: "/global", + filesystemLayer: failingFS, + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("/repo") })), + ), + }), ), ) @@ -169,14 +186,18 @@ describe("InstructionContext", () => { }), ), ), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) const context = yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), - Effect.provide(racingFS), - Effect.provide(Global.layerWith({ config: "/global" })), Effect.provide( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))), + instructionLayer({ + config: "/global", + filesystemLayer: racingFS, + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("/repo") })), + ), + }), ), ) @@ -208,20 +229,21 @@ describe("InstructionContext", () => { }), ), ), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), - Effect.provide(observingFS), - Effect.provide(Global.layerWith({ config: "/global" })), Effect.provide( - Layer.succeed( - Location.Service, - Location.Service.of( - location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }), + instructionLayer({ + config: "/global", + filesystemLayer: observingFS, + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of( + location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }), + ), ), - ), + }), ), ) @@ -241,18 +263,20 @@ describe("InstructionContext", () => { yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), Effect.provide( - Layer.effect( - FSUtil.Service, - FSUtil.Service.pipe( - Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })), + instructionLayer({ + config: "/global", + filesystemLayer: Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })), + ), + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))), + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("/repo") })), ), - ).pipe(Layer.provide(FSUtil.defaultLayer)), - ), - Effect.provide(Global.layerWith({ config: "/global" })), - Effect.provide( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))), + }), ), Effect.ensuring( Effect.sync(() => { @@ -271,23 +295,25 @@ describe("InstructionContext", () => { let scanned = false yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), Effect.provide( - Layer.effect( - FSUtil.Service, - FSUtil.Service.pipe( - Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })), + instructionLayer({ + config: "/global", + filesystemLayer: Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })), + ), + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))), + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make("/outside") }, + { projectDirectory: AbsolutePath.make("/repo") }, + ), + ), ), - ).pipe(Layer.provide(FSUtil.defaultLayer)), - ), - Effect.provide(Global.layerWith({ config: "/global" })), - Effect.provide( - Layer.succeed( - Location.Service, - Location.Service.of( - location({ directory: AbsolutePath.make("/outside") }, { projectDirectory: AbsolutePath.make("/repo") }), - ), - ), + }), ), ) diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index ca4362c605..376d2f9523 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -1,47 +1,14 @@ import { describe, expect } from "bun:test" -import { Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect" +import { Duration, Effect, Exit, Fiber, Scope, Stream } from "effect" import * as TestClock from "effect/testing/TestClock" -import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" -import { it } from "./lib/effect" +import { Integration } from "@opencode-ai/core/integration" +import { testEffect } from "./lib/effect" -const layer = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - create: () => Effect.die("unexpected credential creation"), - list: () => Effect.succeed([]), - }), - ), -) - -function connectionLayer( - created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }>, -) { - return Integration.locationLayer.pipe( - Layer.provideMerge(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - create: (input) => - Effect.sync(() => { - created.push(input) - return new Credential.Stored({ - id: Credential.ID.create(), - integrationID: input.integrationID, - label: input.label ?? "default", - value: input.value, - }) - }), - list: () => Effect.succeed([]), - }), - ), - ) -} +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node]))) describe("Integration", () => { it.effect("registers integrations through the editor", () => @@ -51,7 +18,7 @@ describe("Integration", () => { const openai = Integration.ID.make("openai") yield* integrations - .update((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI"))) + .transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI"))) .pipe(Scope.provide(scope)) expect(yield* integrations.get(openai)).toEqual( new Integration.Info({ id: openai, name: "OpenAI", methods: [], connections: [] }), @@ -59,7 +26,7 @@ describe("Integration", () => { yield* Scope.close(scope, Exit.void) expect(yield* integrations.get(openai)).toBeUndefined() - }).pipe(Effect.provide(layer)), + }), ) it.effect("reveals the previous registration when an override closes", () => @@ -70,17 +37,17 @@ describe("Integration", () => { const second = yield* Scope.fork(yield* Scope.Scope) yield* integrations - .update((editor) => editor.update(id, (integration) => (integration.name = "OpenAI"))) + .transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI"))) .pipe(Scope.provide(first)) yield* integrations - .update((editor) => editor.update(id, (integration) => (integration.name = "OpenAI Override"))) + .transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI Override"))) .pipe(Scope.provide(second)) expect((yield* integrations.get(id))?.name).toBe("OpenAI Override") yield* Scope.close(second, Exit.void) expect((yield* integrations.get(id))?.name).toBe("OpenAI") expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id]) - }).pipe(Effect.provide(layer)), + }), ) it.effect("registers and overrides methods independently", () => @@ -99,7 +66,7 @@ describe("Integration", () => { }) yield* integrations - .update((editor) => + .transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, @@ -108,7 +75,7 @@ describe("Integration", () => { ) .pipe(Scope.provide(first)) yield* integrations - .update((editor) => { + .transform((editor) => { expect(editor.get(integrationID)).toEqual({ id: integrationID, name: "openai" }) expect(editor.list()).toEqual([{ id: integrationID, name: "openai" }]) expect(editor.method.list(integrationID)).toEqual([ @@ -128,20 +95,16 @@ describe("Integration", () => { yield* Scope.close(second, Exit.void) expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" }) expect((yield* integrations.get(integrationID))?.methods).toEqual([expect.objectContaining({ id: methodID })]) - }).pipe(Effect.provide(layer)), + }), ) - it.effect("connects with a key and stores the credential", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("connects with a key and stores the credential", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const events = yield* EventV2.Service const integrationID = Integration.ID.make("openai") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { type: "key", label: "API key" }, @@ -158,28 +121,24 @@ describe("Integration", () => { label: "Work", }) - expect(created).toEqual([ - { + expect(yield* credentials.list(integrationID)).toEqual([ + expect.objectContaining({ integrationID, label: "Work", - value: new Credential.Key({ type: "key", key: "secret" }), - }, + value: Credential.Key.make({ type: "key", key: "secret" }), + }), ]) expect((yield* Fiber.join(updated)).length).toBe(1) - }).pipe(Effect.provide(connectionLayer(created))) - }) + }), + ) - it.effect("completes code OAuth once and stores the credential", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("completes code OAuth once and stores the credential", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, @@ -190,7 +149,7 @@ describe("Integration", () => { instructions: "Paste the code", callback: (code: string) => Effect.succeed( - new Credential.OAuth({ + Credential.OAuth.make({ type: "oauth", methodID, access: "access", @@ -212,33 +171,31 @@ describe("Integration", () => { expect(attempt.mode).toBe("code") yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" }) - expect(created[0]).toEqual({ - integrationID, - label: "Personal", - value: new Credential.OAuth({ - type: "oauth", - methodID, - access: "access", - refresh: "refresh", - expires: 1, - metadata: { code: "1234" }, + expect((yield* credentials.list(integrationID))[0]).toEqual( + expect.objectContaining({ + integrationID, + label: "Personal", + value: Credential.OAuth.make({ + type: "oauth", + methodID, + access: "access", + refresh: "refresh", + expires: 1, + metadata: { code: "1234" }, + }), }), - }) - }).pipe(Effect.provide(connectionLayer(created))) - }) + ) + }), + ) - it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") let closed = false - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, @@ -261,21 +218,17 @@ describe("Integration", () => { expect(closed).toBe(false) yield* integrations.attempt.cancel(attempt.attemptID) expect(closed).toBe(true) - expect(created).toEqual([]) - }).pipe(Effect.provide(connectionLayer(created))) - }) + expect(yield* credentials.list(integrationID)).toEqual([]) + }), + ) - it.effect("completes auto OAuth in the background", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("completes auto OAuth in the background", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("browser") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "Browser" }, @@ -285,7 +238,7 @@ describe("Integration", () => { url: "https://example.com/authorize", instructions: "Sign in", callback: Effect.succeed( - new Credential.OAuth({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }), + Credential.OAuth.make({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }), ), }), }), @@ -297,22 +250,18 @@ describe("Integration", () => { status: "complete", time: attempt.time, }) - expect(created).toHaveLength(1) - }).pipe(Effect.provide(connectionLayer(created))) - }) + expect(yield* credentials.list(integrationID)).toHaveLength(1) + }), + ) - it.effect("expires abandoned OAuth attempts", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("expires abandoned OAuth attempts", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("browser") let closed = false - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "Browser" }, @@ -337,34 +286,12 @@ describe("Integration", () => { time: attempt.time, }) expect(closed).toBe(true) - expect(created).toEqual([]) - }).pipe(Effect.provide(connectionLayer(created))) - }) + expect(yield* credentials.list(integrationID)).toEqual([]) + }), + ) it.effect("projects credential and env connections", () => { const integrationID = Integration.ID.make("acme") - const rows = [ - { - id: Credential.ID.create(), - integrationID, - label: "Work", - value: new Credential.Key({ type: "key", key: "a" }), - }, - { - id: Credential.ID.create(), - integrationID, - label: "Personal", - value: new Credential.Key({ type: "key", key: "b" }), - }, - ] - const projectionLayer = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - list: () => Effect.succeed(rows.map((row) => new Credential.Stored(row))), - }), - ), - ) return Effect.acquireUseRelease( Effect.sync(() => { const previous = process.env.INTEGRATION_TEST_ACME_KEY @@ -375,7 +302,8 @@ describe("Integration", () => { () => Effect.gen(function* () { const integrations = yield* Integration.Service - yield* integrations.update((editor) => + const credentials = yield* Credential.Service + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { @@ -384,23 +312,33 @@ describe("Integration", () => { }, }), ) + const work = yield* credentials.create({ + integrationID, + label: "Work", + value: Credential.Key.make({ type: "key", key: "a" }), + }) + const personal = yield* credentials.create({ + integrationID, + label: "Personal", + value: Credential.Key.make({ type: "key", key: "b" }), + }) // Stored credentials and detected env vars appear as connections. expect((yield* integrations.get(integrationID))?.connections).toEqual([ - { type: "credential", id: rows[0]!.id, label: "Work" }, { type: "credential", - id: rows[1]!.id, + id: personal.id, label: "Personal", }, { type: "env", name: "INTEGRATION_TEST_ACME_KEY" }, ]) - expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({ + expect(yield* integrations.connection.active(integrationID)).toEqual({ type: "credential", - id: rows[1]!.id, + id: personal.id, label: "Personal", }) - }).pipe(Effect.provide(projectionLayer)), + expect(work.id).not.toBe(personal.id) + }), (previous) => Effect.sync(() => { if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY diff --git a/packages/core/test/legacy-event-schema.test.ts b/packages/core/test/legacy-event-schema.test.ts new file mode 100644 index 0000000000..d9a2833b69 --- /dev/null +++ b/packages/core/test/legacy-event-schema.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import { SessionV1 as Wire } from "@opencode-ai/schema/session-v1" +import { SessionV1 } from "../src/v1/session" + +describe("legacy event schema compatibility", () => { + test("Core references canonical SessionV1 definitions", () => { + expect(SessionV1.Event.Created).toBe(Wire.Event.Created) + expect(SessionV1.Event.PartUpdated).toBe(Wire.Event.PartUpdated) + }) + + test("Core retains NamedError constructor identity", () => { + const error = new SessionV1.APIError({ message: "failed", isRetryable: false }) + expect(error).toBeInstanceOf(SessionV1.APIError) + expect(error.toObject()).toEqual({ name: "APIError", data: { message: "failed", isRetryable: false } }) + }) +}) diff --git a/packages/core/test/location-filesystem.test.ts b/packages/core/test/location-filesystem.test.ts index a3ac24a905..f0075db245 100644 --- a/packages/core/test/location-filesystem.test.ts +++ b/packages/core/test/location-filesystem.test.ts @@ -2,10 +2,9 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" import { Effect, Exit, Layer } from "effect" +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 { Ripgrep } from "@opencode-ai/core/ripgrep" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" @@ -13,15 +12,12 @@ import { it } from "./lib/effect" const provide = (directory: string) => Effect.provide( - FileSystem.layer.pipe( - Layer.provide( - Layer.mergeAll( - FSUtil.defaultLayer, - Ripgrep.defaultLayer, - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), - ), - ), - ), + LayerNode.compile(FileSystem.node, [ + [ + Location.node, + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), + ], + ]), ) const withTmp = (f: (directory: string) => Effect.Effect) => diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 69dba2ae0a..2815c32074 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -1,14 +1,22 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Equal, Hash, Layer, Schema } from "effect" -import { Tool } from "@opencode-ai/core/public" +import { DateTime, Effect, Equal, Hash, Schema } from "effect" +import { Tool } from "@opencode-ai/core/tool/tool" +import { define } from "@kilocode/plugin/v2/effect" +import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProjectV2 } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" import { toolDefinitions } from "./lib/tool" @@ -24,37 +32,33 @@ import { Reference } from "../src/reference" import { ToolRegistry } from "../src/tool/registry" import { ApplicationTools } from "../src/tool/application-tools" -const applicationTools = ApplicationTools.layer const it = testEffect( - Layer.merge( - Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer), - LocationServiceMap.layer.pipe( - Layer.provide(applicationTools), - Layer.provide( - Layer.mergeAll( - Project.defaultLayer, - EventV2.defaultLayer, - Credential.defaultLayer, - Credential.layer.pipe(Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh))), - Npm.defaultLayer, - ModelsDev.defaultLayer, - FSUtil.defaultLayer, - Global.defaultLayer, - ), - ), - ), - ), + AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, Database.node, EventV2.node, LocationServiceMap.node])), ) describe("LocationServiceMap", () => { - it.effect("compares equivalent location refs by value", () => - Effect.sync(() => { - const directory = AbsolutePath.make("/project") - expect(Equal.equals(Location.Ref.make({ directory }), Location.Ref.make({ directory }))).toBe(true) - expect(Hash.hash(Location.Ref.make({ directory }))).toBe( - Hash.hash(Location.Ref.make({ directory, workspaceID: undefined })), - ) - }), + it.live("reuses cached services for constructed and decoded location refs", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.scoped( + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const directory = AbsolutePath.make(dir.path) + const constructed = Location.Ref.make({ directory }) + const decoded = Schema.decodeUnknownSync(Location.Ref)({ directory }) + + expect(constructed).toEqual({ directory, workspaceID: undefined }) + expect(decoded).toEqual(constructed) + expect(Equal.equals(constructed, decoded)).toBe(true) + expect(Hash.hash(constructed)).toBe(Hash.hash(decoded)) + expect(yield* locations.contextEffect(constructed)).toBe(yield* locations.contextEffect(decoded)) + }), + ), + ), + ), ) it.live("isolates location state while sharing location policy with catalog", () => @@ -83,18 +87,18 @@ describe("LocationServiceMap", () => { const update = (directory: string) => Effect.gen(function* () { - yield* PluginBoot.Service.use((boot) => boot.wait()) yield* Reference.Service const catalog = yield* Catalog.Service - const transform = yield* catalog.transform() - yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) return { providers: yield* catalog.provider.all(), tools: yield* toolDefinitions(yield* ToolRegistry.Service), } }).pipe( Effect.scoped, - Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), + Effect.provide( + LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })), + ), ) const blockedState = yield* update(blocked.path) @@ -135,4 +139,88 @@ describe("LocationServiceMap", () => { ), ), ) + + it.live("rejects an unavailable selected model during location model resolution", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + yield* Effect.promise(() => + fs.writeFile( + path.join(dir.path, "opencode.json"), + JSON.stringify({ + providers: { + unavailable: { + name: "Unavailable", + api: { type: "native", settings: {} }, + models: { chat: { disabled: true } }, + }, + }, + }), + ), + ) + const failure = yield* SessionRunnerModel.Service.use((models) => + models.resolve( + SessionV2.Info.make({ + id: SessionV2.ID.make("ses_unavailable_model"), + projectID: ProjectV2.ID.global, + title: "test", + model: { + id: ModelV2.ID.make("chat"), + providerID: ProviderV2.ID.make("unavailable"), + }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location, + }), + ), + ).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip) + + expect(failure).toMatchObject({ + _tag: "SessionRunnerModel.ModelUnavailableError", + providerID: "unavailable", + modelID: "chat", + }) + }), + ), + ), + ) + + it.live("installs public plugins into a location", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const reviewer = define({ + id: "reviewer", + effect: (ctx) => + ctx.agent + .transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code" + item.mode = "subagent" + }) + }) + .pipe(Effect.asVoid), + }) + yield* plugins.add(PluginV2.ID.make(reviewer.id), reviewer.effect) + + expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({ + description: "Reviews code", + mode: "subagent", + }) + }).pipe( + Effect.scoped, + Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))), + ), + ), + ), + ) }) diff --git a/packages/core/test/location-mutation.test.ts b/packages/core/test/location-mutation.test.ts index b9fcba0e35..b09be42daa 100644 --- a/packages/core/test/location-mutation.test.ts +++ b/packages/core/test/location-mutation.test.ts @@ -2,7 +2,7 @@ import fs from "fs/promises" import path from "path" import { describe, expect, test } from "bun:test" import { Effect, Layer, Schema } from "effect" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Location } from "@opencode-ai/core/location" import { LocationMutation } from "@opencode-ai/core/location-mutation" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -12,14 +12,12 @@ import { it } from "./lib/effect" function provide(directory: string) { return Effect.provide( - LocationMutation.layer.pipe( - Layer.provide( - Layer.mergeAll( - FSUtil.defaultLayer, - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), - ), - ), - ), + LayerNode.compile(LocationMutation.node, [ + [ + Location.node, + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), + ], + ]), ) } diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index 327c5bff9f..77e9e96745 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -21,7 +22,7 @@ const projectLayer = Layer.succeed( commit: () => Effect.void, }), ) -const it = testEffect(Location.layer(ref).pipe(Layer.provide(projectLayer))) +const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]])) describe("Location", () => { it.effect("resolves the current project and vcs information", () => diff --git a/packages/core/test/model-request.test.ts b/packages/core/test/model-request.test.ts deleted file mode 100644 index c8ac12d470..0000000000 --- a/packages/core/test/model-request.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { ModelRequest } from "@opencode-ai/core/model-request" - -describe("ModelRequest", () => { - test("partitions AI SDK model and models.dev mode options", () => { - expect( - ModelRequest.normalizeAiSdkOptions("@ai-sdk/openai", { - maxOutputTokens: 4096, - temperature: 0.2, - reasoningEffort: "high", - serviceTier: "priority", - custom_extension: { enabled: true }, - }), - ).toEqual({ - generation: { maxTokens: 4096, temperature: 0.2 }, - options: { reasoningEffort: "high", serviceTier: "priority" }, - body: { custom_extension: { enabled: true } }, - }) - }) - - test("keeps unknown-provider options as compatibility fields", () => { - expect(ModelRequest.normalizeAiSdkOptions(undefined, { temperature: 0.2, reasoningEffort: "high" })).toEqual({ - generation: { temperature: 0.2 }, - options: {}, - body: { reasoningEffort: "high" }, - }) - }) - - test("does not consult inherited package-name properties", () => { - expect(ModelRequest.normalizeAiSdkOptions("__proto__", { reasoningEffort: "high" })).toEqual({ - generation: {}, - options: {}, - body: { reasoningEffort: "high" }, - }) - }) - - test("normalizes models.dev wire aliases owned by native protocols", () => { - expect(ModelRequest.normalizeAiSdkOptions("@ai-sdk/openai", { service_tier: "priority" })).toEqual({ - generation: {}, - options: { serviceTier: "priority" }, - body: {}, - }) - }) -}) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index cdec0aac77..cbda7992c2 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -1,11 +1,12 @@ import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" import { Effect, Layer, Ref } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { ModelsDev } from "@opencode-ai/core/models-dev" -import { EventV2 } from "@opencode-ai/core/event" import { it } from "./lib/effect" import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises" import path from "path" @@ -87,13 +88,13 @@ const makeMockClient = (state: Ref.Ref) => ) const buildLayer = (state: Ref.Ref) => - // Layer.fresh is required: ModelsDev.layer is a module-level Layer constant, + // Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant, // and Effect.provide uses a process-global MemoMap by default — without fresh, // every test would reuse the cachedInvalidateWithTTL state from the first run. - Layer.fresh(ModelsDev.layer).pipe( - Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(EventV2.defaultLayer), + Layer.fresh( + AppNodeBuilder.build(ModelsDev.node, [ + [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))], + ]), ) const writeCacheText = (text: string, mtimeMs?: number) => @@ -157,15 +158,12 @@ describe("ModelsDev Service", () => { Effect.gen(function* () { yield* writeCacheText("{") const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + const context = yield* Layer.build(buildLayer(state)) const result = yield* Effect.acquireUseRelease( Effect.sync(() => { Flag.KILO_DISABLE_MODELS_FETCH = false }), - () => - provided( - state, - ModelsDev.Service.use((s) => s.get()), - ), + () => ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)), () => Effect.sync(() => { Flag.KILO_DISABLE_MODELS_FETCH = true diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index 5f7fbb16d3..92beb1fa53 100644 --- a/packages/core/test/move-session.test.ts +++ b/packages/core/test/move-session.test.ts @@ -3,52 +3,35 @@ import { $ } from "bun" import fs from "fs/promises" import path from "path" import { eq } from "drizzle-orm" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { Database } from "@opencode-ai/core/database/database" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Git } from "@opencode-ai/core/git" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events)) -const projector = SessionProjector.layer.pipe(Layer.provide(database), Layer.provide(events)) -const project = Project.layer.pipe( - Layer.provide(database), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(directories), -) -const store = SessionStore.layer.pipe(Layer.provide(database)) -const sessions = SessionV2.layer.pipe( - Layer.provide(database), - Layer.provide(events), - Layer.provide(project), - Layer.provide(store), - Layer.provide(SessionExecution.noopLayer), -) -const layer = MoveSession.layer.pipe( - Layer.provide(database), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(events), - Layer.provide(project), - Layer.provide(sessions), -) const it = testEffect( - Layer.mergeAll(layer, database, events, directories, project, projector, store, SessionExecution.noopLayer, sessions), + AppNodeBuilder.build( + LayerNode.group([ + MoveSession.node, + Database.node, + EventV2.node, + ProjectDirectories.node, + Project.node, + SessionProjector.node, + SessionStore.node, + ]), + ), ) function abs(input: string) { diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index c149116cd5..7e4a5763bf 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -1,12 +1,10 @@ import fs from "fs/promises" import path from "path" import { describe, expect, test } from "bun:test" -import { NodeFileSystem } from "@effect/platform-node" -import { Effect, Layer, Option } from "effect" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { Effect, Option } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/core/global" import { Npm } from "@opencode-ai/core/npm" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { tmpdir } from "./fixture/tmpdir" const win = process.platform === "win32" @@ -21,12 +19,7 @@ const writePackage = (dir: string, pkg: Record) => ) const npmLayer = (cache: string) => - Npm.layer.pipe( - Layer.provide(EffectFlock.layer), - Layer.provide(FSUtil.layer), - Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })), - Layer.provide(NodeFileSystem.layer), - ) + AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]]) describe("Npm.sanitize", () => { test("keeps normal scoped package specs unchanged", () => { @@ -60,7 +53,7 @@ describe("Npm.add", () => { return yield* npm.add(spec) }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise) - expect(Option.isSome(entry.entrypoint)).toBe(true) + expect(entry.entrypoint).toBeDefined() }) }) diff --git a/packages/core/test/oauth-page.test.ts b/packages/core/test/oauth-page.test.ts new file mode 100644 index 0000000000..880a6892f0 --- /dev/null +++ b/packages/core/test/oauth-page.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test" +import { OauthCallbackPage } from "../src/oauth/page" + +describe("OauthCallbackPage", () => { + test("escapes bootstrap options embedded in the inline script", () => { + const html = OauthCallbackPage.bootstrap({ + provider: `xAI`, + tokenPath: `/token`, + }) + + expect(html.match(/<\/script>/g)).toHaveLength(1) + expect(html).toContain(`xAI\\u003c/script>\\u003cscript>alert(\\\"provider\\\")\\u003c/script>`) + expect(html).toContain(`/token\\u003c/script>\\u003cscript>alert(\\\"path\\\")\\u003c/script>`) + }) +}) diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index ebe06400e9..a556f1fce2 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -2,6 +2,8 @@ import { describe, expect } from "bun:test" import { Deferred, Effect, Fiber, Layer } from "effect" 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" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { PermissionV2 } from "@opencode-ai/core/permission" @@ -12,37 +14,28 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionTable } from "@opencode-ai/core/session/sql" -import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionStore } from "@opencode-ai/core/session/store" import { eq } from "drizzle-orm" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") const current = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/project") })), ) -const events = EventV2.layer.pipe(Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) -const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), - Layer.provide(Project.defaultLayer), - Layer.provide(SessionExecution.noopLayer), +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([ + Database.node, + EventV2.node, + SessionStore.node, + PermissionSaved.node, + AgentV2.node, + PermissionV2.node, + ]), + [[Location.node, current]], + ), ) -const saved = PermissionSaved.layer.pipe(Layer.provide(database)) -const layer = PermissionV2.locationLayer.pipe( - Layer.provideMerge(database), - Layer.provideMerge(store), - Layer.provideMerge(events), - Layer.provideMerge(current), - Layer.provideMerge(sessions), - Layer.provideMerge(SessionExecution.noopLayer), - Layer.provideMerge(saved), -) -const it = testEffect(layer) function setup(rules: PermissionV2.Ruleset = []) { return Effect.gen(function* () { @@ -74,8 +67,7 @@ function setup(rules: PermissionV2.Ruleset = []) { function setRules(rules: PermissionV2.Ruleset) { return Effect.gen(function* () { const agents = yield* AgentV2.Service - const update = yield* agents.transform() - yield* update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("test"), (agent) => { agent.permissions = [...rules] }), @@ -130,7 +122,7 @@ describe("PermissionV2", () => { Effect.gen(function* () { yield* setup([{ action: "read", resource: "*", effect: "allow" }]) const agents = yield* AgentV2.Service - yield* agents.update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.permissions.push({ action: "read", resource: "*", effect: "deny" }) }), @@ -139,7 +131,7 @@ describe("PermissionV2", () => { expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" }) expect(yield* service.ask(assertion({ agent: AgentV2.ID.make("reviewer") }))).toMatchObject({ effect: "deny" }) - yield* agents.update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.permissions = [] }), @@ -187,8 +179,7 @@ describe("PermissionV2", () => { .run() .pipe(Effect.orDie) const agents = yield* AgentV2.Service - const update = yield* agents.transform() - yield* update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.permissions = [{ action: "todowrite", resource: "*", effect: "allow" }] }), @@ -214,7 +205,7 @@ describe("PermissionV2", () => { .run() .pipe(Effect.orDie) const agents = yield* AgentV2.Service - yield* agents.update((editor) => { + yield* agents.transform((editor) => { editor.remove(AgentV2.ID.make("test")) editor.remove(AgentV2.ID.make("build")) }) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index d89d531147..2c822d65bd 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,90 +1,71 @@ import { describe, expect } from "bun:test" -import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect, Exit, Fiber } from "effect" +import { define } from "@kilocode/plugin/v2/effect" +import { AgentV2 } from "@opencode-ai/core/agent" import { PluginV2 } from "@opencode-ai/core/plugin" -import { State } from "@opencode-ai/core/state" -import { it } from "./lib/effect" +import { testEffect } from "./lib/effect" +import { PluginTestLayer } from "./plugin/fixture" -const events = Layer.mock(EventV2.Service)({ - publish: (definition, data) => - Effect.succeed({ - id: EventV2.ID.make("evt_plugin_test"), - type: definition.type, - data, - }), -}) -const plugins = PluginV2.layer.pipe(Layer.provide(events)) - -function state() { - return State.create({ - initial: () => ({ values: [] as string[] }), - editor: (draft) => ({ - add: (value: string) => draft.values.push(value), - }), - }) -} +const it = testEffect(PluginTestLayer) describe("PluginV2", () => { - it.effect("closes plugin-owned scopes when the registry layer finalizes", () => + it.effect("waits for a plugin and returns immediately once active", () => Effect.gen(function* () { - const values = state() - const layerScope = yield* Scope.fork(yield* Scope.Scope) - const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service) + const plugins = yield* PluginV2.Service + const id = PluginV2.ID.make("waited") + const waiting = yield* plugins.wait(id).pipe(Effect.forkChild) - yield* plugin.add({ - id: PluginV2.ID.make("scoped"), - effect: Effect.gen(function* () { - const transform = yield* values.transform() - yield* transform((editor) => editor.add("scoped")) - }), - }) - expect(values.get().values).toEqual(["scoped"]) - - yield* Scope.close(layerScope, Exit.void) - expect(values.get().values).toEqual([]) + yield* plugins.add(id, () => Effect.void) + yield* Fiber.join(waiting) + yield* plugins.wait(id) }), ) - it.effect("serializes same-ID additions and leaves one removable attachment", () => + it.effect("propagates plugin activation defects to waiters", () => Effect.gen(function* () { - const values = state() - const layerScope = yield* Scope.fork(yield* Scope.Scope) - const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service) - const id = PluginV2.ID.make("shared") - const firstStarted = yield* Deferred.make() - const releaseFirst = yield* Deferred.make() + const plugins = yield* PluginV2.Service + const id = PluginV2.ID.make("failed") + const waiting = yield* plugins.wait(id).pipe(Effect.exit, Effect.forkChild) - const first = yield* plugin - .add({ - id, - effect: Effect.gen(function* () { - const transform = yield* values.transform() - yield* transform((editor) => editor.add("first")) - yield* Deferred.succeed(firstStarted, undefined) - yield* Deferred.await(releaseFirst) - }), + const added = yield* plugins.add(id, () => Effect.die("boom")).pipe(Effect.exit) + const pending = yield* Fiber.join(waiting) + const later = yield* plugins.wait(id).pipe(Effect.exit) + + expect(Exit.isFailure(added)).toBe(true) + expect(Exit.isFailure(pending)).toBe(true) + expect(Exit.isFailure(later)).toBe(true) + }), + ) + + it.effect("adds, replaces, and removes plugins", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + let description = "first" + + const managed = () => + define({ + id: "managed", + effect: (ctx) => + ctx.agent + .transform((agents) => + agents.update("configured", (agent) => { + agent.description = description + }), + ) + .pipe(Effect.asVoid), }) - .pipe(Effect.forkChild) - yield* Deferred.await(firstStarted) - const second = yield* plugin - .add({ - id, - effect: Effect.gen(function* () { - const transform = yield* values.transform() - yield* transform((editor) => editor.add("second")) - }), - }) - .pipe(Effect.forkChild({ startImmediately: true })) - expect(values.get().values).toEqual(["first"]) + yield* plugins.add(PluginV2.ID.make("managed"), managed().effect) - yield* Deferred.succeed(releaseFirst, undefined) - yield* Fiber.join(first) - yield* Fiber.join(second) - expect(values.get().values).toEqual(["second"]) + expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first") - yield* plugin.remove(id) - expect(values.get().values).toEqual([]) + description = "second" + yield* plugins.add(PluginV2.ID.make("managed"), managed().effect) + expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second") + + yield* plugins.remove(PluginV2.ID.make("managed")) + expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined() }), ) }) diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index 099e182518..d163cd903a 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -1,28 +1,31 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { CommandV2 } from "@opencode-ai/core/command" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Location } from "@opencode-ai/core/location" import { CommandPlugin } from "@opencode-ai/core/plugin/command" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" +import { host } from "./host" const directory = AbsolutePath.make("/repo/packages/app") const project = AbsolutePath.make("/repo") -const it = testEffect( - CommandV2.locationLayer.pipe( - Layer.provide( - Layer.succeed(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project }))), - ), - ), +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory }, { projectDirectory: project })), ) +const it = testEffect(AppNodeBuilder.build(CommandV2.node, [[Location.node, locationLayer]])) describe("CommandPlugin.Plugin", () => { it.effect("registers built-in init and review commands", () => Effect.gen(function* () { const command = yield* CommandV2.Service - yield* CommandPlugin.Plugin.effect.pipe( - Effect.provideService(CommandV2.Service, command), + yield* CommandPlugin.Plugin.effect( + host({ + command: { transform: command.transform, reload: command.reload }, + }), + ).pipe( Effect.provideService( Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project })), diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts new file mode 100644 index 0000000000..0d5b52180a --- /dev/null +++ b/packages/core/test/plugin/fixture.ts @@ -0,0 +1,52 @@ +import { AgentV2 } from "@opencode-ai/core/agent" +import { AISDK } from "@opencode-ai/core/aisdk" +import { Catalog } from "@opencode-ai/core/catalog" +import { CommandV2 } from "@opencode-ai/core/command" +import { Credential } from "@opencode-ai/core/credential" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +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 { FileSystem } from "@opencode-ai/core/filesystem" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Integration } from "@opencode-ai/core/integration" +import { Location } from "@opencode-ai/core/location" +import { Npm } from "@opencode-ai/core/npm" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { Reference } from "@opencode-ai/core/reference" +import { SkillV2 } from "@opencode-ai/core/skill" +import { Effect, Layer } from "effect" +import { tempLocationLayer } from "../fixture/location" + +const npmLayer = Layer.succeed( + Npm.Service, + Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint: undefined }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), + }), +) + +export const PluginTestLayer = AppNodeBuilder.build( + LayerNode.group([ + FileSystem.node, + FSUtil.node, + Location.node, + Npm.node, + Credential.node, + EventV2.node, + LayerNodePlatform.httpClient, + PluginV2.node, + AgentV2.node, + AISDK.node, + Catalog.node, + CommandV2.node, + Integration.node, + Reference.node, + SkillV2.node, + ]), + [ + [Location.node, tempLocationLayer], + [Npm.node, npmLayer], + ], +) diff --git a/packages/core/test/plugin/fixtures/config-effect-plugin.ts b/packages/core/test/plugin/fixtures/config-effect-plugin.ts new file mode 100644 index 0000000000..ca391fed34 --- /dev/null +++ b/packages/core/test/plugin/fixtures/config-effect-plugin.ts @@ -0,0 +1,15 @@ +import { define } from "@kilocode/plugin/v2/effect" +import { Effect } from "effect" + +export default define({ + id: "config-effect-plugin", + effect: (ctx) => + ctx.agent + .transform((agents) => { + agents.update("effect-configured", (agent) => { + agent.description = ctx.options.description + agent.mode = "subagent" + }) + }) + .pipe(Effect.asVoid), +}) diff --git a/packages/core/test/plugin/fixtures/config-promise-plugin.ts b/packages/core/test/plugin/fixtures/config-promise-plugin.ts new file mode 100644 index 0000000000..0617c04050 --- /dev/null +++ b/packages/core/test/plugin/fixtures/config-promise-plugin.ts @@ -0,0 +1,13 @@ +import { define } from "@kilocode/plugin/v2/promise" + +export default define({ + id: "config-promise-plugin", + setup: async (ctx) => { + await ctx.agent.transform((agents) => { + agents.update("configured", (agent) => { + agent.description = ctx.options.description + agent.mode = "subagent" + }) + }) + }, +}) diff --git a/packages/core/test/plugin/fixtures/invalid-plugin.ts b/packages/core/test/plugin/fixtures/invalid-plugin.ts new file mode 100644 index 0000000000..b1c6ea436a --- /dev/null +++ b/packages/core/test/plugin/fixtures/invalid-plugin.ts @@ -0,0 +1 @@ +export default {} diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts new file mode 100644 index 0000000000..63679da6b5 --- /dev/null +++ b/packages/core/test/plugin/host.ts @@ -0,0 +1,303 @@ +import type { PluginContext } from "@kilocode/plugin/v2/effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" +import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@kilocode/sdk/v2/types" +import { Effect } from "effect" + +type Overrides = Partial> + +export function host(overrides: Overrides = {}): PluginContext { + return { + options: {}, + agent: overrides.agent ?? { + transform: () => Effect.die("unused agent.transform"), + reload: () => Effect.die("unused agent.reload"), + }, + aisdk: overrides.aisdk ?? { + sdk: () => Effect.die("unused aisdk.sdk"), + language: () => Effect.die("unused aisdk.language"), + }, + catalog: overrides.catalog ?? { + transform: () => Effect.die("unused catalog.transform"), + reload: () => Effect.die("unused catalog.reload"), + }, + command: overrides.command ?? { + transform: () => Effect.die("unused command.transform"), + reload: () => Effect.die("unused command.reload"), + }, + integration: overrides.integration ?? { + transform: () => Effect.die("unused integration.transform"), + reload: () => Effect.die("unused integration.reload"), + connection: { + active: () => Effect.die("unused integration.connection.active"), + resolve: () => Effect.die("unused integration.connection.resolve"), + }, + }, + plugin: overrides.plugin ?? { + add: () => Effect.die("unused plugin.add"), + remove: () => Effect.die("unused plugin.remove"), + }, + reference: overrides.reference ?? { + transform: () => Effect.die("unused reference.transform"), + reload: () => Effect.die("unused reference.reload"), + }, + skill: overrides.skill ?? { + transform: () => Effect.die("unused skill.transform"), + reload: () => Effect.die("unused skill.reload"), + }, + } +} + +export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] { + return { + reload: agent.reload, + transform: (callback) => + agent.transform((draft) => + callback({ + list: () => draft.list().map(agentInfo), + get: (id) => { + const value = draft.get(AgentV2.ID.make(id)) + return value && agentInfo(value) + }, + default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)), + update: (id, update) => + draft.update(AgentV2.ID.make(id), (value) => { + const current = agentInfo(value) + update(current) + Object.assign(value, current, { id: AgentV2.ID.make(current.id) }) + }), + remove: (id) => draft.remove(AgentV2.ID.make(id)), + }), + ), + } +} + +export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] { + return { + reload: catalog.reload, + transform: (callback) => + catalog.transform((draft) => + callback({ + provider: { + list: () => + draft.provider.list().map((value) => ({ + provider: providerInfo(value.provider), + models: new Map(Array.from(value.models, ([id, model]) => [id, modelInfo(model)])), + })), + get: (id) => { + const value = draft.provider.get(ProviderV2.ID.make(id)) + return ( + value && { + provider: providerInfo(value.provider), + models: new Map(Array.from(value.models, ([id, model]) => [id, modelInfo(model)])), + } + ) + }, + update: (id, update) => + draft.provider.update(ProviderV2.ID.make(id), (value) => { + const current = providerInfo(value) + update(current) + Object.assign(value, current, { id: ProviderV2.ID.make(current.id) }) + }), + remove: (id) => draft.provider.remove(ProviderV2.ID.make(id)), + }, + model: { + get: (providerID, modelID) => { + const value = draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)) + return value && modelInfo(value) + }, + update: (providerID, modelID, update) => + draft.model.update(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID), (value) => { + const current = modelInfo(value) + update(current) + Object.assign(value, current, { + id: ModelV2.ID.make(current.id), + providerID: ProviderV2.ID.make(current.providerID), + family: current.family === undefined ? undefined : ModelV2.Family.make(current.family), + variants: current.variants.map((variant) => ({ + ...variant, + id: ModelV2.VariantID.make(variant.id), + })), + }) + }), + remove: (providerID, modelID) => + draft.model.remove(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + default: { + get: () => { + const value = draft.model.default.get() + return value && { providerID: value.providerID, modelID: value.modelID } + }, + set: (providerID, modelID) => + draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + }, + }, + }), + ), + } +} + +export function integrationHost(integration: Integration.Interface): PluginContext["integration"] { + return { + reload: integration.reload, + connection: { + active: (id) => integration.connection.active(Integration.ID.make(id)), + resolve: (connection) => + integration.connection.resolve( + connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection, + ), + }, + transform: (callback) => + integration.transform((draft) => + callback({ + list: () => draft.list().map((value) => ({ id: value.id, name: value.name })), + get: (id) => { + const value = draft.get(Integration.ID.make(id)) + return value && { id: value.id, name: value.name } + }, + update: (id, update) => draft.update(Integration.ID.make(id), update), + remove: (id) => draft.remove(Integration.ID.make(id)), + method: { + list: (id) => draft.method.list(Integration.ID.make(id)).map(method), + update: (input) => { + if ("authorize" in input) { + const methodID = Integration.MethodID.make(input.method.id) + const refresh = input.refresh + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, id: methodID }, + authorize: (inputs) => + input.authorize(inputs).pipe( + Effect.map((authorization) => { + if (authorization.mode === "auto") { + return { + ...authorization, + callback: authorization.callback.pipe( + Effect.map((credential) => + Credential.OAuth.make({ + ...credential, + methodID: Integration.MethodID.make(credential.methodID), + }), + ), + ), + } + } + return { + ...authorization, + callback: (code: string) => + authorization.callback(code).pipe( + Effect.map((credential) => + Credential.OAuth.make({ + ...credential, + methodID: Integration.MethodID.make(credential.methodID), + }), + ), + ), + } + }), + ), + ...(refresh + ? { + refresh: (value: Credential.OAuth) => + refresh(value).pipe( + Effect.map((next) => + Credential.OAuth.make({ + ...next, + methodID: Integration.MethodID.make(next.methodID), + }), + ), + ), + } + : {}), + ...(input.label ? { label: input.label } : {}), + }) + return + } + if (input.method.type === "env") { + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, names: [...input.method.names] }, + }) + return + } + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: input.method, + }) + }, + remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)), + }, + }), + ), + } +} + +function method(value: Integration.Method) { + if (value.type === "env") return { type: value.type, names: [...value.names] } + if (value.type === "key") return { type: value.type, label: value.label } + return { + type: value.type, + id: value.id, + label: value.label, + prompts: value.prompts?.map((prompt) => { + if (prompt.type === "text") return { ...prompt } + return { ...prompt, options: prompt.options.map((option) => ({ ...option })) } + }), + } +} + +function internalMethod( + value: IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod, +): Integration.Method { + if (value.type === "env") return value + if (value.type === "key") return value + return { + ...value, + id: Integration.MethodID.make(value.id), + } +} + +function agentInfo(value: AgentV2.Info) { + return { + ...value, + model: value.model && { ...value.model }, + request: { headers: { ...value.request.headers }, body: { ...value.request.body } }, + permissions: value.permissions.map((permission) => ({ ...permission })), + } +} + +function providerInfo(value: ProviderV2.MutableInfo) { + return { + ...value, + api: { ...value.api, settings: value.api.settings && { ...value.api.settings } }, + request: { headers: { ...value.request.headers }, body: { ...value.request.body } }, + } +} + +function modelInfo(value: ModelV2.Info | ModelV2.MutableInfo) { + return { + ...value, + api: { ...value.api, settings: value.api.settings && { ...value.api.settings } }, + capabilities: { + ...value.capabilities, + input: [...value.capabilities.input], + output: [...value.capabilities.output], + }, + request: { + ...value.request, + headers: { ...value.request.headers }, + body: { ...value.request.body }, + }, + variants: value.variants.map((variant) => ({ + ...variant, + headers: { ...variant.headers }, + body: { ...variant.body }, + })), + time: { ...value.time }, + cost: value.cost.map((cost) => ({ ...cost, tier: cost.tier && { ...cost.tier }, cache: { ...cost.cache } })), + limit: { ...value.limit }, + } +} diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index f4a0e16bdd..6f43a97cfa 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -3,46 +3,127 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" -import { Credential } from "@opencode-ai/core/credential" -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" import { EventV2 } from "@opencode-ai/core/event" import { Flag } from "@opencode-ai/core/flag/flag" import { Location } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" import { ModelsDev } from "@opencode-ai/core/models-dev" -import { PluginV2 } from "@opencode-ai/core/plugin" import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev" -import { Policy } from "@opencode-ai/core/policy" +import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" +import { catalogHost, host, integrationHost } from "./host" -const events = EventV2.defaultLayer const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), ) -const plugins = PluginV2.layer.pipe(Layer.provide(events)) -const policy = Policy.layer.pipe(Layer.provide(locationLayer)) -const connections = Credential.layer.pipe( - Layer.fresh, - Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)), - Layer.provide(events), -) -const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections)) -const catalog = Catalog.layer.pipe( - Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)), -) -const layer = Layer.mergeAll( - catalog.pipe(Layer.provide(connections)), - integrations, - connections, - events, - locationLayer, - plugins, -) +const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, EventV2.node]), [ + [Location.node, locationLayer], +]) const it = testEffect(layer) describe("ModelsDevPlugin", () => { + it.effect("projects models.dev modes as separate models instead of variants", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const catalog = yield* Catalog.Service + const models = ModelsDev.Service.of({ + get: () => + Effect.succeed({ + acme: { + id: "acme", + name: "Acme", + env: [], + npm: "@ai-sdk/openai-compatible", + api: "https://api.acme.test/v1", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + family: "gpt", + release_date: "2026-01-01", + attachment: false, + reasoning: true, + temperature: true, + tool_call: true, + cost: { + input: 2.5, + output: 15, + tiers: [ + { + tier: { type: "context", size: 272_000 }, + input: 3, + output: 18, + cache_read: 0.25, + }, + ], + context_over_200k: { input: 5, output: 22.5, cache_read: 0.5 }, + }, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, + experimental: { + modes: { + fast: { + cost: { input: 5, output: 30, cache_read: 0.5 }, + provider: { + headers: { "x-mode": "fast" }, + body: { service_tier: "priority" }, + }, + }, + }, + }, + }, + }, + }, + } satisfies Record), + refresh: () => Effect.void, + }) + + yield* ModelsDevPlugin.effect( + host({ + catalog: catalogHost(catalog), + integration: integrationHost(integrations), + }), + ).pipe(Effect.provideService(ModelsDev.Service, models)) + + const providerID = ProviderV2.ID.make("acme") + const base = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4")) + const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast")) + + expect(base?.variants).toEqual([]) + expect(base?.request.body).toEqual({}) + expect(fast).toMatchObject({ + id: "gpt-5.4-fast", + providerID: "acme", + name: "GPT-5.4 Fast", + api: { id: "gpt-5.4" }, + request: { + headers: { "x-mode": "fast" }, + body: { service_tier: "priority" }, + }, + variants: [], + }) + expect(fast?.cost).toEqual([ + { input: 5, output: 30, cache: { read: 0.5, write: 0 } }, + { + tier: { type: "context", size: 272_000 }, + input: 3, + output: 18, + cache: { read: 0.25, write: 0 }, + }, + { + tier: { type: "context", size: 200_000 }, + input: 5, + output: 22.5, + cache: { read: 0.5, write: 0 }, + }, + ]) + }), + ) + it.effect("registers key methods for providers with environment variables", () => Effect.acquireUseRelease( Effect.sync(() => { @@ -56,8 +137,14 @@ describe("ModelsDevPlugin", () => { }), () => Effect.gen(function* () { - yield* ModelsDevPlugin.effect const integrations = yield* Integration.Service + const catalog = yield* Catalog.Service + yield* ModelsDevPlugin.effect( + host({ + catalog: catalogHost(catalog), + integration: integrationHost(integrations), + }), + ) expect(yield* integrations.list()).toEqual([ new Integration.Info({ id: Integration.ID.make("acme"), @@ -72,7 +159,7 @@ describe("ModelsDevPlugin", () => { connections: [], }), ]) - }).pipe(Effect.provide(ModelsDev.defaultLayer)), + }).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))), (previous) => Effect.sync(() => { Flag.KILO_MODELS_PATH = previous.path diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts new file mode 100644 index 0000000000..3f19370500 --- /dev/null +++ b/packages/core/test/plugin/promise.test.ts @@ -0,0 +1,67 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { PluginPromise } from "@opencode-ai/core/plugin/promise" +import { define } from "@kilocode/plugin/v2/promise" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +describe("fromPromise", () => { + it.effect("loads a promise plugin and registers a transform hook", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + + const promisePlugin = define({ + id: "promise-example", + setup: async (ctx) => { + expect(ctx.options.mode).toBe("strict") + await ctx.agent.transform((draft) => { + draft.update("reviewer", (item) => { + item.description = "Reviews code" + item.mode = "subagent" + }) + }) + }, + }) + + const adapted = PluginPromise.fromPromise(promisePlugin) + yield* adapted.effect({ ...host, options: { mode: "strict" } }) + + expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({ + description: "Reviews code", + mode: "subagent", + }) + }), + ) + + it.effect("disposes a hook registration on request", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + + const promisePlugin = define({ + id: "promise-dispose", + setup: async (ctx) => { + const registration = await ctx.agent.transform((draft) => { + draft.update("temp", (item) => { + item.description = "temporary" + }) + }) + await registration.dispose() + }, + }) + + const adapted = PluginPromise.fromPromise(promisePlugin) + yield* adapted.effect(host) + + expect(yield* agents.get(AgentV2.ID.make("temp"))).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/plugin/provider-alibaba.test.ts b/packages/core/test/plugin/provider-alibaba.test.ts index e2fbb8061a..25d46bf83c 100644 --- a/packages/core/test/plugin/provider-alibaba.test.ts +++ b/packages/core/test/plugin/provider-alibaba.test.ts @@ -1,21 +1,38 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { createAlibaba } from "@ai-sdk/alibaba" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba" -import { it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +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* AlibabaPlugin.effect(host) +}) describe("AlibabaPlugin", () => { it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AlibabaPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("alibaba", "qwen"), package: "@ai-sdk/alibaba", options: { name: "alibaba" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/alibaba", + options: { name: "alibaba" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -23,12 +40,16 @@ describe("AlibabaPlugin", () => { it.effect("ignores non-Alibaba SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AlibabaPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("alibaba", "qwen"), package: "@ai-sdk/openai-compatible", options: { name: "alibaba" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "alibaba" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -36,16 +57,16 @@ describe("AlibabaPlugin", () => { it.effect("matches the old bundled Alibaba SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AlibabaPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-alibaba", "qwen"), - package: "@ai-sdk/alibaba", - options: { name: "custom-alibaba", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/alibaba", + options: { name: "custom-alibaba", apiKey: "test" }, + }) const expected = createAlibaba({ apiKey: "test", ...{ name: "custom-alibaba" } }).languageModel("qwen") const actual = result.sdk?.languageModel("qwen") expect(actual?.provider).toBe(expected.provider) @@ -56,9 +77,13 @@ describe("AlibabaPlugin", () => { it.effect("uses the old default languageModel(api.id) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AlibabaPlugin) - const item = model("alibaba", "alias", { api: { id: ModelV2.ID.make("qwen-plus") } }) - const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {}) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const item = ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("qwen-plus"), type: "aisdk", package: "test-provider" }, + }) + const result = yield* aisdk.runSDK({ model: item, package: "@ai-sdk/alibaba", options: {} }) const language = result.sdk?.languageModel(item.api.id) expect(language?.modelId).toBe("qwen-plus") expect(language?.provider).toBe("alibaba.chat") diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index e1ae5bd679..5d24879e49 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -1,10 +1,63 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +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* AmazonBedrockPlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") { const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID) @@ -28,12 +81,10 @@ function openAIUrl(language: unknown, path: string, modelId: string) { describe("AmazonBedrockPlugin", () => { it.effect("moves endpoint option to api URL", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AmazonBedrockPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const bedrock = provider("amazon-bedrock", { + yield* catalog.transform((catalog) => { + const bedrock = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.amazonBedrock), api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" }, request: { headers: {}, @@ -45,7 +96,8 @@ describe("AmazonBedrockPlugin", () => { item.request = bedrock.request }) }) - const result = yield* catalog.provider.get(ProviderV2.ID.amazonBedrock) + yield* addPlugin() + const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock)) expect(result.api).toEqual({ type: "aisdk", package: "@ai-sdk/amazon-bedrock", @@ -59,22 +111,22 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - bearerToken: "token", - baseURL: "https://base.example", - endpoint: "https://endpoint.example", - region: "us-east-1", - }, + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "token", + baseURL: "https://base.example", + endpoint: "https://endpoint.example", + region: "us-east-1", }, - {}, - ) + }) expect(bedrockBaseURL(result.sdk)).toBe("https://endpoint.example") }), ), @@ -84,21 +136,21 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - bearerToken: "token", - baseURL: "https://base.example", - region: "us-east-1", - }, + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "token", + baseURL: "https://base.example", + region: "us-east-1", }, - {}, - ) + }) expect(bedrockBaseURL(result.sdk)).toBe("https://base.example") }), ), @@ -118,16 +170,20 @@ describe("AmazonBedrockPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { name: "amazon-bedrock" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }) expect(result.sdk).toBeDefined() expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com") }), @@ -138,16 +194,16 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { name: "amazon-bedrock", region: "eu-west-1" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock", region: "eu-west-1" }, + }) expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com") }), ), @@ -157,16 +213,16 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { name: "amazon-bedrock" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }) expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com") }), ), @@ -176,16 +232,16 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { name: "amazon-bedrock" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }) expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com") }), ), @@ -195,24 +251,24 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_ACCESS_KEY_ID: undefined, AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const headers: Array = [] - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - bearerToken: "option-token", - fetch: async (_input: Parameters[0], init?: RequestInit) => { - headers.push(new Headers(init?.headers).get("Authorization")) - return new Response("{}") - }, + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "option-token", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") }, }, - {}, - ) + }) yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" })) expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("option-token") expect(headers).toEqual(["Bearer option-token"]) @@ -224,24 +280,24 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "env-token" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const headers: Array = [] - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - bearerToken: "option-token", - fetch: async (_input: Parameters[0], init?: RequestInit) => { - headers.push(new Headers(init?.headers).get("Authorization")) - return new Response("{}") - }, + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "option-token", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") }, }, - {}, - ) + }) yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" })) expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("env-token") expect(headers).toEqual(["Bearer env-token"]) @@ -253,23 +309,25 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "openai.gpt-5.5", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, - }), - package: "@ai-sdk/amazon-bedrock/mantle", - options: { - name: "amazon-bedrock", - bearerToken: "token", - baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", - region: "us-east-2", + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + api: { + id: ModelV2.ID.make("openai.gpt-5.5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", }, + }), + package: "@ai-sdk/amazon-bedrock/mantle", + options: { + name: "amazon-bedrock", + bearerToken: "token", + baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + region: "us-east-2", }, - {}, - ) + }) const language = result.sdk.responses("openai.gpt-5.5") expect(openAIUrl(language, "/responses", "openai.gpt-5.5")).toBe( "https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses", @@ -281,30 +339,33 @@ describe("AmazonBedrockPlugin", () => { it.effect("selects Mantle APIs without Bedrock cross-region prefixes", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AmazonBedrockPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", "openai.gpt-5.5", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, - }), - sdk: fakeSelectorSdk(calls), - options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" }, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", "openai.gpt-oss-safeguard-120b", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, - }), - sdk: fakeSelectorSdk(calls), - options: { region: "us-east-1" }, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + api: { + id: ModelV2.ID.make("openai.gpt-5.5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", + }, + }), + sdk: fakeSelectorSdk(calls), + options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" }, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")), + api: { + id: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", + }, + }), + sdk: fakeSelectorSdk(calls), + options: { region: "us-east-1" }, + }) expect(calls).toEqual(["responses:openai.gpt-5.5", "chat:openai.gpt-oss-safeguard-120b"]) }), ) @@ -312,18 +373,20 @@ describe("AmazonBedrockPlugin", () => { it.effect("ignores other Bedrock provider subpaths", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/anthropic" }, - }), - package: "@ai-sdk/amazon-bedrock/anthropic", - options: { name: "amazon-bedrock" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/anthropic", + }, + }), + package: "@ai-sdk/amazon-bedrock/anthropic", + options: { name: "amazon-bedrock" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -340,23 +403,27 @@ describe("AmazonBedrockPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const headers: Array = [] - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - fetch: async (_input: Parameters[0], init?: RequestInit) => { - headers.push(new Headers(init?.headers).get("Authorization")) - return new Response("{}") - }, + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") }, }, - {}, - ) + }) yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock-runtime.us-east-1.amazonaws.com/model/test/invoke", { body: "{}", @@ -371,53 +438,53 @@ describe("AmazonBedrockPlugin", () => { it.effect("applies legacy cross-region inference prefixes", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AmazonBedrockPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "eu-west-1" }, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", "global.anthropic.claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "eu-west-1" }, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "ap-northeast-1" }, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "ap-southeast-2" }, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "ap-northeast-1" }, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "ap-southeast-2" }, + }) expect(calls).toEqual([ "languageModel:us.anthropic.claude-sonnet-4-5", "languageModel:eu.anthropic.claude-sonnet-4-5", @@ -432,17 +499,17 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_REGION: "eu-west-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AmazonBedrockPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(calls).toEqual(["languageModel:eu.anthropic.claude-sonnet-4-5"]) }), ), @@ -451,6 +518,7 @@ describe("AmazonBedrockPlugin", () => { it.effect("applies the full legacy cross-region prefix matrix", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const cases = [ { region: "us-east-1", modelID: "amazon.nova-micro-v1:0", expected: "us.amazon.nova-micro-v1:0" }, @@ -518,17 +586,16 @@ describe("AmazonBedrockPlugin", () => { expected: "au.anthropic.claude-sonnet-4-5", }, ] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin() for (const item of cases) { - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", item.modelID), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: item.region }, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)), + api: { id: ModelV2.ID.make(item.modelID), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: item.region }, + }) } expect(calls).toEqual(cases.map((item) => `languageModel:${item.expected}`)) }), @@ -537,17 +604,17 @@ describe("AmazonBedrockPlugin", () => { it.effect("ignores non-Bedrock providers for language selection", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: model("openai", "anthropic.claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "eu-west-1" }, - }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index 85881c3e84..a4ad4d7950 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -1,20 +1,36 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, model, provider } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +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* AnthropicPlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} describe("AnthropicPlugin", () => { it.effect("applies legacy beta headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AnthropicPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("anthropic", { + yield* catalog.transform((catalog) => { + const item = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.anthropic), api: { type: "aisdk", package: "@ai-sdk/anthropic" }, request: { headers: { Existing: "1" }, body: {} }, }) @@ -23,75 +39,56 @@ describe("AnthropicPlugin", () => { draft.request = item.request }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe( + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe( "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", ) - expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers.Existing).toBe("1") + expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers.Existing).toBe("1") }), ) it.effect("ignores non-Anthropic providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AnthropicPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(provider("openai").id, () => {})) - expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"]).toBeUndefined() + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {})) + yield* addPlugin() + expect( + required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"], + ).toBeUndefined() }), ) it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* plugin.add(AnthropicPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("anthropic-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider) - }), + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, }), + package: "@ai-sdk/anthropic", + options: { name: "custom-anthropic", apiKey: "test" }, }) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-anthropic", "claude-sonnet-4-5"), - package: "@ai-sdk/anthropic", - options: { name: "custom-anthropic", apiKey: "test" }, - }, - {}, - ) - expect(providers).toEqual(["custom-anthropic"]) + expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("custom-anthropic") }), ) it.effect("uses the Anthropic provider ID as the SDK name for the bundled Anthropic provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* plugin.add(AnthropicPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("anthropic-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider) - }), + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, }), + package: "@ai-sdk/anthropic", + options: { name: "anthropic", apiKey: "test" }, }) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("anthropic", "claude-sonnet-4-5"), - package: "@ai-sdk/anthropic", - options: { name: "anthropic", apiKey: "test" }, - }, - {}, - ) - expect(providers).toEqual(["anthropic"]) + expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("anthropic") }), ) }) diff --git a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts index 3101052cf9..6b3f6c6a38 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -1,25 +1,76 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +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* AzureCognitiveServicesPlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("AzureCognitiveServicesPlugin", () => { it.effect("maps the resource env var to the Azure SDK baseURL", () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzureCognitiveServicesPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => { item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } }) }) - const result = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")) + yield* addPlugin() + const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) expect(result.api).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible", @@ -34,15 +85,16 @@ describe("AzureCognitiveServicesPlugin", () => { it.effect("leaves baseURL unset without resource env and ignores other providers", () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzureCognitiveServicesPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const azure = provider("azure-cognitive-services", { + yield* catalog.transform((catalog) => { + const azure = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services")), api: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, }) - const openai = provider("openai") + const openai = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "test-provider" }, + }) catalog.provider.update(azure.id, (item) => { item.api = azure.api }) @@ -50,8 +102,9 @@ describe("AzureCognitiveServicesPlugin", () => { item.api = openai.api }) }) - const azure = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")) - const openai = yield* catalog.provider.get(ProviderV2.ID.openai) + yield* addPlugin() + const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) + const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai)) expect(azure.request.body.baseURL).toBeUndefined() expect(azure.api).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible" }) expect(openai.request.body.baseURL).toBeUndefined() @@ -63,17 +116,17 @@ describe("AzureCognitiveServicesPlugin", () => { it.effect("selects chat only for completion URLs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AzureCognitiveServicesPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("azure-cognitive-services", "deployment"), - sdk: fakeSelectorSdk(calls), - options: { useCompletionUrls: true }, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }) expect(calls).toEqual(["chat:deployment"]) }), ) @@ -81,18 +134,25 @@ describe("AzureCognitiveServicesPlugin", () => { it.effect("uses the legacy Azure selector order and provider guard", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AzureCognitiveServicesPlugin) - yield* plugin.trigger( - "aisdk.language", - { model: model("azure-cognitive-services", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) - const ignored = yield* plugin.trigger( - "aisdk.language", - { model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + const ignored = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:deployment"]) expect(ignored.language).toBeUndefined() }), @@ -101,36 +161,34 @@ describe("AzureCognitiveServicesPlugin", () => { it.effect("falls back from responses to messages, chat, then languageModel", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) - yield* plugin.add(AzureCognitiveServicesPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("azure-cognitive-services", "messages-deployment"), - sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel }, - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("azure-cognitive-services", "chat-deployment"), - sdk: { chat: sdk.chat, languageModel: sdk.languageModel }, - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("azure-cognitive-services", "language-deployment"), - sdk: { languageModel: sdk.languageModel }, - options: {}, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("messages-deployment")), + api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel }, + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")), + api: { id: ModelV2.ID.make("chat-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { chat: sdk.chat, languageModel: sdk.languageModel }, + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("language-deployment")), + api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: sdk.languageModel }, + options: {}, + }) expect(calls).toEqual([ "messages:messages-deployment", "chat:chat-deployment", diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index c4bdd806c9..1d8172854c 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -1,50 +1,76 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { Credential } from "@opencode-ai/core/credential" -import { Integration } from "@opencode-ai/core/integration" -import { Database } from "@opencode-ai/core/database/database" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" import { testEffect } from "../lib/effect" -import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" +import { PluginTestLayer } from "./fixture" -const database = Database.layerFromPath(":memory:").pipe(Layer.fresh) -const preferences = Credential.layer.pipe(Layer.provide(database)) -const accounts = Layer.merge( - Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)), - preferences, -) -const itWithAccount = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(accounts), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), - ), - Layer.provideMerge(npmLayer), - ), -) +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* AzurePlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("AzurePlugin", () => { it.effect("resolves resourceName from env", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.azure, (item) => { item.api = { type: "aisdk", package: "@ai-sdk/azure" } }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), ) @@ -52,12 +78,10 @@ describe("AzurePlugin", () => { it.effect("keeps explicit resourceName over env and ignores other providers", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const azure = provider("azure", { + yield* catalog.transform((catalog) => { + const azure = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.azure), api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: "from-config" } }, }) @@ -67,51 +91,20 @@ describe("AzurePlugin", () => { }) catalog.provider.update(ProviderV2.ID.openai, () => {}) }) - expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config") - expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined() + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config") + expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined() }), ), ) - itWithAccount.effect("prefers account resourceName over env", () => - withEnv( - { - AZURE_RESOURCE_NAME: "from-env", - }, - () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const catalog = yield* Catalog.Service - yield* credentials.create({ - integrationID: Integration.ID.make("azure"), - value: new Credential.Key({ - type: "key", - key: "key", - metadata: { resourceName: "from-account" }, - }), - }) - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - catalog.provider.update(ProviderV2.ID.azure, (item) => { - item.api = { type: "aisdk", package: "@ai-sdk/azure" } - }) - }) - expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-account") - }), - ), - ) - it.effect("falls back to env when configured resourceName is blank", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const azure = provider("azure", { + yield* catalog.transform((catalog) => { + const azure = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.azure), api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: "" } }, }) @@ -120,7 +113,8 @@ describe("AzurePlugin", () => { item.request = azure.request }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), ) @@ -128,12 +122,10 @@ describe("AzurePlugin", () => { it.effect("falls back to env when configured resourceName is whitespace", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const azure = provider("azure", { + yield* catalog.transform((catalog) => { + const azure = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.azure), api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: " " } }, }) @@ -142,7 +134,8 @@ describe("AzurePlugin", () => { item.request = azure.request }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), ) @@ -151,16 +144,16 @@ describe("AzurePlugin", () => { withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AzurePlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("azure", "deployment"), - package: "@ai-sdk/azure", - options: { name: "azure", baseURL: "https://proxy.example.com/openai" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/azure", + options: { name: "azure", baseURL: "https://proxy.example.com/openai" }, + }) expect(result.sdk).toBeDefined() }), ), @@ -169,14 +162,17 @@ describe("AzurePlugin", () => { it.effect("rejects missing resourceName when baseURL is not configured", () => withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service - yield* plugin.add(AzurePlugin) - const exit = yield* plugin - .trigger( - "aisdk.sdk", - { model: model("azure", "deployment"), package: "@ai-sdk/azure", options: { name: "azure" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const exit = yield* aisdk + .runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/azure", + options: { name: "azure" }, + }) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") }), @@ -186,13 +182,17 @@ describe("AzurePlugin", () => { it.effect("selects chat only for completion URLs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AzurePlugin) - yield* plugin.trigger( - "aisdk.language", - { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }) expect(calls).toEqual(["chat:deployment"]) }), ) @@ -200,13 +200,17 @@ describe("AzurePlugin", () => { it.effect("selects chat from per-call useCompletionUrls", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AzurePlugin) - yield* plugin.trigger( - "aisdk.language", - { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }) expect(calls).toEqual(["chat:deployment"]) }), ) @@ -214,19 +218,18 @@ describe("AzurePlugin", () => { it.effect("ignores model useCompletionUrls when per-call option is unset", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AzurePlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("azure", "deployment", { - request: { headers: {}, body: { useCompletionUrls: true } }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + request: { headers: {}, body: { useCompletionUrls: true } }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:deployment"]) }), ) @@ -234,18 +237,25 @@ describe("AzurePlugin", () => { it.effect("uses the legacy Azure selector order and provider guard", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AzurePlugin) - yield* plugin.trigger( - "aisdk.language", - { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) - const ignored = yield* plugin.trigger( - "aisdk.language", - { model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + const ignored = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:deployment"]) expect(ignored.language).toBeUndefined() }), @@ -254,26 +264,29 @@ describe("AzurePlugin", () => { it.effect("falls back through the legacy Azure selector order", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const make = (method: string) => (id: string) => { calls.push(`${method}:${id}`) return { modelId: id, provider: method, specificationVersion: "v3" } } - yield* plugin.add(AzurePlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("azure", "messages-deployment"), - sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") }, - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { model: model("azure", "language-deployment"), sdk: { languageModel: make("languageModel") }, options: {} }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")), + api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") }, + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")), + api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: make("languageModel") }, + options: {}, + }) expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"]) }), ) diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index aa192274d6..adcc1779b0 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -1,12 +1,24 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, model } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const cerebrasOptions: Record[] = [] +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* CerebrasPlugin.effect(host) +}) void mock.module("@ai-sdk/cerebras", () => ({ createCerebras: (options: Record) => { @@ -21,17 +33,15 @@ void mock.module("@ai-sdk/cerebras", () => ({ describe("CerebrasPlugin", () => { it.effect("applies the legacy integration header", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CerebrasPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => { item.api = { type: "aisdk", package: "@ai-sdk/cerebras" } item.request.headers.Existing = "1" }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras")))?.request.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode", }) @@ -40,12 +50,10 @@ describe("CerebrasPlugin", () => { it.effect("ignores non-Cerebras providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CerebrasPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) - expect((yield* catalog.provider.get(ProviderV2.ID.make("groq"))).request.headers).toEqual({}) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("groq")))?.request.headers).toEqual({}) }), ) @@ -53,16 +61,23 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(CerebrasPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), - package: "@ai-sdk/cerebras", - options: { name: "custom-cerebras", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/cerebras", + options: { name: "custom-cerebras", apiKey: "test" }, + }) expect(cerebrasOptions).toEqual([{ name: "custom-cerebras", apiKey: "test" }]) expect(result.sdk.languageModel("llama-4-scout-17b-16e-instruct").provider).toBe("custom-cerebras") }), @@ -72,16 +87,23 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(CerebrasPlugin) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), - package: "@ai-sdk/cerebras", - options: { name: "configured-cerebras", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/cerebras", + options: { name: "configured-cerebras", apiKey: "test" }, + }) expect(cerebrasOptions).toEqual([{ name: "configured-cerebras", apiKey: "test" }]) }), ) @@ -90,16 +112,23 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(CerebrasPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), - package: "@ai-sdk/groq", - options: { name: "custom-cerebras", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/groq", + options: { name: "custom-cerebras", apiKey: "test" }, + }) expect(cerebrasOptions).toEqual([]) expect(result.sdk).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index 72ad5da33f..008b9a46a3 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -1,8 +1,43 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway" -import { it, model, withEnv } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +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* CloudflareAIGatewayPlugin.effect(host) +}) + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} const aiGatewayCalls: Record[] = [] const unifiedCalls: string[] = [] @@ -78,16 +113,16 @@ describe("CloudflareAIGatewayPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk.languageModel("openai/gpt-5")).toBeDefined() }), ), @@ -98,24 +133,24 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - metadata: { invoked_by: "test", project: "opencode" }, - cacheTtl: 300, - cacheKey: "cache-key", - skipCache: true, - collectLog: false, - }, + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + metadata: { invoked_by: "test", project: "opencode" }, + cacheTtl: 300, + cacheKey: "cache-key", + skipCache: true, + collectLog: false, }, - {}, - ) + }) expect(aiGatewayCalls).toHaveLength(1) expect(aiGatewayCalls[0]).toEqual({ @@ -142,22 +177,22 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - headers: { - "cf-aig-metadata": JSON.stringify({ invoked_by: "header", project: "opencode" }), - }, + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + headers: { + "cf-aig-metadata": JSON.stringify({ invoked_by: "header", project: "opencode" }), }, }, - {}, - ) + }) expect(aiGatewayCalls[0]?.options).toMatchObject({ metadata: { invoked_by: "header", project: "opencode" }, @@ -171,22 +206,22 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - accountId: "auth-account", - gateway: "auth-gateway", - apiKey: "auth-token", - }, + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + accountId: "auth-account", + gateway: "auth-gateway", + apiKey: "auth-token", }, - {}, - ) + }) expect(aiGatewayCalls[0]).toMatchObject({ accountId: "env-account", @@ -208,22 +243,22 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - accountId: "auth-account", - gatewayId: "auth-gateway", - apiKey: "auth-token", - }, + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + accountId: "auth-account", + gatewayId: "auth-gateway", + apiKey: "auth-token", }, - {}, - ) + }) expect(aiGatewayCalls[0]).toMatchObject({ accountId: "auth-account", @@ -239,17 +274,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(aiGatewayCalls[0]).toMatchObject({ apiKey: "cf-aig-token" }) }), @@ -261,17 +296,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) @@ -284,17 +319,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) @@ -313,17 +348,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, + }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) @@ -336,17 +371,24 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "anthropic/claude-sonnet-4-5"), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("anthropic/claude-sonnet-4-5"), + ), + api: { + id: ModelV2.ID.make("anthropic/claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk.languageModel("anthropic/claude-sonnet-4-5")).toEqual({ modelId: { unifiedModelID: "anthropic/claude-sonnet-4-5" }, @@ -364,17 +406,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index 208ab8710d..d1eeaa581e 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -1,36 +1,63 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { Credential } from "@opencode-ai/core/credential" -import { Integration } from "@opencode-ai/core/integration" -import { Database } from "@opencode-ai/core/database/database" +import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { Location } from "@opencode-ai/core/location" -import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { testEffect } from "../lib/effect" -import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper" +import { PluginTestLayer } from "./fixture" -const database = Database.layerFromPath(":memory:").pipe(Layer.fresh) -const preferences = Credential.layer.pipe(Layer.provide(database)) -const accounts = Layer.merge( - Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)), - preferences, -) -const itWithAccount = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(accounts), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), - ), - Layer.provideMerge(npmLayer), - ), -) +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* CloudflareWorkersAIPlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") { return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel( @@ -56,24 +83,23 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const catalog = yield* Catalog.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.api = { type: "aisdk", package: "test-provider" } }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")) - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-workers-ai", "@cf/model", { api: provider.api }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, - }, - {}, - ) + yield* addPlugin() + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))) + const sdk = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { id: ModelV2.ID.make("@cf/model"), ...provider.api }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, + }) expect(provider.api).toEqual({ type: "aisdk", package: "test-provider", @@ -87,16 +113,14 @@ describe("CloudflareWorkersAIPlugin", () => { it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" } }), ) - expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ type: "aisdk", package: "test-provider", url: "https://proxy.example/v1", @@ -109,73 +133,38 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-workers-ai", "@cf/model", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://proxy.example/v1", + }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" }, + }) expect(cloudflareURL(result.sdk)).toBe("https://proxy.example/v1/chat/completions") }), ), ) - itWithAccount.effect("falls back to account metadata when account env is absent", () => - withEnv( - { - CLOUDFLARE_ACCOUNT_ID: undefined, - CLOUDFLARE_API_KEY: undefined, - }, - () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const catalog = yield* Catalog.Service - yield* credentials.create({ - integrationID: Integration.ID.make("cloudflare-workers-ai"), - value: new Credential.Key({ - type: "key", - key: "account-key", - metadata: { accountId: "account-acct" }, - }), - }) - yield* plugin.add(CloudflareWorkersAIPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => - catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { - provider.api = { type: "aisdk", package: "test-provider" } - }), - ) - expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).request.body).toMatchObject( - { - apiKey: "account-key", - accountId: "account-acct", - }, - ) - }), - ), - ) - it.effect("uses env account ID over configured account ID", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.api = { type: "aisdk", package: "test-provider" } provider.request.body.accountId = "configured-acct" }), ) - expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ type: "aisdk", package: "test-provider", url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1", @@ -188,23 +177,26 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-workers-ai", "@cf/model", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" }, - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "cloudflare-workers-ai", - apiKey: "auth-key", - baseURL: "https://proxy.example/v1", - headers: { custom: "header" }, + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://proxy.example/v1", }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "cloudflare-workers-ai", + apiKey: "auth-key", + baseURL: "https://proxy.example/v1", + headers: { custom: "header" }, }, - {}, - ) + }) const headers = yield* Effect.promise(() => Promise.resolve(cloudflareHeaders(result.sdk))) expect(headers.authorization).toBe("Bearer env-key") expect(headers.custom).toBe("header") @@ -217,25 +209,24 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-workers-ai", "@cf/model", { - api: { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", - }, - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "cloudflare-workers-ai", - baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "cloudflare-workers-ai", + baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", }, - {}, - ) + }) expect(cloudflareURL(result.sdk)).toBe( "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/chat/completions", ) @@ -246,17 +237,17 @@ describe("CloudflareWorkersAIPlugin", () => { it.effect("selects languageModel with the API model ID", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(CloudflareWorkersAIPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: model("cloudflare-workers-ai", "alias", { api: { id: ModelV2.ID.make("@cf/api-model") } }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("@cf/api-model"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(result.language).toBeDefined() expect(calls).toEqual(["languageModel:@cf/api-model"]) }), @@ -266,18 +257,21 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-workers-ai", "@cf/model", { - api: { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://proxy.example/v1" }, - }), - package: "@ai-sdk/anthropic", - options: { name: "cloudflare-workers-ai" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/anthropic", + url: "https://proxy.example/v1", + }, + }), + package: "@ai-sdk/anthropic", + options: { name: "cloudflare-workers-ai" }, + }) expect(result.sdk).toBeUndefined() }), ), diff --git a/packages/core/test/plugin/provider-cohere.test.ts b/packages/core/test/plugin/provider-cohere.test.ts index a646c3eb6c..c001ad935f 100644 --- a/packages/core/test/plugin/provider-cohere.test.ts +++ b/packages/core/test/plugin/provider-cohere.test.ts @@ -1,11 +1,37 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const cohereOptions: Record[] = [] +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* CoherePlugin.effect(host) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} void mock.module("@ai-sdk/cohere", () => ({ createCohere: (options: Record) => { @@ -24,20 +50,27 @@ describe("CoherePlugin", () => { it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CoherePlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { model: model("cohere", "command"), package: "@ai-sdk/openai-compatible", options: { name: "cohere" } }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), + api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cohere" }, + }) expect(ignored.sdk).toBeUndefined() - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("cohere", "command"), package: "@ai-sdk/cohere", options: { name: "cohere" } }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), + api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/cohere", + options: { name: "cohere" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -45,16 +78,16 @@ describe("CoherePlugin", () => { it.effect("uses the model provider ID as the bundled SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CoherePlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-cohere", "command-r-plus"), - package: "@ai-sdk/cohere", - options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-cohere"), ModelV2.ID.make("command-r-plus")), + api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/cohere", + options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, + }) expect(cohereOptions.at(-1)).toEqual({ name: "custom-cohere", @@ -68,14 +101,18 @@ describe("CoherePlugin", () => { it.effect("leaves language selection to the default languageModel fallback", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) - yield* plugin.add(CoherePlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { model: model("cohere", "alias", { api: { id: ModelV2.ID.make("command-r-plus") } }), sdk, options: {} }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, + }), + sdk, + options: {}, + }) expect(result.language).toBeUndefined() expect(calls).toEqual([]) diff --git a/packages/core/test/plugin/provider-deepinfra.test.ts b/packages/core/test/plugin/provider-deepinfra.test.ts index 43db117a90..9cb9a4866c 100644 --- a/packages/core/test/plugin/provider-deepinfra.test.ts +++ b/packages/core/test/plugin/provider-deepinfra.test.ts @@ -1,20 +1,27 @@ -import { describe, expect, mock } from "bun:test" -import { Effect, Layer } from "effect" import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" +import { describe, expect, mock } from "bun:test" +import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { it, model } from "./provider-helper" +import { PluginTestLayer } from "./fixture" -const itAISDK = testEffect( - Layer.provideMerge(AISDK.layer, PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))), -) -const deepinfraOptions: Record[] = [] +const it = testEffect(PluginTestLayer) +const deepinfraOptions: Record[] = [] const deepinfraLanguageModels: string[] = [] +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* DeepInfraPlugin.effect(host) +}) + void mock.module("@ai-sdk/deepinfra", () => ({ - createDeepInfra: (options: Record) => { + createDeepInfra: (options: Record) => { const captured = { ...options } deepinfraOptions.push(captured) return { @@ -36,12 +43,16 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* plugin.add(DeepInfraPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -50,16 +61,16 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* plugin.add(DeepInfraPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-deepinfra", "model"), - package: "@ai-sdk/deepinfra", - options: { name: "custom-deepinfra", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "custom-deepinfra", apiKey: "test" }, + }) expect(result.sdk.languageModel("model").provider).toBe("custom-deepinfra.chat") expect(deepinfraOptions).toEqual([{ name: "custom-deepinfra", apiKey: "test" }]) }), @@ -69,16 +80,16 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* plugin.add(DeepInfraPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("deepinfra", "model"), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra", apiKey: "test" }, + }) expect(result.sdk.languageModel("model").provider).toBe("deepinfra.chat") expect(deepinfraOptions).toEqual([{ name: "deepinfra", apiKey: "test" }]) }), @@ -88,7 +99,8 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* plugin.add(DeepInfraPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() const packages = [ "unmatched-package", "@ai-sdk/deepinfra-compatible", @@ -96,35 +108,50 @@ describe("DeepInfraPlugin", () => { ] yield* Effect.forEach(packages, (item) => Effect.gen(function* () { - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { model: model("deepinfra", "model"), package: item, options: { name: "deepinfra" } }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: item, + options: { name: "deepinfra" }, + }) expect(ignored.sdk).toBeUndefined() }), ) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }) expect(result.sdk).toBeDefined() expect(deepinfraOptions).toEqual([{ name: "deepinfra" }]) }), ) - itAISDK.effect("uses the default languageModel selection for DeepInfra models", () => + it.effect("uses the default languageModel selection for DeepInfra models", () => Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(DeepInfraPlugin) - const language = yield* aisdk.language( - model("deepinfra", "meta-llama/Llama-3.3-70B-Instruct", { - api: { type: "aisdk", package: "@ai-sdk/deepinfra" }, + yield* addPlugin() + const sdkEvent = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct")), + api: { + id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), + type: "aisdk", + package: "@ai-sdk/deepinfra", + }, }), - ) + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }) + const result = yield* aisdk.runLanguage({ model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options }) + const language = result.language ?? result.sdk.languageModel(result.model.api.id) expect(language.provider).toBe("deepinfra.chat") expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"]) }), diff --git a/packages/core/test/plugin/provider-dynamic.test.ts b/packages/core/test/plugin/provider-dynamic.test.ts index 2b0be314ba..9eda035143 100644 --- a/packages/core/test/plugin/provider-dynamic.test.ts +++ b/packages/core/test/plugin/provider-dynamic.test.ts @@ -1,37 +1,38 @@ import { Npm } from "@opencode-ai/core/npm" import { describe, expect } from "bun:test" -import { Cause, Effect, Layer, Option } from "effect" +import { Cause, Effect, Layer } from "effect" import fs from "fs/promises" import os from "os" import path from "path" import { fileURLToPath } from "url" import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { fixtureProvider, it, model, npmLayer } from "./provider-helper" +import { PluginTestLayer } from "./fixture" +const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href const fixtureProviderPath = fileURLToPath(fixtureProvider) -const itWithAISDK = testEffect( - AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), -) +const it = testEffect(PluginTestLayer) +const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.build(AISDK.node))) -function npmEntrypointLayer(entrypoint: Option.Option) { - return Layer.succeed( - Npm.Service, - Npm.Service.of({ - add: () => Effect.succeed({ directory: "", entrypoint }), - install: () => Effect.void, - which: () => Effect.succeed(Option.none()), - }), - ) +function npmEntrypoint(entrypoint?: string) { + return Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), + }) } -function dynamicPlugin(layer = npmLayer) { - return { id: DynamicProviderPlugin.id, effect: DynamicProviderPlugin.effect.pipe(Effect.provide(layer)) } -} +const addPlugin = Effect.fn(function* (npm?: Npm.Interface) { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* DynamicProviderPlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm ?? (yield* Npm.Service))) +}) function tempEntrypoint(source: string) { return Effect.acquireRelease( @@ -48,17 +49,16 @@ function tempEntrypoint(source: string) { describe("DynamicProviderPlugin", () => { it.effect("creates an SDK from a provider factory export", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service - yield* plugin.add(dynamicPlugin()) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom", "test-model"), - package: fixtureProvider, - options: { name: "custom", marker: "dynamic" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), + package: fixtureProvider, + options: { name: "custom", marker: "dynamic" }, + }) expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom" }) expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "dynamic", name: "custom" } }) }), @@ -66,63 +66,65 @@ describe("DynamicProviderPlugin", () => { it.effect("does not override an SDK already supplied by an earlier plugin", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const sdk = { marker: "existing" } - yield* plugin.add(dynamicPlugin()) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom", "test-model"), - package: fixtureProvider, - options: { name: "custom", marker: "dynamic" }, - }, - { sdk }, - ) + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), + package: fixtureProvider, + options: { name: "custom", marker: "dynamic" }, + sdk, + }) expect(result.sdk).toBe(sdk) }), ) it.effect("injects the provider ID as the SDK factory name", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service - yield* plugin.add(dynamicPlugin()) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-provider", "test-model"), - package: fixtureProvider, - options: { name: "custom-provider", marker: "dynamic" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), + package: fixtureProvider, + options: { name: "custom-provider", marker: "dynamic" }, + }) expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom-provider" }) }), ) it.effect("loads npm packages through their resolved import entrypoint", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.some(fixtureProviderPath)))) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("npm-provider", "test-model"), - package: "fixture-provider", - options: { name: "npm-provider", marker: "npm" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin(npmEntrypoint(fixtureProviderPath)) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: "fixture-provider" }, + }), + package: "fixture-provider", + options: { name: "npm-provider", marker: "npm" }, + }) expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "npm", name: "npm-provider" } }) }), ) itWithAISDK.effect("wraps missing npm entrypoint failures as AISDK init errors", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.none()))) + yield* addPlugin(npmEntrypoint()) const exit = yield* aisdk - .language(model("missing-entrypoint", "alias", { api: { type: "aisdk", package: "fixture-provider" } })) + .language( + ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" }, + }), + ) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") @@ -131,12 +133,14 @@ describe("DynamicProviderPlugin", () => { itWithAISDK.effect("wraps dynamic import failures as AISDK init errors", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const exit = yield* aisdk .language( - model("bad-import", "alias", { api: { type: "aisdk", package: "file:///missing/provider-factory.js" } }), + ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "file:///missing/provider-factory.js" }, + }), ) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") @@ -149,9 +153,14 @@ describe("DynamicProviderPlugin", () => { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n") - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.some(tmp.entrypoint)))) + yield* addPlugin(npmEntrypoint(tmp.entrypoint)) const exit = yield* aisdk - .language(model("missing-factory", "alias", { api: { type: "aisdk", package: "fixture-provider" } })) + .language( + ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" }, + }), + ) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") @@ -162,9 +171,10 @@ describe("DynamicProviderPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const language = yield* aisdk.language( - model("custom", "alias", { + ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")), api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider }, }), ) diff --git a/packages/core/test/plugin/provider-gateway.test.ts b/packages/core/test/plugin/provider-gateway.test.ts index 8ee69b7dd4..c6a0427cdb 100644 --- a/packages/core/test/plugin/provider-gateway.test.ts +++ b/packages/core/test/plugin/provider-gateway.test.ts @@ -1,11 +1,24 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway" -import { it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const gatewayCalls: Record[] = [] const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"] +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* GatewayPlugin.effect(host) +}) mock.module("@ai-sdk/gateway", () => ({ createGateway(options: Record) { @@ -27,12 +40,16 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GatewayPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("gateway", "model"), package: "@ai-sdk/gateway", options: { name: "gateway" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/gateway", + options: { name: "gateway" }, + }) expect(result.sdk).toBeDefined() expect(gatewayCalls).toHaveLength(1) }), @@ -42,17 +59,21 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("vercel", "anthropic/claude-sonnet-4"), - package: "@ai-sdk/gateway", - options: { name: "vercel", apiKey: "test-key" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make("anthropic/claude-sonnet-4")), + api: { + id: ModelV2.ID.make("anthropic/claude-sonnet-4"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/gateway", + options: { name: "vercel", apiKey: "test-key" }, + }) expect(gatewayCalls).toEqual([{ name: "vercel", apiKey: "test-key" }]) expect(result.sdk.languageModel("anthropic/claude-sonnet-4").provider).toBe("vercel") @@ -63,21 +84,28 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() for (const modelID of vercelGatewayModels) { - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { model: model("vercel", modelID), package: "@ai-sdk/vercel", options: { name: "vercel" } }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), + api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/vercel", + options: { name: "vercel" }, + }) expect(ignored.sdk).toBeUndefined() - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("vercel", modelID), package: "@ai-sdk/gateway", options: { name: "vercel" } }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), + api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/gateway", + options: { name: "vercel" }, + }) expect(result.sdk).toBeDefined() } diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index f16b177e69..beef89ae8c 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -1,35 +1,65 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +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* GithubCopilotPlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("GithubCopilotPlugin", () => { it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GithubCopilotPlugin) - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("github-copilot", "gpt-5"), - package: "@ai-sdk/openai-compatible", - options: { name: "github-copilot" }, - }, - {}, - ) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("github-copilot", "gpt-5"), - package: "@ai-sdk/github-copilot", - options: { name: "github-copilot" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const ignored = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "github-copilot" }, + }) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/github-copilot", + options: { name: "github-copilot" }, + }) expect(ignored.sdk).toBeUndefined() expect(result.sdk).toBeDefined() }), @@ -38,17 +68,17 @@ describe("GithubCopilotPlugin", () => { it.effect("selects languageModel when responses and chat are absent", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("github-copilot", "claude-sonnet-4"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(calls).toEqual(["languageModel:claude-sonnet-4"]) }), ) @@ -56,17 +86,17 @@ describe("GithubCopilotPlugin", () => { it.effect("selects languageModel with the API model ID when responses and chat are absent", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("github-copilot", "alias", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(calls).toEqual(["languageModel:claude-sonnet-4"]) }), ) @@ -74,33 +104,49 @@ describe("GithubCopilotPlugin", () => { it.effect("uses responses for gpt-5 models except gpt-5-mini", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) - yield* plugin.trigger( - "aisdk.language", - { model: model("github-copilot", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { model: model("github-copilot", "gpt-5.1-codex"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { model: model("github-copilot", "gpt-4o"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { model: model("github-copilot", "gpt-5-mini"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { model: model("github-copilot", "gpt-5-mini-2025-08-07"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")), + api: { id: ModelV2.ID.make("gpt-5.1-codex"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")), + api: { id: ModelV2.ID.make("gpt-4o"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")), + api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")), + api: { id: ModelV2.ID.make("gpt-5-mini-2025-08-07"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([ "responses:gpt-5", "responses:gpt-5.1-codex", @@ -114,67 +160,63 @@ describe("GithubCopilotPlugin", () => { it.effect("uses the API model ID when selecting responses or chat", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("github-copilot", "default", { api: { id: ModelV2.ID.make("gpt-5") } }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("github-copilot", "small", { api: { id: ModelV2.ID.make("gpt-5-mini") } }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("github-copilot", "sonnet", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")), + api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:gpt-5", "chat:gpt-5-mini", "chat:claude-sonnet-4"]) }), ) it.effect("disables gpt-5-chat-latest before Copilot language selection", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GithubCopilotPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( - (yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled, + required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))) + .enabled, ).toBe(false) }), ) it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GithubCopilotPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( - (yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled, + required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))) + .enabled, ).toBe(true) }), ) @@ -182,13 +224,17 @@ describe("GithubCopilotPlugin", () => { it.effect("ignores non-Copilot providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { model: model("openai", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index dab52a1f7f..f9880844d3 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,26 +1,45 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" -import { Effect, Layer } from "effect" -import { Credential } from "@opencode-ai/core/credential" -import { Integration } from "@opencode-ai/core/integration" -import { Database } from "@opencode-ai/core/database/database" +import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" import { testEffect } from "../lib/effect" -import { it, model, npmLayer, withEnv } from "./provider-helper" +import { PluginTestLayer } from "./fixture" const gitlabSDKOptions: Record[] = [] -const database = Database.layerFromPath(":memory:").pipe(Layer.fresh) -const preferences = Credential.layer.pipe(Layer.provide(database)) -const accounts = Layer.merge( - Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)), - preferences, -) +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* GitLabPlugin.effect(host) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} void mock.module("gitlab-ai-provider", () => ({ VERSION: "test-version", @@ -35,17 +54,6 @@ void mock.module("gitlab-ai-provider", () => ({ isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact", })) -const itWithAccount = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(accounts), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/") }))), - ), - Layer.provideMerge(npmLayer), - ), -) - describe("GitLabPlugin", () => { it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () => withEnv( @@ -57,12 +65,16 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GitLabPlugin) - yield* plugin.trigger( - "aisdk.sdk", - { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { name: "gitlab" }, + }) expect(gitlabSDKOptions).toHaveLength(1) expect(gitlabSDKOptions[0].instanceUrl).toBe("https://gitlab.com") expect(gitlabSDKOptions[0].apiKey).toBe("env-token") @@ -90,12 +102,16 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GitLabPlugin) - yield* plugin.trigger( - "aisdk.sdk", - { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { name: "gitlab" }, + }) expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example") }), ), @@ -111,28 +127,28 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GitLabPlugin) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("gitlab", "claude"), - package: "gitlab-ai-provider", - options: { - name: "gitlab", - instanceUrl: "https://configured.gitlab.example", - apiKey: "configured-token", - aiGatewayHeaders: { - "anthropic-beta": "configured-beta", - "x-gitlab-test": "1", - }, - featureFlags: { - duo_agent_platform: false, - custom_flag: true, - }, + const aisdk = yield* AISDK.Service + yield* addPlugin() + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { + name: "gitlab", + instanceUrl: "https://configured.gitlab.example", + apiKey: "configured-token", + aiGatewayHeaders: { + "anthropic-beta": "configured-beta", + "x-gitlab-test": "1", + }, + featureFlags: { + duo_agent_platform: false, + custom_flag: true, }, }, - {}, - ) + }) expect(gitlabSDKOptions[0].instanceUrl).toBe("https://configured.gitlab.example") expect(gitlabSDKOptions[0].apiKey).toBe("configured-token") expect(gitlabSDKOptions[0].aiGatewayHeaders).toMatchObject({ @@ -152,114 +168,45 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GitLabPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("gitlab", "claude"), package: "@ai-sdk/openai", options: { name: "gitlab" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "gitlab" }, + }) expect(result.sdk).toBeUndefined() expect(gitlabSDKOptions).toHaveLength(0) }), ) - itWithAccount.effect("uses active account API token over GITLAB_TOKEN", () => - withEnv( - { - GITLAB_TOKEN: "env-token", - }, - () => - Effect.gen(function* () { - gitlabSDKOptions.length = 0 - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const catalog = yield* Catalog.Service - yield* credentials.create({ - integrationID: Integration.ID.make("gitlab"), - value: new Credential.Key({ type: "key", key: "account-token" }), - }) - yield* plugin.add(GitLabPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("gitlab", "claude"), - package: "gitlab-ai-provider", - options: provider.request.body, - }, - {}, - ) - expect(gitlabSDKOptions[0].apiKey).toBe("account-token") - }), - ), - ) - - itWithAccount.effect("uses active account OAuth access token when no API token exists", () => - withEnv( - { - GITLAB_TOKEN: undefined, - }, - () => - Effect.gen(function* () { - gitlabSDKOptions.length = 0 - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const catalog = yield* Catalog.Service - yield* credentials.create({ - integrationID: Integration.ID.make("gitlab"), - value: new Credential.OAuth({ - type: "oauth", - methodID: Integration.MethodID.make("oauth"), - refresh: "refresh-token", - access: "account-oauth-token", - expires: 9999999999999, - }), - }) - yield* plugin.add(GitLabPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("gitlab", "claude"), - package: "gitlab-ai-provider", - options: provider.request.body, - }, - {}, - ) - expect(gitlabSDKOptions[0].apiKey).toBe("account-oauth-token") - }), - ), - ) - it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: [string, unknown][] = [] - yield* plugin.add(GitLabPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: model("gitlab", "duo-workflow-custom", { - request: { - headers: {}, - body: { workflowRef: "ref", workflowDefinition: "definition" }, - }, - }), - sdk: { - workflowChat: (id: string, options: unknown) => { - calls.push([id, options]) - return { id, options } - }, - agenticChat: () => undefined, + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, + request: { + headers: {}, + body: { workflowRef: "ref", workflowDefinition: "definition" }, }, - options: { featureFlags: { configured: true } }, + }), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } + }, + agenticChat: () => undefined, }, - {}, - ) + options: { featureFlags: { configured: true } }, + }) expect(calls).toEqual([ ["duo-workflow", { featureFlags: { configured: true }, workflowDefinition: "definition" }], ]) @@ -274,23 +221,23 @@ describe("GitLabPlugin", () => { it.effect("uses exact static workflow model ids when the provider recognizes them", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: [string, unknown][] = [] - yield* plugin.add(GitLabPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: model("gitlab", "duo-workflow-exact"), - sdk: { - workflowChat: (id: string, options: unknown) => { - calls.push([id, options]) - return { id, options } - }, - agenticChat: () => undefined, + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")), + api: { id: ModelV2.ID.make("duo-workflow-exact"), type: "aisdk", package: "test-provider" }, + }), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } }, - options: { featureFlags: { configured: true } }, + agenticChat: () => undefined, }, - {}, - ) + options: { featureFlags: { configured: true } }, + }) expect(calls).toEqual([ ["duo-workflow-exact", { featureFlags: { configured: true }, workflowDefinition: undefined }], ]) @@ -301,28 +248,27 @@ describe("GitLabPlugin", () => { it.effect("uses provider feature flags instead of request feature flags", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: [string, unknown][] = [] - yield* plugin.add(GitLabPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("gitlab", "duo-workflow-custom", { - request: { - headers: {}, - body: { featureFlags: { request_flag: true } }, - }, - }), - sdk: { - workflowChat: (id: string, options: unknown) => { - calls.push([id, options]) - return { id, options } - }, - agenticChat: () => undefined, + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, + request: { + headers: {}, + body: { featureFlags: { request_flag: true } }, }, - options: { featureFlags: { configured: true } }, + }), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } + }, + agenticChat: () => undefined, }, - {}, - ) + options: { featureFlags: { configured: true } }, + }) expect(calls).toEqual([["duo-workflow", { featureFlags: { configured: true }, workflowDefinition: undefined }]]) }), ) @@ -330,31 +276,30 @@ describe("GitLabPlugin", () => { it.effect("uses agenticChat with provider aiGatewayHeaders and feature flags for normal models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: [string, unknown][] = [] - yield* plugin.add(GitLabPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("gitlab", "claude", { - request: { headers: { h: "v" }, body: {} }, - }), - sdk: { - workflowChat: () => undefined, - agenticChat: (id: string, options: unknown) => { - const selected = options as { - aiGatewayHeaders?: Record - featureFlags?: Record - } - calls.push([ - id, - { aiGatewayHeaders: { ...selected.aiGatewayHeaders }, featureFlags: { ...selected.featureFlags } }, - ]) - }, + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + request: { headers: { h: "v" }, body: {} }, + }), + sdk: { + workflowChat: () => undefined, + agenticChat: (id: string, options: unknown) => { + const selected = options as { + aiGatewayHeaders?: Record + featureFlags?: Record + } + calls.push([ + id, + { aiGatewayHeaders: { ...selected.aiGatewayHeaders }, featureFlags: { ...selected.featureFlags } }, + ]) }, - options: { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }, }, - {}, - ) + options: { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }, + }) expect(calls).toEqual([ ["claude", { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }], ]) diff --git a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts index bdb6029487..d94321f3bd 100644 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts @@ -1,10 +1,52 @@ +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 { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* (definition: typeof GoogleVertexAnthropicPlugin | typeof GoogleVertexPlugin) { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* definition.effect(host) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function selector(calls: string[]) { + return (id: string) => { + calls.push(`languageModel:${id}`) + return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3 + } +} describe("GoogleVertexAnthropicPlugin", () => { it.effect("resolves legacy project and location env on provider update", () => @@ -19,18 +61,19 @@ describe("GoogleVertexAnthropicPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")) - expect(provider.request.body.project).toBe("cloud-project") - expect(provider.request.body.location).toBe("cloud-location") + yield* addPlugin(GoogleVertexAnthropicPlugin) + expect( + (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project, + ).toBe("cloud-project") + expect( + (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location, + ).toBe("cloud-location") }), ), ) @@ -38,20 +81,21 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("keeps configured project and location over env fallback", () => withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } provider.request.body.project = "configured-project" provider.request.body.location = "configured-location" }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")) - expect(provider.request.body.project).toBe("configured-project") - expect(provider.request.body.location).toBe("configured-location") + yield* addPlugin(GoogleVertexAnthropicPlugin) + expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project).toBe( + "configured-project", + ) + expect( + (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location, + ).toBe("configured-location") }), ), ) @@ -69,16 +113,19 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("google-vertex-anthropic", "claude-sonnet-4-5"), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex-anthropic" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin(GoogleVertexAnthropicPlugin) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make("claude-sonnet-4-5"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex-anthropic" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( "https://aiplatform.googleapis.com/v1/projects/gcp-project/locations/global/publishers/anthropic/models", ) @@ -92,16 +139,19 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("google-vertex-anthropic", "claude-sonnet-4-5"), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex-anthropic" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin(GoogleVertexAnthropicPlugin) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make("claude-sonnet-4-5"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex-anthropic" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( "https://cloud-location-aiplatform.googleapis.com/v1/projects/project/locations/cloud-location/publishers/anthropic/models", ) @@ -112,16 +162,16 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("google-vertex", "claude-sonnet-4-5"), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex", project: "project", location: "eu" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin(GoogleVertexAnthropicPlugin) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex", project: "project", location: "eu" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( "https://aiplatform.eu.rep.googleapis.com/v1/projects/project/locations/eu/publishers/anthropic/models", ) @@ -131,16 +181,16 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("keeps configured baseURL for google-vertex Anthropic models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("google-vertex", "claude-sonnet-4-5"), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin(GoogleVertexAnthropicPlugin) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe("https://proxy.example/v1") }), ) @@ -148,26 +198,25 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("selects google-vertex Anthropic language models through V2 plugins", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexPlugin) - yield* plugin.add(GoogleVertexAnthropicPlugin) - const sdkResult = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("google-vertex", " claude-sonnet-4-5 "), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex", project: "project", location: "us" }, - }, - {}, - ) - const languageResult = yield* plugin.trigger( - "aisdk.language", - { - model: model("google-vertex", " claude-sonnet-4-5 "), - sdk: sdkResult.sdk, - options: {}, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin(GoogleVertexPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) + const sdkResult = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex", project: "project", location: "us" }, + }) + const languageResult = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), + sdk: sdkResult.sdk, + options: {}, + }) const language = languageResult.language as unknown as { config: { baseURL: string }; modelId: string } expect(language.config.baseURL).toBe( "https://aiplatform.us.rep.googleapis.com/v1/projects/project/locations/us/publishers/anthropic/models", @@ -179,17 +228,17 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("trims model IDs before selecting language models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(GoogleVertexAnthropicPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("google-vertex-anthropic", " claude-sonnet-4-5 "), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* addPlugin(GoogleVertexAnthropicPlugin) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: selector(calls) }, + options: {}, + }) expect(calls).toEqual(["languageModel:claude-sonnet-4-5"]) }), ) @@ -197,17 +246,17 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("ignores non Vertex Anthropic providers for language selection", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: model("google-vertex", "claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* addPlugin(GoogleVertexAnthropicPlugin) + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: selector(calls) }, + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index cb23cc452c..388528bd65 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -1,13 +1,65 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const vertexOptions: Record[] = [] const googleAuthOptions: Record[] = [] +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* GoogleVertexPlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} void mock.module("@ai-sdk/google-vertex", () => ({ createVertex: (options: Record) => { @@ -37,11 +89,8 @@ void mock.module("google-auth-library", () => ({ describe("GoogleVertexPlugin", () => { it.effect("ignores OpenAI-compatible providers that are not Google Vertex", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.opencode, (provider) => { provider.api = { type: "aisdk", @@ -50,8 +99,9 @@ describe("GoogleVertexPlugin", () => { } }), ) + yield* addPlugin() - const provider = yield* catalog.provider.get(ProviderV2.ID.opencode) + const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode)) expect(provider.request.body).toEqual({}) }), ) @@ -68,11 +118,8 @@ describe("GoogleVertexPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", @@ -81,7 +128,8 @@ describe("GoogleVertexPlugin", () => { } }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) + yield* addPlugin() + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("google-cloud-project") expect(provider.request.body.location).toBe("google-vertex-location") expect(provider.api).toEqual({ @@ -108,10 +156,9 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { vertexOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", @@ -120,18 +167,20 @@ describe("GoogleVertexPlugin", () => { } }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("google-vertex", "gemini", { - api: { type: "aisdk", package: "@ai-sdk/google-vertex" }, - }), - package: "@ai-sdk/google-vertex", - options: { name: "google-vertex" }, - }, - {}, - ) + yield* addPlugin() + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/google-vertex", + }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google-vertex" }, + }) expect(provider.request.body.project).toBe("vertex-project") expect(provider.api).toEqual({ @@ -157,11 +206,8 @@ describe("GoogleVertexPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", @@ -172,7 +218,8 @@ describe("GoogleVertexPlugin", () => { provider.request.body.location = "global" }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) + yield* addPlugin() + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("config-project") expect(provider.request.body.location).toBe("global") expect(provider.api).toEqual({ @@ -186,11 +233,8 @@ describe("GoogleVertexPlugin", () => { it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", @@ -201,7 +245,8 @@ describe("GoogleVertexPlugin", () => { provider.request.body.location = "eu" }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) + yield* addPlugin() + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.api).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible", @@ -222,17 +267,15 @@ describe("GoogleVertexPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" } provider.request.body.project = "config-project" }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) + yield* addPlugin() + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("config-project") expect(provider.request.body.location).toBe("us-central1") }), @@ -249,18 +292,20 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { vertexOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexPlugin) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("google-vertex", "gemini", { - api: { type: "aisdk", package: "@ai-sdk/google-vertex" }, - }), - package: "@ai-sdk/google-vertex", - options: { name: "google-vertex" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/google-vertex", + }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google-vertex" }, + }) expect(vertexOptions).toHaveLength(1) expect(vertexOptions[0].project).toBe("env-project") expect(vertexOptions[0].location).toBe("env-location") @@ -274,21 +319,18 @@ describe("GoogleVertexPlugin", () => { googleAuthOptions.length = 0 const fetchCalls: { input: Parameters[0]; init?: RequestInit }[] = [] const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("capture-openai-compatible"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.promise(async () => { - if (evt.model.providerID !== "google-vertex") return - if (evt.package !== "@ai-sdk/openai-compatible") return - expect(typeof evt.options.fetch).toBe("function") - await evt.options.fetch("https://vertex.example", { - headers: { "x-test": "1" }, - }) - }), + const aisdk = yield* AISDK.Service + yield* addPlugin() + yield* aisdk.hook.sdk((evt) => + Effect.promise(async () => { + if (evt.model.providerID !== "google-vertex") return + if (evt.package !== "@ai-sdk/openai-compatible") return + expect(typeof evt.options.fetch).toBe("function") + await evt.options.fetch("https://vertex.example", { + headers: { "x-test": "1" }, + }) }), - }) + ) const originalFetch = fetch ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = (async ( input: Parameters[0], @@ -300,17 +342,18 @@ describe("GoogleVertexPlugin", () => { yield* Effect.acquireUseRelease( Effect.void, () => - plugin.trigger( - "aisdk.sdk", - { - model: model("google-vertex", "gemini", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "google-vertex" }, - }, - {}, - ), + aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "google-vertex" }, + }), () => Effect.sync(() => { ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = originalFetch @@ -327,17 +370,17 @@ describe("GoogleVertexPlugin", () => { it.effect("trims model IDs before selecting language models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(GoogleVertexPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("google-vertex", " gemini-2.5-pro "), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")), + api: { id: ModelV2.ID.make(" gemini-2.5-pro "), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(calls).toEqual(["languageModel:gemini-2.5-pro"]) }), ) diff --git a/packages/core/test/plugin/provider-google.test.ts b/packages/core/test/plugin/provider-google.test.ts index 9880ff3ae5..8c10689ca1 100644 --- a/packages/core/test/plugin/provider-google.test.ts +++ b/packages/core/test/plugin/provider-google.test.ts @@ -1,31 +1,37 @@ -import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" +import { describe, expect } from "bun:test" +import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { it, model } from "./provider-helper" +import { PluginTestLayer } from "./fixture" -const itWithAISDK = testEffect( - AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), -) +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* GooglePlugin.effect(host) +}) describe("GooglePlugin", () => { it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GooglePlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-google", "gemini"), - package: "@ai-sdk/google", - options: { name: "custom-google", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")), + api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, + }), + package: "@ai-sdk/google", + options: { name: "custom-google", apiKey: "test" }, + }) expect(result.sdk).toBeDefined() expect(result.sdk?.languageModel("gemini").provider).toBe("custom-google") }), @@ -34,34 +40,39 @@ describe("GooglePlugin", () => { it.effect("ignores non-Google SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GooglePlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("google", "gemini"), package: "@ai-sdk/google-vertex", options: { name: "google" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")), + api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google" }, + }) expect(result.sdk).toBeUndefined() }), ) - itWithAISDK.effect("uses default languageModel loading with provider ID parity", () => + it.effect("uses default languageModel loading with provider ID parity", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(GooglePlugin) - const language = yield* aisdk.language( - model("custom-google", "alias", { - api: { - id: ModelV2.ID.make("gemini-api"), - type: "aisdk", - package: "@ai-sdk/google", - }, - request: { - headers: {}, - body: { apiKey: "test" }, - }, + yield* addPlugin() + const sdkEvent = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("gemini-api"), type: "aisdk", package: "@ai-sdk/google" }, }), - ) + package: "@ai-sdk/google", + options: { name: "custom-google", apiKey: "test" }, + }) + const result = yield* aisdk.runLanguage({ + model: sdkEvent.model, + sdk: sdkEvent.sdk, + options: sdkEvent.options, + }) + const language = result.language ?? result.sdk.languageModel(result.model.api.id) expect(language.modelId).toBe("gemini-api") expect(language.provider).toBe("custom-google") }), diff --git a/packages/core/test/plugin/provider-groq.test.ts b/packages/core/test/plugin/provider-groq.test.ts index c6db66b1cb..ac96ea98a3 100644 --- a/packages/core/test/plugin/provider-groq.test.ts +++ b/packages/core/test/plugin/provider-groq.test.ts @@ -1,28 +1,38 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { createGroq } from "@ai-sdk/groq" -import { Effect, Layer } from "effect" -import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq" -import { it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -const aisdkIt = testEffect( - AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), -) +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* GroqPlugin.effect(host) +}) describe("GroqPlugin", () => { it.effect("creates a Groq SDK for @ai-sdk/groq", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GroqPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("groq", "llama"), package: "@ai-sdk/groq", options: { name: "groq" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq", + options: { name: "groq" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -30,12 +40,16 @@ describe("GroqPlugin", () => { it.effect("ignores non-Groq SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GroqPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("groq", "llama"), package: "@ai-sdk/openai-compatible", options: { name: "groq" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "groq" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -43,12 +57,16 @@ describe("GroqPlugin", () => { it.effect("only matches the bundled @ai-sdk/groq package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GroqPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("groq", "llama"), package: "@ai-sdk/groq/compat", options: { name: "groq" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq/compat", + options: { name: "groq" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -56,16 +74,16 @@ describe("GroqPlugin", () => { it.effect("matches the old bundled Groq SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GroqPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-groq", "llama"), - package: "@ai-sdk/groq", - options: { name: "custom-groq", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq", + options: { name: "custom-groq", apiKey: "test" }, + }) const expected = createGroq({ name: "custom-groq", apiKey: "test" } as Parameters[0] & { name: string }).languageModel("llama") @@ -75,26 +93,29 @@ describe("GroqPlugin", () => { }), ) - aisdkIt.effect("uses the default languageModel(api.id) behavior", () => + it.effect("uses the default languageModel(api.id) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(GroqPlugin) - const result = yield* aisdk.language( - model("groq", "alias", { + yield* addPlugin() + const sdk = createGroq({ name: "groq", apiKey: "test" } as Parameters[0] & { + name: string + }) + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")), api: { id: ModelV2.ID.make("llama-api"), type: "aisdk", package: "@ai-sdk/groq", }, - request: { - headers: {}, - body: { apiKey: "test" }, - }, }), - ) - expect(result.modelId).toBe("llama-api") - expect(result.provider).toBe("groq.chat") + sdk, + options: { name: "groq", apiKey: "test" }, + }) + const language = result.language ?? sdk.languageModel(result.model.api.id) + expect(language.modelId).toBe("llama-api") + expect(language.provider).toBe("groq.chat") }), ) }) diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts deleted file mode 100644 index c15928435b..0000000000 --- a/packages/core/test/plugin/provider-helper.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { Npm } from "@opencode-ai/core/npm" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { expect } from "bun:test" -import { Effect, Layer, Option } from "effect" -import { Catalog } from "@opencode-ai/core/catalog" -import { Integration } from "@opencode-ai/core/integration" -import { Credential } from "@opencode-ai/core/credential" -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" - -export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href -const locationLayer = Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("test") })), -) - -export const npmLayer = Layer.succeed( - Npm.Service, - Npm.Service.of({ - add: () => Effect.succeed({ directory: "", entrypoint: Option.none() }), - install: () => Effect.void, - which: () => Effect.succeed(Option.none()), - }), -) - -export const catalogLayer = Layer.succeed( - Catalog.Service, - Catalog.Service.of({ - transform: () => Effect.die("unexpected catalog.transform"), - provider: { - get: () => Effect.die("unexpected provider.get"), - all: () => Effect.succeed([]), - available: () => Effect.succeed([]), - }, - model: { - get: () => Effect.die("unexpected model.get"), - all: () => Effect.succeed([]), - available: () => Effect.succeed([]), - default: () => Effect.succeed(Option.none()), - small: () => Effect.succeed(Option.none()), - }, - }), -) - -const integrations = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - create: () => Effect.die("unexpected credential creation"), - all: () => Effect.succeed([]), - list: () => Effect.succeed([]), - }), - ), -) - -export const it = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(integrations), - Layer.provideMerge( - Layer.mock(Credential.Service)({ - all: () => Effect.succeed([]), - }), - ), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - Layer.provideMerge(npmLayer), - ), -) - -type ProviderInput = Partial> & { - api?: ProviderV2.Api - request?: ProviderV2.Request -} - -type ModelInput = Partial> & { - api?: (ProviderV2.Api & { id?: ModelV2.ID }) | { id: ModelV2.ID } - request?: ModelV2.Info["request"] -} - -export function provider(providerID: string, options?: ProviderInput) { - return new ProviderV2.Info({ - ...ProviderV2.Info.empty(ProviderV2.ID.make(providerID)), - api: options?.api ?? { - type: "aisdk", - package: "test-provider", - }, - ...options, - request: { - headers: {}, - body: {}, - ...options?.request, - }, - }) -} - -export function model(providerID: string, modelID: string, options?: ModelInput) { - return new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), - ...options, - api: - options?.api && "type" in options.api - ? { id: ModelV2.ID.make(modelID), ...options.api } - : { - id: ModelV2.ID.make(modelID), - ...options?.api, - type: "aisdk", - package: "test-provider", - }, - request: { - headers: {}, - body: {}, - ...options?.request, - }, - }) -} - -export function withEnv(vars: Record, fx: () => Effect.Effect) { - return Effect.acquireUseRelease( - Effect.sync(() => { - const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) - for (const [key, value] of Object.entries(vars)) { - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - return previous - }), - () => fx(), - (previous) => - Effect.sync(() => { - for (const [key, value] of Object.entries(previous)) { - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - }), - ) -} - -export function fakeSelectorSdk(calls: string[]) { - const make = (method: string) => (id: string) => { - calls.push(`${method}:${id}`) - return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 - } - return { - responses: make("responses"), - messages: make("messages"), - chat: make("chat"), - languageModel: make("languageModel"), - } -} - -export function expectPluginRegistered(ids: string[], id: string) { - expect(ids).toContain(PluginV2.ID.make(id)) -} diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index 33da03c032..1df0fd3156 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -2,99 +2,98 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, provider } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* KiloPlugin.effect(host) +}) describe("KiloPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "kilo", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("kilo"))), ) it.effect("applies legacy referer headers only to kilo", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(KiloPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const kilo = provider("kilo", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, - request: { headers: { Existing: "value" }, body: {} }, + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/api/gateway", + } + provider.request = { headers: { Existing: "value" }, body: {} } }) - catalog.provider.update(kilo.id, (draft) => { - draft.api = kilo.api - draft.request = kilo.request - }) - catalog.provider.update(provider("openrouter").id, () => {}) + catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) }), ) it.effect("uses the exact legacy Kilo header casing and set", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(KiloPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("kilo", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/api/gateway", + } }) }) + yield* addPlugin() - const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo")) - expect(result.request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect(result.request.headers).not.toHaveProperty("http-referer") - expect(result.request.headers).not.toHaveProperty("x-title") - expect(result.request.headers).not.toHaveProperty("X-Source") + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty( + "http-referer", + ) + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("x-title") + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("X-Source") }), ) it.effect("uses the legacy provider-id guard instead of endpoint package matching", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(KiloPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const kilo = provider("kilo", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/api/gateway", + } }) - catalog.provider.update(kilo.id, (draft) => { - draft.api = kilo.api - }) - const custom = provider("custom-kilo", { - api: { type: "aisdk", package: "kilo" }, - }) - catalog.provider.update(custom.id, (draft) => { - draft.api = custom.api + catalog.provider.update(ProviderV2.ID.make("custom-kilo"), (provider) => { + provider.api = { type: "aisdk", package: "kilo" } }) }) + yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo")))?.request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 39a643e348..d7f9d0d73d 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -3,78 +3,78 @@ import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, provider } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + const integration = yield* Integration.Service + yield* LLMGatewayPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration)) +}) describe("LLMGatewayPlugin", () => { - const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) { - const integrations = yield* Integration.Service - yield* plugin.add({ - ...LLMGatewayPlugin, - effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), - }) - }) - it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "llmgateway", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("llmgateway"))), ) it.effect("applies legacy referer headers only to enabled llmgateway", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* add(plugin) const integrations = yield* Integration.Service - yield* integrations.update((editor) => { + yield* integrations.transform((editor) => { editor.update(Integration.ID.make("llmgateway"), () => {}) editor.update(Integration.ID.make("openrouter"), () => {}) }) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const llmgateway = provider("llmgateway", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, - request: { headers: { Existing: "value" }, body: {} }, - }) - catalog.provider.update(llmgateway.id, (draft) => { - draft.api = llmgateway.api - draft.request = llmgateway.request + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.llmgateway.io/v1", + } + provider.request = { headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-Source": "opencode", }) - expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) }), ) it.effect("does not apply legacy headers to a disabled llmgateway provider", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* add(plugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("llmgateway", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api + const integrations = yield* Integration.Service + yield* integrations.transform((editor) => { + editor.update(Integration.ID.make("llmgateway"), () => {}) + }) + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => { + provider.disabled = true + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.llmgateway.io/v1", + } }) }) + yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined() - expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.disabled).toBe(true) + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-mistral.test.ts b/packages/core/test/plugin/provider-mistral.test.ts index b442d4f4d6..b1b1bc0638 100644 --- a/packages/core/test/plugin/provider-mistral.test.ts +++ b/packages/core/test/plugin/provider-mistral.test.ts @@ -1,20 +1,38 @@ +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 { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +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* MistralPlugin.effect(host) +}) describe("MistralPlugin", () => { it.effect("creates a Mistral SDK for @ai-sdk/mistral", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(MistralPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "mistral" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -22,16 +40,16 @@ describe("MistralPlugin", () => { it.effect("ignores non-Mistral SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(MistralPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("mistral", "mistral-large"), - package: "@ai-sdk/openai-compatible", - options: { name: "mistral" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "mistral" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -39,22 +57,22 @@ describe("MistralPlugin", () => { it.effect("matches the old bundled Mistral SDK provider name for the bundled provider ID", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const providers: string[] = [] - yield* plugin.add(MistralPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("mistral-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("mistral-large").provider) - }), + yield* addPlugin() + yield* aisdk.hook.sdk((event) => + Effect.sync(() => { + providers.push(event.sdk.languageModel("mistral-large").provider) }), - }) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } }, - {}, ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "mistral" }, + }) expect(result.sdk).toBeDefined() expect(providers).toEqual(["mistral.chat"]) }), @@ -63,26 +81,22 @@ describe("MistralPlugin", () => { it.effect("matches the old bundled Mistral SDK provider name for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const providers: string[] = [] - yield* plugin.add(MistralPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("mistral-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("mistral-large").provider) - }), + yield* addPlugin() + yield* aisdk.hook.sdk((event) => + Effect.sync(() => { + providers.push(event.sdk.languageModel("mistral-large").provider) }), - }) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-mistral", "mistral-large"), - package: "@ai-sdk/mistral", - options: { name: "custom-mistral" }, - }, - {}, ) + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "custom-mistral" }, + }) expect(providers).toEqual(["mistral.chat"]) }), ) @@ -90,14 +104,23 @@ describe("MistralPlugin", () => { it.effect("leaves Mistral language selection on the default sdk.languageModel(api.id) path", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - const sdk = fakeSelectorSdk(calls) - yield* plugin.add(MistralPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { model: model("mistral", "alias", { api: { id: ModelV2.ID.make("mistral-large") } }), sdk, options: {} }, - {}, - ) + const sdk = { + languageModel: (id: string) => { + calls.push(`languageModel:${id}`) + return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3 + }, + } + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + sdk, + options: {}, + }) const language = result.language ?? sdk.languageModel(result.model.api.id) expect(calls).toEqual(["languageModel:mistral-large"]) expect(language).toBeDefined() diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index e4f781e54a..a1c05df335 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -2,66 +2,66 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, provider } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* NvidiaPlugin.effect(host) +}) describe("NvidiaPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "nvidia", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("nvidia"))), ) it.effect("applies NVIDIA tracking headers only to nvidia", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(NvidiaPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const nvidia = provider("nvidia", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, - request: { headers: { Existing: "value" }, body: {} }, + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://integrate.api.nvidia.com/v1", + } + provider.request = { headers: { Existing: "value" }, body: {} } }) - catalog.provider.update(nvidia.id, (draft) => { - draft.api = nvidia.api - draft.request = nvidia.request - }) - catalog.provider.update(provider("openrouter").id, () => {}) + catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "OpenCode", }) - expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) }), ) it.effect("adds billing origin for custom NVIDIA endpoints", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(NvidiaPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("nvidia", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, - request: { headers: {}, body: {} }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://integrate.api.nvidia.com/v1", + } }) }) + yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "OpenCode", @@ -71,25 +71,23 @@ describe("NvidiaPlugin", () => { it.effect("preserves an explicit NVIDIA billing origin header", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(NvidiaPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("nvidia", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, - request: { + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://integrate.api.nvidia.com/v1", + } + provider.request = { headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" }, body: { baseURL: "https://integrate.api.nvidia.com/v1" }, - }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + } }) }) + yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "CustomOrigin", diff --git a/packages/core/test/plugin/provider-openai-compatible.test.ts b/packages/core/test/plugin/provider-openai-compatible.test.ts index e8bf1f7575..4e74a37125 100644 --- a/packages/core/test/plugin/provider-openai-compatible.test.ts +++ b/packages/core/test/plugin/provider-openai-compatible.test.ts @@ -1,28 +1,45 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { OpenAICompatiblePlugin } from "@opencode-ai/core/plugin/provider/openai-compatible" -import { it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +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* OpenAICompatiblePlugin.effect(host) +}) describe("OpenAICompatiblePlugin", () => { it.effect("preserves explicit includeUsage false and defaults it to true", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(OpenAICompatiblePlugin) - const defaulted = yield* plugin.trigger( - "aisdk.sdk", - { model: model("custom", "model"), package: "@ai-sdk/openai-compatible", options: { name: "custom" } }, - {}, - ) - const disabled = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom", "model"), - package: "@ai-sdk/openai-compatible", - options: { name: "custom", includeUsage: false }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const defaulted = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "custom" }, + }) + const disabled = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "custom", includeUsage: false }, + }) expect(defaulted.options.includeUsage).toBe(true) expect(disabled.options.includeUsage).toBe(false) }), @@ -31,16 +48,16 @@ describe("OpenAICompatiblePlugin", () => { it.effect("defaults includeUsage for OpenAI-compatible package matches", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(OpenAICompatiblePlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom", "model"), - package: "file:///tmp/@ai-sdk/openai-compatible-provider.js", - options: { name: "custom" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "file:///tmp/@ai-sdk/openai-compatible-provider.js", + options: { name: "custom" }, + }) expect(result.options.includeUsage).toBe(true) }), ) @@ -48,53 +65,42 @@ describe("OpenAICompatiblePlugin", () => { it.effect("uses the provider ID as the OpenAI-compatible provider name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const observed: string[] = [] - yield* plugin.add(OpenAICompatiblePlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - observed.push(evt.sdk.languageModel("model").provider) - }), + yield* addPlugin() + yield* aisdk.hook.sdk((event) => + Effect.sync(() => { + observed.push(event.sdk.languageModel("model").provider) }), - }) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-provider", "model"), - package: "@ai-sdk/openai-compatible", - options: { name: "custom-provider", baseURL: "https://example.com/v1" }, - }, - {}, ) + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "custom-provider", baseURL: "https://example.com/v1" }, + }) expect(observed).toEqual(["custom-provider.chat"]) }), ) it.effect("does not overwrite an SDK created by an earlier provider-specific plugin", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const sentinel = { languageModel: (modelID: string) => ({ modelID }) } - yield* plugin.add({ - id: PluginV2.ID.make("sentinel"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - evt.sdk = sentinel - }), - }), + yield* aisdk.hook.sdk((event) => { + event.sdk = sentinel + }) + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-workers-ai" }, }) - yield* plugin.add(OpenAICompatiblePlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-workers-ai", "model"), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai" }, - }, - {}, - ) expect(result.sdk).toBe(sentinel) }), ) diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index d30b585b96..31a80f9319 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -1,25 +1,49 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" 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" import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, provider } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -function add(plugin: PluginV2.Interface, integrations: Integration.Interface) { - return plugin.add({ - ...OpenAIPlugin, - effect: OpenAIPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), - }) +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) + const integrations = yield* Integration.Service + yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } } describe("OpenAIPlugin", () => { it.effect("registers browser and headless ChatGPT OAuth methods", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service - yield* add(plugin, yield* Integration.Service) + yield* addPlugin() expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([ { id: Integration.MethodID.make("chatgpt-browser"), @@ -38,16 +62,16 @@ describe("OpenAIPlugin", () => { it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* add(plugin, yield* Integration.Service) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-openai", "gpt-5"), - package: "@ai-sdk/openai", - options: { name: "custom-openai", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "custom-openai", apiKey: "test" }, + }) expect(result.sdk?.responses("gpt-5").provider).toBe("custom-openai.responses") }), ) @@ -55,12 +79,16 @@ describe("OpenAIPlugin", () => { it.effect("ignores non-OpenAI SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* add(plugin, yield* Integration.Service) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("openai", "gpt-5"), package: "@ai-sdk/openai-compatible", options: { name: "openai" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "openai" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -68,19 +96,17 @@ describe("OpenAIPlugin", () => { it.effect("uses the Responses API for language models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* add(plugin, yield* Integration.Service) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: model("openai", "alias", { - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:gpt-5"]) expect(result.language).toBeDefined() }), @@ -89,13 +115,17 @@ describe("OpenAIPlugin", () => { it.effect("ignores non-OpenAI providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* add(plugin, yield* Integration.Service) - const result = yield* plugin.trigger( - "aisdk.language", - { model: model("anthropic", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), @@ -103,36 +133,43 @@ describe("OpenAIPlugin", () => { it.effect("disables gpt-5-chat-latest during catalog transforms", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* add(plugin, yield* Integration.Service) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("openai", { api: { type: "aisdk", package: "@ai-sdk/openai" } }) + yield* catalog.transform((catalog) => { + const item = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "@ai-sdk/openai" }, + }) catalog.provider.update(item.id, (draft) => { draft.api = item.api }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) - expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true) - expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(false) + yield* addPlugin() + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true) + expect( + required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled, + ).toBe(false) }), ) it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* add(plugin, yield* Integration.Service) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("custom-openai") - catalog.provider.update(item.id, () => {}) + yield* catalog.transform((catalog) => { + const item = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.make("custom-openai")), + api: { type: "aisdk", package: "test-provider" }, + }) + catalog.provider.update(item.id, (draft) => { + draft.api = item.api + }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( - (yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled, + required(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))) + .enabled, ).toBe(true) }), ) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 01cabf3581..20af84d02f 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -1,47 +1,224 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Layer, Option } from "effect" +import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" import { Integration } from "@opencode-ai/core/integration" -import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { it, model, provider, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] -const locationLayer = Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("test") })), -) +const it = testEffect(PluginTestLayer) -const pluginWithIntegrations = (integrations: Integration.Interface) => ({ - ...OpencodePlugin, - effect: OpencodePlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + const events = yield* EventV2.Service + const integration = yield* Integration.Service + yield* OpencodePlugin.effect(host).pipe( + Effect.provideService(EventV2.Service, events), + Effect.provideService(Integration.Service, integration), + ) }) +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function eventually( + effect: Effect.Effect, + predicate: (value: A) => boolean, + remaining = 1000, +): Effect.Effect { + return Effect.gen(function* () { + const value = yield* effect + if (predicate(value)) return value + if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value")) + yield* Effect.promise(() => Bun.sleep(1)) + return yield* eventually(effect, predicate, remaining - 1) + }) +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} + +const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] + describe("OpencodePlugin", () => { + it.effect("registers account and service account methods", () => + Effect.gen(function* () { + yield* addPlugin() + expect((yield* (yield* Integration.Service).get(Integration.ID.make("opencode")))?.methods).toEqual([ + { + id: Integration.MethodID.make("device"), + type: "oauth", + label: "OpenCode Console account", + }, + { type: "key", label: "API key (service account)" }, + ]) + }), + ) + + it.live("loads providers and models from the connected OpenCode server", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const authorization: Array = [] + const gate = Promise.withResolvers() + return { + authorization, + release: gate.resolve, + server: Bun.serve({ + port: 0, + fetch: async (request) => { + await gate.promise + authorization.push(request.headers.get("authorization")) + const origin = new URL(request.url).origin + return Response.json({ + config: { + enterprise: { url: origin }, + provider: { + remote: { + name: "Remote", + npm: "@ai-sdk/openai-compatible", + api: `${origin}/v1`, + env: ["REMOTE_API_KEY"], + options: { + apiKey: "{env:REMOTE_API_KEY}", + headers: { "x-org-id": "org" }, + custom: "value", + }, + models: { + model: { + name: "Remote Model", + family: "remote", + release_date: "2026-01-02", + tool_call: true, + modalities: { input: ["text", "image"], output: ["text"] }, + options: { apiKey: "model-secret", temperature: 0.5 }, + variants: { high: { apiKey: "variant-secret", temperature: 0.2 } }, + cost: { input: 1, output: 2, cache_read: 0.1 }, + limit: { context: 1000, output: 100 }, + }, + disabled: { name: "Disabled", status: "deprecated" }, + }, + }, + }, + }, + }) + }, + }), + } + }), + ({ authorization, release, server }) => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const catalog = yield* Catalog.Service + yield* catalog.transform((draft) => { + draft.provider.update(ProviderV2.ID.make("remote"), () => {}) + draft.model.update(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"), () => {}) + }) + yield* credentials.create({ + integrationID: Integration.ID.make("opencode"), + value: Credential.Key.make({ + type: "key", + key: "secret", + metadata: { server: server.url.origin }, + }), + }) + + yield* addPlugin() + expect(authorization).toEqual([]) + release() + + const provider = required( + yield* eventually( + catalog.provider.get(ProviderV2.ID.make("remote")), + (item) => item?.integrationID === Integration.ID.make("opencode"), + ), + ) + expect(provider).toMatchObject({ + name: "Remote", + integrationID: "opencode", + api: { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: `${server.url.origin}/v1`, + }, + }) + expect(provider.request).toEqual({ headers: { "x-org-id": "org" }, body: { custom: "value" } }) + expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined() + + const model = required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model"))) + expect(model).toMatchObject({ + name: "Remote Model", + family: "remote", + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }], + limit: { context: 1000, output: 100 }, + }) + expect(model.request.body).toEqual({ custom: "value", temperature: 0.5 }) + expect(model.variants).toEqual([ + { + id: ModelV2.VariantID.make("high"), + headers: {}, + body: { temperature: 0.2 }, + }, + ]) + expect( + required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("disabled"))).enabled, + ).toBe(false) + expect(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"))).toBeDefined() + expect(authorization).toContain("Bearer secret") + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), + ) + it.effect("uses a public key and disables paid models without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + yield* catalog.transform((catalog) => { + const provider = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = ModelV2.Info.make({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false) + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false) }), ), ) @@ -49,20 +226,25 @@ describe("OpencodePlugin", () => { it.effect("keeps free models without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const free = model("opencode", "free", { cost: cost(0) }) - catalog.model.update(item.id, free.id, (draft) => { - draft.cost = [...free.cost] + yield* catalog.transform((catalog) => { + const provider = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = ModelV2.Info.make({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")), + api: { id: ModelV2.ID.make("free"), type: "aisdk", package: "test-provider" }, + cost: cost(0), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true) + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true) }), ), ) @@ -70,20 +252,27 @@ describe("OpencodePlugin", () => { it.effect("treats output-only cost as free without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) }) - catalog.model.update(item.id, outputOnly.id, (draft) => { - draft.cost = [...outputOnly.cost] + yield* catalog.transform((catalog) => { + const provider = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = ModelV2.Info.make({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")), + api: { id: ModelV2.ID.make("output-only"), type: "aisdk", package: "test-provider" }, + cost: cost(0, 1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(true) + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe( + true, + ) }), ), ) @@ -91,20 +280,25 @@ describe("OpencodePlugin", () => { it.effect("uses OPENCODE_API_KEY as credentials", () => withEnv({ OPENCODE_API_KEY: "secret" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + yield* catalog.transform((catalog) => { + const provider = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = ModelV2.Info.make({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), ) @@ -112,27 +306,32 @@ describe("OpencodePlugin", () => { it.effect("uses configured provider env vars as credentials", () => withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service const integrations = yield* Integration.Service - yield* plugin.add(pluginWithIntegrations(integrations)) - yield* integrations.update((editor) => { + yield* integrations.transform((editor) => { editor.method.update({ integrationID: Integration.ID.make("opencode"), method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] }, }) }) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + yield* catalog.transform((catalog) => { + const provider = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = ModelV2.Info.make({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), ) @@ -140,27 +339,31 @@ describe("OpencodePlugin", () => { it.effect("uses configured apiKey as credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("opencode", { + yield* catalog.transform((catalog) => { + const provider = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, request: { headers: {}, body: { apiKey: "configured" }, }, }) - catalog.provider.update(item.id, (draft) => { - draft.request = item.request + const model = ModelV2.Info.make({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), }) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + catalog.provider.update(provider.id, (draft) => { + draft.request = provider.request + }) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured") - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured") + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), ) @@ -168,20 +371,25 @@ describe("OpencodePlugin", () => { it.effect("ignores non-opencode providers and models", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("openai") - catalog.provider.update(item.id, () => {}) - const paid = model("openai", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + yield* catalog.transform((catalog) => { + const provider = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = ModelV2.Info.make({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined() - expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true) + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined() + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), ) @@ -191,28 +399,25 @@ describe("OpencodePlugin", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.opencode - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [...cost(1, 1)] - model.time.released = DateTime.makeUnsafe(Date.now()) + model.time.released = Date.now() }) catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [...cost(10, 10)] - model.time.released = DateTime.makeUnsafe(Date.now()) + model.time.released = Date.now() }) }) const selected = yield* catalog.model.small(providerID) - expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano")) - }).pipe( - Effect.provide(Catalog.locationLayer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(locationLayer))), - ), + expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano")) + }), ) }) diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index fe8ccb6233..953943673f 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -1,121 +1,112 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, model, provider } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +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* OpenRouterPlugin.effect(host) +}) describe("OpenRouterPlugin", () => { it.effect("is registered so legacy OpenRouter behavior can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "openrouter", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("openrouter"))), ) it.effect("applies legacy referer headers only to openrouter", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpenRouterPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const openrouter = provider("openrouter", { - api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, - request: { headers: { Existing: "value" }, body: {} }, - }) - catalog.provider.update(openrouter.id, (item) => { - item.api = openrouter.api - item.request = openrouter.request + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { + provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" } + provider.request = { headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {}) }) + yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({}) }), ) it.effect("creates an SDK only for the OpenRouter package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(OpenRouterPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("openrouter", "openai/gpt-5"), - package: "@ai-sdk/openai-compatible", - options: { name: "openrouter" }, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "openrouter" }, + }) expect(ignored.sdk).toBeUndefined() - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("custom", "openai/gpt-5"), package: "@openrouter/ai-sdk-provider", options: { name: "custom" } }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@openrouter/ai-sdk-provider", + options: { name: "custom" }, + }) expect(result.sdk).toBeDefined() }), ) it.effect("filters OpenRouter's gpt-5 chat alias", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpenRouterPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const openrouter = provider("openrouter", { - api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, - }) - catalog.provider.update(openrouter.id, (item) => { - item.api = openrouter.api + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { + provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" } }) catalog.provider.update(ProviderV2.ID.openai, () => {}) - for (const item of [ - model("openrouter", "openai/gpt-5-chat"), - model("openrouter", "openai/gpt-5"), - model("openai", "openai/gpt-5-chat"), - ]) { - catalog.model.update(item.providerID, item.id, () => {}) - } + catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat"), () => {}) + catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5"), () => {}) + catalog.model.update(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"), () => {}) }) + yield* addPlugin() - expect( - (yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat"))).enabled, - ).toBe(false) - expect( - (yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5"))).enabled, - ).toBe(true) - expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"))).enabled).toBe(true) + expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe( + false, + ) + expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")))?.enabled).toBe(true) + expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe(true) }), ) it.effect("does not disable gpt-5-chat-latest for non-OpenRouter providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpenRouterPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( (yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"))) - .enabled, + ?.enabled, ).toBe(true) }), ) diff --git a/packages/core/test/plugin/provider-perplexity.test.ts b/packages/core/test/plugin/provider-perplexity.test.ts index 444badd856..a2a3969233 100644 --- a/packages/core/test/plugin/provider-perplexity.test.ts +++ b/packages/core/test/plugin/provider-perplexity.test.ts @@ -1,20 +1,51 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +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* PerplexityPlugin.effect(host) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("PerplexityPlugin", () => { it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(PerplexityPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity", + options: { name: "perplexity" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -22,16 +53,16 @@ describe("PerplexityPlugin", () => { it.effect("ignores packages that are not the bundled Perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(PerplexityPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("perplexity", "sonar"), - package: "@ai-sdk/perplexity-compatible", - options: { name: "perplexity" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity-compatible", + options: { name: "perplexity" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -39,67 +70,51 @@ describe("PerplexityPlugin", () => { it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* plugin.add(PerplexityPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("perplexity-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("sonar").provider) - }), + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, }), + package: "@ai-sdk/perplexity", + options: { name: "perplexity" }, }) - yield* plugin.trigger( - "aisdk.sdk", - { model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } }, - {}, - ) - expect(providers).toEqual(["perplexity"]) + expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") }), ) it.effect("creates bundled Perplexity SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* plugin.add(PerplexityPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("custom-perplexity-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("sonar").provider) - }), + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, }), + package: "@ai-sdk/perplexity", + options: { name: "custom-perplexity" }, }) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-perplexity", "sonar"), - package: "@ai-sdk/perplexity", - options: { name: "custom-perplexity" }, - }, - {}, - ) - expect(providers).toEqual(["perplexity"]) + expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") }), ) it.effect("leaves Perplexity language selection to the default languageModel fallback", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(PerplexityPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: model("perplexity", "alias", { api: { id: ModelV2.ID.make("sonar") } }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-sap-ai-core.test.ts b/packages/core/test/plugin/provider-sap-ai-core.test.ts index 565b9280ab..5056c5dbb7 100644 --- a/packages/core/test/plugin/provider-sap-ai-core.test.ts +++ b/packages/core/test/plugin/provider-sap-ai-core.test.ts @@ -1,10 +1,57 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { Npm } from "@opencode-ai/core/npm" import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core" -import { fixtureProvider, it, model, npmLayer, withEnv } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -const pluginWithNpm = { id: SapAICorePlugin.id, effect: SapAICorePlugin.effect.pipe(Effect.provide(npmLayer)) } +const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href +const it = testEffect(PluginTestLayer) +const npm = Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint: undefined }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), +}) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* SapAICorePlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm)) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + for (const [key, value] of Object.entries(vars)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + return previous + }), + effect, + (previous) => + Effect.sync(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }), + ) +} + +function model(providerID: string) { + return ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")), + api: { id: ModelV2.ID.make("sap-model"), type: "aisdk", package: fixtureProvider }, + }) +} describe("SapAICorePlugin", () => { it.effect("copies serviceKey option into AICORE_SERVICE_KEY but keeps SDK options to deployment metadata", () => @@ -13,16 +60,13 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("sap-ai-core", "sap-model"), - package: fixtureProvider, - options: { name: "sap-ai-core", serviceKey: "service-key" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const sdk = yield* aisdk.runSDK({ + model: model("sap-ai-core"), + package: fixtureProvider, + options: { name: "sap-ai-core", serviceKey: "service-key" }, + }) expect(process.env.AICORE_SERVICE_KEY).toBe("service-key") expect(sdk.sdk.options).toEqual({ deploymentId: "deployment", resourceGroup: "resource-group" }) }), @@ -39,16 +83,13 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("sap-ai-core", "sap-model"), - package: fixtureProvider, - options: { name: "sap-ai-core", serviceKey: "option-service-key" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const sdk = yield* aisdk.runSDK({ + model: model("sap-ai-core"), + package: fixtureProvider, + options: { name: "sap-ai-core", serviceKey: "option-service-key" }, + }) expect(process.env.AICORE_SERVICE_KEY).toBe("env-service-key") expect(sdk.sdk.options).toEqual({ deploymentId: "deployment", resourceGroup: "resource-group" }) }), @@ -61,12 +102,13 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { model: model("sap-ai-core", "sap-model"), package: fixtureProvider, options: { name: "sap-ai-core" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const sdk = yield* aisdk.runSDK({ + model: model("sap-ai-core"), + package: fixtureProvider, + options: { name: "sap-ai-core" }, + }) expect(process.env.AICORE_SERVICE_KEY).toBeUndefined() expect(sdk.sdk.options).toEqual({}) }), @@ -76,17 +118,14 @@ describe("SapAICorePlugin", () => { it.effect("uses the callable SDK for language selection", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) + const aisdk = yield* AISDK.Service + yield* addPlugin() const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), { languageModel() { throw new Error("SAP AI Core should call the SDK directly") }, }) - const language = yield* plugin.trigger( - "aisdk.language", - { model: model("sap-ai-core", "sap-model"), sdk, options: {} }, - {}, - ) + const language = yield* aisdk.runLanguage({ model: model("sap-ai-core"), sdk, options: {} }) expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" }) }), ) @@ -97,27 +136,20 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("openai", "sap-model"), - package: fixtureProvider, - options: { name: "openai", serviceKey: "service-key" }, + const aisdk = yield* AISDK.Service + yield* addPlugin() + const sdk = yield* aisdk.runSDK({ + model: model("openai"), + package: fixtureProvider, + options: { name: "openai", serviceKey: "service-key" }, + }) + const language = yield* aisdk.runLanguage({ + model: model("openai"), + sdk: () => { + throw new Error("SAP AI Core should ignore other providers") }, - {}, - ) - const language = yield* plugin.trigger( - "aisdk.language", - { - model: model("openai", "sap-model"), - sdk: () => { - throw new Error("SAP AI Core should ignore other providers") - }, - options: {}, - }, - {}, - ) + options: {}, + }) expect(process.env.AICORE_SERVICE_KEY).toBeUndefined() expect(sdk.sdk).toBeUndefined() expect(language.language).toBeUndefined() diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index ff5ec4ba45..cd67feb40e 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -1,18 +1,50 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, it as bun_it } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" -import { expectPluginRegistered, it, model, withEnv } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +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* SnowflakeCortexPlugin.effect(host) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} describe("SnowflakeCortexPlugin", () => { it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () => Effect.sync(() => { - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "snowflake-cortex", - ) - const ids = ProviderPlugins.map((p) => p.id as string) + expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("snowflake-cortex")) + const ids = ProviderPlugins.map((p) => p.id) expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible")) }), ) @@ -20,12 +52,16 @@ describe("SnowflakeCortexPlugin", () => { it.effect("ignores non-snowflake-cortex providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("openai", "gpt-4"), package: "@ai-sdk/openai", options: { name: "openai" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")), + api: { id: ModelV2.ID.make("gpt-4"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "openai" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -34,16 +70,16 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("snowflake-cortex", "claude-sonnet-4-6"), - package: "@ai-sdk/openai-compatible", - options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, + }) expect(result.sdk).toBeDefined() }), ), @@ -53,20 +89,20 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("snowflake-cortex", "claude-sonnet-4-6"), - package: "@ai-sdk/openai-compatible", - options: { - name: "snowflake-cortex", - baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", - apiKey: "options-pat", - }, + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "snowflake-cortex", + baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", + apiKey: "options-pat", }, - {}, - ) + }) expect(result.sdk).toBeDefined() }), ), @@ -76,16 +112,16 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("snowflake-cortex", "claude-sonnet-4-6"), - package: "@ai-sdk/openai-compatible", - options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, + }) expect(result.sdk).toBeDefined() }), ), @@ -95,20 +131,20 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("snowflake-cortex", "claude-sonnet-4-6"), - package: "@ai-sdk/openai-compatible", - options: { - name: "snowflake-cortex", - baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", - token: "options-token", - }, + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "snowflake-cortex", + baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", + token: "options-token", }, - {}, - ) + }) expect(result.sdk).toBeDefined() }), ), @@ -118,27 +154,17 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const captured: Record[] = [] - yield* plugin.add(SnowflakeCortexPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - captured.push({ ...evt.options }) - }), + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, }), + package: "@ai-sdk/openai-compatible", + options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, }) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("snowflake-cortex", "claude-sonnet-4-6"), - package: "@ai-sdk/openai-compatible", - options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, - }, - {}, - ) - expect(captured[0]?.includeUsage).toBe(true) + expect(result.options.includeUsage).toBe(true) }), ), ) diff --git a/packages/core/test/plugin/provider-togetherai.test.ts b/packages/core/test/plugin/provider-togetherai.test.ts index 3457c2ac82..56bf7c9642 100644 --- a/packages/core/test/plugin/provider-togetherai.test.ts +++ b/packages/core/test/plugin/provider-togetherai.test.ts @@ -1,19 +1,51 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +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* TogetherAIPlugin.effect(host) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("TogetherAIPlugin", () => { it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(TogetherAIPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/togetherai", + options: { name: "togetherai" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -21,24 +53,27 @@ describe("TogetherAIPlugin", () => { it.effect("matches the old bundled provider package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(TogetherAIPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("togetherai", "model"), - package: "file:///tmp/@ai-sdk/togetherai-provider.js", - options: { name: "togetherai" }, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "file:///tmp/@ai-sdk/togetherai-provider.js", + options: { name: "togetherai" }, + }) expect(ignored.sdk).toBeUndefined() - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/togetherai", + options: { name: "togetherai" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -46,47 +81,44 @@ describe("TogetherAIPlugin", () => { it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const observed: string[] = [] - yield* plugin.add(TogetherAIPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - observed.push(evt.sdk.languageModel("model").provider) - }), + const aisdk = yield* AISDK.Service + yield* addPlugin() + + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, }), + package: "@ai-sdk/togetherai", + options: { name: "custom-togetherai" }, }) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-togetherai", "model"), - package: "@ai-sdk/togetherai", - options: { name: "custom-togetherai" }, - }, - {}, - ) - - expect(observed).toEqual(["togetherai.chat"]) + expect(result.sdk.languageModel("model").provider).toBe("togetherai.chat") }), ) it.effect("defaults language selection to sdk.languageModel with the model API ID", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(TogetherAIPlugin) + yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: model("togetherai", "meta-llama/Llama-3.3-70B-Instruct-Turbo"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("togetherai"), + ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), + ), + api: { + id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), + type: "aisdk", + package: "test-provider", + }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(result.language).toBeUndefined() expect(calls).toEqual([]) diff --git a/packages/core/test/plugin/provider-venice.test.ts b/packages/core/test/plugin/provider-venice.test.ts index ff4a922ab1..3ed6c711c4 100644 --- a/packages/core/test/plugin/provider-venice.test.ts +++ b/packages/core/test/plugin/provider-venice.test.ts @@ -1,19 +1,51 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +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* VenicePlugin.effect(host) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("VenicePlugin", () => { it.effect("creates a Venice SDK for venice-ai-sdk-provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(VenicePlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("venice", "model"), package: "venice-ai-sdk-provider", options: { name: "venice" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "venice-ai-sdk-provider", + options: { name: "venice" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -21,49 +53,42 @@ describe("VenicePlugin", () => { it.effect("uses the model provider ID as the bundled Venice SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const observed: string[] = [] - yield* plugin.add(VenicePlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - observed.push(evt.sdk.languageModel("model").provider) - }), + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, }), + package: "venice-ai-sdk-provider", + options: { name: "custom-venice", apiKey: "test" }, }) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-venice", "model"), - package: "venice-ai-sdk-provider", - options: { name: "custom-venice", apiKey: "test" }, - }, - {}, - ) expect(result.sdk).toBeDefined() - expect(observed).toEqual(["custom-venice.chat"]) + expect(result.sdk.languageModel("model").provider).toBe("custom-venice.chat") }), ) it.effect("only handles the bundled venice-ai-sdk-provider package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(VenicePlugin) - const similar = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("venice", "model"), - package: "file:///tmp/venice-ai-sdk-provider.js", - options: { name: "venice" }, - }, - {}, - ) - const other = yield* plugin.trigger( - "aisdk.sdk", - { model: model("venice", "model"), package: "@ai-sdk/openai-compatible", options: { name: "venice" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const similar = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "file:///tmp/venice-ai-sdk-provider.js", + options: { name: "venice" }, + }) + const other = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "venice" }, + }) expect(similar.sdk).toBeUndefined() expect(other.sdk).toBeUndefined() }), @@ -72,13 +97,17 @@ describe("VenicePlugin", () => { it.effect("leaves Venice language selection to the default languageModel fallback", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(VenicePlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { model: model("venice", "alias"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index fe0e599ffb..eb87b15132 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -1,29 +1,36 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, model, provider } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +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* VercelPlugin.effect(host) +}) describe("VercelPlugin", () => { it.effect("applies legacy lower-case referer headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(VercelPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("vercel", { - api: { type: "aisdk", package: "@ai-sdk/vercel" }, - request: { headers: { Existing: "1" }, body: {} }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => { + provider.api = { type: "aisdk", package: "@ai-sdk/vercel" } + provider.request.headers.Existing = "1" }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).toEqual({ Existing: "1", "http-referer": "https://opencode.ai/", "x-title": "opencode", @@ -33,32 +40,33 @@ describe("VercelPlugin", () => { it.effect("does not add legacy upper-case referer headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(VercelPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("vercel", { api: { type: "aisdk", package: "@ai-sdk/vercel" } }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - }) - }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty( + yield* catalog.transform((catalog) => + catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => { + provider.api = { type: "aisdk", package: "@ai-sdk/vercel" } + }), + ) + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty( "HTTP-Referer", ) - expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty("X-Title") + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty("X-Title") }), ) it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(VercelPlugin) - const event = yield* plugin.trigger( - "aisdk.sdk", - { model: model("custom-vercel", "v0-1.0-md"), package: "@ai-sdk/vercel", options: { name: "custom-vercel" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const event = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")), + api: { id: ModelV2.ID.make("v0-1.0-md"), type: "aisdk", package: "@ai-sdk/vercel" }, + }), + package: "@ai-sdk/vercel", + options: { name: "custom-vercel" }, + }) expect(event.sdk).toBeDefined() expect(event.sdk.languageModel("v0-1.0-md").provider).toBe("vercel.chat") }), @@ -66,12 +74,10 @@ describe("VercelPlugin", () => { it.effect("ignores non-Vercel providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(VercelPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(provider("gateway").id, () => {})) - expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).request.headers).toEqual({}) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gateway"), () => {})) + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway")))?.request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index e505f8538a..c4d1a5ece3 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -1,37 +1,61 @@ +import { AISDK } from "@opencode-ai/core/aisdk" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai" import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { fakeSelectorSdk } from "./provider-helper" +import { PluginTestLayer } from "./fixture" -const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))) +const it = testEffect(PluginTestLayer) -const model = new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), - api: { - id: ModelV2.ID.make("grok-4"), - type: "aisdk", - package: "@ai-sdk/xai", - }, +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) }) +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} + describe("XAIPlugin", () => { it.effect("creates an xAI SDK only for @ai-sdk/xai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(XAIPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { model, package: "@ai-sdk/openai-compatible", options: {} }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + package: "@ai-sdk/openai-compatible", + options: {}, + }) - const result = yield* plugin.trigger("aisdk.sdk", { model, package: "@ai-sdk/xai", options: {} }, {}) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + package: "@ai-sdk/xai", + options: {}, + }) expect(ignored.sdk).toBeUndefined() expect(typeof result.sdk?.responses).toBe("function") @@ -41,52 +65,37 @@ describe("XAIPlugin", () => { it.effect("creates xAI SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] + const aisdk = yield* AISDK.Service + yield* addPlugin() - yield* plugin.add(XAIPlugin) - yield* plugin.add( - PluginV2.define({ - id: PluginV2.ID.make("xai-sdk-name-observer"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { - if (!evt.sdk) return - providers.push(evt.sdk.responses("grok-4").provider) - }), - } - }), + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, }), - ) + package: "@ai-sdk/xai", + options: {}, + }) - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.make("custom-xai") }), - package: "@ai-sdk/xai", - options: {}, - }, - {}, - ) - - expect(providers).toEqual(["xai.responses"]) + expect(result.sdk.responses("grok-4").provider).toBe("xai.responses") }), ) it.effect("uses responses with the model api.id for xAI language models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(XAIPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ ...model, id: ModelV2.ID.make("alias") }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:grok-4"]) expect(result.language).toBeDefined() @@ -96,18 +105,18 @@ describe("XAIPlugin", () => { it.effect("ignores non-xAI providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(XAIPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.openai }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index 101d652615..b9d34a1f5b 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -2,36 +2,45 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, provider } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* ZenmuxPlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} describe("ZenmuxPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "zenmux", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("zenmux"))), ) it.effect("applies the exact legacy Zenmux headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(ZenmuxPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("zenmux", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://zenmux.ai/api/v1", + } }) }) - const result = yield* catalog.provider.get(ProviderV2.ID.make("zenmux")) + yield* addPlugin() + const result = required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))) expect(result.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }) expect(Object.keys(result.request.headers).sort()).toEqual(["HTTP-Referer", "X-Title"]) }), @@ -39,22 +48,20 @@ describe("ZenmuxPlugin", () => { it.effect("merges legacy Zenmux headers with existing headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(ZenmuxPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("zenmux", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, - request: { headers: { Existing: "value" }, body: {} }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://zenmux.ai/api/v1", + } + provider.request.headers.Existing = "value" }) }) + yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", @@ -64,25 +71,20 @@ describe("ZenmuxPlugin", () => { it.effect("lets configured Zenmux legacy headers override defaults", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(ZenmuxPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("zenmux", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, - request: { - headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, - body: {}, - }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://zenmux.ai/api/v1", + } + provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } }) }) + yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ "HTTP-Referer": "https://example.com/", "X-Title": "custom-title", }) @@ -91,23 +93,15 @@ describe("ZenmuxPlugin", () => { it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(ZenmuxPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("openrouter", { - request: { - headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, - body: {}, - }, - }) - catalog.provider.update(item.id, (draft) => { - draft.request = item.request + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { + provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } }) }) + yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({ "HTTP-Referer": "https://example.com/", "X-Title": "custom-title", }) diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 63d028e4ec..61bee856b9 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -1,25 +1,18 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { AgentV2 } from "@opencode-ai/core/agent" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { SkillPlugin } from "@opencode-ai/core/plugin/skill" import { SkillV2 } from "@opencode-ai/core/skill" -import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" import { testEffect } from "../lib/effect" +import { host } from "./host" -const it = testEffect( - SkillV2.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(SkillDiscovery.defaultLayer), - Layer.provideMerge(AgentV2.locationLayer), - ), -) +const it = testEffect(AppNodeBuilder.build(SkillV2.node)) describe("SkillPlugin.Plugin", () => { it.effect("registers the built-in customize-opencode skill", () => Effect.gen(function* () { const skill = yield* SkillV2.Service - yield* SkillPlugin.Plugin.effect.pipe(Effect.provideService(SkillV2.Service, skill)) + yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })) expect(yield* skill.list()).toContainEqual( expect.objectContaining({ diff --git a/packages/core/test/plugin/variant.test.ts b/packages/core/test/plugin/variant.test.ts new file mode 100644 index 0000000000..84af04a4df --- /dev/null +++ b/packages/core/test/plugin/variant.test.ts @@ -0,0 +1,67 @@ +import { describe, expect } from "bun:test" +import { Catalog } from "@opencode-ai/core/catalog" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Location } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" +import { VariantPlugin } from "@opencode-ai/core/plugin/variant" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Effect, Layer } from "effect" +import { location } from "../fixture/location" +import { testEffect } from "../lib/effect" +import { catalogHost, host } from "./host" + +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), +) +const it = testEffect(AppNodeBuilder.build(Catalog.node, [[Location.node, locationLayer]])) + +describe("VariantPlugin", () => { + it.effect("adds GLM 5.2 variants after catalog sources", () => + Effect.gen(function* () { + const service = yield* Catalog.Service + yield* service.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.opencode, (provider) => { + provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } + }) + catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2"), (model) => { + model.api = { + id: ModelV2.ID.make("glm-5.2"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + } + }) + }) + yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) })) + + expect((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2")))?.variants).toEqual([ + expect.objectContaining({ id: "high", body: { reasoning_effort: "high" } }), + expect.objectContaining({ id: "max", body: { reasoning_effort: "max" } }), + ]) + }), + ) + + it.effect("keeps explicit variants over generated defaults", () => + Effect.gen(function* () { + const service = yield* Catalog.Service + yield* service.transform((catalog) => { + catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2"), (model) => { + model.api = { + id: ModelV2.ID.make("glm-5.2"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + } + model.variants = [{ id: ModelV2.VariantID.make("high"), headers: { custom: "true" }, body: {} }] + }) + }) + yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) })) + + expect((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2")))?.variants).toEqual([ + expect.objectContaining({ id: "high", headers: { custom: "true" } }), + expect.objectContaining({ id: "max", body: { reasoning_effort: "max" } }), + ]) + }), + ) +}) diff --git a/packages/core/test/policy.test.ts b/packages/core/test/policy.test.ts index 42736eb7d8..1428c1b830 100644 --- a/packages/core/test/policy.test.ts +++ b/packages/core/test/policy.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Location } from "@opencode-ai/core/location" import { Policy } from "@opencode-ai/core/policy" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -7,11 +8,12 @@ import { location } from "./fixture/location" import { testEffect } from "./lib/effect" const it = testEffect( - Policy.locationLayer.pipe( - Layer.provide( + AppNodeBuilder.build(Policy.node, [ + [ + Location.node, Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), - ), - ), + ], + ]), ) describe("Policy", () => { diff --git a/packages/core/test/preload.ts b/packages/core/test/preload.ts new file mode 100644 index 0000000000..d44cffd849 --- /dev/null +++ b/packages/core/test/preload.ts @@ -0,0 +1,5 @@ +import path from "path" + +process.env.KILO_DB = ":memory:" +process.env.KILO_MODELS_PATH = path.join(import.meta.dir, "plugin", "fixtures", "models-dev.json") +process.env.KILO_DISABLE_MODELS_FETCH = "true" diff --git a/packages/core/test/process/process.test.ts b/packages/core/test/process/process.test.ts index f8377f718a..82b6dc4717 100644 --- a/packages/core/test/process/process.test.ts +++ b/packages/core/test/process/process.test.ts @@ -5,10 +5,11 @@ import { tmpdir } from "node:os" import path from "node:path" import { Effect, Exit, Fiber, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppProcess } from "@opencode-ai/core/process" import { testEffect } from "../lib/effect" -const it = testEffect(AppProcess.defaultLayer) +const it = testEffect(LayerNode.compile(AppProcess.node)) const NODE = process.execPath const cmd = (...args: string[]) => ChildProcess.make(NODE, args) @@ -39,6 +40,22 @@ describe("AppProcess", () => { }), ) + it.effect( + "captures stdout and stderr in emission order", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const script = [ + 'process.stdout.write("out 1\\n")', + 'setTimeout(() => process.stderr.write("err 1\\n"), 10)', + 'setTimeout(() => process.stdout.write("out 2\\n"), 20)', + ].join(";") + const result = yield* svc.run(cmd("-e", script), { combineOutput: true }) + expect(result.output?.toString("utf8")).toBe("out 1\nerr 1\nout 2\n") + expect(result.stdout.toString("utf8")).toBe("") + expect(result.stderr.toString("utf8")).toBe("") + }), + ) + it.effect( "non-zero exit returns RunResult; caller can require success", Effect.gen(function* () { @@ -61,6 +78,7 @@ describe("AppProcess", () => { if (reason && reason._tag === "Fail") { expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError) expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1) + expect((reason.error as AppProcess.AppProcessError).message).toContain("Command failed (exit 1)") } else { throw new Error("expected fail reason") } @@ -144,7 +162,7 @@ describe("AppProcess", () => { const script = `const fs=require('fs');fs.writeFileSync(${JSON.stringify(ready)},String(process.pid));process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});setInterval(()=>{},60000)` return Effect.gen(function* () { const svc = yield* AppProcess.Service - const exit = yield* Effect.exit(svc.run(cmd("-e", script), { timeout: "1 second" })) + const exit = yield* Effect.exit(svc.run(cmd("-e", script), { timeout: "250 millis" })) expect(Exit.isFailure(exit)).toBe(true) expect(yield* waitForFile(ready)).toMatch(/^\d+$/) expect(yield* waitForFile(settled)).toBe("settled") diff --git a/packages/core/test/project-copy.test.ts b/packages/core/test/project-copy.test.ts index 47f2176c37..d37f630207 100644 --- a/packages/core/test/project-copy.test.ts +++ b/packages/core/test/project-copy.test.ts @@ -3,9 +3,10 @@ import { $ } from "bun" import fs from "fs/promises" import path from "path" import { eq } from "drizzle-orm" -import { Effect, Fiber, Layer, Stream } from "effect" +import { Effect, Fiber, Stream } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AbsolutePath } from "@opencode-ai/core/schema" -import { FSUtil } from "@opencode-ai/core/fs-util" import { Git } from "@opencode-ai/core/git" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" @@ -16,17 +17,9 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const databaseLayer = Database.layerFromPath(":memory:") -const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer)) -const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer)) -const copyLayer = ProjectCopy.layer.pipe( - Layer.provide(databaseLayer), - Layer.provide(directoriesLayer), - Layer.provide(eventLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([ProjectCopy.node, Database.node, EventV2.node, ProjectDirectories.node])), ) -const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer, directoriesLayer)) function abs(input: string) { return AbsolutePath.make(input) diff --git a/packages/core/test/project-directories.test.ts b/packages/core/test/project-directories.test.ts index c1d2d8801f..d91683cf42 100644 --- a/packages/core/test/project-directories.test.ts +++ b/packages/core/test/project-directories.test.ts @@ -1,17 +1,15 @@ import { describe, expect } from "bun:test" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" import { Database } from "@opencode-ai/core/database/database" -import { EventV2 } from "@opencode-ai/core/event" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Project } from "@opencode-ai/core/project" import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events)) -const it = testEffect(Layer.mergeAll(database, events, directories)) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, ProjectDirectories.node]))) const projectID = Project.ID.make("project-directories") const directory = AbsolutePath.make("/tmp/project-directories") diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 45fba0d18f..fa709a8b2b 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -2,31 +2,15 @@ import { describe, expect } from "bun:test" import { $ } from "bun" import fs from "fs/promises" import path from "path" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { ProjectV2 } from "@opencode-ai/core/project" -import { Database } from "@opencode-ai/core/database/database" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Git } from "@opencode-ai/core/git" import { AbsolutePath } from "@opencode-ai/core/schema" import { Hash } from "@opencode-ai/core/util/hash" -import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const databaseLayer = Database.layerFromPath(":memory:") -const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer)) -const it = testEffect( - Layer.mergeAll( - ProjectV2.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(directoriesLayer), - Layer.provide(databaseLayer), - ), - databaseLayer, - directoriesLayer, - ), -) +const it = testEffect(AppNodeBuilder.build(ProjectV2.node)) function remoteID(remote: string) { return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`)) diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts index 6b78ec4d13..9b41c98fb4 100644 --- a/packages/core/test/pty/pty-session.test.ts +++ b/packages/core/test/pty/pty-session.test.ts @@ -1,6 +1,8 @@ import { describe, expect } from "bun:test" import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect" import { Config } from "@opencode-ai/core/config" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { Pty } from "@opencode-ai/core/pty" @@ -17,11 +19,10 @@ const locationLayer = Layer.succeed( ) const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) const it = testEffect( - Pty.layer.pipe( - Layer.provide(configLayer), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - ), + AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [ + [Config.node, configLayer], + [Location.node, locationLayer], + ]), ) const ptyTest = process.platform === "win32" ? it.live.skip : it.live @@ -205,8 +206,9 @@ describe("pty", () => { const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash") const configuredIt = testEffect( - Pty.layer.pipe( - Layer.provide( + AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [ + [ + Config.node, Layer.mock(Config.Service)({ entries: () => Effect.succeed( @@ -215,10 +217,9 @@ const configuredIt = testEffect( : [], ), }), - ), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - ), + ], + [Location.node, locationLayer], + ]), ) const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live diff --git a/packages/core/test/pty/ticket.test.ts b/packages/core/test/pty/ticket.test.ts index e36808fa2d..10849f2f61 100644 --- a/packages/core/test/pty/ticket.test.ts +++ b/packages/core/test/pty/ticket.test.ts @@ -1,12 +1,15 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PtyID } from "@opencode-ai/core/pty/schema" import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { testEffect } from "../lib/effect" -const it = testEffect(PtyTicket.layer) -const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5))) +const it = testEffect(LayerNode.compile(PtyTicket.node)) +const itExpiring = testEffect( + LayerNode.compile(PtyTicket.node, [[PtyTicket.node, Layer.effect(PtyTicket.Service, PtyTicket.make(5))]]), +) describe("PTY websocket tickets", () => { it.live("consumes tickets once", () => diff --git a/packages/core/test/public-opencode.test.ts b/packages/core/test/public-opencode.test.ts deleted file mode 100644 index c5f90e92c4..0000000000 --- a/packages/core/test/public-opencode.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import fs from "fs/promises" -import path from "path" -import { describe, expect } from "bun:test" -import { Effect, Schema } from "effect" -import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public" -import { tmpdir } from "./fixture/tmpdir" -import { testEffect } from "./lib/effect" - -const it = testEffect(OpenCode.layer) - -describe("public native OpenCode API", () => { - it.effect("exposes only the intentional Session capabilities", () => - Effect.gen(function* () { - const opencode = yield* OpenCode.Service - - expect(Object.keys(opencode).sort()).toEqual(["sessions", "tools"]) - - expect(Object.keys(opencode.sessions).sort()).toEqual([ - "context", - "create", - "events", - "get", - "interrupt", - "list", - "message", - "messages", - "prompt", - "switchModel", - ]) - expect(Session.ID.create()).toStartWith("ses_") - expect(Session.MessageID.create()).toStartWith("msg_") - expect(yield* opencode.sessions.list()).toBeArray() - yield* opencode.tools.register({ - public_tool: Tool.make({ - description: "Public tool", - input: Schema.Struct({}), - output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.succeed({ ok: true }), - }), - }) - }), - ) - - it.effect("switches to an available model and variant", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - yield* writeProvider(tmp.path) - const opencode = yield* OpenCode.Service - const sessionID = Session.ID.make("ses_public_switch_available") - const model = ref({ variant: "fast" }) - yield* opencode.sessions.create({ - id: sessionID, - location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }), - }) - - yield* opencode.sessions.switchModel({ sessionID, model }) - - expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model) - }), - ), - ), - ) - - it.effect("rejects missing and Location-disabled models without changing the Session", () => - Effect.acquireRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), - (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)), - ).pipe( - Effect.flatMap(([available, disabled]) => - Effect.gen(function* () { - yield* writeProvider(available.path) - yield* writeProvider(disabled.path, true) - const opencode = yield* OpenCode.Service - const availableID = Session.ID.make("ses_public_switch_exact_available") - const disabledID = Session.ID.make("ses_public_switch_exact_disabled") - yield* opencode.sessions.create({ - id: availableID, - location: Location.Ref.make({ directory: AbsolutePath.make(available.path) }), - }) - yield* opencode.sessions.create({ - id: disabledID, - location: Location.Ref.make({ directory: AbsolutePath.make(disabled.path) }), - }) - - yield* opencode.sessions.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) }) - const disabledError = yield* opencode.sessions - .switchModel({ sessionID: disabledID, model: ref() }) - .pipe(Effect.flip) - const missingError = yield* opencode.sessions - .switchModel({ sessionID: disabledID, model: ref({ id: "missing" }) }) - .pipe(Effect.flip) - - expect(disabledError).toBeInstanceOf(Session.ModelUnavailableError) - expect(missingError).toBeInstanceOf(Session.ModelUnavailableError) - expect((yield* opencode.sessions.get(availableID)).model).toEqual(ref({ variant: "default" })) - expect((yield* opencode.sessions.get(disabledID)).model).toBeUndefined() - }), - ), - ), - ) - - it.effect("rejects an unavailable variant without changing the Session", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - yield* writeProvider(tmp.path) - const opencode = yield* OpenCode.Service - const sessionID = Session.ID.make("ses_public_switch_variant") - const selected = ref({ variant: "fast" }) - yield* opencode.sessions.create({ - id: sessionID, - location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }), - }) - yield* opencode.sessions.switchModel({ sessionID, model: selected }) - - const error = yield* opencode.sessions - .switchModel({ sessionID, model: ref({ variant: "unknown" }) }) - .pipe(Effect.flip) - - expect(error).toBeInstanceOf(Session.VariantUnavailableError) - expect((yield* opencode.sessions.get(sessionID)).model).toEqual(selected) - }), - ), - ), - ) - - it.effect("preserves the typed not-found error for a missing Session", () => - Effect.gen(function* () { - const opencode = yield* OpenCode.Service - const sessionID = Session.ID.make("ses_public_switch_missing") - const error = yield* opencode.sessions - .switchModel({ - sessionID, - model: Schema.decodeUnknownSync(Model.Ref)({ id: "claude-sonnet-4-5", providerID: "anthropic" }), - }) - .pipe(Effect.flip) - - expect(error).toBeInstanceOf(Session.NotFoundError) - if (error instanceof Session.NotFoundError) expect(error.sessionID).toBe(sessionID) - }), - ) -}) - -const ref = (input: { id?: string; variant?: string } = {}) => - Schema.decodeUnknownSync(Model.Ref)({ - id: input.id ?? "chat", - providerID: "public-test", - variant: input.variant, - }) - -const writeProvider = (directory: string, disabled = false) => - Effect.promise(() => - fs.writeFile( - path.join(directory, "opencode.json"), - JSON.stringify({ - providers: { - "public-test": { - name: "Public test", - api: { type: "native", settings: {} }, - models: { - chat: { - disabled, - variants: [{ id: "fast" }], - }, - }, - }, - }, - }), - ), - ) diff --git a/packages/core/test/public-tool.test.ts b/packages/core/test/public-tool.test.ts deleted file mode 100644 index d3f444dd09..0000000000 --- a/packages/core/test/public-tool.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { Tool } from "@opencode-ai/core/public" -import { Effect } from "effect" - -describe("public Tool API", () => { - it("keeps the public registration capability narrow", () => { - const tools = { - register: () => Effect.void, - } satisfies Tool.Interface - - expect(Object.keys(tools)).toEqual(["register"]) - }) -}) diff --git a/packages/core/test/question.test.ts b/packages/core/test/question.test.ts index 57bf399669..03d61a9565 100644 --- a/packages/core/test/question.test.ts +++ b/packages/core/test/question.test.ts @@ -1,15 +1,14 @@ import { describe, expect } from "bun:test" import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect" -import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EventV2 } from "@opencode-ai/core/event" import { QuestionV2 } from "@opencode-ai/core/question" import { SessionV2 } from "@opencode-ai/core/session" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const questions = QuestionV2.layer.pipe(Layer.provide(events)) -const it = testEffect(Layer.mergeAll(database, events, questions)) +const questions = AppNodeBuilder.build(LayerNode.group([EventV2.node, QuestionV2.node])) +const it = testEffect(questions) const sessionID = SessionV2.ID.make("ses_question_test") const question: QuestionV2.Info = { diff --git a/packages/core/test/reference-guidance.test.ts b/packages/core/test/reference-guidance.test.ts index 5e1aba1923..2a317af2ca 100644 --- a/packages/core/test/reference-guidance.test.ts +++ b/packages/core/test/reference-guidance.test.ts @@ -1,12 +1,15 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AbsolutePath } from "@opencode-ai/core/schema" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Reference } from "@opencode-ai/core/reference" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" import { SystemContext } from "@opencode-ai/core/system-context/index" import { it } from "./lib/effect" +const guidanceLayer = (referenceLayer: Layer.Layer) => + AppNodeBuilder.build(ReferenceGuidance.node, [[Reference.node, referenceLayer]]) + describe("ReferenceGuidance", () => { it.effect("lists available references in the system context", () => Effect.gen(function* () { @@ -18,25 +21,25 @@ describe("ReferenceGuidance", () => { expect(generation.baseline).toContain("/docs") expect(generation.baseline).toContain("Use for product documentation") }).pipe( - Effect.provide(ReferenceGuidance.layer), Effect.provide( - Layer.mock(Reference.Service, { - list: () => - Effect.succeed([ - new Reference.Info({ - name: "docs", - path: AbsolutePath.make("/docs"), - description: "Use for product documentation", - source: new Reference.LocalSource({ - type: "local", + guidanceLayer( + Layer.mock(Reference.Service, { + list: () => + Effect.succeed([ + new Reference.Info({ + name: "docs", path: AbsolutePath.make("/docs"), description: "Use for product documentation", + source: Reference.LocalSource.make({ + type: "local", + path: AbsolutePath.make("/docs"), + description: "Use for product documentation", + }), }), - }), - ]), - }), + ]), + }), + ), ), - Effect.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.void })), ), ) @@ -45,11 +48,7 @@ describe("ReferenceGuidance", () => { const guidance = yield* ReferenceGuidance.Service const generation = yield* SystemContext.initialize(yield* guidance.load()) expect(generation.baseline).toBe("") - }).pipe( - Effect.provide(ReferenceGuidance.layer), - Effect.provide(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })), - Effect.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.void })), - ), + }).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))), ) it.effect("omits references without descriptions", () => @@ -58,20 +57,20 @@ describe("ReferenceGuidance", () => { const generation = yield* SystemContext.initialize(yield* guidance.load()) expect(generation.baseline).toBe("") }).pipe( - Effect.provide(ReferenceGuidance.layer), Effect.provide( - Layer.mock(Reference.Service, { - list: () => - Effect.succeed([ - new Reference.Info({ - name: "docs", - path: AbsolutePath.make("/docs"), - source: new Reference.LocalSource({ type: "local", path: AbsolutePath.make("/docs") }), - }), - ]), - }), + guidanceLayer( + Layer.mock(Reference.Service, { + list: () => + Effect.succeed([ + new Reference.Info({ + name: "docs", + path: AbsolutePath.make("/docs"), + source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }), + }), + ]), + }), + ), ), - Effect.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.void })), ), ) }) diff --git a/packages/core/test/reference.test.ts b/packages/core/test/reference.test.ts index dfa8a202a6..db61232310 100644 --- a/packages/core/test/reference.test.ts +++ b/packages/core/test/reference.test.ts @@ -1,31 +1,32 @@ import { describe, expect } from "bun:test" import { Effect, Exit, Layer, Scope } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AbsolutePath } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { Reference } from "@opencode-ai/core/reference" import { Repository } from "@opencode-ai/core/repository" import { RepositoryCache } from "@opencode-ai/core/repository-cache" -import { EventV2 } from "@opencode-ai/core/event" import { it } from "./lib/effect" const cache = Layer.mock(RepositoryCache.Service, { ensure: () => Effect.die("unexpected Git materialization"), }) +const referenceLayer = AppNodeBuilder.build(Reference.node, [[RepositoryCache.node, cache]]) describe("Reference", () => { it.effect("registers normalized sources for the owning scope", () => Effect.gen(function* () { const references = yield* Reference.Service const scope = yield* Scope.make() - const update = yield* references.transform().pipe(Effect.provideService(Scope.Scope, scope)) const path = AbsolutePath.make("/docs") - const source = new Reference.LocalSource({ + const source = Reference.LocalSource.make({ type: "local", path, description: "Use for API documentation", hidden: true, }) - yield* update((editor) => editor.add("docs", source)) + yield* references.transform((editor) => editor.add("docs", source)).pipe(Scope.provide(scope)) expect(yield* references.list()).toEqual([ new Reference.Info({ name: "docs", path, description: "Use for API documentation", hidden: true, source }), @@ -33,21 +34,15 @@ describe("Reference", () => { yield* Scope.close(scope, Exit.void) expect(yield* references.list()).toEqual([]) - }).pipe( - Effect.provide(Reference.layer), - Effect.provide(cache), - Effect.provide(EventV2.defaultLayer), - Effect.provide(Global.defaultLayer), - ), + }).pipe(Effect.provide(referenceLayer)), ) it.effect("derives Git paths without exposing cache operations", () => Effect.gen(function* () { const references = yield* Reference.Service - const update = yield* references.transform() const repository = Repository.parseRemote("owner/repo") - const source = new Reference.GitSource({ type: "git", repository: "owner/repo", branch: "main" }) - yield* update((editor) => editor.add("sdk", source)) + const source = Reference.GitSource.make({ type: "git", repository: "owner/repo", branch: "main" }) + yield* references.transform((editor) => editor.add("sdk", source)) expect(yield* references.list()).toEqual([ new Reference.Info({ @@ -56,26 +51,19 @@ describe("Reference", () => { source, }), ]) - }).pipe( - Effect.scoped, - Effect.provide(Reference.layer), - Effect.provide(cache), - Effect.provide(EventV2.defaultLayer), - Effect.provide(Global.defaultLayer), - ), + }).pipe(Effect.scoped, Effect.provide(referenceLayer)), ) it.effect("preserves configured Git descriptions", () => Effect.gen(function* () { const references = yield* Reference.Service - const update = yield* references.transform() const repository = Repository.parseRemote("owner/repo") - const source = new Reference.GitSource({ + const source = Reference.GitSource.make({ type: "git", repository: "owner/repo", description: "Use for SDK implementation details", }) - yield* update((editor) => editor.add("sdk", source)) + yield* references.transform((editor) => editor.add("sdk", source)) expect(yield* references.list()).toEqual([ new Reference.Info({ @@ -85,12 +73,6 @@ describe("Reference", () => { source, }), ]) - }).pipe( - Effect.scoped, - Effect.provide(Reference.layer), - Effect.provide(cache), - Effect.provide(EventV2.defaultLayer), - Effect.provide(Global.defaultLayer), - ), + }).pipe(Effect.scoped, Effect.provide(referenceLayer)), ) }) diff --git a/packages/core/test/repository-cache.test.ts b/packages/core/test/repository-cache.test.ts index a99daea8e2..2dd0ce2502 100644 --- a/packages/core/test/repository-cache.test.ts +++ b/packages/core/test/repository-cache.test.ts @@ -3,12 +3,11 @@ import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" import { Effect, Layer } from "effect" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Git } from "@opencode-ai/core/git" +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 { Repository } from "@opencode-ai/core/repository" import { RepositoryCache } from "@opencode-ai/core/repository-cache" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { git, gitRemote } from "./fixture/git" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -89,15 +88,9 @@ describe("RepositoryCache", () => { }) function cacheLayer(root: string) { - const dependencies = Layer.mergeAll( - Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") }), - FSUtil.defaultLayer, - ) - return RepositoryCache.layer.pipe( - Layer.provide(EffectFlock.layer.pipe(Layer.provide(dependencies))), - Layer.provide(Git.defaultLayer), - Layer.provide(dependencies), - ) + return AppNodeBuilder.build(RepositoryCache.node, [ + [Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })], + ]) } function withRemote(body: (fixture: Awaited>) => Effect.Effect) { diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts index d7efe2a2e3..3abce1c02d 100644 --- a/packages/core/test/ripgrep.test.ts +++ b/packages/core/test/ripgrep.test.ts @@ -2,12 +2,13 @@ import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" import { Effect } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { RelativePath } from "@opencode-ai/core/schema" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const it = testEffect(Ripgrep.defaultLayer) +const it = testEffect(LayerNode.compile(Ripgrep.node)) describe("Ripgrep", () => { it.live("keeps ignored files out of catch-all find results", () => diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 3551ec52f3..4688ede82d 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -4,6 +4,8 @@ import { Effect, Layer, Stream } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { asc, eq } from "drizzle-orm" 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" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" import { Location } from "@opencode-ai/core/location" @@ -25,8 +27,6 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { testEffect } from "./lib/effect" import { tmpdir } from "./fixture/tmpdir" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) const projects = Layer.succeed( ProjectV2.Service, ProjectV2.Service.of({ @@ -35,37 +35,19 @@ const projects = Layer.succeed( commit: () => Effect.void, }), ) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) -const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), - Layer.provide(projects), - Layer.provide(SessionExecution.noopLayer), -) const it = testEffect( - Layer.mergeAll(database, events, projects, projector, store, SessionExecution.noopLayer, sessions), + AppNodeBuilder.build( + LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), + [ + [ProjectV2.node, projects], + [SessionExecution.node, SessionExecution.noopLayer], + ], + ), ) const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) const id = SessionV2.ID.create() describe("SessionV2.create", () => { - it.effect("derives stable namespaced external IDs", () => - Effect.sync(() => { - const input = { namespace: "opencord.agent-thread", key: "thread-1" } - - expect(SessionV2.ID.fromExternal(input)).toBe(SessionV2.ID.fromExternal(input)) - expect(SessionV2.ID.fromExternal(input)).toMatch(/^ses_[a-f0-9]{64}$/) - expect(SessionV2.ID.fromExternal({ ...input, namespace: "another-app" })).not.toBe( - SessionV2.ID.fromExternal(input), - ) - expect(SessionV2.ID.fromExternal({ namespace: "a:b", key: "c" })).not.toBe( - SessionV2.ID.fromExternal({ namespace: "a", key: "b:c" }), - ) - }), - ) - it.effect("creates a fresh projected session when the ID is omitted", () => Effect.gen(function* () { const session = yield* SessionV2.Service @@ -214,14 +196,14 @@ describe("SessionV2.create", () => { const events = yield* EventV2.Service const { db } = yield* Database.Service const created = yield* session.create({ location }) - yield* session.prompt({ sessionID: created.id, prompt: new Prompt({ text: "Hello" }), resume: false }) + yield* session.prompt({ sessionID: created.id, prompt: Prompt.make({ text: "Hello" }), resume: false }) yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER) expect( Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)), ).toMatchObject([ - { cursor: 1, event: { type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } } }, - { cursor: 2, event: { type: "session.next.prompt.promoted" } }, + { durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } }, + { durable: { seq: 2 }, type: "session.next.prompted" }, ]) }), ) @@ -234,7 +216,7 @@ describe("SessionV2.create", () => { const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location }) const admitted = yield* session.prompt({ sessionID: created.id, - prompt: new Prompt({ text: "Replay lifecycle" }), + prompt: Prompt.make({ text: "Replay lifecycle" }), resume: false, }) yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id, Number.MAX_SAFE_INTEGER) @@ -257,9 +239,10 @@ describe("SessionV2.create", () => { (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) const targetDatabase = Database.layerFromPath(path.join(tmp.path, "target.sqlite")) - const targetEvents = EventV2.layer.pipe(Layer.provide(targetDatabase)) - const targetProjector = SessionProjector.layer.pipe(Layer.provide(targetEvents), Layer.provide(targetDatabase)) - const targetStore = SessionStore.layer.pipe(Layer.provide(targetDatabase)) + const targetLayer = AppNodeBuilder.build( + LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node]), + [[Database.node, targetDatabase]], + ) yield* Effect.gen(function* () { const db = (yield* Database.Service).db @@ -304,10 +287,10 @@ describe("SessionV2.create", () => { .pipe(Effect.orDie)).map((event) => [event.seq, event.type]), ).toEqual([ [0, EventV2.versionedType(SessionV1.Event.Created.type, 1)], - [1, EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1)], - [2, EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1)], + [1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)], + [2, EventV2.versionedType(SessionEvent.Prompted.type, 1)], ]) - }).pipe(Effect.provide(Layer.fresh(Layer.mergeAll(targetDatabase, targetEvents, targetProjector, targetStore)))) + }).pipe(Effect.provide(Layer.fresh(targetLayer))) }), ) @@ -336,7 +319,34 @@ describe("SessionV2.create", () => { expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell") expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill") - expect(yield* unavailable(session.switchAgent({ sessionID: created.id, agent: "build" }))).toBe("switchAgent") + }), + ) + + it.effect("switches the selected agent through the durable Session event", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + + yield* session.switchAgent({ sessionID: created.id, agent: "plan" }) + + expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" }) + expect( + Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)), + ).toMatchObject([{ type: "session.next.agent.switched", data: { agent: "plan" } }]) + }), + ) + + it.effect("rejects an agent switch for a missing Session", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const missing = SessionV2.ID.make("ses_missing_agent_switch") + + expect( + yield* session.switchAgent({ sessionID: missing, agent: "plan" }).pipe( + Effect.flip, + Effect.map((error) => error._tag), + ), + ).toBe("Session.NotFoundError") }), ) @@ -355,11 +365,11 @@ describe("SessionV2.create", () => { expect(yield* session.get(created.id)).toMatchObject({ model }) expect( Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)), - ).toMatchObject([{ event: { type: "session.next.model.switched", data: { model } } }]) + ).toMatchObject([{ type: "session.next.model.switched", data: { model } }]) }), ) - it.effect("persists repeated switches as distinct durable Session events", () => + it.effect("ignores a model switch when the selected model is unchanged", () => Effect.gen(function* () { const session = yield* SessionV2.Service const created = yield* session.create({ location }) @@ -371,11 +381,29 @@ describe("SessionV2.create", () => { const { db } = yield* Database.Service expect( yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie), - ).toHaveLength(3) + ).toHaveLength(2) expect(yield* session.get(created.id)).toMatchObject({ model }) }), ) + it.effect("treats an omitted variant as the default variant", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }) + const created = yield* session.create({ location, model }) + + yield* session.switchModel({ + sessionID: created.id, + model: ModelV2.Ref.make({ ...model, variant: ModelV2.VariantID.make("default") }), + }) + + const { db } = yield* Database.Service + expect( + yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie), + ).toHaveLength(1) + }), + ) + it.effect("rejects a model switch for a missing Session", () => Effect.gen(function* () { const session = yield* SessionV2.Service diff --git a/packages/core/test/session-history.test.ts b/packages/core/test/session-history.test.ts new file mode 100644 index 0000000000..c67f776d29 --- /dev/null +++ b/packages/core/test/session-history.test.ts @@ -0,0 +1,165 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer, Schema } from "effect" +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" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionStore } from "@opencode-ai/core/session/store" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { testEffect } from "./lib/effect" + +const projects = Layer.succeed( + ProjectV2.Service, + ProjectV2.Service.of({ + resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), + directories: () => Effect.succeed([]), + commit: () => Effect.void, + }), +) +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), + [ + [ProjectV2.node, projects], + [SessionExecution.node, SessionExecution.noopLayer], + ], + ), +) +const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) + +const GapEvent = EventV2.define({ + type: "test.session.history.gap", + durable: { aggregate: "sessionID", version: 1 }, + schema: { sessionID: SessionV2.ID, value: Schema.String }, +}) + +describe("SessionV2.history", () => { + it.effect("returns an exhausted page for a migrated Session with no event sequence", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + const session = yield* SessionV2.Service + const sessionID = SessionV2.ID.make("ses_empty_history") + yield* db + .insert(ProjectTable) + .values({ id: ProjectV2.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: ProjectV2.ID.global, + slug: "empty-history", + directory: "/project", + title: "Empty history", + version: "test", + }) + .run() + + const first = yield* session.history({ sessionID, limit: 10 }) + + expect(first).toEqual({ events: [], hasMore: false }) + }), + ) + + it.effect("treats after as an exclusive aggregate sequence", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* session.switchAgent({ sessionID: created.id, agent: "two" }) + + const page = yield* session.history({ sessionID: created.id, after: 1, limit: 10 }) + + expect(page.events.map((event) => event.durable?.seq)).toEqual([2]) + expect(page.hasMore).toBe(false) + }), + ) + + it.effect("paginates public events in aggregate order across filtered gaps without duplicates", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const created = yield* session.create({ location }) + yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" }) + yield* session.switchAgent({ sessionID: created.id, agent: "two" }) + yield* session.switchAgent({ sessionID: created.id, agent: "three" }) + + const first = yield* session.history({ sessionID: created.id, limit: 2 }) + const after = first.events.at(-1)?.durable?.seq + const second = yield* session.history({ + sessionID: created.id, + after, + limit: 2, + }) + const sequence = [...first.events, ...second.events].map((event) => event.durable?.seq) + + expect(first.hasMore).toBe(true) + expect(second.hasMore).toBe(false) + expect(sequence).toEqual([1, 3, 4]) + expect(new Set(sequence).size).toBe(sequence.length) + }), + ) + + it.effect("includes events committed between pages", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* session.switchAgent({ sessionID: created.id, agent: "two" }) + + const first = yield* session.history({ sessionID: created.id, limit: 1 }) + yield* session.switchAgent({ sessionID: created.id, agent: "later" }) + const second = yield* session.history({ + sessionID: created.id, + after: first.events.at(-1)?.durable?.seq, + limit: 10, + }) + + expect(first.hasMore).toBe(true) + expect([...first.events, ...second.events].map((event) => event.durable?.seq)).toEqual([1, 2, 3]) + expect(second.hasMore).toBe(false) + }), + ) + + it.effect("reports exhaustion for exact-limit and limit-plus-one pages", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* session.switchAgent({ sessionID: created.id, agent: "two" }) + + const exact = yield* session.history({ sessionID: created.id, limit: 2 }) + const oneMore = yield* session.history({ sessionID: created.id, limit: 1 }) + const exhausted = yield* session.history({ + sessionID: created.id, + after: oneMore.events.at(-1)?.durable?.seq, + limit: 1, + }) + + expect(exact.events).toHaveLength(2) + expect(exact.hasMore).toBe(false) + expect(oneMore.events).toHaveLength(1) + expect(oneMore.hasMore).toBe(true) + expect(exhausted.events).toHaveLength(1) + expect(exhausted.hasMore).toBe(false) + }), + ) + + it.effect("fails with NotFoundError for a missing Session", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const error = yield* session.history({ sessionID: SessionV2.ID.make("ses_missing"), limit: 10 }).pipe(Effect.flip) + + expect(error._tag).toBe("Session.NotFoundError") + }), + ) +}) diff --git a/packages/core/test/session-logging.test.ts b/packages/core/test/session-logging.test.ts deleted file mode 100644 index 3d6cff2e4e..0000000000 --- a/packages/core/test/session-logging.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Cause, Effect, Logger } from "effect" -import { logFailure } from "@opencode-ai/core/session/logging" -import { SessionSchema } from "@opencode-ai/core/session/schema" - -describe("Session logging", () => { - for (const message of ["Failed to drain Session", "Failed to wake Session"] as const) { - test(`renders the cause for ${message}`, async () => { - const entries: Array> = [] - const logger = Logger.formatStructured.pipe( - Logger.map((entry): void => { - entries.push(entry) - }), - ) - - await logFailure( - message, - SessionSchema.ID.make("session-123"), - Cause.fail({ _tag: "SessionFailure", detail: { code: "nested-code" } }), - ).pipe(Effect.provide(Logger.layer([logger])), Effect.runPromise) - - expect(entries).toHaveLength(1) - expect(entries[0]?.message).toBe(message) - expect(entries[0]?.annotations).toEqual({ sessionID: "session-123" }) - expect(entries[0]?.cause).toContain("SessionFailure") - expect(entries[0]?.cause).toContain("nested-code") - expect(entries[0]?.cause).not.toContain("[Object") - }) - } -}) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index f84f60f308..6648ee43c3 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -1,7 +1,9 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Layer, Schema } from "effect" +import { DateTime, Effect, Schema } from "effect" import { asc, eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" import { ModelV2 } from "@opencode-ai/core/model" @@ -17,14 +19,12 @@ import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" -import { SessionStore } from "@opencode-ai/core/session/store" import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" +import { Snapshot } from "@opencode-ai/core/snapshot" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const it = testEffect(Layer.mergeAll(database, events, projector)) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node]))) +const sessionsLayer = AppNodeBuilder.build(SessionV2.node, [[SessionExecution.node, SessionExecution.noopLayer]]) const sessionID = SessionV2.ID.make("ses_projector_test") const created = DateTime.makeUnsafe(0) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } @@ -39,11 +39,63 @@ const assistantRow = ( id: _, type, ...data - } = encodeMessage(new SessionMessage.Assistant({ id, type: "assistant", agent: "build", model, content: [], time })) + } = encodeMessage(SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time })) return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data } } describe("SessionProjector", () => { + it.effect("projects staged, cleared, and committed reverts", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + const boundary = SessionMessage.ID.make("msg_boundary") + yield* db + .insert(SessionMessageTable) + .values([assistantRow(boundary, 1), assistantRow(SessionMessage.ID.make("msg_later"), 2)]) + .run() + const events = yield* EventV2.Service + yield* events.publish(SessionEvent.RevertEvent.Staged, { + sessionID, + timestamp: DateTime.makeUnsafe(1), + revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] }, + }) + expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({ + messageID: boundary, + snapshot: "tree", + files: [], + }) + yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID, timestamp: DateTime.makeUnsafe(2) }) + expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toBeNull() + yield* events.publish(SessionEvent.RevertEvent.Staged, { + sessionID, + timestamp: DateTime.makeUnsafe(3), + revert: { messageID: boundary, files: [] }, + }) + yield* events.publish(SessionEvent.RevertEvent.Committed, { + sessionID, + messageID: boundary, + timestamp: DateTime.makeUnsafe(4), + }) + expect( + (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id), + ).toEqual([boundary]) + }), + ) + it.effect("orders projected messages and context by durable aggregate sequence", () => Effect.gen(function* () { const { db } = yield* Database.Service @@ -72,7 +124,7 @@ describe("SessionProjector", () => { sessionID, messageID: SessionMessage.ID.make("msg_first"), timestamp: created, - prompt: new Prompt({ text: "first" }), + prompt: Prompt.make({ text: "first" }), delivery: "steer", }, { id: EventV2.ID.make("evt_z") }, @@ -83,7 +135,7 @@ describe("SessionProjector", () => { sessionID, messageID: SessionMessage.ID.make("msg_second"), timestamp: created, - prompt: new Prompt({ text: "second" }), + prompt: Prompt.make({ text: "second" }), delivery: "steer", }, { id: EventV2.ID.make("evt_a") }, @@ -110,20 +162,10 @@ describe("SessionProjector", () => { expect( (yield* sessions.context(sessionID)).map((message) => (message.type === "user" ? message.text : message.type)), ).toEqual(["first", "second"]) - }).pipe( - Effect.provide( - SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(Project.defaultLayer), - Layer.provide(SessionStore.layer.pipe(Layer.provide(database))), - Layer.provide(SessionExecution.noopLayer), - ), - ), - ), + }).pipe(Effect.provide(sessionsLayer)), ) - it.effect("marks an admitted lifecycle row promoted with the PromptPromoted event sequence", () => + it.effect("marks an inbox row promoted with the Prompted event sequence", () => Effect.gen(function* () { const { db } = yield* Database.Service yield* db @@ -145,24 +187,25 @@ describe("SessionProjector", () => { .pipe(Effect.orDie) const events = yield* EventV2.Service const id = SessionMessage.ID.make("msg_admitted") - yield* SessionInput.admit(db, events, { + const admitted = yield* SessionInput.admit(db, events, { id, sessionID, - prompt: new Prompt({ text: "promote me" }), + prompt: Prompt.make({ text: "promote me" }), delivery: "steer", }) + if (!admitted) return yield* Effect.die("Prompt admission failed") - const event = yield* events.publish(SessionEvent.PromptLifecycle.Promoted, { + const event = yield* events.publish(SessionEvent.Prompted, { sessionID, - timestamp: created, + timestamp: admitted.timeCreated, messageID: id, - prompt: new Prompt({ text: "promote me" }), - timeCreated: created, + prompt: Prompt.make({ text: "promote me" }), + delivery: "steer", }) expect( yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), - ).toMatchObject({ promoted_seq: event.seq }) + ).toMatchObject({ promoted_seq: event.durable?.seq }) }), ) @@ -334,137 +377,9 @@ describe("SessionProjector", () => { }), ) - it.effect("rejects a Prompted event that conflicts with an admitted inbox row", () => - Effect.gen(function* () { - const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) - .run() - .pipe(Effect.orDie) - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: Project.ID.global, - slug: "test", - directory: "/project", - title: "test", - version: "test", - }) - .run() - .pipe(Effect.orDie) - const events = yield* EventV2.Service - const id = SessionMessage.ID.make("msg_conflict") - yield* SessionInput.admit(db, events, { - id, - sessionID, - prompt: new Prompt({ text: "admitted" }), - delivery: "steer", - }) - - const exit = yield* events - .publish(SessionEvent.Prompted, { - sessionID, - messageID: id, - timestamp: created, - prompt: new Prompt({ text: "different" }), - delivery: "steer", - }) - .pipe(Effect.exit) - - expect(String(exit)).toContain("SessionInput.LifecycleConflict") - expect( - yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), - ).toMatchObject({ promoted_seq: null }) - }), - ) - - it.effect("rejects an assistant message ID that conflicts with an admitted inbox row", () => - Effect.gen(function* () { - const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) - .run() - .pipe(Effect.orDie) - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: Project.ID.global, - slug: "test", - directory: "/project", - title: "test", - version: "test", - }) - .run() - .pipe(Effect.orDie) - const events = yield* EventV2.Service - const id = SessionMessage.ID.make("msg_conflict") - yield* SessionInput.admit(db, events, { - id, - sessionID, - prompt: new Prompt({ text: "admitted" }), - delivery: "steer", - }) - - const exit = yield* events - .publish(SessionEvent.Step.Started, { - sessionID, - timestamp: created, - assistantMessageID: id, - agent: "build", - model, - }) - .pipe(Effect.exit) - - expect(String(exit)).toContain("SessionInput.LifecycleConflict") - expect( - yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie), - ).toBeUndefined() - }), - ) - - it.effect("rejects a Prompted delivery mode that conflicts with an admitted inbox row", () => - Effect.gen(function* () { - const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) - .run() - .pipe(Effect.orDie) - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: Project.ID.global, - slug: "test", - directory: "/project", - title: "test", - version: "test", - }) - .run() - .pipe(Effect.orDie) - const events = yield* EventV2.Service - const id = SessionMessage.ID.make("msg_delivery_conflict") - const prompt = new Prompt({ text: "admitted" }) - yield* SessionInput.admit(db, events, { id, sessionID, prompt, delivery: "queue" }) - - const exit = yield* events - .publish(SessionEvent.Prompted, { sessionID, messageID: id, timestamp: created, prompt, delivery: "steer" }) - .pipe(Effect.exit) - - expect(String(exit)).toContain("SessionInput.LifecycleConflict") - expect( - yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), - ).toMatchObject({ delivery: "queue", promoted_seq: null }) - }), - ) - it.effect("does not revive a stale incomplete in-memory assistant projection", () => Effect.gen(function* () { - const stale = new SessionMessage.Assistant({ + const stale = SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", agent: "build", @@ -472,7 +387,7 @@ describe("SessionProjector", () => { content: [], time: { created }, }) - const completed = new SessionMessage.Assistant({ + const completed = SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", agent: "build", @@ -596,15 +511,15 @@ describe("SessionProjector", () => { Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }), ) expect(messages).toEqual([ - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", agent: "build", model, - content: [new SessionMessage.AssistantText({ type: "text", id: "text-stale", text: "" })], + content: [SessionMessage.AssistantText.make({ type: "text", id: "text-stale", text: "" })], time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, }), - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", agent: "build", diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index d663cb715b..c6bc9430b3 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -2,6 +2,8 @@ import { describe, expect } from "bun:test" import { DateTime, Effect, Fiber, Layer, Stream } from "effect" import { eq } from "drizzle-orm" 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" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" import { SessionEvent } from "@opencode-ai/core/session/event" @@ -18,42 +20,34 @@ import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode- import { SessionStore } from "@opencode-ai/core/session/store" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const executionCalls: SessionV2.ID[] = [] const interruptCalls: SessionV2.ID[] = [] -const interruptSeqs: Array = [] const wakeCalls: SessionV2.ID[] = [] -const wakeSeqs: Array = [] +const activeSessions = new Set() const execution = Layer.succeed( SessionExecution.Service, SessionExecution.Service.of({ + active: Effect.sync(() => new Set(activeSessions)), resume: (sessionID) => Effect.sync(() => { executionCalls.push(sessionID) }), - interrupt: (sessionID, seq) => + interrupt: (sessionID) => Effect.sync(() => { interruptCalls.push(sessionID) - interruptSeqs.push(seq) }), - wake: (sessionID, seq) => + wake: (sessionID) => Effect.sync(() => { wakeCalls.push(sessionID) - wakeSeqs.push(seq) }), }), ) -const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), - Layer.provide(Project.defaultLayer), - Layer.provide(execution), +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), + [[SessionExecution.node, execution]], + ), ) -const it = testEffect(Layer.mergeAll(database, events, projector, store, execution, sessions)) const sessionID = SessionV2.ID.make("ses_prompt_test") const messageID = SessionMessage.ID.create() @@ -104,16 +98,14 @@ const eventCount = (type: string) => ), ) -const interruptEvent = Database.Service.use(({ db }) => - db - .select() - .from(EventTable) - .where(eq(EventTable.type, "session.next.interrupt.requested.1")) - .get() - .pipe(Effect.orDie), -) - describe("SessionV2.prompt", () => { + it.effect("exposes the execution registry", () => + Effect.gen(function* () { + activeSessions.add(sessionID) + expect(Array.from(yield* (yield* SessionV2.Service).active)).toEqual([sessionID]) + }).pipe(Effect.ensuring(Effect.sync(() => activeSessions.clear()))), + ) + it.effect("delegates execution continuation through SessionExecution", () => Effect.gen(function* () { yield* setup @@ -126,19 +118,14 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("delegates interruption through SessionExecution", () => + it.effect("delegates process-local interruption through SessionExecution", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service interruptCalls.length = 0 - interruptSeqs.length = 0 yield* session.interrupt(sessionID) expect(interruptCalls).toEqual([sessionID]) - expect(interruptSeqs).toHaveLength(1) - expect(typeof interruptSeqs[0]).toBe("number") - expect(yield* eventCount("session.next.interrupt.requested.1")).toBe(1) - expect(yield* interruptEvent).toMatchObject({ aggregate_id: sessionID, seq: interruptSeqs[0] }) expect(yield* session.messages({ sessionID })).toEqual([]) }), ) @@ -147,11 +134,9 @@ describe("SessionV2.prompt", () => { Effect.gen(function* () { const session = yield* SessionV2.Service interruptCalls.length = 0 - interruptSeqs.length = 0 yield* session.interrupt(SessionV2.ID.make("ses_missing")) expect(interruptCalls).toEqual([SessionV2.ID.make("ses_missing")]) - expect(interruptSeqs).toEqual([undefined]) }), ) @@ -162,7 +147,7 @@ describe("SessionV2.prompt", () => { const message = yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false, }) @@ -177,7 +162,28 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("streams durable Session events after an aggregate cursor", () => + it.effect("resolves attachment MIME before admission", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + + const message = yield* session.prompt({ + sessionID, + prompt: { + text: "Inspect this image", + files: [{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png" }], + }, + resume: false, + }) + + expect(message.prompt.files).toEqual([ + { uri: "data:image/png;base64,aGVsbG8=", name: "image.png", mime: "image/png" }, + ]) + expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files) + }), + ) + + it.effect("streams durable Session events after an aggregate sequence", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -186,22 +192,24 @@ describe("SessionV2.prompt", () => { const fiber = yield* session.events({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER) const streamed = Array.from(yield* Fiber.join(fiber)) - expect(streamed.map((event) => [event.cursor, event.event.type])).toEqual([ - [EventV2.Cursor.make(0), "session.next.prompt.admitted"], - [EventV2.Cursor.make(1), "session.next.prompt.admitted"], - [EventV2.Cursor.make(2), "session.next.prompt.promoted"], - [EventV2.Cursor.make(3), "session.next.prompt.promoted"], + expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([ + [0, "session.next.prompt.admitted"], + [1, "session.next.prompt.admitted"], + [2, "session.next.prompted"], + [3, "session.next.prompted"], ]) expect( Array.from( - yield* session.events({ sessionID, after: streamed[0]!.cursor }).pipe(Stream.take(1), Stream.runCollect), - ).map((event) => [event.cursor, event.event.type]), - ).toEqual([[EventV2.Cursor.make(1), "session.next.prompt.admitted"]]) + yield* session + .events({ sessionID, after: streamed[0]!.durable?.seq }) + .pipe(Stream.take(1), Stream.runCollect), + ).map((event) => [event.durable?.seq, event.type]), + ).toEqual([[1, "session.next.prompt.admitted"]]) }), ) @@ -211,7 +219,7 @@ describe("SessionV2.prompt", () => { const session = yield* SessionV2.Service const message = yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false, }) @@ -230,7 +238,7 @@ describe("SessionV2.prompt", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - const input = { sessionID, prompt: new Prompt({ text: "Fix the failing tests" }), resume: false } + const input = { sessionID, prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false } const first = yield* session.prompt(input) const second = yield* session.prompt(input) @@ -248,7 +256,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false, } @@ -268,7 +276,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: new Prompt({ text: "Recover committed prompt" }), + prompt: Prompt.make({ text: "Recover committed prompt" }), resume: false, } const first = yield* session.prompt(input) @@ -289,13 +297,13 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ sessionID, id: messageID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), }) const failure = yield* session .prompt({ sessionID, id: messageID, - prompt: new Prompt({ text: "Delete the failing tests" }), + prompt: Prompt.make({ text: "Delete the failing tests" }), resume: false, }) .pipe(Effect.flip) @@ -314,14 +322,14 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ id: messageID, sessionID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false, }) const failure = yield* session .prompt({ id: messageID, sessionID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), delivery: "queue", resume: false, }) @@ -338,7 +346,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false, } @@ -347,7 +355,7 @@ describe("SessionV2.prompt", () => { expect(messages[1]).toEqual(messages[0]) expect(yield* session.messages({ sessionID })).toEqual([]) expect(yield* admittedCount).toBe(1) - expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1))).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1))).toBe(1) }), ) @@ -357,7 +365,7 @@ describe("SessionV2.prompt", () => { const { db } = yield* Database.Service const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Promote once" }), resume: false }) + yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Promote once" }), resume: false }) yield* Effect.all( [ @@ -367,7 +375,7 @@ describe("SessionV2.prompt", () => { { concurrency: "unbounded" }, ) - expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1))).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.Prompted.type, 1))).toBe(1) expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 }) expect(yield* session.messages({ sessionID })).toMatchObject([ { id: messageID, type: "user", text: "Promote once" }, @@ -375,15 +383,15 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("promotes steers only through the captured aggregate cutoff", () => + it.effect("promotes steers only through the captured inbox cutoff", () => Effect.gen(function* () { yield* setup const { db } = yield* Database.Service const session = yield* SessionV2.Service const events = yield* EventV2.Service - const first = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Before cutoff" }), resume: false }) - const cutoff = yield* SessionInput.latestSeq(db, sessionID) - const second = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "After cutoff" }), resume: false }) + const first = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Before cutoff" }), resume: false }) + const cutoff = first.admittedSeq + const second = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "After cutoff" }), resume: false }) yield* SessionInput.promoteSteers(db, events, sessionID, cutoff) @@ -392,14 +400,19 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("reprojects one pending lifecycle without scheduling execution", () => + it.effect("reprojects pending inbox input without scheduling execution", () => Effect.gen(function* () { yield* setup const { db } = yield* Database.Service const session = yield* SessionV2.Service const events = yield* EventV2.Service wakeCalls.length = 0 - yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Replay pending" }), resume: false }) + yield* session.prompt({ + id: messageID, + sessionID, + prompt: Prompt.make({ text: "Replay pending" }), + resume: false, + }) const recorded = yield* db .select() .from(EventTable) @@ -435,7 +448,7 @@ describe("SessionV2.prompt", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - const prompt = new Prompt({ text: "Historical prompt" }) + const prompt = Prompt.make({ text: "Historical prompt" }) yield* events.publish(SessionEvent.Prompted, { sessionID, messageID, @@ -456,7 +469,7 @@ describe("SessionV2.prompt", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - const prompt = new Prompt({ text: "Historical queued prompt" }) + const prompt = Prompt.make({ text: "Historical queued prompt" }) yield* events.publish(SessionEvent.Prompted, { sessionID, messageID, @@ -472,58 +485,6 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("rejects an input ID already used by a durable non-prompt event", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - yield* events.publish(SessionEvent.Synthetic, { - sessionID, - messageID, - timestamp: yield* DateTime.now, - text: "Collision", - }) - - const failure = yield* session - .prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Collision" }), resume: false }) - .pipe(Effect.flip) - - expect(failure._tag).toBe("Session.PromptConflictError") - expect(yield* admitted(messageID)).toBeUndefined() - }), - ) - - it.effect("rejects a durable event ID reserved by an admitted prompt without poisoning promotion", () => - Effect.gen(function* () { - yield* setup - const { db } = yield* Database.Service - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - const prompt = new Prompt({ text: "Reserved prompt" }) - yield* session.prompt({ id: messageID, sessionID, prompt, resume: false }) - - const failure = yield* events - .publish(SessionEvent.Synthetic, { - sessionID, - messageID, - timestamp: yield* DateTime.now, - text: "Conflicting synthetic", - }) - .pipe(Effect.catchDefect(Effect.succeed)) - - expect(String(failure)).toContain("SessionInput.LifecycleConflict") - expect(yield* admitted(messageID)).not.toHaveProperty("promotedSeq") - expect(yield* session.messages({ sessionID })).toEqual([]) - - yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER) - - expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 }) - expect(yield* session.messages({ sessionID })).toMatchObject([ - { id: messageID, type: "user", text: "Reserved prompt" }, - ]) - }), - ) - it.effect("rejects reuse of one globally unique message ID across sessions", () => Effect.gen(function* () { yield* setup @@ -543,7 +504,7 @@ describe("SessionV2.prompt", () => { .onConflictDoNothing() .run() .pipe(Effect.orDie) - const prompt = new Prompt({ text: "Fix the failing tests" }) + const prompt = Prompt.make({ text: "Fix the failing tests" }) yield* session.prompt({ id: messageID, sessionID, prompt, resume: false }) const failure = yield* session @@ -554,19 +515,38 @@ describe("SessionV2.prompt", () => { }), ) + it.effect("rejects a prompt ID already used by visible Session history", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* events.publish(SessionEvent.Synthetic, { + sessionID, + messageID, + timestamp: yield* DateTime.now, + text: "Existing history", + }) + + const failure = yield* session + .prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Conflicting prompt" }), resume: false }) + .pipe(Effect.flip) + + expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID }) + expect(yield* admitted(messageID)).toBeUndefined() + }), + ) + it.effect("starts execution by default after recording the prompt", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service executionCalls.length = 0 wakeCalls.length = 0 - wakeSeqs.length = 0 - const admitted = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run by default" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run by default" }) }) expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([sessionID]) - expect(wakeSeqs).toEqual([admitted.admittedSeq]) }), ) @@ -576,17 +556,15 @@ describe("SessionV2.prompt", () => { const session = yield* SessionV2.Service executionCalls.length = 0 wakeCalls.length = 0 - wakeSeqs.length = 0 - const admitted = yield* session.prompt({ + yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Run explicitly" }), + prompt: Prompt.make({ text: "Run explicitly" }), resume: true, }) expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([sessionID]) - expect(wakeSeqs).toEqual([admitted.admittedSeq]) }), ) @@ -596,13 +574,11 @@ describe("SessionV2.prompt", () => { const session = yield* SessionV2.Service executionCalls.length = 0 wakeCalls.length = 0 - wakeSeqs.length = 0 - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Do not run" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Do not run" }), resume: false }) expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([]) - expect(wakeSeqs).toEqual([]) }), ) }) diff --git a/packages/core/test/session-run-coordinator.test.ts b/packages/core/test/session-run-coordinator.test.ts index 39fb5779d3..dfbeda664c 100644 --- a/packages/core/test/session-run-coordinator.test.ts +++ b/packages/core/test/session-run-coordinator.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Cause, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" import { testEffect } from "./lib/effect" @@ -22,14 +22,39 @@ describe("SessionRunCoordinator", () => { expect(runs).toBe(1) yield* Deferred.succeed(gate, undefined) - yield* Fiber.join(first) - yield* Fiber.join(second) + yield* Effect.all([Fiber.join(first), Fiber.join(second)]) expect(runs).toBe(1) }), ), ) - it.effect("starts a drain when woken while idle", () => + it.effect("joins a wake-started execution without forcing a successor", () => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make() + const gate = yield* Deferred.make() + const forces: boolean[] = [] + const coordinator = yield* SessionRunCoordinator.make({ + drain: (_key, force) => + Effect.sync(() => forces.push(force)).pipe( + Effect.andThen(Deferred.succeed(started, undefined)), + Effect.andThen(Deferred.await(gate)), + ), + }) + + yield* coordinator.wake("session") + yield* Deferred.await(started) + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) + yield* Effect.yieldNow + yield* Deferred.succeed(gate, undefined) + yield* Fiber.join(resumed) + + expect(forces).toEqual([false]) + }), + ), + ) + + it.effect("starts execution when woken while idle", () => Effect.scoped( Effect.gen(function* () { const drained = yield* Deferred.make() @@ -41,632 +66,117 @@ describe("SessionRunCoordinator", () => { ), ) - it.effect("does nothing when interrupted while idle", () => - Effect.scoped( - Effect.gen(function* () { - const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void }) - - yield* coordinator.interrupt("session") - }), - ), - ) - - it.effect("suppresses stale wakes after an idle interrupt boundary", () => - Effect.scoped( - Effect.gen(function* () { - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.sync(() => runs++) }) - - yield* coordinator.interrupt("session", 2) - yield* coordinator.wake("session", 1) - yield* coordinator.awaitIdle("session") - expect(runs).toBe(0) - - yield* coordinator.wake("session", 3) - yield* coordinator.awaitIdle("session") - expect(runs).toBe(1) - }), - ), - ) - - it.effect("does not interrupt a wake newer than the interrupt boundary", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make() - const gate = yield* Deferred.make() - const interrupted = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Deferred.succeed(started, undefined).pipe( - Effect.andThen(Deferred.await(gate)), - Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), - ), - }) - - yield* coordinator.wake("session", 3) - yield* Deferred.await(started) - yield* coordinator.interrupt("session", 2) - expect(yield* Deferred.isDone(interrupted)).toBeFalse() - yield* Deferred.succeed(gate, undefined) - yield* coordinator.awaitIdle("session") - }), - ), - ) - - it.effect("preserves a queued wake newer than the interrupt boundary", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never)) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session", 1) - yield* Deferred.await(firstStarted) - yield* coordinator.wake("session", 3) - yield* coordinator.interrupt("session", 2) - yield* Deferred.await(secondStarted) - yield* coordinator.awaitIdle("session").pipe(Effect.exit) - - expect(runs).toBe(2) - }), - ), - ) - - it.effect("interrupts only the requested key", () => + it.effect("snapshots only active executions", () => Effect.scoped( Effect.gen(function* () { const firstStarted = yield* Deferred.make() const secondStarted = yield* Deferred.make() + const firstGate = yield* Deferred.make() const secondGate = yield* Deferred.make() - const secondInterrupted = yield* Deferred.make() const coordinator = yield* SessionRunCoordinator.make({ drain: (key: string) => - key === "first" - ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never)) - : Deferred.succeed(secondStarted, undefined).pipe( - Effect.andThen(Deferred.await(secondGate)), - Effect.onInterrupt(() => Deferred.succeed(secondInterrupted, undefined)), - ), - }) - - yield* coordinator.wake("first") - yield* coordinator.wake("second") - yield* Effect.all([Deferred.await(firstStarted), Deferred.await(secondStarted)]) - - yield* coordinator.interrupt("first") - expect(yield* Deferred.isDone(secondInterrupted)).toBeFalse() - yield* Deferred.succeed(secondGate, undefined) - yield* coordinator.awaitIdle("second") - }), - ), - ) - - it.effect("interrupts the active drain and suppresses its queued wake", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const interrupted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), - ) - : Effect.void, - ), + Deferred.succeed(key === "first" ? firstStarted : secondStarted, undefined).pipe( + Effect.andThen(Deferred.await(key === "first" ? firstGate : secondGate)), ), }) - const run = yield* coordinator.run("session").pipe(Effect.forkChild) + expect(Array.from(yield* coordinator.active)).toEqual([]) + const first = yield* coordinator.run("first").pipe(Effect.forkChild) yield* Deferred.await(firstStarted) - yield* coordinator.wake("session") + expect(Array.from(yield* coordinator.active)).toEqual(["first"]) - yield* coordinator.interrupt("session") - yield* Deferred.await(interrupted) - yield* coordinator.awaitIdle("session") - const exit = yield* Fiber.await(run) - expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue() - expect(runs).toBe(1) - yield* coordinator.interrupt("session") - }), - ), - ) - - it.effect("suppresses a wake received during interruption cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const firstInterrupted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const interrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild) - yield* Effect.yieldNow - yield* coordinator.wake("session", 1) - yield* Deferred.await(firstInterrupted) - expect(runs).toBe(1) - yield* Deferred.succeed(cleanupGate, undefined) - yield* Fiber.join(interrupt) - yield* coordinator.awaitIdle("session") - - expect(runs).toBe(1) - yield* coordinator.wake("session", 3) + const second = yield* coordinator.run("second").pipe(Effect.forkChild) yield* Deferred.await(secondStarted) - yield* coordinator.awaitIdle("session") - expect(runs).toBe(2) - }), - ), - ) - - it.effect("remembers a wake received after the interrupt boundary during cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const firstInterrupted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const interrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild) - yield* Deferred.await(firstInterrupted) - yield* coordinator.wake("session", 3) - const staleInterrupt = yield* coordinator.interrupt("session", 1).pipe(Effect.forkChild) - expect(runs).toBe(1) - yield* Deferred.succeed(cleanupGate, undefined) - yield* Fiber.join(interrupt) - yield* Fiber.join(staleInterrupt) - yield* Deferred.await(secondStarted) - yield* coordinator.awaitIdle("session") - - expect(runs).toBe(2) - }), - ), - ) - - it.effect("moves the stop barrier forward for repeated interrupts", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const firstInterrupted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const firstInterrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild) - yield* Deferred.await(firstInterrupted) - yield* coordinator.wake("session", 3) - const secondInterrupt = yield* coordinator.interrupt("session", 4).pipe(Effect.forkChild) - yield* Deferred.succeed(cleanupGate, undefined) - yield* Fiber.join(firstInterrupt) - yield* Fiber.join(secondInterrupt) - yield* coordinator.awaitIdle("session") - expect(runs).toBe(1) - - yield* coordinator.wake("session", 5) - yield* Deferred.await(secondStarted) - yield* coordinator.awaitIdle("session") - expect(runs).toBe(2) - }), - ), - ) - - it.effect("interrupts an explicit run queued before the interruption request", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never)) : Effect.void, - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Effect.yieldNow - - yield* coordinator.interrupt("session") - const exit = yield* Fiber.await(run) - expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue() - expect(runs).toBe(1) - }), - ), - ) - - it.effect("settles a pre-interrupt explicit run only after active wake cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const runSettled = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Deferred.succeed(started, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(started) - const run = yield* coordinator - .run("session") - .pipe(Effect.exit, Effect.ensuring(Deferred.succeed(runSettled, undefined)), Effect.forkChild) - const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.await(cleanupStarted) - - expect(yield* Deferred.isDone(runSettled)).toBeFalse() - yield* Deferred.succeed(cleanupGate, undefined) - const runExit = yield* Fiber.join(run) - expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue() - yield* Fiber.join(interrupt) - }), - ), - ) - - it.effect("starts an explicit run arriving during interrupt cleanup after the stop barrier", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.await(cleanupStarted) - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Deferred.succeed(cleanupGate, undefined) - yield* Fiber.join(interrupt) - yield* Fiber.join(run) - yield* Deferred.await(secondStarted) - expect(runs).toBe(2) - }), - ), - ) - - it.effect("interrupts pre-stop waiters and runs post-stop waiters after cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const before = yield* coordinator.run("session").pipe(Effect.exit, Effect.forkChild) - const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.await(cleanupStarted) - const after = yield* coordinator.run("session").pipe(Effect.exit, Effect.forkChild) - yield* Deferred.succeed(cleanupGate, undefined) - - const beforeExit = yield* Fiber.join(before) - expect(Exit.isFailure(beforeExit) && Cause.hasInterruptsOnly(beforeExit.cause)).toBeTrue() - yield* Fiber.join(interrupt) - yield* Fiber.join(after) - yield* Deferred.await(secondStarted) - expect(runs).toBe(2) - }), - ), - ) - - it.effect("waits for interrupt cleanup before settling callers", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const runSettled = yield* Deferred.make() - const idleSettled = yield* Deferred.make() - const interruptSettled = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Deferred.succeed(started, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ), - }) - - const run = yield* coordinator - .run("session") - .pipe(Effect.ensuring(Deferred.succeed(runSettled, undefined)), Effect.forkChild) - yield* Deferred.await(started) - const idle = yield* coordinator - .awaitIdle("session") - .pipe(Effect.exit, Effect.ensuring(Deferred.succeed(idleSettled, undefined)), Effect.forkChild) - const interrupt = yield* coordinator - .interrupt("session") - .pipe(Effect.ensuring(Deferred.succeed(interruptSettled, undefined)), Effect.forkChild) - yield* Deferred.await(cleanupStarted) - - expect(yield* Deferred.isDone(runSettled)).toBeFalse() - expect(yield* Deferred.isDone(idleSettled)).toBeFalse() - expect(yield* Deferred.isDone(interruptSettled)).toBeFalse() - yield* Deferred.succeed(cleanupGate, undefined) - const runExit = yield* Fiber.await(run) - const idleExit = yield* Fiber.join(idle) - expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue() - expect(Exit.isFailure(idleExit) && Cause.hasInterruptsOnly(idleExit.cause)).toBeTrue() - yield* Fiber.join(interrupt) - }), - ), - ) - - it.effect("joins concurrent interruption requests for one active drain", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Deferred.succeed(started, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(started) - const first = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.await(cleanupStarted) - const second = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.succeed(cleanupGate, undefined) + expect(Array.from(yield* coordinator.active)).toEqual(["first", "second"]) + yield* Deferred.succeed(firstGate, undefined) yield* Fiber.join(first) + expect(Array.from(yield* coordinator.active)).toEqual(["second"]) + yield* Deferred.succeed(secondGate, undefined) yield* Fiber.join(second) + expect(Array.from(yield* coordinator.active)).toEqual([]) }), ), ) - it.effect("does not discard a post-stop explicit run when interrupted again", () => + it.effect("cleans active executions after failure and defect", () => + Effect.scoped( + Effect.gen(function* () { + const failure = new Error("failed") + const defect = new Error("defect") + const coordinator = yield* SessionRunCoordinator.make({ + drain: (key: string) => (key === "failure" ? Effect.fail(failure) : Effect.die(defect)), + }) + + const failed = yield* coordinator.run("failure").pipe(Effect.exit) + expect(Exit.isFailure(failed) && Cause.hasFails(failed.cause)).toBeTrue() + expect(Array.from(yield* coordinator.active)).toEqual([]) + + const died = yield* coordinator.run("defect").pipe(Effect.exit) + expect(Exit.isFailure(died) && Cause.hasDies(died.cause)).toBeTrue() + expect(Array.from(yield* coordinator.active)).toEqual([]) + }), + ), + ) + + it.effect("cleans active executions when its scope closes", () => + Effect.gen(function* () { + const started = yield* Deferred.make() + const coordinator = yield* Effect.scoped( + Effect.gen(function* () { + const coordinator = yield* SessionRunCoordinator.make({ + drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), + }) + yield* coordinator.wake("session") + yield* Deferred.await(started) + expect(Array.from(yield* coordinator.active)).toEqual(["session"]) + return coordinator + }), + ) + + expect(Array.from(yield* coordinator.active)).toEqual([]) + }), + ) + + it.effect("coalesces wakes received during active execution", () => Effect.scoped( Effect.gen(function* () { const firstStarted = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() + const firstGate = yield* Deferred.make() const secondStarted = yield* Deferred.make() let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ + const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.sync(() => ++runs).pipe( Effect.flatMap((run) => run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) + ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(firstGate))) : Deferred.succeed(secondStarted, undefined), ), ), }) - yield* coordinator.wake("session") + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) yield* Deferred.await(firstStarted) - const firstInterrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.await(cleanupStarted) - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - const secondInterrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.succeed(cleanupGate, undefined) - - yield* Effect.all([Fiber.join(firstInterrupt), Fiber.join(secondInterrupt), Fiber.join(run)]) - yield* Deferred.await(secondStarted) - expect(runs).toBe(2) - }), - ), - ) - - it.effect("coalesces wakes received during an active run", () => - Effect.scoped( - Effect.gen(function* () { - const gate = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe(Effect.flatMap((run) => (run === 1 ? Deferred.await(gate) : Effect.void))), - }) - - const first = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Effect.yieldNow yield* Effect.all([coordinator.wake("session"), coordinator.wake("session"), coordinator.wake("session")], { concurrency: "unbounded", }) - yield* Deferred.succeed(gate, undefined) - yield* Fiber.join(first) - - expect(runs).toBe(2) - }), - ), - ) - - it.effect("waits for a coalesced ownership chain to become idle", () => - Effect.scoped( - Effect.gen(function* () { - const firstGate = yield* Deferred.make() - const secondGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - const idleSettled = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.await(firstGate) - : Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))), - ), - ), - }) - - yield* coordinator.wake("session") - const idle = yield* coordinator - .awaitIdle("session") - .pipe(Effect.andThen(Deferred.succeed(idleSettled, undefined)), Effect.forkChild) - yield* coordinator.wake("session") yield* Deferred.succeed(firstGate, undefined) yield* Deferred.await(secondStarted) - expect(yield* Deferred.isDone(idleSettled)).toBeFalse() - yield* Deferred.succeed(secondGate, undefined) - yield* Fiber.join(idle) + yield* Fiber.join(resumed) expect(runs).toBe(2) }), ), ) - it.effect("reports the first defect after a failed chain becomes idle", () => - Effect.scoped( - Effect.gen(function* () { - const firstGate = yield* Deferred.make() - const secondGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - const defect = new Error("defect") - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.await(firstGate).pipe(Effect.andThen(Effect.die(defect))) - : Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))), - ), - ), - }) - - yield* coordinator.wake("session") - const idle = yield* coordinator - .awaitIdle("session") - .pipe(Effect.catchDefect(Effect.succeed), Effect.forkChild({ startImmediately: true })) - yield* coordinator.wake("session") - yield* Deferred.succeed(firstGate, undefined) - yield* Deferred.await(secondStarted) - yield* Deferred.succeed(secondGate, undefined) - - expect(yield* Fiber.join(idle)).toBe(defect) - expect(runs).toBe(2) - }), - ), - ) - - it.effect("runs again when woken during the coalesced drain", () => + it.effect("runs again when woken during the follow-up", () => Effect.scoped( Effect.gen(function* () { const firstGate = yield* Deferred.make() const secondStarted = yield* Deferred.make() const secondGate = yield* Deferred.make() + const thirdStarted = yield* Deferred.make() let runs = 0 const coordinator = yield* SessionRunCoordinator.make({ drain: () => @@ -676,196 +186,169 @@ describe("SessionRunCoordinator", () => { ? Deferred.await(firstGate) : run === 2 ? Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))) - : Effect.void, + : Deferred.succeed(thirdStarted, undefined), ), ), }) - const first = yield* coordinator.run("session").pipe(Effect.forkChild) + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) yield* Effect.yieldNow yield* coordinator.wake("session") yield* Deferred.succeed(firstGate, undefined) yield* Deferred.await(secondStarted) yield* coordinator.wake("session") yield* Deferred.succeed(secondGate, undefined) - yield* Fiber.join(first) + yield* Deferred.await(thirdStarted) + yield* Fiber.join(resumed) expect(runs).toBe(3) }), ), ) - it.effect("starts one successor after a wake races with failure", () => + it.effect("does nothing when interrupted while idle", () => + Effect.scoped( + Effect.gen(function* () { + const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void }) + yield* coordinator.interrupt("session") + }), + ), + ) + + it.effect("interrupts active execution and clears its pending wake", () => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make() + const interrupted = yield* Deferred.make() + let runs = 0 + const coordinator = yield* SessionRunCoordinator.make({ + drain: () => + Effect.sync(() => ++runs).pipe( + Effect.andThen(Deferred.succeed(started, undefined)), + Effect.andThen(Effect.never), + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), + ), + }) + + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) + yield* Deferred.await(started) + yield* coordinator.wake("session") + yield* coordinator.interrupt("session") + yield* Deferred.await(interrupted) + + const exit = yield* Fiber.await(resumed) + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue() + expect(Array.from(yield* coordinator.active)).toEqual([]) + expect(runs).toBe(1) + }), + ), + ) + + it.effect("runs a wake registered during interruption cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const firstStarted = yield* Deferred.make() + const cleanupStarted = yield* Deferred.make() + const cleanupGate = yield* Deferred.make() + const secondStarted = yield* Deferred.make() + let runs = 0 + const coordinator = yield* SessionRunCoordinator.make({ + drain: () => + Effect.sync(() => ++runs).pipe( + Effect.flatMap((run) => + run === 1 + ? Deferred.succeed(firstStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), + ), + ) + : Deferred.succeed(secondStarted, undefined), + ), + ), + }) + + yield* coordinator.wake("session") + yield* Deferred.await(firstStarted) + const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) + yield* Deferred.await(cleanupStarted) + yield* coordinator.wake("session") + yield* Deferred.succeed(cleanupGate, undefined) + yield* Fiber.join(interrupt) + yield* Deferred.await(secondStarted) + + expect(runs).toBe(2) + }), + ), + ) + + it.effect("starts a resume registered during interruption cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const firstStarted = yield* Deferred.make() + const cleanupStarted = yield* Deferred.make() + const cleanupGate = yield* Deferred.make() + const secondStarted = yield* Deferred.make() + const forces: boolean[] = [] + const coordinator = yield* SessionRunCoordinator.make({ + drain: (_key, force) => { + forces.push(force) + return forces.length === 1 + ? Deferred.succeed(firstStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), + ), + ) + : Deferred.succeed(secondStarted, undefined) + }, + }) + + yield* coordinator.wake("session") + yield* Deferred.await(firstStarted) + const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) + yield* Deferred.await(cleanupStarted) + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) + yield* Deferred.succeed(cleanupGate, undefined) + yield* Effect.all([Fiber.join(interrupt), Fiber.join(resumed)]) + yield* Deferred.await(secondStarted) + + expect(forces).toEqual([false, true]) + }), + ), + ) + + it.effect("starts one follow-up when a wake races with failure", () => Effect.scoped( Effect.gen(function* () { const gate = yield* Deferred.make() + const secondStarted = yield* Deferred.make() const failure = new Error("failed") let runs = 0 const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.sync(() => ++runs).pipe( Effect.flatMap((run) => - run === 1 ? Deferred.await(gate).pipe(Effect.andThen(Effect.fail(failure))) : Effect.void, + run === 1 + ? Deferred.await(gate).pipe(Effect.andThen(Effect.fail(failure))) + : Deferred.succeed(secondStarted, undefined), ), ), }) - const first = yield* coordinator.run("session").pipe(Effect.forkChild) + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) yield* Effect.yieldNow yield* coordinator.wake("session") yield* Deferred.succeed(gate, undefined) - expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(failure) - yield* Effect.yieldNow + expect(yield* Fiber.join(resumed).pipe(Effect.flip)).toBe(failure) + yield* Deferred.await(secondStarted) expect(runs).toBe(2) }), ), ) - it.effect("upgrades an active wake when an explicit run joins it", () => - Effect.scoped( - Effect.gen(function* () { - const wakeStarted = yield* Deferred.make() - const wakeGate = yield* Deferred.make() - const modes: SessionRunCoordinator.Mode[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - drain: (_key, mode) => - Effect.sync(() => modes.push(mode)).pipe( - Effect.andThen( - mode === "wake" - ? Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate))) - : Effect.void, - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(wakeStarted) - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Deferred.succeed(wakeGate, undefined) - yield* Fiber.join(run) - - expect(modes).toEqual(["wake", "run"]) - }), - ), - ) - - it.effect("upgrades a recursive wake drain when an explicit run joins it", () => - Effect.scoped( - Effect.gen(function* () { - const runGate = yield* Deferred.make() - const wakeStarted = yield* Deferred.make() - const wakeGate = yield* Deferred.make() - const forcedStarted = yield* Deferred.make() - const modes: SessionRunCoordinator.Mode[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - drain: (_key, mode) => - Effect.gen(function* () { - modes.push(mode) - if (modes.length === 1) return yield* Deferred.await(runGate) - if (modes.length === 2) - return yield* Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate))) - yield* Deferred.succeed(forcedStarted, undefined) - }), - }) - - const first = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Effect.yieldNow - yield* coordinator.wake("session") - yield* Deferred.succeed(runGate, undefined) - yield* Deferred.await(wakeStarted) - const second = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Deferred.succeed(wakeGate, undefined) - yield* Deferred.await(forcedStarted) - yield* Fiber.join(first) - yield* Fiber.join(second) - - expect(modes).toEqual(["run", "wake", "run"]) - }), - ), - ) - - it.effect("propagates an upgraded explicit run failure before a successful advisory successor", () => - Effect.scoped( - Effect.gen(function* () { - const wakeStarted = yield* Deferred.make() - const wakeGate = yield* Deferred.make() - const runStarted = yield* Deferred.make() - const runGate = yield* Deferred.make() - const advisoryStarted = yield* Deferred.make() - const failure = new Error("explicit run failed") - const modes: SessionRunCoordinator.Mode[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - drain: (_key, mode) => - Effect.sync(() => modes.push(mode)).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate))) - : run === 2 - ? Deferred.succeed(runStarted, undefined).pipe( - Effect.andThen(Deferred.await(runGate)), - Effect.andThen(Effect.fail(failure)), - ) - : Deferred.succeed(advisoryStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(wakeStarted) - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Deferred.succeed(wakeGate, undefined) - yield* Deferred.await(runStarted) - yield* coordinator.wake("session") - yield* Deferred.succeed(runGate, undefined) - yield* Deferred.await(advisoryStarted) - - expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure) - expect(modes).toEqual(["wake", "run", "wake"]) - }), - ), - ) - - it.effect("settles active callers when its owning scope closes", () => - Effect.gen(function* () { - const scope = yield* Scope.make() - const started = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), - }).pipe(Scope.provide(scope)) - - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Deferred.await(started) - const idle = yield* coordinator.awaitIdle("session").pipe(Effect.forkChild) - yield* Effect.yieldNow - yield* Scope.close(scope, Exit.void) - - const runExit = yield* Fiber.await(run) - const idleExit = yield* Fiber.await(idle) - expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue() - expect(Exit.isSuccess(idleExit)).toBeTrue() - }), - ) - - it.effect("does not start work after its owning scope closes", () => - Effect.gen(function* () { - const scope = yield* Scope.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Effect.sync(() => runs++), - }).pipe(Scope.provide(scope)) - yield* Scope.close(scope, Exit.void) - - yield* coordinator.wake("session") - yield* coordinator.awaitIdle("session") - const runExit = yield* coordinator.run("session").pipe(Effect.exit) - - expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue() - expect(runs).toBe(0) - }), - ) - - it.effect("does not cancel the owner when one joined waiter is interrupted", () => + it.effect("does not cancel execution when a joined waiter is interrupted", () => Effect.scoped( Effect.gen(function* () { const gate = yield* Deferred.make() @@ -904,105 +387,29 @@ describe("SessionRunCoordinator", () => { const second = yield* coordinator.run("second").pipe(Effect.forkChild) yield* Deferred.await(bothStarted) yield* Deferred.succeed(gate, undefined) - yield* Fiber.join(first) - yield* Fiber.join(second) + yield* Effect.all([Fiber.join(first), Fiber.join(second)]) }), ), ) - it.effect("reports an advisory drain failure exactly once", () => - Effect.scoped( - Effect.gen(function* () { - const failure = new Error("wake failed") - const reported: Cause.Cause[] = [] - const reportedOnce = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Effect.fail(failure), - onFailure: (_key, cause) => - Effect.sync(() => reported.push(cause)).pipe(Effect.andThen(Deferred.succeed(reportedOnce, undefined))), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(reportedOnce) - yield* Effect.yieldNow - - expect(reported).toHaveLength(1) - expect(Cause.squash(reported[0]!)).toBe(failure) - }), - ), - ) - - it.effect("contains defects thrown while constructing an advisory failure report", () => - Effect.scoped( - Effect.gen(function* () { - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Effect.fail(new Error("wake failed")), - onFailure: () => { - throw new Error("report defect") - }, - }) - - yield* coordinator.wake("session") - yield* coordinator.awaitIdle("session").pipe(Effect.exit) - yield* coordinator.wake("session") - yield* coordinator.awaitIdle("session").pipe(Effect.exit) - }), - ), - ) - - it.effect("reports an independently interrupted advisory drain", () => - Effect.scoped( - Effect.gen(function* () { - const reported = yield* Deferred.make>() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Effect.interrupt, - onFailure: (_key, cause) => Deferred.succeed(reported, cause).pipe(Effect.asVoid), - }) - - yield* coordinator.wake("session") - - expect(Cause.hasInterruptsOnly(yield* Deferred.await(reported))).toBeTrue() - }), - ), - ) - - it.effect("does not report deliberate interruption as an advisory failure", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make() - const reported: Cause.Cause[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), - onFailure: (_key, cause) => Effect.sync(() => reported.push(cause)), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(started) - yield* coordinator.interrupt("session") - yield* Effect.yieldNow - - expect(reported).toEqual([]) - }), - ), - ) - - it.effect("trampolines many synchronous self-waking drains", () => + it.effect("trampolines synchronous self-waking execution", () => Effect.scoped( Effect.gen(function* () { const limit = 20_000 + const completed = yield* Deferred.make() let runs = 0 let wake: (key: string) => Effect.Effect = () => Effect.void - const coordinator = yield* SessionRunCoordinator.make({ + const coordinator = yield* SessionRunCoordinator.make({ drain: (key) => Effect.sync(() => ++runs).pipe( - Effect.tap((run) => (run < limit ? wake(key) : Effect.void)), + Effect.tap((run) => (run < limit ? wake(key) : Deferred.succeed(completed, undefined))), Effect.asVoid, ), }) wake = coordinator.wake yield* coordinator.wake("session") - yield* coordinator.awaitIdle("session") + yield* Deferred.await(completed) expect(runs).toBe(limit) }), diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 708fd9e7f8..5798b665a8 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -14,44 +14,77 @@ const id = (value: string) => SessionMessage.ID.make(`msg_${value}`) const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route }) describe("toLLMMessages", () => { - test("maps every top-level V2 Session message type", () => { - const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) + test("omits empty assistant turns", () => { + const assistant = (value: string, content: SessionMessage.Assistant["content"]) => + SessionMessage.Assistant.make({ + id: id(value), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content, + time: { created, completed: created }, + }) const messages = toLLMMessages( [ - new SessionMessage.AgentSwitched({ + assistant("empty", []), + assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", id: "empty", text: "" })]), + assistant("empty-reasoning", [ + SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "empty-reasoning", text: "" }), + ]), + assistant("text", [SessionMessage.AssistantText.make({ type: "text", id: "text", text: "Partial" })]), + assistant("reasoning", [ + SessionMessage.AssistantReasoning.make({ + type: "reasoning", + id: "reasoning", + text: "", + providerMetadata: { anthropic: { signature: "sig_1" } }, + }), + ]), + ], + model, + ) + + expect(messages.map((message) => message.id)).toEqual([id("text"), id("reasoning")]) + }) + + test("maps every top-level V2 Session message type", () => { + const file = FileAttachment.make({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) + const messages = toLLMMessages( + [ + SessionMessage.AgentSwitched.make({ id: id("agent"), type: "agent-switched", agent: "build", time: { created }, }), - new SessionMessage.ModelSwitched({ + SessionMessage.ModelSwitched.make({ id: id("model"), type: "model-switched", model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, time: { created }, }), - new SessionMessage.System({ + SessionMessage.System.make({ id: id("system"), type: "system", text: "Updated context\n\nOther context", time: { created }, }), - new SessionMessage.User({ + SessionMessage.User.make({ id: id("user"), type: "user", text: "Inspect this image", files: [file], - agents: [new AgentAttachment({ name: "build" })], + agents: [AgentAttachment.make({ name: "build" })], time: { created }, }), - new SessionMessage.Synthetic({ + SessionMessage.Synthetic.make({ id: id("synthetic"), type: "synthetic", sessionID: SessionV2.ID.make("ses_translate"), text: "Synthetic context", time: { created }, }), - new SessionMessage.Shell({ + SessionMessage.Shell.make({ id: id("shell"), type: "shell", callID: "shell-1", @@ -59,7 +92,7 @@ describe("toLLMMessages", () => { output: "/project", time: { created, completed: created }, }), - new SessionMessage.Compaction({ + SessionMessage.Compaction.make({ id: id("compaction"), type: "compaction", reason: "auto", @@ -109,31 +142,31 @@ Recent work test("replays durable tool media into canonical tool messages without structured base64", () => { const messages = toLLMMessages( [ - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: id("assistant"), type: "assistant", agent: "build", model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [ - new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }), - new SessionMessage.AssistantReasoning({ + SessionMessage.AssistantText.make({ type: "text", id: "text-1", text: "Checking" }), + SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "reasoning-1", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "pending", name: "read", - state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }), + state: SessionMessage.ToolStatePending.make({ status: "pending", input: '{"path":"README.md"}' }), time: { created }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "running", name: "read", - state: new SessionMessage.ToolStateRunning({ + state: SessionMessage.ToolStateRunning.make({ status: "running", input: { path: "README.md" }, content: [], @@ -141,11 +174,11 @@ Recent work }), time: { created }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "completed", name: "read", - state: new SessionMessage.ToolStateCompleted({ + state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { path: "README.md" }, content: [ @@ -161,7 +194,7 @@ Recent work }), time: { created, completed: created }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "hosted", name: "web_search", @@ -170,7 +203,7 @@ Recent work metadata: { fake: { continuation: "hosted-call" } }, resultMetadata: { fake: { continuation: "hosted-result" } }, }, - state: new SessionMessage.ToolStateCompleted({ + state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { query: "Effect" }, content: [{ type: "text", text: "Found it" }], @@ -178,12 +211,12 @@ Recent work }), time: { created, completed: created }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "hosted-failed", name: "write", provider: { executed: true, metadata: { fake: { continuation: "failed" } } }, - state: new SessionMessage.ToolStateError({ + state: SessionMessage.ToolStateError.make({ status: "error", input: { path: "README.md" }, content: [], @@ -266,13 +299,13 @@ Recent work test("restores OpenAI encrypted reasoning metadata", () => { const messages = toLLMMessages( [ - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: id("assistant-openai-reasoning"), type: "assistant", agent: "build", model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [ - new SessionMessage.AssistantReasoning({ + SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "reasoning-openai", text: "Think", @@ -294,22 +327,94 @@ Recent work ]) }) + test("drops provider-native continuation metadata from failed assistant turns", () => { + const messages = toLLMMessages( + [ + SessionMessage.Assistant.make({ + id: id("assistant-failed"), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content: [ + SessionMessage.AssistantReasoning.make({ + type: "reasoning", + id: "reasoning-failed", + text: "Partial thought", + providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } }, + }), + SessionMessage.AssistantTool.make({ + type: "tool", + id: "hosted-failed", + name: "web_search", + provider: { + executed: true, + metadata: { openai: { itemId: "call_failed" } }, + resultMetadata: { openai: { itemId: "result_failed" } }, + }, + state: SessionMessage.ToolStateError.make({ + status: "error", + input: { query: "Effect" }, + error: { type: "unknown", message: "Provider turn interrupted" }, + content: [], + structured: {}, + }), + time: { created, completed: created }, + }), + ], + finish: "error", + error: { type: "unknown", message: "Provider turn interrupted" }, + time: { created, completed: created }, + }), + ], + model, + ) + + expect(messages[0]?.content).toEqual([ + { type: "reasoning", text: "Partial thought", providerMetadata: undefined }, + { + type: "tool-call", + id: "hosted-failed", + name: "web_search", + input: { query: "Effect" }, + providerExecuted: true, + providerMetadata: undefined, + }, + { + type: "tool-result", + id: "hosted-failed", + name: "web_search", + result: { + type: "error", + value: { + error: { type: "unknown", message: "Provider turn interrupted" }, + content: [], + structured: {}, + }, + }, + providerExecuted: true, + cache: undefined, + metadata: undefined, + providerMetadata: undefined, + }, + ]) + }) + test("drops provider-native continuation metadata after a model switch", () => { const messages = toLLMMessages( [ - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: id("assistant-old-model"), type: "assistant", agent: "build", model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") }, content: [ - new SessionMessage.AssistantReasoning({ + SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "reasoning-old-model", text: "Visible thought", providerMetadata: { anthropic: { signature: "sig_old" } }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "hosted-old-model", name: "web_search", @@ -318,7 +423,7 @@ Recent work metadata: { openai: { itemId: "hosted-old-model" } }, resultMetadata: { openai: { itemId: "hosted-old-model" } }, }, - state: new SessionMessage.ToolStateCompleted({ + state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { query: "Effect" }, content: [], @@ -327,7 +432,7 @@ Recent work }), time: { created, completed: created }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "local-old-model", name: "read", @@ -336,7 +441,7 @@ Recent work metadata: { fake: { call: "old" } }, resultMetadata: { fake: { result: "old" } }, }, - state: new SessionMessage.ToolStateCompleted({ + state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { path: "README.md" }, content: [], diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index e1f5e32b75..49bbce95a3 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { LLM } from "@opencode-ai/llm" import { LLMClient } from "@opencode-ai/llm/route" -import { ConfigProvider, DateTime, Effect } from "effect" +import { DateTime, Effect } from "effect" import { Headers } from "effect/unstable/http" import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" @@ -23,7 +23,7 @@ type Api = | { readonly type: "native"; readonly url?: string; readonly settings: Record } const model = (api: Api, variants: ModelV2.Info["variants"] = []) => - new ModelV2.Info({ + ModelV2.Info.make({ id: ModelV2.ID.make("test-model"), providerID: ProviderV2.ID.make("test-provider"), name: "Test model", @@ -32,25 +32,15 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) => request: { headers: { "x-test": "header" }, body: { apiKey: "secret", custom_extension: { enabled: true } }, - generation: { temperature: 0.7 }, - options: { store: false, serviceTier: "priority" }, }, variants, - time: { released: DateTime.makeUnsafe(0) }, + time: { released: 0 }, cost: [], status: "active", enabled: true, limit: { context: 100, output: 20 }, }) -const provider = (api: ProviderV2.Info["api"]) => - new ProviderV2.Info({ - id: ProviderV2.ID.make("test-provider"), - name: "Test provider", - api, - request: { headers: {}, body: {} }, - }) - describe("SessionRunnerModel", () => { it.effect("maps catalog OpenAI AI SDK models into native Responses routes", () => Effect.gen(function* () { @@ -65,8 +55,6 @@ describe("SessionRunnerModel", () => { defaults: { headers: { "x-test": "header" }, limits: { context: 100, output: 20 }, - generation: { temperature: 0.7 }, - providerOptions: { openai: { store: false, serviceTier: "priority" } }, http: { body: { custom_extension: { enabled: true } } }, }, }) @@ -88,14 +76,14 @@ describe("SessionRunnerModel", () => { it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( - new ModelV2.Info({ + ModelV2.Info.make({ ...model({ type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://compatible.example/v1", settings: { apiKey: "settings-secret", compatibility: "strict" }, }), - request: { headers: {}, body: {}, generation: {}, options: {} }, + request: { headers: {}, body: {} }, }), ) const request = LLM.request({ model: resolved, prompt: "Hello" }) @@ -112,21 +100,20 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("lowers selected OpenAI Session variants into Responses options", () => + it.effect("overlays selected OpenAI Session variant bodies", () => Effect.gen(function* () { - const base = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [ + const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [ { id: ModelV2.VariantID.make("high"), headers: { "x-variant": "high" }, - body: {}, - generation: { temperature: 0.2 }, - options: { reasoningEffort: "high" }, + body: { + store: false, + service_tier: "priority", + temperature: 0.2, + reasoning: { effort: "high" }, + }, }, ]) - const catalog = new ModelV2.Info({ - ...base, - request: { ...base.request, options: { ...base.request.options, reasoningEffort: "medium" } }, - }) const session = SessionV2.Info.make({ id: SessionV2.ID.make("ses_model_variant"), projectID: ProjectV2.ID.global, @@ -143,21 +130,19 @@ describe("SessionRunnerModel", () => { }) const resolved = yield* SessionRunnerModel.resolve(session, catalog) - const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" })) expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" }) - expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } }) - expect(prepared.body).toMatchObject({ + expect(resolved.route.defaults.http?.body).toEqual({ + custom_extension: { enabled: true }, store: false, service_tier: "priority", temperature: 0.2, reasoning: { effort: "high" }, }) - expect(prepared.body).not.toHaveProperty("reasoningEffort") }), ) - it.effect("lowers selected OpenAI-compatible Session variants into Chat options", () => + it.effect("overlays selected OpenAI-compatible Session variant bodies", () => Effect.gen(function* () { const catalog = model( { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://compatible.example/v1" }, @@ -165,9 +150,7 @@ describe("SessionRunnerModel", () => { { id: ModelV2.VariantID.make("high"), headers: {}, - body: {}, - generation: {}, - options: { reasoningEffort: "high" }, + body: { store: false, reasoning_effort: "high" }, }, ], ) @@ -183,26 +166,52 @@ describe("SessionRunnerModel", () => { }) const resolved = yield* SessionRunnerModel.resolve(session, catalog) - const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" })) - expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } }) - expect(prepared.body).toMatchObject({ + expect(resolved.route.defaults.http?.body).toEqual({ + custom_extension: { enabled: true }, store: false, reasoning_effort: "high", }) - expect(prepared.body).not.toHaveProperty("reasoningEffort") }), ) - it.effect("lowers selected Anthropic Session variants into Messages options", () => + it.effect("rejects an explicit unavailable Session variant during model resolution", () => + Effect.gen(function* () { + const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }) + const session = SessionV2.Info.make({ + id: SessionV2.ID.make("ses_model_variant_unavailable"), + projectID: ProjectV2.ID.global, + title: "test", + model: { + id: catalog.id, + providerID: catalog.providerID, + variant: ModelV2.VariantID.make("unknown"), + }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: { directory: AbsolutePath.make("/project") }, + }) + + const failure = yield* SessionRunnerModel.resolve(session, catalog).pipe(Effect.flip) + + expect(failure).toMatchObject({ + _tag: "SessionRunnerModel.VariantUnavailableError", + providerID: "test-provider", + modelID: "test-model", + variant: "unknown", + }) + expect(failure.message).toBe("Variant unavailable for test-provider/test-model: unknown") + }), + ) + + it.effect("overlays selected Anthropic Session variant bodies", () => Effect.gen(function* () { const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [ { id: ModelV2.VariantID.make("high"), headers: {}, - body: {}, - generation: {}, - options: { thinking: { type: "enabled", budgetTokens: 12000 } }, + body: { thinking: { type: "enabled", budget_tokens: 12000 } }, }, ]) const session = SessionV2.Info.make({ @@ -217,13 +226,11 @@ describe("SessionRunnerModel", () => { }) const resolved = yield* SessionRunnerModel.resolve(session, catalog) - const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" })) - expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } }) - expect(prepared.body).toMatchObject({ + expect(resolved.route.defaults.http?.body).toEqual({ + custom_extension: { enabled: true }, thinking: { type: "enabled", budget_tokens: 12000 }, }) - expect(JSON.stringify(prepared.body)).not.toContain("budgetTokens") }), ) @@ -240,27 +247,23 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("preserves environment-backed bearer auth", () => + it.effect("uses resolved credentials for bearer auth", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( - new ModelV2.Info({ + ModelV2.Info.make({ ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), - request: { headers: {}, body: {}, generation: {}, options: {} }, + request: { headers: {}, body: {} }, }), - { type: "env", name: "TEST_PROVIDER_API_KEY" }, + Credential.Key.make({ type: "key", key: "secret" }), ) const request = LLM.request({ model: resolved, prompt: "Hello" }) - const headers = yield* resolved.route.auth - .apply({ - request, - method: "POST", - url: "https://openai.example/v1/responses", - body: "{}", - headers: Headers.empty, - }) - .pipe( - Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { TEST_PROVIDER_API_KEY: "secret" } }))), - ) + const headers = yield* resolved.route.auth.apply({ + request, + method: "POST", + url: "https://openai.example/v1/responses", + body: "{}", + headers: Headers.empty, + }) expect(headers.authorization).toBe("Bearer secret") }), @@ -268,18 +271,12 @@ describe("SessionRunnerModel", () => { it.effect("prefers stored credentials over configured auth", () => Effect.gen(function* () { - const credential = new Credential.Stored({ - id: Credential.ID.create(), - integrationID: Integration.ID.make("test-provider"), - label: "Work", - value: new Credential.Key({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }), - }) + const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }) const resolved = yield* SessionRunnerModel.fromCatalogModel( - new ModelV2.Info({ + ModelV2.Info.make({ ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), - request: { headers: {}, body: { apiKey: "configured-secret" }, generation: {}, options: {} }, + request: { headers: {}, body: { apiKey: "configured-secret" } }, }), - { type: "credential", id: credential.id, label: credential.label }, credential, ) const headers = yield* resolved.route.auth.apply({ @@ -295,6 +292,27 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("does not project OAuth account metadata into the request body", () => + Effect.gen(function* () { + const resolved = yield* SessionRunnerModel.fromCatalogModel( + ModelV2.Info.make({ + ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), + request: { headers: {}, body: {} }, + }), + Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("device"), + access: "secret", + refresh: "refresh", + expires: Date.now() + 60_000, + metadata: { server: "https://console.example", orgID: "org_123" }, + }), + ) + + expect(resolved.route.defaults.http?.body).toEqual({}) + }), + ) + it.effect("rejects catalog APIs without a native route", () => Effect.gen(function* () { const failure = yield* SessionRunnerModel.fromCatalogModel( @@ -307,6 +325,7 @@ describe("SessionRunnerModel", () => { modelID: "test-model", api: "aisdk:@ai-sdk/google", }) + expect(failure.message).toBe("Unsupported API for test-provider/test-model: aisdk:@ai-sdk/google") }), ) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index e8da56a3a5..d45cc8c734 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -3,6 +3,9 @@ import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/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" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" import { PermissionV2 } from "@opencode-ai/core/permission" @@ -12,13 +15,16 @@ import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" +import { Snapshot } from "@opencode-ai/core/snapshot" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" +import { SessionRunner } from "@opencode-ai/core/session/runner" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { Location } from "@opencode-ai/core/location" @@ -32,10 +38,6 @@ import { Effect, Layer } from "effect" import path from "node:path" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const cassette = process.env.RECORD === "true" ? HttpRecorderInternal.cassetteLayer("session-runner/openai-chat-streams-text", { @@ -58,8 +60,6 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) -const agents = AgentV2.layer const model = OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.com/v1" }, @@ -68,65 +68,68 @@ const model = OpenAIChat.route }) .model({ id: "gpt-4o-mini" }) const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) -const systemContext = SystemContextRegistry.layer -const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer)) +const systemContext = AppNodeBuilder.build(SystemContextRegistry.node) const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) -const runner = SessionRunnerLLM.defaultLayer.pipe( - Layer.provide(database), - Layer.provide(store), - Layer.provide(events), - Layer.provide(client), - Layer.provide(registry), - Layer.provide(models), - Layer.provide(systemContext), - Layer.provide(location), - Layer.provide(agents), - Layer.provide(skillGuidance), - Layer.provide(referenceGuidance), - Layer.provide(config), -) -const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) +const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ + [Snapshot.node, Snapshot.noopLayer], + [LayerNodePlatform.llmClient, client], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [Config.node, config], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], +]) const execution = Layer.effect( SessionExecution.Service, - SessionRunCoordinator.Service.pipe( - Effect.map((coordinator) => - SessionExecution.Service.of({ - resume: coordinator.run, - wake: coordinator.wake, - interrupt: coordinator.interrupt, - }), - ), - ), -).pipe(Layer.provide(coordinator)) -const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), - Layer.provide(Project.defaultLayer), - Layer.provide(execution), -) + Effect.gen(function* () { + const sessionRunner = yield* SessionRunner.Service + const coordinator = yield* SessionRunCoordinator.make({ + drain: (sessionID, force) => sessionRunner.run({ sessionID, force }), + }) + return SessionExecution.Service.of({ + active: coordinator.active, + resume: coordinator.run, + wake: coordinator.wake, + interrupt: coordinator.interrupt, + }) + }), +).pipe(Layer.provide(runnerLayer)) const it = testEffect( - Layer.mergeAll( - database, - events, - projector, - store, - executor, - client, - permission, - agents, - registry, - models, - systemContext, - location, - skillGuidance, - config, - runner, - coordinator, - execution, - sessions, + AppNodeBuilder.build( + LayerNode.group([ + Database.node, + EventV2.node, + SessionProjector.node, + SessionStore.node, + AgentV2.node, + ToolRegistry.node, + SessionRunnerModel.node, + SystemContextRegistry.node, + SkillGuidance.node, + ReferenceGuidance.node, + Config.node, + Snapshot.node, + SessionRunnerLLM.node, + SessionV2.node, + ]), + [ + [LayerNodePlatform.llmClient, client], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [Config.node, config], + [Snapshot.node, Snapshot.noopLayer], + [SessionExecution.node, execution], + ], ), ) const sessionID = SessionV2.ID.make("ses_runner_recorded") @@ -157,7 +160,7 @@ describe("SessionRunnerLLM recorded", () => { const session = yield* SessionV2.Service const prompt = yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Say hello in one short sentence." }), + prompt: Prompt.make({ text: "Say hello in one short sentence." }), resume: false, }) @@ -179,7 +182,7 @@ describe("SessionRunnerLLM recorded", () => { .all()).map((event) => event.type), ).toEqual([ "session.next.prompt.admitted.1", - "session.next.prompt.promoted.1", + "session.next.prompted.1", "session.next.step.started.1", "session.next.text.started.1", "session.next.text.ended.1", diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 3d4a858cbb..f96ea4dea2 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -19,17 +19,17 @@ const capture = () => { Effect.sync(() => { const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload published.push({ - type: definition.sync ? EventV2.versionedType(definition.type, definition.sync.version) : definition.type, + type: definition.durable + ? EventV2.versionedType(definition.type, definition.durable.version) + : definition.type, data, }) return event }), subscribe: () => Stream.empty, all: () => Stream.empty, - aggregateEvents: () => Stream.empty, - sync: () => Effect.succeed(Effect.void), + durable: () => Stream.empty, listen: () => Effect.succeed(Effect.void), - beforeCommit: () => Effect.void, project: () => Effect.void, replay: () => Effect.void, replayAll: () => Effect.succeed(undefined), @@ -125,3 +125,12 @@ test("old success event data containing result still decodes", () => { }) expect(decoded.result).toMatchObject({ type: "content" }) }) + +test("step finish records settlement without publishing step ended", async () => { + const { published, publisher } = capture() + await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 }))) + await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" }))) + + expect(published.some((event) => event.type === "session.next.step.ended.2")).toBe(false) + expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" }) +}) diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 7c326a9cad..82cd015aaf 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -1,6 +1,8 @@ import { describe, expect } from "bun:test" import { Tool } from "@opencode-ai/core/tool/tool" 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" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" @@ -27,9 +29,13 @@ const outputStore = Layer.mock(ToolOutputStore.Service, { ) }, }) -const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore)) -const it = testEffect(registry) -const integrated = testEffect(Layer.mergeAll(ApplicationTools.layer, registry)) +const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]]) +const it = testEffect(registryLayer) +const integrated = testEffect( + AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, ToolRegistry.node]), [ + [ToolOutputStore.node, outputStore], + ]), +) const identity = { agent: AgentV2.ID.make("build"), assistantMessageID: SessionMessage.ID.make("msg_registry"), @@ -206,6 +212,7 @@ describe("ToolRegistry", () => { expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure) + expect(retentionFailure.message).toBe("Failed to write tool output: disk full") }), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index c3089da0db..7fdf4dfe74 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -11,6 +11,10 @@ import { } from "@opencode-ai/llm" import * as OpenAIChat from "@opencode-ai/llm/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" +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 { PermissionV2 } from "@opencode-ai/core/permission" import { EventTable } from "@opencode-ai/core/event/sql" @@ -19,6 +23,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { QuestionV2 } from "@opencode-ai/core/question" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" +import { Snapshot } from "@opencode-ai/core/snapshot" import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionInput } from "@opencode-ai/core/session/input" @@ -26,13 +31,11 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" -import { SessionContextEpoch } from "@opencode-ai/core/session/context-epoch" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" import { SessionRunner } from "@opencode-ai/core/session/runner" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { ToolRegistry } from "@opencode-ai/core/tool/registry" -import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" @@ -56,11 +59,6 @@ import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const questions = QuestionV2.layer.pipe(Layer.provide(events)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const requests: LLMRequest[] = [] let response: LLMEvent[] = [] let responses: LLMEvent[][] | undefined @@ -123,13 +121,6 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const applications = ApplicationTools.layer -const registry = ToolRegistry.layer.pipe( - Layer.provide(permission), - Layer.provide(applications), - Layer.provide(ToolOutputStore.defaultLayer), -) -const agents = AgentV2.layer const echo = Layer.effectDiscard( ToolRegistry.Service.use((registry) => registry.register({ @@ -159,7 +150,8 @@ const echo = Layer.effectDiscard( }), }), ), -).pipe(Layer.provide(registry)) +) +const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] }) let modelResolveHook = Effect.void let currentModel = model const models = SessionRunnerModel.layerWith((session) => @@ -199,8 +191,7 @@ const systemContext = Layer.effectDiscard( }), ), ), -).pipe(Layer.provideMerge(SystemContextRegistry.layer)) -const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer)) +).pipe(Layer.provideMerge(AppNodeBuilder.build(SystemContextRegistry.node))) const skillGuidance = Layer.mock(SkillGuidance.Service, { load: (agent) => Effect.succeed( @@ -234,62 +225,67 @@ const config = Layer.succeed( ]), }), ) -const runner = SessionRunnerLLM.layer.pipe( - Layer.provide(database), - Layer.provide(store), - Layer.provide(events), - Layer.provide(client), - Layer.provide(registry), - Layer.provide(models), - Layer.provide(systemContext), - Layer.provide(location), - Layer.provide(agents), - Layer.provide(skillGuidance), - Layer.provide(referenceGuidance), - Layer.provide(config), -) -const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) +const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ + [Snapshot.node, Snapshot.noopLayer], + [LayerNodePlatform.llmClient, client], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [PermissionV2.node, permission], + [Config.node, config], +]) const execution = Layer.effect( SessionExecution.Service, - SessionRunCoordinator.Service.pipe( - Effect.map((coordinator) => - SessionExecution.Service.of({ - resume: coordinator.run, - wake: coordinator.wake, - interrupt: coordinator.interrupt, - }), - ), - ), -).pipe(Layer.provide(coordinator)) -const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), - Layer.provide(Project.defaultLayer), - Layer.provide(execution), -) + Effect.gen(function* () { + const sessionRunner = yield* SessionRunner.Service + const coordinator = yield* SessionRunCoordinator.make({ + drain: (sessionID, force) => sessionRunner.run({ sessionID, force }), + }) + return SessionExecution.Service.of({ + active: coordinator.active, + resume: coordinator.run, + wake: coordinator.wake, + interrupt: coordinator.interrupt, + }) + }), +).pipe(Layer.provide(runnerLayer)) const it = testEffect( - Layer.mergeAll( - database, - events, - questions, - projector, - store, - client, - permission, - applications, - agents, - registry, - echo, - models, - systemContext, - location, - skillGuidance, - config, - runner, - coordinator, - execution, - sessions, + AppNodeBuilder.build( + LayerNode.group([ + Database.node, + EventV2.node, + QuestionV2.node, + SessionProjector.node, + SessionStore.node, + ApplicationTools.node, + AgentV2.node, + ToolRegistry.node, + ToolRegistry.toolsNode, + echoNode, + SessionRunnerModel.node, + SystemContextRegistry.node, + SkillGuidance.node, + ReferenceGuidance.node, + Config.node, + Snapshot.node, + SessionRunnerLLM.node, + SessionExecution.node, + SessionV2.node, + ]), + [ + [LayerNodePlatform.llmClient, client], + [PermissionV2.node, permission], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [Snapshot.node, Snapshot.noopLayer], + [SessionExecution.node, execution], + [Config.node, config], + ], ), ) const sessionID = SessionV2.ID.make("ses_runner_test") @@ -355,7 +351,7 @@ const setupOverflowRecovery = Effect.gen(function* () { response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Earlier question ".repeat(700) }), + prompt: Prompt.make({ text: "Earlier question ".repeat(700) }), resume: false, }) yield* session.resume(sessionID) @@ -364,12 +360,12 @@ const setupOverflowRecovery = Effect.gen(function* () { return session }) -const userTexts = (request: LLMRequest) => +const messageTexts = (request: LLMRequest, role: "user" | "system") => request.messages.flatMap((message) => - message.role === "user" - ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) - : [], + message.role === role ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) : [], ) +const userTexts = (request: LLMRequest) => messageTexts(request, "user") +const systemTexts = (request: LLMRequest) => messageTexts(request, "system") const replaySessionProjection = (id: SessionV2.ID) => Effect.gen(function* () { @@ -480,7 +476,7 @@ const verifyEphemeralDeltas = (kind: FragmentKind) => const chunks = Array.from({ length: 32 }, (_, index) => `${index},`) const fixture = fragmentFixture(kind, fragmentID(kind, "many"), chunks) const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant] - yield* session.prompt({ sessionID, prompt: new Prompt({ text: prompt }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false }) const events = yield* EventV2.Service const live = yield* events.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow @@ -511,7 +507,7 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) => const prompt = `Fail after ${kind}` const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"]) const failure = providerUnavailable() - yield* session.prompt({ sessionID, prompt: new Prompt({ text: prompt }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false }) responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure)) expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) @@ -533,7 +529,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => const prompt = `Interrupt after ${kind}` const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"]) const streamed = yield* Deferred.make() - yield* session.prompt({ sessionID, prompt: new Prompt({ text: prompt }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false }) responseStream = Stream.concat( Stream.fromIterable(fixture.partialEvents), Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)), @@ -547,6 +543,8 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => { type: "user", text: prompt }, { type: "assistant", + finish: "error", + error: { type: "unknown", message: "Provider turn interrupted" }, content: [ kind === "tool input" ? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } } @@ -575,7 +573,7 @@ describe("SessionRunnerLLM", () => { }), }), }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Use application context" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use application context" }), resume: false }) responses = [ [ LLMEvent.stepStart({ index: 0 }), @@ -623,7 +621,7 @@ describe("SessionRunnerLLM", () => { streamStarted = undefined response = [] - const message = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run automatically" }) }) + const message = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run automatically" }) }) expect(requests).toHaveLength(1) expect(yield* session.messages({ sessionID })).toMatchObject([ @@ -636,8 +634,8 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) requests.length = 0 responses = undefined @@ -664,7 +662,7 @@ describe("SessionRunnerLLM", () => { const { db } = yield* Database.Service const messageID = SessionMessage.ID.create() systemUnavailable = true - yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -682,8 +680,7 @@ describe("SessionRunnerLLM", () => { ).toBeUndefined() systemUnavailable = false - yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }) }) - yield* (yield* SessionRunCoordinator.Service).awaitIdle(sessionID) + yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }) }) expect(requests).toHaveLength(1) expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"]) @@ -696,7 +693,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) @@ -704,7 +701,7 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Moved, { sessionID, timestamp: DateTime.makeUnsafe(1), - location: { directory: AbsolutePath.make("/moved") }, + location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), }) expect( yield* db @@ -714,7 +711,7 @@ describe("SessionRunnerLLM", () => { .get(), ).toBeUndefined() - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) const exit = yield* session.resume(sessionID).pipe(Effect.exit) expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true) @@ -728,7 +725,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) response = [] yield* session.resume(sessionID) yield* db @@ -737,7 +734,7 @@ describe("SessionRunnerLLM", () => { .where(eq(SessionContextEpochTable.session_id, sessionID)) .run() .pipe(Effect.orDie) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) requests.length = 0 const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -748,50 +745,17 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("does not create a source Location epoch after a concurrent Session move", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - const { db } = yield* Database.Service - let moved = false - systemLoadHook = Effect.suspend(() => { - if (moved) return Effect.void - moved = true - return events - .publish(SessionEvent.Moved, { - sessionID, - timestamp: DateTime.makeUnsafe(1), - location: { directory: AbsolutePath.make("/moved") }, - }) - .pipe(Effect.asVoid) - }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - - expect(Exit.isFailure(yield* session.resume(sessionID).pipe(Effect.exit))).toBe(true) - expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true) - expect( - yield* db - .select() - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get(), - ).toBeUndefined() - expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved")) - }), - ) - it.effect("reuses one durable baseline after the context producer changes", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -819,14 +783,14 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const agent = yield* AgentV2.Service - yield* agent.update((editor) => + yield* agent.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.system = "Build agent instructions" agent.mode = "primary" }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-build", ["Done"]).completeEvents @@ -840,7 +804,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const agent = yield* AgentV2.Service - yield* agent.update((editor) => { + yield* agent.transform((editor) => { editor.update(AgentV2.ID.make("build"), (agent) => { agent.system = "Build agent instructions" agent.mode = "primary" @@ -852,7 +816,7 @@ describe("SessionRunnerLLM", () => { editor.default(AgentV2.ID.make("reviewer")) }) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-reviewer", ["Done"]).completeEvents @@ -868,7 +832,7 @@ describe("SessionRunnerLLM", () => { yield* setup const { db } = yield* Database.Service const agent = yield* AgentV2.Service - yield* agent.update((editor) => + yield* agent.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.system = "Reviewer instructions" agent.mode = "primary" @@ -881,7 +845,7 @@ describe("SessionRunnerLLM", () => { .run() .pipe(Effect.orDie) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-selected", ["Done"]).completeEvents @@ -892,13 +856,13 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("composes selected-agent skill guidance and replaces it after an agent switch", () => + it.effect("updates selected-agent skill guidance after an agent switch", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service skillBaselines.set(AgentV2.ID.make("build"), "Build skills") - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -910,17 +874,18 @@ describe("SessionRunnerLLM", () => { timestamp: DateTime.makeUnsafe(1), agent: "reviewer", }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ ["Initial context\n\nBuild skills"], - ["Initial context\n\nReviewer skills"], + ["Initial context\n\nBuild skills"], ]) + expect(systemTexts(requests[1]!)).toContainEqual(expect.stringContaining("Reviewer skills")) }), ) - it.effect("retries first-epoch preparation when the selected agent changes during observation", () => + it.effect("keeps the sampled agent when selection changes during observation", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -940,95 +905,19 @@ describe("SessionRunnerLLM", () => { }) .pipe(Effect.asVoid) }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ - ["Initial context\n\nReviewer skills"], + ["Initial context\n\nBuild skills"], ]) }), ) - it.effect("opens a queued activity once when the selected agent changes during observation", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - skillBaselines.set(AgentV2.ID.make("build"), "Build skills") - skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") - let switched = false - systemLoadHook = Effect.suspend(() => { - if (switched) return Effect.void - switched = true - return events - .publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - agent: "reviewer", - }) - .pipe(Effect.asVoid) - }) - yield* session.prompt({ - sessionID, - prompt: new Prompt({ text: "Queued" }), - delivery: "queue", - resume: false, - }) - - requests.length = 0 - response = [] - yield* session.resume(sessionID) - - expect(requests).toHaveLength(1) - expect((yield* session.context(sessionID)).filter((message) => message.type === "user")).toHaveLength(1) - }), - ) - - it.effect("retries an agent switch before the final provider-dispatch boundary", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - const { db } = yield* Database.Service - skillBaselines.set(AgentV2.ID.make("build"), "Build skills") - skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") - let switched = false - modelResolveHook = Effect.suspend(() => { - if (switched) return Effect.void - switched = true - return events - .publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - agent: "reviewer", - }) - .pipe(Effect.asVoid) - }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - - requests.length = 0 - response = [] - yield* session.resume(sessionID) - expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ - ["Initial context\n\nReviewer skills"], - ]) - expect( - yield* db - .select({ replacementSeq: SessionContextEpochTable.replacement_seq }) - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get() - .pipe(Effect.orDie), - ).toEqual({ replacementSeq: null }) - }), - ) - - it.effect("retries a model switch before the final provider-dispatch boundary", () => + it.effect("keeps the sampled model when selection changes during model resolution", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -1046,161 +935,27 @@ describe("SessionRunnerLLM", () => { }) .pipe(Effect.asVoid) }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) - expect(requests.map((request) => request.model)).toEqual([replacementModel]) + expect(requests.map((request) => request.model)).toEqual([model]) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([["Initial context"]]) }), ) - it.effect("fences an unchanged epoch read across an agent ABA replacement request", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - response = [] - yield* session.resume(sessionID) - let switched = false - systemLoadHook = Effect.suspend(() => { - if (switched) return Effect.void - switched = true - return events - .publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - agent: AgentV2.ID.make("reviewer"), - }) - .pipe( - Effect.andThen( - events.publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(2), - agent: AgentV2.defaultID, - }), - ), - Effect.asVoid, - ) - }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) - - requests.length = 0 - yield* session.resume(sessionID) - - expect(requests).toHaveLength(1) - expect( - yield* db - .select({ replacementSeq: SessionContextEpochTable.replacement_seq }) - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get() - .pipe(Effect.orDie), - ).toEqual({ replacementSeq: null }) - }), - ) - - it.effect("rejects stale agent guidance when committing an existing-epoch replacement", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - response = [] - yield* session.resume(sessionID) - yield* events.publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - agent: AgentV2.ID.make("reviewer"), - }) - const context = (text: string) => - Effect.succeed( - SystemContext.make({ - key: systemContextKey, - codec: Schema.toCodecJson(Schema.String), - load: Effect.succeed(text), - baseline: String, - update: (_previous, current) => current, - }), - ) - const location = (yield* session.get(sessionID)).location - - expect( - yield* SessionContextEpoch.prepare( - db, - events, - context("Stale build context"), - sessionID, - location, - AgentV2.defaultID, - ).pipe(Effect.catchDefect(Effect.succeed)), - ).toBeInstanceOf(SessionContextEpoch.AgentMismatch) - - expect( - yield* SessionContextEpoch.prepare( - db, - events, - context("Reviewer context"), - sessionID, - location, - AgentV2.ID.make("reviewer"), - ), - ).toMatchObject({ baseline: "Reviewer context" }) - }), - ) - - it.effect("blocks a cross-agent provider turn while replacement context is unavailable", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - skillBaselines.set(AgentV2.defaultID, "Build skills") - skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - response = [] - yield* session.resume(sessionID) - yield* events.publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - agent: AgentV2.ID.make("reviewer"), - }) - systemUnavailable = true - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) - - requests.length = 0 - const blocked = yield* session.resume(sessionID).pipe(Effect.exit) - expect(Exit.isFailure(blocked)).toBe(true) - if (Exit.isFailure(blocked)) - expect(Cause.squash(blocked.cause)).toBeInstanceOf(SessionContextEpoch.AgentReplacementBlocked) - expect(requests).toHaveLength(0) - - systemUnavailable = false - yield* session.resume(sessionID) - expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ - ["Initial context\n\nReviewer skills"], - ]) - }), - ) - it.effect("admits removed context as a chronological System message", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemRemoved = true - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) @@ -1211,18 +966,18 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("replaces the baseline lazily after a model switch and drops prior System updates", () => + it.effect("keeps the baseline and chronological System updates after a model switch", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) yield* events.publish(SessionEvent.ModelSwitched, { sessionID, @@ -1231,35 +986,37 @@ describe("SessionRunnerLLM", () => { model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ ["Initial context"], ["Initial context"], - ["Replacement context"], + ["Initial context"], ]) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) - expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "user", "user"]) + expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2) expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([ "user", "user", + "system", "model-switched", "user", + "system", ]) yield* replaySessionProjection(sessionID) - expect(yield* session.messages({ sessionID })).toHaveLength(5) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fourth" }), resume: false }) + expect(yield* session.messages({ sessionID })).toHaveLength(6) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fourth" }), resume: false }) yield* session.resume(sessionID) }), ) - it.effect("defers replacement while admitted context is temporarily unavailable", () => + it.effect("preserves the baseline while context is temporarily unavailable", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1271,124 +1028,27 @@ describe("SessionRunnerLLM", () => { model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemUnavailable = true - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) systemUnavailable = false systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ ["Initial context"], ["Initial context"], - ["Replacement context"], + ["Initial context"], ]) }), ) - it.effect("advances a pending replacement to the latest invalidation boundary", () => + it.effect("rebuilds the baseline directly after completed compaction", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - response = [] - yield* session.resume(sessionID) - - yield* events.publish(SessionEvent.ModelSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") }, - }) - yield* events.publish(SessionEvent.ModelSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(2), - model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") }, - }) - const latest = yield* SessionInput.latestSeq(db, sessionID) - - expect( - yield* db - .select({ replacementSeq: SessionContextEpochTable.replacement_seq }) - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get() - .pipe(Effect.orDie), - ).toEqual({ replacementSeq: latest }) - }), - ) - - it.effect("retries epoch preparation until observation-time invalidations settle", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - response = [] - yield* session.resume(sessionID) - - requests.length = 0 - systemBaseline = "Changed context" - let invalidations = 0 - systemLoadHook = Effect.suspend(() => { - if (invalidations === 4) return Effect.void - invalidations++ - return events - .publish(SessionEvent.ModelSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(invalidations), - model: { id: ModelV2.ID.make(`replacement-${invalidations}`), providerID: ProviderV2.ID.make("fake") }, - }) - .pipe(Effect.asVoid) - }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) - - yield* session.resume(sessionID) - - expect(invalidations).toBe(4) - expect(requests).toHaveLength(1) - expect(requests[0]?.system.map((part) => part.text)).toEqual(["Changed context"]) - }), - ) - - it.effect("replays retained context projections while replacement is pending", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - - requests.length = 0 - response = [] - yield* session.resume(sessionID) - systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) - yield* session.resume(sessionID) - yield* events.publish(SessionEvent.ModelSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, - }) - - yield* replaySessionProjection(sessionID) - systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) - yield* session.resume(sessionID) - expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Replacement context"]) - }), - ) - - it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1409,7 +1069,7 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1417,7 +1077,7 @@ describe("SessionRunnerLLM", () => { ["Replacement context"], ]) yield* replaySessionProjection(sessionID) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) }), ) @@ -1429,7 +1089,7 @@ describe("SessionRunnerLLM", () => { response = fragmentFixture("text", "text-first", ["Earlier answer"]).completeEvents yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Earlier question ".repeat(180) }), + prompt: Prompt.make({ text: "Earlier question ".repeat(180) }), resume: false, }) yield* session.resume(sessionID) @@ -1442,7 +1102,7 @@ describe("SessionRunnerLLM", () => { ] yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Recent exact request ".repeat(180) }), + prompt: Prompt.make({ text: "Recent exact request ".repeat(180) }), resume: false, }) yield* session.resume(sessionID) @@ -1468,7 +1128,7 @@ describe("SessionRunnerLLM", () => { ] yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Newest exact request ".repeat(180) }), + prompt: Prompt.make({ text: "Newest exact request ".repeat(180) }), resume: false, }) yield* session.resume(sessionID) @@ -1496,7 +1156,7 @@ describe("SessionRunnerLLM", () => { fragmentFixture("text", "text-summary", ["## Goal\n- Recover overflow"]).completeEvents, fragmentFixture("text", "text-final", ["Recovered"]).completeEvents, ] - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1526,7 +1186,7 @@ describe("SessionRunnerLLM", () => { fragmentFixture("text", "text-summary", ["## Goal\n- Recover once"]).completeEvents, overflow(), ] - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1554,7 +1214,7 @@ describe("SessionRunnerLLM", () => { fragmentFixture("text", "text-summary", ["## Goal\n- Recover raw overflow"]).completeEvents, fragmentFixture("text", "text-final", ["Recovered"]).completeEvents, ] - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1572,7 +1232,7 @@ describe("SessionRunnerLLM", () => { [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], [LLMEvent.providerError({ message: "summary unavailable" })], ] - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(2) @@ -1595,7 +1255,7 @@ describe("SessionRunnerLLM", () => { const firstGate = yield* Deferred.make() const summaryGate = yield* Deferred.make() streamGate = firstGate - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow streamGate = summaryGate @@ -1610,18 +1270,18 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("preserves effective System updates while compaction replacement is blocked", () => + it.effect("preserves effective System updates while compaction rebaseline is blocked", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) const compactionID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Compaction.Started, { @@ -1639,20 +1299,11 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemUnavailable = true - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"]) - expect( - requests - .at(-1) - ?.messages.some( - (message) => - message.role === "system" && - message.content[0]?.type === "text" && - message.content[0].text === "Changed context", - ), - ).toBe(true) + expect(systemTexts(requests.at(-1)!)).toContain("Changed context") }), ) @@ -1660,7 +1311,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Use tools" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use tools" }), resume: false }) requests.length = 0 responses = undefined @@ -1758,7 +1409,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo this" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false }) requests.length = 0 authorizations.length = 0 @@ -1817,7 +1468,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo this" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false }) requests.length = 0 responses = [ @@ -1851,8 +1502,9 @@ describe("SessionRunnerLLM", () => { expect(requests.map((request) => request.model)).toEqual([model, replacementModel]) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ ["Initial context"], - ["Replacement context"], + ["Initial context"], ]) + expect(systemTexts(requests[1]!)).toContain("Replacement context") }), ) @@ -1860,7 +1512,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Think first" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Think first" }), resume: false }) requests.length = 0 response = [ @@ -1898,7 +1550,7 @@ describe("SessionRunnerLLM", () => { }, ]) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) response = [] yield* session.resume(sessionID) @@ -1917,7 +1569,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Search first" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Search first" }), resume: false }) requests.length = 0 response = [ @@ -1942,7 +1594,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) yield* replaySessionProjection(sessionID) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) response = [] yield* session.resume(sessionID) @@ -1972,7 +1624,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo five times" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo five times" }), resume: false }) requests.length = 0 executions.length = 0 @@ -2033,7 +1685,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo twice" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo twice" }), resume: false }) requests.length = 0 executions.length = 0 @@ -2121,7 +1773,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run once" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run once" }), resume: false }) requests.length = 0 responses = undefined @@ -2160,7 +1812,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2180,7 +1832,7 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Change direction" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Change direction" }) }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2199,11 +1851,11 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("starts queued input after the active activity settles", () => + it.effect("promotes queued input after continuation ends", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2231,7 +1883,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Wait until the next activity" }), + prompt: Prompt.make({ text: "Wait until continuation ends" }), delivery: "queue", }) yield* Deferred.succeed(streamGate, undefined) @@ -2242,7 +1894,7 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(3) expect(userTexts(requests[0]!)).toEqual(["Start working"]) expect(userTexts(requests[1]!)).toEqual(["Start working"]) - expect(userTexts(requests[2]!)).toEqual(["Start working", "Wait until the next activity"]) + expect(userTexts(requests[2]!)).toEqual(["Start working", "Wait until continuation ends"]) }), ) @@ -2251,7 +1903,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt current work" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false }) requests.length = 0 responses = [ @@ -2269,7 +1921,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Run after interrupt" }), + prompt: Prompt.make({ text: "Run after interrupt" }), delivery: "queue", }) yield* session.interrupt(sessionID) @@ -2294,7 +1946,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt current work" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false }) requests.length = 0 responses = [ @@ -2312,7 +1964,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Steer after interrupt" }), + prompt: Prompt.make({ text: "Steer after interrupt" }), }) yield* session.interrupt(sessionID) expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) @@ -2332,11 +1984,11 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("runs queued active inputs as separate FIFO activities", () => + it.effect("promotes queued inputs one at a time in FIFO order", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2361,8 +2013,8 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue first" }), delivery: "queue" }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue second" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue first" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue second" }), delivery: "queue" }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2375,14 +2027,14 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("opens queued input after idle steering activity settles", () => + it.effect("promotes queued input after steering continuation ends", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start steering activity" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start steering" }), resume: false }) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Queue later activity" }), + prompt: Prompt.make({ text: "Queue for later" }), delivery: "queue", resume: false, }) @@ -2404,16 +2056,16 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) expect(requests).toHaveLength(2) - expect(userTexts(requests[0]!)).toEqual(["Start steering activity"]) - expect(userTexts(requests[1]!)).toEqual(["Start steering activity", "Queue later activity"]) + expect(userTexts(requests[0]!)).toEqual(["Start steering"]) + expect(userTexts(requests[1]!)).toEqual(["Start steering", "Queue for later"]) }), ) - it.effect("coalesces steers into the active queued activity before starting the next queued activity", () => + it.effect("promotes steers before the next queued input", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2444,13 +2096,13 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue first" }), delivery: "queue" }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue second" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue first" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue second" }), delivery: "queue" }) streamGate = secondGate yield* Deferred.succeed(firstGate, undefined) while (requests.length < 2) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Steer first queued activity" }) }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Also steer first queued activity" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Steer before next queued input" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Also steer before next queued input" }) }) yield* Deferred.succeed(secondGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2461,14 +2113,14 @@ describe("SessionRunnerLLM", () => { expect(userTexts(requests[2]!)).toEqual([ "Start working", "Queue first", - "Steer first queued activity", - "Also steer first queued activity", + "Steer before next queued input", + "Also steer before next queued input", ]) expect(userTexts(requests[3]!)).toEqual([ "Start working", "Queue first", - "Steer first queued activity", - "Also steer first queued activity", + "Steer before next queued input", + "Also steer before next queued input", "Queue second", ]) }), @@ -2478,7 +2130,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2498,8 +2150,8 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First steer" }) }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second steer" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First steer" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second steer" }) }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2508,7 +2160,7 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(2) expect(userTexts(requests[1]!)).toEqual(["Start working", "First steer", "Second steer"]) - yield* (yield* SessionRunCoordinator.Service).wake(sessionID) + yield* (yield* SessionExecution.Service).wake(sessionID) yield* Effect.yieldNow expect(requests).toHaveLength(2) }), @@ -2518,7 +2170,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = undefined @@ -2529,7 +2181,7 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover with this" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover with this" }) }) yield* Deferred.succeed(streamGate, undefined) expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(streamFailure) @@ -2548,7 +2200,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover interrupted tool" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover interrupted tool" }), resume: false }) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER) const assistantMessageID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Step.Started, { @@ -2610,7 +2262,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Recover interrupted hosted tool" }), + prompt: Prompt.make({ text: "Recover interrupted hosted tool" }), resume: false, }) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER) @@ -2670,7 +2322,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Recover interrupted tool input" }), + prompt: Prompt.make({ text: "Recover interrupted tool input" }), resume: false, }) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER) @@ -2702,46 +2354,23 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("starts the first queued activity when woken while idle", () => + it.effect("promotes the first queued input when woken while idle", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Wait for fresh activity" }), + prompt: Prompt.make({ text: "Wait in queue" }), delivery: "queue", resume: false, }) requests.length = 0 - yield* (yield* SessionRunCoordinator.Service).wake(sessionID) + yield* (yield* SessionExecution.Service).wake(sessionID) yield* Effect.yieldNow expect(requests).toHaveLength(1) - expect(userTexts(requests[0]!)).toEqual(["Wait for fresh activity"]) - }), - ) - - it.effect("does not spend one activity step budget across queued activities", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const queued = Array.from({ length: 26 }, (_, index) => `Queued activity ${index + 1}`) - for (const text of queued) { - yield* session.prompt({ sessionID, prompt: new Prompt({ text }), delivery: "queue", resume: false }) - } - - requests.length = 0 - responses = queued.map(() => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ]) - - yield* session.resume(sessionID) - - expect(requests).toHaveLength(queued.length) - expect(userTexts(requests.at(-1)!)).toEqual(queued) + expect(userTexts(requests[0]!)).toEqual(["Wait in queue"]) }), ) @@ -2752,8 +2381,8 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service const defect = new Error("fail after prompt promotion") let fail = true - yield* events.project(SessionEvent.PromptLifecycle.Promoted, () => (fail ? Effect.die(defect) : Effect.void)) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover promoted input" }), resume: false }) + yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void)) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover promoted input" }), resume: false }) expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) fail = false @@ -2764,7 +2393,7 @@ describe("SessionRunnerLLM", () => { LLMEvent.finish({ reason: "stop" }), ] - yield* (yield* SessionRunCoordinator.Service).wake(sessionID) + yield* (yield* SessionExecution.Service).wake(sessionID) while (requests.length === 0) yield* Effect.yieldNow expect(userTexts(requests[0]!)).toEqual(["Recover promoted input"]) @@ -2777,13 +2406,11 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service yield* events.listen((event) => - event.type === SessionEvent.PromptLifecycle.Promoted.type - ? Effect.die("fail after prompt promotion commits") - : Effect.void, + event.type === SessionEvent.Prompted.type ? Effect.die("fail after prompt promotion commits") : Effect.void, ) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Run committed promotion" }), + prompt: Prompt.make({ text: "Run committed promotion" }), resume: false, }) @@ -2800,8 +2427,8 @@ describe("SessionRunnerLLM", () => { yield* setup yield* insertSession(otherSessionID) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run first" }), resume: false }) - yield* session.prompt({ sessionID: otherSessionID, prompt: new Prompt({ text: "Run second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run first" }), resume: false }) + yield* session.prompt({ sessionID: otherSessionID, prompt: Prompt.make({ text: "Run second" }), resume: false }) requests.length = 0 responses = undefined @@ -2827,37 +2454,31 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("bounds external session prompt cache keys", () => + it.effect("bounds 64-character session prompt cache keys", () => Effect.gen(function* () { yield* setup - const externalSessionID = SessionV2.ID.fromExternal({ - namespace: "discord", - key: "thread-one", - }) - const otherExternalSessionID = SessionV2.ID.fromExternal({ - namespace: "discord", - key: "thread-two", - }) - yield* insertSession(externalSessionID) - yield* insertSession(otherExternalSessionID) + const longSessionID = SessionV2.ID.make(`ses_${"a".repeat(64)}`) + const otherLongSessionID = SessionV2.ID.make(`ses_${"b".repeat(64)}`) + yield* insertSession(longSessionID) + yield* insertSession(otherLongSessionID) const session = yield* SessionV2.Service yield* session.prompt({ - sessionID: externalSessionID, - prompt: new Prompt({ text: "Run external session" }), + sessionID: longSessionID, + prompt: Prompt.make({ text: "Run long session" }), resume: false, }) yield* session.prompt({ - sessionID: otherExternalSessionID, - prompt: new Prompt({ text: "Run other external session" }), + sessionID: otherLongSessionID, + prompt: Prompt.make({ text: "Run other long session" }), resume: false, }) requests.length = 0 - yield* session.resume(externalSessionID) - yield* session.resume(otherExternalSessionID) + yield* session.resume(longSessionID) + yield* session.resume(otherLongSessionID) const keys = requests.map((request) => request.providerOptions?.openai?.promptCacheKey) - expect(keys).toEqual([externalSessionID.slice(4), otherExternalSessionID.slice(4)]) + expect(keys).toEqual([longSessionID.slice(4), otherLongSessionID.slice(4)]) expect(keys.every((key) => typeof key === "string" && key.length === 64)).toBe(true) expect(keys[0]).not.toBe(keys[1]) }), @@ -2867,7 +2488,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Retry after failure" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Retry after failure" }), resume: false }) requests.length = 0 responses = undefined @@ -2898,7 +2519,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call missing" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call missing" }), resume: false }) requests.length = 0 responses = [ @@ -2940,11 +2561,11 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("propagates unexpected local tool defects operationally", () => + it.effect("returns unexpected local tool defects to the model and continues", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call defect" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call defect" }), resume: false }) requests.length = 0 responses = [ @@ -2954,11 +2575,20 @@ describe("SessionRunnerLLM", () => { LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" }), ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text-after-defect" }), + LLMEvent.textDelta({ id: "text-after-defect", text: "Recovered" }), + LLMEvent.textEnd({ id: "text-after-defect" }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ], ] - expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe("unexpected tool defect") + yield* session.resume(sessionID) - expect(requests).toHaveLength(1) + expect(requests).toHaveLength(2) + expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Call defect" }, { @@ -2974,6 +2604,7 @@ describe("SessionRunnerLLM", () => { }, ], }, + { type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] }, ]) }), ) @@ -2993,7 +2624,7 @@ describe("SessionRunnerLLM", () => { questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie), }), }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Ask then stop" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false }) requests.length = 0 responses = [ @@ -3038,7 +2669,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Settle before failing" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Settle before failing" }), resume: false }) const failure = providerUnavailable() toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( @@ -3072,7 +2703,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt blocked tool" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt blocked tool" }), resume: false }) executions.length = 0 toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( @@ -3118,11 +2749,11 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("interrupts a blocked provider turn without local tool activity", () => + it.effect("interrupts a blocked provider turn without local tool execution", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt provider" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt provider" }), resume: false }) requests.length = 0 response = [] streamGate = yield* Deferred.make() @@ -3145,7 +2776,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt tool settlement" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt tool settlement" }), resume: false }) executions.length = 0 toolExecutionGate = yield* Deferred.make() response = [ @@ -3178,49 +2809,17 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("continues past 25 local tool steps when the agent has no step limit", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Loop forever" }), resume: false }) - - requests.length = 0 - authorizations.length = 0 - executions.length = 0 - streamGate = undefined - streamStarted = undefined - responses = [ - ...Array.from({ length: 25 }, (_, index) => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ]), - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] - - yield* session.resume(sessionID) - - expect(requests).toHaveLength(26) - expect(executions).toHaveLength(25) - }), - ) - it.effect("forces a text response on an agent's configured final step", () => Effect.gen(function* () { yield* setup const agents = yield* AgentV2.Service - yield* agents.update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.steps = 2 }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Finish at the limit" }), resume: false }) requests.length = 0 executions.length = 0 @@ -3258,11 +2857,63 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("resets the configured step allowance when steering input promotes", () => + Effect.gen(function* () { + yield* setup + const agents = yield* AgentV2.Service + yield* agents.transform((editor) => + editor.update(AgentV2.ID.make("build"), (agent) => { + agent.steps = 2 + }), + ) + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start work" }), resume: false }) + + requests.length = 0 + executions.length = 0 + responses = [ + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-before-steer", name: "echo", input: { text: "before" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-after-steer", name: "echo", input: { text: "after" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ], + ] + streamGate = yield* Deferred.make() + streamStarted = yield* Deferred.make() + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + yield* Deferred.await(streamStarted) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Change direction" }) }) + yield* Deferred.succeed(streamGate, undefined) + yield* Fiber.join(run) + streamGate = undefined + streamStarted = undefined + + expect(requests).toHaveLength(3) + expect(requests[1]?.toolChoice).toBeUndefined() + expect(requests[1]?.tools).not.toEqual([]) + expect(requests[2]?.toolChoice).toMatchObject({ type: "none" }) + expect(executions).toEqual(["before", "after"]) + }), + ) + it.effect("projects provider errors as terminal assistant step failures", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail durably" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail durably" }), resume: false }) requests.length = 0 responses = undefined @@ -3284,7 +2935,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail before step" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail before step" }), resume: false }) requests.length = 0 response = [LLMEvent.providerError({ message: "Provider unavailable" })] @@ -3303,7 +2954,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail after output" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail after output" }), resume: false }) requests.length = 0 response = [ @@ -3332,7 +2983,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail raw stream durably" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail raw stream durably" }), resume: false }) const failure = providerUnavailable() responseStream = Stream.fail(failure) @@ -3351,7 +3002,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Do not continue failed provider" }), + prompt: Prompt.make({ text: "Do not continue failed provider" }), resume: false, }) @@ -3374,7 +3025,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail hosted tool durably" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail hosted tool durably" }), resume: false }) requests.length = 0 response = [ @@ -3405,7 +3056,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail hosted tool at EOF" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail hosted tool at EOF" }), resume: false }) response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ @@ -3432,7 +3083,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Fail hosted tool on raw failure" }), + prompt: Prompt.make({ text: "Fail hosted tool on raw failure" }), resume: false, }) const failure = providerUnavailable() @@ -3467,7 +3118,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Two blocks" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Two blocks" }), resume: false }) responses = undefined streamGate = undefined @@ -3530,7 +3181,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call provider tool" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call provider tool" }), resume: false }) responses = undefined streamGate = undefined diff --git a/packages/core/test/session-todo.test.ts b/packages/core/test/session-todo.test.ts index d1d656af38..9b848f8270 100644 --- a/packages/core/test/session-todo.test.ts +++ b/packages/core/test/session-todo.test.ts @@ -1,7 +1,9 @@ import { describe, expect } from "bun:test" import { asc } from "drizzle-orm" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EventV2 } from "@opencode-ai/core/event" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" @@ -11,10 +13,7 @@ import { SessionTable, TodoTable } from "@opencode-ai/core/session/sql" import { SessionTodo } from "@opencode-ai/core/session/todo" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events)) -const it = testEffect(Layer.mergeAll(database, events, todos)) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionTodo.node]))) const sessionID = SessionV2.ID.make("ses_todo_test") const setup = Effect.gen(function* () { diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index 09cc159a20..6d07b14657 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -1,7 +1,8 @@ import { describe, expect } from "bun:test" import { asc, eq } from "drizzle-orm" -import { DateTime, Effect, Layer, Schema } from "effect" +import { DateTime, Effect, Schema } from "effect" import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" import { ModelV2 } from "@opencode-ai/core/model" @@ -16,10 +17,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const it = testEffect(Layer.mergeAll(database, events, projector)) +const it = testEffect(LayerNode.compile(LayerNode.group([Database.node, EventV2.node, SessionProjector.node]))) const timestamp = DateTime.makeUnsafe(1) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts new file mode 100644 index 0000000000..959024cd4a --- /dev/null +++ b/packages/core/test/shared-schema.test.ts @@ -0,0 +1,206 @@ +import { expect, test } from "bun:test" +import { Schema } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { ModelV2 } from "@opencode-ai/core/model" +import { SessionV2 } from "@opencode-ai/core/session" +import { Agent } from "@opencode-ai/schema/agent" +import { Location } from "@opencode-ai/schema/location" +import { Model } from "@opencode-ai/schema/model" +import { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt" +import { Provider } from "@opencode-ai/schema/provider" +import { Project } from "@opencode-ai/schema/project" +import { ProjectDirectories } from "@opencode-ai/schema/project-directories" +import { PermissionV1 } from "@opencode-ai/schema/permission-v1" +import { Session } from "@opencode-ai/schema/session" +import { SessionInput } from "@opencode-ai/schema/session-input" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Workspace } from "@opencode-ai/schema/workspace" +import { Command } from "@opencode-ai/schema/command" +import { Connection } from "@opencode-ai/schema/connection" +import { Credential } from "@opencode-ai/schema/credential" +import { FileSystem } from "@opencode-ai/schema/filesystem" +import { Integration } from "@opencode-ai/schema/integration" +import { LLM } from "@opencode-ai/schema/llm" +import { Permission } from "@opencode-ai/schema/permission" +import { Plugin } from "@opencode-ai/schema/plugin" +import { Pty } from "@opencode-ai/schema/pty" +import { Reference } from "@opencode-ai/schema/reference" +import { SessionTodo } from "@opencode-ai/schema/session-todo" +import { Skill } from "@opencode-ai/schema/skill" +import { AbsolutePath, DateTimeUtcFromMillis, optional, statics } from "@opencode-ai/schema/schema" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { PluginV2 } from "@opencode-ai/core/plugin" + +test("Core reuses the canonical shared schemas", async () => { + const [ + coreCommand, + coreConnection, + coreCredential, + coreFileSystem, + coreIntegration, + coreLocation, + coreLLM, + corePermission, + corePermissionV1, + coreProjectCopy, + corePty, + coreProject, + coreReference, + coreSessionInput, + coreSessionMessage, + coreSessionTodo, + corePrompt, + coreSkill, + coreV2Schema, + coreSchema, + coreWorkspace, + ] = await Promise.all([ + import("@opencode-ai/core/command"), + import("@opencode-ai/core/integration/connection"), + import("@opencode-ai/core/credential"), + import("@opencode-ai/core/filesystem"), + import("@opencode-ai/core/integration"), + import("@opencode-ai/core/location"), + import("@opencode-ai/llm"), + import("@opencode-ai/core/permission"), + import("@opencode-ai/core/v1/permission"), + import("@opencode-ai/core/project/copy"), + import("@opencode-ai/core/pty"), + import("@opencode-ai/core/project/schema"), + import("@opencode-ai/core/reference"), + import("@opencode-ai/core/session/input"), + import("@opencode-ai/core/session/message"), + import("@opencode-ai/core/session/todo"), + import("@opencode-ai/core/session/prompt"), + import("@opencode-ai/core/skill"), + import("@opencode-ai/core/v2-schema"), + import("@opencode-ai/core/schema"), + import("@opencode-ai/core/workspace"), + ]) + + const schemas = [ + [AgentV2.ID, Agent.ID], + [AgentV2.Color, Agent.Color], + [AgentV2.Info, Agent.Info], + [coreCommand.Info, Command.Info], + [coreConnection.CredentialInfo, Connection.CredentialInfo], + [coreConnection.EnvInfo, Connection.EnvInfo], + [coreConnection.Info, Connection.Info], + [coreCredential.ID, Credential.ID], + [coreCredential.OAuth, Credential.OAuth], + [coreCredential.Key, Credential.Key], + [coreCredential.Value, Credential.Value], + [coreFileSystem.Entry, FileSystem.Entry], + [coreFileSystem.Submatch, FileSystem.Submatch], + [coreFileSystem.Match, FileSystem.Match], + [coreIntegration.ID, Integration.ID], + [coreIntegration.MethodID, Integration.MethodID], + [coreIntegration.When, Integration.When], + [coreIntegration.TextPrompt, Integration.TextPrompt], + [coreIntegration.SelectPrompt, Integration.SelectPrompt], + [coreIntegration.Prompt, Integration.Prompt], + [coreIntegration.OAuthMethod, Integration.OAuthMethod], + [coreIntegration.KeyMethod, Integration.KeyMethod], + [coreIntegration.EnvMethod, Integration.EnvMethod], + [coreIntegration.Method, Integration.Method], + [coreIntegration.Inputs, Integration.Inputs], + [coreIntegration.Ref, Integration.Ref], + [coreLocation.Ref, Location.Ref], + [coreLLM.ProviderMetadata, LLM.ProviderMetadata], + [coreLLM.ToolTextContent, LLM.ToolTextContent], + [coreLLM.ToolFileContent, LLM.ToolFileContent], + [coreLLM.ToolContent, LLM.ToolContent], + [ModelV2.ID, Model.ID], + [ModelV2.VariantID, Model.VariantID], + [ModelV2.Ref, Model.Ref], + [ModelV2.Family, Model.Family], + [ModelV2.Capabilities, Model.Capabilities], + [ModelV2.Cost, Model.Cost], + [ModelV2.Api, Model.Api], + [ModelV2.Info, Model.Info], + [ProviderV2.ID, Provider.ID], + [ProviderV2.AISDK, Provider.AISDK], + [ProviderV2.Native, Provider.Native], + [ProviderV2.Api, Provider.Api], + [ProviderV2.Request, Provider.Request], + [ProviderV2.Info, Provider.Info], + [corePermission.Effect, Permission.Effect], + [corePermission.Rule, Permission.Rule], + [corePermission.Ruleset, Permission.Ruleset], + [corePermissionV1.Event, PermissionV1.Event], + [coreProjectCopy.Event, ProjectDirectories.Event], + [PluginV2.ID, Plugin.ID], + [PluginV2.Event, Plugin.Event], + [corePty.Info, Pty.Info], + [corePty.Event, Pty.Event], + [coreProject.ID, Project.ID], + [coreReference.LocalSource, Reference.LocalSource], + [coreReference.GitSource, Reference.GitSource], + [coreReference.Source, Reference.Source], + [SessionV2.ID, Session.ID], + [SessionV2.Info, Session.Info], + [SessionV2.ListAnchor, Session.ListAnchor], + [coreSessionInput.Delivery, SessionInput.Delivery], + [coreSessionInput.Admitted, SessionInput.Admitted], + [coreSessionMessage.ID, SessionMessage.ID], + [coreSessionMessage.UnknownError, SessionMessage.UnknownError], + [coreSessionMessage.AgentSwitched, SessionMessage.AgentSwitched], + [coreSessionMessage.ModelSwitched, SessionMessage.ModelSwitched], + [coreSessionMessage.User, SessionMessage.User], + [coreSessionMessage.Synthetic, SessionMessage.Synthetic], + [coreSessionMessage.System, SessionMessage.System], + [coreSessionMessage.Shell, SessionMessage.Shell], + [coreSessionMessage.ToolStatePending, SessionMessage.ToolStatePending], + [coreSessionMessage.ToolStateRunning, SessionMessage.ToolStateRunning], + [coreSessionMessage.ToolStateCompleted, SessionMessage.ToolStateCompleted], + [coreSessionMessage.ToolStateError, SessionMessage.ToolStateError], + [coreSessionMessage.ToolState, SessionMessage.ToolState], + [coreSessionMessage.AssistantTool, SessionMessage.AssistantTool], + [coreSessionMessage.AssistantText, SessionMessage.AssistantText], + [coreSessionMessage.AssistantReasoning, SessionMessage.AssistantReasoning], + [coreSessionMessage.AssistantContent, SessionMessage.AssistantContent], + [coreSessionMessage.Assistant, SessionMessage.Assistant], + [coreSessionMessage.Compaction, SessionMessage.Compaction], + [coreSessionMessage.Message, SessionMessage.Message], + [coreSessionTodo.Info, SessionTodo.Info], + [coreSessionTodo.Event, SessionTodo.Event], + [corePrompt.Source, Source], + [corePrompt.FileAttachment, FileAttachment], + [corePrompt.AgentAttachment, AgentAttachment], + [corePrompt.Prompt, Prompt], + [coreSkill.DirectorySource, Skill.DirectorySource], + [coreSkill.UrlSource, Skill.UrlSource], + [coreSkill.EmbeddedSource, Skill.EmbeddedSource], + [coreSkill.Source, Skill.Source], + [coreSkill.Info, Skill.Info], + [coreV2Schema.DateTimeUtcFromMillis, DateTimeUtcFromMillis], + [coreSchema.optional, optional], + [coreSchema.statics, statics], + [coreWorkspace.ID, Workspace.ID], + ] + for (const [core, shared] of schemas) expect(core).toBe(shared) + + expect(Agent.Info.empty(Agent.ID.make("test"))).toEqual(AgentV2.Info.empty(AgentV2.ID.make("test"))) + expect(Model.Info.empty(Provider.ID.make("test"), Model.ID.make("model"))).toEqual( + ModelV2.Info.empty(ProviderV2.ID.make("test"), ModelV2.ID.make("model")), + ) + expect(Provider.Info.empty(Provider.ID.make("test"))).toEqual(ProviderV2.Info.empty(ProviderV2.ID.make("test"))) + expect(Skill.Source.key(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/tmp") }))).toBe( + "directory:/tmp", + ) +}) + +test("shared record schemas construct and decode plain objects", () => { + const made = Prompt.make({ text: "hello" }) + const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" }) + const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" }) + + expect(Object.getPrototypeOf(made)).toBe(Object.prototype) + expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype) + expect(Object.getPrototypeOf(content)).toBe(Object.prototype) + expect(Prompt.ast.annotations?.identifier).toBe("Prompt") + expect(SessionMessage.AssistantText.ast.annotations?.identifier).toBe("Session.Message.Assistant.Text") + expect(Prompt.equivalence(Prompt.make({ text: "hello" }), decoded)).toBe(true) + expect(Prompt.fromUserMessage({ text: "hello" })).toEqual(made) + expect(Workspace.ID.ascending("")).toStartWith("wrk_") +}) diff --git a/packages/core/test/skill-discovery.test.ts b/packages/core/test/skill-discovery.test.ts index 5fcecae4c7..e875beb931 100644 --- a/packages/core/test/skill-discovery.test.ts +++ b/packages/core/test/skill-discovery.test.ts @@ -3,15 +3,17 @@ import path from "path" import { describe, expect, test } from "bun:test" import { Effect, Layer } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Global } from "@opencode-ai/core/global" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" import { tmpdir } from "./fixture/tmpdir" const base = "https://skills.example.test/catalog/" -async function pull(skills: unknown[], files: Record = {}) { - const tmp = await tmpdir() +async function pull(skills: unknown[], files: Record = {}, cache?: Awaited>) { + const tmp = cache ?? (await tmpdir()) const requests: string[] = [] const http = Layer.succeed( HttpClient.HttpClient, @@ -27,15 +29,14 @@ async function pull(skills: unknown[], files: Record = {}) { ), ), ) - const layer = SkillDiscovery.layer.pipe( - Layer.provide(http), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layerWith({ cache: tmp.path })), - ) + const skillDiscoveryLayer = AppNodeBuilder.build(SkillDiscovery.node, [ + [LayerNodePlatform.httpClient, http], + [Global.node, Global.layerWith({ cache: tmp.path })], + ]) const directories = await Effect.runPromise( Effect.gen(function* () { return yield* (yield* SkillDiscovery.Service).pull(base) - }).pipe(Effect.provide(layer)), + }).pipe(Effect.provide(skillDiscoveryLayer)), ) return { tmp, requests, directories } } @@ -101,4 +102,64 @@ describe("SkillDiscovery.pull", () => { await result.tmp[Symbol.asyncDispose]() } }) + + test("refreshes cached files when the version changes", async () => { + const tmp = await tmpdir() + try { + const first = await pull( + [{ name: "deploy", version: "1", files: ["SKILL.md"] }], + { + [`${base}deploy/SKILL.md`]: "# Old", + }, + tmp, + ) + const second = await pull( + [{ name: "deploy", version: "2", files: ["SKILL.md"] }], + { + [`${base}deploy/SKILL.md`]: "# New", + }, + tmp, + ) + + expect(await fs.readFile(path.join(first.directories[0], "SKILL.md"), "utf8")).toBe("# New") + expect(second.requests).toContain(`${base}deploy/SKILL.md`) + const third = await pull( + [{ name: "deploy", version: "2", files: ["SKILL.md"] }], + { [`${base}deploy/SKILL.md`]: "# Ignored" }, + tmp, + ) + expect(third.requests).toEqual([`${base}index.json`]) + } finally { + await tmp[Symbol.asyncDispose]() + } + }) + + test("publishes complete updates and removes stale files", async () => { + const tmp = await tmpdir() + try { + const first = await pull( + [{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }], + { + [`${base}deploy/SKILL.md`]: "# Old", + [`${base}deploy/old.md`]: "old reference", + }, + tmp, + ) + const root = first.directories[0] + + await pull( + [{ name: "deploy", version: "2", files: ["SKILL.md", "missing.md"] }], + { [`${base}deploy/SKILL.md`]: "# Partial" }, + tmp, + ) + expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# Old") + expect(await fs.readFile(path.join(root, "old.md"), "utf8")).toBe("old reference") + + await pull([{ name: "deploy", version: "3", files: ["SKILL.md"] }], { [`${base}deploy/SKILL.md`]: "# New" }, tmp) + expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# New") + expect(await Bun.file(path.join(root, "old.md")).exists()).toBe(false) + } finally { + await tmp[Symbol.asyncDispose]() + } + }) }) diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index d0e01d0677..0f8e1b3cb3 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -3,6 +3,8 @@ import path from "path" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" 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" import { FSUtil } from "@opencode-ai/core/fs-util" import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" @@ -22,11 +24,7 @@ const discovery = Layer.succeed( }), ) const it = testEffect( - SkillV2.layer.pipe( - Layer.provide(discovery), - Layer.provide(FSUtil.defaultLayer), - Layer.provideMerge(AgentV2.locationLayer), - ), + AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node]), [[SkillDiscovery.node, discovery]]), ) function write(directory: string, name: string, description: string) { @@ -59,8 +57,7 @@ describe("SkillV2", () => { }) const skill = yield* SkillV2.Service - const register = yield* skill.transform() - yield* register((editor) => { + yield* skill.transform((editor) => { editor.source({ type: "directory", path: AbsolutePath.make(first) }) editor.source({ type: "directory", path: AbsolutePath.make(first) }) editor.source({ type: "directory", path: AbsolutePath.make(second) }) @@ -75,7 +72,7 @@ describe("SkillV2", () => { { type: "directory", path: AbsolutePath.make(second) }, ]) expect(yield* skill.list()).toEqual([ - new SkillV2.Info({ + SkillV2.Info.make({ name: "foo", slash: true, location: AbsolutePath.make(path.join(first, "foo.md")), @@ -108,15 +105,14 @@ describe("SkillV2", () => { urls.set("https://example.test/skills/", [AbsolutePath.make(tmp.path)]) const agents = yield* AgentV2.Service - yield* agents.update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" }) }), ) const skill = yield* SkillV2.Service - const register = yield* skill.transform() - yield* register((editor) => editor.source({ type: "url", url: "https://example.test/skills/" })) + yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" })) expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index fce6ea1087..b059201b30 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -2,7 +2,7 @@ import path from "path" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" import { SystemContext } from "@opencode-ai/core/system-context" @@ -10,45 +10,42 @@ import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { it } from "../lib/effect" const build = AgentV2.ID.make("build") -const effect = new SkillV2.Info({ +const effect = SkillV2.Info.make({ name: "effect", description: "Build applications with Effect", location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")), content: "Effect guidance", }) -const hidden = new SkillV2.Info({ +const hidden = SkillV2.Info.make({ name: "hidden", location: AbsolutePath.make(path.resolve("/skills/hidden/SKILL.md")), content: "Undescribed guidance", }) -const denied = new SkillV2.Info({ +const denied = SkillV2.Info.make({ name: "denied", description: "Must not be advertised", location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")), content: "Denied guidance", }) -const layer = (list: () => SkillV2.Info[], wait: () => void = () => {}) => - SkillGuidance.layer.pipe( - Layer.provide(Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })), - Layer.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.sync(wait) })), - ) +const layer = (list: () => SkillV2.Info[]) => + AppNodeBuilder.build(SkillGuidance.node, [ + [SkillV2.node, Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })], + ]) describe("SkillGuidance", () => { it.effect("renders described agent skills and reconciles the complete available list", () => { - const agent = new AgentV2.Info({ + const agent = AgentV2.Info.make({ ...AgentV2.Info.empty(build), permissions: [{ action: "skill", resource: "denied", effect: "deny" }], }) let skills = [hidden, denied, effect] - let waited = 0 return Effect.gen(function* () { const guidance = yield* SkillGuidance.Service const initialized = yield* guidance .load({ id: agent.id, info: agent }) .pipe(Effect.flatMap(SystemContext.initialize)) - expect(waited).toBe(1) expect(initialized.baseline).toBe( [ "Skills provide specialized instructions and workflows for specific tasks.", @@ -71,18 +68,11 @@ describe("SkillGuidance", () => { _tag: "Updated", text: expect.stringContaining("No skills are currently available."), }) - }).pipe( - Effect.provide( - layer( - () => skills, - () => waited++, - ), - ), - ) + }).pipe(Effect.provide(layer(() => skills))) }) it.effect("omits guidance when the selected agent denies all skills", () => { - const agent = new AgentV2.Info({ + const agent = AgentV2.Info.make({ ...AgentV2.Info.empty(build), permissions: [{ action: "skill", resource: "*", effect: "deny" }], }) @@ -98,7 +88,7 @@ describe("SkillGuidance", () => { }) it.effect("omits guidance when a resource-specific denial follows the global denial", () => { - const agent = new AgentV2.Info({ + const agent = AgentV2.Info.make({ ...AgentV2.Info.empty(build), permissions: [ { action: "skill", resource: "*", effect: "deny" }, @@ -117,7 +107,7 @@ describe("SkillGuidance", () => { }) it.effect("retains specifically allowed skills after a global denial", () => { - const agent = new AgentV2.Info({ + const agent = AgentV2.Info.make({ ...AgentV2.Info.empty(build), permissions: [ { action: "skill", resource: "*", effect: "deny" }, @@ -133,7 +123,7 @@ describe("SkillGuidance", () => { }) it.effect("omits guidance when a specifically allowed skill is denied again", () => { - const agent = new AgentV2.Info({ + const agent = AgentV2.Info.make({ ...AgentV2.Info.empty(build), permissions: [ { action: "skill", resource: "*", effect: "deny" }, diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts new file mode 100644 index 0000000000..5e01fefc17 --- /dev/null +++ b/packages/core/test/snapshot.test.ts @@ -0,0 +1,178 @@ +import { $ } from "bun" +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 { Global } from "@opencode-ai/core/global" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" +import { Snapshot } from "@opencode-ai/core/snapshot" +import { Hash } from "@opencode-ai/core/util/hash" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +describe("Snapshot", () => { + testEffect(Layer.empty).live("captures and restores Location-scoped changes", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const project = path.join(tmp.path, "project") + const location = path.join(project, "scope") + yield* Effect.promise(async () => { + await fs.mkdir(location, { recursive: true }) + await fs.writeFile(path.join(location, "tracked.txt"), "one\n") + await fs.writeFile(path.join(project, "outside.txt"), "outside\n") + await $`git init`.cwd(project).quiet() + await $`git config core.fsmonitor false`.cwd(project).quiet() + await $`git config commit.gpgsign false`.cwd(project).quiet() + await $`git config user.email test@opencode.test`.cwd(project).quiet() + await $`git config user.name Test`.cwd(project).quiet() + await $`git add .`.cwd(project).quiet() + await $`git commit -m initial`.cwd(project).quiet() + }) + + const layer = snapshotLayer(tmp.path, location) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.capture() + expect(before).toBeDefined() + if (!before) return + + yield* Effect.promise(async () => { + await fs.writeFile(path.join(location, "tracked.txt"), "two\n") + await fs.writeFile(path.join(location, "added.txt"), "added\n") + await fs.writeFile(path.join(project, "outside.txt"), "changed outside\n") + }) + const after = yield* snapshot.capture() + expect(after).toBeDefined() + if (!after) return + + expect(yield* snapshot.files({ from: before, to: after })).toEqual([ + RelativePath.make("scope/added.txt"), + RelativePath.make("scope/tracked.txt"), + ]) + const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]]) + const preview = yield* snapshot.preview({ files: plan, context: 1 }) + expect(preview).toHaveLength(1) + expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt")) + yield* snapshot.restore({ files: plan }) + expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n") + expect(yield* read(path.join(location, "added.txt"))).toBe("added\n") + expect(yield* read(path.join(project, "outside.txt"))).toBe("changed outside\n") + }).pipe(Effect.provide(layer)) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + + testEffect(Layer.empty).live("treats capture outside Git as unavailable", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + expect( + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + return yield* snapshot.capture() + }).pipe(Effect.provide(snapshotLayer(tmp.path, tmp.path))), + ).toBeUndefined() + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + + testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const project = path.join(tmp.path, "project") + const linked = path.join(tmp.path, "linked") + yield* Effect.promise(async () => { + await fs.mkdir(project) + await fs.writeFile(path.join(project, "tracked.txt"), "main\n") + await $`git init`.cwd(project).quiet() + await $`git config core.fsmonitor false`.cwd(project).quiet() + await $`git config commit.gpgsign false`.cwd(project).quiet() + await $`git config user.email test@opencode.test`.cwd(project).quiet() + await $`git config user.name Test`.cwd(project).quiet() + await $`git add .`.cwd(project).quiet() + await $`git commit -m initial`.cwd(project).quiet() + await $`git worktree add --detach ${linked} HEAD`.cwd(project).quiet() + }) + + const capture = (directory: string) => + Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + return yield* snapshot.capture() + }).pipe(Effect.provide(snapshotLayer(tmp.path, directory))) + expect(yield* capture(project)).toBeDefined() + expect(yield* capture(linked)).toBeDefined() + + const projectID = yield* Effect.gen(function* () { + return (yield* Location.Service).project.id + }).pipe( + Effect.provide( + AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))), + ), + ) + expect( + yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))), + ).toBeDefined() + expect( + yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))), + ).toBeDefined() + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + + testEffect(Layer.empty).live("checks out a legacy revert snapshot without removing unrelated files", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const project = path.join(tmp.path, "project") + yield* Effect.promise(async () => { + await fs.mkdir(project) + await fs.writeFile(path.join(project, "tracked.txt"), "one\n") + await $`git init`.cwd(project).quiet() + await $`git config core.fsmonitor false`.cwd(project).quiet() + await $`git config commit.gpgsign false`.cwd(project).quiet() + await $`git config user.email test@opencode.test`.cwd(project).quiet() + await $`git config user.name Test`.cwd(project).quiet() + await $`git add .`.cwd(project).quiet() + await $`git commit -m initial`.cwd(project).quiet() + }) + + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.capture() + expect(before).toBeDefined() + if (!before) return + yield* Effect.promise(async () => { + await fs.writeFile(path.join(project, "tracked.txt"), "two\n") + await fs.writeFile(path.join(project, "unrelated.txt"), "keep\n") + }) + yield* snapshot.checkout(before) + expect(yield* read(path.join(project, "tracked.txt"))).toBe("one\n") + expect(yield* read(path.join(project, "unrelated.txt"))).toBe("keep\n") + }).pipe(Effect.provide(snapshotLayer(tmp.path, project))) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) +}) + +function snapshotLayer(data: string, directory: string) { + return AppNodeBuilder.build(Snapshot.node, [ + [Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))], + [Global.node, Global.layerWith({ data, config: path.join(data, "config") })], + ]) +} + +function read(file: string) { + return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replaceAll("\r\n", "\n"))) +} diff --git a/packages/core/test/state.test.ts b/packages/core/test/state.test.ts index cb795f8568..505cd724e7 100644 --- a/packages/core/test/state.test.ts +++ b/packages/core/test/state.test.ts @@ -13,13 +13,16 @@ describe("State", () => { let block = true const state = State.create({ initial: () => ({ values: [] as string[] }), - editor: (draft) => ({ add: (value: string) => draft.values.push(value) }), + draft: (draft) => ({ add: (value: string) => draft.values.push(value) }), finalize: () => block ? Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))) : Effect.void, }) const scope = yield* Scope.make() - const update = yield* state.transform().pipe(Scope.provide(scope)) - const fiber = yield* update((editor) => editor.add("registered")).pipe(Effect.forkChild) + const fiber = yield* state + .transform((editor) => { + editor.add("registered") + }) + .pipe(Scope.provide(scope), Effect.forkChild) yield* Deferred.await(rebuilding) const interruption = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild) block = false @@ -31,4 +34,82 @@ describe("State", () => { expect(state.get().values).toEqual([]) }), ) + + it.effect("runs effectful transforms during every reload", () => + Effect.gen(function* () { + let value = "first" + const state = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (item: string) => draft.values.push(item) }), + }) + + yield* state.transform((editor) => + Effect.sync(() => { + editor.add(value) + }), + ) + expect(state.get().values).toEqual(["first"]) + + value = "second" + yield* state.reload() + expect(state.get().values).toEqual(["second"]) + }), + ) + + it.effect("disposes a transform once and rebuilds remaining state", () => + Effect.gen(function* () { + const state = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (item: string) => draft.values.push(item) }), + }) + yield* state.transform((editor) => { + editor.add("first") + }) + const registration = yield* state.transform((editor) => { + editor.add("second") + }) + expect(state.get().values).toEqual(["first", "second"]) + + yield* registration.dispose + expect(state.get().values).toEqual(["first"]) + + yield* registration.dispose + expect(state.get().values).toEqual(["first"]) + }), + ) + + it.effect("batches automatic rebuilds", () => + Effect.gen(function* () { + let finalized = 0 + const first = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (item: string) => draft.values.push(item) }), + finalize: () => Effect.sync(() => finalized++), + }) + const second = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (item: string) => draft.values.push(item) }), + finalize: () => Effect.sync(() => finalized++), + }) + + yield* State.batch( + Effect.gen(function* () { + yield* first.transform((draft) => { + draft.add("first") + }) + yield* first.transform((draft) => { + draft.add("second") + }) + yield* second.transform((draft) => { + draft.add("third") + }) + expect(finalized).toBe(0) + }), + ) + + expect(first.get().values).toEqual(["first", "second"]) + expect(second.get().values).toEqual(["third"]) + expect(finalized).toBe(2) + }), + ) }) diff --git a/packages/core/test/system-context/builtins.test.ts b/packages/core/test/system-context/builtins.test.ts index a74dd94866..26b9175223 100644 --- a/packages/core/test/system-context/builtins.test.ts +++ b/packages/core/test/system-context/builtins.test.ts @@ -1,6 +1,8 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import * as TestClock from "effect/testing/TestClock" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Location } from "@opencode-ai/core/location" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" @@ -25,12 +27,12 @@ const locationLayer = Layer.succeed( ), ), ) +const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node]) const it = testEffect( - SystemContextBuiltIns.locationLayer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layerWith({ config: "/global" })), - Layer.provide(locationLayer), - ), + AppNodeBuilder.build(builtInsNode, [ + [Location.node, locationLayer], + [Global.node, Global.layerWith({ config: "/global" })], + ]), ) const instructionFS = Layer.effect( FSUtil.Service, @@ -43,13 +45,13 @@ const instructionFS = Layer.effect( }), ), ), -).pipe(Layer.provide(FSUtil.defaultLayer)) +).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) const itWithInstructions = testEffect( - SystemContextBuiltIns.locationLayer.pipe( - Layer.provide(instructionFS), - Layer.provide(Global.layerWith({ config: "/global" })), - Layer.provide(locationLayer), - ), + AppNodeBuilder.build(builtInsNode, [ + [Location.node, locationLayer], + [FSUtil.node, instructionFS], + [Global.node, Global.layerWith({ config: "/global" })], + ]), ) describe("SystemContextBuiltIns", () => { diff --git a/packages/core/test/system-context/registry.test.ts b/packages/core/test/system-context/registry.test.ts index b77b4132a4..3e68493078 100644 --- a/packages/core/test/system-context/registry.test.ts +++ b/packages/core/test/system-context/registry.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { Cause, Effect, Exit, Schema, Scope } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { SystemContext } from "@opencode-ai/core/system-context" import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" import { testEffect } from "../lib/effect" @@ -17,7 +18,7 @@ const entry = (key: string, text: string, sourceKey = key) => ({ ), }) -const it = testEffect(SystemContextRegistry.layer) +const it = testEffect(AppNodeBuilder.build(SystemContextRegistry.node)) describe("SystemContextRegistry", () => { it.effect("loads empty system context when there are no entries", () => diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index 74eda3378e..b986d4972a 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-apply-patch.test.ts @@ -2,6 +2,8 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" import { Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FileMutation } from "@opencode-ai/core/file-mutation" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" @@ -10,6 +12,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" @@ -81,26 +84,34 @@ const filesystem = Layer.effect( }, }) }), -).pipe(Layer.provide(FSUtil.defaultLayer)) +).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) const withTool = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => { const activeLocation = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) })), ) - const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation)) - const mutation = FileMutation.layer.pipe(Layer.provide(filesystem)) - const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) - const patch = ApplyPatchTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(resolution), - Layer.provide(mutation), - Layer.provide(filesystem), - ) return Effect.gen(function* () { return yield* body(yield* ToolRegistry.Service) - }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, patch))) + }).pipe( + Effect.provide( + AppNodeBuilder.build( + LayerNode.group([ + ToolRegistry.node, + ToolRegistry.toolsNode, + LocationMutation.node, + FileMutation.node, + ApplyPatchTool.node, + ]), + [ + [FSUtil.node, filesystem], + [Location.node, activeLocation], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ], + ), + ), + ) } const call = (patchText: string, id = "call-apply-patch") => ({ @@ -149,6 +160,29 @@ describe("ApplyPatchTool", () => { { type: "update", resource: "update.txt" }, { type: "delete", resource: "remove.txt" }, ], + files: [ + { + file: "nested/new.txt", + status: "added", + additions: 1, + deletions: 0, + patch: expect.stringContaining("+created"), + }, + { + file: "update.txt", + status: "modified", + additions: 1, + deletions: 1, + patch: expect.stringContaining("-before\n+after"), + }, + { + file: "remove.txt", + status: "deleted", + additions: 0, + deletions: 1, + patch: expect.stringContaining("-remove"), + }, + ], }) expect(assertions).toMatchObject([ { sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] }, diff --git a/packages/core/test/tool-bash.test.ts b/packages/core/test/tool-bash.test.ts index 0fb2cd7355..3a969e9570 100644 --- a/packages/core/test/tool-bash.test.ts +++ b/packages/core/test/tool-bash.test.ts @@ -6,6 +6,8 @@ import { Effect, Layer } from "effect" import { ChildProcess } from "effect/unstable/process" import { FSUtil } from "@opencode-ai/core/fs-util" import { Config } from "@opencode-ai/core/config" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Location } from "@opencode-ai/core/location" import { LocationMutation } from "@opencode-ai/core/location-mutation" import { PermissionV2 } from "@opencode-ai/core/permission" @@ -14,6 +16,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { BashTool } from "@opencode-ai/core/tool/bash" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -31,8 +34,10 @@ let denyAction: string | undefined let result: AppProcess.RunResult = { command: "mock", exitCode: 0, + output: Buffer.from("hello\n"), stdout: Buffer.from("hello\n"), stderr: Buffer.alloc(0), + outputTruncated: false, stdoutTruncated: false, stderrTruncated: false, } @@ -83,8 +88,10 @@ const reset = () => { result = { command: "mock", exitCode: 0, + output: Buffer.from("hello\n"), stdout: Buffer.from("hello\n"), stderr: Buffer.alloc(0), + outputTruncated: false, stdoutTruncated: false, stderrTruncated: false, } @@ -95,24 +102,26 @@ const withTool = ( body: (registry: ToolRegistry.Interface) => Effect.Effect, processLayer: Layer.Layer = appProcess, ) => { - const filesystem = FSUtil.defaultLayer const activeLocation = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) })), ) - const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation)) - const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) - const bash = BashTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(mutation), - Layer.provide(filesystem), - Layer.provide(processLayer), - Layer.provide(config), - ) return Effect.gen(function* () { return yield* body(yield* ToolRegistry.Service) - }).pipe(Effect.provide(Layer.mergeAll(registry, bash))) + }).pipe( + Effect.provide( + AppNodeBuilder.build( + LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, BashTool.node]), + [ + [Location.node, activeLocation], + [PermissionV2.node, permission], + [AppProcess.node, processLayer], + [Config.node, config], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ], + ), + ), + ) } const call = (input: typeof BashTool.Input.Type, id = "call-bash") => ({ @@ -134,26 +143,34 @@ describe("BashTool", () => { const definitions = yield* toolDefinitions(registry) expect(definitions.map((tool) => tool.name)).toEqual(["bash"]) expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background") + expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description") + expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output") + expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.command") + expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.cwd") expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([]) - expect( - yield* settleTool(registry, call({ command: "pwd", description: "Print working directory" })), - ).toEqual({ - result: { type: "text", value: "hello\n\n\nCommand exited with code 0." }, + expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({ + result: { + type: "content", + value: [ + { type: "text", text: "hello\n" }, + { type: "text", text: "Command exited with code 0." }, + ], + }, output: { structured: { - command: "pwd", - cwd: realpathSync(tmp.path), - exitCode: 0, - output: "hello\n", + exit: 0, truncated: false, }, - content: [{ type: "text", text: "hello\n\n\nCommand exited with code 0." }], + content: [ + { type: "text", text: "hello\n" }, + { type: "text", text: "Command exited with code 0." }, + ], }, }) expect(runs).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }]) expect(runs[0]?.options).toMatchObject({ + combineOutput: true, maxOutputBytes: BashTool.MAX_CAPTURE_BYTES, - maxErrorBytes: BashTool.MAX_CAPTURE_BYTES, }) expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }]) }), @@ -219,17 +236,21 @@ describe("BashTool", () => { return withTool( tmp.path, (registry) => settleTool(registry, call({ command: "printf core-bash" })), - AppProcess.defaultLayer, + LayerNode.compile(AppProcess.node), ).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.result).toEqual({ type: "text", value: "core-bash\n\nCommand exited with code 0." }) - expect(settled.output?.structured).toMatchObject({ - command: "printf core-bash", - cwd: realpathSync(tmp.path), - exitCode: 0, - output: "core-bash", + expect(settled.result).toEqual({ + type: "content", + value: [ + { type: "text", text: "core-bash" }, + { type: "text", text: "Command exited with code 0." }, + ], }) + expect(settled.output?.structured).toMatchObject({ + exit: 0, + }) + expect(settled.output?.structured).not.toHaveProperty("output") }), ), ) @@ -304,11 +325,13 @@ describe("BashTool", () => { expect(assertions.map((item) => item.action)).toEqual(["bash"]) expect(runs).toHaveLength(1) expect(settled.output?.structured).toMatchObject({ - warnings: [ - `Command argument references external directory ${path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`, - ], + truncated: false, + }) + expect(settled.output?.structured).not.toHaveProperty("warnings") + expect(settled.output?.content[1]).toMatchObject({ + type: "text", + text: expect.stringContaining("Warnings:"), }) - expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining("Warnings:") }) }), ), ) @@ -325,21 +348,19 @@ describe("BashTool", () => { Effect.promise(() => tmpdir()), (tmp) => { reset() - result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") } + result = { ...result, exitCode: 7, output: Buffer.from("HEAD full output TAIL") } return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.result).toMatchObject({ + expect(settled.output?.content[1]).toMatchObject({ type: "text", - value: expect.stringContaining("Command exited with code 7"), + text: expect.stringContaining("Command exited with code 7"), }) expect(settled.output?.structured).toMatchObject({ - command: "false", - cwd: realpathSync(tmp.path), - exitCode: 7, - output: "HEAD full output TAIL", + exit: 7, truncated: false, }) + expect(settled.output?.content[0]).toEqual({ type: "text", text: "HEAD full output TAIL" }) }), ), ) @@ -353,14 +374,14 @@ describe("BashTool", () => { Effect.promise(() => tmpdir()), (tmp) => { reset() - result = { ...result, stdoutTruncated: true } + result = { ...result, outputTruncated: true } return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true }) - expect(settled.result).toMatchObject({ + expect(settled.output?.structured).toMatchObject({ truncated: true }) + expect(settled.output?.content[0]).toMatchObject({ type: "text", - value: expect.stringContaining("stdout capture truncated"), + text: expect.stringContaining("output capture truncated"), }) expect(settled.output?.structured).not.toHaveProperty("resource") }), @@ -380,13 +401,12 @@ describe("BashTool", () => { return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.result).toMatchObject({ + expect(settled.output?.content[1]).toMatchObject({ type: "text", - value: expect.stringContaining("Command timed out"), + text: expect.stringContaining("Command timed out"), }) expect(settled.output?.structured).toMatchObject({ - command: "sleep 60", - timedOut: true, + timeout: true, truncated: false, }) }), diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 57a354fc7c..2c684523b7 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -3,6 +3,8 @@ import path from "path" import { fileURLToPath } from "url" import { describe, expect, test } from "bun:test" import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FileMutation } from "@opencode-ai/core/file-mutation" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" @@ -11,6 +13,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { EditTool } from "@opencode-ai/core/tool/edit" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" @@ -71,26 +74,34 @@ const filesystem = Layer.effect( Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFileString(target, content, options))), }) }), -).pipe(Layer.provide(FSUtil.defaultLayer)) +).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) const withTool = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => { const activeLocation = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) })), ) - const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation)) - const mutation = FileMutation.layer.pipe(Layer.provide(filesystem)) - const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) - const edit = EditTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(resolution), - Layer.provide(mutation), - Layer.provide(filesystem), - ) return Effect.gen(function* () { return yield* body(yield* ToolRegistry.Service) - }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, edit))) + }).pipe( + Effect.provide( + AppNodeBuilder.build( + LayerNode.group([ + ToolRegistry.node, + ToolRegistry.toolsNode, + LocationMutation.node, + FileMutation.node, + EditTool.node, + ]), + [ + [FSUtil.node, filesystem], + [Location.node, activeLocation], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ], + ), + ), + ) } const call = (input: typeof EditTool.Input.Type, id = "call-edit") => ({ @@ -125,11 +136,16 @@ describe("EditTool", () => { value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```", }) expect(settled.output?.structured).toEqual({ - operation: "write", - target: yield* Effect.promise(() => fs.realpath(target)), - resource: "hello.txt", - existed: true, replacements: 1, + files: [ + { + file: "hello.txt", + status: "modified", + additions: 1, + deletions: 1, + patch: expect.stringContaining("-before\n+after"), + }, + ], }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n") expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }]) @@ -404,7 +420,7 @@ test("keeps the locked edit schema, semantics docstring, and deferred TODOs visi expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["newString", "oldString", "path", "replaceAll"]) expect(source).toContain( - "Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.", + "absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.", ) for (const todo of [ "Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.", diff --git a/packages/core/test/tool-output-store.test.ts b/packages/core/test/tool-output-store.test.ts index f0504e9759..a2d132a74b 100644 --- a/packages/core/test/tool-output-store.test.ts +++ b/packages/core/test/tool-output-store.test.ts @@ -1,6 +1,8 @@ import { describe, expect } from "bun:test" import path from "path" import { Cause, Effect, Exit, Fiber, Layer, Option } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { Config } from "@opencode-ai/core/config" @@ -28,14 +30,14 @@ const withStore = ( }), ) : Layer.empty - const store = ToolOutputStore.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(global), - Layer.provide(configured), - ) + + const store = AppNodeBuilder.build(LayerNode.group([ToolOutputStore.node, FSUtil.node]), [ + [Global.node, global], + [Config.node, configured], + ]) return Effect.gen(function* () { return yield* body({ root: tmp.path, store: yield* ToolOutputStore.Service, fs: yield* FSUtil.Service }) - }).pipe(Effect.provide(Layer.mergeAll(store, FSUtil.defaultLayer))) + }).pipe(Effect.provide(store)) }, (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) @@ -185,11 +187,11 @@ describe("ToolOutputStore", () => { writeFileString: () => Effect.never, }) }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) - const store = ToolOutputStore.layer.pipe( - Layer.provide(blockedFilesystem), - Layer.provide(Global.layerWith({ data: root.path })), - ) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) + const store = AppNodeBuilder.build(ToolOutputStore.nodeWithoutConfig, [ + [Global.node, Global.layerWith({ data: root.path })], + [FSUtil.node, blockedFilesystem], + ]) const exit = yield* Effect.gen(function* () { const service = yield* ToolOutputStore.Service const fiber = yield* service diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 98105a8dc0..5c5c95da61 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -1,10 +1,13 @@ import { describe, expect } from "bun:test" import { Effect, Exit, Fiber, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionV2 } from "@opencode-ai/core/permission" import { QuestionV2 } from "@opencode-ai/core/question" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { QuestionTool } from "@opencode-ai/core/tool/question" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" @@ -28,7 +31,6 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const question = Layer.succeed( QuestionV2.Service, QuestionV2.Service.of({ @@ -41,8 +43,13 @@ const question = Layer.succeed( list: () => Effect.die("unused"), }), ) -const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(question)) -const it = testEffect(Layer.mergeAll(permission, registry, question, tool)) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, QuestionTool.node]), [ + [PermissionV2.node, permission], + [QuestionV2.node, question], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]), +) describe("QuestionTool", () => { it.effect("omits a denied built-in question and terminally settles a stale call", () => diff --git a/packages/core/test/tool-read-filesystem.test.ts b/packages/core/test/tool-read-filesystem.test.ts new file mode 100644 index 0000000000..e9970fb975 --- /dev/null +++ b/packages/core/test/tool-read-filesystem.test.ts @@ -0,0 +1,118 @@ +import { describe, expect } from "bun:test" +import path from "path" +import { Effect, FileSystem } from "effect" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" +import { testEffect } from "./lib/effect" + +const it = testEffect(LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem]))) +const fixture = Effect.gen(function* () { + const fs = yield* FSUtil.Service + const files = yield* FileSystem.FileSystem + const directory = yield* files.makeTempDirectoryScoped() + return { fs, files, directory } +}) + +describe("ReadToolFileSystem", () => { + it.effect("fails with a typed filesystem error when a resolved file disappears", () => + Effect.gen(function* () { + const { fs, directory } = yield* fixture + const file = path.join(directory, "missing.txt") + + const error = yield* ReadToolFileSystem.read(fs, file, "missing.txt").pipe(Effect.flip) + + expect(error).toMatchObject({ _tag: "PlatformError" }) + }), + ) + + it.effect("fails when a file becomes the wrong path kind", () => + Effect.gen(function* () { + const { fs, directory } = yield* fixture + + const error = yield* ReadToolFileSystem.read(fs, directory, "folder").pipe(Effect.flip) + + expect(error).toBeInstanceOf(ReadToolFileSystem.PathKindError) + }), + ) + + it.effect("fails with a typed filesystem error when directory listing fails", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const file = path.join(directory, "file.txt") + yield* files.writeFileString(file, "hello") + + const error = yield* ReadToolFileSystem.list(fs, file).pipe(Effect.flip) + + expect(error).toBeInstanceOf(FSUtil.FileSystemError) + if (error instanceof FSUtil.FileSystemError) expect(error.method).toBe("readDirectoryEntries") + }), + ) + + it.effect("reports binary and malformed UTF-8 content as typed errors", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const binary = path.join(directory, "archive.dat") + const malformed = path.join(directory, "malformed.txt") + yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3)) + const malformedContent = new Uint8Array(64 * 1024 + 1).fill(97) + malformedContent[64 * 1024] = 0x80 + yield* files.writeFile(malformed, malformedContent) + + const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip) + const malformedError = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt").pipe(Effect.flip) + + expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError) + expect(binaryError.message).toBe("Cannot read binary file: archive.dat") + expect(malformedError).toBeInstanceOf(ReadToolFileSystem.MalformedUtf8Error) + }), + ) + + it.effect("reports out-of-range pagination as a typed error", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const file = path.join(directory, "short.txt") + yield* files.writeFileString(file, "one\n") + + const error = yield* ReadToolFileSystem.read(fs, file, "short.txt", { offset: 2 }).pipe(Effect.flip) + + expect(error).toBeInstanceOf(ReadToolFileSystem.OffsetOutOfRangeError) + expect(error.message).toBe("Offset 2 is out of range") + }), + ) + + it.effect("stops reading after the requested page is complete", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const prefix = new TextEncoder().encode("one\n") + for (const [name, trailing] of [ + ["malformed.txt", 0x80], + ["nul.txt", 0], + ] as const) { + const file = path.join(directory, name) + yield* files.writeFile(file, Uint8Array.from([...prefix, trailing])) + + const result = yield* ReadToolFileSystem.read(fs, file, name, { limit: 1 }) + + expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 }) + } + }), + ) + + it.effect("preserves the media ingestion limit message", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const file = path.join(directory, "oversized.png") + yield* files.writeFile(file, Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) + yield* files.truncate(file, ReadToolFileSystem.MAX_MEDIA_INGEST_BYTES + 1) + + const error = yield* ReadToolFileSystem.read(fs, file, "oversized.png").pipe(Effect.flip) + + expect(error).toBeInstanceOf(ReadToolFileSystem.MediaIngestLimitError) + expect(error.message).toBe( + `Media exceeds ${ReadToolFileSystem.MAX_MEDIA_INGEST_BYTES} byte ingestion limit: oversized.png`, + ) + }), + ) +}) diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 605a1e17da..3c94888d57 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect } from "bun:test" -import { Effect, Exit, Layer } from "effect" +import path from "path" +import { Effect, Exit, Layer, PlatformError } from "effect" import { Config } from "@opencode-ai/core/config" import { ConfigAttachments } from "@opencode-ai/core/config/attachments" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +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" @@ -10,14 +13,18 @@ import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" import { AbsolutePath } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" +import { LocationMutation } from "@opencode-ai/core/location-mutation" import { location } from "./fixture/location" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ReadTool } from "@opencode-ai/core/tool/read" import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const assertions: PermissionV2.AssertInput[] = [] +const missingPath = "__missing_read_target__.txt" +const missingAbsolutePath = path.join(process.cwd(), missingPath) const readCalls: { input: AbsolutePath page: ReadToolFileSystem.PageInput @@ -32,7 +39,7 @@ let readResult: FileSystem.Content | ReadToolFileSystem.TextPage = { encoding: "utf8", mime: "text/plain", } -let readFailure: unknown +let readFailure: ReadToolFileSystem.ReadError | undefined let configEntries: Config.Entry[] = [] const reader = Layer.succeed( ReadToolFileSystem.Service, @@ -40,7 +47,7 @@ const reader = Layer.succeed( inspect: () => (resolveFailure === undefined ? Effect.succeed(resolvedType) : Effect.die(resolveFailure)), read: (input, _resource, page = {}) => { readCalls.push({ input, page }) - if (readFailure !== undefined) return Effect.die(readFailure) + if (readFailure !== undefined) return Effect.fail(readFailure) return Effect.succeed(readResult) }, list: (_path, input = {}) => @@ -65,42 +72,77 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) })) -const image = Image.layer.pipe(Layer.provide(config)) +const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]]) const testFileSystem = Layer.effect( FSUtil.Service, - FSUtil.Service.use((fs) => Effect.succeed(FSUtil.Service.of({ ...fs, realPath: (path) => Effect.succeed(path) }))), -).pipe(Layer.provide(FSUtil.defaultLayer)) -const infrastructure = Layer.mergeAll( - testFileSystem, - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))), - Global.layerWith({ data: Global.Path.data }), + FSUtil.Service.use((fs) => + Effect.succeed( + FSUtil.Service.of({ + ...fs, + realPath: (path) => + path === missingAbsolutePath + ? Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "realPath", + pathOrDescriptor: path, + }), + ) + : Effect.succeed(path), + }), + ), + ), +).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })), +) +const mutation = Layer.succeed( + LocationMutation.Service, + LocationMutation.Service.of({ + resolve: (input) => { + if (input.path === missingPath) + return Effect.fail(new LocationMutation.PathError({ path: input.path, reason: "non_directory_ancestor" })) + const canonical = path.resolve(process.cwd(), input.path) + const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical) + const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "." + const directory = path.dirname(canonical) + const externalResource = path.join(directory, "*").replaceAll("\\", "/") + return Effect.succeed({ + canonical, + resource, + externalDirectory: external + ? { + action: "external_directory" as const, + directory, + resource: externalResource, + save: externalResource, + } + : undefined, + }) + }, + }), ) const unavailableImage = Layer.succeed( Image.Service, Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }), ) -const read = ReadTool.layer.pipe( - Layer.provide(registry), - Layer.provide(reader), - Layer.provide(permission), - Layer.provide(config), - Layer.provide(image), - Layer.provide(infrastructure), -) -const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, infrastructure, read)) -const unavailableRead = ReadTool.layer.pipe( - Layer.provide(registry), - Layer.provide(reader), - Layer.provide(permission), - Layer.provide(config), - Layer.provide(unavailableImage), - Layer.provide(infrastructure), -) -const itWithoutResizer = testEffect( - Layer.mergeAll(registry, reader, permission, config, unavailableImage, infrastructure, unavailableRead), -) +const readLayer = (imageLayer: Layer.Layer) => + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, ReadTool.node]), [ + [ReadToolFileSystem.node, reader], + [PermissionV2.node, permission], + [Config.node, config], + [Image.node, imageLayer], + [LocationMutation.node, mutation], + [FSUtil.node, testFileSystem], + [Location.node, locationLayer], + [Global.node, Global.layerWith({ data: Global.Path.data })], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]) +const it = testEffect(readLayer(imageLayer)) +const itWithoutResizer = testEffect(readLayer(unavailableImage)) const sessionID = SessionV2.ID.make("ses_read_tool_test") describe("ReadTool", () => { @@ -145,7 +187,36 @@ describe("ReadTool", () => { }, }) expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }]) - expect(readCalls).toEqual([{ input: AbsolutePath.make(`${process.cwd()}/README.md`), page: {} }]) + expect(readCalls).toEqual([ + { + input: AbsolutePath.make(path.join(process.cwd(), "README.md")), + page: { offset: undefined, limit: undefined }, + }, + ]) + }), + ) + + it.effect("asks for external_directory approval before reading an external absolute path", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const external = path.join(path.parse(process.cwd()).root, "external-read", "notes.txt") + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } }, + }), + ).toMatchObject({ type: "json" }) + expect(assertions).toMatchObject([ + { + sessionID, + action: "external_directory", + resources: [path.join(path.dirname(external), "*").replaceAll("\\", "/")], + }, + { sessionID, action: "read", resources: [external.replaceAll("\\", "/")], save: ["*"] }, + ]) + expect(readCalls).toEqual([{ input: AbsolutePath.make(external), page: { offset: undefined, limit: undefined } }]) }), ) @@ -174,7 +245,12 @@ describe("ReadTool", () => { { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" }, ], }) - expect(readCalls).toEqual([{ input: AbsolutePath.make(`${process.cwd()}/pixel.png`), page: {} }]) + expect(readCalls).toEqual([ + { + input: AbsolutePath.make(path.join(process.cwd(), "pixel.png")), + page: { offset: undefined, limit: undefined }, + }, + ]) const settled = yield* settleTool(registry, { sessionID, @@ -412,9 +488,32 @@ describe("ReadTool", () => { }), ) + it.effect("returns expected filesystem failures to the model", () => + Effect.gen(function* () { + readFailure = new ReadToolFileSystem.BinaryFileError({ resource: "archive.dat" }) + const registry = yield* ToolRegistry.Service + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-binary", + name: "read", + input: { path: "archive.dat", offset: 2, limit: 1 }, + }, + }), + ).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" }) + expect(readCalls).toEqual([ + { input: AbsolutePath.make(path.join(process.cwd(), "archive.dat")), page: { offset: 2, limit: 1 } }, + ]) + }), + ) + it.effect("preserves unexpected filesystem defects", () => Effect.gen(function* () { - readFailure = new ReadToolFileSystem.BinaryFileError("archive.dat") + resolveFailure = new Error("unexpected") const registry = yield* ToolRegistry.Service expect( @@ -422,18 +521,10 @@ describe("ReadTool", () => { yield* executeTool(registry, { sessionID, ...toolIdentity, - call: { - type: "tool-call", - id: "call-binary", - name: "read", - input: { path: "archive.dat", offset: 2, limit: 1 }, - }, + call: { type: "tool-call", id: "call-defect", name: "read", input: { path: "README.md" } }, }).pipe(Effect.exit), ), ).toBe(true) - expect(readCalls).toEqual([ - { input: AbsolutePath.make(`${process.cwd()}/archive.dat`), page: { offset: 2, limit: 1 } }, - ]) }), ) @@ -453,6 +544,22 @@ describe("ReadTool", () => { }), ) + it.effect("returns missing paths as model-visible tool failures", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-missing-path", name: "read", input: { path: missingPath } }, + }), + ).toEqual({ type: "error", value: `Unable to read ${missingPath}` }) + expect(assertions).toEqual([]) + expect(readCalls).toEqual([]) + }), + ) + it.effect("lists a bounded directory page through read", () => Effect.gen(function* () { resolvedType = "directory" @@ -539,7 +646,7 @@ describe("ReadTool", () => { value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 }, }) expect(readCalls).toEqual([ - { input: AbsolutePath.make(`${process.cwd()}/large.txt`), page: { offset: 2, limit: 1 } }, + { input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } }, ]) }), ) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 8c08454c4f..4831acb799 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -1,16 +1,16 @@ import fs from "fs/promises" import path from "path" -import { pathToFileURL } from "url" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionV2 } from "@opencode-ai/core/permission" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SkillV2 } from "@opencode-ai/core/skill" import { SkillTool } from "@opencode-ai/core/tool/skill" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { tmpdir } from "./fixture/tmpdir" import { it } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" @@ -42,16 +42,6 @@ describe("SkillTool", () => { let current = [info] const assertions: PermissionV2.AssertInput[] = [] let deny = false - let bootWaited = false - const boot = Layer.succeed( - PluginBoot.Service, - PluginBoot.Service.of({ - wait: () => - Effect.sync(() => { - bootWaited = true - }), - }), - ) const permission = Layer.succeed( PermissionV2.Service, PermissionV2.Service.of({ @@ -69,24 +59,23 @@ describe("SkillTool", () => { const skills = Layer.succeed( SkillV2.Service, SkillV2.Service.of({ - transform: () => Effect.die("unused"), + transform: (_transform) => Effect.die("unused"), + reload: () => Effect.die("unused"), sources: () => Effect.die("unused"), list: () => Effect.succeed(current), }), ) - const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) - const tool = SkillTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(boot), - Layer.provide(skills), + const skillToolLayer = AppNodeBuilder.build( + LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, SkillTool.node]), + [ + [PermissionV2.node, permission], + [SkillV2.node, skills], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ], ) - const layer = Layer.mergeAll(permission, skills, registry, boot, tool) return yield* Effect.gen(function* () { const registry = yield* ToolRegistry.Service - expect(bootWaited).toBe(true) expect((yield* toolDefinitions(registry))[0]).toMatchObject({ name: "skill", description: SkillTool.description, @@ -101,9 +90,7 @@ describe("SkillTool", () => { type: "text", value: SkillTool.toModelOutput(info, [reference]), }) - expect(SkillTool.toModelOutput(info, [reference])).toContain( - `Base directory for this skill: ${pathToFileURL(directory).href}`, - ) + expect(SkillTool.toModelOutput(info, [reference])).toContain(`Base directory for this skill: ${directory}`) expect( yield* settleTool(registry, { sessionID, @@ -134,7 +121,7 @@ describe("SkillTool", () => { }), ).toEqual({ type: "error", value: "Unable to load skill effect" }) deny = false - const flat = new SkillV2.Info({ + const flat = SkillV2.Info.make({ name: "public", description: "Public guidance", location: AbsolutePath.make(path.join(tmp.path, "public.md")), @@ -154,7 +141,7 @@ describe("SkillTool", () => { call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } }, }), ).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) }) - }).pipe(Effect.provide(layer)) + }).pipe(Effect.provide(skillToolLayer)) }), ), ), diff --git a/packages/core/test/tool-todowrite.test.ts b/packages/core/test/tool-todowrite.test.ts index c8d1799010..170d1230a3 100644 --- a/packages/core/test/tool-todowrite.test.ts +++ b/packages/core/test/tool-todowrite.test.ts @@ -1,6 +1,8 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" 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" import { EventV2 } from "@opencode-ai/core/event" import { PermissionV2 } from "@opencode-ai/core/permission" import { Project } from "@opencode-ai/core/project" @@ -11,6 +13,7 @@ import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionTodo } from "@opencode-ai/core/session/todo" import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" @@ -32,12 +35,22 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events)) -const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) -const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(todos)) -const it = testEffect(Layer.mergeAll(database, events, todos, permission, registry, tool)) +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([ + Database.node, + EventV2.node, + SessionTodo.node, + ToolRegistry.node, + ToolRegistry.toolsNode, + TodoWriteTool.node, + ]), + [ + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ], + ), +) const setup = Effect.gen(function* () { assertions.length = 0 @@ -74,7 +87,9 @@ describe("TodoWriteTool", () => { yield* setup const registry = yield* ToolRegistry.Service const service = yield* SessionTodo.Service - const todoList = [{ content: "Implement slice", status: "in_progress", priority: "high" }] + const todoList: ReadonlyArray = [ + { content: "Implement slice", status: "in_progress", priority: "high" }, + ] expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([TodoWriteTool.name]) expect(yield* settleTool(registry, call(todoList))).toEqual({ diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index b2541e3e21..2fe46f0ae5 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -1,11 +1,15 @@ import { describe, expect, test } from "bun:test" import { Duration, Effect, Fiber, Layer, Schema } from "effect" import * as TestClock from "effect/testing/TestClock" -import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { WebFetchTool } from "@opencode-ai/core/tool/webfetch" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" @@ -35,15 +39,14 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) -const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(http)) -const it = testEffect(Layer.mergeAll(registry, permission, http, webfetch)) -const fetchWebfetch = WebFetchTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(FetchHttpClient.layer), -) -const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, fetchWebfetch)) +const toolLayer = (replacements: LayerNode.Replacements = []) => + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebFetchTool.node]), [ + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ...replacements, + ]) +const it = testEffect(toolLayer([[LayerNodePlatform.httpClient, http]])) +const live = testEffect(toolLayer()) const reset = () => { requests.length = 0 @@ -176,6 +179,25 @@ describe("WebFetchTool registration", () => { }), ) + it.effect("returns an error result when HTML-to-Markdown conversion throws", () => + Effect.gen(function* () { + reset() + respond = () => + Effect.succeed( + new Response("
".repeat(10_000) + "content" + "
".repeat(10_000), { + headers: { "content-type": "text/html" }, + }), + ) + const registry = yield* ToolRegistry.Service + const url = "https://1.1.1.1/deep-html" + + expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toEqual({ + type: "error", + value: `Unable to fetch ${url}`, + }) + }), + ) + it.effect("rejects declared and streamed oversized bodies", () => Effect.gen(function* () { reset() diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index dc38a9c350..0b99a80ecb 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -1,10 +1,14 @@ -import { describe, expect, test } from "bun:test" +import { beforeEach, describe, expect, test } from "bun:test" import { Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { WebSearchTool } from "@opencode-ai/core/tool/websearch" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" @@ -66,8 +70,14 @@ interface Request { const requests: Request[] = [] const assertions: PermissionV2.AssertInput[] = [] let responseBody = payload("search results") +let makeResponse = () => new Response(responseBody, { status: 200 }) let config: WebSearchTool.Config = { enableExa: false, enableParallel: false } +beforeEach(() => { + responseBody = payload("search results") + makeResponse = () => new Response(responseBody, { status: 200 }) +}) + const http = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -78,7 +88,7 @@ const http = Layer.succeed( headers: request.headers, body: JSON.parse(new TextDecoder().decode(request.body.body)), }) - return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: 200 })) + return HttpClientResponse.fromWeb(request, makeResponse()) }), ), ) @@ -93,7 +103,6 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const websearchConfig = Layer.succeed( WebSearchTool.ConfigService, WebSearchTool.ConfigService.of({ @@ -114,13 +123,17 @@ const websearchConfig = Layer.succeed( }, }), ) -const websearch = WebSearchTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(http), - Layer.provide(websearchConfig), +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, WebSearchTool.node]), + [ + [PermissionV2.node, permission], + [LayerNodePlatform.httpClient, http], + [WebSearchTool.configNode, websearchConfig], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ], + ), ) -const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, websearch)) describe("WebSearchTool registration", () => { it.effect("registers websearch, asserts query permission, and calls Exa", () => @@ -270,7 +283,22 @@ describe("WebSearchTool registration", () => { Effect.gen(function* () { requests.length = 0 assertions.length = 0 - responseBody = "x".repeat(WebSearchTool.MAX_RESPONSE_BYTES + 1) + let chunksRead = 0 + let cancelled = false + makeResponse = () => + new Response( + new ReadableStream({ + pull(controller) { + chunksRead++ + if (chunksRead === 10) throw new Error("response was not stopped at the byte limit") + controller.enqueue(new Uint8Array(64 * 1024)) + }, + cancel() { + cancelled = true + }, + }), + { status: 200 }, + ) config = { provider: "exa", enableExa: false, enableParallel: false } const registry = yield* ToolRegistry.Service @@ -281,6 +309,8 @@ describe("WebSearchTool registration", () => { call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } }, }), ).toEqual({ type: "error", value: "Unable to search the web for too much" }) + expect(chunksRead).toBeLessThan(10) + expect(cancelled).toBe(true) }), ) }) diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index de5c7c264a..a45bf73ee4 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -4,6 +4,8 @@ import { fileURLToPath } from "url" import { describe, expect, test } from "bun:test" import { Effect, Layer } from "effect" import { FileMutation } from "@opencode-ai/core/file-mutation" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" import { LocationMutation } from "@opencode-ai/core/location-mutation" @@ -11,6 +13,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { WriteTool } from "@opencode-ai/core/tool/write" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" @@ -55,25 +58,34 @@ const filesystem = Layer.effect( Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))), }) }), -).pipe(Layer.provide(FSUtil.defaultLayer)) +).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) const withTool = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => { const activeLocation = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) })), ) - const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation)) - const mutation = FileMutation.layer.pipe(Layer.provide(filesystem)) - const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) - const write = WriteTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(resolution), - Layer.provide(mutation), - ) return Effect.gen(function* () { return yield* body(yield* ToolRegistry.Service) - }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, write))) + }).pipe( + Effect.provide( + AppNodeBuilder.build( + LayerNode.group([ + ToolRegistry.node, + ToolRegistry.toolsNode, + LocationMutation.node, + FileMutation.node, + WriteTool.node, + ]), + [ + [FSUtil.node, filesystem], + [Location.node, activeLocation], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ], + ), + ), + ) } const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({ @@ -279,7 +291,7 @@ test("keeps the locked write schema, semantics docstring, and deferred UX TODOs expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["content", "path"]) expect(source).toContain( - "Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.", + "absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.", ) for (const todo of [ "Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.", diff --git a/packages/core/test/util/effect-flock.test.ts b/packages/core/test/util/effect-flock.test.ts index 0ec17c1e63..a0f737a998 100644 --- a/packages/core/test/util/effect-flock.test.ts +++ b/packages/core/test/util/effect-flock.test.ts @@ -3,9 +3,10 @@ import { spawn } from "child_process" import fs from "fs/promises" import path from "path" import os from "os" -import { Cause, Effect, Exit, Layer } from "effect" +import { Cause, Effect, Exit } from "effect" import { testEffect } from "../lib/effect" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Global } from "@opencode-ai/core/global" import { Hash } from "@opencode-ai/core/util/hash" @@ -109,7 +110,7 @@ const testGlobal = Global.layerWith({ log: os.tmpdir(), }) -const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(FSUtil.defaultLayer)) +const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]]) // --------------------------------------------------------------------------- // Tests diff --git a/packages/effect-drizzle-sqlite/examples/basic.ts b/packages/effect-drizzle-sqlite/examples/basic.ts index 675aabcb85..80397cd6e6 100644 --- a/packages/effect-drizzle-sqlite/examples/basic.ts +++ b/packages/effect-drizzle-sqlite/examples/basic.ts @@ -25,7 +25,7 @@ class Database extends Context.Service()("@opencode/exa class UserStoreError extends Schema.TaggedErrorClass()("UserStoreError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} const mapStoreError = (message: string) => (cause: unknown) => new UserStoreError({ message, cause }) diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index e1769c5178..920418a989 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.15", + "version": "7.4.16", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 6c7ddec79e..4f1e2c81b5 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.15", + "version": "7.4.16", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index b8a9859e04..99f770634a 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.15", + "version": "7.4.16", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", @@ -53,11 +53,11 @@ "typescript": "catalog:" }, "dependencies": { - "@effect/platform-node": "4.0.0-beta.74", + "@effect/platform-node": "4.0.0-beta.83", "effect": "catalog:", - "@effect/platform-node-shared": "4.0.0-beta.74" + "@effect/platform-node-shared": "4.0.0-beta.83" }, "peerDependencies": { - "effect": "4.0.0-beta.74" + "effect": "4.0.0-beta.83" } } diff --git a/packages/httpapi-codegen/README.md b/packages/httpapi-codegen/README.md new file mode 100644 index 0000000000..0d31b129b7 --- /dev/null +++ b/packages/httpapi-codegen/README.md @@ -0,0 +1,42 @@ +# @opencode-ai/httpapi-codegen + +Build-time source generation for domain-oriented Promise and Effect APIs derived directly from `HttpApi` and Effect Schema contracts. + +The package is private while its API is explored. Its tests are the executable specification for the generator. It must remain independent of OpenCode Core and use synthetic `HttpApi` fixtures. + +## Settled rules + +- Reflect one authoritative `HttpApi` into a shared contract with `compile(Api)`. +- Emit clients independently with `emitPromise(contract)` and `emitEffect(contract)`. +- Give each emitter its own public type projection; the shared contract, not a generated type package, is the common source. +- Generate a rich Effect client with decoded Effect-native values, runtime schemas, preserved transformations, and `HttpApiClient`. +- Generate a zero-Effect Promise client with structural wire-oriented values, direct `fetch`, and syntax parsing without runtime structural validation. +- Keep the Promise surface domain-oriented rather than Hey API compatible: methods return unwrapped values and reject with tagged declared errors or `ClientError`. +- Return Promise streams as lazy `AsyncIterable` values and Effect streams as `Stream` values. Neither runtime reconnects automatically. + +- Flatten path, query, header, and payload fields into one input object. +- Reject duplicate field names across input channels. +- Emit no method argument for zero fields, an optional object when every field is optional, and a required object when any field is required. +- Unwrap exact `{ data: A }` success envelopes. +- Map no-content success to `void`. +- Preserve other single success values. +- Reject ambiguous multiple-success contracts. +- Expose streaming success as `Stream`, not `Effect`. +- Reject schemas whose wire/domain transformation cannot be generated exactly. +- Map transport, unexpected-status, and response-decoding failures to one stable generated `ClientError`. +- Commit generated source for review; CI regenerates and fails when the worktree changes. +- Track generated files in `.httpapi-codegen.json` so regeneration removes only stale files previously owned by the generator. + +## Boundary + +This package generates only client APIs derived from `HttpApi`. It does not generate embedded-only capabilities. Networked and embedded OpenCode use the same generated Effect client against network and in-memory `HttpClient` transports respectively; the embedded host structurally extends that client with same-process capabilities. + +Codegen generates every endpoint in the `HttpApi` it receives. OpenCode owns the product decision by composing the exact remote API before invoking the generator; the generic package has no endpoint filtering policy. + +The existing public `generate(Api, { directory })` operation writes the rich Effect output and remains an Effect requiring `FileSystem`. The staged API uses pure `compile(Api)`, `emitEffect(contract)`, and `emitPromise(contract)` phases before `write(output, directory)`. Compiler tests inspect virtual files directly; writer tests use `FileSystem.makeNoop`. + +Generation formats TypeScript with Prettier before writing. Output paths are flat, unique, and checked against traversal, reserved manifest names, and existing symbolic links. + +Portable Effect output uses one self-contained module per `HttpApiGroup`, plus root client and index modules. Promise output uses shared type and client modules, while imported Effect output keeps adapters in the root client module. Schema dependencies may be duplicated across portable Effect group modules. Cross-group schema partitioning is deferred until measured output or bundle cost requires it. + +Codegen preserves transport identifiers internally. `compile` may explicitly map consumer-facing group names, and endpoint operation IDs are projected to their final dot-delimited segment. The generator performs no other implicit product-specific naming or public-name annotation mapping. diff --git a/packages/httpapi-codegen/package.json b/packages/httpapi-codegen/package.json new file mode 100644 index 0000000000..1af10d6d94 --- /dev/null +++ b/packages/httpapi-codegen/package.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/httpapi-codegen", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "bun test --timeout 5000 --only-failures", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "effect": "catalog:", + "prettier": "3.6.2" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + }, + "version": "7.4.16" +} diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts new file mode 100644 index 0000000000..298a1211dd --- /dev/null +++ b/packages/httpapi-codegen/src/index.ts @@ -0,0 +1,1185 @@ +import { isAbsolute, join } from "node:path" +import { Effect, FileSystem, PlatformError, Schema, SchemaAST, SchemaRepresentation } from "effect" +import { HttpMethod, type HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" +import { format } from "prettier" + +export type InputField = { + readonly name: string + readonly source: "params" | "query" | "headers" | "payload" +} + +export type Operation = { + readonly group: string + readonly name: string + readonly input: ReadonlyArray + readonly inputMode: "none" | "optional" | "required" + readonly success: "value" | "void" | "stream" + readonly errors: ReadonlyArray +} + +export type Output = { + readonly operations: ReadonlyArray + readonly files: ReadonlyArray<{ + readonly path: string + readonly content: string + }> +} + +export type Contract = { + readonly groups: ReadonlyArray +} + +export class GenerationError extends Schema.TaggedErrorClass()("GenerationError", { + reason: Schema.String, +}) { + override get message() { + return this.reason + } +} + +export type Endpoint = { + readonly group: string + readonly sourceGroup: string + readonly topLevel: boolean + readonly endpoint: HttpApiEndpoint.AnyWithProps + readonly params: Schema.Top | undefined + readonly query: Schema.Top | undefined + readonly headers: Schema.Top | undefined + readonly payloads: ReadonlyArray + readonly operation: Operation + readonly input: ReadonlyArray + readonly unwrapData: boolean + readonly errors: ReadonlyArray<{ readonly status: number; readonly schema: Schema.Top }> + readonly successes: ReadonlyArray + readonly effectPortable: boolean +} + +export type Group = { + readonly identifier: string + readonly sourceIdentifier: string + readonly module: string + readonly endpoints: ReadonlyArray +} + +type Slot = { + readonly name: string + readonly schema: Schema.Top +} + +const resolveHttpApiStatus = SchemaAST.resolveAt("httpApiStatus") +const resolveHttpApiEncoding = SchemaAST.resolveAt("~httpApiEncoding") +const resolveContentSchema = SchemaAST.resolveAt("contentSchema") +const Manifest = Schema.fromJsonString(Schema.Array(Schema.String)) +const manifestName = ".httpapi-codegen.json" + +export function compile( + api: HttpApi.HttpApi, + options?: { + readonly groupNames?: Readonly> + readonly endpointNames?: Readonly> + readonly omitEndpoints?: ReadonlySet + }, +): Contract { + const endpoints: Array = [] + const portable = new Map() + + HttpApi.reflect(api, { + onGroup() {}, + onEndpoint({ endpoint, errors, group, middleware }) { + if (options?.omitEndpoints?.has(endpoint.name)) return + const groupName = options?.groupNames?.[group.identifier] ?? group.identifier + const name = `${groupName}.${endpoint.name}` + const required = Array.from(middleware).find((item) => item.requiredForClient) + if (required !== undefined) { + throw new GenerationError({ reason: `Client middleware requires adapter: ${required.key}` }) + } + + const successSchemas = Array.from(endpoint.success) + if (successSchemas.length === 0) successSchemas.push(HttpApiSchema.NoContent) + if (successSchemas.length > 1) throw new GenerationError({ reason: `Multiple success schemas: ${name}` }) + + const params = normalizeTransport(endpoint.params, "params", endpoint, name) + const query = normalizeTransport(endpoint.query, "query", endpoint, name) + const headers = normalizeTransport(endpoint.headers, "headers", endpoint, name) + const sourcePayloads = Array.from(endpoint.payload.values()).flatMap(({ schemas }) => schemas) + if (sourcePayloads.length > 1) { + throw new GenerationError({ reason: `Multiple payload schemas: ${name}` }) + } + const payloads = sourcePayloads.map((schema) => normalizeTransport(schema, "payload", endpoint, name)!) + const success = normalizeTransport(successSchemas[0], "success", endpoint, name)! + const errorSchemas = Array.from(errors).flatMap(([status, schemas]) => + schemas.map((schema) => ({ status, ...normalizeTransport(schema, "error", endpoint, name)! })), + ) + const inputs = [ + ...inputFields(params?.schema, "params", name), + ...inputFields(query?.schema, "query", name), + ...inputFields(headers?.schema, "headers", name), + ...payloads.flatMap((item) => inputFields(item.schema, "payload", name)), + ] + const names = new Set() + for (const field of inputs) { + if (names.has(field.name)) throw new GenerationError({ reason: `Input field collision: ${field.name}` }) + names.add(field.name) + } + + const schemaPaths: Array = [ + ...(params === undefined ? [] : [[`${name}.params`, params.schema] as const]), + ...(query === undefined ? [] : [[`${name}.query`, query.schema] as const]), + ...(headers === undefined ? [] : [[`${name}.headers`, headers.schema] as const]), + ...payloads.map((item) => [`${name}.payload`, item.schema] as const), + ...responseSchemas(success.schema, `${name}.success`), + ...errorSchemas.map((item) => [`${name}.error.${item.status}`, item.schema] as const), + ] + const effectPortable = + [params, query, headers, ...payloads, success, ...errorSchemas].every( + (item) => item?.effectPortable !== false, + ) && streamEffectPortable(success.schema) + if (effectPortable) { + for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable) + } + + endpoints.push({ + group: groupName, + sourceGroup: group.identifier, + topLevel: group.topLevel, + endpoint, + params: params?.schema, + query: query?.schema, + headers: headers?.schema, + payloads: payloads.map((item) => item.schema), + input: inputs, + unwrapData: isDataEnvelope(success.schema), + successes: [success.schema], + errors: errorSchemas.map((item) => ({ status: item.status, schema: item.schema })), + effectPortable, + operation: { + group: groupName, + name: options?.endpointNames?.[endpoint.name] ?? clientEndpointName(endpoint.name), + input: inputs.map(({ name, source }) => ({ name, source })), + inputMode: inputs.length === 0 ? "none" : inputs.every((field) => field.optional) ? "optional" : "required", + success: isStreamSchema(success.schema) + ? "stream" + : HttpApiSchema.isNoContent(success.schema.ast) + ? "void" + : "value", + errors: [ + ...new Set([ + ...errorSchemas.flatMap((item) => { + const identifier = SchemaAST.resolveIdentifier(item.schema.ast) + return identifier === undefined ? [] : [identifier] + }), + "ClientError", + ]), + ], + }, + }) + }, + }) + + const modules = new Set(["client", "client-error", "index"]) + const groups = Array.from( + Map.groupBy(endpoints, (endpoint) => endpoint.group), + ([identifier, endpoints], index) => { + if (new Set(endpoints.map((endpoint) => endpoint.sourceGroup)).size > 1) { + throw new GenerationError({ reason: `Client group name collision: ${identifier}` }) + } + const base = /^[A-Za-z0-9_-]+$/.test(identifier) ? identifier : `group-${index}` + const module = uniqueModule(base, index, modules) + modules.add(module.toLowerCase()) + return { identifier, sourceIdentifier: endpoints[0].sourceGroup, module, endpoints } + }, + ) + const publicNames = new Set() + for (const group of groups) { + const endpointNames = new Set() + for (const endpoint of group.endpoints) { + if (endpointNames.has(endpoint.operation.name)) { + throw new GenerationError({ + reason: `Client endpoint name collision: ${group.identifier}.${endpoint.operation.name}`, + }) + } + endpointNames.add(endpoint.operation.name) + } + const names = group.endpoints[0]?.topLevel ? group.endpoints.map((item) => item.operation.name) : [group.identifier] + for (const name of names) { + if (publicNames.has(name)) throw new GenerationError({ reason: `Client name collision: ${name}` }) + publicNames.add(name) + } + } + return { + groups, + } +} + +export function emitEffect(contract: Contract): Output { + const endpoint = contract.groups.flatMap((group) => group.endpoints).find((endpoint) => !endpoint.effectPortable) + if (endpoint !== undefined) { + throw new GenerationError({ + reason: `Effect schema requires authoritative import: ${endpoint.group}.${endpoint.endpoint.name}`, + }) + } + return { operations: operations(contract.groups), files: renderEffectFiles(contract.groups) } +} + +export function emitEffectImported( + contract: Contract, + options: + | { readonly module: string; readonly api: string } + | { readonly module: string; readonly group: string } + | { readonly module: string; readonly endpoints: Readonly> }, +): Output { + return { + operations: operations(contract.groups), + files: renderImportedEffectFiles(contract.groups, options), + } +} + +export function emitPromise( + contract: Contract, + options?: { + readonly outputTypes?: Readonly> + }, +): Output { + const groups = contract.groups + for (const group of groups) { + for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint) + } + return { + operations: operations(groups), + files: [ + { path: "types.ts", content: renderPromiseTypes(groups, options?.outputTypes) }, + { + path: "client-error.ts", + content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`, + }, + { + path: "client.ts", + content: renderPromiseClient(groups).replace("let next: ReadableStreamReadResult", "let next"), + }, + { + path: "index.ts", + content: + 'export { ClientError, type ClientErrorReason } from "./client-error"\nexport * as OpenCode from "./client"\nexport * from "./types"\n', + }, + ], + } +} + +function assertPromiseEndpoint(endpoint: Endpoint) { + const name = `${endpoint.group}.${endpoint.endpoint.name}` + const payload = endpoint.payloads[0] + const payloadEncoding = payload === undefined ? undefined : resolveHttpApiEncoding(payload.ast) + if ( + payload !== undefined && + (payloadEncoding?._tag ?? (HttpMethod.hasBody(endpoint.endpoint.method) ? "Json" : "FormUrlEncoded")) !== "Json" + ) { + throw new GenerationError({ reason: `Unsupported Promise payload encoding: ${name}` }) + } + const success = endpoint.successes[0] + if (isStreamSchema(success)) { + if ( + success._tag !== "StreamSse" || + success.sseMode !== "data" || + !SchemaAST.isNever(Schema.toType(success.error).ast) + ) { + throw new GenerationError({ reason: `Unsupported Promise stream: ${name}` }) + } + } else if ( + !HttpApiSchema.isNoContent(success.ast) && + (resolveHttpApiEncoding(success.ast)?._tag ?? "Json") !== "Json" + ) { + throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` }) + } + for (const error of endpoint.errors) { + if (declaredErrorFields(error.schema) === undefined) { + throw new GenerationError({ reason: `Promise error must have a literal discriminator: ${name}` }) + } + if ((resolveHttpApiEncoding(error.schema.ast)?._tag ?? "Json") !== "Json") { + throw new GenerationError({ reason: `Unsupported Promise error encoding: ${name}` }) + } + } +} + +function operations(groups: ReadonlyArray) { + return groups.flatMap((group) => group.endpoints.map((endpoint) => endpoint.operation)) +} + +function renderEffectFiles(groups: ReadonlyArray): Output["files"] { + return [ + ...groups.map((group, index) => ({ path: `${group.module}.ts`, content: renderGroup(group, index) })), + { + path: "client-error.ts", + content: + 'import { Schema } from "effect"\n\nexport class ClientError extends Schema.TaggedErrorClass()("ClientError", {\n cause: Schema.Defect(),\n}) {}\n', + }, + { path: "client.ts", content: renderClient(groups) }, + { + path: "index.ts", + content: 'export { ClientError } from "./client-error"\nexport * as OpenCode from "./client"\n', + }, + ] +} + +function renderImportedEffectFiles( + groups: ReadonlyArray, + options: + | { readonly module: string; readonly api: string } + | { readonly module: string; readonly group: string } + | { readonly module: string; readonly endpoints: Readonly> }, +): Output["files"] { + const adapters = groups.map((group, groupIndex) => { + const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]` + const methods = group.endpoints.map((item, endpointIndex) => { + const prefix = `Endpoint${groupIndex}_${endpointIndex}` + const request = (["params", "query", "headers", "payload"] as const) + .flatMap((source) => { + const fields = item.input.filter((field) => field.source === source) + if (fields.length === 0) return [] + return [ + `${source}: { ${fields.map((field) => `${JSON.stringify(field.name)}: input${item.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(field.name)}]`).join(", ")} }`, + ] + }) + .join(", ") + const input = item.input + .map( + (field) => + `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}][${JSON.stringify(field.name)}]`, + ) + .join("; ") + const argument = + item.operation.inputMode === "none" + ? "" + : `input${item.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input` + const rawCall = `raw[${JSON.stringify(item.endpoint.name)}]({ ${request} })` + const mapped = `${rawCall}.pipe(Effect.mapError(mapClientError)${item.unwrapData ? ", Effect.map((value) => value.data)" : ""})` + return `${item.operation.inputMode === "none" ? "" : `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(item.endpoint.name)}]>[0]\ntype ${prefix}Input = { ${input} }\n`}const ${prefix} = (raw: ${rawGroup}) => (${argument}) => ${item.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(mapClientError), Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError)))))` : mapped}` + }) + return `${methods.join("\n\n")}\n\nconst adaptGroup${groupIndex} = (raw: ${rawGroup}) => ({ ${group.endpoints.map((item, endpointIndex) => `${JSON.stringify(item.operation.name)}: Endpoint${groupIndex}_${endpointIndex}(raw)`).join(", ")} })` + }) + const fields = groups.flatMap((group, index) => + group.endpoints[0]?.topLevel + ? [`...adaptGroup${index}(raw)`] + : [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.sourceIdentifier)}])`], + ) + const usesStream = groups.some((group) => group.endpoints.some((item) => item.operation.success === "stream")) + const imported = "api" in options + const projection = imported + ? undefined + : "group" in options + ? renderImportedGroup(options.group) + : renderImportedProjection(groups, options.endpoints) + const api = imported ? options.api : "Api" + const imports = + projection === undefined + ? `import { ${api} } from ${JSON.stringify(options.module)}` + : `import { HttpApi, HttpApiClient${"endpoints" in options ? ", HttpApiGroup" : ""} } from "effect/unstable/httpapi"\nimport { ${projection.imports.join(", ")} } from ${JSON.stringify(options.module)}` + const httpApiImport = projection === undefined ? 'import { HttpApiClient } from "effect/unstable/httpapi"\n' : "" + const client = `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect${usesStream ? ", Stream" : ""}, Schema } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\n${httpApiImport}${imports}\nimport { ClientError } from "./client-error"\n\n${projection?.source ?? ""}type RawClient = HttpApiClient.ForApi\n\nconst mapClientError = (error: E) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : error\n\n${adapters.join("\n\n")}\n\nconst adaptClient = (raw: RawClient) => ({ ${fields.join(", ")} })\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) => HttpApiClient.make(${api}, options).pipe(Effect.map(adaptClient))\n` + return [ + { + path: "client-error.ts", + content: + 'import { Schema } from "effect"\n\nexport class ClientError extends Schema.TaggedErrorClass()("ClientError", {\n cause: Schema.Defect(),\n}) {}\n', + }, + { path: "client.ts", content: client }, + { + path: "index.ts", + content: 'export { ClientError } from "./client-error"\nexport * as OpenCode from "./client"\n', + }, + ] +} + +function renderImportedGroup(group: string) { + return { + imports: [group], + source: `const Api = HttpApi.make("generated").add(${group})\n\n`, + } +} + +function renderImportedProjection(groups: ReadonlyArray, endpoints: Readonly>) { + const imports = groups.flatMap((group) => + group.endpoints.map((endpoint) => { + const name = endpoints[`${group.identifier}.${endpoint.endpoint.name}`] + if (name === undefined) { + throw new GenerationError({ + reason: `Missing imported endpoint: ${group.identifier}.${endpoint.endpoint.name}`, + }) + } + return name + }), + ) + const source = `const Api = HttpApi.make("generated").${groups + .map((group) => { + const options = group.endpoints[0]?.topLevel ? ", { topLevel: true }" : "" + return `add(HttpApiGroup.make(${JSON.stringify(group.identifier)}${options})${group.endpoints.map((endpoint) => `.add(${endpoints[`${group.identifier}.${endpoint.endpoint.name}`]})`).join("")})` + }) + .join(".")}\n\n` + return { imports: [...new Set(imports)], source } +} + +function renderPromiseTypes( + groups: ReadonlyArray, + outputTypes?: Readonly>, +) { + const types = new Map() + const typeOf = (schema: Schema.Top, decoded = false) => { + const projected = decoded ? Schema.toType(schema) : Schema.toEncoded(schema) + const cached = types.get(projected.ast) + if (cached !== undefined) return cached + const type = structuralType(projected) + types.set(projected.ast, type) + return type + } + const errors = new Map( + groups.flatMap((group) => + group.endpoints.flatMap((endpoint) => + endpoint.errors.flatMap((error) => { + const tagged = declaredErrorFields(error.schema) + return tagged === undefined ? [] : [[tagged.tag, tagged] as const] + }), + ), + ), + ) + const errorTypes = Array.from(errors.values()).map((error) => { + const fields = error.fields + .map(([name, schema, optional]) => `readonly ${JSON.stringify(name)}${optional ? "?" : ""}: ${typeOf(schema)}`) + .join("; ") + return `export type ${error.identifier} = { readonly ${JSON.stringify(error.key)}: ${JSON.stringify(error.tag)}; ${fields} }\nexport const is${error.identifier} = (value: unknown): value is ${error.identifier} => typeof value === "object" && value !== null && ${JSON.stringify(error.key)} in value && value[${JSON.stringify(error.key)}] === ${JSON.stringify(error.tag)}` + }) + const operations = groups + .flatMap((group) => + group.endpoints.flatMap((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) + const schemas = { + params: endpoint.params, + query: endpoint.query, + headers: endpoint.headers, + payload: endpoint.payloads[0], + } + const input = endpoint.input + .map((field) => { + const schema = schemas[field.source] + if (schema === undefined) + throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` }) + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema, field.source === "query")})[${JSON.stringify(field.name)}]` + }) + .join("; ") + const successSchema = endpoint.successes[0] + const success = + outputTypes?.[`${group.identifier}.${endpoint.operation.name}`]?.name ?? + typeOf( + isStreamSchema(successSchema) && successSchema._tag === "StreamSse" + ? successSchema.sseMode === "data" + ? streamEncodedDataSchema(successSchema) + : successSchema.events + : successSchema, + ) + return [ + ...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]), + `export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`, + ] + }), + ) + .join("\n\n") + const json = operations.includes("JsonValue") + ? "export type JsonValue = null | boolean | number | string | ReadonlyArray | { readonly [key: string]: JsonValue }" + : "" + const imports = [...new Set(Object.values(outputTypes ?? {}).map((override) => override.import))] + return [...imports, json, ...errorTypes, operations].filter(Boolean).join("\n\n") +} + +function renderPromiseClient(groups: ReadonlyArray) { + const imports = groups.flatMap((group) => + group.endpoints.flatMap((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) + return [...(endpoint.operation.inputMode === "none" ? [] : [`${prefix}Input`]), `${prefix}Output`] + }), + ) + const fields = groups.map((group) => { + const methods = group.endpoints.map((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) + const argument = + endpoint.operation.inputMode === "none" + ? "requestOptions?: RequestOptions" + : `input${endpoint.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input, requestOptions?: RequestOptions` + const path = promisePath(endpoint.endpoint.path, endpoint.input) + const access = (name: string) => + `input${endpoint.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(name)}]` + const part = (source: InputField["source"]) => { + const inputs = endpoint.input.filter((field) => field.source === source) + return inputs.length === 0 + ? undefined + : `{ ${inputs.map((field) => `${JSON.stringify(field.name)}: ${access(field.name)}`).join(", ")} }` + } + const parts = [ + endpoint.query === undefined ? undefined : `query: ${part("query")}`, + endpoint.headers === undefined ? undefined : `headers: ${part("headers")}`, + endpoint.payloads.length === 0 ? undefined : `body: ${part("payload")}`, + ].filter((value): value is string => value !== undefined) + const declaredStatuses = [...new Set(endpoint.errors.map((error) => error.status))] + const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"} }` + if (endpoint.operation.success === "stream") { + const success = endpoint.successes[0] + if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") { + throw new GenerationError({ + reason: `Promise stream emission is not implemented: ${group.identifier}.${endpoint.endpoint.name}`, + }) + } + return `${JSON.stringify(endpoint.operation.name)}: (${argument}): AsyncIterable<${prefix}Output> => sse<${prefix}Output>(${descriptor}, requestOptions)` + } + const unwrap = endpoint.unwrapData ? ".then((value) => value.data)" : "" + return `${JSON.stringify(endpoint.operation.name)}: (${argument}) => request<${endpoint.unwrapData ? `{ readonly data: ${prefix}Output }` : `${prefix}Output`}>(${descriptor}, requestOptions)${unwrap}` + }) + if (group.endpoints[0]?.topLevel) return methods.join(", ") + return `${JSON.stringify(group.identifier)}: { ${methods.join(", ")} }` + }) + return `import type { ${imports.join(", ")} } from "./types"\nimport { ClientError } from "./client-error"\n\nexport interface ClientOptions {\n readonly baseUrl: string\n readonly fetch?: typeof globalThis.fetch\n readonly headers?: HeadersInit\n}\n\nexport interface RequestOptions {\n readonly signal?: AbortSignal\n readonly headers?: HeadersInit\n}\n\ninterface RequestDescriptor {\n readonly method: string\n readonly path: string\n readonly query?: Record\n readonly headers?: Record\n readonly body?: unknown\n readonly successStatus: number\n readonly declaredStatuses: ReadonlyArray\n readonly empty: boolean\n}\n\nexport function make(options: ClientOptions) {\n const fetch = options.fetch ?? globalThis.fetch\n\n const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n const url = new URL(descriptor.path, options.baseUrl)\n for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)\n const headers = new Headers(options.headers)\n for (const [key, value] of Object.entries(descriptor.headers ?? {})) {\n if (value !== undefined && value !== null) headers.set(key, String(value))\n }\n for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)\n if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")\n return {\n url,\n init: {\n method: descriptor.method,\n signal: requestOptions?.signal,\n headers,\n body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),\n } satisfies RequestInit,\n }\n }\n\n const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n try {\n const prepared = prepare(descriptor, requestOptions)\n return await fetch(prepared.url, prepared.init)\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n\n const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => {\n if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })\n }\n\n const request = async
(descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) return responseError(response, descriptor)\n if (descriptor.empty) {\n try {\n await response.body?.cancel()\n } catch {}\n return undefined as A\n }\n return await json(response) as A\n }\n\n const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({\n async *[Symbol.asyncIterator]() {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) await responseError(response, descriptor)\n if (!isContentType(response, "text/event-stream")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n if (response.body === null) throw new ClientError("MalformedResponse")\n const reader = response.body.getReader()\n const decoder = new TextDecoder()\n let buffer = ""\n try {\n while (true) {\n let next: ReadableStreamReadResult\n try {\n next = await reader.read()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n buffer += decoder.decode(next.value, { stream: !next.done })\n if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")\n const trailingCarriageReturn = !next.done && buffer.endsWith("\\r")\n if (trailingCarriageReturn) buffer = buffer.slice(0, -1)\n buffer = buffer.replaceAll("\\r\\n", "\\n").replaceAll("\\r", "\\n")\n if (trailingCarriageReturn) buffer += "\\r"\n if (next.done && buffer !== "") buffer += "\\n\\n"\n let boundary = buffer.indexOf("\\n\\n")\n while (boundary >= 0) {\n const block = buffer.slice(0, boundary)\n buffer = buffer.slice(boundary + 2)\n const data = block.split("\\n").flatMap((line) => line.startsWith("data:") ? [line.slice(5).trimStart()] : []).join("\\n")\n if (data !== "") {\n try {\n yield JSON.parse(data) as A\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n }\n boundary = buffer.indexOf("\\n\\n")\n }\n if (next.done) return\n }\n } finally {\n try {\n await reader.cancel()\n } catch {}\n reader.releaseLock()\n }\n },\n })\n\n return { ${fields.join(", ")} }\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {\n if (value === undefined || value === null) return\n if (Array.isArray(value)) {\n for (const item of value) appendQuery(params, key, item)\n return\n }\n if (typeof value === "object") {\n for (const [child, item] of Object.entries(value)) appendQuery(params, \`\${key}[\${child}]\`, item)\n return\n }\n params.append(key, String(value))\n}\n\nasync function json(response: Response): Promise {\n if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n let text: string\n try {\n text = await response.text()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (text === "") throw new ClientError("MalformedResponse")\n try {\n return JSON.parse(text)\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n}\n\nfunction isContentType(response: Response, expected: string) {\n return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected\n}\n` +} + +function promiseTypePrefix(group: string, endpoint: string) { + return `${identifierPart(group)}${identifierPart(endpoint)}` +} + +function clientEndpointName(name: string) { + return name.slice(name.lastIndexOf(".") + 1) +} + +function identifierPart(value: string) { + return value + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`) + .join("") +} + +function structuralType(schema: Schema.Top) { + const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.fromASTs([schema.ast])) + if ( + document.artifacts.some( + (artifact) => + artifact._tag !== "Import" || artifact.importDeclaration !== 'import type * as Brand from "effect/Brand"', + ) || + Object.keys(document.references.recursives).length > 0 + ) { + throw new GenerationError({ reason: "Referenced Promise types are not implemented" }) + } + const references = new Map( + document.references.nonRecursives.map((reference) => [reference.$ref, reference.code.Type]), + ) + const expand = (type: string, seen = new Set()): string => { + for (const [reference, value] of references) { + const pattern = `(?/g, "") + .replaceAll("Schema.Json", "JsonValue") +} + +function promisePath(path: string, input: ReadonlyArray) { + if (path.includes("*")) throw new GenerationError({ reason: `Unsupported Promise path wildcard: ${path}` }) + const fields = new Set(input.filter((field) => field.source === "params").map((field) => field.name)) + const segments = path.split(/(:[A-Za-z_][A-Za-z0-9_]*)/g).filter(Boolean) + const template = segments + .map((segment) => { + if (!segment.startsWith(":")) return segment.replaceAll("`", "\\`") + const name = segment.slice(1) + if (!fields.has(name)) throw new GenerationError({ reason: `Missing path parameter: ${name}` }) + return `\${encodeURIComponent(input.${name})}` + }) + .join("") + return `\`${template}\`` +} + +function uniqueModule(base: string, index: number, modules: ReadonlySet) { + if (!modules.has(base.toLowerCase())) return base + const seed = `${base}-${index}` + let suffix = 0 + while (modules.has(`${seed}${suffix === 0 ? "" : `-${suffix}`}`.toLowerCase())) suffix++ + return `${seed}${suffix === 0 ? "" : `-${suffix}`}` +} + +function normalizeTransport( + schema: Schema.Top | undefined, + source: InputField["source"] | "success" | "error", + endpoint: HttpApiEndpoint.AnyWithProps, + operation: string, +) { + if (schema === undefined) return undefined + if (isStreamSchema(schema)) return { schema, effectPortable: true } as const + if (!metadataPortable(schema.ast, new Set())) { + throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` }) + } + const decoded = Schema.toType(schema) + if (!isPathInput(endpoint.path)) { + throw new GenerationError({ reason: `Invalid endpoint path: ${operation}` }) + } + const rebuilt = HttpApiEndpoint.make(endpoint.method)(endpoint.name, endpoint.path, { + ...(source === "params" ? { params: decoded } : undefined), + ...(source === "query" ? { query: decoded } : undefined), + ...(source === "headers" ? { headers: decoded } : undefined), + ...(source === "payload" ? { payload: decoded } : undefined), + ...(source === "success" ? { success: decoded } : { success: Schema.String }), + ...(source === "error" ? { error: decoded } : undefined), + }) + const normalized = + source === "params" + ? rebuilt.params + : source === "query" + ? rebuilt.query + : source === "headers" + ? rebuilt.headers + : source === "payload" + ? Array.from(rebuilt.payload.values())[0]?.schemas[0] + : source === "success" + ? Array.from(rebuilt.success)[0] + : Array.from(rebuilt.error)[0] + if (normalized === undefined) throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` }) + if (!sameEncoding(schema.ast, normalized.ast)) return { schema, effectPortable: false } as const + return { schema: decoded, effectPortable: true } as const +} + +function isPathInput(path: string): path is HttpRouter.PathInput { + return path === "*" || path.startsWith("/") +} + +function sameEncoding(left: SchemaAST.AST, right: SchemaAST.AST): boolean { + if (left._tag !== right._tag || left.encoding?.length !== right.encoding?.length) return false + if ( + left.encoding?.some((link, index) => { + const other = right.encoding?.[index] + return other === undefined || link.transformation !== other.transformation || !sameEncoding(link.to, other.to) + }) + ) + return false + if (!sameChecks(left.checks, right.checks) || !sameContext(left.context, right.context)) return false + if (SchemaAST.isSuspend(left) && SchemaAST.isSuspend(right)) return sameEncoding(left.thunk(), right.thunk()) + if (SchemaAST.isUnion(left) && SchemaAST.isUnion(right)) { + return ( + left.types.length === right.types.length && + left.types.every((ast, index) => sameEncoding(ast, right.types[index])) + ) + } + if (SchemaAST.isArrays(left) && SchemaAST.isArrays(right)) { + return ( + left.elements.length === right.elements.length && + left.rest.length === right.rest.length && + left.elements.every((ast, index) => sameEncoding(ast, right.elements[index])) && + left.rest.every((ast, index) => sameEncoding(ast, right.rest[index])) + ) + } + if (SchemaAST.isObjects(left) && SchemaAST.isObjects(right)) { + return ( + left.propertySignatures.length === right.propertySignatures.length && + left.indexSignatures.length === right.indexSignatures.length && + left.propertySignatures.every((field, index) => sameEncoding(field.type, right.propertySignatures[index].type)) && + left.indexSignatures.every( + (field, index) => + sameEncoding(field.parameter, right.indexSignatures[index].parameter) && + sameEncoding(field.type, right.indexSignatures[index].type), + ) + ) + } + return true +} + +function sameChecks(left: SchemaAST.Checks | undefined, right: SchemaAST.Checks | undefined): boolean { + if (left?.length !== right?.length) return false + if (left === undefined || right === undefined) return true + return left.every((check, index) => { + const other = right[index] + if (other === undefined || check._tag !== other._tag) return false + if (check._tag === "Filter" && other._tag === "Filter") { + return check.run === other.run && check.aborted === other.aborted + } + return check._tag === "FilterGroup" && other._tag === "FilterGroup" && sameChecks(check.checks, other.checks) + }) +} + +function sameContext(left: SchemaAST.Context | undefined, right: SchemaAST.Context | undefined) { + return left?.isOptional === right?.isOptional && left?.isMutable === right?.isMutable +} + +export function write( + output: Output, + directory: string, +): Effect.Effect { + return Effect.gen(function* () { + const paths = new Set() + const normalizedPaths = new Set() + for (const file of output.files) { + if (!isSafeOutputPath(file.path)) yield* new GenerationError({ reason: `Unsafe output path: ${file.path}` }) + const path = file.path.toLowerCase() + if (normalizedPaths.has(path)) yield* new GenerationError({ reason: `Duplicate output path: ${file.path}` }) + normalizedPaths.add(path) + paths.add(file.path) + } + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(directory, { recursive: true }) + const manifest = join(directory, manifestName) + const previous = (yield* fs.exists(manifest)) + ? yield* fs.readFileString(manifest).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Manifest)), + Effect.mapError(() => new GenerationError({ reason: `Invalid generated file manifest: ${manifest}` })), + ) + : [] + if (previous.some((path) => !isSafeOutputPath(path))) { + yield* new GenerationError({ reason: `Invalid generated file manifest: ${manifest}` }) + } + yield* Effect.forEach( + previous.filter((path) => !paths.has(path)), + (path) => fs.remove(join(directory, path), { force: true }), + { concurrency: 8, discard: true }, + ) + yield* Effect.forEach( + output.files, + (file) => + fs.exists(join(directory, file.path)).pipe( + Effect.flatMap((exists) => (exists ? fs.stat(join(directory, file.path)) : Effect.succeed(undefined))), + Effect.flatMap((info) => + info?.type === "SymbolicLink" + ? new GenerationError({ reason: `Unsafe output path: ${file.path}` }) + : Effect.void, + ), + ), + { concurrency: 8, discard: true }, + ) + yield* Effect.forEach( + output.files, + (file) => + Effect.tryPromise({ + try: () => format(file.content, { filepath: file.path, parser: "typescript", semi: false, printWidth: 120 }), + catch: (error) => new GenerationError({ reason: `Failed to format ${file.path}: ${String(error)}` }), + }).pipe(Effect.flatMap((content) => fs.writeFileString(join(directory, file.path), content))), + { concurrency: 8, discard: true }, + ) + yield* fs.writeFileString(manifest, JSON.stringify(output.files.map((file) => file.path).sort(), null, 2) + "\n") + }) +} + +function isSafeOutputPath(path: string) { + return path !== manifestName && !isAbsolute(path) && path !== "." && path !== ".." && !/[\\/]/.test(path) +} + +export function generate( + api: HttpApi.HttpApi, + options: { readonly directory: string }, +): Effect.Effect { + return Effect.try({ + try: () => emitEffect(compile(api)), + catch: (error) => (error instanceof GenerationError ? error : new GenerationError({ reason: String(error) })), + }).pipe(Effect.flatMap((output) => write(output, options.directory))) +} + +function inputFields(schema: Schema.Top | undefined, source: InputField["source"], operation: string) { + if (schema === undefined) return [] + const ast = Schema.toType(schema).ast + if (!SchemaAST.isObjects(ast) || ast.indexSignatures.length > 0) { + throw new GenerationError({ reason: `Input schema must be a struct: ${operation}.${source}` }) + } + return ast.propertySignatures.map((field) => { + if (typeof field.name !== "string") { + throw new GenerationError({ reason: `Input field must have a string name: ${operation}.${source}` }) + } + return { + name: field.name, + source, + optional: SchemaAST.isOptional(field.type), + } + }) +} + +function responseSchemas(schema: Schema.Top, path: string): Array { + if (HttpApiSchema.isNoContent(schema.ast)) return [] + if (!isStreamSchema(schema)) return [[path, schema]] + if (schema._tag === "StreamUint8Array") return [] + const value = schema.sseMode === "data" ? streamDataSchema(schema) : schema.events + return [ + [`${path}.${schema.sseMode}`, value], + [`${path}.error`, schema.error], + ] +} + +function assertPortable(schema: Schema.Top, path: string, portable: Map) { + const visiting = new Set() + const taggedError = taggedErrorFields(schema) + const visit = (ast: SchemaAST.AST): boolean => { + const cached = portable.get(ast) + if (cached !== undefined) return cached + if (visiting.has(ast)) return true + visiting.add(ast) + const result = visitCurrent(ast) + visiting.delete(ast) + portable.set(ast, result) + return result + } + const visitCurrent = (ast: SchemaAST.AST): boolean => { + if (!annotationsPortable(ast.annotations)) return false + if (!checksPortable(ast.checks) || ("encodingChecks" in ast && !checksPortable(ast.encodingChecks))) return false + if (SchemaAST.isDeclaration(ast)) { + return generationPortable(ast.annotations?.generation) && ast.typeParameters.every(visit) + } + if (ast.encoding !== undefined && ast.annotations?.generation === undefined) return false + if (SchemaAST.isSuspend(ast)) return visit(ast.thunk()) + if (SchemaAST.isUnion(ast)) return ast.types.every(visit) + if (SchemaAST.isArrays(ast)) { + return ast.elements.every(visit) && ast.rest.every(visit) + } + if (SchemaAST.isObjects(ast)) { + return ( + ast.propertySignatures.every((field) => visit(field.type)) && + ast.indexSignatures.every((index) => visit(index.parameter) && visit(index.type)) + ) + } + if (SchemaAST.isTemplateLiteral(ast)) return ast.parts.every(visit) + return true + } + if (taggedError !== undefined && SchemaAST.isDeclaration(schema.ast)) { + if ( + schema.ast.checks !== undefined || + ("encodingChecks" in schema.ast && !checksPortable(schema.ast.encodingChecks)) || + schema.ast.typeParameters.some((ast) => ast.checks !== undefined) || + !schema.ast.typeParameters.every(visit) + ) { + throw new GenerationError({ reason: `Unportable schema: ${path}` }) + } + return + } + if (!visit(schema.ast)) throw new GenerationError({ reason: `Unportable schema: ${path}` }) +} + +function checksPortable(checks: SchemaAST.Checks | undefined): boolean { + if (checks === undefined) return true + return checks.every((check) => + check._tag === "Filter" + ? !check.aborted && + check.annotations?.meta !== undefined && + typeof check.annotations.arbitrary === "object" && + check.annotations.arbitrary !== null && + "constraint" in check.annotations.arbitrary + : checksPortable(check.checks), + ) +} + +function metadataPortable(ast: SchemaAST.AST, seen: Set): boolean { + if (seen.has(ast)) return true + seen.add(ast) + if (!annotationsPortable(ast.annotations) || !checksPortable(ast.checks)) return false + if ("encodingChecks" in ast && !checksPortable(ast.encodingChecks)) return false + if (ast.encoding?.some((link) => !metadataPortable(link.to, seen))) return false + if (SchemaAST.isDeclaration(ast)) return ast.typeParameters.every((item) => metadataPortable(item, seen)) + if (SchemaAST.isSuspend(ast)) return metadataPortable(ast.thunk(), seen) + if (SchemaAST.isUnion(ast)) return ast.types.every((item) => metadataPortable(item, seen)) + if (SchemaAST.isArrays(ast)) { + return ( + ast.elements.every((item) => metadataPortable(item, seen)) && + ast.rest.every((item) => metadataPortable(item, seen)) + ) + } + if (SchemaAST.isObjects(ast)) { + return ( + ast.propertySignatures.every((field) => metadataPortable(field.type, seen)) && + ast.indexSignatures.every( + (field) => metadataPortable(field.parameter, seen) && metadataPortable(field.type, seen), + ) + ) + } + return true +} + +function generationPortable(generation: unknown): boolean { + if (typeof generation !== "object" || generation === null) return false + const value = generation as { + readonly runtime?: unknown + readonly Type?: unknown + readonly importDeclaration?: unknown + } + if (typeof value.runtime !== "string" || typeof value.Type !== "string") return false + if (value.importDeclaration !== undefined) { + if ( + typeof value.importDeclaration !== "string" || + !/from ["']effect(?:\/[^"']+)?["']$/.test(value.importDeclaration) + ) { + return false + } + } + const namespace = + typeof value.importDeclaration === "string" + ? /import(?: type)? \* as ([A-Za-z_$][\w$]*)/.exec(value.importDeclaration)?.[1] + : undefined + return value.runtime.startsWith("Schema.") || (namespace !== undefined && value.runtime.startsWith(`${namespace}.`)) +} + +function annotationsPortable(annotations: Schema.Annotations.Annotations | undefined) { + if (annotations === undefined) return true + return Object.entries(annotations).every(([key, value]) => { + if ( + ["toCodec", "toCodecJson", "toArbitrary", "toFormatter", "toEquivalence", "~effect/Schema/Class"].includes(key) + ) { + return true + } + if (key === "generation") return generationPortable(value) + return serializable(value) + }) +} + +function serializable(value: unknown): boolean { + if (value === null || ["string", "number", "boolean"].includes(typeof value)) return true + if (Array.isArray(value)) return value.every(serializable) + if (typeof value !== "object") return false + return Object.values(value).every(serializable) +} + +function taggedErrorFields(schema: Schema.Top) { + const fields = declaredErrorFields(schema) + return fields?.key === "_tag" ? fields : undefined +} + +function declaredErrorFields(schema: Schema.Top) { + if (!SchemaAST.isDeclaration(schema.ast) || schema.ast.annotations?.["~effect/Schema/Class"] === undefined) { + return undefined + } + const fields = schema.ast.typeParameters[0] + if (!SchemaAST.isObjects(fields) || fields.indexSignatures.length > 0) return undefined + const key = fields.propertySignatures.find((field) => field.name === "_tag" || field.name === "name")?.name + if (key !== "_tag" && key !== "name") return undefined + const tag = fields.propertySignatures.find((field) => field.name === key)?.type + if (tag === undefined || !SchemaAST.isLiteral(tag) || typeof tag.literal !== "string") return undefined + return { + key, + tag: tag.literal, + identifier: SchemaAST.resolveIdentifier(schema.ast) ?? tag.literal, + fields: fields.propertySignatures.flatMap((field) => + field.name === key || typeof field.name !== "string" + ? [] + : [[field.name, Schema.make(field.type), SchemaAST.isOptional(field.type)] as const], + ), + } +} + +function isDataEnvelope(schema: Schema.Top) { + if (isStreamSchema(schema) || HttpApiSchema.isNoContent(schema.ast)) return false + const ast = Schema.toType(schema).ast + return ( + SchemaAST.isObjects(ast) && + ast.indexSignatures.length === 0 && + ast.propertySignatures.length === 1 && + ast.propertySignatures[0]?.name === "data" + ) +} + +function isStreamSchema(schema: Schema.Top): schema is HttpApiSchema.StreamSchema { + return "_tag" in schema && (schema._tag === "StreamSse" || schema._tag === "StreamUint8Array") +} + +function streamDataSchema(schema: Extract) { + return Schema.make(streamDataAst(Schema.toType(schema.events).ast)) +} + +function streamEncodedDataSchema(schema: Extract) { + const data = streamDataAst(schema.events.ast) + const encodedAst = data.encoding?.at(-1)?.to + if (encodedAst === undefined) throw new GenerationError({ reason: "Invalid SSE data schema" }) + const encoded = resolveContentSchema(encodedAst) + if (!SchemaAST.isAST(encoded)) throw new GenerationError({ reason: "Invalid SSE data schema" }) + return Schema.make(encoded) +} + +function streamDataAst(ast: SchemaAST.AST) { + if (!SchemaAST.isObjects(ast)) throw new GenerationError({ reason: "Invalid SSE data schema" }) + const data = ast.propertySignatures.find((field) => field.name === "data")?.type + if (data === undefined) throw new GenerationError({ reason: "Invalid SSE data schema" }) + return data +} + +function streamEffectPortable(schema: Schema.Top) { + if (!isStreamSchema(schema) || schema._tag === "StreamUint8Array" || schema.sseMode === "events") return true + const rebuilt = HttpApiSchema.StreamSse({ + data: streamDataSchema(schema), + error: schema.error, + contentType: schema.contentType, + }) + return sameEncoding(schema.events.ast, rebuilt.events.ast) +} + +function renderGroup(group: Group, groupIndex: number) { + const slots: Array = [] + const adapters: Array = [] + const endpointSources = group.endpoints.map((operation, endpointIndex) => { + const { + endpoint, + errors, + headers: endpointHeaders, + params: endpointParams, + payloads: endpointPayloads, + query: endpointQuery, + successes, + } = operation + const prefix = `Endpoint${endpointIndex}` + const params = addSlot(endpointParams, `${prefix}Params`) + const query = addSlot(endpointQuery, `${prefix}Query`) + const headers = addSlot(endpointHeaders, `${prefix}Headers`) + const payloads = endpointPayloads.map((schema, index) => addSlot(schema, `${prefix}Payload${index}`)!) + const success = renderSuccess(successes[0], `${prefix}Success`) + const errorSlots = errors.map((error, index) => addSlot(error.schema, `${prefix}Error${index}`)!) + const options = [ + params === undefined ? undefined : `params: ${params.name}`, + query === undefined ? undefined : `query: ${query.name}`, + headers === undefined ? undefined : `headers: ${headers.name}`, + payloads.length === 0 + ? undefined + : `payload: ${payloads.length === 1 ? payloads[0].name : `[${payloads.map((slot) => slot.name).join(", ")}]`}`, + `success: ${success.source}`, + errorSlots.length === 0 + ? undefined + : `error: ${errorSlots.length === 1 ? errorSlots[0].name : `[${errorSlots.map((slot) => slot.name).join(", ")}]`}`, + ].filter((option): option is string => option !== undefined) + const schemaBySource = { params, query, headers, payload: payloads[0] } + const inputType = operation.input + .map((field) => { + const slot = schemaBySource[field.source] + if (slot === undefined) { + throw new GenerationError({ reason: `Missing input schema: ${group.identifier}.${endpoint.name}` }) + } + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (typeof ${slot.name}.Type)[${JSON.stringify(field.name)}]` + }) + .join("; ") + const argument = + operation.operation.inputMode === "none" + ? "" + : `input${operation.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input` + const request = (["params", "query", "headers", "payload"] as const) + .flatMap((source) => { + const slot = schemaBySource[source] + if (slot === undefined) return [] + const fields = operation.input + .filter((field) => field.source === source) + .map( + (field) => + `${JSON.stringify(field.name)}: input${operation.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(field.name)}]`, + ) + return [`${source}: { ${fields.join(", ")} }`] + }) + .join(", ") + const declared = [...errorSlots, ...(success.streamError === undefined ? [] : [success.streamError])] + const declaredSchema = + declared.length === 0 ? "Schema.Never" : `Schema.Union([${declared.map((slot) => slot.name).join(", ")}])` + const rawCall = `raw[${JSON.stringify(endpoint.name)}]({ ${request} })` + const mapped = `${rawCall}.pipe(Effect.mapError(map${prefix}Error)${operation.unwrapData ? ", Effect.map((value) => value.data)" : ""})` + const inputDeclaration = operation.operation.inputMode === "none" ? "" : `type ${prefix}Input = { ${inputType} }\n` + adapters.push( + `${inputDeclaration}const ${prefix}DeclaredError = ${declaredSchema}\nconst map${prefix}Error = (error: unknown) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : Schema.is(${prefix}DeclaredError)(error) ? error : new ClientError({ cause: error })\nconst ${prefix} = (raw: RawGroup) => (${argument}) => ${operation.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(map${prefix}Error), Effect.map((stream) => stream.pipe(Stream.mapError(map${prefix}Error)))))` : mapped}`, + ) + return `HttpApiEndpoint.make(${JSON.stringify(endpoint.method)})(${JSON.stringify(endpoint.name)}, ${JSON.stringify(endpoint.path)}, { ${options.join(", ")} })` + }) + + function addSlot(schema: Schema.Top | undefined, name: string) { + if (schema === undefined) return undefined + const slot = { name, schema } + slots.push(slot) + return slot + } + + function renderSuccess(schema: Schema.Top, name: string) { + if (!isStreamSchema(schema)) return { source: addSlot(schema, name)!.name } + const status = resolveHttpApiStatus(schema.ast) ?? 200 + const annotate = status === 200 ? "" : `.pipe(HttpApiSchema.status(${status}))` + if (schema._tag === "StreamUint8Array") { + return { + source: `HttpApiSchema.StreamUint8Array({ contentType: ${JSON.stringify(schema.contentType)} })${annotate}`, + } + } + const value = addSlot( + schema.sseMode === "data" ? streamDataSchema(schema) : schema.events, + `${name}${schema.sseMode === "data" ? "Data" : "Events"}`, + )! + const error = addSlot(schema.error, `${name}Error`)! + return { + source: `HttpApiSchema.StreamSse({ ${schema.sseMode}: ${value.name}, error: ${error.name}, contentType: ${JSON.stringify(schema.contentType)} })${annotate}`, + streamError: error, + } + } + + const declarations = renderSchemas(slots) + const groupSource = `HttpApiGroup.make(${JSON.stringify(group.identifier)}, { topLevel: ${group.endpoints[0]?.topLevel ?? false} })${endpointSources.map((endpoint) => `.add(${endpoint})`).join("")}` + const usesHttpApiSchema = endpointSources.some((source) => source.includes("HttpApiSchema.")) + const methods = group.endpoints + .map((item, index) => `${JSON.stringify(item.operation.name)}: Endpoint${index}(raw)`) + .join(", ") + const rawGroup = group.endpoints[0]?.topLevel + ? `HttpApiClient.Client` + : `HttpApiClient.Client.Group` + const usesStream = group.endpoints.some((item) => item.operation.success === "stream") + return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect, Schema${usesStream ? ", Stream" : ""} } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\nimport { HttpApiClient, HttpApiEndpoint, HttpApiGroup${usesHttpApiSchema ? ", HttpApiSchema" : ""} } from "effect/unstable/httpapi"\nimport { ClientError } from "./client-error"\n\n${declarations}\n\nexport const Group${groupIndex} = ${groupSource}\n\ntype RawGroup = ${rawGroup}\n\n${adapters.join("\n\n")}\n\nexport const adaptGroup${groupIndex} = (raw: RawGroup) => ({ ${methods} })\n` +} + +function renderSchemas(slots: ReadonlyArray) { + if (slots.length === 0) return "" + const classes = new Map( + slots.flatMap((slot, index) => { + const tagged = taggedErrorFields(slot.schema) + return tagged === undefined ? [] : [[index, tagged] as const] + }), + ) + const expanded = [ + ...slots.map((slot, index) => (classes.has(index) ? { name: slot.name, schema: Schema.Never } : slot)), + ...Array.from(classes.values()).flatMap((tagged, classIndex) => + tagged.fields.map(([name, schema]) => ({ name: `Class${classIndex}${name}`, schema })), + ), + ] + const [first, ...rest] = expanded + const document = SchemaRepresentation.toCodeDocument( + SchemaRepresentation.fromASTs([first.schema.ast, ...rest.map((slot) => slot.schema.ast)]), + ) + const artifacts = document.artifacts.flatMap((artifact) => { + if (artifact._tag === "Import") return [artifact.importDeclaration] + if (artifact._tag === "Enum") return [artifact.generation.runtime] + return [`const ${artifact.identifier} = ${artifact.generation.runtime}`] + }) + const references = [ + ...document.references.nonRecursives.map(({ $ref, code }) => `const ${$ref} = ${code.runtime}`), + ...Object.entries(document.references.recursives).map( + ([$ref, code]) => `type ${$ref} = ${code.Type}\nconst ${$ref}: Schema.Codec<${$ref}> = ${code.runtime}`, + ), + ] + let fieldIndex = slots.length + const declarations = slots.map((slot, index) => { + const tagged = classes.get(index) + if (tagged === undefined) return `const ${slot.name} = ${document.codes[index].runtime}` + const fields = tagged.fields + .map(([name]) => `${JSON.stringify(name)}: ${document.codes[fieldIndex++].runtime}`) + .join(", ") + const annotations = Object.entries({ + httpApiStatus: resolveHttpApiStatus(slot.schema.ast), + "~httpApiEncoding": resolveHttpApiEncoding(slot.schema.ast), + }).filter((entry) => entry[1] !== undefined) + const annotate = + annotations.length === 0 + ? "" + : `.annotate({ ${annotations.map(([key, value]) => `${JSON.stringify(key)}: ${JSON.stringify(value)}`).join(", ")} })` + return `class ${slot.name}Class extends Schema.TaggedErrorClass<${slot.name}Class>(${JSON.stringify(tagged.identifier)})(${JSON.stringify(tagged.tag)}, { ${fields} }) {}\nconst ${slot.name} = ${slot.name}Class${annotate}` + }) + return [...artifacts, ...references, ...declarations].join("\n\n") +} + +function renderClient(groups: ReadonlyArray) { + const imports = groups + .map((group, index) => `import { adaptGroup${index}, Group${index} } from ${JSON.stringify(`./${group.module}`)}`) + .join("\n") + const api = `HttpApi.make("generated")${groups.map((_, index) => `.add(Group${index})`).join("")}` + const fields = groups.flatMap((group, index) => { + if (!group.endpoints[0]?.topLevel) { + return [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.identifier)}])`] + } + const raw = `{ ${group.endpoints.map((item) => `${JSON.stringify(item.endpoint.name)}: raw[${JSON.stringify(item.endpoint.name)}]`).join(", ")} }` + return [`...adaptGroup${index}(${raw})`] + }) + return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect } from "effect"\nimport { HttpApi, HttpApiClient } from "effect/unstable/httpapi"\n${imports}\n\nconst Api = ${api}\nconst adaptClient = (raw: HttpApiClient.ForApi) => ({ ${fields.join(", ")} })\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) =>\n HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))\n` +} diff --git a/packages/httpapi-codegen/sst-env.d.ts b/packages/httpapi-codegen/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/httpapi-codegen/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/httpapi-codegen/test/effect.ts b/packages/httpapi-codegen/test/effect.ts new file mode 100644 index 0000000000..3accad3cc9 --- /dev/null +++ b/packages/httpapi-codegen/test/effect.ts @@ -0,0 +1,28 @@ +import { test } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import type { Scope } from "effect/Scope" +import { TestClock, TestConsole } from "effect/testing" + +type Body = Effect.Effect | (() => Effect.Effect) + +const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer()) + +const effect = (name: string, body: Body, options?: Parameters[2]) => + test( + name, + () => + Effect.gen(function* () { + const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe( + Effect.scoped, + Effect.provide(layer), + Effect.exit, + ) + if (Exit.isFailure(exit)) { + yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true }) + } + return yield* exit + }).pipe(Effect.runPromise), + options, + ) + +export const it = { effect } diff --git a/packages/httpapi-codegen/test/fixture.ts b/packages/httpapi-codegen/test/fixture.ts new file mode 100644 index 0000000000..9fb7fedda1 --- /dev/null +++ b/packages/httpapi-codegen/test/fixture.ts @@ -0,0 +1,45 @@ +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" + +export class Missing extends Schema.TaggedErrorClass()("Missing", { + message: Schema.String, +}) {} + +export const Api = HttpApi.make("fixture") + .add( + HttpApiGroup.make("session") + .add(HttpApiEndpoint.get("health", "/session/health", { success: Schema.String })) + .add( + HttpApiEndpoint.get("list", "/session", { + query: { archived: Schema.optional(Schema.Boolean) }, + success: Schema.Array(Schema.String), + }), + ) + .add( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + error: Missing.pipe(HttpApiSchema.status(404)), + }), + ) + .add( + HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { + params: { sessionID: Schema.String }, + success: HttpApiSchema.NoContent, + }), + ), + ) + .add( + HttpApiGroup.make("event").add( + HttpApiEndpoint.get("subscribe", "/event", { + success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }).pipe( + HttpApiSchema.status(202), + ), + }), + ), + ) + .add( + HttpApiGroup.make("system", { topLevel: true }).add( + HttpApiEndpoint.get("status", "/status", { success: Schema.String }), + ), + ) diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts new file mode 100644 index 0000000000..6e076b7ef4 --- /dev/null +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -0,0 +1,1022 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { Effect, FileSystem, Schema, SchemaAST, SchemaGetter } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema } from "effect/unstable/httpapi" +import { format } from "prettier" +import { + compile as compileContract, + emitEffect, + emitEffectImported, + emitPromise, + generate, + GenerationError, +} from "../src" +import { it } from "./effect" +import { Api as FixtureApi, Missing } from "./fixture" + +function api(endpoint: HttpApiEndpoint.Any) { + return HttpApi.make("test").add(HttpApiGroup.make("session").add(endpoint)) +} + +function compile(source: HttpApi.HttpApi) { + return emitEffect(compileContract(source)) +} + +describe("HttpApiCodegen.generate", () => { + test("compiles one contract for Promise and Effect emitters", () => { + const contract = compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ) + + const promise = emitPromise(contract) + const effect = emitEffect(contract) + + expect(promise.operations).toEqual(effect.operations) + expect(promise.files.map((file) => file.path)).toEqual(["types.ts", "client-error.ts", "client.ts", "index.ts"]) + const promiseClient = promise.files.find((file) => file.path === "client.ts")?.content + expect(promiseClient).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)') + expect(promiseClient).toContain("`/session/${encodeURIComponent(input.sessionID)}`") + expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain( + 'params: { "sessionID": input["sessionID"] }', + ) + }) + + test("allows Promise outputs to use an authoritative imported wire type", () => { + const contract = compileContract( + api(HttpApiEndpoint.get("events", "/event", { success: HttpApiSchema.StreamSse({ data: Schema.Unknown }) })), + ) + const output = emitPromise(contract, { + outputTypes: { + "session.events": { + name: "EventWire", + import: 'import type { EventWire } from "./event-wire"', + }, + }, + }) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain('import type { EventWire } from "./event-wire"') + expect(types).toContain("export type SessionEventsOutput = EventWire") + }) + + test("emits an Effect client against an imported authoritative API", () => { + const output = emitEffectImported( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + { module: "@example/api", api: "Api" }, + ) + + expect(output.files.map((file) => file.path)).toEqual(["client-error.ts", "client.ts", "index.ts"]) + expect(output.files.find((file) => file.path === "client.ts")?.content).toContain( + 'import { Api } from "@example/api"', + ) + expect(output.files.find((file) => file.path === "client.ts")?.content).toContain( + "HttpApiClient.ForApi", + ) + }) + + test("projects imported endpoint constants into a generated API", () => { + const output = emitEffectImported( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + { module: "@example/api", endpoints: { "session.get": "SessionGet" } }, + ) + const client = output.files.find((file) => file.path === "client.ts")?.content + + expect(client).toContain('import { SessionGet } from "@example/api"') + expect(client).toContain('const Api = HttpApi.make("generated").add(HttpApiGroup.make("session").add(SessionGet))') + }) + + test("imports an authoritative group without reconstructing it", () => { + const output = emitEffectImported( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.String, + }), + ), + ), + { module: "@example/api", group: "SessionGroup" }, + ) + const client = output.files.find((file) => file.path === "client.ts")?.content + + expect(client).toContain('import { SessionGroup } from "@example/api"') + expect(client).toContain('const Api = HttpApi.make("generated").add(SessionGroup)') + expect(client).not.toContain("HttpApiGroup") + }) + + test("separates hosted and consumer group names", () => { + const source = HttpApi.make("test").add( + HttpApiGroup.make("server.session").add( + HttpApiEndpoint.get("session.get", "/session", { success: Schema.String }), + ), + ) + const contract = compileContract(source, { groupNames: { "server.session": "sessions" } }) + + expect(contract.groups[0]?.identifier).toBe("sessions") + expect(contract.groups[0]?.sourceIdentifier).toBe("server.session") + expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "get" }) + }) + + test("supports explicit public endpoint names", () => { + const source = HttpApi.make("test").add( + HttpApiGroup.make("server.permission") + .add(HttpApiEndpoint.get("permission.request.list", "/request", { success: Schema.String })) + .add(HttpApiEndpoint.get("session.permission.list", "/session", { success: Schema.String })), + ) + const contract = compileContract(source, { + endpointNames: { "permission.request.list": "listRequests" }, + }) + + expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual(["listRequests", "list"]) + }) + + test("omits custom transport endpoints", () => { + const source = HttpApi.make("test").add( + HttpApiGroup.make("server.pty") + .add(HttpApiEndpoint.get("pty.get", "/pty", { success: Schema.String })) + .add(HttpApiEndpoint.get("pty.connect", "/pty/connect", { success: Schema.Boolean })), + ) + const contract = compileContract(source, { omitEndpoints: new Set(["pty.connect"]) }) + + expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.endpoint.name)).toEqual(["pty.get"]) + }) + + test("uses bracket access for input field names", () => { + const source = api( + HttpApiEndpoint.post("token", "/token", { + headers: { "x-example-token": Schema.Literal("1") }, + success: Schema.String, + }), + ) + const contract = compileContract(source) + const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content + const effect = emitEffectImported(contract, { + module: "@example/api", + endpoints: { "session.token": "Token" }, + }).files.find((file) => file.path === "client.ts")?.content + + expect(promise).toContain('"x-example-token": input["x-example-token"]') + expect(effect).toContain('"x-example-token": input["x-example-token"]') + }) + + test("rejects consumer group name collisions", () => { + const source = HttpApi.make("test") + .add(HttpApiGroup.make("first").add(HttpApiEndpoint.get("one", "/one", { success: Schema.String }))) + .add(HttpApiGroup.make("second").add(HttpApiEndpoint.get("two", "/two", { success: Schema.String }))) + + expect(() => compileContract(source, { groupNames: { first: "same", second: "same" } })).toThrow( + "Client group name collision: same", + ) + }) + + test("uses the unqualified endpoint name for the public client", () => { + const contract = compileContract( + api( + HttpApiEndpoint.get("session.get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.String, + }), + ), + ) + const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content + const effect = emitEffectImported(contract, { + module: "@example/api", + endpoints: { "session.session.get": "SessionGet" }, + }).files.find((file) => file.path === "client.ts")?.content + + expect(contract.groups[0]?.endpoints[0]?.operation.name).toBe("get") + expect(promise).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)') + expect(effect).toContain('const adaptGroup0 = (raw: RawClient["session"]) => ({ "get": Endpoint0_0(raw) })') + expect(effect).toContain('raw["session.get"]') + }) + + test("preserves optional keys in Promise error types", () => { + class OptionalError extends Schema.TaggedErrorClass()( + "OptionalError", + { message: Schema.String, detail: Schema.String.pipe(Schema.optional) }, + { httpApiStatus: 400 }, + ) {} + const output = emitPromise( + compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: OptionalError }))), + ) + + expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( + 'readonly "message": string; readonly "detail"?: string | undefined', + ) + }) + + test("supports name-discriminated Promise errors", () => { + class NamedError extends Schema.ErrorClass("NamedError")( + { name: Schema.Literal("NamedError"), message: Schema.String }, + { httpApiStatus: 400 }, + ) {} + const output = emitPromise( + compileContract( + api(HttpApiEndpoint.get("get", "/session", { success: Schema.NumberFromString, error: NamedError })), + ), + ) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain('readonly "name": "NamedError"') + expect(types).toContain('"name" in value && value["name"] === "NamedError"') + }) + + test("preserves reflected default error statuses", () => { + class MissingStatus extends Schema.TaggedErrorClass()("MissingStatus", { + message: Schema.String, + }) {} + const output = emitPromise( + compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: MissingStatus }))), + ) + + expect(output.files.find((file) => file.path === "client.ts")?.content).toContain("declaredStatuses: [500]") + }) + + test("erases brands from Promise wire types", () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String.pipe(Schema.brand("SessionID")) }, + success: Schema.Struct({ data: Schema.String.pipe(Schema.brand("SessionID")) }), + }), + ), + ), + ) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain('readonly "sessionID": string') + expect(types).not.toContain("Brand") + }) + + test("inlines non-recursive references in Promise wire types", () => { + const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" }) + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.Struct({ data: Referenced }), + }), + ), + ), + ) + + expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( + 'export type SessionGetOutput = ({ readonly "data": ({ readonly "value": string }) })["data"]', + ) + }) + + test("expands Promise references only at identifier boundaries", () => { + const Session = Schema.Struct({ name: Schema.Literal("Session"), id: Schema.String }).annotate({ + identifier: "Session", + }) + const SessionID = Schema.String.annotate({ identifier: "SessionID" }) + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.Struct({ session: Session, sessionID: SessionID }), + }), + ), + ), + ) + + expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( + 'readonly "session": ({ readonly "name": "Session", readonly "id": string })', + ) + }) + + test("emits Effect Json schemas as standalone Promise types", () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.Json, + }), + ), + ), + ) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain("export type JsonValue =") + expect(types).toContain("{ readonly [key: string]: JsonValue }") + expect(types).not.toContain("Schema.Json") + }) + + test("emits an optional Promise input when every field is optional", () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("list", "/session", { + query: { limit: Schema.optional(Schema.Number) }, + success: Schema.Array(Schema.String), + }), + ), + ), + ) + + expect(output.files.find((file) => file.path === "client.ts")?.content).toContain( + '"list": (input?: SessionListInput, requestOptions?: RequestOptions)', + ) + }) + + test("rejects Promise transports that are not implemented", () => { + expect(() => + emitPromise( + compileContract( + api( + HttpApiEndpoint.get("text", "/text", { + success: Schema.String.pipe(HttpApiSchema.asText()), + }), + ), + ), + ), + ).toThrow("Unsupported Promise success encoding: session.text") + + expect(() => + emitPromise( + compileContract( + api( + HttpApiEndpoint.get("binary", "/binary", { + success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + }), + ), + ), + ), + ).toThrow("Unsupported Promise success encoding: session.binary") + + expect(() => + emitPromise(compileContract(api(HttpApiEndpoint.get("read", "/file/*", { success: Schema.String })))), + ).toThrow("Unsupported Promise path wildcard: /file/*") + + expect(() => + emitPromise( + compileContract( + api( + HttpApiEndpoint.get("events", "/events", { + success: HttpApiSchema.StreamSse({ data: Schema.String, error: Missing }), + }), + ), + ), + ), + ).toThrow("Unsupported Promise stream: session.events") + }) + + test("executes an emitted Promise GET through fetch", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + let request: Request | undefined + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL) => { + request = input instanceof Request ? input : new Request(input) + return Response.json({ data: "hello" }) + }, + }) + + expect(await client.session.get({ sessionID: "a/b" })).toBe("hello") + expect(request?.method).toBe("GET") + expect(request?.url).toBe("https://example.com/session/a%2Fb") + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("maps an emitted no-content response to undefined", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { + params: { sessionID: Schema.String }, + success: HttpApiSchema.NoContent, + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async () => new Response(null, { status: 204 }), + }) + + expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("serializes flattened query, header, and JSON payload inputs", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.post("prompt", "/session/:sessionID", { + params: { sessionID: Schema.String }, + query: { resume: Schema.optional(Schema.Boolean) }, + headers: { traceID: Schema.String }, + payload: Schema.Struct({ prompt: Schema.String }), + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + let request: Request | undefined + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + request = input instanceof Request ? input : new Request(input, init) + return Response.json({ data: "admitted" }) + }, + }) + + expect( + await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }), + ).toBe("admitted") + expect(request?.url).toBe("https://example.com/session/session?resume=true") + expect(request?.headers.get("traceID")).toBe("trace") + expect(await request?.json()).toEqual({ prompt: "hello" }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("rejects with declared tagged errors and exports a type guard", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + error: Missing.pipe(HttpApiSchema.status(404)), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }), + }) + + const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause) + expect(error).toEqual({ _tag: "Missing", message: "gone" }) + expect(generated.isMissing(error)).toBeTrue() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("iterates an emitted SSE stream lazily without reconnecting", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("subscribe", "/event", { + query: { after: Schema.optional(Schema.Number) }, + success: HttpApiSchema.StreamSse({ + data: Schema.Struct({ type: Schema.String, count: Schema.NumberFromString }), + }), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + let requests = 0 + let url: string | undefined + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL) => { + requests++ + url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + const encoder = new TextEncoder() + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r')) + controller.enqueue(encoder.encode("\n\r\n")) + controller.close() + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + }, + }) + const events = client.session.subscribe({ after: 2 }) + + expect(requests).toBe(0) + const received = [] + for await (const event of events) received.push(event) + expect(received).toEqual([{ type: "ready", count: "1" }]) + expect(requests).toBe(1) + expect(url).toBe("https://example.com/event?after=2") + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("preserves public group and endpoint identifiers exactly", () => { + const output = compile( + HttpApi.make("test").add( + HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session/:sessionID", { success: Schema.String })), + ), + ) + + expect(output.operations[0]).toMatchObject({ group: "session", name: "get" }) + }) + + test("emits one client module per HttpApi group", () => { + const source = HttpApi.make("test") + .add(HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String }))) + .add(HttpApiGroup.make("tool").add(HttpApiEndpoint.get("list", "/tool", { success: Schema.String }))) + + const output = compile(source) + + expect(output.files.map((file) => file.path)).toEqual([ + "session.ts", + "tool.ts", + "client-error.ts", + "client.ts", + "index.ts", + ]) + }) + + test("emits syntactically valid TypeScript modules", () => { + const output = compile( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ) + const transpiler = new Bun.Transpiler({ loader: "ts" }) + + for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow() + }) + + it.effect("keeps the strict generated-consumer fixture current", () => + Effect.gen(function* () { + const output = compile(FixtureApi) + const actual = yield* Effect.promise(() => + Array.fromAsync(new Bun.Glob("*.ts").scan(new URL("generated", import.meta.url).pathname)), + ) + expect(actual.sort((a, b) => a.localeCompare(b))).toEqual( + output.files.map((file) => file.path).sort((a, b) => a.localeCompare(b)), + ) + yield* Effect.forEach(output.files, (file) => + Effect.tryPromise(() => + Promise.all([ + Bun.file(new URL(`generated/${file.path}`, import.meta.url)).text(), + format(file.content, { parser: "typescript", semi: false, printWidth: 120 }), + ]), + ).pipe(Effect.map(([content, expected]) => expect(content).toBe(expected))), + ) + }), + ) + + test("flattens transport input channels into one domain input", () => { + const output = compile( + api( + HttpApiEndpoint.post("prompt", "/session/:sessionID", { + params: { sessionID: Schema.String }, + query: { resume: Schema.String }, + headers: { traceID: Schema.String }, + payload: Schema.Struct({ prompt: Schema.String }), + success: Schema.Struct({ data: Schema.String }), + }), + ), + ) + + expect(output.operations[0]?.input).toEqual([ + { name: "sessionID", source: "params" }, + { name: "resume", source: "query" }, + { name: "traceID", source: "headers" }, + { name: "prompt", source: "payload" }, + ]) + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + 'params: { "sessionID": input["sessionID"] }', + ) + }) + + test("uses no argument when an operation has no input fields", () => { + const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String }))) + + expect(output.operations[0]?.inputMode).toBe("none") + }) + + test("uses an optional object when every input field is optional", () => { + const output = compile( + api( + HttpApiEndpoint.get("list", "/session", { + query: { limit: Schema.optional(Schema.String) }, + success: Schema.Array(Schema.String), + }), + ), + ) + + expect(output.operations[0]?.inputMode).toBe("optional") + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('input?.["limit"]') + }) + + test("regenerates standard HttpApi transport codecs from decoded schemas", () => { + const output = compile( + api( + HttpApiEndpoint.get("list", "/session", { + query: { archived: Schema.optional(Schema.Boolean) }, + success: Schema.String, + }), + ), + ) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain("Schema.Boolean") + }) + + test("uses a required object when any input field is required", () => { + const output = compile( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + query: { includeArchived: Schema.optional(Schema.String) }, + success: Schema.String, + }), + ), + ) + + expect(output.operations[0]?.inputMode).toBe("required") + }) + + test("rejects colliding input names across transport channels", () => { + expect(() => + compile( + api( + HttpApiEndpoint.post("prompt", "/session/:id", { + params: { id: Schema.String }, + payload: Schema.Struct({ id: Schema.String }), + success: Schema.Void, + }), + ), + ), + ).toThrow("Input field collision: id") + }) + + test("rejects multiple payload alternatives until selection semantics are explicit", () => { + expect(() => + compile( + api( + HttpApiEndpoint.post("prompt", "/session", { + payload: [Schema.Struct({ text: Schema.String }), Schema.Struct({ count: Schema.Number })], + success: Schema.String, + }), + ), + ), + ).toThrow("Multiple payload schemas: session.prompt") + }) + + test("unwraps an exact data success envelope", () => { + const output = compile( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ) + + expect(output.operations[0]?.success).toBe("value") + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + "Effect.map((value) => value.data)", + ) + }) + + test("maps no-content success to void", () => { + const output = compile( + api(HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { success: HttpApiSchema.NoContent })), + ) + + expect(output.operations[0]?.success).toBe("void") + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 204') + }) + + test("preserves non-default empty response statuses", () => { + const output = compile(api(HttpApiEndpoint.post("create", "/session", { success: HttpApiSchema.Created }))) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 201') + }) + + test("returns a non-envelope success unchanged", () => { + const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String }))) + + expect(output.operations[0]?.success).toBe("value") + }) + + test("rejects multiple success shapes until their public semantics are explicit", () => { + expect(() => + compile( + api( + HttpApiEndpoint.get("get", "/session", { + success: [Schema.String, Schema.Number], + }), + ), + ), + ).toThrow("Multiple success schemas: session.get") + }) + + test("models an SSE success as a direct stream", () => { + const output = compile( + api( + HttpApiEndpoint.get("subscribe", "/event", { + success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }), + }), + ), + ) + + expect(output.operations[0]?.success).toBe("stream") + }) + + test("preserves annotated stream response statuses", () => { + const output = compile( + api( + HttpApiEndpoint.get("subscribe", "/event", { + success: HttpApiSchema.StreamSse({ data: Schema.String }).pipe(HttpApiSchema.status(202)), + }), + ), + ) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + ".pipe(HttpApiSchema.status(202))", + ) + }) + + test("rejects schemas whose semantics cannot be emitted exactly", () => { + const OpaqueUrl = Schema.declare((input): input is URL => input instanceof URL) + + expect(() => compile(api(HttpApiEndpoint.get("get", "/url", { success: OpaqueUrl })))).toThrow( + "Unportable schema: session.get.success", + ) + }) + + test("rejects custom transformations hidden beneath standard HttpApi codecs", () => { + const QueryBoolean = Schema.Literals(["yes", "no"]).pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "yes"), + encode: SchemaGetter.transform((value) => (value ? "yes" : "no")), + }), + ) + + expect(() => + compile( + api( + HttpApiEndpoint.get("get", "/session", { + query: { archived: QueryBoolean }, + success: Schema.String, + }), + ), + ), + ).toThrow("Effect schema requires authoritative import: session.get") + }) + + test("rejects custom validation checks without portable metadata", () => { + const Positive = Schema.Number.check(Schema.makeFilter((value) => (value > 0 ? undefined : "positive"))) + + expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Positive })))).toThrow( + "Unportable schema: session.get.success", + ) + }) + + test("rejects spoofed and aborted validation checks", () => { + const Spoofed = Schema.Number.check( + Schema.makeFilter(() => "always fails", { meta: { _tag: "isFinite" }, arbitrary: {} }), + ) + const Aborted = Schema.Number.check(Schema.isFinite().abort()) + + expect(() => compile(api(HttpApiEndpoint.get("spoofed", "/session", { success: Spoofed })))).toThrow( + "Unportable schema: session.spoofed.success", + ) + expect(() => compile(api(HttpApiEndpoint.get("aborted", "/session", { success: Aborted })))).toThrow( + "Unportable schema: session.aborted.success", + ) + }) + + test("rejects altered wire-side schemas even when the codec transformation is canonical", () => { + const JsonNumber = Schema.toCodecJson(Schema.Number) + const link = JsonNumber.ast.encoding?.[0] + if (link === undefined) throw new Error("Expected JSON number encoding") + // This helper is present at runtime but omitted from the public declaration surface. + const replaceEncoding: unknown = Reflect.get(SchemaAST, "replaceEncoding") + if (typeof replaceEncoding !== "function") throw new Error("Expected SchemaAST.replaceEncoding") + const ast: unknown = replaceEncoding(JsonNumber.ast, [ + new SchemaAST.Link(Schema.String.check(Schema.isMinLength(2)).ast, link.transformation), + ]) + if (!SchemaAST.isAST(ast)) throw new Error("Expected altered schema AST") + const Altered = Schema.make(ast) + + expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Altered })))).toThrow( + "Effect schema requires authoritative import: session.get", + ) + }) + + test("rejects lexical generation and annotation values", () => { + const Generated = Schema.declare((input): input is string => typeof input === "string").annotate({ + generation: { runtime: "LocalOnly", Type: "string" }, + }) + const Annotated = Schema.declare((input): input is string => typeof input === "string").annotate({ + custom: () => "local", + }) + + expect(() => compile(api(HttpApiEndpoint.get("generated", "/session", { success: Generated })))).toThrow( + "Unportable schema: session.generated.success", + ) + expect(() => compile(api(HttpApiEndpoint.get("annotated", "/session", { success: Annotated })))).toThrow( + "Unportable schema: session.annotated.success", + ) + }) + + test("preserves errors from server-only middleware", () => { + class Unauthorized extends Schema.TaggedErrorClass()("Unauthorized", {}) {} + class Authorization extends HttpApiMiddleware.Service()("Authorization", { + error: Unauthorized, + }) {} + + const output = compile( + api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(Authorization)), + ) + + expect(output.operations[0]).toBeDefined() + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + 'extends Schema.TaggedErrorClass("Unauthorized")', + ) + }) + + test("preserves tagged error response statuses", () => { + class Missing extends Schema.TaggedErrorClass()("Missing", {}) {} + const output = compile( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.String, + error: Missing.pipe(HttpApiSchema.status(404)), + }), + ), + ) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + 'Endpoint0Error0Class.annotate({ "httpApiStatus": 404 })', + ) + }) + + test("supports every HttpApi method through the generic constructor", () => { + const output = compile(api(HttpApiEndpoint.make("TRACE")("trace", "/trace", { success: Schema.String }))) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('HttpApiEndpoint.make("TRACE")') + }) + + test("uses safe unique module paths without changing public group identifiers", () => { + const output = compile( + HttpApi.make("test") + .add(HttpApiGroup.make("../session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String }))) + .add(HttpApiGroup.make("GROUP-0").add(HttpApiEndpoint.get("list", "/session", { success: Schema.String }))), + ) + + expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-0.ts", "GROUP-0-1.ts"]) + expect(output.files[0]?.content).toContain('HttpApiGroup.make("../session"') + }) + + test("reserves support module names case-insensitively", () => { + const output = compile( + HttpApi.make("test") + .add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("get", "/client", { success: Schema.String }))) + .add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String }))), + ) + + expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-0.ts", "INDEX-1.ts"]) + }) + + test("keeps searching when a reserved-name fallback is also occupied", () => { + const output = compile( + HttpApi.make("test") + .add(HttpApiGroup.make("client-1").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String }))) + .add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))), + ) + + expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-1.ts", "client-1-1.ts"]) + }) + + test("rejects collisions in the flattened client namespace", () => { + expect(() => + compile( + HttpApi.make("test") + .add(HttpApiGroup.make("status").add(HttpApiEndpoint.get("get", "/nested", { success: Schema.String }))) + .add( + HttpApiGroup.make("system", { topLevel: true }).add( + HttpApiEndpoint.get("status", "/status", { success: Schema.String }), + ), + ), + ), + ).toThrow("Client name collision: status") + }) + + test("emits a usable raw type for top-level groups", () => { + const output = compile( + HttpApi.make("test").add( + HttpApiGroup.make("health", { topLevel: true }).add( + HttpApiEndpoint.get("check", "/health", { success: Schema.String }), + ), + ), + ) + + expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client + Effect.gen(function* () { + const error = yield* generate( + api( + HttpApiEndpoint.get("get", "/url", { + success: Schema.declare((input): input is URL => input instanceof URL), + }), + ), + { + directory: "/generated", + }, + ).pipe(Effect.flip) + + expect(error).toBeInstanceOf(GenerationError) + if (error instanceof GenerationError) expect(error.reason).toBe("Unportable schema: session.get.success") + }).pipe(Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({}))), + ) + + test("rejects required client middleware without an adapter", () => { + class SignedRequest extends HttpApiMiddleware.Service()("SignedRequest", { + requiredForClient: true, + }) {} + + expect(() => + compile(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(SignedRequest))), + ).toThrow("Client middleware requires adapter: SignedRequest") + }) + + test("maps transport and decode failures to one stable client error", () => { + const output = compile( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.String, + }), + ), + ) + + expect(output.operations[0]?.errors).toContain("ClientError") + expect(output.operations[0]?.errors).not.toContain("HttpClientError") + expect(output.operations[0]?.errors).not.toContain("SchemaError") + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + "new ClientError({ cause: error })", + ) + }) +}) diff --git a/packages/httpapi-codegen/test/generated-consumer.ts b/packages/httpapi-codegen/test/generated-consumer.ts new file mode 100644 index 0000000000..448db01be8 --- /dev/null +++ b/packages/httpapi-codegen/test/generated-consumer.ts @@ -0,0 +1,28 @@ +import { Effect, Stream } from "effect" +import { HttpClient } from "effect/unstable/http" +import { ClientError, OpenCode } from "./generated" +import { Missing } from "./fixture" + +export const program = OpenCode.make().pipe( + Effect.map((client) => { + const health = client.session.health() + const list = client.session.list() + const filtered = client.session.list({ archived: true }) + const get = client.session.get({ sessionID: "session" }) + const interrupt = client.session.interrupt({ sessionID: "session" }) + const status = client.status() + const subscribe = client.event.subscribe() + + const _health: Effect.Effect = health + const _list: Effect.Effect, ClientError> = list + const _filtered: Effect.Effect, ClientError> = filtered + const _get: Effect.Effect = get + const _interrupt: Effect.Effect = interrupt + const _status: Effect.Effect = status + const _subscribe: Stream.Stream<{ readonly type: string }, ClientError> = subscribe + + return { _health, _list, _filtered, _get, _interrupt, _status, _subscribe } + }), +) + +const _requiresHttpClient: Effect.Effect = program diff --git a/packages/httpapi-codegen/test/generated/client-error.ts b/packages/httpapi-codegen/test/generated/client-error.ts new file mode 100644 index 0000000000..bcc65d9bdd --- /dev/null +++ b/packages/httpapi-codegen/test/generated/client-error.ts @@ -0,0 +1,5 @@ +import { Schema } from "effect" + +export class ClientError extends Schema.TaggedErrorClass()("ClientError", { + cause: Schema.Defect(), +}) {} diff --git a/packages/httpapi-codegen/test/generated/client.ts b/packages/httpapi-codegen/test/generated/client.ts new file mode 100644 index 0000000000..f67fc00a99 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/client.ts @@ -0,0 +1,16 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect } from "effect" +import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" +import { adaptGroup0, Group0 } from "./session" +import { adaptGroup1, Group1 } from "./event" +import { adaptGroup2, Group2 } from "./system" + +const Api = HttpApi.make("generated").add(Group0).add(Group1).add(Group2) +const adaptClient = (raw: HttpApiClient.ForApi) => ({ + session: adaptGroup0(raw["session"]), + event: adaptGroup1(raw["event"]), + ...adaptGroup2({ status: raw["status"] }), +}) + +export const make = (options?: { readonly baseUrl?: URL | string }) => + HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient)) diff --git a/packages/httpapi-codegen/test/generated/event.ts b/packages/httpapi-codegen/test/generated/event.ts new file mode 100644 index 0000000000..764f2fd1c7 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/event.ts @@ -0,0 +1,39 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect, Schema, Stream } from "effect" +import { Sse } from "effect/unstable/encoding" +import { HttpClientError } from "effect/unstable/http" +import { HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" +import { ClientError } from "./client-error" + +const Endpoint0SuccessData = Schema.Struct({ type: Schema.String }) + +const Endpoint0SuccessError = Schema.Never + +export const Group1 = HttpApiGroup.make("event", { topLevel: false }).add( + HttpApiEndpoint.make("GET")("subscribe", "/event", { + success: HttpApiSchema.StreamSse({ + data: Endpoint0SuccessData, + error: Endpoint0SuccessError, + contentType: "text/event-stream", + }).pipe(HttpApiSchema.status(202)), + }), +) + +type RawGroup = HttpApiClient.Client.Group + +const Endpoint0DeclaredError = Schema.Union([Endpoint0SuccessError]) +const mapEndpoint0Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint0DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint0 = (raw: RawGroup) => () => + Stream.unwrap( + raw["subscribe"]({}).pipe( + Effect.mapError(mapEndpoint0Error), + Effect.map((stream) => stream.pipe(Stream.mapError(mapEndpoint0Error))), + ), + ) + +export const adaptGroup1 = (raw: RawGroup) => ({ subscribe: Endpoint0(raw) }) diff --git a/packages/httpapi-codegen/test/generated/index.ts b/packages/httpapi-codegen/test/generated/index.ts new file mode 100644 index 0000000000..bc0dbc9fa4 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/index.ts @@ -0,0 +1,2 @@ +export { ClientError } from "./client-error" +export * as OpenCode from "./client" diff --git a/packages/httpapi-codegen/test/generated/session.ts b/packages/httpapi-codegen/test/generated/session.ts new file mode 100644 index 0000000000..6a1937c49e --- /dev/null +++ b/packages/httpapi-codegen/test/generated/session.ts @@ -0,0 +1,96 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect, Schema } from "effect" +import { Sse } from "effect/unstable/encoding" +import { HttpClientError } from "effect/unstable/http" +import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { ClientError } from "./client-error" + +const Endpoint0Success = Schema.String + +const Endpoint1Query = Schema.Struct({ archived: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) }) + +const Endpoint1Success = Schema.Array(Schema.String) + +const Endpoint2Params = Schema.Struct({ sessionID: Schema.String }) + +const Endpoint2Success = Schema.Struct({ data: Schema.String }) + +class Endpoint2Error0Class extends Schema.TaggedErrorClass("Missing")("Missing", { + message: Schema.String, +}) {} +const Endpoint2Error0 = Endpoint2Error0Class.annotate({ httpApiStatus: 404 }) + +const Endpoint3Params = Schema.Struct({ sessionID: Schema.String }) + +const Endpoint3Success = Schema.Void.annotate({ httpApiStatus: 204 }) + +export const Group0 = HttpApiGroup.make("session", { topLevel: false }) + .add(HttpApiEndpoint.make("GET")("health", "/session/health", { success: Endpoint0Success })) + .add(HttpApiEndpoint.make("GET")("list", "/session", { query: Endpoint1Query, success: Endpoint1Success })) + .add( + HttpApiEndpoint.make("GET")("get", "/session/:sessionID", { + params: Endpoint2Params, + success: Endpoint2Success, + error: Endpoint2Error0, + }), + ) + .add( + HttpApiEndpoint.make("POST")("interrupt", "/session/:sessionID/interrupt", { + params: Endpoint3Params, + success: Endpoint3Success, + }), + ) + +type RawGroup = HttpApiClient.Client.Group + +const Endpoint0DeclaredError = Schema.Never +const mapEndpoint0Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint0DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint0 = (raw: RawGroup) => () => raw["health"]({}).pipe(Effect.mapError(mapEndpoint0Error)) + +type Endpoint1Input = { readonly archived?: (typeof Endpoint1Query.Type)["archived"] } +const Endpoint1DeclaredError = Schema.Never +const mapEndpoint1Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint1DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint1 = (raw: RawGroup) => (input?: Endpoint1Input) => + raw["list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapEndpoint1Error)) + +type Endpoint2Input = { readonly sessionID: (typeof Endpoint2Params.Type)["sessionID"] } +const Endpoint2DeclaredError = Schema.Union([Endpoint2Error0]) +const mapEndpoint2Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint2DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint2 = (raw: RawGroup) => (input: Endpoint2Input) => + raw["get"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapEndpoint2Error), + Effect.map((value) => value.data), + ) + +type Endpoint3Input = { readonly sessionID: (typeof Endpoint3Params.Type)["sessionID"] } +const Endpoint3DeclaredError = Schema.Never +const mapEndpoint3Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint3DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint3 = (raw: RawGroup) => (input: Endpoint3Input) => + raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpoint3Error)) + +export const adaptGroup0 = (raw: RawGroup) => ({ + health: Endpoint0(raw), + list: Endpoint1(raw), + get: Endpoint2(raw), + interrupt: Endpoint3(raw), +}) diff --git a/packages/httpapi-codegen/test/generated/system.ts b/packages/httpapi-codegen/test/generated/system.ts new file mode 100644 index 0000000000..6ba523d2c0 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/system.ts @@ -0,0 +1,25 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect, Schema } from "effect" +import { Sse } from "effect/unstable/encoding" +import { HttpClientError } from "effect/unstable/http" +import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { ClientError } from "./client-error" + +const Endpoint0Success = Schema.String + +export const Group2 = HttpApiGroup.make("system", { topLevel: true }).add( + HttpApiEndpoint.make("GET")("status", "/status", { success: Endpoint0Success }), +) + +type RawGroup = HttpApiClient.Client + +const Endpoint0DeclaredError = Schema.Never +const mapEndpoint0Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint0DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint0 = (raw: RawGroup) => () => raw["status"]({}).pipe(Effect.mapError(mapEndpoint0Error)) + +export const adaptGroup2 = (raw: RawGroup) => ({ status: Endpoint0(raw) }) diff --git a/packages/httpapi-codegen/test/write.test.ts b/packages/httpapi-codegen/test/write.test.ts new file mode 100644 index 0000000000..f0704abea2 --- /dev/null +++ b/packages/httpapi-codegen/test/write.test.ts @@ -0,0 +1,160 @@ +import { describe, expect } from "bun:test" +import { Effect, FileSystem, Option } from "effect" +import { write, type Output } from "../src" +import { it } from "./effect" + +describe("HttpApiCodegen.write", () => { + it.effect("writes compiled files beneath the output directory", () => { + const writes: Array<{ readonly path: string; readonly content: string }> = [] + const output: Output = { + operations: [], + files: [{ path: "session.ts", content: "export const session = {}" }], + } + + return Effect.gen(function* () { + yield* write(output, "/generated") + + expect(writes).toEqual([ + { path: "/generated/session.ts", content: "export const session = {}\n" }, + { path: "/generated/.httpapi-codegen.json", content: '[\n "session.ts"\n]\n' }, + ]) + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(false), + makeDirectory: () => Effect.void, + writeFileString: (path, content) => { + writes.push({ path, content }) + return Effect.void + }, + }), + ), + ) + }) + + it.effect("removes only stale files owned by the previous manifest", () => { + const removed: Array = [] + return write( + { + operations: [], + files: [{ path: "session.ts", content: "" }], + }, + "/generated", + ).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: (path) => Effect.succeed(path.endsWith(".httpapi-codegen.json")), + makeDirectory: () => Effect.void, + readFileString: () => Effect.succeed('["old.ts", "session.ts"]'), + remove: (path) => { + removed.push(path) + return Effect.void + }, + writeFileString: () => Effect.void, + }), + ), + Effect.tap(() => Effect.sync(() => expect(removed).toEqual(["/generated/old.ts"]))), + ) + }) + + it.effect("rejects unsafe and duplicate output paths before writing", () => { + const writes: Array = [] + return Effect.gen(function* () { + const error = yield* write( + { + operations: [], + files: [ + { path: "../outside.ts", content: "" }, + { path: "client.ts", content: "" }, + { path: "CLIENT.ts", content: "" }, + ], + }, + "/generated", + ).pipe(Effect.flip) + + expect(error._tag).toBe("GenerationError") + expect(writes).toEqual([]) + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + writeFileString: (path) => { + writes.push(path) + return Effect.void + }, + }), + ), + ) + }) + + it.effect("rejects case-insensitive duplicate output paths", () => { + const writes: Array = [] + return Effect.gen(function* () { + const error = yield* write( + { + operations: [], + files: [ + { path: "client.ts", content: "" }, + { path: "CLIENT.ts", content: "" }, + ], + }, + "/generated", + ).pipe(Effect.flip) + + expect(error._tag).toBe("GenerationError") + expect(error.reason).toBe("Duplicate output path: CLIENT.ts") + expect(writes).toEqual([]) + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + writeFileString: (path) => { + writes.push(path) + return Effect.void + }, + }), + ), + ) + }) + + it.effect("reserves the private manifest path", () => + write({ operations: [], files: [{ path: ".httpapi-codegen.json", content: "" }] }, "/generated").pipe( + Effect.flip, + Effect.tap((error) => Effect.sync(() => expect(error.reason).toContain("Unsafe output path"))), + Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})), + ), + ) + + it.effect("rejects existing symbolic-link output targets", () => + write({ operations: [], files: [{ path: "session.ts", content: "" }] }, "/generated").pipe( + Effect.flip, + Effect.tap((error) => Effect.sync(() => expect(error.reason).toBe("Unsafe output path: session.ts"))), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: (path) => Effect.succeed(path.endsWith("session.ts")), + makeDirectory: () => Effect.void, + stat: () => + Effect.succeed({ + type: "SymbolicLink", + mtime: Option.none(), + atime: Option.none(), + birthtime: Option.none(), + dev: 0, + ino: Option.none(), + mode: 0, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(0), + blksize: Option.none(), + blocks: Option.none(), + }), + }), + ), + ), + ) +}) diff --git a/packages/httpapi-codegen/tsconfig.json b/packages/httpapi-codegen/tsconfig.json new file mode 100644 index 0000000000..00ef125468 --- /dev/null +++ b/packages/httpapi-codegen/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/llm/DESIGN.md b/packages/llm/DESIGN.md new file mode 100644 index 0000000000..22e76969a4 --- /dev/null +++ b/packages/llm/DESIGN.md @@ -0,0 +1,1114 @@ +# AI Library Design + +> Discussion draft. This document describes the intended replacement for the +> current private `@opencode-ai/llm` API. Names and exact TypeScript signatures +> are illustrative until implementation, but the domain boundaries and defaults +> are deliberate. + +## Status + +- Proposed package: `@opencode-ai/ai` +- Initial stable domain: `LLM` +- Release posture: pre-1.0, with a stable-core intent +- Migration posture: clean break; do not preserve compatibility aliases +- Primary audience: general-purpose TypeScript developers using Effect +- Secondary audience: OpenCode and other durable agent runtimes + +The package name leaves room for future domains such as embeddings, images, and +speech. Those domains are not part of this design and should not be forced into +the LLM run/turn model. + +## Goals + +1. Make a useful model call require very little code. +2. Make the default behavior good enough that most callers do not configure it. +3. Let advanced callers inspect, transform, or replace every important stage. +4. Keep provider quirks behind provider and protocol boundaries. +5. Preserve one provider turn as an explicit primitive for durable runtimes. +6. Keep serializable request data separate from process-local execution behavior. +7. Make unsupported combinations fail locally with useful typed errors. +8. Stay Effect-native without making package-specific service provisioning part + of every call site. + +## Non-goals + +- A global provider or model registry +- Durable agent orchestration or persistence +- Session history ownership +- Permission handling +- Cost billing or accounting guarantees +- Runtime model-catalog network requests +- Compatibility with the current private API +- Designing embeddings, image generation, speech, or transcription now + +## Design Principles + +### Progressive disclosure + +The API has four layers: + +1. **Run a model** with `LLM.generate` or `LLM.stream`. +2. **Control one provider turn** with `LLM.generateTurn` or `LLM.streamTurn`. +3. **Customize execution** with model defaults, call options, hooks, and provider + configuration. +4. **Author providers** with experimental provider definitions and protocols. + +Normal documentation should teach only the first layer initially. + +### Values over registries + +Provider definitions, configured providers, models, protocols, tools, and hooks +are immutable values. Importing a provider does not register anything globally. + +### Portable data, local behavior + +Requests, messages, tool definitions, events, usage, and result projections are +plain immutable data with schemas. Configured models, executable tools, hooks, +and provider definitions may contain functions and Effect requirements and are +not serializable. + +### Strong defaults, explicit overrides + +Defaults should make common calls correct without hiding where behavior comes +from. Overrides compose in a documented order and never require patching +installed dependencies. + +## Domain Model + +### Provider Definition + +An immutable, declarative description of a provider integration. It owns model +selection, option schemas, catalog corrections, protocols, and provider-wide +hooks. It is an experimental provider-authoring API. + +### Configured Provider + +A provider definition bound to deployment concerns such as credentials, +endpoint, transport, and provider headers. + +`configure(...)` is intentionally deployment-only. It does not establish hidden +generation defaults. + +### Model + +A process-local executable model value selected from a configured provider. It +contains identity, capabilities, pricing metadata, provider-specific option +types, reusable request-behavior defaults, and hidden execution behavior. + +Normal users do not need to learn the current `Route` composite. Protocol, +endpoint, auth, transport, and hooks are bound behind `Model`. + +### Request + +Portable, model-independent input for a model call. It may contain system +instructions, messages, tool definitions, generation controls, output intent, +cache policy, and metadata. It does not contain a configured model, executable +tool handlers, or hooks. + +### Provider Turn + +Exactly one request to a model provider and its normalized response. It does not +execute local tools or continue the conversation. + +### Model Run + +A complete interaction consisting of one or more provider turns. A run executes +local tools, appends their results, and continues until the model completes or a +stopping condition matches. + +### TurnResult + +The result of exactly one provider turn. + +### GenerateResult + +The result of a complete model run. It preserves every turn, tool activity, +aggregate usage, and estimated cost while exposing shortcuts to the final output. + +### Protocol + +The provider-wire contract that lowers portable requests into provider-native +bodies and raises provider-native stream events into normalized turn events. +Protocols are public, reusable, fully inspectable, and immutably patchable, but +the entire protocol-authoring API is experimental. + +## Happy Path + +### Effect + +```ts +import { Effect } from "effect" +import { LLM } from "@opencode-ai/ai" +import { OpenAI } from "@opencode-ai/ai/providers/openai" + +// Environment-based credentials are a provider default. No LLMClient layer is +// required: the Effect exposes standard runtime dependencies directly. +const model = OpenAI.model("gpt-4.1-mini") + +const program = Effect.gen(function* () { + const result = yield* LLM.generate({ + model, + system: "You are concise.", + prompt: "Explain Effect in one sentence.", + }) + + // `generate` always returns GenerateResult, even when the run has one turn. + console.log(result.text) + console.log(result.turns.length) // 1 + console.log(result.usage) + console.log(result.cost) // Estimated cost, or undefined if any turn is unpriced. +}) +``` + +The required Effect environment should contain standard services plus services +required by tools and hooks. It should not contain an `LLMClient` wrapper service. + +### Current API + +The current README appears similarly small but omits the package-specific service +and layer required at runtime: + +```ts +// Current API: this request contains an executable model/route value. +const request = LLM.request({ + model: OpenAI.configure({ apiKey }).responses("gpt-4o-mini"), + prompt: "Say hello.", +}) + +// Current API: this performs one provider turn, despite the broad name. +const response = yield * LLM.generate(request) + +// Current API: execution also needs LLMClient.layer and RequestExecutor services. +``` + +The proposal removes mandatory request construction, removes package-specific +runtime provisioning, and makes `generate` mean a complete run. + +## Provider And Model Selection + +### Environment defaults + +```ts +import { OpenAI } from "@opencode-ai/ai/providers/openai" + +// Open strings receive autocomplete for IDs from the generated models.dev +// snapshot but continue to accept newly released and fine-tuned model IDs. +const model = OpenAI.model("gpt-4.1-mini") +``` + +### Deployment configuration + +```ts +const openai = OpenAI.configure({ + apiKey, + baseURL: "https://gateway.example.com/openai/v1", + headers: { + "x-tenant": "acme", + }, +}) + +const model = openai.model("gpt-4.1-mini") +``` + +`configure(...)` owns deployment concerns only: + +- Credentials and authentication +- Base URL and deployment location +- Transport selection +- Provider/deployment headers +- Other provider-specific connection setup + +It does not own temperature, maximum output tokens, cache policy, retry policy, +tools, output schema, or system instructions. + +### Reusable model defaults + +```ts +const model = OpenAI.model("gpt-4.1-mini", { + generation: { + temperature: 0.2, + maxTokens: 2_000, + }, + cache: "auto", + provider: { + store: false, + }, +}) +``` + +The second argument may default request behavior but not prompt/history or +executable tools. Call-level values override model defaults. + +Provider-specific options are inferred from the concrete model: + +```ts +yield * + LLM.generate({ + model: OpenAI.model("gpt-4.1-mini"), + prompt: "Hello", + provider: { + store: false, + // OpenAI-specific autocomplete here; no `{ openai: ... }` nesting. + }, + }) +``` + +Code choosing between providers dynamically must narrow the model before using +provider-specific options. Portable generation controls remain available without +narrowing. + +### Current API + +```ts +// Current API mixes deployment configuration and reusable request behavior. +const model = OpenAI.configure({ + apiKey, + generation: { maxTokens: 160 }, + providerOptions: { + openai: { store: false }, + }, +}).model("gpt-4o-mini") +``` + +The proposal separates deployment configuration from selected-model behavior and +removes provider-keyed option bags when a concrete model already identifies the +provider. + +## Requests + +### Inline input + +```ts +const result = + yield * + LLM.generate({ + model, + system: "You are concise.", + prompt: "Summarize this pull request.", + generation: { maxTokens: 500 }, + }) +``` + +### Reusable portable request + +```ts +const request = LLM.request({ + system: "You are concise.", + prompt: "Summarize this pull request.", + generation: { maxTokens: 500 }, +}) + +// Bind process-local execution behavior only when running. +const result = yield * LLM.generate({ model, request }) +``` + +`LLM.request(...)` returns a plain immutable object. Use ordinary object spread +to derive another request: + +```ts +const longer = { + ...request, + generation: { + ...request.generation, + maxTokens: 1_000, + }, +} +``` + +There is no `LLM.updateRequest(...)` helper and no request Schema class. + +### Conversation history + +```ts +import { Message } from "@opencode-ai/ai" + +const request = LLM.request({ + system: "You are concise.", + messages: [ + Message.user("What is Effect?"), + Message.assistant("A TypeScript library for typed functional effects."), + Message.user("Why would I use it?"), + ], +}) +``` + +Message helpers return plain immutable data. Object literals remain valid when +they satisfy the same input type. + +`system` stays separate from chronological messages because it is the initial +privileged instruction. A chronological system message represents an instruction +change at a specific point in history. + +## Complete Runs + +### Automatic local tool loop + +```ts +import { Effect, Schema } from "effect" +import { LLM, Tool } from "@opencode-ai/ai" + +const tools = { + getWeather: Tool.make({ + description: "Get current weather for a city.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ forecast: Schema.String }), + + // Tool service requirements and typed errors flow into LLM.generate's + // Effect environment/error model instead of being erased. + execute: ({ city }) => Weather.get(city), + + // Expected domain failures need an explicit model-visible representation. + formatError: (error) => ({ + type: "text", + text: `Weather lookup failed: ${error.message}`, + }), + }), +} + +const result = + yield * + LLM.generate({ + model, + prompt: "What is the weather in London?", + tools, + }) + +// The runtime advertises definitions, dispatches calls, records results, and +// continues provider turns automatically. +console.log(result.text) +console.log(result.turns) +console.log(result.toolExecutions) +``` + +The default stopping condition is equivalent to: + +```ts +stopWhen: StopWhen.turnCount(20) +``` + +This matches the Vercel AI SDK `ToolLoopAgent` default. Reaching the limit is a +successful result with `stopReason: "max-turns"`, not an Effect failure. + +### Custom stopping + +```ts +const result = + yield * + LLM.generate({ + model, + prompt, + tools, + stopWhen: StopWhen.any(StopWhen.turnCount(8), StopWhen.hasToolCall("finalize")), + }) +``` + +`stopWhen` accepts one predicate. Composition is explicit through combinators +such as `StopWhen.any`, `StopWhen.all`, and `StopWhen.not`. + +Successful run stop reasons are closed: + +```ts +type RunStopReason = "completed" | "max-turns" | "stop-condition" +``` + +### Tool concurrency + +Independent tool calls emitted in one turn run concurrently with a bounded, +configurable concurrency limit. Results are appended in deterministic emitted +order. The runtime does not infer dependencies between tool calls; the model must +request dependent calls in separate turns. + +Tools may declare an optional timeout. The overall run timeout still applies. + +### Current API + +Today callers must manually bridge every layer: + +```ts +const request = LLM.request({ + model, + prompt, + tools: Tool.toDefinitions(tools), +}) + +const events = yield * LLM.stream(request).pipe(Stream.runCollect) +const call = Array.from(events).find(LLMEvent.is.toolCall) + +if (call && !call.providerExecuted) { + const dispatched = yield * ToolRuntime.dispatch(tools, call) + const followUp = LLM.updateRequest(request, { + messages: [...request.messages, Message.assistant([call]), Message.tool({ ...call, result: dispatched.result })], + }) + // Caller must invoke the provider again and repeat the loop. +} +``` + +That explicit flow remains possible through turn APIs, but it is no longer the +only tool experience. + +## One Provider Turn + +OpenCode and other durable runtimes need to own persistence, tool settlement, +and continuation. They use the explicit turn API: + +```ts +const result = + yield * + LLM.generateTurn({ + model, + request, + // Definitions only. generateTurn never dispatches local handlers. + tools: { + getWeather: Tool.definition({ + description: "Get current weather for a city.", + parameters: WeatherInput, + }), + }, + }) + +// Persist the TurnResult and settle calls durably before the next turn. +for (const call of result.toolCalls) { + // Application-owned dispatch and persistence. +} +``` + +`generateTurn` and `streamTurn` make exactly one provider request. They never +execute a local tool and never continue automatically. + +This separation is load-bearing: + +- `generate` / `stream`: complete Model Run +- `generateTurn` / `streamTurn`: one Provider Turn + +## Portable Tool Definitions + +A portable request may declare serializable definitions, but executable handlers +are bound at run time: + +```ts +const request = LLM.request({ + prompt: "What is the weather in London?", + tools: { + getWeather: Tool.definition({ + description: "Get current weather for a city.", + parameters: WeatherInput, + }), + }, +}) + +const result = + yield * + LLM.generate({ + model, + request, + tools: { + getWeather: Tool.make({ + description: "Get current weather for a city.", + parameters: WeatherInput, + success: WeatherOutput, + execute: getWeather, + formatError, + }), + }, + }) +``` + +Definitions and handlers match by record key. Before the first provider call, +the runtime validates that every local definition has a compatible executable +binding. Missing or incompatible bindings fail with a typed tool-binding error. + +Provider-hosted tools are distinct typed values: + +```ts +const result = + yield * + LLM.generate({ + model: OpenAI.model("gpt-4.1"), + prompt: "Find today's relevant announcements.", + tools: { + search: OpenAI.tool.webSearch({ searchContextSize: "medium" }), + }, + }) +``` + +Hosted tools do not pretend to have local handlers, and callers do not inspect a +`providerExecuted` boolean to decide whether dispatch is safe. + +## Streaming + +### Run stream + +`LLM.stream` returns an Effect `Stream`. +Run events explicitly expose orchestration boundaries: + +```ts +const program = LLM.stream({ model, prompt, tools }).pipe( + Stream.tap((event) => + Effect.sync(() => { + switch (event.type) { + case "run-start": + break + case "turn-start": + break + case "turn-event": + // Normalized text, reasoning, tool-call, usage, and finish events. + if (event.event.type === "text-delta") { + process.stdout.write(event.event.text) + } + break + case "tool-start": + break + case "tool-finish": + break + case "turn-finish": + break + case "run-finish": + // Contains the same full GenerateResult returned by LLM.generate. + console.log(event.result.usage) + break + } + }), + ), + Stream.runDrain, +) +``` + +Exact event tag spelling remains an implementation detail to finalize, but the +algebra is settled: + +- A separate `RunEvent` union for run, turn, and tool lifecycle +- A focused `TurnEvent` union for normalized provider output +- `streamTurn` emits only `TurnEvent` +- The terminal run event contains the full `GenerateResult` + +External cancellation remains Effect interruption. It does not fabricate a +successful result with an `interrupted` stop reason. + +## Structured Output + +Structured output is an option on `generate`, not a separate operation: + +```ts +const Weather = Schema.Struct({ + city: Schema.String, + forecast: Schema.String, + highCelsius: Schema.Number, +}) + +const result = + yield * + LLM.generate({ + model, + prompt: "Give me today's weather for London.", + output: Weather, + }) + +// Inferred from Weather. +result.output.city +``` + +The model declaration and protocol select the best reliable strategy: + +1. Provider-native structured output when supported and reliable +2. Forced tool output when required as a compatibility fallback +3. Typed unsupported-capability failure before network execution when neither is + available + +Advanced callers may override the strategy when exact provider semantics matter. + +### Current API + +```ts +// Current API is a separate operation and always forces a synthetic tool. +const result = + yield * + LLM.generateObject({ + model, + prompt, + schema: Weather, + }) +``` + +The proposal unifies generation and lets capabilities choose the strategy rather +than permanently encoding one cross-provider workaround. + +## Model Catalog + +`models.dev` is the release-time source for: + +- Model ID suggestions +- Capabilities and modalities +- Context and output limits +- Pricing +- Other available model metadata + +The package ships a generated, versioned snapshot. Normal execution performs no +catalog network requests. + +Provider definitions may correct generated metadata where protocol-specific +knowledge is more accurate. Precedence is: + +```text +models.dev snapshot + < provider-definition correction + < provider configuration override + < model-selection override + < call override +``` + +Unknown model IDs inherit only capabilities guaranteed by the selected protocol. +Unsupported request capabilities fail before network execution unless the caller +explicitly overrides the model declaration. + +## Usage And Cost + +`GenerateResult` aggregates normalized usage across every turn, including cache +read/write usage where providers report it. + +It also exposes estimated cost using the generated models.dev pricing snapshot: + +```ts +result.usage.inputTokens +result.usage.outputTokens +result.usage.cacheReadInputTokens +result.usage.cacheWriteInputTokens + +result.cost?.total +result.cost?.currency // e.g. "USD" +``` + +Cost is an estimate, not a billing guarantee. If reliable pricing is unavailable +for any turn, aggregate run cost is unavailable rather than partial or silently +zero. Per-turn metadata should retain the catalog/pricing identity used so an +estimate can be explained. + +## Caching + +Prompt caching remains `"auto"` by default. The library places protocol-aware +cache boundaries where explicit caching is supported and does nothing on the wire +where providers cache implicitly. + +```ts +yield * + LLM.generate({ + model, + prompt, + cache: "none", // Explicit opt-out. + }) +``` + +Granular cache policy remains available as an advanced request option. + +## Retries, Timeouts, And Cancellation + +### Retries + +The default retry policy is deliberately conservative: + +- Retry bounded transient transport and rate-limit failures +- Retry only before observable output +- Never silently retry after ambiguous tool execution or other side effects +- Allow each call to override or disable retry behavior + +Retry configuration is call-scoped only. Provider and model configuration do not +silently inherit custom retry policies. + +### Timeouts + +```ts +yield * + LLM.generate({ + model, + prompt, + timeout: "2 minutes", // Entire run, including tools. + turnTimeout: "30 seconds", // Each provider turn. + tools, + }) +``` + +Exact Duration input spelling follows Effect conventions. Individual tools may +also declare optional timeouts. + +### Cancellation + +- Effect API: fiber interruption +- Promise API: `AbortSignal`, rejecting with a recognizable abort error +- Cancellation is not a successful run stop reason + +## Hooks + +Stable high-level hooks exist at five named stages: + +1. Canonical request +2. Provider-native body +3. Prepared transport request +4. Normalized event +5. Error + +Hooks are Effectful. They may transform the stage value or fail with a typed +error. They may not secretly short-circuit execution, synthesize a response, +retry, or redirect control flow. + +```ts +const model = OpenAI.model("gpt-4.1", { + hooks: { + request: (request) => + Effect.succeed({ + ...request, + metadata: { ...request.metadata, tenant: "acme" }, + }), + body: (body, context) => auditBody(body, context), + transport: (request) => signInternalGatewayRequest(request), + event: (event) => redactProviderMetadata(event), + error: (error) => classifyInternalError(error), + }, +}) +``` + +Hook scopes compose in this order: + +```text +provider-definition hooks -> model hooks -> call hooks +``` + +Each hook sees the prior hook's output. Replacement requires an explicit +definition-level patch, not accidental last-writer-wins semantics. + +Provider-definition hooks are authored by provider integrations. They are not +passed through `Provider.configure(...)`, which remains deployment-only. + +## HTTP And Provider Escape Hatches + +The request customization ladder is: + +1. Portable generation controls +2. Model-typed `provider` options +3. Stable staged hooks +4. Serializable HTTP/body overlays +5. Experimental provider-definition or protocol patching + +```ts +yield * + LLM.generate({ + model, + prompt, + http: { + headers: { "x-experimental": "1" }, + query: { debug: "true" }, + body: { newlyReleasedProviderField: true }, + }, + }) +``` + +Raw overlays are intentional last-resort support for provider features that ship +before the library has a typed option. + +## Provider-Native Metadata + +Normalized message/content/event unions remain closed and exhaustive. Unknown or +provider-required round-trip data lives in caller-writable `providerMetadata`. + +```ts +const assistant = Message.assistant([ + { + type: "reasoning", + text: "...", + providerMetadata: { + openai: { + // Opaque provider data needed for replay or continuation. + }, + }, + }, +]) +``` + +Protocols validate metadata they consume. The field is an escape hatch, not a +portable semantic guarantee. + +## Error Model + +The Effect error channel is a tagged domain union rather than one `LLMError` +wrapper with nested reasons. Illustrative categories: + +```ts +type LLMError = + | AuthenticationError + | InvalidRequestError + | UnsupportedCapabilityError + | ToolBindingError + | TransportError + | ProviderResponseError + | InvalidProviderOutputError + | HookError +``` + +Each error retains relevant provider/model/turn/stage context and its underlying +cause where available. + +Expected tool errors keep their own typed error channel. `Tool.make` requires an +explicit mapping before such errors become model-visible tool results. Expected +mapped failures let the model recover; defects and interruption fail the run. + +## Observability + +The core library emits Effect-native spans and metrics for: + +- Model runs +- Provider turns +- Provider requests +- Retries +- Tool executions + +Default telemetry records metadata only: + +- Provider and model identity +- Timing +- Token/cache usage +- Estimated cost availability +- Finish and stop reasons +- Retry counts +- Tool names + +Prompts, model output, tool arguments, and tool results are never recorded by +default. Explicit hooks or telemetry configuration may opt into content capture. + +## Promise API + +Promise wrappers live at a separate subpath so the root remains unambiguously +Effect-first: + +```ts +import { LLM } from "@opencode-ai/ai/promise" +import { OpenAI } from "@opencode-ai/ai/providers/openai" + +const result = await LLM.generate({ + model: OpenAI.model("gpt-4.1-mini"), + prompt: "Explain Effect in one sentence.", + signal: abortController.signal, +}) +``` + +Streaming returns an `AsyncIterable`: + +```ts +for await (const event of LLM.stream({ model, prompt, signal })) { + if (event.type === "turn-event" && event.event.type === "text-delta") { + process.stdout.write(event.event.text) + } +} +``` + +Top-level Promise functions use a default runtime for built-in services. Custom +Effect service requirements use a configured client: + +```ts +const client = LLM.makeClient({ + layer: Layer.mergeAll(WeatherLive, AuditLive), +}) + +const result = await client.generate({ model, prompt, tools }) +``` + +The Promise API mirrors Effect semantics. It does not invent different run, +error, stopping, or cancellation behavior. + +## Schemas + +Schemas live in a dedicated namespace/subpath instead of flooding root exports: + +```ts +import { LLMSchema } from "@opencode-ai/ai/schema" + +const request = yield * Schema.decodeUnknown(LLMSchema.Request)(input) +``` + +Schemas cover only serializable domain values: + +- Requests and messages +- Portable tool definitions +- Turn and run events +- Serializable result projections +- Usage and cost estimates +- Tagged errors where serializable +- Provider metadata containers + +Configured models, executable tools, hooks, provider definitions, and protocols +are process-local behavior and do not receive fake serialization schemas. + +## Provider Authoring + +Provider authoring is public but experimental. + +### Declarative provider definition + +```ts +import { Provider, Protocol } from "@opencode-ai/ai/provider" + +export const ExampleAI = Provider.define({ + id: "example", + options: ExampleProviderOptions, + configure: configureExampleDeployment, + protocols: { + responses: ExampleResponses, + }, + models: ({ deployment, catalog }) => ({ + model: (id, defaults) => + Provider.model({ + id, + deployment, + protocol: ExampleResponses, + metadata: catalog.model(id), + defaults, + }), + }), + catalog: generatedExampleCatalog, + corrections: exampleCatalogCorrections, + hooks: exampleProviderHooks, +}) +``` + +The exact builder fields need implementation design, but it must remain one +declarative immutable object, infer provider option types, and support `.with(...)` +patching. It must not register globally. + +Built-ins export their immutable definition for advanced forking: + +```ts +import { OpenAI } from "@opencode-ai/ai/providers/openai" + +const PatchedOpenAI = OpenAI.definition.with({ + protocols: { + responses: OpenAI.protocols.responses.with({ + // Explicit immutable stage patch. + body: { + fromRequest: patchResponsesBody, + }, + }), + }, +}) +``` + +### Protocols + +A protocol exposes all native types and stages: + +- Provider-native request body and schema +- Transport frame type +- Provider-native event and schema +- Parser state +- Request lowering +- Event stepping +- Terminal detection and final flushing + +Every stage is immutably patchable. This is deliberately more open than the AI +SDK integrations that motivated this package. + +```ts +const PatchedResponses = OpenAIResponses.with({ + body: { + fromRequest: (request) => + OpenAIResponses.body.fromRequest(request).pipe(Effect.map((body) => ({ ...body, custom_field: true }))), + }, + stream: { + step: patchResponsesStep, + }, +}) +``` + +Protocol body, frame, native event, and parser-state types are exported. Because +provider wire formats change often, these types and patch APIs are explicitly +experimental and do not receive the high-level API's compatibility promise. + +## Package Surface + +Illustrative export layout: + +```text +@opencode-ai/ai + LLM + Message + Tool + StopWhen + stable domain types + +@opencode-ai/ai/promise + Promise/AsyncIterable LLM facade + +@opencode-ai/ai/schema + serializable domain schemas + +@opencode-ai/ai/provider + experimental Provider and Protocol authoring APIs + +@opencode-ai/ai/providers/openai +@opencode-ai/ai/providers/anthropic +@opencode-ai/ai/providers/google +... +``` + +Providers are imported through individual subpaths. The root does not export all +providers, and there is no preferred all-providers barrel. + +## Defaults + +| Concern | Default | +| ---------------------------- | --------------------------------------------------- | +| `LLM.generate` semantics | Complete Model Run | +| `LLM.generateTurn` semantics | Exactly one Provider Turn | +| Maximum turns | 20 | +| Turn-limit outcome | Successful `max-turns` result | +| Tool execution | Automatic in runs | +| Tool concurrency | Concurrent, bounded, deterministic result order | +| Prompt caching | `auto` | +| Retries | Conservative, pre-output transient failures only | +| Structured output | Capability-selected native or tool strategy | +| Capability mismatch | Typed failure before network execution | +| Unknown model capability | Conservative protocol baseline | +| Telemetry content | Metadata only | +| Cost | Estimated aggregate or unavailable | +| Cancellation | Interruption/rejection, never successful completion | + +## Clean-break Migration + +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 | +| `LLMClient.layer` requirement | Standard Effect requirements exposed directly | +| Public `Route` mental model | Hidden behind executable `Model` | +| `Provider.make` structural helper | Experimental declarative `Provider.define` | +| Schema classes as canonical values | Plain immutable values plus schema subpath | +| `LLM.updateRequest` | Object spread | +| `Tool.toDefinitions` in normal calls | Named executable tool records | +| Manual `ToolRuntime.dispatch` loop | Automatic run dispatch; explicit turn API for orchestration | +| `providerOptions: { openai: ... }` | Model-typed `provider: ...` | +| `generateObject` | Typed `output` option on `generate` | +| One event union for provider output | Separate `TurnEvent` and `RunEvent` unions | +| `providerExecuted` dispatch check | Distinct hosted-tool constructors | +| One wrapped `LLMError` | Tagged domain error union | + +OpenCode should migrate to `generateTurn` / `streamTurn`, preserving its durable +prompt admission, persistence, permission, tool settlement, and continuation +boundaries. It should not use the automatic run API for Session orchestration. + +## Remaining Implementation-level Questions + +These do not reopen the main design: + +1. Exact `RunEvent` and `TurnEvent` tag names and payloads +2. Exact `GenerateResult` shortcut fields for text, reasoning, output, and messages +3. Exact Provider definition TypeScript shape needed for strong inference +4. Exact protocol `.with(...)` patch syntax and replacement semantics +5. Exact Duration input fields and names +6. Exact models.dev generation pipeline and correction-file format +7. Exact cost representation and decimal arithmetic strategy +8. Exact default retry schedule and bounded tool concurrency number +9. Whether request-level serializable HTTP overlays belong in the stable schema +10. Which tagged errors are serializable versus process-local + +These should be resolved with call-site sketches and implementation spikes rather +than by changing the domain boundaries above. diff --git a/packages/llm/example/tutorial.ts b/packages/llm/example/tutorial.ts index 0a227f5bbc..af7a842c5d 100644 --- a/packages/llm/example/tutorial.ts +++ b/packages/llm/example/tutorial.ts @@ -238,7 +238,7 @@ const inspectFakeProvider = Effect.gen(function* () { // Provide the LLM runtime and the HTTP request executor once. Keep one path // enabled at a time so the tutorial can demonstrate generate, prepare, stream, // or tool-loop behavior without spending tokens on every example. -const requestExecutorLayer = RequestExecutor.defaultLayer +const requestExecutorLayer = RequestExecutor.fetchLayer const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer) const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps)) diff --git a/packages/llm/package.json b/packages/llm/package.json index bfe01fce94..c5a632ffac 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.15", + "version": "7.4.16", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", @@ -47,7 +47,8 @@ "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", - "effect": "catalog:" + "effect": "catalog:", + "@opencode-ai/schema": "workspace:*" }, "peerDependencies": {} } diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index a37cd2c9a7..1c0dcd32a4 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -9,6 +9,7 @@ import { Usage, type CacheHint, type FinishReason, + type JsonSchema, type LLMRequest, type MediaPart, type ProviderMetadata, @@ -21,6 +22,7 @@ import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./share import { isContextOverflow } from "../provider-error" import * as Cache from "./utils/cache" import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "anthropic-messages" @@ -256,10 +258,10 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string | return typeof anthropic.signature === "string" ? anthropic.signature : undefined } -const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({ +const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({ name: tool.name, description: tool.description, - input_schema: tool.inputSchema, + input_schema: inputSchema, cache_control: cacheControl(breakpoints, tool.cache), }) @@ -504,6 +506,8 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const generation = request.generation + const toolSchemaCompatibility = request.model.compatibility?.toolSchema + const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096 // Allocate the 4-breakpoint budget in invalidation order: tools → system → // messages. Tools live highest in the cache hierarchy, so when callers // over-mark we keep their tool hints and shed the message-tail ones first. @@ -511,7 +515,13 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques const tools = request.tools.length === 0 || request.toolChoice?.type === "none" ? undefined - : request.tools.map((tool) => lowerTool(breakpoints, tool)) + : request.tools.map((tool) => + lowerTool( + breakpoints, + tool, + ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility), + ), + ) const system = request.system.length === 0 ? undefined @@ -533,7 +543,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques tools, tool_choice: toolChoice, stream: true as const, - max_tokens: generation?.maxTokens ?? request.model.route.defaults.limits?.output ?? 4096, + max_tokens: generation?.maxTokens ?? outputLimit, temperature: generation?.temperature, top_p: generation?.topP, top_k: generation?.topK, diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 80412ca9dd..c447a1a39d 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -7,7 +7,9 @@ import { Usage, type CacheHint, type FinishReason, + type JsonSchema, type LLMRequest, + type ModelToolSchemaCompatibility, type ProviderMetadata, type ReasoningPart, type ToolCallPart, @@ -21,6 +23,7 @@ import { BedrockAuth } from "./utils/bedrock-auth" import { BedrockCache } from "./utils/bedrock-cache" import { BedrockMedia } from "./utils/bedrock-media" import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "bedrock-converse" @@ -205,18 +208,22 @@ type BedrockEvent = Schema.Schema.Type // ============================================================================= // Request Lowering // ============================================================================= -const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({ +const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({ toolSpec: { name: tool.name, description: tool.description, - inputSchema: { json: tool.inputSchema }, + inputSchema: { json: inputSchema }, }, }) -const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray): BedrockTool[] => { +const lowerTools = ( + compatibility: ModelToolSchemaCompatibility | undefined, + breakpoints: BedrockCache.Breakpoints, + tools: ReadonlyArray, +): BedrockTool[] => { const result: BedrockTool[] = [] for (const tool of tools) { - result.push(lowerToolSpec(tool)) + result.push(lowerToolSpec(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, compatibility))) const cachePoint = BedrockCache.block(breakpoints, tool.cache) if (cachePoint) result.push(cachePoint) } @@ -386,7 +393,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: const breakpoints = BedrockCache.breakpoints() const toolConfig = request.tools.length > 0 && request.toolChoice?.type !== "none" - ? { tools: lowerTools(breakpoints, request.tools), toolChoice } + ? { tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice } : undefined const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system) const messages = yield* lowerMessages(request, breakpoints) diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index c8fa34b509..c4bb9476a4 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -8,6 +8,7 @@ import { LLMEvent, Usage, type FinishReason, + type JsonSchema, type LLMRequest, type MediaPart, type ProviderMetadata, @@ -19,9 +20,10 @@ import { import { JsonObject, optionalArray, ProviderShared } from "./shared" import { GeminiToolSchema } from "./utils/gemini-tool-schema" import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" const ADAPTER = "gemini" -const IMAGE_MIMES = new Set(ProviderShared.IMAGE_MIMES) +const MEDIA_MIMES = new Set(ProviderShared.MEDIA_MIMES) export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" // ============================================================================= @@ -166,10 +168,10 @@ interface ParserState { // ============================================================================= // Request Lowering // ============================================================================= -const lowerTool = (tool: ToolDefinition) => ({ +const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema) => ({ name: tool.name, description: tool.description, - parameters: GeminiToolSchema.convert(tool.inputSchema), + parameters: GeminiToolSchema.convert(inputSchema), }) const lowerToolConfig = (toolChoice: NonNullable) => @@ -182,7 +184,7 @@ const lowerToolConfig = (toolChoice: NonNullable) => const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPart | MediaPart) { if (part.type === "text") return { text: part.text } - const media = yield* ProviderShared.validateMedia("Gemini", part, IMAGE_MIMES) + const media = yield* ProviderShared.validateMedia("Gemini", part, MEDIA_MIMES) return { inlineData: { mimeType: media.mime, data: media.base64 } } }) @@ -275,7 +277,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR }) for (const item of content) { if (item.type === "text") continue - const media = yield* ProviderShared.validateToolFile("Gemini", item, IMAGE_MIMES) + const media = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES) parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } }) } } @@ -300,6 +302,7 @@ const thinkingConfig = (request: LLMRequest) => { const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none" const generation = request.generation + const toolSchemaCompatibility = request.model.compatibility?.toolSchema const generationConfig = { maxOutputTokens: generation?.maxTokens, temperature: generation?.temperature, @@ -313,7 +316,15 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque contents: yield* lowerMessages(request), systemInstruction: request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] }, - tools: toolsEnabled ? [{ functionDeclarations: request.tools.map(lowerTool) }] : undefined, + tools: toolsEnabled + ? [ + { + functionDeclarations: request.tools.map((tool) => + lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), + ), + }, + ] + : undefined, toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined, generationConfig: Object.values(generationConfig).some((value) => value !== undefined) ? generationConfig @@ -407,21 +418,35 @@ const step = (state: ParserState, event: GeminiEvent) => { if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought) reasoningSignature = part.thoughtSignature if ("text" in part && part.text.length > 0) { - lifecycle = part.thought - ? Lifecycle.reasoningDelta( - lifecycle, - events, - "reasoning-0", - part.text, - part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined, - ) - : Lifecycle.textDelta(lifecycle, events, "text-0", part.text) + if (part.thought) { + lifecycle = Lifecycle.reasoningDelta( + lifecycle, + events, + "reasoning-0", + part.text, + part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined, + ) + continue + } + lifecycle = Lifecycle.reasoningEnd( + lifecycle, + events, + "reasoning-0", + reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined, + ) + lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text) continue } if ("functionCall" in part) { const input = part.functionCall.args const id = `tool_${nextToolCallId++}` + lifecycle = Lifecycle.reasoningEnd( + lifecycle, + events, + "reasoning-0", + reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined, + ) lifecycle = Lifecycle.stepStart(lifecycle, events) events.push( LLMEvent.toolCall({ diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index e37eec95ec..9ac85b07b1 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -8,6 +8,7 @@ import { LLMEvent, Usage, type FinishReason, + type JsonSchema, type LLMRequest, type MediaPart, type ReasoningPart, @@ -19,6 +20,7 @@ import { import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { OpenAIOptions } from "./utils/openai-options" import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "openai-chat" @@ -174,12 +176,12 @@ const invalid = ProviderShared.invalidRequest // Lowering is the only place that knows how common LLM messages map onto the // OpenAI Chat wire format. Keep provider quirks here instead of leaking native // fields into `LLMRequest`. -const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({ +const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({ type: "function", function: { name: tool.name, description: tool.description, - parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema), + parameters: ToolSchemaProjection.openAI(inputSchema), }, }) @@ -343,10 +345,16 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR // `fromRequest` returns the provider body only. Endpoint, auth, framing, // validation, and HTTP execution are composed by `Route.make`. const generation = request.generation + const toolSchemaCompatibility = request.model.compatibility?.toolSchema return { model: request.model.id, messages: yield* lowerMessages(request), - tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool), + tools: + request.tools.length === 0 + ? undefined + : request.tools.map((tool) => + lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), + ), tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined, stream: true as const, stream_options: { include_usage: true }, @@ -411,7 +419,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) => if (delta?.reasoning_content) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content) - if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content) + if (delta?.content) { + lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0") + lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content) + } + + if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0") for (const tool of toolDeltas) { const result = ToolStream.appendOrStart( diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index b8a955640e..4936d31c92 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -8,6 +8,7 @@ import { LLMEvent, Usage, type FinishReason, + type JsonSchema, type LLMRequest, type ProviderMetadata, type ReasoningPart, @@ -21,6 +22,7 @@ import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./share import { isContextOverflow } from "../provider-error" import { OpenAIOptions } from "./utils/openai-options" import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "openai-responses" @@ -53,7 +55,7 @@ const OpenAIResponsesReasoningSummaryText = Schema.Struct({ const OpenAIResponsesReasoningItem = Schema.Struct({ type: Schema.tag("reasoning"), - id: Schema.String, + id: Schema.optionalKey(Schema.String), summary: Schema.Array(OpenAIResponsesReasoningSummaryText), encrypted_content: optionalNull(Schema.String), }) @@ -101,6 +103,7 @@ type OpenAIResponsesReasoningInput = { summary: Array<{ type: "summary_text"; text: string }> encrypted_content?: string | null } +type OpenAIResponsesReasoningReplay = Omit const OpenAIResponsesTool = Schema.Struct({ type: Schema.tag("function"), @@ -253,11 +256,13 @@ const invalid = ProviderShared.invalidRequest // ============================================================================= // Request Lowering // ============================================================================= -const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({ +const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIResponsesTool => ({ type: "function", name: tool.name, description: tool.description, - parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema), + parameters: ToolSchemaProjection.openAI(inputSchema), + // TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas. + strict: false, }) const lowerToolChoice = (toolChoice: NonNullable) => @@ -364,7 +369,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ if (message.role === "assistant") { const content: TextPart[] = [] - const reasoningItems: Record = {} + const reasoningItems: Record = {} const reasoningReferences = new Set() const hostedToolReferences = new Set() const flushText = () => { @@ -381,7 +386,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ flushText() const reasoning = lowerReasoning(part) if (!reasoning) continue - if (store !== false && reasoning.id) { + if (store !== false) { if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id }) reasoningReferences.add(reasoning.id) continue @@ -393,8 +398,13 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ existing.encrypted_content = reasoning.encrypted_content continue } - reasoningItems[reasoning.id] = reasoning - input.push(reasoning) + const replay = { + type: reasoning.type, + summary: reasoning.summary, + encrypted_content: reasoning.encrypted_content, + } + reasoningItems[reasoning.id] = replay + input.push(replay) continue } if (part.type === "tool-call") { @@ -468,10 +478,16 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) { const generation = request.generation const options = yield* lowerOptions(request) + const toolSchemaCompatibility = request.model.compatibility?.toolSchema return { model: request.model.id, input: yield* lowerMessages(request), - tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool), + tools: + request.tools.length === 0 + ? undefined + : request.tools.map((tool) => + lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), + ), tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined, stream: true as const, max_output_tokens: generation?.maxTokens, @@ -972,6 +988,7 @@ export const route = Route.make({ endpoint, auth, transport: httpTransport, + defaults: { providerOptions: { openai: { store: false } } }, }) const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage)) @@ -999,6 +1016,7 @@ export const webSocketRoute = Route.make({ endpoint, auth, transport: webSocketTransport, + defaults: { providerOptions: { openai: { store: false } } }, }) export * as OpenAIResponses from "./openai-responses" diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index 4a1fed5539..173dc511bb 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer" -import { Effect, JsonSchema, Schema, Stream } from "effect" +import { Effect, Schema, Stream } from "effect" import * as Sse from "effect/unstable/encoding/Sse" import { Headers, HttpClientRequest } from "effect/unstable/http" import { @@ -19,43 +19,11 @@ export { isRecord } export const Json = Schema.fromJsonString(Schema.Unknown) export const decodeJson = Schema.decodeUnknownSync(Json) export const encodeJson = Schema.encodeSync(Json) +const isJson = Schema.is(Schema.Json) export const JsonObject = Schema.Record(Schema.String, Schema.Unknown) export const optionalArray = (schema: S) => Schema.optional(Schema.Array(schema)) export const optionalNull = (schema: S) => Schema.optional(Schema.NullOr(schema)) -/** OpenAI function schemas require one flat object at the top level. */ -export const openAiToolInputSchema = (schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema => { - const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : [] - const flattened = - variants.length === 0 - ? { ...schema, type: "object" } - : { - ...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")), - type: "object", - properties: variants.reduce( - (properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }), - {}, - ), - additionalProperties: false, - } - const normalized = removeNullSchemas(flattened) - return isRecord(normalized) ? normalized : { type: "object" } -} - -const removeNullSchemas = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(removeNullSchemas) - if (!isRecord(value)) return value - const fields = Object.fromEntries( - Object.entries(value) - .filter(([key]) => key !== "anyOf") - .map(([key, field]) => [key, removeNullSchemas(field)]), - ) - if (!Array.isArray(value.anyOf)) return fields - const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas) - if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] } - return { ...fields, anyOf: variants } -} - /** * Streaming tool-call accumulator. Adapters that build a tool call across * multiple `tool-input-delta` chunks store the partial JSON input string here @@ -188,8 +156,11 @@ export const parseToolInput = (route: string, name: string, raw: string) => parseJson(route, raw || "{}", `Invalid JSON input for ${route} tool call ${name}`) export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const -export const MAX_MEDIA_ENCODED_BYTES = 8 * 1024 * 1024 -export const MAX_MEDIA_DECODED_BYTES = 6 * 1024 * 1024 +export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const +export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const +export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES] as const +export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024 +export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024 const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ @@ -240,8 +211,14 @@ export const validateToolFile = (route: string, part: ToolFileContent, supported export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "") export const toolResultText = (part: ToolResultPart) => { - if (part.result.type === "text" || part.result.type === "error") return String(part.result.value) - if (part.result.type === "content") return encodeJson(part.result.value) + if (part.result.type === "text") return String(part.result.value) + if (part.result.type === "error") { + const value = part.result.value + const prototype = + typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) + const structured = Array.isArray(value) || prototype === Object.prototype || prototype === null + return structured && isJson(value) ? encodeJson(value) : String(value) + } return encodeJson(part.result.value) } diff --git a/packages/llm/src/protocols/utils/gemini-tool-schema.ts b/packages/llm/src/protocols/utils/gemini-tool-schema.ts index 7690b2e600..efdbe3f6ec 100644 --- a/packages/llm/src/protocols/utils/gemini-tool-schema.ts +++ b/packages/llm/src/protocols/utils/gemini-tool-schema.ts @@ -1,4 +1,4 @@ -import { ProviderShared } from "../shared" +import { isRecord } from "../../utils/record" // Gemini accepts a JSON Schema-like dialect for tool parameters, but rejects a // handful of common JSON Schema shapes. Keep this projection isolated so the @@ -20,8 +20,6 @@ const SCHEMA_INTENT_KEYS = [ "else", ] -const isRecord = ProviderShared.isRecord - const hasCombiner = (schema: unknown) => isRecord(schema) && (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf) || Array.isArray(schema.allOf)) diff --git a/packages/llm/src/protocols/utils/tool-schema.ts b/packages/llm/src/protocols/utils/tool-schema.ts new file mode 100644 index 0000000000..3a311eb34c --- /dev/null +++ b/packages/llm/src/protocols/utils/tool-schema.ts @@ -0,0 +1,86 @@ +import type { JsonSchema, ModelToolSchemaCompatibility } from "../../schema" +import { isRecord } from "../../utils/record" +import { GeminiToolSchema } from "./gemini-tool-schema" + +const removeNullSchemas = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(removeNullSchemas) + if (!isRecord(value)) return value + const fields = Object.fromEntries( + Object.entries(value) + .filter(([key]) => key !== "anyOf") + .map(([key, field]) => [key, removeNullSchemas(field)]), + ) + if (!Array.isArray(value.anyOf)) return fields + const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas) + if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] } + return { ...fields, anyOf: variants } +} + +const tupleItemsSchema = (items: ReadonlyArray) => { + const projected = items.map(moonshotNode) + if (projected.length === 0) return {} + if (projected.length === 1) return projected[0] + return { anyOf: projected } +} + +const moonshotNode = (schema: unknown): unknown => { + if (Array.isArray(schema)) return schema.map(moonshotNode) + if (!isRecord(schema)) return schema + if (typeof schema.$ref === "string") return { $ref: schema.$ref } + return Object.fromEntries( + Object.entries(schema).flatMap(([key, value]) => { + if (key === "items" && Array.isArray(value)) return [[key, tupleItemsSchema(value)]] + if (key === "prefixItems") { + if ("items" in schema) return [] + return [["items", tupleItemsSchema(Array.isArray(value) ? value : [])]] + } + if (key === "unevaluatedItems") return [] + return [[key, moonshotNode(value)]] + }), + ) +} + +const moonshot = (schema: JsonSchema): JsonSchema => { + const projected = moonshotNode(schema) + return isRecord(projected) ? projected : {} +} + +const openAI = (schema: JsonSchema): JsonSchema => { + const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : [] + const flattened = + variants.length === 0 + ? { ...schema, type: "object" } + : { + ...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")), + type: "object", + properties: variants.reduce( + (properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }), + {}, + ), + additionalProperties: false, + } + const normalized = removeNullSchemas(flattened) + return isRecord(normalized) ? normalized : { type: "object" } +} + +const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {} + +const modelCompatibility = ( + schema: JsonSchema, + compatibility: ModelToolSchemaCompatibility | undefined, +): JsonSchema => { + if (compatibility === undefined) return schema + switch (compatibility) { + case "gemini": + return gemini(schema) + case "moonshot": + return moonshot(schema) + } +} + +export const ToolSchemaProjection = { + gemini, + modelCompatibility, + moonshot, + openAI, +} as const diff --git a/packages/llm/src/provider.ts b/packages/llm/src/provider.ts index 7f69583418..c0406f3da0 100644 --- a/packages/llm/src/provider.ts +++ b/packages/llm/src/provider.ts @@ -1,7 +1,6 @@ -import type { RouteDefaultsInput } from "./route/client" import type { Model, ModelID, ProviderID } from "./schema" -export type ModelOptions = RouteDefaultsInput +export type ModelOptions = Pick /** * Advanced structural provider definition helper. Built-in providers should diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts index 5b5bc5ab2d..d3b41f5817 100644 --- a/packages/llm/src/route/client.ts +++ b/packages/llm/src/route/client.ts @@ -164,13 +164,20 @@ export interface GenerateMethod { export class Service extends Context.Service()("@opencode/LLMClient") {} -const resolveRequestOptions = (request: LLMRequest) => - LLMRequest.update(request, { - generation: - mergeGenerationOptions(request.model.route.defaults.generation, request.generation) ?? new GenerationOptions({}), - providerOptions: mergeProviderOptions(request.model.route.defaults.providerOptions, request.providerOptions), - http: mergeHttpOptions(request.model.route.defaults.http, request.http), +const resolveRequestOptions = (request: LLMRequest) => { + const routeDefaults = request.model.route.defaults + const modelDefaults = request.model.defaults + const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation) + return LLMRequest.update(request, { + generation: generation ?? new GenerationOptions({}), + providerOptions: mergeProviderOptions( + routeDefaults.providerOptions, + modelDefaults?.providerOptions, + request.providerOptions, + ), + http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http), }) +} export interface MakeInput { /** Route id used in diagnostics and prepared request metadata. */ @@ -374,17 +381,12 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) = const generateWith = (stream: Interface["stream"]) => Effect.fn("LLM.generate")(function* (request: LLMRequest) { - return new LLMResponse( - yield* stream(request).pipe( - Stream.runFold( - () => ({ events: [] as LLMEvent[], usage: undefined as LLMResponse["usage"] }), - (acc, event) => { - acc.events.push(event) - if ("usage" in event && event.usage !== undefined) acc.usage = event.usage - return acc - }, - ), - ), + const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce)) + const response = LLMResponse.complete(state) + if (response) return response + return yield* ProviderShared.eventError( + `${request.model.provider}/${request.model.route.id}`, + "Provider stream ended without a terminal finish event", ) }) diff --git a/packages/llm/src/route/executor.ts b/packages/llm/src/route/executor.ts index 10d8273272..b2f679c683 100644 --- a/packages/llm/src/route/executor.ts +++ b/packages/llm/src/route/executor.ts @@ -380,6 +380,6 @@ export const layer: Layer.Layer = Layer.e }), ) -export const defaultLayer = layer.pipe(Layer.provide(FetchHttpClient.layer)) +export const fetchLayer = layer.pipe(Layer.provide(FetchHttpClient.layer)) export * as RequestExecutor from "./executor" diff --git a/packages/llm/src/route/transport/http.ts b/packages/llm/src/route/transport/http.ts index 00508957a7..acc52c6ea1 100644 --- a/packages/llm/src/route/transport/http.ts +++ b/packages/llm/src/route/transport/http.ts @@ -28,9 +28,56 @@ const applyQuery = (url: string, query: Record | undefined) => { return next.toString() } +const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([ + "content", + "contents", + "frequencyPenalty", + "frequency_penalty", + "generationConfig", + "inferenceConfig", + "input", + "maxTokens", + "max_tokens", + "messages", + "model", + "presencePenalty", + "presence_penalty", + "responseFormat", + "response_format", + "seed", + "stop", + "stopSequences", + "stop_sequences", + "stream", + "streamOptions", + "stream_options", + "system", + "systemInstruction", + "system_instruction", + "temperature", + "thinking", + "toolChoice", + "toolConfig", + "tool_choice", + "tool_config", + "tools", + "topK", + "topP", + "top_k", + "top_p", +]) + +const forbiddenBodyOverlayKeys = (body: Record) => + Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key)) + const bodyWithOverlay = (body: Body, request: LLMRequest, encodeBody: (body: Body) => string) => Effect.gen(function* () { if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) } + const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body) + if (forbiddenKeys.length > 0) + return yield* ProviderShared.invalidRequest( + `http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`, + ) if (ProviderShared.isRecord(body)) { const overlaid = mergeJsonRecords(body, request.http.body) ?? {} return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) } diff --git a/packages/llm/src/schema/errors.ts b/packages/llm/src/schema/errors.ts index 35546ca30b..072e4e8389 100644 --- a/packages/llm/src/schema/errors.ts +++ b/packages/llm/src/schema/errors.ts @@ -202,6 +202,6 @@ export class LLMError extends Schema.TaggedErrorClass()("LLM.Error", { */ export class ToolFailure extends Schema.TaggedErrorClass()("LLM.ToolFailure", { message: Schema.String, - error: Schema.optional(Schema.Defect), + error: Schema.optional(Schema.Defect()), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }) {} diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index a685f07d5e..98fcc9a24d 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids" import { ModelSchema } from "./options" -import { ToolOutput, ToolResultValue } from "./messages" +import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages" import { ProviderFailureClassification } from "./errors" /** @@ -175,7 +175,7 @@ export const ToolError = Schema.Struct({ id: ToolCallID, name: Schema.String, message: Schema.String, - error: Schema.optional(Schema.Defect), + error: Schema.optional(Schema.Defect()), providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.ToolError" }) export type ToolError = Schema.Schema.Type @@ -335,9 +335,234 @@ const responseUsage = (events: ReadonlyArray) => undefined, ) +interface ContentAssembly { + readonly contentIndex: number + readonly text: string + readonly providerMetadata?: ProviderMetadata +} + +interface ToolInputAssembly { + readonly name: string + readonly text: string + readonly providerMetadata?: ProviderMetadata +} + +interface ResponseState { + readonly events: ReadonlyArray + readonly message: Message + readonly usage?: Usage + readonly finishReason?: FinishReason + readonly textParts: Readonly> + readonly reasoningParts: Readonly> + readonly toolInputs: Readonly> +} + +const emptyResponseState = (): ResponseState => ({ + events: [], + message: Message.assistant([]), + textParts: {}, + reasoningParts: {}, + toolInputs: {}, +}) + +const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => { + const events = [...state.events, event] + if (LLMEvent.is.finish(event)) { + return { + ...state, + events, + usage: event.usage ?? state.usage, + finishReason: event.reason, + } + } + if (LLMEvent.is.providerError(event)) { + return { + ...state, + events, + finishReason: state.finishReason ?? "error", + } + } + return { + ...state, + events, + usage: "usage" in event && event.usage !== undefined ? event.usage : state.usage, + } +} + +const textContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart => + providerMetadata === undefined ? { type: "text", text } : { type: "text", text, providerMetadata } + +const reasoningContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart => + providerMetadata === undefined ? { type: "reasoning", text } : { type: "reasoning", text, providerMetadata } + +const contentWith = (state: ResponseState, content: ReadonlyArray): ResponseState => ({ + ...state, + message: Message.assistant(content), +}) + +const appendContent = (state: ResponseState, part: ContentPart) => contentWith(state, [...state.message.content, part]) + +const replaceContent = (state: ResponseState, index: number, part: ContentPart) => + contentWith( + state, + state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)), + ) + +const ensureText = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => { + if (state.textParts[id]) return state + return { + ...appendContent(state, textContent("", providerMetadata)), + textParts: { + ...state.textParts, + [id]: { contentIndex: state.message.content.length, text: "", providerMetadata }, + }, + } +} + +const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => { + const started = ensureText(state, event.id, event.providerMetadata) + const current = started.textParts[event.id] + if (!current) return started + const text = current.text + event.text + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(started, current.contentIndex, textContent(text, providerMetadata)), + textParts: { ...started.textParts, [event.id]: { ...current, text, providerMetadata } }, + } +} + +const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => { + const current = state.textParts[event.id] + if (!current) return state + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)), + textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } }, + } +} + +const ensureReasoning = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => { + if (state.reasoningParts[id]) return state + return { + ...appendContent(state, reasoningContent("", providerMetadata)), + reasoningParts: { + ...state.reasoningParts, + [id]: { contentIndex: state.message.content.length, text: "", providerMetadata }, + }, + } +} + +const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => { + const started = ensureReasoning(state, event.id, event.providerMetadata) + const current = started.reasoningParts[event.id] + if (!current) return started + const text = current.text + event.text + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(started, current.contentIndex, reasoningContent(text, providerMetadata)), + reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, providerMetadata } }, + } +} + +const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): ResponseState => { + const current = state.reasoningParts[event.id] + if (!current) return state + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)), + reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } }, + } +} + +const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): ResponseState => ({ + ...state, + toolInputs: { + ...state.toolInputs, + [event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata }, + }, +}) + +const reduceToolInputDelta = (state: ResponseState, event: ToolInputDelta): ResponseState => { + const current = state.toolInputs[event.id] ?? { name: event.name, text: "" } + return { + ...state, + toolInputs: { ...state.toolInputs, [event.id]: { ...current, text: current.text + event.text } }, + } +} + +const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): ResponseState => { + const current = state.toolInputs[event.id] ?? { name: event.name, text: "" } + return { + ...state, + toolInputs: { + ...state.toolInputs, + [event.id]: { + ...current, + name: event.name, + providerMetadata: event.providerMetadata ?? current.providerMetadata, + }, + }, + } +} + +const toolCallContent = (event: ToolCall): ContentPart => + ToolCallPart.make({ + id: event.id, + name: event.name, + input: event.input, + ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), + ...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }), + }) + +const toolResultContent = (event: ToolResult): ContentPart => + ToolResultPart.make({ + id: event.id, + name: event.name, + result: event.result, + ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), + ...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }), + }) + +const reduceToolCall = (state: ResponseState, event: ToolCall): ResponseState => { + const { [event.id]: _finished, ...toolInputs } = state.toolInputs + return { ...appendContent(state, toolCallContent(event)), toolInputs } +} + +const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseState => { + const next = appendEvent(state, event) + switch (event.type) { + case "text-start": + return ensureText(next, event.id, event.providerMetadata) + case "text-delta": + return reduceTextDelta(next, event) + case "text-end": + return reduceTextEnd(next, event) + case "reasoning-start": + return ensureReasoning(next, event.id, event.providerMetadata) + case "reasoning-delta": + return reduceReasoningDelta(next, event) + case "reasoning-end": + return reduceReasoningEnd(next, event) + case "tool-input-start": + return reduceToolInputStart(next, event) + case "tool-input-delta": + return reduceToolInputDelta(next, event) + case "tool-input-end": + return reduceToolInputEnd(next, event) + case "tool-call": + return reduceToolCall(next, event) + case "tool-result": + return appendContent(next, toolResultContent(event)) + default: + return next + } +} + export class LLMResponse extends Schema.Class("LLM.Response")({ + message: Message, events: Schema.Array(LLMEvent), usage: Schema.optional(Usage), + finishReason: FinishReason, }) { /** Concatenated assistant text assembled from streamed `text-delta` events. */ get text() { @@ -356,8 +581,29 @@ export class LLMResponse extends Schema.Class("LLM.Response")({ } export namespace LLMResponse { + export type State = ResponseState export type Output = LLMResponse | { readonly events: ReadonlyArray; readonly usage?: Usage } + /** Initial reducer state for assembling one provider attempt. */ + export const empty = emptyResponseState + + /** Purely fold one provider-neutral event into the attempt assembly state. */ + export const reduce = reduceResponseState + + /** Return a completed response only after a terminal finish or provider error. */ + export const complete = (state: State): LLMResponse | undefined => + state.finishReason === undefined + ? undefined + : new LLMResponse({ + message: state.message, + events: [...state.events], + usage: state.usage, + finishReason: state.finishReason, + }) + + /** Convenience reducer for callers that already have a collected event list. */ + export const fromEvents = (events: ReadonlyArray) => complete(events.reduce(reduce, empty())) + /** Concatenate assistant text from a response or collected event list. */ export const text = (response: Output) => responseText(response.events) diff --git a/packages/llm/src/schema/ids.ts b/packages/llm/src/schema/ids.ts index 61289aa9d0..7eb7409802 100644 --- a/packages/llm/src/schema/ids.ts +++ b/packages/llm/src/schema/ids.ts @@ -1,4 +1,7 @@ import { Schema } from "effect" +import { ProviderMetadata } from "@opencode-ai/schema/llm" + +export { ProviderMetadata } /** Stable string identifier for a protocol implementation. */ export const ProtocolID = Schema.String @@ -38,6 +41,3 @@ export type FinishReason = Schema.Schema.Type export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown) export type JsonSchema = Schema.Schema.Type - -export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)) -export type ProviderMetadata = Schema.Schema.Type diff --git a/packages/llm/src/schema/messages.ts b/packages/llm/src/schema/messages.ts index b160f2d4a4..4a9de3a735 100644 --- a/packages/llm/src/schema/messages.ts +++ b/packages/llm/src/schema/messages.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { ToolContent, ToolFileContent, ToolTextContent } from "@opencode-ai/schema/llm" import { JsonSchema, MessageRole, ProviderMetadata } from "./ids" import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options" import { isRecord } from "../utils/record" @@ -39,23 +40,7 @@ export const MediaPart = Schema.Struct({ }).annotate({ identifier: "LLM.Content.Media" }) export type MediaPart = Schema.Schema.Type -export const ToolTextContent = Schema.Struct({ - type: Schema.Literal("text"), - text: Schema.String, -}).annotate({ identifier: "Tool.TextContent" }) -export type ToolTextContent = typeof ToolTextContent.Type - -export const ToolFileContent = Schema.Struct({ - type: Schema.Literal("file"), - uri: Schema.String, - mime: Schema.String, - name: Schema.optional(Schema.String), -}).annotate({ identifier: "Tool.FileContent" }) -export type ToolFileContent = typeof ToolFileContent.Type - -/** Ordered, provider-independent content shown to models and UIs after a tool succeeds. */ -export const ToolContent = Schema.Union([ToolTextContent, ToolFileContent]).pipe(Schema.toTaggedUnion("type")) -export type ToolContent = Schema.Schema.Type +export { ToolContent, ToolFileContent, ToolTextContent } const isToolResultValue = (value: unknown): value is ToolResultValue => isRecord(value) && diff --git a/packages/llm/src/schema/options.ts b/packages/llm/src/schema/options.ts index c02af6d1ed..6d11333b53 100644 --- a/packages/llm/src/schema/options.ts +++ b/packages/llm/src/schema/options.ts @@ -134,15 +134,62 @@ export namespace ModelLimits { input instanceof ModelLimits ? input : new ModelLimits(input ?? {}) } +export class ModelDefaults extends Schema.Class("LLM.ModelDefaults")({ + limits: Schema.optional(ModelLimits), + generation: Schema.optional(GenerationOptions), + providerOptions: Schema.optional(ProviderOptions), + http: Schema.optional(HttpOptions), +}) {} + +export namespace ModelDefaults { + export type Input = + | ModelDefaults + | { + readonly limits?: ModelLimits.Input + readonly generation?: GenerationOptions.Input + readonly providerOptions?: ProviderOptions + readonly http?: HttpOptions.Input + } + + /** Normalize selected-model request defaults without applying precedence. */ + export const make = (input: Input) => { + if (input instanceof ModelDefaults) return input + return new ModelDefaults({ + limits: input.limits === undefined ? undefined : ModelLimits.make(input.limits), + generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation), + providerOptions: input.providerOptions, + http: input.http === undefined ? undefined : HttpOptions.make(input.http), + }) + } +} + +export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"]) +export type ModelToolSchemaCompatibility = Schema.Schema.Type + +export class ModelCompatibility extends Schema.Class("LLM.ModelCompatibility")({ + toolSchema: Schema.optional(ModelToolSchemaCompatibility), +}) {} + +export namespace ModelCompatibility { + export type Input = ModelCompatibility | ConstructorParameters[0] + + /** Normalize model/upstream compatibility metadata without projecting requests. */ + export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input)) +} + export class Model { readonly id: ModelID readonly provider: ProviderID readonly route: AnyRoute + readonly defaults?: ModelDefaults + readonly compatibility?: ModelCompatibility constructor(input: Model.ConstructorInput) { this.id = input.id this.provider = input.provider this.route = input.route + this.defaults = input.defaults + this.compatibility = input.compatibility } static make(input: Model.Input) { @@ -150,6 +197,8 @@ export class Model { id: ModelID.make(input.id), provider: ProviderID.make(input.provider), route: input.route, + defaults: input.defaults === undefined ? undefined : ModelDefaults.make(input.defaults), + compatibility: input.compatibility === undefined ? undefined : ModelCompatibility.make(input.compatibility), }) } @@ -158,6 +207,8 @@ export class Model { id: model.id, provider: model.provider, route: model.route, + defaults: model.defaults, + compatibility: model.compatibility, } } @@ -175,11 +226,15 @@ export namespace Model { readonly id: ModelID readonly provider: ProviderID readonly route: AnyRoute + readonly defaults?: ModelDefaults + readonly compatibility?: ModelCompatibility } - export type Input = Omit & { + export type Input = Omit & { readonly id: string | ModelID readonly provider: string | ProviderID + readonly defaults?: ModelDefaults.Input + readonly compatibility?: ModelCompatibility.Input } } diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts index 8e182948fa..bbbb29f37a 100644 --- a/packages/llm/test/adapter.test.ts +++ b/packages/llm/test/adapter.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" -import { LLM } from "../src" +import { LLM, LLMResponse } from "../src" import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route" import { Model } from "../src/schema" import { testEffect } from "./lib/effect" @@ -112,9 +112,16 @@ describe("llm route", () => { const llm = yield* LLMClient.Service const events = Array.from(yield* llm.stream(request).pipe(Stream.runCollect)) const response = yield* llm.generate(request) + const reduced = LLMResponse.fromEvents(events) expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"]) - expect(response.events.map((event) => event.type)).toEqual(["text-delta", "finish"]) + expect(reduced).toBeDefined() + if (!reduced) throw new Error("stream reducer did not produce a completed response") + expect(response.events).toEqual(events) + expect(response.message).toEqual(reduced.message) + expect(response.usage).toEqual(reduced.usage) + expect(response.finishReason).toEqual(reduced.finishReason) + expect(response.message.content).toEqual([{ type: "text", text: 'echo:{"body":"hello"}' }]) }), ) diff --git a/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json b/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json index 0145756887..209aadfc1a 100644 --- a/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json +++ b/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json @@ -21,7 +21,7 @@ "headers": { "content-type": "text/event-stream" }, - "body": "" + "body": "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hi.\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1200,\"candidatesTokenCount\":2,\"totalTokenCount\":1202}}\n\n" } }, { @@ -39,7 +39,7 @@ "headers": { "content-type": "text/event-stream" }, - "body": "" + "body": "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hi.\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"cachedContentTokenCount\":1100,\"promptTokenCount\":1200,\"candidatesTokenCount\":2,\"totalTokenCount\":1202}}\n\n" } } ] diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json index a3f2e014df..9d441205fd 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json +++ b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json @@ -22,7 +22,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_output_tokens\":80}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"stream\":true,\"max_output_tokens\":80}" }, "response": { "status": 200, @@ -40,7 +40,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_output_tokens\":80}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"stream\":true,\"max_output_tokens\":80}" }, "response": { "status": 200, diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json index 172b8407e6..8aa5dedaaf 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json +++ b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json @@ -14,7 +14,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"stream\":true,\"max_output_tokens\":80}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"stream\":true,\"max_output_tokens\":80}" }, "response": { "status": 200, diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json index 21fb8944f4..4f65f43b57 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json @@ -28,7 +28,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read images carefully. Reply only with the visible text, lowercase, no punctuation.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use the read_screenshot tool, then reply with the words shown.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_screenshot_1\",\"name\":\"read_screenshot\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_screenshot_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"Image read successfully\"},{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnYAAACKCAYAAAAnmweyAAACKWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjYzMCIKICAgZXhpZjpVc2VyQ29tbWVudD0iU2NyZWVuc2hvdCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjEzOCIKICAgdGlmZjpZUmVzb2x1dGlvbj0iMTQ0LzEiCiAgIHRpZmY6WFJlc29sdXRpb249IjE0NC8xIgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+at0SpgAACrhpQ0NQSUNDIFByb2ZpbGUAAEiJlZcHUFNZF8fvey+dhJYQASmh994CSAmhBVCQDjZCEiAQQkxBwa4sruBaUBHBsqKrIgo2qg0RxbYo9r4gi4iyLhZsqHwPGMLufvN933xn5s75zXnn/u+5d959cx4AFFOuRCKC1QHIFsul0SEBjMSkZAb+JcACTUACnoDK5ckkrKioCIDahP+7fbgLoFF/y25U69+f/1fT4AtkPACgKJRT+TJeNsonAIABTyKVA4CgDEwWyCWjfB9lmhQtEOWBUU4fY8yoDi11nGljObHRbJQtASCQuVxpOgBkVzTOyOWlozrkWJQdxXyhGOUClH2zs3P4KLehbInmSFAe1Wem/kUn/W+aqUpNLjddyeN7GTNCoFAmEXHz/s/j+N+WLVJMrGGBDnKGNDQa9Xrouf2elROuZHHqjMgJFvLH8sc4QxEaN8E8GTt5gmWiGM4E87mB4Uod0YyICU4TBitzhHJO7AQLZEExEyzNiVaumyZlsyaYK52sQZEVp4xnCDhK/fyM2IQJzhXGz1DWlhUTPpnDVsalimjlXgTikIDJdYOV55At+8vehRzlXHlGbKjyHLiT9QvErElNWaKyNr4gMGgyJ06ZL5EHKNeSiKKU+QJRiDIuy41RzpWjL+fk3CjlGWZyw6ImGMQAOVAAPhCCHMAAgaiXAQkQAS7IkwsWykc3xM6R5EmF6RlyBgu9dQIGR8yzt2U4Ozq7AzB6h8dfkXf0sbsJ0a9MxlZVAeDTNDIycnIyFnYDgKMpAJDqJmOWcwBQ7wPg0imeQpo7Hhu7a1j0y6AGaEAHGAATYAnsgDNwB97AHwSBMBAJYkESmAt4IANkAylYABaDFaAQFIMNYAsoB7vAHnAAHAbHQAM4Bc6Bi+AquAHugEegC/SCV2AQfADDEAThIQpEhXQgQ8gMsoGcISbkCwVBEVA0lASlQOmQGFJAi6FVUDFUApVDu6Eq6CjUBJ2DLkOd0AOoG+qH3kJfYAQmwzRYHzaHHWAmzILD4Vh4DpwOz4fz4QJ4HVwGV8KH4Hr4HHwVvgN3wa/gIQQgKggdMULsECbCRiKRZCQNkSJLkSKkFKlEapBmpB25hXQhA8hnDA5DxTAwdhhvTCgmDsPDzMcsxazFlGMOYOoxbZhbmG7MIOY7loLVw9pgvbAcbCI2HbsAW4gtxe7D1mEvYO9ge7EfcDgcHWeB88CF4pJwmbhFuLW4HbhaXAuuE9eDG8Lj8Tp4G7wPPhLPxcvxhfht+EP4s/ib+F78J4IKwZDgTAgmJBPEhJWEUsJBwhnCTUIfYZioTjQjehEjiXxiHnE9cS+xmXid2EscJmmQLEg+pFhSJmkFqYxUQ7pAekx6p6KiYqziqTJTRaiyXKVM5YjKJZVulc9kTbI1mU2eTVaQ15H3k1vID8jvKBSKOcWfkkyRU9ZRqijnKU8pn1SpqvaqHFW+6jLVCtV61Zuqr9WIamZqLLW5avlqpWrH1a6rDagT1c3V2epc9aXqFepN6vfUhzSoGk4akRrZGms1Dmpc1nihidc01wzS5GsWaO7RPK/ZQ0WoJlQ2lUddRd1LvUDtpeFoFjQOLZNWTDtM66ANamlquWrFay3UqtA6rdVFR+jmdA5dRF9PP0a/S/8yRX8Ka4pgypopNVNuTvmoPVXbX1ugXaRdq31H+4sOQydIJ0tno06DzhNdjK617kzdBbo7dS/oDkylTfWeyptaNPXY1Id6sJ61XrTeIr09etf0hvQN9EP0Jfrb9M/rDxjQDfwNMg02G5wx6DekGvoaCg03G541fMnQYrAYIkYZo40xaKRnFGqkMNpt1GE0bGxhHGe80rjW+IkJyYRpkmay2aTVZNDU0HS66WLTatOHZkQzplmG2VazdrOP5hbmCearzRvMX1hoW3As8i2qLR5bUiz9LOdbVlretsJZMa2yrHZY3bCGrd2sM6wrrK/bwDbuNkKbHTadtlhbT1uxbaXtPTuyHcsu167artuebh9hv9K+wf61g6lDssNGh3aH745ujiLHvY6PnDSdwpxWOjU7vXW2duY5VzjfdqG4BLssc2l0eeNq4ypw3el6343qNt1ttVur2zd3D3epe417v4epR4rHdo97TBozirmWeckT6xnguczzlOdnL3cvudcxrz+97byzvA96v5hmMU0wbe+0Hh9jH67Pbp8uX4Zviu/Pvl1+Rn5cv0q/Z/4m/nz/ff59LCtWJusQ63WAY4A0oC7gI9uLvYTdEogEhgQWBXYEaQbFBZUHPQ02Dk4Prg4eDHELWRTSEooNDQ/dGHqPo8/hcao4g2EeYUvC2sLJ4THh5eHPIqwjpBHN0+HpYdM3TX88w2yGeEZDJIjkRG6KfBJlETU/6uRM3MyomRUzn0c7RS+Obo+hxsyLORjzITYgdn3sozjLOEVca7xa/Oz4qviPCYEJJQldiQ6JSxKvJukmCZMak/HJ8cn7kodmBc3aMqt3ttvswtl351jMWTjn8lzduaK5p+epzePOO56CTUlIOZjylRvJreQOpXJSt6cO8ti8rbxXfH/+Zn6/wEdQIuhL80krSXuR7pO+Kb0/wy+jNGNAyBaWC99khmbuyvyYFZm1P2tElCCqzSZkp2Q3iTXFWeK2HIOchTmdEhtJoaRrvtf8LfMHpeHSfTJINkfWKKehzdI1haXiB0V3rm9uRe6nBfELji/UWCheeC3POm9NXl9+cP4vizCLeItaFxstXrG4ewlrye6l0NLUpa3LTJYVLOtdHrL8wArSiqwVv650XFmy8v2qhFXNBfoFywt6fgj5obpQtVBaeG+19+pdP2J+FP7YscZlzbY134v4RVeKHYtLi7+u5a298pPTT2U/jaxLW9ex3n39zg24DeINdzf6bTxQolGSX9Kzafqm+s2MzUWb32+Zt+VyqWvprq2krYqtXWURZY3bTLdt2Pa1PKP8TkVARe12ve1rtn/cwd9xc6f/zppd+ruKd335Wfjz/d0hu+srzStL9+D25O55vjd+b/svzF+q9unuK973bb94f9eB6ANtVR5VVQf1Dq6vhqsV1f2HZh+6cTjwcGONXc3uWnpt8RFwRHHk5dGUo3ePhR9rPc48XnPC7MT2OmpdUT1Un1c/2JDR0NWY1NjZFNbU2uzdXHfS/uT+U0anKk5rnV5/hnSm4MzI2fyzQy2SloFz6ed6Wue1PjqfeP5228y2jgvhFy5dDL54vp3VfvaSz6VTl70uN11hXmm46n61/prbtbpf3X6t63DvqL/ucb3xhueN5s5pnWdu+t08dyvw1sXbnNtX78y403k37u79e7Pvdd3n33/xQPTgzcPch8OPlj/GPi56ov6k9Kne08rfrH6r7XLvOt0d2H3tWcyzRz28nle/y37/2lvwnPK8tM+wr+qF84tT/cH9N17Oetn7SvJqeKDwD40/tr+2fH3iT/8/rw0mDva+kb4Zebv2nc67/e9d37cORQ09/ZD9Yfhj0SedTwc+Mz+3f0n40je84Cv+a9k3q2/N38O/Px7JHhmRcKXcsVYAQQeclgbA2/0AUJIAoKI9BGnWeI89ZtD4f8EYgf/E4334mKGdSw3qRtsjdgsAR9BhvhwANX8ARlujWH8Au7gox0Q/PNa7jxoO/Yup8UK0Vjk9ta0C/7Txvv4vdf/TA6Xq3/y/AOOhDyne6KAWAAAAimVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA5KGAAcAAAASAAAAeKACAAQAAAABAAACdqADAAQAAAABAAAAigAAAABBU0NJSQAAAFNjAAAAAAAAAADxh4F4AAAAHGlET1QAAAACAAAAAAAAAEUAAAAoAAAARQAAAEUAAAbT33OL9AAABp9JREFUeAHs3F9olWUcB/DnLHCT/rgKQxbhtLwpkIqsLrxZQfQXKggEA/tjZuCFCRHR1Wg3XiyhoKgVeKFd1k1CFNGNRAhhkFAQFBlSkLhjbqtNbW3jeOB0dt6dHc905/d8dnXe5332nvf3+b7jfGXMUt+NG6aTLwIECBAgQIAAgY4XKCl2HZ+hAQgQIECAAAECcwKKnQeBAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEoPT+4NHp+WY58edPaeST1+c7ZY0AAQIECBAgQGAZCpQ+H/ln3mJXPnMy7R4eWIa37JYIECBAgAABAgTmE1Ds5lOxRoAAAQIECBDoQIFFF7uelb0dOKZbJkCAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATgYbFburcZNoxdFcdQ8/K3ro1CwQIECBAgAABApdfoGGx+3d6Oj03uLHuDhW7OhILBAgQIECAAIFlIaDYLYsY3AQBAgQIEMhLYOvWp5dk4IMHDyzJdTvloopdpyTlPgkQIECAQCABxW5pwlTslsbVVQkQIECAAIECAcWuAOciTil2F4HnWwkQIECAAIHWBBS71twW+i7FbiEh5wkQIECAAIG2Cyh2bSedu2DDYnf2/Nn0wht31r2rv4qtI7FAgAABAgQILFJAsVskWJPbGxa78pmTaffwQN1lFLs6EgsECBAgQIDAIgUUu0WCNbldsatA3XrbxnTfA4/VsH343r7098REzVrRQXd3d3p+58upq+uK6rYD+99N5dFT1WMvFhboX3dzevTxLdWN4+Nn0v6Rt9P0zP+t6IsAAQKdIuAzoTgpxa7Yp9Wzil1FbuD+B9OLu16pcdzxzJPpr9Ona9aKDtb2r0t7931Qs2Xv0Gvp6LdHatYcFAs89MgTadv2XTWbtm15OE1OTtasOSBAgMByFvCZUJyOYlfs0+pZxa4ip9g1foRW9fam/nW3NN4wc+b8uXPp2PffFe5p9qRi16yUfQQILGcBxa44HcWu2KfVs4pdRU6xa/wI3X3v5rTn1cHGGypn3hoeSl8f/mrBfQttUOwWEnKeAIFOEFDsilNS7Ip9Wj2r2FXk/l/spqYm085nn0oTE+NN20b9IW622I2882b68otDTXs12qjYNZKxXiSwevUNqdTVlaZmfmVfLo8WbXWOwCURiPqZ0C48xa5dkrXXUewqHrO/buzru6mqMzrzBw9//H6ietzMi+6enrR+/Yaarb8d/yWNjY3VrLXjYLb8XHnV1dVLfXbo44t6n7X969OmezZXr3f815/TkW8Ozx3ffsemtP2lPdVzF15cs2pVWrGi+8Jhalexu/a669OaNX3V686++PGHYx3xxxNFjjUDOWirQKlUSvs/+jTN/gyWy6fm/lHW1jdwMQItCFzKz4QWbu+yf4titzQR/AcAAP//YCg3bwAAJAVJREFU7V0HvNXE0x1p0osivYMgIB2xIXb5oyKIoCAovUuvgvTee5WOFBEQULBgAxERQUBAlN5BQOkKCvrNCW7e3tzklpd738v73gy/R5LdzWZzNjc5Ozsze9unb1/7l2zkwuVz1H7U4345KVNl9EuThLhFIGu27DRuyjvmRU8cP0LdOzanv//+20wLd6dVu+5U6bGnzdMmjxtK679aax7b7aRPn4Gmz11uZr09eTR9vna1eZwYd2KDY2LEKdL3nCVrNho/dYFR7fXr16l+7WcjfQmpTxAQBCKMQN269SJc463qFiyI+T5G5QIer/Q2IXYe7yGb5tWoVY9efrWhkXPz5k3q1a01HTywz6ZkaEkpUtzOBG0ZpUyZyjhh86YNNHpYn6AnC7HzhSi2OPrWErdHadOmpatXr9K//9qO7+K2MS6uVrb8A9S15yCjhvggdv9fcHTRBYnm1HTp0tHly5cTzf1G80aF2EUH3URD7PIXKES33XZbyCheu/YnnTxxPGD53HnyUvLkKQKWiS3hypkzN2VhzVzmzFkp0x13Egjc2TOn6fSpE9SmQw+6K2t247pLFs2h5UvmB2wDMlOlTk158uSnzHdlpTvvykKpUqWmSxfO06+nT1KuPPno1debGXVc5LQu7RrRpUuXgtYZCWKXNGkyypsvf8Br/fnnH3Tq5ImAZVRmnrz5KVmyZHSDtZdHjx5WycY2W/achOcAcsedd/G9n6Bf9uwK6yUdDRyNBrn4D23KzvdmJ+fOnaFLFy+aWbfffjs9VbkqFSlawsDirizZ6N0Fs+j9pbe0XWZBm50778zMfVWQUqZKRRkyZKKzZ0/T0SOH+Ln8NWximJKfv/wFClKWLPyM8zMJuXDhd7rIf6f4d3fixDGbFtgn4Z5q1m5AVau/bBbo0bmFuW/dOcbPhZ12Oy5wTJ48OeXKnZfy5i9EWfg3fP63c4zhAeNZ/fOPP6xNDek4Y6Y7KD/XlztfAUqaJClBg3/k8AE68+vpgP2C90omPhdy7OgRxuQvSp8hA5Up9wBdv3aNU/+lrd9vMtJRpmChIlSocFG6fOkC3bhxg3Zs3/JfOeRGT25PmZLwPoTg/fQbYwbB+6fYvaX4PZmDf/PJ6ejh/XRg/146//tvRn6g//AsZ8iYybbI8WNH6a+/rpt5KPt0lWpUoGBhfmbvprTp0lO/nu3pZ353WCWa3wT0c15+v+Hdf8cdmbmNf9H587/RxfO/0+FD+/n3c97anKDHbp/HLFmyGnjgQngX4LlQgmep0N1FCe3+959/jPbu2/uT8VyqMkLsFBKR3SYaYjd38RrCByBU2fvzLur9ZruAxcdOnkvZsucKWKZOjacCvlytJ5e770GqVqMOFb6nuDXL7/jo4YPUvVNz+od/NE6SkV9ez75Qi57mjzk+XMFkxOC3+GX+bbBiRn4kiB0+LlNmLgl4vT27f6R+b3UIWEZlTpy+iIlCFgIxb1DneSM5W/YcVLteE7r/wUp+5P7K5Us0Y+pY2rRxnarCdhtNHG0vGEZimbIVqFuvIbZnrHr/XVo4b7qR91DFx6hu/eZ0Z+YsPmU/WbOCZr89wSdNP6jwQEVq1LwdZcx4iwToedjH4GD8qIH8Uf3FmuV3DK1m5Wer8zNe2/wg+BXihLO/nqIftn5Hy96dx4OMGGKqyqJNjzz2DA9W8hkf9nAGbWs+WEbzZk1WVZnbaOKId0/9xm/Qo09UpqRJk5rXVDvQmH752Uc0d+ZEgsYxFCnOpKZl224mMbae8/tvZ2kSm1Ts3rndmmUcv1ynAdV4+TVjf1DfLnQb/2vTsSelY8KkZOeOrTRicC9qzP2Ptuty6MBeGtyva1gDI/38UPeL3FOM+g259Xyu//JTmjpxBL1StxH977katu/03Tu30dgR/QK2CwPZF158xbYJwwa8Sdt+2GwM2jFYqPbSq37XGTO8L3337dd+50fjmwBi9NIrr9MTT1XhZyeZ3zWRgOcH/bGF392hDPQj9Ty24uev0uPPGG3q2aWlQaxTp07DmNWhKs/XIPzedYGCYuXyRfQeKyTQZiF2OjqR2xdi54BlfBC71xu1omervuTQIv/kQwf30ZudnDUT2XPkpL6DxjmOTP1rJK6vOR06uN8uyy/Ny8QOjW1WvwZrRbJRj74jCC8bJ8HLplv7JnT8+FHbItHG0faiYSQGIiRbNm+kqROGUafuA6ho8ZK2tToRO2g+6zVowR/QF23P0xNv3rxB82dPpY9Xv68n++yDfGG6tEy5+33SAx307NLKljCCaDz9vxcCneqY9/W6z2jSWH8iHC0cc+XOQ+079zE0446N+i/jFGsqRw3t7fgsohhwfIkJGT72wQgtPp4rli00tLLWa+vE7tOPVtEjjz5lO/g7d/ZXR/IY6oyB9drhHOvEDu+848eOGG0NVMcZHhgMHdDdcdYlELED6f/5px+pQ9e+BI22ncQVscM7dvDIqcZg1a4d1rRQzBAi+TzqxG7siP70065t1Kv/KMqdt4C1aT7H0yePoi/WrhFi54NK5A4SDbHr1X8kTyMUC4icrtELhdgNH/M2ZbVMgel14GK1X3wy4DVVJrQpbTv1UofGFtNFZ8+cIkxFpk6VxpiatY7YnBwW8MIfOX4m5cyV16fOC6y6P8+q+yScn4nV+ekz+DrDYJTfummdkLSMkSB2GI3qjiCqsTqOe3bvYI1dR5UVcKs0dig0c9o4qvNaE5PUXblymV/Yu3ha+waVKlPetClE2Y0bvmKt0wDs+khc4OhzwVgclCpdjjoycVOiYwfN1xmewi9eoozKNraYdjvGUycXL16gbzd8aeso06RFB562vaX1VCfjmfydp3czZrqTMEWmCzTHnds2dPyYPlPlBWrUzFcLjg/ROW4fpr4wxYXnQTdvcCJ29Ru3pieefs68vH7PSAyk9dr49Rc0bdJI81y1Ew0ccS/jpsznqf/M6jLGFu374+oVw8zCJ4MPftmzk/r0aG9NNo9BtBs0ecM8VjuYgkydJq2fdgn5g/p0pp0/blNFja1O7FQGNN3QsiRJkkQlmdur/PvBVJs+hYlp305tGpllorGjEzu9fpBWmKZAMBth1ShD2ziob1f9FHMfNsrP8UyGEv352bZ1s2EmgGdcF7w/TvLgD1OeK5ctMLRTej72I/lNQH3tu/SmBx56FLumoB2/8W+QX9L8/s5k9Ifqr2DELtLPo07soIkry4M2Rerwnv1p1w7WnF6iIjwDpc8UwOyiRcNaQuzMXo3sTqIhdqHA1rFbP8IUDyQUYmdXZ3VW29eu19jMCnUqts/A0axRKWWet2LpQlr1/mL644+rZlr69OnZlqgh4QOpZNPG9ca0gzpWW0zT9BowWh2yXcMpg7js3xczXQbSUoy1OCCU+ssaNnawuQkmkSB2Ttfo0WcYlSxd3siOzVSstd5PPlpJSxbMNBwFkAfP4mFMzJXDCDQlHd5oYD2N4gJHv4u6THj4kSeMKTW7ajDlvO7zj2k3v3B1OyJrWdgjjpow25w2xIt4+sSRtH3b98bUP6YT87JNFzQf95Ysa57uRJBRoPeAUWwTVdosu3D+2/TpmpXGtLlKxDNZuEhRKn9/ReODBs1IKHaqtes2puo1XzWqCfZxU9cKto0Ejs+9UJNea9jSvBRIw9LFcwybKGiKYYdUslQ5atGmm2Ebqgo6mUSAgMD7V/1e8fGcxv2yY/v3bH92wegv2JnWqtOQ4FCi5CBPk/fs2tpnwGYldqdOHqeBvTsZpK43vzuUHS/qwHsGnvJoc9tOb7FZwyNG1ZHCWrXTbmtH7E6eOEojh/TyGUQ88FAlgle6Pv0X6gxE05Yd6clnYgYKqh0YyHy8ejlt/nY94d0JMhmuxPabgPuYMf99837wLZg8bgj9sOU7H/MblCtZuiyVr1CRSpQqawzMndoY6edRJ3b6NX/atZ2mc5QERbzxnsXvvwDbaSpp0bAmPfecP+Yq381WvGIl3In5/MQnsZuz6EOTZOAl3IOnoOwEH77xU98xpwhg39Su5S07Gb3889VqGdNoKm34wB6GzZI61rewnWnZJmZki2kqTFcFk4RC7EAgVi1f7Hc7TVvxy/w/rQ9e4K+/UsXvxR0XOPo1zGWCHSH5lTUbs6aPYwKwNaTa23XuTQ8+fEtTAGzat6xnGq3rFcD2cMS4maZdFj58bZrVoXPnzurFjP05Cz9gx4vUxj5e/P17dfIrE9uEuCJ24eCIe53Av1Vls3bu7BkOS9SUrly54nebsEeCFlLJ3p93s41vW3VobmF/Cy20kvmzp9DqVUvVoblNkyYNDR093XxPIGMwa69+ZC2WEiux07WjLd7oQo89+T9VlJq8Vs1s90MVHzfIncqELSs0fdESO2LXsvHLtk4S1ncZbPImjx8WtGl2xG7Xjz+w1n9syI5bTheJLbGDo9eQUdPMapcunktL2eY0thKN59GO2H3/3Tc0bmR/H0cKtLlipSfojQ49zeb3ebMNlS8XMyg0MyKwI8ROiJ35GMUXsQNZW7hsrWkvE0xbOGbSXMqe45bTBj407Vq9bt6D2qnJ9jc1a9dXhzwl0YV27vjBPNZ3rERg4pjBtGH953oR2/2EQOzg7QmvTzuB8bTyBkZ+3ZqVjWlavWxc4KhfLxL71v4ESZ8+aZTp3RjsGpjWmvXOKvN5XPfFJzRlwnDH0+AM0bBpGzPf6VmbtWCVOS0O78ZWTV4xNEDmiS524oLYhYtj+QoPUuc3B5p3NXxQT9a2bDKPrTuDR0w2NRrQzjSq+4K1CA0dNZXysWcmxE4Lp5+gh4BBupUEWomdbjYC8ggSqUTPK1GyDPXsFzOVDVtWOwcXda7brZXYbdn8LWvr3rKtFhrNabOXmgMIOJh17dDUtqyeaCV2E8cM4nfgF3qRWO/Hltjly1/QIOfqwnDWgAY7thKN59FK7DBgw/Q3NLtWKXR3ERo4PMZpCQONEvcWtRaLyLEQOyF25oMUX8QODRjJWg+EHYFA6zFj6hjDuFRX/cO+7rlqNenV12JeVNvYc3AYa+OsgmmJ9l36mMnwmMLUhQoVoDIwJdm911CTKCJdeTepMk7bhEDsAk2FW22V7IhdXODohG9s063Erm2Luj4hBoLVa9UUDBvIXoI8hegk1vJOdp86KUFdsIGaN2tSSNP+TtdW6XFB7MLFUdfC4XfckInaNbaXdRI4qkBDrETXkqm0We+sNOzocPzBindpwdxbHs8qX99aCTocW+bMmGgW0YkdbDHbtKhn5j3Kno7wuIWgzQ1erWrm3VP0Xuo7eJx5HNfEDs4N8Gx2ki49BhKiC0BgF9j4tepORc10ndj9zuFUMOiIlMSW2MHhC4MhJXiG1n78AU/lzw4pHJU6T22j8TxaiV3T16s7eiPDRGD42BmqOYYGWYidCUdEd8TGToMzPomdnaE6HB2OHD7ExtApjLhb+ThWlZrWUc2GBx1U31ZB7KUJHPpDGdUiHy+GA/t+ZsPya6wmZ/settnD6B8aQyWIf9Wtw62YdirNaet1Yvcl25JN49AIThIKsYsLHJ3aF9t0t8TOSmZhi3fk0H7H5qRNl8FnYAD70MVsz2gVK94qH/aNMOyHRx1CVcQm+KsXiV19dnCo8p9HMRwbMH0YSODlC29fJdYBFoIgz5i/UmWTE4E2C/DO9DnLTAcp6yBQJ3bo3268eo0SLxO7YNq0Zq06sWPNs+pWjLBHwaaKdWKHKfM32JwgUhJbYofr698k1R44KUFbu4t/M7v5NwOHMDhDBZNIP4+4nk7sgpFoIXbBeihy+ULsNCz1H1Gw6VDtNJ/d2P6I8dIeMHQiZf8vEKdPpQ4HiHsFt3Fdq6cXtfNC1POt+/hh9u/VkcnkQWuW7bHXid1HHy7nuGCTbNuORCvRsNPYoVy0ccQ1Iiluid0LHGNO1wqH27bPPvnQ0Dhbz4PDRa06DYwpPn0woZfDRwteoXAcCqQl1M/BvheJna45cnLO0e8DS/rB+F/JkP7daMe2LerQCCit21whduBG9mgOJLDHRSBkyH4ODvtWt5gp84RK7Ky4WO8fzmt4Dytpz6YqyohfpVm3XiV2GHzDsUZ3hLG2HcGkEXdvycKZPs4k1nKRfh5Rv07sgikFhNhZeyR6x0LsNGzjk9ihGXdxYN1GzTtwnK8KWqv8d+Hh+sGKJfTZJx84kjp1FpwD4CQQSPAxhTfj+0vmhRXxP7EQO2AXTRwD9U1s8twSO+uUYLhtgAfy7OnjHU+DpzFisN1TrKSPRtl6wtqPV/FU7ZSQtBFeJHb9Bo81VvjAfZ0+dZzat4qxebXeK46thv9dObYiovkrsdq2BdNc4byJ0xeaMeisSwUmVGIH+zrY2TmJ/iygjJOjhX6+V4mdaiPCDj31zPPGiiVOgyJ4KL/DzjRr+btgJ5F+HnENIXZ2SMd/mhA7rQ/im9ihKbrHGZYY2rrlW8NOBPGjECj0NIckwFI+IGOhij5q//zT1XSDQySk4PhaqA9LTu1hg9czvCxUuGIldsFsX8KpPxLhTiKlsVPtjhaOqv5Ibd0SO2tIBESy3/fLTyE3D0vfOQV71ivBmpvFS5Tlv9JGaJusvDSUVUINgKt/zCMVgsMtjnoMMjiLNOfwDoEEwckRpFyJ1dsUgWVHjp+tsmkmr5ji9BFXhXSbvA9XvkfvzJmqsiihEjvEIMRshZPoJA1G/PVqVQ46ANbP8dJUrPUe4YVeileaKVXmPirBYYaspjkoj+XO9vy003qqT0y8SDyPuIAQOz+YPZEgxE7rBi8Qu85vDuB4RA8ZrRrA06KIN+ZGdM+qcAL9hnJNrEwwf8nHpo2e9cMRSh1OZbxG7KKJoxMGsU13S0hgeI5pGyWhekmr8rHdYs3g1xq28omLF2oAXJ3YYRCEj7lbcYsjprMxra0kkGE5yui2YZd5GbWm7G2qC+KVzV282vy9WZ0h9LLYh33opBnvmsnQokKbqiShEjuE/EDoDyeBJzI8QCGhkrSEQuz0e4bmDlO0r3OcRD1QPjTdCM5ulUg/j6hfiJ0VZW8cC7HT+iG+iR28oKaxsTMWZoY0a8BhBLQF3LWmhryrhy1w+sGHXJlNwcn84VBR9SNJHL1G7KKNow20sU5yS0hy5WLNEAcnVuIUU03lR3ILz++ps5aYmohQtW/WsDQIfhqbRdH1e3GLI6bP4BSlZArHU1vHcdXsBKt4jGJtHNY5huzfu4ft4fxXl5g6+z1zhQXY7XXh6Vp94XW9bmsYmqH9uxsBplWZhErsjh89TJ3bxQSBV/eDLXA0wp1wQFxIqM9uQiR2xg3yfxgQDRsT420K21R4slslGs+jEDsryt44FmKn9UN8EzvdEw3NQtwieLDi7y+2n7jEyz9hibFjRw/xeolHg04voI4J0xb4BCmFsTU+ltc5oOi1P/80lqaB/Q/WYLQLnIo6Akn/IeOo8D33mkXe6trKiNBuJvy3A+eQv1mTAkPfUMRrxC7aOIaCSahl3BISaGInz3yXvaZjlptDwNFvv1nn2IQCBe82FmVfveo9R+cbxLFCQO1AXq/w4h41fpbpRATP8BaNAnuTolHWe0ZYj0Dr1jreiJZhrTPccCe6lhfVIjZd947NbEPPNG/dmR7nRd6VIG4g4gdapVP3/nTf/Q+byU5auxw5c9GQkdPMZd8Q77Jjm4Y+8cUSKrHDzTvFSqxcpRo1bBYT2Bne2fDSDiZeJHYgqblz5zV+TwgS7iRYhm/qrPfMbMRKRMxEq0TjeRRiZ0XZG8dC7LR+CIfYQbuWiX9QVqlWsy7Bu01JZ36Z6l6rsG/79fQple2zta5y4JNpOcAHb/Omb+iD9xfRWXbPtxNoP6bNWUpp06azy/ZJU96IWJgZwYn1NvsUtBxgzcUatWLiX8GzFtHaf2Q7QNSBtWof5Qj2lR57xlj6bOv3vkbPeCmlYSyt0qPvcHNtQRBa2I3ocpU/khd4zVur6GvFRsrGLi5wtN5HOMdwutGXUXq40pNUgxeJV4LR+xmHZ+4k22za9bWdswjsudZ9/pHhYIO1hhEkOwdr9x59vLK5Fu0AXpJq987t6tI+W9h74WP14/YfaNM3X7JjwEEeWJw1gtsivWDBwlT1xTo+zkNbNm804i/6VGRzYNVaIPYaAlNv5OtA6w17vrwcLqjCg5VoB3sQol6rRANH/Z2C6x3je/549Qra+8suXgLsPBW6uyiVYHspFRYFZbBcVue2jW3taLGMG1aU0A3oYQKxi2MCHtj/s6E9L1zkXsPjOyeTAiV2jhYJmdghBA+mG7d8t8FYJhD2vlg7+JW6jUxsMIBt3eRlvwErsMueIyfdxv+UwMEMzjxKOtksL4g8hBVxskeO9DcBfY1lDzEg+H7TBg5rtYG/HSf4fX/WiC2IwXIJXo4OnuY5cuZRTWfv2Nm0/L13zGN9J9LPoxA7HV3v7Aux0/pC1xIhntaA3p21XN/dQSMmUcFC9/gmhnBkDQSqn4IXDpbygXdcqIIXzdgR/clKmNT5CB7bq/8oM6ipSg+0xb0PHfCmETsvUDnkYekirF2ZJgTyaF3/Ekvc4GOvx9oLdj2V/xXHqJtqE6MuGsQO14w2juq+wt1aDerDPb9+7WcNDa71PDyLQ0ZOMVc5sOY7HQcjdlik3iogljpR0fOd4jTqZdT+m72HGkbl6thpixUksGyeLtHCEdPawzn4eDjP+OhhfXjQtkFvns9+m449DQ2lT2KAA8So696phR+BT8jETr9dzAJgYGAVJ+9sa7xA63mBjkGee3aJWfpNLxvpb4Iidvo11L7TbwaEF8oEJ/IZ6edRiJ3qEW9thdhp/aFPtwULbjts9DRDA6CdHtJuIGKHCvCBK1m6HAclTmloYVLwEjlp06WnrFlzGAvXI0gxjnXBi63jG/X9VpVQZbJlz2FozlKkSGHUiZdg5sxZKQt7IWLkmidvAb8P60ccpX6uFqVe1WW3xRRys9adeAHyZHbZZpqV2Fkjq5sFQ9iJa2KHJkUbxxBu26+IVVPlVyBIghOxw2nQYLXgNYSLlygTpJZb2dCsYhH5o2wDZSe6h6ZdvjUNWgdoH0IVtHcQk1F9CtnuXDtiF00cKzxQkTDVGmzwc4W13VMnDAsYygP3A01Ns9ZdCPUGE5hzTJ80wtBqWcsmVGIHu7nC9xS33o7P8aaN6w3ybhe4F9pRBOuNjQQidpH+JgQidnZthwfw2BH9bAPW6+Uj+TwKsdOR9c6+ELv/+gIhF6DZUrJo/gxauXyROvTbWm3L/Ao4JFiDhDoUc0yG/VP5Cg/zGqdNzcCjKAwNBD5YsREQlipVaxLsU5TAFqpdy5jpPJXutIVGCx8vTHdZtS8Iq7Jh3ee05sOlPs4gIJgz5q0wnUWc6rZLR9+gj6wyasIsg8QiPZyp2CuXLxleiHbTktZrOB1HAkenup3Scc0xk+b5Ye5UXk/HtHmzBi/52F3p+dhHXyJ+FkJxwPPOqnmC4f6BfXt4qaNVhI+pkyE/6sIg4v4HH6X7ebm7/LziiRJMmWGNTwjwx+LrCLFiF7JBneO0Bel5+dXGxsoD+K1Y5TcO74MYkFb7u2jjmDnzXWz71Y6KFithaM+hWVHT5zCr2PvLHpo3cyKHHzprbbLjMTRP6Jds3C/oJ1Un+uA42+F+zmYV6Bcn0ddKRiBkBP5VUpqnh7uzBhQSaEkxDCrh5IU+jJZY14rtxU4lOTiQe3U2e4E5AOzP0NfAAP0LB5X3Fs3x01Cq9sGWEe+q2IiT/RrqivQ3AfdTuEgxw3zgfjYhUI41uJbqa+yjv9fzPa9YtsDWfhNlrBKp5xErpeA5hIQboLhbhyZU8eEYe1FrG90cy1qxslYsL7mTgXr3H22u1Qp7s45sYxEsWrmbB8/tuU9XrkqNW8TYncGeCAvex1bwEpnC3ogZM96yG8Tor27NZ8KuDmQNBr9JkiTlF+2tcAO/83JKbghT2I2IxxMihWM83oLjpfHxxAcV2mR4biNQNtYejk3fpmSvxYyZMlGGDJnYhuiK8dzBVhQ2d3ZaFsdGOWSgH6DBAxkFaYSd3fnfzznaozpUE5VktCtlqlSseUtPJ44fNWwM3VwI95crdz7+2F/j31wSrvMYk/Ubbqr01Ll2xG4few2jjxH7MEOGjAbROc82t3Z2t566GReNwaAF7+cMbJd8mbXjMGvAoBnv13DimlqbEOnn0Vp/oOO6desFyo51nhC7RE7sSpQqS42atjW98PAkrf9qLU0ed2u0GusnK4onYtqodbselIeNa5UEi8auytltoTnAyF83PD7MXrKwyxEJHQHBMXSspKQgECoCTsQu1POlnHcREGIXnb5JVFOxWKsSxv7wICpavBSVu+8BKlS4mA+yWKwbITugiYhPAUmAJ19a/kvPWg1MOeTkUXmx4iUpd578Pk3DVE4nNpi9evWqT7p+gNEt7h0LtqdjGz2o9eGxmidfQSpVuryf8fHC+W/TquWL9SpknxEQHOUxEATiFgEhdnGLd1xeTYhddNBONMRuxLgZfoTICikCfg7u1zXOp2uwjFgjtlVIniw5JeXpLhBQEIhQBK7wMFg/eGCfT3F4NIIM3qovmZ9tlE9hywE0lgimGpspNktVCf5QcEzwXSg3kMAREGKXwDswQPOF2AUAx0VWoiF2cxevMQ20rXjBpgfG1CvYRi2Q1st6XqSOq1Z/herWbxZWdTBW/mTNCtaqLfSL04SKAt2v04Xg8bWEbfV2bN/qVCTRpQuOia7L5YY9hoAQO491SASbI8QugmBqVSVaYgdPql/27KQd276njV9/Ea9Tr6EQO0y3nmSN4pEjh4xgpFhDFt5qThKMkIDMwjkEhtZ7f95FO3ds4RUtjjhVl2jTBcdE2/Vy4x5BwLrEHUI7nTxx3COtk2a4QUCInRv0nM91JHZ/3bhOzQaW9zszZaqYZYb8Mj2cgKC/WEYLi2tfunTBcAuPpot+OFDAGaIgR6C/ceNvunnjprFFGIHLHILj8uWLdJE9oHAcjjzGqz1AbrIrPOoFkb169TIhrMclYMBegjLVGhxRwTE4RlJCEIg2ArA3hnkK3lmBlqSLdjuk/sgiIMQusniq2hyJ3T/8A2rUL2aJFXVCQiV2qv2yFQQEAUFAEBAEBIH4R0CIXXT6QIhddHCVWgUBQUAQEAQEAUFAEIhzBITYxTnkckFBQBAQBAQBQUAQEASig4AQu+jgKrUKAoKAICAICAKCgCAQ5wgIsYtzyOWCgoAgIAgIAoKAICAIRAcBR2L3982/qemAsn5XFecJP0gkQRAQBAQBQUAQEAQEAU8g4EjsLlw+R+1HPe7XSCF2fpBIgiAgCAgCgoAgIAgIAp5AQIidJ7pBGiEICAKCgCAgCAgCgoB7BITYucdQahAEBAFBQBAQBAQBQcATCAix80Q3SCMEAUFAEBAEBAFBQBBwj4AQO/cYSg2CgCAgCAgCgoAgIAh4AgEhdp7oBmmEICAICAKCgCAgCAgC7hEQYuceQ6lBEBAEBAFBQBAQBAQBTyAgxM4T3SCNEAQEAUFAEBAEBAFBwD0CQuzcYyg1CAKCgCAgCAgCgoAg4AkEhNh5ohukEYKAICAICAKCgCAgCLhHQIidewylBkFAEBAEBAFBQBAQBDyBgBA7T3SDNEIQEAQEAUFAEBAEBAH3CAixc4+h1CAICAKCgCAgCAgCgoAnEBBi54lukEYIAoKAICAICAKCgCDgHgEhdu4xlBoEAUFAEBAEBAFBQBDwBAJC7DzRDdIIQUAQEAQEAUFAEBAE3CMgxM49hlKDICAICAKCgCAgCAgCnkBAiJ0nukEaIQgIAoKAICAICAKCgHsEhNi5x1BqEAQEAUFAEBAEBAFBwBMICLHzRDdIIwQBQUAQEAQEAUFAEHCPgBA79xhKDYKAICAICAKCgCAgCHgCASF2nugGaYQgIAgIAoKAICAICALuERBi5x5DqUEQEAQEAUFAEBAEBAFPICDEzhPdII0QBAQBQUAQEAQEAUHAPQJC7NxjKDUIAoKAICAICAKCgCDgCQSE2HmiG6QRgoAgIAgIAoKAICAIuEdAiJ17DKUGQUAQEAQEAUFAEBAEPIGAEDtPdIM0QhAQBAQBQUAQEAQEAfcICLFzj6HUIAgIAoKAICAICAKCgCcQEGLniW6QRggCgoAgIAgIAoKAIOAeASF27jGUGgQBQUAQEAQEAUFAEPAEAkLsPNEN0ghBQBAQBAQBQUAQEATcIyDEzj2GUoMgIAgIAoKAICAICAKeQECInSe6QRohCAgCgoAgIAgIAoKAewQcid1fN65Ts4Hl/a6QMlVGvzRJEAQEAUFAEBAEBAFBQBCIfwT+D/zF7ZhlIKO3AAAAAElFTkSuQmCC\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_screenshot\",\"description\":\"Capture a screenshot of the current screen.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read images carefully. Reply only with the visible text, lowercase, no punctuation.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use the read_screenshot tool, then reply with the words shown.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_screenshot_1\",\"name\":\"read_screenshot\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_screenshot_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"Image read successfully\"},{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnYAAACKCAYAAAAnmweyAAACKWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjYzMCIKICAgZXhpZjpVc2VyQ29tbWVudD0iU2NyZWVuc2hvdCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjEzOCIKICAgdGlmZjpZUmVzb2x1dGlvbj0iMTQ0LzEiCiAgIHRpZmY6WFJlc29sdXRpb249IjE0NC8xIgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+at0SpgAACrhpQ0NQSUNDIFByb2ZpbGUAAEiJlZcHUFNZF8fvey+dhJYQASmh994CSAmhBVCQDjZCEiAQQkxBwa4sruBaUBHBsqKrIgo2qg0RxbYo9r4gi4iyLhZsqHwPGMLufvN933xn5s75zXnn/u+5d959cx4AFFOuRCKC1QHIFsul0SEBjMSkZAb+JcACTUACnoDK5ckkrKioCIDahP+7fbgLoFF/y25U69+f/1fT4AtkPACgKJRT+TJeNsonAIABTyKVA4CgDEwWyCWjfB9lmhQtEOWBUU4fY8yoDi11nGljObHRbJQtASCQuVxpOgBkVzTOyOWlozrkWJQdxXyhGOUClH2zs3P4KLehbInmSFAe1Wem/kUn/W+aqUpNLjddyeN7GTNCoFAmEXHz/s/j+N+WLVJMrGGBDnKGNDQa9Xrouf2elROuZHHqjMgJFvLH8sc4QxEaN8E8GTt5gmWiGM4E87mB4Uod0YyICU4TBitzhHJO7AQLZEExEyzNiVaumyZlsyaYK52sQZEVp4xnCDhK/fyM2IQJzhXGz1DWlhUTPpnDVsalimjlXgTikIDJdYOV55At+8vehRzlXHlGbKjyHLiT9QvErElNWaKyNr4gMGgyJ06ZL5EHKNeSiKKU+QJRiDIuy41RzpWjL+fk3CjlGWZyw6ImGMQAOVAAPhCCHMAAgaiXAQkQAS7IkwsWykc3xM6R5EmF6RlyBgu9dQIGR8yzt2U4Ozq7AzB6h8dfkXf0sbsJ0a9MxlZVAeDTNDIycnIyFnYDgKMpAJDqJmOWcwBQ7wPg0imeQpo7Hhu7a1j0y6AGaEAHGAATYAnsgDNwB97AHwSBMBAJYkESmAt4IANkAylYABaDFaAQFIMNYAsoB7vAHnAAHAbHQAM4Bc6Bi+AquAHugEegC/SCV2AQfADDEAThIQpEhXQgQ8gMsoGcISbkCwVBEVA0lASlQOmQGFJAi6FVUDFUApVDu6Eq6CjUBJ2DLkOd0AOoG+qH3kJfYAQmwzRYHzaHHWAmzILD4Vh4DpwOz4fz4QJ4HVwGV8KH4Hr4HHwVvgN3wa/gIQQgKggdMULsECbCRiKRZCQNkSJLkSKkFKlEapBmpB25hXQhA8hnDA5DxTAwdhhvTCgmDsPDzMcsxazFlGMOYOoxbZhbmG7MIOY7loLVw9pgvbAcbCI2HbsAW4gtxe7D1mEvYO9ge7EfcDgcHWeB88CF4pJwmbhFuLW4HbhaXAuuE9eDG8Lj8Tp4G7wPPhLPxcvxhfht+EP4s/ib+F78J4IKwZDgTAgmJBPEhJWEUsJBwhnCTUIfYZioTjQjehEjiXxiHnE9cS+xmXid2EscJmmQLEg+pFhSJmkFqYxUQ7pAekx6p6KiYqziqTJTRaiyXKVM5YjKJZVulc9kTbI1mU2eTVaQ15H3k1vID8jvKBSKOcWfkkyRU9ZRqijnKU8pn1SpqvaqHFW+6jLVCtV61Zuqr9WIamZqLLW5avlqpWrH1a6rDagT1c3V2epc9aXqFepN6vfUhzSoGk4akRrZGms1Dmpc1nihidc01wzS5GsWaO7RPK/ZQ0WoJlQ2lUddRd1LvUDtpeFoFjQOLZNWTDtM66ANamlquWrFay3UqtA6rdVFR+jmdA5dRF9PP0a/S/8yRX8Ka4pgypopNVNuTvmoPVXbX1ugXaRdq31H+4sOQydIJ0tno06DzhNdjK617kzdBbo7dS/oDkylTfWeyptaNPXY1Id6sJ61XrTeIr09etf0hvQN9EP0Jfrb9M/rDxjQDfwNMg02G5wx6DekGvoaCg03G541fMnQYrAYIkYZo40xaKRnFGqkMNpt1GE0bGxhHGe80rjW+IkJyYRpkmay2aTVZNDU0HS66WLTatOHZkQzplmG2VazdrOP5hbmCearzRvMX1hoW3As8i2qLR5bUiz9LOdbVlretsJZMa2yrHZY3bCGrd2sM6wrrK/bwDbuNkKbHTadtlhbT1uxbaXtPTuyHcsu167artuebh9hv9K+wf61g6lDssNGh3aH745ujiLHvY6PnDSdwpxWOjU7vXW2duY5VzjfdqG4BLssc2l0eeNq4ypw3el6343qNt1ttVur2zd3D3epe417v4epR4rHdo97TBozirmWeckT6xnguczzlOdnL3cvudcxrz+97byzvA96v5hmMU0wbe+0Hh9jH67Pbp8uX4Zviu/Pvl1+Rn5cv0q/Z/4m/nz/ff59LCtWJusQ63WAY4A0oC7gI9uLvYTdEogEhgQWBXYEaQbFBZUHPQ02Dk4Prg4eDHELWRTSEooNDQ/dGHqPo8/hcao4g2EeYUvC2sLJ4THh5eHPIqwjpBHN0+HpYdM3TX88w2yGeEZDJIjkRG6KfBJlETU/6uRM3MyomRUzn0c7RS+Obo+hxsyLORjzITYgdn3sozjLOEVca7xa/Oz4qviPCYEJJQldiQ6JSxKvJukmCZMak/HJ8cn7kodmBc3aMqt3ttvswtl351jMWTjn8lzduaK5p+epzePOO56CTUlIOZjylRvJreQOpXJSt6cO8ti8rbxXfH/+Zn6/wEdQIuhL80krSXuR7pO+Kb0/wy+jNGNAyBaWC99khmbuyvyYFZm1P2tElCCqzSZkp2Q3iTXFWeK2HIOchTmdEhtJoaRrvtf8LfMHpeHSfTJINkfWKKehzdI1haXiB0V3rm9uRe6nBfELji/UWCheeC3POm9NXl9+cP4vizCLeItaFxstXrG4ewlrye6l0NLUpa3LTJYVLOtdHrL8wArSiqwVv650XFmy8v2qhFXNBfoFywt6fgj5obpQtVBaeG+19+pdP2J+FP7YscZlzbY134v4RVeKHYtLi7+u5a298pPTT2U/jaxLW9ex3n39zg24DeINdzf6bTxQolGSX9Kzafqm+s2MzUWb32+Zt+VyqWvprq2krYqtXWURZY3bTLdt2Pa1PKP8TkVARe12ve1rtn/cwd9xc6f/zppd+ruKd335Wfjz/d0hu+srzStL9+D25O55vjd+b/svzF+q9unuK973bb94f9eB6ANtVR5VVQf1Dq6vhqsV1f2HZh+6cTjwcGONXc3uWnpt8RFwRHHk5dGUo3ePhR9rPc48XnPC7MT2OmpdUT1Un1c/2JDR0NWY1NjZFNbU2uzdXHfS/uT+U0anKk5rnV5/hnSm4MzI2fyzQy2SloFz6ed6Wue1PjqfeP5228y2jgvhFy5dDL54vp3VfvaSz6VTl70uN11hXmm46n61/prbtbpf3X6t63DvqL/ucb3xhueN5s5pnWdu+t08dyvw1sXbnNtX78y403k37u79e7Pvdd3n33/xQPTgzcPch8OPlj/GPi56ov6k9Kne08rfrH6r7XLvOt0d2H3tWcyzRz28nle/y37/2lvwnPK8tM+wr+qF84tT/cH9N17Oetn7SvJqeKDwD40/tr+2fH3iT/8/rw0mDva+kb4Zebv2nc67/e9d37cORQ09/ZD9Yfhj0SedTwc+Mz+3f0n40je84Cv+a9k3q2/N38O/Px7JHhmRcKXcsVYAQQeclgbA2/0AUJIAoKI9BGnWeI89ZtD4f8EYgf/E4334mKGdSw3qRtsjdgsAR9BhvhwANX8ARlujWH8Au7gox0Q/PNa7jxoO/Yup8UK0Vjk9ta0C/7Txvv4vdf/TA6Xq3/y/AOOhDyne6KAWAAAAimVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA5KGAAcAAAASAAAAeKACAAQAAAABAAACdqADAAQAAAABAAAAigAAAABBU0NJSQAAAFNjAAAAAAAAAADxh4F4AAAAHGlET1QAAAACAAAAAAAAAEUAAAAoAAAARQAAAEUAAAbT33OL9AAABp9JREFUeAHs3F9olWUcB/DnLHCT/rgKQxbhtLwpkIqsLrxZQfQXKggEA/tjZuCFCRHR1Wg3XiyhoKgVeKFd1k1CFNGNRAhhkFAQFBlSkLhjbqtNbW3jeOB0dt6dHc905/d8dnXe5332nvf3+b7jfGXMUt+NG6aTLwIECBAgQIAAgY4XKCl2HZ+hAQgQIECAAAECcwKKnQeBAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEoPT+4NHp+WY58edPaeST1+c7ZY0AAQIECBAgQGAZCpQ+H/ln3mJXPnMy7R4eWIa37JYIECBAgAABAgTmE1Ds5lOxRoAAAQIECBDoQIFFF7uelb0dOKZbJkCAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATgYbFburcZNoxdFcdQ8/K3ro1CwQIECBAgAABApdfoGGx+3d6Oj03uLHuDhW7OhILBAgQIECAAIFlIaDYLYsY3AQBAgQIEMhLYOvWp5dk4IMHDyzJdTvloopdpyTlPgkQIECAQCABxW5pwlTslsbVVQkQIECAAIECAcWuAOciTil2F4HnWwkQIECAAIHWBBS71twW+i7FbiEh5wkQIECAAIG2Cyh2bSedu2DDYnf2/Nn0wht31r2rv4qtI7FAgAABAgQILFJAsVskWJPbGxa78pmTaffwQN1lFLs6EgsECBAgQIDAIgUUu0WCNbldsatA3XrbxnTfA4/VsH343r7098REzVrRQXd3d3p+58upq+uK6rYD+99N5dFT1WMvFhboX3dzevTxLdWN4+Nn0v6Rt9P0zP+t6IsAAQKdIuAzoTgpxa7Yp9Wzil1FbuD+B9OLu16pcdzxzJPpr9Ona9aKDtb2r0t7931Qs2Xv0Gvp6LdHatYcFAs89MgTadv2XTWbtm15OE1OTtasOSBAgMByFvCZUJyOYlfs0+pZxa4ip9g1foRW9fam/nW3NN4wc+b8uXPp2PffFe5p9qRi16yUfQQILGcBxa44HcWu2KfVs4pdRU6xa/wI3X3v5rTn1cHGGypn3hoeSl8f/mrBfQttUOwWEnKeAIFOEFDsilNS7Ip9Wj2r2FXk/l/spqYm085nn0oTE+NN20b9IW622I2882b68otDTXs12qjYNZKxXiSwevUNqdTVlaZmfmVfLo8WbXWOwCURiPqZ0C48xa5dkrXXUewqHrO/buzru6mqMzrzBw9//H6ietzMi+6enrR+/Yaarb8d/yWNjY3VrLXjYLb8XHnV1dVLfXbo44t6n7X969OmezZXr3f815/TkW8Ozx3ffsemtP2lPdVzF15cs2pVWrGi+8Jhalexu/a669OaNX3V686++PGHYx3xxxNFjjUDOWirQKlUSvs/+jTN/gyWy6fm/lHW1jdwMQItCFzKz4QWbu+yf4titzQR/AcAAP//YCg3bwAAJAVJREFU7V0HvNXE0x1p0osivYMgIB2xIXb5oyKIoCAovUuvgvTee5WOFBEQULBgAxERQUBAlN5BQOkKCvrNCW7e3tzklpd738v73gy/R5LdzWZzNjc5Ozsze9unb1/7l2zkwuVz1H7U4345KVNl9EuThLhFIGu27DRuyjvmRU8cP0LdOzanv//+20wLd6dVu+5U6bGnzdMmjxtK679aax7b7aRPn4Gmz11uZr09eTR9vna1eZwYd2KDY2LEKdL3nCVrNho/dYFR7fXr16l+7WcjfQmpTxAQBCKMQN269SJc463qFiyI+T5G5QIer/Q2IXYe7yGb5tWoVY9efrWhkXPz5k3q1a01HTywz6ZkaEkpUtzOBG0ZpUyZyjhh86YNNHpYn6AnC7HzhSi2OPrWErdHadOmpatXr9K//9qO7+K2MS6uVrb8A9S15yCjhvggdv9fcHTRBYnm1HTp0tHly5cTzf1G80aF2EUH3URD7PIXKES33XZbyCheu/YnnTxxPGD53HnyUvLkKQKWiS3hypkzN2VhzVzmzFkp0x13Egjc2TOn6fSpE9SmQw+6K2t247pLFs2h5UvmB2wDMlOlTk158uSnzHdlpTvvykKpUqWmSxfO06+nT1KuPPno1debGXVc5LQu7RrRpUuXgtYZCWKXNGkyypsvf8Br/fnnH3Tq5ImAZVRmnrz5KVmyZHSDtZdHjx5WycY2W/achOcAcsedd/G9n6Bf9uwK6yUdDRyNBrn4D23KzvdmJ+fOnaFLFy+aWbfffjs9VbkqFSlawsDirizZ6N0Fs+j9pbe0XWZBm50778zMfVWQUqZKRRkyZKKzZ0/T0SOH+Ln8NWximJKfv/wFClKWLPyM8zMJuXDhd7rIf6f4d3fixDGbFtgn4Z5q1m5AVau/bBbo0bmFuW/dOcbPhZ12Oy5wTJ48OeXKnZfy5i9EWfg3fP63c4zhAeNZ/fOPP6xNDek4Y6Y7KD/XlztfAUqaJClBg3/k8AE68+vpgP2C90omPhdy7OgRxuQvSp8hA5Up9wBdv3aNU/+lrd9vMtJRpmChIlSocFG6fOkC3bhxg3Zs3/JfOeRGT25PmZLwPoTg/fQbYwbB+6fYvaX4PZmDf/PJ6ejh/XRg/146//tvRn6g//AsZ8iYybbI8WNH6a+/rpt5KPt0lWpUoGBhfmbvprTp0lO/nu3pZ353WCWa3wT0c15+v+Hdf8cdmbmNf9H587/RxfO/0+FD+/n3c97anKDHbp/HLFmyGnjgQngX4LlQgmep0N1FCe3+959/jPbu2/uT8VyqMkLsFBKR3SYaYjd38RrCByBU2fvzLur9ZruAxcdOnkvZsucKWKZOjacCvlytJ5e770GqVqMOFb6nuDXL7/jo4YPUvVNz+od/NE6SkV9ez75Qi57mjzk+XMFkxOC3+GX+bbBiRn4kiB0+LlNmLgl4vT27f6R+b3UIWEZlTpy+iIlCFgIxb1DneSM5W/YcVLteE7r/wUp+5P7K5Us0Y+pY2rRxnarCdhtNHG0vGEZimbIVqFuvIbZnrHr/XVo4b7qR91DFx6hu/eZ0Z+YsPmU/WbOCZr89wSdNP6jwQEVq1LwdZcx4iwToedjH4GD8qIH8Uf3FmuV3DK1m5Wer8zNe2/wg+BXihLO/nqIftn5Hy96dx4OMGGKqyqJNjzz2DA9W8hkf9nAGbWs+WEbzZk1WVZnbaOKId0/9xm/Qo09UpqRJk5rXVDvQmH752Uc0d+ZEgsYxFCnOpKZl224mMbae8/tvZ2kSm1Ts3rndmmUcv1ynAdV4+TVjf1DfLnQb/2vTsSelY8KkZOeOrTRicC9qzP2Ptuty6MBeGtyva1gDI/38UPeL3FOM+g259Xyu//JTmjpxBL1StxH977katu/03Tu30dgR/QK2CwPZF158xbYJwwa8Sdt+2GwM2jFYqPbSq37XGTO8L3337dd+50fjmwBi9NIrr9MTT1XhZyeZ3zWRgOcH/bGF392hDPQj9Ty24uev0uPPGG3q2aWlQaxTp07DmNWhKs/XIPzedYGCYuXyRfQeKyTQZiF2OjqR2xdi54BlfBC71xu1omervuTQIv/kQwf30ZudnDUT2XPkpL6DxjmOTP1rJK6vOR06uN8uyy/Ny8QOjW1WvwZrRbJRj74jCC8bJ8HLplv7JnT8+FHbItHG0faiYSQGIiRbNm+kqROGUafuA6ho8ZK2tToRO2g+6zVowR/QF23P0xNv3rxB82dPpY9Xv68n++yDfGG6tEy5+33SAx307NLKljCCaDz9vxcCneqY9/W6z2jSWH8iHC0cc+XOQ+079zE0446N+i/jFGsqRw3t7fgsohhwfIkJGT72wQgtPp4rli00tLLWa+vE7tOPVtEjjz5lO/g7d/ZXR/IY6oyB9drhHOvEDu+848eOGG0NVMcZHhgMHdDdcdYlELED6f/5px+pQ9e+BI22ncQVscM7dvDIqcZg1a4d1rRQzBAi+TzqxG7siP70065t1Kv/KMqdt4C1aT7H0yePoi/WrhFi54NK5A4SDbHr1X8kTyMUC4icrtELhdgNH/M2ZbVMgel14GK1X3wy4DVVJrQpbTv1UofGFtNFZ8+cIkxFpk6VxpiatY7YnBwW8MIfOX4m5cyV16fOC6y6P8+q+yScn4nV+ekz+DrDYJTfummdkLSMkSB2GI3qjiCqsTqOe3bvYI1dR5UVcKs0dig0c9o4qvNaE5PUXblymV/Yu3ha+waVKlPetClE2Y0bvmKt0wDs+khc4OhzwVgclCpdjjoycVOiYwfN1xmewi9eoozKNraYdjvGUycXL16gbzd8aeso06RFB562vaX1VCfjmfydp3czZrqTMEWmCzTHnds2dPyYPlPlBWrUzFcLjg/ROW4fpr4wxYXnQTdvcCJ29Ru3pieefs68vH7PSAyk9dr49Rc0bdJI81y1Ew0ccS/jpsznqf/M6jLGFu374+oVw8zCJ4MPftmzk/r0aG9NNo9BtBs0ecM8VjuYgkydJq2fdgn5g/p0pp0/blNFja1O7FQGNN3QsiRJkkQlmdur/PvBVJs+hYlp305tGpllorGjEzu9fpBWmKZAMBth1ShD2ziob1f9FHMfNsrP8UyGEv352bZ1s2EmgGdcF7w/TvLgD1OeK5ctMLRTej72I/lNQH3tu/SmBx56FLumoB2/8W+QX9L8/s5k9Ifqr2DELtLPo07soIkry4M2Rerwnv1p1w7WnF6iIjwDpc8UwOyiRcNaQuzMXo3sTqIhdqHA1rFbP8IUDyQUYmdXZ3VW29eu19jMCnUqts/A0axRKWWet2LpQlr1/mL644+rZlr69OnZlqgh4QOpZNPG9ca0gzpWW0zT9BowWh2yXcMpg7js3xczXQbSUoy1OCCU+ssaNnawuQkmkSB2Ttfo0WcYlSxd3siOzVSstd5PPlpJSxbMNBwFkAfP4mFMzJXDCDQlHd5oYD2N4gJHv4u6THj4kSeMKTW7ajDlvO7zj2k3v3B1OyJrWdgjjpow25w2xIt4+sSRtH3b98bUP6YT87JNFzQf95Ysa57uRJBRoPeAUWwTVdosu3D+2/TpmpXGtLlKxDNZuEhRKn9/ReODBs1IKHaqtes2puo1XzWqCfZxU9cKto0Ejs+9UJNea9jSvBRIw9LFcwybKGiKYYdUslQ5atGmm2Ebqgo6mUSAgMD7V/1e8fGcxv2yY/v3bH92wegv2JnWqtOQ4FCi5CBPk/fs2tpnwGYldqdOHqeBvTsZpK43vzuUHS/qwHsGnvJoc9tOb7FZwyNG1ZHCWrXTbmtH7E6eOEojh/TyGUQ88FAlgle6Pv0X6gxE05Yd6clnYgYKqh0YyHy8ejlt/nY94d0JMhmuxPabgPuYMf99837wLZg8bgj9sOU7H/MblCtZuiyVr1CRSpQqawzMndoY6edRJ3b6NX/atZ2mc5QERbzxnsXvvwDbaSpp0bAmPfecP+Yq381WvGIl3In5/MQnsZuz6EOTZOAl3IOnoOwEH77xU98xpwhg39Su5S07Gb3889VqGdNoKm34wB6GzZI61rewnWnZJmZki2kqTFcFk4RC7EAgVi1f7Hc7TVvxy/w/rQ9e4K+/UsXvxR0XOPo1zGWCHSH5lTUbs6aPYwKwNaTa23XuTQ8+fEtTAGzat6xnGq3rFcD2cMS4maZdFj58bZrVoXPnzurFjP05Cz9gx4vUxj5e/P17dfIrE9uEuCJ24eCIe53Av1Vls3bu7BkOS9SUrly54nebsEeCFlLJ3p93s41vW3VobmF/Cy20kvmzp9DqVUvVoblNkyYNDR093XxPIGMwa69+ZC2WEiux07WjLd7oQo89+T9VlJq8Vs1s90MVHzfIncqELSs0fdESO2LXsvHLtk4S1ncZbPImjx8WtGl2xG7Xjz+w1n9syI5bTheJLbGDo9eQUdPMapcunktL2eY0thKN59GO2H3/3Tc0bmR/H0cKtLlipSfojQ49zeb3ebMNlS8XMyg0MyKwI8ROiJ35GMUXsQNZW7hsrWkvE0xbOGbSXMqe45bTBj407Vq9bt6D2qnJ9jc1a9dXhzwl0YV27vjBPNZ3rERg4pjBtGH953oR2/2EQOzg7QmvTzuB8bTyBkZ+3ZqVjWlavWxc4KhfLxL71v4ESZ8+aZTp3RjsGpjWmvXOKvN5XPfFJzRlwnDH0+AM0bBpGzPf6VmbtWCVOS0O78ZWTV4xNEDmiS524oLYhYtj+QoPUuc3B5p3NXxQT9a2bDKPrTuDR0w2NRrQzjSq+4K1CA0dNZXysWcmxE4Lp5+gh4BBupUEWomdbjYC8ggSqUTPK1GyDPXsFzOVDVtWOwcXda7brZXYbdn8LWvr3rKtFhrNabOXmgMIOJh17dDUtqyeaCV2E8cM4nfgF3qRWO/Hltjly1/QIOfqwnDWgAY7thKN59FK7DBgw/Q3NLtWKXR3ERo4PMZpCQONEvcWtRaLyLEQOyF25oMUX8QODRjJWg+EHYFA6zFj6hjDuFRX/cO+7rlqNenV12JeVNvYc3AYa+OsgmmJ9l36mMnwmMLUhQoVoDIwJdm911CTKCJdeTepMk7bhEDsAk2FW22V7IhdXODohG9s063Erm2Luj4hBoLVa9UUDBvIXoI8hegk1vJOdp86KUFdsIGaN2tSSNP+TtdW6XFB7MLFUdfC4XfckInaNbaXdRI4qkBDrETXkqm0We+sNOzocPzBindpwdxbHs8qX99aCTocW+bMmGgW0YkdbDHbtKhn5j3Kno7wuIWgzQ1erWrm3VP0Xuo7eJx5HNfEDs4N8Gx2ki49BhKiC0BgF9j4tepORc10ndj9zuFUMOiIlMSW2MHhC4MhJXiG1n78AU/lzw4pHJU6T22j8TxaiV3T16s7eiPDRGD42BmqOYYGWYidCUdEd8TGToMzPomdnaE6HB2OHD7ExtApjLhb+ThWlZrWUc2GBx1U31ZB7KUJHPpDGdUiHy+GA/t+ZsPya6wmZ/settnD6B8aQyWIf9Wtw62YdirNaet1Yvcl25JN49AIThIKsYsLHJ3aF9t0t8TOSmZhi3fk0H7H5qRNl8FnYAD70MVsz2gVK94qH/aNMOyHRx1CVcQm+KsXiV19dnCo8p9HMRwbMH0YSODlC29fJdYBFoIgz5i/UmWTE4E2C/DO9DnLTAcp6yBQJ3bo3268eo0SLxO7YNq0Zq06sWPNs+pWjLBHwaaKdWKHKfM32JwgUhJbYofr698k1R44KUFbu4t/M7v5NwOHMDhDBZNIP4+4nk7sgpFoIXbBeihy+ULsNCz1H1Gw6VDtNJ/d2P6I8dIeMHQiZf8vEKdPpQ4HiHsFt3Fdq6cXtfNC1POt+/hh9u/VkcnkQWuW7bHXid1HHy7nuGCTbNuORCvRsNPYoVy0ccQ1Iiluid0LHGNO1wqH27bPPvnQ0Dhbz4PDRa06DYwpPn0woZfDRwteoXAcCqQl1M/BvheJna45cnLO0e8DS/rB+F/JkP7daMe2LerQCCit21whduBG9mgOJLDHRSBkyH4ODvtWt5gp84RK7Ky4WO8fzmt4Dytpz6YqyohfpVm3XiV2GHzDsUZ3hLG2HcGkEXdvycKZPs4k1nKRfh5Rv07sgikFhNhZeyR6x0LsNGzjk9ihGXdxYN1GzTtwnK8KWqv8d+Hh+sGKJfTZJx84kjp1FpwD4CQQSPAxhTfj+0vmhRXxP7EQO2AXTRwD9U1s8twSO+uUYLhtgAfy7OnjHU+DpzFisN1TrKSPRtl6wtqPV/FU7ZSQtBFeJHb9Bo81VvjAfZ0+dZzat4qxebXeK46thv9dObYiovkrsdq2BdNc4byJ0xeaMeisSwUmVGIH+zrY2TmJ/iygjJOjhX6+V4mdaiPCDj31zPPGiiVOgyJ4KL/DzjRr+btgJ5F+HnENIXZ2SMd/mhA7rQ/im9ihKbrHGZYY2rrlW8NOBPGjECj0NIckwFI+IGOhij5q//zT1XSDQySk4PhaqA9LTu1hg9czvCxUuGIldsFsX8KpPxLhTiKlsVPtjhaOqv5Ibd0SO2tIBESy3/fLTyE3D0vfOQV71ivBmpvFS5Tlv9JGaJusvDSUVUINgKt/zCMVgsMtjnoMMjiLNOfwDoEEwckRpFyJ1dsUgWVHjp+tsmkmr5ji9BFXhXSbvA9XvkfvzJmqsiihEjvEIMRshZPoJA1G/PVqVQ46ANbP8dJUrPUe4YVeileaKVXmPirBYYaspjkoj+XO9vy003qqT0y8SDyPuIAQOz+YPZEgxE7rBi8Qu85vDuB4RA8ZrRrA06KIN+ZGdM+qcAL9hnJNrEwwf8nHpo2e9cMRSh1OZbxG7KKJoxMGsU13S0hgeI5pGyWhekmr8rHdYs3g1xq28omLF2oAXJ3YYRCEj7lbcYsjprMxra0kkGE5yui2YZd5GbWm7G2qC+KVzV282vy9WZ0h9LLYh33opBnvmsnQokKbqiShEjuE/EDoDyeBJzI8QCGhkrSEQuz0e4bmDlO0r3OcRD1QPjTdCM5ulUg/j6hfiJ0VZW8cC7HT+iG+iR28oKaxsTMWZoY0a8BhBLQF3LWmhryrhy1w+sGHXJlNwcn84VBR9SNJHL1G7KKNow20sU5yS0hy5WLNEAcnVuIUU03lR3ILz++ps5aYmohQtW/WsDQIfhqbRdH1e3GLI6bP4BSlZArHU1vHcdXsBKt4jGJtHNY5huzfu4ft4fxXl5g6+z1zhQXY7XXh6Vp94XW9bmsYmqH9uxsBplWZhErsjh89TJ3bxQSBV/eDLXA0wp1wQFxIqM9uQiR2xg3yfxgQDRsT420K21R4slslGs+jEDsryt44FmKn9UN8EzvdEw3NQtwieLDi7y+2n7jEyz9hibFjRw/xeolHg04voI4J0xb4BCmFsTU+ltc5oOi1P/80lqaB/Q/WYLQLnIo6Akn/IeOo8D33mkXe6trKiNBuJvy3A+eQv1mTAkPfUMRrxC7aOIaCSahl3BISaGInz3yXvaZjlptDwNFvv1nn2IQCBe82FmVfveo9R+cbxLFCQO1AXq/w4h41fpbpRATP8BaNAnuTolHWe0ZYj0Dr1jreiJZhrTPccCe6lhfVIjZd947NbEPPNG/dmR7nRd6VIG4g4gdapVP3/nTf/Q+byU5auxw5c9GQkdPMZd8Q77Jjm4Y+8cUSKrHDzTvFSqxcpRo1bBYT2Bne2fDSDiZeJHYgqblz5zV+TwgS7iRYhm/qrPfMbMRKRMxEq0TjeRRiZ0XZG8dC7LR+CIfYQbuWiX9QVqlWsy7Bu01JZ36Z6l6rsG/79fQple2zta5y4JNpOcAHb/Omb+iD9xfRWXbPtxNoP6bNWUpp06azy/ZJU96IWJgZwYn1NvsUtBxgzcUatWLiX8GzFtHaf2Q7QNSBtWof5Qj2lR57xlj6bOv3vkbPeCmlYSyt0qPvcHNtQRBa2I3ocpU/khd4zVur6GvFRsrGLi5wtN5HOMdwutGXUXq40pNUgxeJV4LR+xmHZ+4k22za9bWdswjsudZ9/pHhYIO1hhEkOwdr9x59vLK5Fu0AXpJq987t6tI+W9h74WP14/YfaNM3X7JjwEEeWJw1gtsivWDBwlT1xTo+zkNbNm804i/6VGRzYNVaIPYaAlNv5OtA6w17vrwcLqjCg5VoB3sQol6rRANH/Z2C6x3je/549Qra+8suXgLsPBW6uyiVYHspFRYFZbBcVue2jW3taLGMG1aU0A3oYQKxi2MCHtj/s6E9L1zkXsPjOyeTAiV2jhYJmdghBA+mG7d8t8FYJhD2vlg7+JW6jUxsMIBt3eRlvwErsMueIyfdxv+UwMEMzjxKOtksL4g8hBVxskeO9DcBfY1lDzEg+H7TBg5rtYG/HSf4fX/WiC2IwXIJXo4OnuY5cuZRTWfv2Nm0/L13zGN9J9LPoxA7HV3v7Aux0/pC1xIhntaA3p21XN/dQSMmUcFC9/gmhnBkDQSqn4IXDpbygXdcqIIXzdgR/clKmNT5CB7bq/8oM6ipSg+0xb0PHfCmETsvUDnkYekirF2ZJgTyaF3/Ekvc4GOvx9oLdj2V/xXHqJtqE6MuGsQO14w2juq+wt1aDerDPb9+7WcNDa71PDyLQ0ZOMVc5sOY7HQcjdlik3iogljpR0fOd4jTqZdT+m72HGkbl6thpixUksGyeLtHCEdPawzn4eDjP+OhhfXjQtkFvns9+m449DQ2lT2KAA8So696phR+BT8jETr9dzAJgYGAVJ+9sa7xA63mBjkGee3aJWfpNLxvpb4Iidvo11L7TbwaEF8oEJ/IZ6edRiJ3qEW9thdhp/aFPtwULbjts9DRDA6CdHtJuIGKHCvCBK1m6HAclTmloYVLwEjlp06WnrFlzGAvXI0gxjnXBi63jG/X9VpVQZbJlz2FozlKkSGHUiZdg5sxZKQt7IWLkmidvAb8P60ccpX6uFqVe1WW3xRRys9adeAHyZHbZZpqV2Fkjq5sFQ9iJa2KHJkUbxxBu26+IVVPlVyBIghOxw2nQYLXgNYSLlygTpJZb2dCsYhH5o2wDZSe6h6ZdvjUNWgdoH0IVtHcQk1F9CtnuXDtiF00cKzxQkTDVGmzwc4W13VMnDAsYygP3A01Ns9ZdCPUGE5hzTJ80wtBqWcsmVGIHu7nC9xS33o7P8aaN6w3ybhe4F9pRBOuNjQQidpH+JgQidnZthwfw2BH9bAPW6+Uj+TwKsdOR9c6+ELv/+gIhF6DZUrJo/gxauXyROvTbWm3L/Ao4JFiDhDoUc0yG/VP5Cg/zGqdNzcCjKAwNBD5YsREQlipVaxLsU5TAFqpdy5jpPJXutIVGCx8vTHdZtS8Iq7Jh3ee05sOlPs4gIJgz5q0wnUWc6rZLR9+gj6wyasIsg8QiPZyp2CuXLxleiHbTktZrOB1HAkenup3Scc0xk+b5Ye5UXk/HtHmzBi/52F3p+dhHXyJ+FkJxwPPOqnmC4f6BfXt4qaNVhI+pkyE/6sIg4v4HH6X7ebm7/LziiRJMmWGNTwjwx+LrCLFiF7JBneO0Bel5+dXGxsoD+K1Y5TcO74MYkFb7u2jjmDnzXWz71Y6KFithaM+hWVHT5zCr2PvLHpo3cyKHHzprbbLjMTRP6Jds3C/oJ1Un+uA42+F+zmYV6Bcn0ddKRiBkBP5VUpqnh7uzBhQSaEkxDCrh5IU+jJZY14rtxU4lOTiQe3U2e4E5AOzP0NfAAP0LB5X3Fs3x01Cq9sGWEe+q2IiT/RrqivQ3AfdTuEgxw3zgfjYhUI41uJbqa+yjv9fzPa9YtsDWfhNlrBKp5xErpeA5hIQboLhbhyZU8eEYe1FrG90cy1qxslYsL7mTgXr3H22u1Qp7s45sYxEsWrmbB8/tuU9XrkqNW8TYncGeCAvex1bwEpnC3ogZM96yG8Tor27NZ8KuDmQNBr9JkiTlF+2tcAO/83JKbghT2I2IxxMihWM83oLjpfHxxAcV2mR4biNQNtYejk3fpmSvxYyZMlGGDJnYhuiK8dzBVhQ2d3ZaFsdGOWSgH6DBAxkFaYSd3fnfzznaozpUE5VktCtlqlSseUtPJ44fNWwM3VwI95crdz7+2F/j31wSrvMYk/Ubbqr01Ll2xG4few2jjxH7MEOGjAbROc82t3Z2t566GReNwaAF7+cMbJd8mbXjMGvAoBnv13DimlqbEOnn0Vp/oOO6desFyo51nhC7RE7sSpQqS42atjW98PAkrf9qLU0ed2u0GusnK4onYtqodbselIeNa5UEi8auytltoTnAyF83PD7MXrKwyxEJHQHBMXSspKQgECoCTsQu1POlnHcREGIXnb5JVFOxWKsSxv7wICpavBSVu+8BKlS4mA+yWKwbITugiYhPAUmAJ19a/kvPWg1MOeTkUXmx4iUpd578Pk3DVE4nNpi9evWqT7p+gNEt7h0LtqdjGz2o9eGxmidfQSpVuryf8fHC+W/TquWL9SpknxEQHOUxEATiFgEhdnGLd1xeTYhddNBONMRuxLgZfoTICikCfg7u1zXOp2uwjFgjtlVIniw5JeXpLhBQEIhQBK7wMFg/eGCfT3F4NIIM3qovmZ9tlE9hywE0lgimGpspNktVCf5QcEzwXSg3kMAREGKXwDswQPOF2AUAx0VWoiF2cxevMQ20rXjBpgfG1CvYRi2Q1st6XqSOq1Z/herWbxZWdTBW/mTNCtaqLfSL04SKAt2v04Xg8bWEbfV2bN/qVCTRpQuOia7L5YY9hoAQO491SASbI8QugmBqVSVaYgdPql/27KQd276njV9/Ea9Tr6EQO0y3nmSN4pEjh4xgpFhDFt5qThKMkIDMwjkEhtZ7f95FO3ds4RUtjjhVl2jTBcdE2/Vy4x5BwLrEHUI7nTxx3COtk2a4QUCInRv0nM91JHZ/3bhOzQaW9zszZaqYZYb8Mj2cgKC/WEYLi2tfunTBcAuPpot+OFDAGaIgR6C/ceNvunnjprFFGIHLHILj8uWLdJE9oHAcjjzGqz1AbrIrPOoFkb169TIhrMclYMBegjLVGhxRwTE4RlJCEIg2ArA3hnkK3lmBlqSLdjuk/sgiIMQusniq2hyJ3T/8A2rUL2aJFXVCQiV2qv2yFQQEAUFAEBAEBIH4R0CIXXT6QIhddHCVWgUBQUAQEAQEAUFAEIhzBITYxTnkckFBQBAQBAQBQUAQEASig4AQu+jgKrUKAoKAICAICAKCgCAQ5wgIsYtzyOWCgoAgIAgIAoKAICAIRAcBR2L3982/qemAsn5XFecJP0gkQRAQBAQBQUAQEAQEAU8g4EjsLlw+R+1HPe7XSCF2fpBIgiAgCAgCgoAgIAgIAp5AQIidJ7pBGiEICAKCgCAgCAgCgoB7BITYucdQahAEBAFBQBAQBAQBQcATCAix80Q3SCMEAUFAEBAEBAFBQBBwj4AQO/cYSg2CgCAgCAgCgoAgIAh4AgEhdp7oBmmEICAICAKCgCAgCAgC7hEQYuceQ6lBEBAEBAFBQBAQBAQBTyAgxM4T3SCNEAQEAUFAEBAEBAFBwD0CQuzcYyg1CAKCgCAgCAgCgoAg4AkEhNh5ohukEYKAICAICAKCgCAgCLhHQIidewylBkFAEBAEBAFBQBAQBDyBgBA7T3SDNEIQEAQEAUFAEBAEBAH3CAixc4+h1CAICAKCgCAgCAgCgoAnEBBi54lukEYIAoKAICAICAKCgCDgHgEhdu4xlBoEAUFAEBAEBAFBQBDwBAJC7DzRDdIIQUAQEAQEAUFAEBAE3CMgxM49hlKDICAICAKCgCAgCAgCnkBAiJ0nukEaIQgIAoKAICAICAKCgHsEhNi5x1BqEAQEAUFAEBAEBAFBwBMICLHzRDdIIwQBQUAQEAQEAUFAEHCPgBA79xhKDYKAICAICAKCgCAgCHgCASF2nugGaYQgIAgIAoKAICAICALuERBi5x5DqUEQEAQEAUFAEBAEBAFPICDEzhPdII0QBAQBQUAQEAQEAUHAPQJC7NxjKDUIAoKAICAICAKCgCDgCQSE2HmiG6QRgoAgIAgIAoKAICAIuEdAiJ17DKUGQUAQEAQEAUFAEBAEPIGAEDtPdIM0QhAQBAQBQUAQEAQEAfcICLFzj6HUIAgIAoKAICAICAKCgCcQEGLniW6QRggCgoAgIAgIAoKAIOAeASF27jGUGgQBQUAQEAQEAUFAEPAEAkLsPNEN0ghBQBAQBAQBQUAQEATcIyDEzj2GUoMgIAgIAoKAICAICAKeQECInSe6QRohCAgCgoAgIAgIAoKAewQcid1fN65Ts4Hl/a6QMlVGvzRJEAQEAUFAEBAEBAFBQBCIfwT+D/zF7ZhlIKO3AAAAAElFTkSuQmCC\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_screenshot\",\"description\":\"Capture a screenshot of the current screen.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" }, "response": { "status": 200, diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json index 850e381caf..47670c8127 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json @@ -44,7 +44,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" }, "response": { "status": 200, diff --git a/packages/llm/test/llm.test.ts b/packages/llm/test/llm.test.ts index 633a4662da..64346a8bb0 100644 --- a/packages/llm/test/llm.test.ts +++ b/packages/llm/test/llm.test.ts @@ -90,15 +90,47 @@ describe("llm constructors", () => { provider: "fake", route: chatRoute, }) - const updated = Model.update(base, { route: responsesRoute }) + const updated = Model.update(base, { + route: responsesRoute, + defaults: { generation: { maxTokens: 20 } }, + compatibility: { toolSchema: "gemini" }, + }) + const updatedInput = Model.input(updated) expect(updated).toBeInstanceOf(Model) expect(String(updated.id)).toBe("fake-model") expect(updated.route).toBe(responsesRoute) - expect(String(Model.input(updated).provider)).toBe("fake") + expect(updated.defaults?.generation).toEqual({ maxTokens: 20 }) + expect(updated.compatibility).toEqual({ toolSchema: "gemini" }) + expect(updatedInput.defaults).toBe(updated.defaults) + expect(updatedInput.compatibility).toBe(updated.compatibility) + expect(String(updatedInput.provider)).toBe("fake") expect(Model.update(updated, {})).toBe(updated) }) + test("carries model defaults and compatibility through route model selection", () => { + const model = chatRoute.model({ + id: "kimi-k2", + defaults: { + limits: { context: 128_000, output: 8_192 }, + generation: { maxTokens: 1_024, stop: ["END"] }, + providerOptions: { openai: { parallelToolCalls: false } }, + http: { body: { extra_body: true } }, + }, + compatibility: { toolSchema: "moonshot" }, + }) + const request = LLM.request({ model, prompt: "Say hello." }) + + expect(request.model.defaults?.limits).toEqual({ context: 128_000, output: 8_192 }) + expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] }) + expect(request.model.defaults?.providerOptions).toEqual({ openai: { parallelToolCalls: false } }) + expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } }) + expect(request.model.compatibility).toEqual({ toolSchema: "moonshot" }) + expect(request.generation).toBeUndefined() + expect(request.providerOptions).toBeUndefined() + expect(request.http).toBeUndefined() + }) + test("builds tool choices from names and tools", () => { const tool = ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }) diff --git a/packages/llm/test/prepare.test.ts b/packages/llm/test/prepare.test.ts new file mode 100644 index 0000000000..6923c5a678 --- /dev/null +++ b/packages/llm/test/prepare.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM, mergeProviderOptions } from "../src" +import { AnthropicMessages, OpenAIChat } from "../src/protocols" +import { Auth, LLMClient } from "../src/route" +import { it } from "./lib/effect" +import { dynamicResponse } from "./lib/http" +import { deltaChunk } from "./lib/openai-chunks" +import { sseEvents } from "./lib/sse" + +const TargetJson = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownSync(TargetJson) + +describe("request option precedence", () => { + test("deep-merges provider option records and replaces arrays, primitives, and null", () => { + const merged = mergeProviderOptions( + { + openai: { + include: ["route"], + metadata: { route: true, shared: "route" }, + nullable: "route", + primitive: "route", + }, + }, + { + openai: { + include: ["model"], + metadata: { model: true, shared: "model" }, + nullable: null, + primitive: "model", + }, + }, + { openai: { metadata: { request: true }, primitive: false } }, + ) + + expect(merged).toEqual({ + openai: { + include: ["model"], + metadata: { route: true, model: true, request: true, shared: "model" }, + nullable: null, + primitive: false, + }, + }) + }) + + it.effect("prepares bodies with route defaults, model defaults, and call options in order", () => + Effect.gen(function* () { + const route = OpenAIChat.route.with({ + endpoint: { baseURL: "https://api.openai.test/v1/" }, + auth: Auth.bearer("test"), + generation: { maxTokens: 10, temperature: 1, stop: ["route"] }, + providerOptions: { openai: { store: false, reasoningEffort: "low" } }, + }) + const model = route.model({ + id: "gpt-4o-mini", + defaults: { + generation: { maxTokens: 20, temperature: 0.5, frequencyPenalty: 0.25, stop: ["model"] }, + providerOptions: { openai: { reasoningEffort: "medium" } }, + }, + }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "Say hello.", + generation: { maxTokens: 30, topP: 0.9, stop: ["request"] }, + providerOptions: { openai: { store: true } }, + }), + ) + + expect(prepared.body).toMatchObject({ + model: "gpt-4o-mini", + stream: true, + max_tokens: 30, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.25, + store: true, + reasoning_effort: "medium", + }) + expect(prepared.body.stop).toEqual(["request"]) + }), + ) + + it.effect("applies model HTTP defaults before request HTTP overlays", () => + LLMClient.generate( + LLM.request({ + model: OpenAIChat.route + .with({ + endpoint: { baseURL: "https://api.openai.test/v1/" }, + auth: Auth.bearer("fresh-key"), + http: { + body: { metadata: { route: true, shared: "route" }, value: "route" }, + headers: { "x-route": "route", "x-shared": "route" }, + query: { route: "1", shared: "route" }, + }, + }) + .model({ + id: "gpt-4o-mini", + defaults: { + http: { + body: { metadata: { model: true, shared: "model" }, value: "model" }, + headers: { "x-model": "model", "x-shared": "model" }, + query: { model: "1", shared: "model" }, + }, + }, + }), + prompt: "Say hello.", + http: { + body: { metadata: { request: true }, value: null }, + headers: { "x-request": "request" }, + query: { request: "1" }, + }, + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.openai.test/v1/chat/completions?route=1&shared=model&model=1&request=1") + expect(web.headers.get("authorization")).toBe("Bearer fresh-key") + expect(web.headers.get("x-route")).toBe("route") + expect(web.headers.get("x-model")).toBe("model") + expect(web.headers.get("x-request")).toBe("request") + expect(web.headers.get("x-shared")).toBe("model") + expect(decodeJson(input.text)).toMatchObject({ + metadata: { route: true, model: true, request: true, shared: "model" }, + value: null, + }) + return input.respond(sseEvents(deltaChunk({}, "stop")), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("rejects raw body overlays for protocol-owned roots", () => + Effect.gen(function* () { + const model = OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-4o-mini" }) + const error = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "Say hello.", + http: { body: { model: "gpt-5", messages: [], tools: [] } }, + }), + ).pipe(Effect.flip) + + expect(error.reason).toMatchObject({ + _tag: "InvalidRequest", + message: "http.body cannot overlay protocol-owned field(s): model, messages, tools", + }) + }), + ) + + it.effect("uses model output limits after route limits and before call maxTokens", () => + Effect.gen(function* () { + const route = AnthropicMessages.route.with({ + endpoint: { baseURL: "https://api.anthropic.test/v1/" }, + auth: Auth.header("x-api-key", "test"), + limits: { output: 128 }, + }) + const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } }) + const withoutMaxTokens = yield* LLMClient.prepare( + LLM.request({ model, prompt: "Say hello.", cache: "none" }), + ) + const withMaxTokens = yield* LLMClient.prepare( + LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }), + ) + + expect(withoutMaxTokens.body.max_tokens).toBe(64) + expect(withMaxTokens.body.max_tokens).toBe(32) + }), + ) +}) diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index dabf512f6b..8989312958 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -395,6 +395,10 @@ describe("Anthropic Messages route", () => { expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({ providerMetadata: { anthropic: { signature: "sig_1" } }, }) + expect(response.message.content).toEqual([ + { type: "text", text: "Hello!" }, + { type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } }, + ]) expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop", diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index d30742c47d..1dc253c0ea 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -347,10 +347,10 @@ describe("Gemini route", () => { { type: "step-start", index: 0 }, { type: "reasoning-start", id: "reasoning-0" }, { type: "reasoning-delta", id: "reasoning-0", text: "thinking" }, + { type: "reasoning-end", id: "reasoning-0" }, { type: "text-start", id: "text-0" }, { type: "text-delta", id: "text-0", text: "Hello" }, { type: "text-delta", id: "text-0", text: "!" }, - { type: "reasoning-end", id: "reasoning-0" }, { type: "text-end", id: "text-0" }, { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined }, { @@ -399,6 +399,9 @@ describe("Gemini route", () => { providerMetadata: { google: { thoughtSignature: "thought_sig" } }, }) expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } }) + expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan( + response.events.findIndex((event) => event.type === "tool-call"), + ) const prepared = yield* LLMClient.prepare( LLM.request({ diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 9966b92e3d..b736dc9dd3 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" import { HttpClientRequest } from "effect/unstable/http" -import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src" +import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src" import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" import * as OpenAIChat from "../../src/protocols/openai-chat" @@ -224,6 +224,27 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("preserves structured tool errors for the model", () => + Effect.gen(function* () { + const error = { error: { type: "unknown", message: "Tool execution interrupted" } } + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), + Message.tool({ id: "call_1", name: "bash", resultType: "error", result: error }), + ], + }), + ) + + expect(prepared.body.messages.at(-1)).toEqual({ + role: "tool", + tool_call_id: "call_1", + content: ProviderShared.encodeJson(error), + }) + }), + ) + it.effect("continues image tool results as vision input without base64 text", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( @@ -521,9 +542,9 @@ describe("OpenAI Chat route", () => { { type: "step-start", index: 0 }, { type: "reasoning-start", id: "reasoning-0" }, { type: "reasoning-delta", id: "reasoning-0", text: "thinking" }, + { type: "reasoning-end", id: "reasoning-0" }, { type: "text-start", id: "text-0" }, { type: "text-delta", id: "text-0", text: "Hello" }, - { type: "reasoning-end", id: "reasoning-0" }, { type: "text-end", id: "text-0" }, { type: "step-finish", index: 0, reason: "stop" }, { type: "finish", reason: "stop" }, @@ -576,19 +597,22 @@ describe("OpenAI Chat route", () => { }), deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }), ) - const response = yield* LLMClient.generate( - LLM.updateRequest(request, { - tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], - }), - ).pipe(Effect.provide(fixedResponse(body))) + const input = LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }) + const events = Array.from( + yield* LLMClient.stream(input).pipe(Stream.runCollect, Effect.provide(fixedResponse(body))), + ) + const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip) - expect(response.events).toEqual([ + expect(events).toEqual([ { type: "step-start", index: 0 }, { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, ]) - expect(response.toolCalls).toEqual([]) + expect(events.filter(LLMEvent.is.toolCall)).toEqual([]) + expect(error.message).toContain("Provider stream ended without a terminal finish event") }), ) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 717a7e8024..cd8bad51af 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -50,6 +50,7 @@ describe("OpenAI Responses route", () => { { role: "system", content: "You are concise." }, { role: "user", content: [{ type: "input_text", text: "Say hello." }] }, ], + store: false, stream: true, max_output_tokens: 20, temperature: 0, @@ -115,6 +116,7 @@ describe("OpenAI Responses route", () => { type: "function", name: "read", description: "Read a path or resource.", + strict: false, parameters: { type: "object", properties: { @@ -160,16 +162,16 @@ describe("OpenAI Responses route", () => { Effect.gen(function* () { const prepared = yield* LLMClient.prepare( LLM.updateRequest(request, { - model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket( - "gpt-4.1-mini", - ), + model: OpenAIResponses.webSocketRoute + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-4.1-mini" }), }), ) expect(prepared.route).toBe("openai-responses-websocket") expect(prepared.protocol).toBe("openai-responses") expect(prepared.metadata).toEqual({ transport: "websocket-json" }) - expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", stream: true }) + expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true }) }), ) @@ -355,11 +357,75 @@ describe("OpenAI Responses route", () => { { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' }, { type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' }, ], + store: false, stream: true, + max_output_tokens: undefined, + temperature: undefined, + tool_choice: undefined, + tools: undefined, + top_p: undefined, }) }), ) + it.effect("preserves structured tool errors for the model", () => + Effect.gen(function* () { + const error = { + error: { type: "unknown", message: "Tool execution interrupted" }, + content: [], + structured: {}, + } + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: { command: "sleep 10" } })]), + Message.tool({ + id: "call_1", + name: "bash", + resultType: "error", + result: error, + }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toBe(ProviderShared.encodeJson(error)) + }), + ) + + it.effect("keeps primitive tool errors as plain text", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), + Message.tool({ id: "call_1", name: "bash", resultType: "error", result: 503 }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toBe("503") + }), + ) + + it.effect("keeps non-JSON tool errors as plain text", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), + Message.tool({ id: "call_1", name: "bash", resultType: "error", result: new Error("boom") }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toBe("Error: boom") + }), + ) + // Regression: screenshot/read tool results must stay structured so base64 // image data is not JSON-stringified into `function_call_output.output`. it.effect("lowers image tool-result content as structured input_image items", () => @@ -460,7 +526,6 @@ describe("OpenAI Responses route", () => { }, { type: "reasoning", - id: "rs_continuation_1", encrypted_content: "encrypted-continuation-state", summary: [{ type: "summary_text", text: "I inspected the previous turn." }], }, @@ -713,6 +778,11 @@ describe("OpenAI Responses route", () => { { type: "step-finish", index: 0, reason: "stop" }, { type: "finish", reason: "stop" }, ]) + expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1) + expect(response.message.content).toEqual([ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "Hello" }, + ]) }), ) @@ -805,7 +875,9 @@ describe("OpenAI Responses route", () => { it.effect("closes reasoning summary parts when storage is not disabled", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { providerOptions: { openai: { store: true } } }), + ).pipe( Effect.provide( fixedResponse( sseEvents( @@ -866,12 +938,12 @@ describe("OpenAI Responses route", () => { dynamicResponse((input) => Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) - expect(yield* Effect.promise(() => web.json())).toMatchObject({ + const body = yield* Effect.promise(() => web.json()) + expect(body).toMatchObject({ input: [ { role: "user", content: [{ type: "input_text", text: "What changed?" }] }, { type: "reasoning", - id: "rs_1", encrypted_content: "encrypted-state", summary: [{ type: "summary_text", text: "Checked the previous diff." }], }, @@ -879,6 +951,7 @@ describe("OpenAI Responses route", () => { { role: "user", content: [{ type: "input_text", text: "Summarize it." }] }, ], }) + expect(body.input[1]).not.toHaveProperty("id") return input.respond( sseEvents( { type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." }, @@ -925,7 +998,6 @@ describe("OpenAI Responses route", () => { { role: "assistant", content: [{ type: "output_text", text: "Before." }] }, { type: "reasoning", - id: "rs_1", encrypted_content: "encrypted-state", summary: [{ type: "summary_text", text: "Checked order." }], }, @@ -1019,7 +1091,6 @@ describe("OpenAI Responses route", () => { expect(prepared.body.input).toEqual([ { type: "reasoning", - id: "rs_1", encrypted_content: "encrypted-state", summary: [ { type: "summary_text", text: "First" }, diff --git a/packages/llm/test/response.test.ts b/packages/llm/test/response.test.ts new file mode 100644 index 0000000000..5e48e5ef45 --- /dev/null +++ b/packages/llm/test/response.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test" +import { LLMEvent, LLMResponse } from "../src" + +const reduce = (events: ReadonlyArray) => events.reduce(LLMResponse.reduce, LLMResponse.empty()) +const finishEvents = (events: ReadonlyArray) => events.filter(LLMEvent.is.finish) + +describe("LLMResponse reducer", () => { + test("assembles interleaved reasoning and text with end metadata", () => { + const events = [ + LLMEvent.reasoningStart({ id: "r1" }), + LLMEvent.reasoningDelta({ id: "r1", text: "I should " }), + LLMEvent.textStart({ id: "t1" }), + LLMEvent.reasoningDelta({ id: "r1", text: "compare..." }), + LLMEvent.reasoningEnd({ id: "r1", providerMetadata: { anthropic: { signature: "sig" } } }), + LLMEvent.textDelta({ id: "t1", text: "Answer" }), + LLMEvent.textEnd({ id: "t1" }), + LLMEvent.finish({ reason: "stop", usage: { outputTokens: 5 } }), + ] + const response = LLMResponse.fromEvents(events) + + expect(response?.finishReason).toBe("stop") + expect(response?.usage).toMatchObject({ outputTokens: 5 }) + expect(response?.events).toEqual(events) + expect(response?.events.map((event) => event.type)).toEqual([ + "reasoning-start", + "reasoning-delta", + "text-start", + "reasoning-delta", + "reasoning-end", + "text-delta", + "text-end", + "finish", + ]) + expect(finishEvents(response?.events ?? [])).toHaveLength(1) + expect(response?.message.content).toEqual([ + { + type: "reasoning", + text: "I should compare...", + providerMetadata: { anthropic: { signature: "sig" } }, + }, + { type: "text", text: "Answer" }, + ]) + }) + + test("preserves partial content without completing a failed stream", () => { + const state = reduce([LLMEvent.textStart({ id: "t1" }), LLMEvent.textDelta({ id: "t1", text: "partial" })]) + + expect(LLMResponse.complete(state)).toBeUndefined() + expect(state.message.content).toEqual([{ type: "text", text: "partial" }]) + }) + + test("does not complete ended content without a terminal finish", () => { + const state = reduce([ + LLMEvent.textStart({ id: "t1" }), + LLMEvent.textDelta({ id: "t1", text: "partial" }), + LLMEvent.textEnd({ id: "t1" }), + ]) + + expect(LLMResponse.complete(state)).toBeUndefined() + expect(state.message.content).toEqual([{ type: "text", text: "partial" }]) + }) + + test("uses terminal usage when present and keeps prior usage when finish omits it", () => { + const withFinishUsage = LLMResponse.fromEvents([ + LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }), + LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }), + ]) + const withoutFinishUsage = LLMResponse.fromEvents([ + LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }), + LLMEvent.finish({ reason: "stop" }), + ]) + + expect(withFinishUsage?.usage).toMatchObject({ outputTokens: 2 }) + expect(withoutFinishUsage?.usage).toMatchObject({ inputTokens: 3 }) + }) + + test("assembles tool-call content only after the completed tool call event", () => { + const pending = reduce([ + LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }), + LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: '{"query"' }), + ]) + + expect(pending.message.content).toEqual([]) + expect(pending.toolInputs.call_1?.text).toBe('{"query"') + + const response = LLMResponse.fromEvents([ + ...pending.events, + LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: ':"weather"}' }), + LLMEvent.toolInputEnd({ id: "call_1", name: "lookup" }), + LLMEvent.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }), + LLMEvent.finish({ reason: "tool-calls" }), + ]) + + expect(response?.message.content).toEqual([ + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + ]) + }) +}) diff --git a/packages/llm/test/tool-runtime.test.ts b/packages/llm/test/tool-runtime.test.ts index c69456e425..c03a18bd8a 100644 --- a/packages/llm/test/tool-runtime.test.ts +++ b/packages/llm/test/tool-runtime.test.ts @@ -598,7 +598,7 @@ describe("LLMClient tools", () => { include: ["reasoning.encrypted_content"], input: [ { role: "user" }, - { type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" }, + { type: "reasoning", summary: [], encrypted_content: "encrypted-state" }, { type: "function_call", call_id: "call_1", name: "get_weather" }, { type: "function_call_output", call_id: "call_1" }, ], diff --git a/packages/llm/test/tool-schema-projection.test.ts b/packages/llm/test/tool-schema-projection.test.ts new file mode 100644 index 0000000000..a9df815daf --- /dev/null +++ b/packages/llm/test/tool-schema-projection.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../src" +import { OpenAIChat } from "../src/protocols" +import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema" +import { Auth, LLMClient } from "../src/route" +import { it } from "./lib/effect" + +describe("tool schema projections", () => { + test("moonshot strips $ref siblings and converts tuple arrays to a schema object", () => { + expect( + ToolSchemaProjection.moonshot({ + type: "object", + properties: { + linked: { $ref: "#/$defs/Linked", description: "drop me" }, + tuple: { type: "array", items: [{ type: "string" }, { type: "number" }] }, + prefixTuple: { type: "array", prefixItems: [{ type: "boolean" }, { type: "string" }] }, + }, + }), + ).toEqual({ + type: "object", + properties: { + linked: { $ref: "#/$defs/Linked" }, + tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } }, + prefixTuple: { type: "array", items: { anyOf: [{ type: "boolean" }, { type: "string" }] } }, + }, + }) + }) + + test("gemini handles numeric enums, dangling required fields, untyped arrays, and scalar object keys", () => { + expect( + ToolSchemaProjection.gemini({ + type: "object", + required: ["status", "missing"], + properties: { + status: { type: "integer", enum: [1, 2] }, + tags: { type: "array" }, + name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] }, + }, + }), + ).toEqual({ + type: "object", + required: ["status"], + properties: { + status: { type: "string", enum: ["1", "2"] }, + tags: { type: "array", items: { type: "string" } }, + name: { type: "string" }, + }, + }) + }) + + test("openai keeps one flat object top-level schema", () => { + expect( + ToolSchemaProjection.openAI({ + anyOf: [ + { + type: "object", + properties: { + path: { type: "string" }, + maybe: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, + }, + { type: "object", properties: { resource: { type: "string" } } }, + ], + }), + ).toEqual({ + type: "object", + properties: { + path: { type: "string" }, + maybe: { type: "string" }, + resource: { type: "string" }, + }, + additionalProperties: false, + }) + }) + + it.effect("applies model compatibility before protocol projection", () => + Effect.gen(function* () { + const model = OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "kimi-k2", compatibility: { toolSchema: "moonshot" } }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "Use the tool.", + tools: [ + { + name: "lookup", + description: "Lookup data.", + inputSchema: { + type: "object", + anyOf: [ + { + type: "object", + properties: { + tuple: { type: "array", items: [{ type: "string" }, { type: "number" }] }, + linked: { $ref: "#/$defs/Linked", description: "drop me" }, + }, + }, + ], + }, + }, + ], + }), + ) + + expect(prepared.body.tools?.[0]?.function.parameters).toEqual({ + type: "object", + properties: { + tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } }, + linked: { $ref: "#/$defs/Linked" }, + }, + additionalProperties: false, + }) + }), + ) +}) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 588fb4b511..d8be8af209 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.15", + "version": "7.4.16", "name": "@kilocode/cli", "type": "module", "license": "MIT", @@ -166,7 +166,7 @@ "tree-sitter-wasms": "^0.1.12", "turndown": "7.2.0", "ulid": "catalog:", - "venice-ai-sdk-provider": "2.0.2", + "venice-ai-sdk-provider": "2.1.1", "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "which": "6.0.1", @@ -187,7 +187,9 @@ "xdg-basedir": "5.1.0", "@ff-labs/fff-bun": "0.9.4", "@opencode-ai/tui": "workspace:*", - "@solid-primitives/scheduled": "1.5.2" + "@solid-primitives/scheduled": "1.5.2", + "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/schema": "workspace:*" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts index 948eb3c063..af8ef761eb 100644 --- a/packages/opencode/src/account/account.ts +++ b/packages/opencode/src/account/account.ts @@ -1,5 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { @@ -186,7 +186,7 @@ export class Service extends Context.Service()("@opencode/Ac export const use = serviceUse(Service) -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const repo = yield* AccountRepo.Service @@ -456,8 +456,6 @@ export const layer: Layer.Layer()("@opencode/Ac export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service @@ -166,8 +166,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) - -export const node = LayerNode.make(layer, [Database.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] }) export * as AccountRepo from "./repo" diff --git a/packages/opencode/src/account/schema.ts b/packages/opencode/src/account/schema.ts index 222296ff1b..8c008435ab 100644 --- a/packages/opencode/src/account/schema.ts +++ b/packages/opencode/src/account/schema.ts @@ -33,19 +33,19 @@ export class Org extends Schema.Class("Org")({ export class AccountRepoError extends Schema.TaggedErrorClass()("AccountRepoError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class AccountServiceError extends Schema.TaggedErrorClass()("AccountServiceError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class AccountTransportError extends Schema.TaggedErrorClass()("AccountTransportError", { method: Schema.String, url: Schema.String, description: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) { static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError { return new AccountTransportError({ @@ -92,7 +92,7 @@ export class PollExpired extends Schema.TaggedClass()("PollExpired" export class PollDenied extends Schema.TaggedClass()("PollDenied", {}) {} export class PollError extends Schema.TaggedClass()("PollError", { - cause: Schema.Defect, + cause: Schema.Defect(), }) {} export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError]) diff --git a/packages/opencode/src/acp/content.ts b/packages/opencode/src/acp/content.ts index 5f149d85f0..9207dd7c9d 100644 --- a/packages/opencode/src/acp/content.ts +++ b/packages/opencode/src/acp/content.ts @@ -1,6 +1,6 @@ import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk" import path from "node:path" -import { pathToFileURL } from "node:url" +import { fileURLToPath, pathToFileURL } from "node:url" import { SessionV1 } from "@opencode-ai/core/v1/session" export type PromptPart = SessionV1.TextPartInput | SessionV1.FilePartInput @@ -76,7 +76,26 @@ export function contentBlockToParts(block: ContentBlock): PromptPart[] { case "resource": if ("text" in block.resource) { - return [{ type: "text", text: block.resource.text }] + try { + const parsed = new URL(block.resource.uri) + if (parsed.protocol === "file:") { + const line = parsed.hash.match(/^#L(\d+)/)?.[1] + let filepath: string + try { + filepath = fileURLToPath(parsed) + } catch { + filepath = decodeURIComponent(parsed.pathname) + } + if (path.sep === "\\") filepath = filepath.replace(/\\/g, "/") + return [ + { + type: "text", + text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}`, + }, + ] + } + } catch {} + return [{ type: "text", text: `[${block.resource.uri}]\n${block.resource.text}` }] } if (block.resource.mimeType) { return [ diff --git a/packages/opencode/src/acp/directory.ts b/packages/opencode/src/acp/directory.ts index f61023c103..2301c8929a 100644 --- a/packages/opencode/src/acp/directory.ts +++ b/packages/opencode/src/acp/directory.ts @@ -1,7 +1,9 @@ import { Agent } from "@/agent/agent" import { Command } from "@/command" import { InstanceRef } from "@/effect/instance-ref" +import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Provider } from "@/provider/provider" @@ -139,7 +141,7 @@ export const loaderLayer = Layer.effect( }), ) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const loader = yield* Loader @@ -199,12 +201,12 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(loaderLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Command.defaultLayer), - Layer.provide(InstanceStore.defaultLayer), -) +export const loaderNode = LayerNode.make({ + service: Loader, + layer: loaderLayer, + deps: [Provider.node, Agent.node, Command.node, InstanceStore.node], +}) + +export const node = LayerNode.make({ service: Service, layer, deps: [loaderNode] }) export * as Directory from "./directory" diff --git a/packages/opencode/src/acp/error.ts b/packages/opencode/src/acp/error.ts index 0c66a0e049..fd7684db9a 100644 --- a/packages/opencode/src/acp/error.ts +++ b/packages/opencode/src/acp/error.ts @@ -46,6 +46,7 @@ export class UnsupportedOperationError extends Schema.TaggedErrorClass()("ACPServiceFailureError", { safeMessage: Schema.String, service: Schema.optional(Schema.String), + errorName: Schema.optional(Schema.String), }) {} export type Error = @@ -81,7 +82,13 @@ export function toRequestError(error: Error) { case "ACPUnsupportedOperationError": return RequestError.methodNotFound(error.method) case "ACPServiceFailureError": - return RequestError.internalError({ service: error.service }, error.safeMessage) + return RequestError.internalError( + { + ...(error.service ? { service: error.service } : {}), + ...(error.errorName ? { errorName: error.errorName } : {}), + }, + error.safeMessage, + ) } } diff --git a/packages/opencode/src/acp/permission.ts b/packages/opencode/src/acp/permission.ts index ba07fdf4af..06b394238d 100644 --- a/packages/opencode/src/acp/permission.ts +++ b/packages/opencode/src/acp/permission.ts @@ -1,9 +1,16 @@ -import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk" +import type { + AgentSideConnection, + PermissionOption, + RequestPermissionResponse, + ToolCallContent, + ToolCallLocation, + ToolCallUpdate, +} from "@agentclientprotocol/sdk" import type { Event, KiloClient } from "@kilocode/sdk/v2" import { applyPatch } from "diff" import { exists, readText } from "@/util/filesystem" import type { ACPSession } from "./session" -import { toLocations, toToolKind, type ToolInput } from "./tool" +import { pendingToolCall, toLocations, type ToolInput } from "./tool" import { Effect } from "effect" type PermissionEvent = Extract @@ -54,14 +61,11 @@ export class Handler { const result = await this.input.connection .requestPermission({ sessionId: permission.sessionID, - toolCall: { + toolCall: await permissionToolCall({ toolCallId: permission.tool?.callID ?? permission.id, - status: "pending", - title: permission.permission, - rawInput: permission.metadata, - kind: toToolKind(permission.permission), - locations: toLocations(permission.permission, permission.metadata), - }, + toolName: permission.permission, + input: permission.metadata, + }), options: permissionOptions, }) .catch(async () => { @@ -111,6 +115,107 @@ export class Handler { } } +async function permissionToolCall(input: { + readonly toolCallId: string + readonly toolName: string + readonly input: ToolInput +}): Promise { + const toolCall = pendingToolCall({ + toolCallId: input.toolCallId, + toolName: input.toolName, + state: { + input: input.input, + title: permissionTitle(input.toolName, input.input), + }, + }) + const content = await permissionContent(input.toolName, input.input) + return { + ...toolCall, + locations: permissionLocations(input.toolName, input.input), + ...(content.length ? { content } : {}), + } +} + +function permissionTitle(toolName: string, input: ToolInput) { + const tool = toolName.toLocaleLowerCase() + switch (tool) { + case "external_directory": + return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir) + + case "webfetch": + return stringValue(input.url) + + case "websearch": + return stringValue(input.query) + + case "grep": + case "glob": + return stringValue(input.pattern) + + case "read": + case "edit": + case "write": + return editTitle(input) + + default: + return undefined + } +} + +function editTitle(input: ToolInput) { + const files = fileMetadata(input) + if (files.length === 1) return files[0]?.relativePath ?? files[0]?.filePath + if (files.length > 1) return `${files.length} files` + return stringValue(input.filePath) ?? stringValue(input.filepath) ?? stringValue(input.path) +} + +function permissionLocations(toolName: string, input: ToolInput): ToolCallLocation[] { + const files = fileMetadata(input) + if (files.length) { + return Array.from( + new Set(files.flatMap((file) => [file.filePath, file.movePath].filter((path): path is string => !!path))), + (path) => ({ path }), + ) + } + return toLocations(toolName, input) +} + +async function permissionContent(toolName: string, input: ToolInput): Promise { + if (toolName.toLocaleLowerCase() !== "edit") return [] + + const files = fileMetadata(input) + if (files.length) return diffContentForFiles(files) + + const filepath = stringValue(input.filepath) ?? stringValue(input.filePath) + const diff = stringValue(input.diff) + if (!filepath || !diff) return [] + const content = await diffContentForPatch(filepath, diff) + return content ? [content] : [] +} + +async function diffContentForFiles(files: PermissionFileMetadata[]) { + const content = await Promise.all( + files.map(async (file) => { + if (!file.patch) return [] + const content = await diffContentForPatch(file.filePath, file.patch, file.movePath) + return content ? [content] : [] + }), + ) + return content.flat() +} + +async function diffContentForPatch(filepath: string, diff: string, displayPath = filepath) { + const content = (await exists(filepath)) ? await readText(filepath) : "" + const next = applyPatch(content, diff) + if (next === false) return undefined + return { + type: "diff" as const, + path: displayPath, + oldText: content, + newText: next, + } +} + function selectedReply(result: RequestPermissionResponse): Reply { if (result.outcome.outcome !== "selected") return "reject" if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId @@ -121,4 +226,29 @@ function stringValue(value: unknown) { return typeof value === "string" ? value : undefined } +type PermissionFileMetadata = { + readonly filePath: string + readonly relativePath?: string + readonly movePath?: string + readonly patch?: string +} + +function fileMetadata(input: ToolInput): PermissionFileMetadata[] { + if (!Array.isArray(input.files)) return [] + return input.files.flatMap((file): PermissionFileMetadata[] => { + if (!file || typeof file !== "object") return [] + const info = file as Record + const filePath = stringValue(info.filePath) + if (!filePath) return [] + return [ + { + filePath, + relativePath: stringValue(info.relativePath), + movePath: stringValue(info.movePath), + patch: stringValue(info.patch), + }, + ] + }) +} + export * as ACPPermission from "./permission" diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index c005cf484b..3fbebe5745 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -30,7 +30,8 @@ import { type SetSessionModeResponse, } from "@agentclientprotocol/sdk" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import type { Message, KiloClient, SessionMessageResponse } from "@kilocode/sdk/v2" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import type { AssistantMessage, Message, KiloClient, SessionMessageResponse } from "@kilocode/sdk/v2" import { Context, Effect, Layer, ManagedRuntime } from "effect" import * as ACPError from "./error" import { buildConfigOptions, parseModelSelection } from "./config-option" @@ -314,7 +315,6 @@ export function make(input: { yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? []) yield* sendAvailableCommands(input.connection, state.id, snapshot) - yield* replayMessages(events, messages) return { configOptions: configOptions(snapshot, { @@ -521,7 +521,7 @@ export function make(input: { "session", ) yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) - return promptResponse(response.info, params.messageId) + return yield* promptResponse(response.info, params.messageId) } const known = snapshot.availableCommands.find((item) => item.name === command.name) @@ -543,7 +543,7 @@ export function make(input: { "session", ) yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) - return promptResponse(response.info, params.messageId) + return yield* promptResponse(response.info, params.messageId) } if (command.name === "compact") { @@ -563,30 +563,31 @@ export function make(input: { } yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) - return promptResponse(undefined, params.messageId) + return yield* promptResponse(undefined, params.messageId) }), cancel, } } function makeSessionService() { - return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + return ManagedRuntime.make(AppNodeBuilder.build(ACPSession.node)).runSync( ACPSession.Service.use((service) => Effect.succeed(service)), ) } function makeDirectoryService(sdk: KiloClient) { return ManagedRuntime.make( - Directory.layer.pipe( - Layer.provide( + AppNodeBuilder.build(Directory.node, [ + [ + Directory.loaderNode, Layer.succeed( Directory.Loader, Directory.Loader.of({ load: (directory) => request(() => loadDirectorySnapshot(sdk, directory), "directory"), }), ), - ), - ), + ], + ]), ).runSync(Directory.Service.use((service) => Effect.succeed(service))) } @@ -695,7 +696,8 @@ type MessageInfo = { readonly agent?: Message["agent"] } -type AssistantInfo = UsageService.AssistantTokenCost | undefined +type AssistantError = NonNullable +type AssistantInfo = (UsageService.AssistantTokenCost & Pick) | undefined function request(fn: () => Promise>, service?: string) { return Effect.tryPromise({ @@ -811,13 +813,60 @@ function detectSlashCommand(parts: ReturnType) { return { name, args: rest.join(" ").trim() } } -function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse { - return { - stopReason: "end_turn", - ...(info ? { usage: UsageService.buildUsage(info) } : {}), +const promptResponse = Effect.fn("ACP.promptResponse")(function* ( + info: AssistantInfo, + messageId: string | null | undefined, +) { + if (!info?.error) { + return { + stopReason: "end_turn" as const, + ...(info ? { usage: UsageService.buildUsage(info) } : {}), + ...(messageId ? { userMessageId: messageId } : {}), + _meta: {}, + } + } + + const base = { + usage: UsageService.buildUsage(info), ...(messageId ? { userMessageId: messageId } : {}), _meta: {}, } + + if (info.error.name === "MessageAbortedError") { + return { + stopReason: "cancelled" as const, + ...base, + } + } + + if (info.error.name === "MessageOutputLengthError") { + return { + stopReason: "max_tokens" as const, + ...base, + } + } + + if (info.error.name === "ContentFilterError") { + return { + stopReason: "refusal" as const, + ...base, + } + } + + if (info.error.name === "ProviderAuthError") { + return yield* new ACPError.AuthRequiredError({ providerId: info.error.data.providerID }) + } + + return yield* new ACPError.ServiceFailureError({ + service: "session", + safeMessage: promptErrorMessage(info.error), + errorName: info.error.name, + }) +}) + +function promptErrorMessage(error: AssistantError) { + if ("message" in error.data && typeof error.data.message === "string") return error.data.message + return "OpenCode prompt failed" } function sendUsageUpdate( diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index 514f6812bf..05a0919f14 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -1,5 +1,6 @@ import type { McpServer } from "@agentclientprotocol/sdk" import type { Message, Part } from "@kilocode/sdk/v2" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Context, Effect, Layer, Ref } from "effect" @@ -93,7 +94,7 @@ export class Service extends Context.Service()("@opencode/AC type State = Map -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const sessions = yield* Ref.make(new Map()) @@ -200,7 +201,7 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer +export const node = LayerNode.make({ service: Service, layer, deps: [] }) function makeSession(input: StoreInput): Info { return { diff --git a/packages/opencode/src/acp/tool.ts b/packages/opencode/src/acp/tool.ts index d0e57cc2ec..309c126d6e 100644 --- a/packages/opencode/src/acp/tool.ts +++ b/packages/opencode/src/acp/tool.ts @@ -192,11 +192,8 @@ export function completedToolUpdate(input: { return { toolCallId: input.toolCallId, status: "completed", - kind: toToolKind(input.toolName), - title: toolTitle(input.toolName, input.state.input, input.state.title), - locations: toLocations(input.toolName, input.state.input, input.cwd), + ...(input.state.title ? { title: input.state.title } : {}), content: completedToolContent(input.toolName, input.state), - rawInput: rawInput(input.toolName, input.state.input, input.cwd), rawOutput: completedToolRawOutput(input.state), } } @@ -266,7 +263,7 @@ export function shellOutputSnapshot(state: { readonly metadata?: unknown }) { // For shell tools, surface the actual command as the title so it stays visible // before output lands; non-shell tools keep their model-provided title. function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) { - if (isShell(toolName)) return shellCommand(input) ?? stringValue(input.description) ?? fallback ?? toolName + if (isShell(toolName)) return shellCommand(input) ?? fallback ?? toolName return fallback || toolName } diff --git a/packages/opencode/src/acp/usage.ts b/packages/opencode/src/acp/usage.ts index 1f110f3893..b13a7fe1e7 100644 --- a/packages/opencode/src/acp/usage.ts +++ b/packages/opencode/src/acp/usage.ts @@ -1,7 +1,10 @@ import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk" import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@kilocode/sdk/v2" import { InstanceRef } from "@/effect/instance-ref" +import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" +import { makeGlobalNode, Node } from "@opencode-ai/core/effect/app-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Provider } from "@/provider/provider" @@ -132,7 +135,7 @@ export const contextLimitLoaderLayer = Layer.effect( }), ) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const messageLoader = yield* MessageLoader @@ -223,10 +226,14 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(contextLimitLoaderLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(InstanceStore.defaultLayer), -) +export const messageLoaderNode = LayerNode.unbound(MessageLoader, Node.tags.values.global) + +export const contextLimitLoaderNode = makeGlobalNode({ + service: ContextLimitLoader, + layer: contextLimitLoaderLayer, + deps: [Provider.node, InstanceStore.node], +}) + +export const node = makeGlobalNode({ service: Service, layer, deps: [messageLoaderNode, contextLimitLoaderNode] }) export * as UsageService from "./usage" diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b1430314ff..536a642fe4 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -27,10 +27,10 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer" import { AbsolutePath, type DeepMutable } from "@opencode-ai/core/schema" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Reference } from "@opencode-ai/core/reference" import { Location } from "@opencode-ai/core/location" +import { PluginV2 } from "@opencode-ai/core/plugin" export const Info = Schema.Struct({ name: Schema.String, @@ -85,7 +85,7 @@ export class Service extends Context.Service()("@opencode/Ag export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -93,16 +93,18 @@ export const layer = Layer.effect( const plugin = yield* Plugin.Service const skill = yield* Skill.Service const provider = yield* Provider.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const state = yield* InstanceState.make( Effect.fn("Agent.state")(function* (ctx) { const cfg = yield* config.get() const skillDirs = yield* skill.dirs() - const referenceDirs = yield* Effect.gen(function* () { - yield* (yield* PluginBoot.Service).wait() - return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) - }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) + const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length + ? yield* Effect.gen(function* () { + yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference")) + return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) + }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) + : [] const whitelistedDirs = [ Truncate.GLOB, path.join(Global.Path.tmp, "*"), @@ -436,24 +438,16 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Plugin.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(LocationServiceMap.layer), -) +const locationServiceMapNode = LayerNode.make({ + service: LocationServiceMap.Service, + layer: locationServiceMapLayer, + deps: [], +}) -const locationServiceMapNode = LayerNode.make(LocationServiceMap.layer, []) - -export const node = LayerNode.make(layer, [ - Config.node, - Auth.node, - Plugin.node, - Skill.node, - Provider.node, - locationServiceMapNode, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node, locationServiceMapNode], +}) export * as Agent from "./agent" diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index b62f29df1b..87654c2ba6 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -37,7 +37,7 @@ export type Info = Schema.Schema.Type export class AuthError extends Schema.TaggedErrorClass()("AuthError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export interface Interface { @@ -49,7 +49,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Auth") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fsys = yield* FSUtil.Service @@ -92,8 +92,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer)) - -export const node = LayerNode.make(layer, [FSUtil.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node] }) export * as Auth from "." diff --git a/packages/opencode/src/background/job.ts b/packages/opencode/src/background/job.ts index f3511676d7..261f5ad02b 100644 --- a/packages/opencode/src/background/job.ts +++ b/packages/opencode/src/background/job.ts @@ -15,7 +15,7 @@ export { } from "@opencode-ai/core/background-job" /** Keeps the legacy service instance-scoped while sharing the core registry engine. */ -export const layer = Layer.effect( +const layer = Layer.effect( CoreBackgroundJob.Service, Effect.gen(function* () { const state = yield* InstanceState.make(() => CoreBackgroundJob.make) @@ -32,8 +32,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer - -export const node = LayerNode.make(layer, []) +export const node = LayerNode.make({ service: CoreBackgroundJob.Service, layer, deps: [] }) export * as BackgroundJob from "./job" diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index af25f0cf7b..6814f51821 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -41,14 +41,31 @@ export const AttachCommand = cmd({ alias: ["u"], type: "string", describe: "basic auth username (defaults to KILO_SERVER_USERNAME or 'opencode')", + }) + .option("mini", { + type: "boolean", + describe: "start the minimal interactive interface", + default: false, + }) + .option("replay", { + type: "boolean", + hidden: true, + }) + .option("no-replay", { + type: "boolean", + describe: "disable mini session history replay on resume and after resize", + }) + .option("replay-limit", { + type: "number", + describe: "cap visible mini replay to the newest N messages", }), handler: async (args) => { - const { TuiConfig } = await import("@/config/tui") - if (args.fork && !args.continue && !args.session) { - UI.error("--fork requires --continue or --session") + if (args.replay === true) { + UI.error("--replay is not supported; replay is enabled by default") process.exitCode = 1 return } + const noReplay = args.replay === false || args.noReplay === true const directory = (() => { if (!args.dir) return undefined @@ -60,6 +77,40 @@ export const AttachCommand = cmd({ return args.dir } })() + + if (args.mini) { + const { runMini } = await import("./run") + await runMini({ + attach: args.url, + directory, + password: args.password, + username: args.username, + continue: args.continue, + session: args.session, + fork: args.fork, + replay: noReplay ? false : undefined, + replayLimit: args.replayLimit, + }) + return + } + + const unsupported = [ + ["--no-replay", noReplay], + ["--replay-limit", args.replayLimit !== undefined], + ].find((entry) => entry[1])?.[0] + if (unsupported) { + UI.error(`${unsupported} requires --mini`) + process.exitCode = 1 + return + } + + const { TuiConfig } = await import("@/config/tui") + if (args.fork && !args.continue && !args.session) { + UI.error("--fork requires --continue or --session") + process.exitCode = 1 + return + } + const headers = ServerAuth.headers({ password: args.password, username: args.username }) const config = await TuiConfig.get() diff --git a/packages/opencode/src/cli/cmd/cmd.ts b/packages/opencode/src/cli/cmd/cmd.ts index 05af009b88..910787f940 100644 --- a/packages/opencode/src/cli/cmd/cmd.ts +++ b/packages/opencode/src/cli/cmd/cmd.ts @@ -1,6 +1,6 @@ import type { CommandModule } from "yargs" -export type WithDoubleDash = T & { "--"?: string[] } +export type WithDoubleDash = T & { "--"?: string[]; _?: Array } export function cmd(input: CommandModule>) { return input diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 0e6ef98c24..02383aeaf9 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -1,7 +1,7 @@ import { EOL } from "os" import { Effect } from "effect" import { FileSystem } from "@opencode-ai/core/filesystem" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" @@ -9,8 +9,8 @@ import { cmd } from "../cmd" const filesystem = (effect: Effect.Effect) => effect.pipe( - Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(process.cwd()) }))), - Effect.provide(LocationServiceMap.layer), + Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(process.cwd()) }))), + Effect.provide(locationServiceMapLayer), ) const FileSearchCommand = effectCmd({ diff --git a/packages/opencode/src/cli/cmd/debug/scrap.ts b/packages/opencode/src/cli/cmd/debug/scrap.ts index eef6e05569..f624ee882e 100644 --- a/packages/opencode/src/cli/cmd/debug/scrap.ts +++ b/packages/opencode/src/cli/cmd/debug/scrap.ts @@ -7,8 +7,9 @@ export const ScrapCommand = cmd({ builder: (yargs) => yargs, async handler() { const { Project } = await import("@/project/project") + const { AppNodeBuilder } = await import("@opencode-ai/core/effect/app-node-builder") const { makeRuntime } = await import("@opencode-ai/core/effect/runtime") - const runtime = makeRuntime(Project.Service, Project.defaultLayer) + const runtime = makeRuntime(Project.Service, AppNodeBuilder.build(Project.node)) const list = await runtime.runPromise((project) => project.list()) process.stdout.write(JSON.stringify(list, null, 2) + EOL) }, diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts index 74288529d4..c1f82bb79f 100644 --- a/packages/opencode/src/cli/cmd/debug/v2.ts +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -1,9 +1,8 @@ import { EOL } from "os" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { AbsolutePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" @@ -13,22 +12,16 @@ export const V2Command = effectCmd({ instance: false, handler: () => Effect.gen(function* () { - yield* PluginBoot.Service.use((service) => service.wait()) const catalog = yield* Catalog.Service const providers = (yield* catalog.provider.available()).sort((a, b) => a.id.localeCompare(b.id)) const all = (yield* catalog.provider.all()).sort((a, b) => a.id.localeCompare(b.id)) const result = { providers, - default: catalog.model - .default() - .pipe(Effect.map(Option.map((item) => item.id)), Effect.map(Option.getOrUndefined)), + default: catalog.model.default().pipe(Effect.map((item) => item?.id)), small: Object.fromEntries( yield* Effect.all( all.map((provider) => - Effect.map( - catalog.model.small(provider.id), - (model) => [provider.id, Option.getOrUndefined(Option.map(model, (item) => item.id))] as const, - ), + Effect.map(catalog.model.small(provider.id), (model) => [provider.id, model?.id] as const), ), { concurrency: "unbounded" }, ), @@ -38,12 +31,12 @@ export const V2Command = effectCmd({ }).pipe( Effect.withSpan("Cli.debug.v2"), Effect.provide( - LocationServiceMap.get( + LocationServiceMap.Service.get( Location.Ref.make({ directory: AbsolutePath.make(process.cwd()), }), ), ), - Effect.provide(LocationServiceMap.layer), + Effect.provide(locationServiceMapLayer), ), }) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 06cb77bce1..c2d2ee2f3b 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -19,8 +19,6 @@ import path from "path" import { Global } from "@opencode-ai/core/global" import { modify, applyEdits } from "jsonc-parser" import { Filesystem } from "@/util/filesystem" -import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" import { Effect } from "effect" function getAuthStatusIcon(status: MCP.AuthStatus): string { @@ -258,21 +256,13 @@ export const McpAuthCommand = effectCmd({ const spinner = prompts.spinner() spinner.start("Starting OAuth flow...") - // Subscribe to browser open failure events to show URL for manual opening - const events = yield* EventV2Bridge.Service - const unsubscribe = yield* events.listen((event) => { - if (event.type !== MCP.BrowserOpenFailed.type) return Effect.void - const data = event.data as EventV2.Data - if (data.mcpName === serverName) { - spinner.stop("Could not open browser automatically") - prompts.log.warn("Please open this URL in your browser to authenticate:") - prompts.log.info(data.url) + yield* MCP.Service.use((mcp) => + mcp.authenticate(serverName, (url) => { + spinner.stop("Authorize in your browser:") + prompts.log.info(url) spinner.start("Waiting for authorization...") - } - return Effect.void - }) - - yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe( + }), + ).pipe( Effect.tap((status) => Effect.sync(() => { if (status.status === "connected") { @@ -307,7 +297,6 @@ export const McpAuthCommand = effectCmd({ prompts.log.error(error instanceof Error ? error.message : String(error)) }), ), - Effect.ensuring(unsubscribe), ) prompts.outro("Done") @@ -680,14 +669,20 @@ export const McpDebugCommand = effectCmd({ const config = yield* Config.Service.use((cfg) => cfg.get()) const mcp = yield* MCP.Service const auth = yield* McpAuth.Service + const serverConfig = config.mcp?.[args.name] + const authInfo = + serverConfig && isMcpRemote(serverConfig) && serverConfig.oauth !== false + ? yield* Effect.all({ + authStatus: mcp.getAuthStatus(args.name), + entry: auth.get(args.name), + }) + : undefined yield* Effect.promise(async () => { UI.empty() prompts.intro("MCP OAuth Debug") - const mcpServers = config.mcp ?? {} const serverName = args.name - const serverConfig = mcpServers[serverName] if (!serverConfig) { prompts.log.error(`MCP server not found: ${serverName}`) prompts.outro("Done") @@ -709,17 +704,13 @@ export const McpDebugCommand = effectCmd({ prompts.log.info(`Server: ${serverName}`) prompts.log.info(`URL: ${serverConfig.url}`) - // Check stored auth status — services already in hand, run inline. - const { authStatus, entry } = await Effect.runPromise( - Effect.all({ - authStatus: mcp.getAuthStatus(serverName), - entry: auth.get(serverName), - }), - ) + const { authStatus, entry } = authInfo! prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) if (entry?.tokens) { - prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) + prompts.log.info( + ` Access token: ${entry.tokens.accessToken.length > 8 ? `${entry.tokens.accessToken.slice(0, 4)}***${entry.tokens.accessToken.slice(-4)}` : "***"}`, + ) if (entry.tokens.expiresAt) { const expiresDate = new Date(entry.tokens.expiresAt * 1000) const isExpired = entry.tokens.expiresAt < Date.now() / 1000 @@ -770,7 +761,7 @@ export const McpDebugCommand = effectCmd({ } if (response.status === 401) { - prompts.log.warn("Server returned 401 Unauthorized") + prompts.log.info("Initial unauthenticated check returned 401, so this server requires OAuth") // Try to discover OAuth metadata const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index f6b18129a9..2b249f73c1 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,12 +1,13 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission" -// CLI entry point for `opencode run`. +import { FSUtil } from "@opencode-ai/core/fs-util" +// CLI entry point for `opencode run` and `opencode --mini`. // // Handles three modes: // 1. Non-interactive (default): sends a single prompt, streams events to // stdout, and exits when the session goes idle. -// 2. Interactive local (`--interactive`): boots the split-footer direct mode +// 2. Interactive local (`opencode --mini`): boots the split-footer direct mode // with an in-process server (no external HTTP). -// 3. Interactive attach (`--interactive --attach`): connects to a running +// 3. Interactive attach (`opencode --mini --attach`): connects to a running // opencode server and runs interactive mode against it. // // Also supports `--command` for slash-command execution, `--format json` for @@ -15,6 +16,7 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission" import type { Argv } from "yargs" import path from "path" import { pathToFileURL } from "url" +import { open } from "node:fs/promises" import { Effect } from "effect" import { UI } from "../ui" import { effectCmd } from "../effect-cmd" @@ -54,6 +56,8 @@ type FilePart = { mime: string } +const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024 + type Inline = { icon: string title: string @@ -213,13 +217,20 @@ export const RunCommand = effectCmd({ type: "boolean", describe: "show thinking blocks", }) + .option("mini", { + type: "boolean", + hidden: true, + default: false, + }) .option("replay", { type: "boolean", default: true, + hidden: true, describe: "replay interactive session history on resume and after resize (use --no-replay to disable)", }) .option("replay-limit", { type: "number", + hidden: true, describe: "cap visible interactive replay to the newest N messages", }) .option("interactive", { @@ -228,14 +239,25 @@ export const RunCommand = effectCmd({ describe: "run in direct interactive split-footer mode", default: false, }) - .option("dangerously-skip-permissions", { + .option("auto", { type: "boolean", describe: "auto-approve permissions that are not explicitly denied (dangerous!)", default: false, }) + .option("yolo", { + type: "boolean", + hidden: true, + default: false, + }) + .option("dangerously-skip-permissions", { + type: "boolean", + hidden: true, + default: false, + }) .option("demo", { type: "boolean", default: false, + hidden: true, describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately", }), handler: Effect.fn("Cli.run")(function* (args) { @@ -248,7 +270,9 @@ export const RunCommand = effectCmd({ const localInstance = yield* InstanceRef yield* Effect.promise(async () => { const rawMessage = [...args.message, ...(args["--"] || [])].join(" ") - const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false) + const interactive = args.mini + const auto = args.auto || args.yolo || args["dangerously-skip-permissions"] + const thinking = interactive ? (args.thinking ?? true) : (args.thinking ?? false) const die = (message: string): never => { UI.error(message) process.exit(1) @@ -265,20 +289,24 @@ export const RunCommand = effectCmd({ .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") - if (args.interactive && args.command) { - die("--interactive cannot be used with --command") + if (interactive && args.command) { + die("--mini cannot be used with --command") } - if (args.demo && !args.interactive) { - die("--demo requires --interactive") + if (interactive && args._?.[0] !== "mini") { + die("--mini must be used without the run subcommand") } - if (args.interactive && args.format === "json") { - die("--interactive cannot be used with --format json") + if (args.demo && !interactive) { + die("--demo requires --mini") } - if (args["replay-limit"] !== undefined && !args.interactive) { - die("--replay-limit requires --interactive") + if (interactive && args.format === "json") { + die("--mini cannot be used with --format json") + } + + if (args["replay-limit"] !== undefined && !interactive) { + die("--replay-limit requires --mini") } if ( @@ -288,11 +316,11 @@ export const RunCommand = effectCmd({ die("--replay-limit must be a positive integer") } - if (args.interactive && !process.stdout.isTTY) { - die("--interactive requires a TTY stdout") + if (interactive && !process.stdout.isTTY) { + die("--mini requires a TTY stdout") } - if (args.interactive) { + if (interactive) { try { resolveInteractiveStdin().cleanup?.() } catch (error) { @@ -300,7 +328,7 @@ export const RunCommand = effectCmd({ } } - const replay = args.replay || args["replay-limit"] !== undefined + const replay = args.replay === false ? false : args.replay || args["replay-limit"] !== undefined const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) const directory = (() => { @@ -337,11 +365,48 @@ export const RunCommand = effectCmd({ process.exit(1) } - const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain" + const stat = Filesystem.stat(resolvedPath) + const isDirectory = stat?.isDirectory() ?? false + if (args.attach && isDirectory) { + UI.error(`Cannot attach local directory without a shared filesystem: ${filePath}`) + process.exit(1) + } + + const content = await (async () => { + if (!args.attach) return + const handle = await open(resolvedPath, "r") + try { + const opened = await handle.stat() + if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) { + UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${filePath}`) + process.exit(1) + } + if (opened.size === 0) return Buffer.alloc(0) + const buffer = Buffer.alloc(Number(opened.size)) + let offset = 0 + while (offset < buffer.length) { + const read = await handle.read(buffer, offset, buffer.length - offset, offset) + if (read.bytesRead === 0) break + offset += read.bytesRead + } + return buffer.subarray(0, offset) + } finally { + await handle.close() + } + })() + const detected = FSUtil.mimeType(resolvedPath) + const text = content?.toString("utf8") + const mime = !args.attach + ? isDirectory + ? "application/x-directory" + : "text/plain" + : content && text !== undefined && Buffer.from(text, "utf8").equals(content) + ? "text/plain" + : detected files.push({ type: "file", - url: pathToFileURL(resolvedPath).href, + url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(resolvedPath).href, filename: path.basename(resolvedPath), mime, }) @@ -352,7 +417,7 @@ export const RunCommand = effectCmd({ message = resolveRunInput(message, piped) ?? "" const initialInput = resolveRunInput(rawMessage, piped) - if (message.trim().length === 0 && !args.command && !args.interactive) { + if (message.trim().length === 0 && !args.command && !interactive) { UI.error("You must provide a message or a command") process.exit(1) } @@ -362,7 +427,7 @@ export const RunCommand = effectCmd({ process.exit(1) } - const rules: PermissionV1.Ruleset = args.interactive + const rules: PermissionV1.Ruleset = interactive ? [] : [ { @@ -732,7 +797,7 @@ export const RunCommand = effectCmd({ const permission = event.properties if (permission.sessionID !== sessionID) continue - if (args["dangerously-skip-permissions"]) { + if (auto) { await client.permission.reply({ requestID: permission.id, reply: "once", @@ -760,7 +825,7 @@ export const RunCommand = effectCmd({ await share(client, sessionID) - if (!args.interactive) { + if (!interactive) { const events = await client.event.subscribe() const completed = loop(client, events).catch((e) => { console.error(e) @@ -834,7 +899,7 @@ export const RunCommand = effectCmd({ return } - if (args.interactive && !args.attach && !args.session && !args.continue) { + if (interactive && !args.attach && !args.session && !args.continue) { const model = pick(args.model) const { runInteractiveLocalMode } = await import("./run/runtime") const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -892,3 +957,55 @@ export const RunCommand = effectCmd({ }) }), }) + +type MiniCommandInput = { + directory?: string + attach?: string + password?: string + username?: string + continue?: boolean + session?: string + fork?: boolean + model?: string + agent?: string + prompt?: string + replay?: boolean + replayLimit?: number + demo?: boolean +} + +export async function runMini(input: MiniCommandInput) { + if (!RunCommand.handler) throw new Error("Mini command handler is unavailable") + await RunCommand.handler({ + $0: "opencode", + _: ["mini"], + message: input.prompt ? [input.prompt] : [], + command: undefined, + continue: input.continue, + session: input.session, + fork: input.fork, + share: undefined, + model: input.model, + agent: input.agent, + format: "default", + file: undefined, + title: undefined, + attach: input.attach, + password: input.password, + username: input.username, + dir: input.directory, + port: undefined, + variant: undefined, + thinking: undefined, + mini: true, + interactive: false, + replay: input.replay ?? true, + "replay-limit": input.replayLimit, + replayLimit: input.replayLimit, + auto: false, + yolo: false, + "dangerously-skip-permissions": false, + dangerouslySkipPermissions: false, + demo: input.demo ?? false, + }) +} diff --git a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts b/packages/opencode/src/cli/cmd/run/runtime.stdin.ts index dad46a7fb0..d236fb02c2 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.stdin.ts @@ -1,7 +1,7 @@ import fs from "fs" import * as tty from "node:tty" -export const INTERACTIVE_INPUT_ERROR = "--interactive requires a controlling terminal for input" +export const INTERACTIVE_INPUT_ERROR = "--mini requires a controlling terminal for input" type InteractiveStdin = { stdin: NodeJS.ReadStream diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 2997d84a44..a1056a7ee8 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -1,4 +1,4 @@ -// Top-level orchestrator for `run --interactive`. +// Top-level orchestrator for `opencode --mini`. // // Wires the boot sequence, lifecycle (renderer + footer), stream transport, // and prompt queue together into a single session loop. Two entry points: diff --git a/packages/opencode/src/cli/cmd/run/splash.ts b/packages/opencode/src/cli/cmd/run/splash.ts index 20194b95ce..141ff6fc55 100644 --- a/packages/opencode/src/cli/cmd/run/splash.ts +++ b/packages/opencode/src/cli/cmd/run/splash.ts @@ -234,7 +234,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback lines, body_left + label.length, top + 1, - `opencode run -i -s ${meta.session_id}`, + `opencode --mini -s ${meta.session_id}`, right, undefined, TextAttributes.BOLD, diff --git a/packages/opencode/src/cli/cmd/run/tool.ts b/packages/opencode/src/cli/cmd/run/tool.ts index 58ac575438..116a32620d 100644 --- a/packages/opencode/src/cli/cmd/run/tool.ts +++ b/packages/opencode/src/cli/cmd/run/tool.ts @@ -623,20 +623,18 @@ function snapQuestion(p: ToolProps): ToolSnapshot { function scrollBashStart(p: ToolProps): string { const cmd = p.input.command ?? "" - const desc = p.input.description || "Shell" const wd = p.input.workdir ?? "" - const dir = wd && wd !== "." ? toolPath(wd) : "" - if (cmd && desc === "Shell" && !dir) { + const formatted = wd && wd !== "." ? toolPath(wd) : "" + const dir = formatted === "." ? "" : formatted + if (cmd && !dir) { return `$ ${cmd}` } - const title = dir && !desc.includes(dir) ? `${desc} in ${dir}` : desc - if (!cmd) { - return `# ${title}` + return dir ? `# Running in ${dir}` : "" } - return `# ${title}\n$ ${cmd}` + return `# Running in ${dir}\n$ ${cmd}` } function scrollBashProgress(p: ToolProps): string { @@ -968,11 +966,10 @@ function permList(p: ToolPermissionProps): ToolPermissionInfo { } function permBash(p: ToolPermissionProps): ToolPermissionInfo { - const title = p.input.description || "Shell command" const cmd = p.input.command || "" return { icon: "#", - title, + title: "Shell command", lines: cmd ? [`$ ${cmd}`] : p.patterns.map((item) => `- ${item}`), } } diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index 5c3582655a..f81d86b695 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -1,4 +1,4 @@ -// Shared type vocabulary for the direct interactive mode (`run --interactive`). +// Shared type vocabulary for the direct interactive mode (`opencode --mini`). // // Direct mode uses a split-footer terminal layout: immutable scrollback for the // session transcript, and a mutable footer for prompt input, status, and diff --git a/packages/opencode/src/cli/cmd/run/variant.shared.ts b/packages/opencode/src/cli/cmd/run/variant.shared.ts index 0c4b82fa5c..e685ceb028 100644 --- a/packages/opencode/src/cli/cmd/run/variant.shared.ts +++ b/packages/opencode/src/cli/cmd/run/variant.shared.ts @@ -8,6 +8,7 @@ // variant and the persisted file. import path from "path" import { FSUtil } from "@opencode-ai/core/fs-util" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Context, Effect, Layer } from "effect" import { makeRuntime } from "@/effect/run-service" import { Global } from "@opencode-ai/core/global" @@ -135,7 +136,7 @@ function state(value: unknown): ModelState { } } -function createLayer(fs = FSUtil.defaultLayer) { +function createLayer(fs = AppNodeBuilder.build(FSUtil.node)) { return Layer.fresh( Layer.effect( Service, @@ -196,7 +197,7 @@ function createLayer(fs = FSUtil.defaultLayer) { } /** @internal Exported for testing. */ -export function createVariantRuntime(fs = FSUtil.defaultLayer): VariantRuntime { +export function createVariantRuntime(fs = AppNodeBuilder.build(FSUtil.node)): VariantRuntime { const runtime = makeRuntime(Service, createLayer(fs)) return { resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined), diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index a3bc542fdb..a608bfdc72 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -6,11 +6,12 @@ import { fileURLToPath } from "url" import { UI } from "@/cli/ui" import { errorMessage } from "@opencode-ai/tui/util/error" import { withTimeout } from "@/util/timeout" -import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network" +import { withNetworkOptions, resolveNetworkOptionsNoConfig, hasArg } from "@/cli/network" import { Filesystem } from "@/util/filesystem" import type { GlobalEvent } from "@kilocode/sdk/v2" import type { EventSource } from "@opencode-ai/tui/context/sdk" import { writeHeapSnapshot } from "v8" +import { ServerAuth } from "@/server/auth" import { validateSession } from "../tui/validate-session" import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32" @@ -103,8 +104,88 @@ export const TuiThreadCommand = cmd({ .option("agent", { type: "string", describe: "agent to use", + }) + .option("auto", { + type: "boolean", + describe: "auto-approve permissions that are not explicitly denied (dangerous!)", + default: false, + }) + .option("yolo", { + type: "boolean", + hidden: true, + default: false, + }) + .option("dangerously-skip-permissions", { + type: "boolean", + hidden: true, + default: false, + }) + .option("mini", { + type: "boolean", + describe: "start the minimal interactive interface", + default: false, + }) + .option("replay", { + type: "boolean", + hidden: true, + }) + .option("no-replay", { + type: "boolean", + describe: "disable mini session history replay on resume and after resize", + }) + .option("replay-limit", { + type: "number", + describe: "cap visible mini replay to the newest N messages", + }) + .option("demo", { + type: "boolean", + hidden: true, }), handler: async (args) => { + if (args.replay === true) { + UI.error("--replay is not supported; replay is enabled by default") + process.exitCode = 1 + return + } + const noReplay = args.replay === false || args.noReplay === true + + if (args.mini) { + const network = ["--port", "--hostname", "--mdns", "--no-mdns", "--mdns-domain", "--cors"].find((option) => + process.argv.some((arg) => arg === option || arg.startsWith(option + "=")), + ) + if (network) { + UI.error(`${network} cannot be used with --mini`) + process.exitCode = 1 + return + } + + const { runMini } = await import("./run") + await runMini({ + directory: resolveThreadDirectory(args.project), + continue: args.continue, + session: args.session, + fork: args.fork, + model: args.model, + agent: args.agent, + prompt: args.prompt, + replay: noReplay ? false : undefined, + replayLimit: args.replayLimit, + demo: args.demo, + }) + return + } + + const unsupported = [ + ["--no-replay", noReplay], + ["--replay-limit", args.replayLimit !== undefined], + ["--demo", args.demo !== undefined], + ].find((entry) => entry[1])?.[0] + if (unsupported) { + UI.error(`${unsupported} requires --mini`) + process.exitCode = 1 + return + } + const unguard = win32InstallCtrlCGuard() try { const { TuiConfig } = await import("@/config/tui") @@ -146,19 +227,16 @@ export const TuiThreadCommand = cmd({ const config = await TuiConfig.get() const network = resolveNetworkOptionsNoConfig(args) - const external = - process.argv.includes("--port") || - process.argv.includes("--hostname") || - process.argv.includes("--mdns") || - network.mdns || - network.port !== 0 || - network.hostname !== "127.0.0.1" + const external = hasArg("--port") || hasArg("--hostname") || network.mdns === true + + const headers = external ? ServerAuth.headers() : undefined const transport = external ? { url: (await client.call("server", network)).url, fetch: undefined, events: undefined, + headers, } : { url: "http://kilo.internal", @@ -172,6 +250,7 @@ export const TuiThreadCommand = cmd({ sessionID: args.session, directory: cwd, fetch: transport.fetch, + headers, }) } catch (error) { UI.error(errorMessage(error)) @@ -199,6 +278,7 @@ export const TuiThreadCommand = cmd({ pluginHost: createLegacyTuiPluginHost(), directory: cwd, fetch: transport.fetch, + headers: transport.headers, events: transport.events, args: { continue: args.continue, @@ -207,6 +287,7 @@ export const TuiThreadCommand = cmd({ model: args.model, prompt, fork: args.fork, + auto: args.auto || args.yolo || args["dangerously-skip-permissions"], }, }), ) diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index 11179186af..22040ed410 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -37,6 +37,22 @@ export type NetworkOptions = InferredOptionTypes export function withNetworkOptions(yargs: Argv) { return yargs.options(options) } + +export function hasArg(name: string) { + return networkArgs().some((arg) => arg === name || arg.startsWith(name + "=")) +} + +function hasBooleanArg(name: string) { + return networkArgs().some( + (arg) => arg === name || arg === name + "=true" || arg === name + "=false" || arg === "--no-" + name.slice(2), + ) +} + +function networkArgs() { + const separator = process.argv.indexOf("--") + return process.argv.slice(2, separator === -1 ? undefined : separator) +} + export const resolveNetworkOptions = Effect.fn("Cli.resolveNetworkOptions")(function* (args: NetworkOptions) { const { Config } = yield* Effect.promise(() => import("@/config/config")) const config = yield* Config.Service.use((cfg) => cfg.getGlobal()) @@ -44,10 +60,10 @@ export const resolveNetworkOptions = Effect.fn("Cli.resolveNetworkOptions")(func }) export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: ConfigV1.Info) { - const portExplicitlySet = process.argv.includes("--port") - const hostnameExplicitlySet = process.argv.includes("--hostname") - const mdnsExplicitlySet = process.argv.includes("--mdns") - const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain") + const portExplicitlySet = hasArg("--port") + const hostnameExplicitlySet = hasArg("--hostname") + const mdnsExplicitlySet = hasBooleanArg("--mdns") + const mdnsDomainExplicitlySet = hasArg("--mdns-domain") const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : (config?.server?.mdnsDomain ?? args["mdns-domain"]) const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port) diff --git a/packages/opencode/src/cli/tui/layer.ts b/packages/opencode/src/cli/tui/layer.ts index cc88498cb9..a592b86dae 100644 --- a/packages/opencode/src/cli/tui/layer.ts +++ b/packages/opencode/src/cli/tui/layer.ts @@ -1,7 +1,8 @@ import { run as runTui, type TuiInput } from "@opencode-ai/tui" import { Global } from "@opencode-ai/core/global" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Effect } from "effect" export function run(input: TuiInput) { - return runTui(input).pipe(Effect.provide(Global.defaultLayer)) + return runTui(input).pipe(Effect.provide(AppNodeBuilder.build(Global.node))) } diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 9f33cd5b93..4cf6b2d446 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -13,6 +13,13 @@ import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecy Heap.start() +const onUnhandledRejection = (_error: unknown) => {} + +const onUncaughtException = (_error: Error) => {} + +process.on("unhandledRejection", onUnhandledRejection) +process.on("uncaughtException", onUncaughtException) + // Subscribe to global events and forward them via RPC GlobalBus.on("event", (event) => { Rpc.emit("global.event", event) @@ -65,6 +72,8 @@ export const rpc = { async shutdown() { await InstanceRuntime.disposeAllInstances() if (server) await server.stop(true) + process.off("unhandledRejection", onUnhandledRejection) + process.off("uncaughtException", onUncaughtException) }, } diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 0463e83f6b..057754cd9e 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,30 +1,22 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import path from "path" import { InstanceState } from "@/effect/instance-state" import { EffectBridge } from "@/effect/bridge" import type { InstanceContext } from "@/project/instance-context" -import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context, Schema } from "effect" import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" -import { EventV2 } from "@opencode-ai/core/event" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" +import { LegacyEvent } from "@opencode-ai/schema/legacy-event" type State = { commands: Record } export const Event = { - Executed: EventV2.define({ - type: "command.executed", - schema: { - name: Schema.String, - sessionID: SessionID, - arguments: Schema.String, - messageID: MessageID, - }, - }), + Executed: LegacyEvent.CommandExecuted, } export const Info = Schema.Struct({ @@ -63,7 +55,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Command") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -141,12 +133,19 @@ export const layer = Layer.effect( for (const item of yield* skill.all()) { if (commands[item.name]) continue + const dir = item.location === "" ? undefined : path.dirname(item.location) commands[item.name] = { name: item.name, description: item.description, source: "skill", get template() { - return item.content + if (!dir) return item.content + return [ + item.content, + "", + `Base directory for this skill: ${dir}`, + "Relative paths in this skill (e.g., scripts/, references/) are relative to this base directory.", + ].join("\n") }, hints: [], } @@ -173,12 +172,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(MCP.defaultLayer), - Layer.provide(Skill.defaultLayer), -) - -export const node = LayerNode.make(layer, [Config.node, MCP.node, Skill.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Config.node, MCP.node, Skill.node] }) export * as Command from "." diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index c86a981f17..c23f3c62b6 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1,5 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { serviceUse } from "@opencode-ai/core/effect/service-use" import path from "path" import { pathToFileURL } from "url" @@ -172,7 +172,7 @@ function writableGlobal(info: Info) { return next } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -671,16 +671,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Account.defaultLayer), - Layer.provide(Npm.defaultLayer), - Layer.provide(FetchHttpClient.layer), -) - -export const node = LayerNode.make(layer, [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient], +}) export * as Config from "./config" diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index 0f729153d1..755a3aff99 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -2,6 +2,8 @@ export * as TuiConfig from "./tui" import path from "path" import { mergeDeep, unique } from "remeda" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Context, Effect, Fiber, Layer } from "effect" import { ConfigParse } from "@/config/parse" import * as ConfigPaths from "@/config/paths" @@ -223,7 +225,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: } }) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const directory = yield* CurrentWorkingDirectory @@ -257,9 +259,9 @@ export const layer = Layer.effect( }).pipe(Effect.withSpan("TuiConfig.layer")), ) -export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(FSUtil.defaultLayer)) +export const node = LayerNode.make({ service: Service, layer, deps: [Npm.node, FSUtil.node] }) -const { runPromise } = makeRuntime(Service, defaultLayer) +const { runPromise } = makeRuntime(Service, AppNodeBuilder.build(node)) export async function waitForDependencies() { await runPromise((svc) => svc.waitForDependencies()) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index ac0bb98bd3..85dc8a2f5d 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,5 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http" @@ -31,8 +31,9 @@ import { waitEvent } from "./util" import { WorkspaceRef } from "@/effect/instance-ref" import { Vcs } from "@/project/vcs" import { InstanceStore } from "@/project/instance-store" -import { InstanceBootstrap } from "@/project/bootstrap" import { WorkspaceAdapterRuntime } from "./workspace-adapter-runtime" +import { AppNodeBuilderV1 } from "@/effect/app-node-builder-v1" +import { WorkspaceEvent } from "@opencode-ai/schema/workspace-event" export const Info = Schema.Struct({ ...WorkspaceInfoSchema.fields, @@ -40,27 +41,10 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Workspace" }) export type Info = WorkspaceInfo & { timeUsed: number } -export const ConnectionStatus = Schema.Struct({ - workspaceID: WorkspaceV2.ID, - status: Schema.Literals(["connected", "connecting", "disconnected", "error"]), -}) -export type ConnectionStatus = Schema.Schema.Type +export const ConnectionStatus = WorkspaceEvent.ConnectionStatus +export type ConnectionStatus = WorkspaceEvent.ConnectionStatus -export const Event = { - Ready: EventV2.define({ - type: "workspace.ready", - schema: { - name: Schema.String, - }, - }), - Failed: EventV2.define({ - type: "workspace.failed", - schema: { - message: Schema.String, - }, - }), - Status: EventV2.define({ type: "workspace.status", schema: ConnectionStatus.fields }), -} +export const Event = WorkspaceEvent function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { return { @@ -131,7 +115,7 @@ export class SyncTimeoutError extends Schema.TaggedErrorClass( export class SyncAbortedError extends Schema.TaggedErrorClass()("WorkspaceSyncAbortedError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} type CreateError = Auth.AuthError @@ -166,7 +150,7 @@ export class Service extends Context.Service()("@opencode/Wo export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const auth = yield* Auth.Service @@ -617,7 +601,7 @@ export const layer = Layer.effect( }), fallback: "", response: "text", - }).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)))) + }).pipe(Effect.provide(AppNodeBuilderV1.build(InstanceStore.node))) : "" if (sourcePatch) { @@ -633,7 +617,7 @@ export const layer = Layer.effect( body: HttpBody.jsonUnsafe({ patch: sourcePatch }), }), fallback: { applied: false }, - }).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)))) + }).pipe(Effect.provide(AppNodeBuilderV1.build(InstanceStore.node))) } if (input.workspaceID === null) { @@ -901,19 +885,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionPrompt.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(Vcs.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(RuntimeFlags.defaultLayer), -) - const TIMEOUT = 5000 type HistoryEvent = { @@ -974,16 +945,20 @@ function route(url: string | URL, path: string) { return next } -export const node = LayerNode.make(layer, [ - Auth.node, - Session.node, - SessionPrompt.node, - httpClient, - EventV2Bridge.node, - Vcs.node, - RuntimeFlags.node, - FSUtil.node, - Database.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [ + Auth.node, + Session.node, + SessionPrompt.node, + httpClient, + EventV2Bridge.node, + Vcs.node, + RuntimeFlags.node, + FSUtil.node, + Database.node, + ], +}) export * as Workspace from "./workspace" diff --git a/packages/opencode/src/effect/app-node-builder-v1.ts b/packages/opencode/src/effect/app-node-builder-v1.ts new file mode 100644 index 0000000000..7573475d9a --- /dev/null +++ b/packages/opencode/src/effect/app-node-builder-v1.ts @@ -0,0 +1,12 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { InstanceBootstrap } from "@/project/bootstrap" +import { InstanceStore } from "@/project/instance-store" + +const bootstrapReplacement = [InstanceStore.bootstrapNode, InstanceBootstrap.node] as const + +export function build(root: LayerNode.Node, replacements: LayerNode.Replacements = []) { + return AppNodeBuilder.build(root, replacements.concat([bootstrapReplacement])) +} + +export * as AppNodeBuilderV1 from "./app-node-builder-v1" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 30dbb4c880..d17326966f 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -38,7 +38,7 @@ import { Command } from "@/command" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" import { Format } from "@/format" -import { InstanceLayer } from "@/project/instance-layer" +import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" import { Workspace } from "@/control-plane/workspace" @@ -51,59 +51,62 @@ import { memoMap } from "@opencode-ai/core/effect/memo-map" import { BackgroundJob } from "@/background/job" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilderV1 } from "./app-node-builder-v1" +import { SessionProjector } from "@opencode-ai/core/session/projector" -export const AppLayer = Layer.mergeAll( - Npm.defaultLayer, - FSUtil.defaultLayer, - Database.defaultLayer, - Auth.defaultLayer, - Account.defaultLayer, - Config.defaultLayer, - Git.defaultLayer, - Storage.defaultLayer, - Snapshot.defaultLayer, - Plugin.defaultLayer, - ModelsDev.defaultLayer, - Provider.defaultLayer, - ProviderAuth.defaultLayer, - Agent.defaultLayer, - Skill.defaultLayer, - Discovery.defaultLayer, - Question.defaultLayer, - Permission.defaultLayer, - Todo.defaultLayer, - Session.defaultLayer, - SessionStatus.defaultLayer, - BackgroundJob.defaultLayer, - RuntimeFlags.defaultLayer, - EventV2Bridge.defaultLayer, - SessionRunState.defaultLayer, - SessionProcessor.defaultLayer, - SessionCompaction.defaultLayer, - SessionRevert.defaultLayer, - SessionSummary.defaultLayer, - SessionPrompt.defaultLayer, - Instruction.defaultLayer, - LLM.defaultLayer, - LSP.defaultLayer, - MCP.defaultLayer, - McpAuth.defaultLayer, - Command.defaultLayer, - Truncate.defaultLayer, - ToolRegistry.defaultLayer, - Format.defaultLayer, - Project.defaultLayer, - Vcs.defaultLayer, - Workspace.defaultLayer, - Worktree.appLayer, - Installation.defaultLayer, - ShareNext.defaultLayer, - SessionShare.defaultLayer, -).pipe( - Layer.provideMerge(Ripgrep.defaultLayer), - Layer.provideMerge(InstanceLayer.layer), - Layer.provideMerge(Observability.layer), -) +export const AppLayer = AppNodeBuilderV1.build( + LayerNode.group([ + Npm.node, + FSUtil.node, + Database.node, + Auth.node, + Account.node, + Config.node, + Git.node, + Storage.node, + Snapshot.node, + Plugin.node, + ModelsDev.node, + Provider.node, + ProviderAuth.node, + Agent.node, + Skill.node, + Discovery.node, + Question.node, + Permission.node, + Todo.node, + Session.node, + SessionProjector.node, + SessionStatus.node, + BackgroundJob.node, + RuntimeFlags.node, + EventV2Bridge.node, + SessionRunState.node, + SessionProcessor.node, + SessionCompaction.node, + SessionRevert.node, + SessionSummary.node, + SessionPrompt.node, + Instruction.node, + LLM.node, + LSP.node, + MCP.node, + McpAuth.node, + Command.node, + Truncate.node, + ToolRegistry.node, + Format.node, + InstanceStore.node, + Project.node, + Vcs.node, + Workspace.node, + Worktree.node, + Installation.node, + ShareNext.node, + SessionShare.node, + ]), +).pipe(Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) type Runtime = Pick diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts index c1987a48ce..57fe43b46e 100644 --- a/packages/opencode/src/effect/bootstrap-runtime.ts +++ b/packages/opencode/src/effect/bootstrap-runtime.ts @@ -1,4 +1,6 @@ import { Layer, ManagedRuntime } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Plugin } from "@/plugin" import { LSP } from "@/lsp/lsp" @@ -10,14 +12,8 @@ import { Config } from "@/config/config" import * as Observability from "@opencode-ai/core/observability" import { memoMap } from "@opencode-ai/core/effect/memo-map" -export const BootstrapLayer = Layer.mergeAll( - Config.defaultLayer, - Plugin.defaultLayer, - ShareNext.defaultLayer, - Format.defaultLayer, - LSP.defaultLayer, - Vcs.defaultLayer, - Snapshot.defaultLayer, +export const BootstrapLayer = AppNodeBuilder.build( + LayerNode.group([Config.node, Plugin.node, ShareNext.node, Format.node, LSP.node, Vcs.node, Snapshot.node]), ).pipe(Layer.provide(Observability.layer)) export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap }) diff --git a/packages/opencode/src/effect/config-service.ts b/packages/opencode/src/effect/config-service.ts index 41680490a8..dcdf734436 100644 --- a/packages/opencode/src/effect/config-service.ts +++ b/packages/opencode/src/effect/config-service.ts @@ -14,9 +14,9 @@ export type Shape = { */ export type ServiceClass = Context.ServiceClass & { /** Provide already-parsed config, useful in tests. */ - readonly layer: (input: Service) => Layer.Layer + readonly configLayer: (input: Service) => Layer.Layer /** Parse config once from the active Effect ConfigProvider and provide the service. */ - readonly defaultLayer: Layer.Layer + readonly layer: Layer.Layer } /** @@ -35,19 +35,19 @@ export type ServiceClass = Context.ServiceClas * }, * ) {} * - * const live = ServerAuthConfig.defaultLayer - * const test = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" }) + * const live = ServerAuthConfig.layer + * const test = ServerAuthConfig.configLayer({ password: Option.some("secret"), username: "kit" }) * ``` */ export const Service = () => (id: Id, fields: Fields) => { class ConfigTag extends Context.Service>()(id) { - static layer(input: Shape) { + static configLayer(input: Shape) { return Layer.succeed(this, this.of(input)) } - static get defaultLayer() { + static get layer() { const tag = this return Layer.effect( tag, diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 3fa0e964f8..ec8683c63f 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -57,7 +57,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime export type Info = Context.Service.Shape -const emptyConfigLayer = Service.defaultLayer.pipe( +const emptyConfigLayer = Service.layer.pipe( Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({}))), Layer.orDie, ) @@ -71,9 +71,7 @@ export const layer = (overrides: Partial = {}) => }), ).pipe(Layer.provide(emptyConfigLayer)) -export const defaultLayer = Service.defaultLayer.pipe(Layer.orDie) - -export const node = LayerNode.make(defaultLayer, []) +export const node = LayerNode.make({ service: Service, layer: Service.layer.pipe(Layer.orDie), deps: [] }) export * as RuntimeFlags from "./runtime-flags" import { LayerNode } from "@opencode-ai/core/effect/layer-node" diff --git a/packages/opencode/src/env/index.ts b/packages/opencode/src/env/index.ts index 5f85dc6f2e..5879f27fe3 100644 --- a/packages/opencode/src/env/index.ts +++ b/packages/opencode/src/env/index.ts @@ -16,7 +16,7 @@ export class Service extends Context.Service()("@opencode/En export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const state = yield* InstanceState.make(Effect.fn("Env.state")(() => Effect.succeed({ ...process.env }))) @@ -36,8 +36,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer - -export const node = LayerNode.make(layer, []) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [] }) export * as Env from "." diff --git a/packages/opencode/src/event-manifest.ts b/packages/opencode/src/event-manifest.ts new file mode 100644 index 0000000000..b84b3f2979 --- /dev/null +++ b/packages/opencode/src/event-manifest.ts @@ -0,0 +1,3 @@ +export * as EventManifest from "./event-manifest" + +export { Definitions, Durable, Latest } from "@opencode-ai/schema/event-manifest" diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts index 14a8053f97..8e65f2752d 100644 --- a/packages/opencode/src/event-v2-bridge.ts +++ b/packages/opencode/src/event-v2-bridge.ts @@ -7,14 +7,11 @@ import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" -import "@opencode-ai/core/account" -import "@opencode-ai/core/catalog" -import "@opencode-ai/core/session/event" import { Context, Effect, Layer } from "effect" export class Service extends Context.Service()("@opencode/EventV2Bridge") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service @@ -45,10 +42,7 @@ export const layer = Layer.effect( workspace: workspaceID, payload: { id: event.id, type: event.type, properties: event.data }, }) - const sync = EventV2.registry.get(event.type)?.sync - if (sync === undefined || event.seq === undefined || event.version === undefined) return - const aggregateID = (event.data as Record)[sync.aggregate] - if (typeof aggregateID !== "string") return + if (event.durable === undefined) return GlobalBus.emit("event", { directory: event.location?.directory ?? ctx?.directory, project: ctx?.project.id, @@ -57,9 +51,9 @@ export const layer = Layer.effect( type: "sync", syncEvent: { id: event.id, - type: EventV2.versionedType(event.type, event.version), - seq: event.seq, - aggregateID, + type: EventV2.versionedType(event.type, event.durable.version), + seq: event.durable.seq, + aggregateID: event.durable.aggregateID, data: event.data, }, }, @@ -72,8 +66,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer)) - -export const node = LayerNode.make(layer, [EventV2.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2.node] }) export * as EventV2Bridge from "./event-v2-bridge" diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index e323fcc243..4288f83efc 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -28,7 +28,7 @@ export class Service extends Context.Service()("@opencode/Fo export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -194,12 +194,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(AppProcess.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), -) - -export const node = LayerNode.make(layer, [Config.node, AppProcess.node, RuntimeFlags.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, AppProcess.node, RuntimeFlags.node], +}) export * as Format from "." diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 7a37b1cb82..968e394a95 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -100,7 +100,7 @@ const kind = (code: string): Kind => { export class Service extends Context.Service()("@opencode/Git") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const appProcess = yield* AppProcess.Service @@ -343,8 +343,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer)) - -export const node = LayerNode.make(layer, [AppProcess.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [AppProcess.node] }) export * as Git from "." diff --git a/packages/opencode/src/ide/index.ts b/packages/opencode/src/ide/index.ts index 46ce480d98..441b846cad 100644 --- a/packages/opencode/src/ide/index.ts +++ b/packages/opencode/src/ide/index.ts @@ -1,7 +1,7 @@ -import { EventV2 } from "@opencode-ai/core/event" import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { Process } from "@/util/process" +import { IdeEvent } from "@opencode-ai/schema/ide-event" const SUPPORTED_IDES = [ { name: "Windsurf" as const, cmd: "windsurf" }, @@ -11,14 +11,7 @@ const SUPPORTED_IDES = [ { name: "VSCodium" as const, cmd: "codium" }, ] -export const Event = { - Installed: EventV2.define({ - type: "ide.installed", - schema: { - ide: Schema.String, - }, - }), -} +export const Event = IdeEvent export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", {}) diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts index 91c8955e15..744bd3fc95 100644 --- a/packages/opencode/src/image/image.ts +++ b/packages/opencode/src/image/image.ts @@ -56,7 +56,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Image") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -167,8 +167,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) - -export const node = LayerNode.make(layer, [Config.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Config.node] }) export * as Image from "./image" diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 0ed10dc327..4300220255 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,37 +1,25 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Effect, Layer, Schema, Context, Stream } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" -import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { withTransientReadRetry } from "@/util/effect-http-client" import { errorMessage } from "@/util/error" import { ChildProcess } from "effect/unstable/process" import { AppProcess } from "@opencode-ai/core/process" import path from "path" -import { EventV2 } from "@opencode-ai/core/event" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import semver from "semver" import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" import { NpmConfig } from "@opencode-ai/core/npm-config" +import { InstallationEvent } from "@opencode-ai/schema/installation-event" export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" export type ReleaseType = "patch" | "minor" | "major" -export const Event = { - Updated: EventV2.define({ - type: "installation.updated", - schema: { - version: Schema.String, - }, - }), - UpdateAvailable: EventV2.define({ - type: "installation.update-available", - schema: { - version: Schema.String, - }, - }), -} +export const Event = InstallationEvent export function getReleaseType(current: string, latest: string): ReleaseType { const currMajor = semver.major(current) @@ -95,7 +83,7 @@ export class Service extends Context.Service()("@opencode/In export const use = serviceUse(Service) -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const http = yield* HttpClient.HttpClient @@ -337,14 +325,12 @@ export const layer: Layer.Layer) => runPromise((s) => s.latest(...args)) export const method = () => runPromise((s) => s.method()) export const upgrade = (...args: Parameters) => runPromise((s) => s.upgrade(...args)) -export const node = LayerNode.make(layer, [httpClient, AppProcess.node]) - export * as Installation from "." diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 0949ec1be0..08d8a53d9b 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -28,7 +28,7 @@ export type Diagnostic = VSCodeDiagnostic export class InitializeError extends Schema.TaggedErrorClass()("LSPInitializeError", { serverID: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} type DocumentDiagnosticReport = { diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 0e7cf82d9d..1ab3a0b823 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -1,7 +1,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" import * as LSPClient from "./client" import path from "path" import { pathToFileURL, fileURLToPath } from "url" @@ -14,10 +13,9 @@ import { InstanceState } from "@/effect/instance-state" import { containsPath } from "@/project/instance-context" import { NonNegativeInt } from "@opencode-ai/core/schema" import { RuntimeFlags } from "@/effect/runtime-flags" +import { LspEvent } from "@opencode-ai/schema/lsp-event" -export const Event = { - Updated: EventV2.define({ type: "lsp.updated", schema: {} }), -} +export const Event = LspEvent const Position = Schema.Struct({ line: NonNegativeInt, @@ -137,7 +135,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/LSP") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -498,14 +496,12 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), -) - export * as Diagnostic from "./diagnostic" -export const node = LayerNode.make(layer, [Config.node, RuntimeFlags.node, FSUtil.node, EventV2Bridge.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, RuntimeFlags.node, FSUtil.node, EventV2Bridge.node], +}) export * as LSP from "./lsp" diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index be03760c52..808aa30296 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -50,14 +50,13 @@ export interface Interface { readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect readonly getOAuthState: (mcpName: string) => Effect.Effect readonly clearOAuthState: (mcpName: string) => Effect.Effect - readonly isTokenExpired: (mcpName: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/McpAuth") {} export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -142,13 +141,6 @@ export const layer = Layer.effect( return entry?.oauthState }) - const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) { - const entry = yield* get(mcpName) - if (!entry?.tokens) return null - if (!entry.tokens.expiresAt) return false - return entry.tokens.expiresAt < Date.now() / 1000 - }) - return Service.of({ all, get, @@ -162,13 +154,10 @@ export const layer = Layer.effect( updateOAuthState, getOAuthState, clearOAuthState, - isTokenExpired, }) }), ) -export const defaultLayer = layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(FSUtil.defaultLayer)) - -export const node = LayerNode.make(layer, [FSUtil.node, EffectFlock.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EffectFlock.node] }) export * as McpAuth from "./auth" diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index 6d4b985dd2..3f985eeb94 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -72,7 +72,8 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe .filter((text) => text.trim()) .join("\n\n") || "MCP tool returned an error", ) - if (result.structuredContent === undefined || result.structuredContent === null) return result + if (result.content.length > 0 || result.structuredContent === undefined || result.structuredContent === null) + return result return { ...result, content: [{ type: "text" as const, text: JSON.stringify(result.structuredContent) }], @@ -86,6 +87,7 @@ export function fetch( client: Client, list: (client: Client) => Promise, label: string, + key?: (item: T) => string, ) { return Effect.tryPromise({ try: () => list(client), @@ -99,8 +101,13 @@ export function fetch( ), Effect.map((items) => { const sanitizedClient = sanitize(clientName) + // Escape both the separator and escape marker so `server:uri` keys remain unambiguous. + const resourceClient = clientName.replaceAll("%", "%25").replaceAll(":", "%3A") return Object.fromEntries( - items.map((item) => [sanitizedClient + ":" + sanitize(item.name), { ...item, client: clientName }]), + items.map((item) => [ + key ? resourceClient + ":" + key(item) : sanitizedClient + ":" + sanitize(item.name), + { ...item, client: clientName }, + ]), ) }), Effect.orElseSucceed(() => undefined), @@ -109,6 +116,8 @@ export function fetch( export const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_") +export const toolName = (clientName: string, name: string) => sanitize(clientName) + "_" + sanitize(name) + export function prompts(client: Client, timeout?: number) { if (!client.getServerCapabilities()?.prompts) return Promise.resolve([]) return paginate( @@ -125,6 +134,14 @@ export function resources(client: Client, timeout?: number) { ) } +export function resourceTemplates(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.resources) return Promise.resolve([]) + return paginate( + (cursor) => client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, { timeout }), + (result) => result.resourceTemplates, + ) +} + function listTools(client: Client, timeout: number) { return Effect.tryPromise({ try: () => diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 08d58118c9..e574e20fba 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -22,19 +22,19 @@ import { NamedError } from "@opencode-ai/core/util/error" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { withTimeout } from "@/util/timeout" import { FSUtil } from "@opencode-ai/core/fs-util" -import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider" +import { McpOAuthPendingProvider, McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider" import { McpOAuthCallback } from "./oauth-callback" import { McpAuth } from "./auth" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" import { TuiEvent } from "@/server/tui-event" import open from "open" -import { Cause, Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect" +import { Cause, Effect, Exit, Layer, Context, Schema, Stream } from "effect" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { McpCatalog } from "./catalog" +import { McpEvent } from "@opencode-ai/schema/mcp-event" const DEFAULT_TIMEOUT = 30_000 const CLIENT_OPTIONS = { @@ -59,20 +59,9 @@ export const Resource = Schema.Struct({ }).annotate({ identifier: "McpResource" }) export type Resource = Schema.Schema.Type -export const ToolsChanged = EventV2.define({ - type: "mcp.tools.changed", - schema: { - server: Schema.String, - }, -}) +export const ToolsChanged = McpEvent.ToolsChanged -export const BrowserOpenFailed = EventV2.define({ - type: "mcp.browser.open.failed", - schema: { - mcpName: Schema.String, - url: Schema.String, - }, -}) +export const BrowserOpenFailed = McpEvent.BrowserOpenFailed export const Failed = NamedError.create("MCPFailed", { name: Schema.String, @@ -120,11 +109,12 @@ export type Status = Schema.Schema.Type // Store transports for OAuth servers to allow finishing auth type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport -const pendingOAuthTransports = new Map() +const pendingOAuthTransports = new Map() // Prompt cache types type PromptInfo = Awaited>["prompts"][number] type ResourceInfo = Awaited>["resources"][number] +type ResourceTemplateInfo = Awaited>["resourceTemplates"][number] type McpEntry = NonNullable[string] function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info { @@ -139,6 +129,7 @@ interface CreateResult { mcpClient?: MCPClient status: Status defs?: MCPToolDef[] + instructions?: string } interface AuthResult { @@ -154,14 +145,25 @@ interface State { status: Record clients: Record defs: Record + instructions: Record +} + +export interface ServerInstructions { + name: string + instructions: string + tools: string[] } export interface Interface { readonly status: () => Effect.Effect> readonly clients: () => Effect.Effect> + readonly instructions: () => Effect.Effect readonly tools: () => Effect.Effect> readonly prompts: () => Effect.Effect> - readonly resources: () => Effect.Effect> + readonly resources: (clientName?: string) => Effect.Effect> + readonly resourceTemplates: ( + clientName?: string, + ) => Effect.Effect> readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record | Status }> readonly connect: (name: string) => Effect.Effect readonly disconnect: (name: string) => Effect.Effect @@ -177,7 +179,10 @@ export interface Interface { readonly startAuth: ( mcpName: string, ) => Effect.Effect<{ authorizationUrl: string; oauthState: string }, NotFoundError> - readonly authenticate: (mcpName: string) => Effect.Effect + readonly authenticate: ( + mcpName: string, + onAuthorization?: (authorizationUrl: string) => void, + ) => Effect.Effect readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect readonly removeAuth: (mcpName: string) => Effect.Effect readonly supportsOAuth: (mcpName: string) => Effect.Effect @@ -189,7 +194,7 @@ export class Service extends Context.Service()("@opencode/MC export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner @@ -296,7 +301,7 @@ export const layer = Layer.effect( }) .pipe(Effect.ignore, Effect.as(undefined)) } else { - pendingOAuthTransports.set(key, transport) + pendingOAuthTransports.set(key, { transport }) lastStatus = { status: "needs_auth" as const } return events .publish(TuiEvent.ToastShow, { @@ -379,7 +384,12 @@ export const layer = Layer.effect( if (!listed) { return yield* Effect.fail(new Error("Failed to get tools")) } - return { mcpClient, status, defs: listed } satisfies CreateResult + return { + mcpClient, + status, + defs: listed, + instructions: mcpClient.getInstructions()?.trim(), + } satisfies CreateResult }).pipe( Effect.catchCause((cause) => Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore, Effect.andThen(Effect.failCause(cause))), @@ -426,6 +436,7 @@ export const layer = Layer.effect( if (s.clients[name] !== client) return delete s.clients[name] delete s.defs[name] + delete s.instructions[name] s.status[name] = { status: "failed", error: "Connection closed" } bridge.fork( Effect.logWarning("MCP connection closed", { server: name }).pipe( @@ -480,6 +491,7 @@ export const layer = Layer.effect( status: {}, clients: {}, defs: {}, + instructions: {}, } yield* Effect.forEach( @@ -501,6 +513,7 @@ export const layer = Layer.effect( if (result.mcpClient) { s.clients[key] = result.mcpClient s.defs[key] = result.defs! + if (result.instructions) s.instructions[key] = result.instructions watch(s, key, result.mcpClient, bridge, mcp.timeout) } }), @@ -512,6 +525,7 @@ export const layer = Layer.effect( const clients = Object.values(s.clients) s.clients = {} s.defs = {} + s.instructions = {} yield* Effect.forEach( clients, (client) => @@ -541,6 +555,7 @@ export const layer = Layer.effect( const client = s.clients[name] delete s.clients[name] delete s.defs[name] + delete s.instructions[name] if (!client) return Effect.void return Effect.tryPromise(() => client.close()).pipe(Effect.ignore) } @@ -550,6 +565,7 @@ export const layer = Layer.effect( name: string, client: MCPClient, listed: MCPToolDef[], + instructions: string | undefined, timeout?: number, ) { const bridge = yield* EffectBridge.make() @@ -557,6 +573,8 @@ export const layer = Layer.effect( s.status[name] = { status: "connected" } s.clients[name] = client s.defs[name] = listed + if (instructions) s.instructions[name] = instructions + else delete s.instructions[name] watch(s, name, client, bridge, timeout) if (previous) yield* Effect.tryPromise(() => previous.close()).pipe(Effect.ignore) return s.status[name] @@ -586,6 +604,18 @@ export const layer = Layer.effect( return s.clients }) + const instructions = Effect.fn("MCP.instructions")(function* () { + const s = yield* InstanceState.get(state) + return Object.entries(s.instructions) + .filter(([name]) => s.status[name]?.status === "connected") + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, item]) => ({ + name, + instructions: item, + tools: (s.defs[name] ?? []).map((tool) => McpCatalog.toolName(name, tool.name)), + })) + }) + const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCPV1.Info) { const s = yield* InstanceState.get(state) const result = yield* create(name, mcp) @@ -597,7 +627,7 @@ export const layer = Layer.effect( return result.status } - return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout) + return yield* storeClient(s, name, result.mcpClient, result.defs!, result.instructions, mcp.timeout) }) const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCPV1.Info) { @@ -643,7 +673,7 @@ export const layer = Layer.effect( } const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout) for (const mcpTool of listed) { - const key = McpCatalog.sanitize(clientName) + "_" + McpCatalog.sanitize(mcpTool.name) + const key = McpCatalog.toolName(clientName, mcpTool.name) result[key] = McpCatalog.convertTool(mcpTool, client, timeout) } } @@ -654,17 +684,22 @@ export const layer = Layer.effect( s: State, listFn: (c: Client, timeout?: number) => Promise, label: string, + key?: (item: T) => string, + targetClientName?: string, ) { return Effect.gen(function* () { const cfg = yield* cfgSvc.get() return yield* Effect.forEach( - Object.entries(s.clients).filter(([name]) => s.status[name]?.status === "connected"), + Object.entries(s.clients).filter( + ([name]) => s.status[name]?.status === "connected" && (!targetClientName || name === targetClientName), + ), ([clientName, client]) => McpCatalog.fetch( clientName, client, (c) => listFn(c, requestTimeout(s, clientName, cfg.mcp?.[clientName], cfg.experimental?.mcp_timeout)), label, + key, ).pipe(Effect.map((items) => Object.entries(items ?? {}))), { concurrency: "unbounded" }, ).pipe(Effect.map((results) => Object.fromEntries(results.flat()))) @@ -675,8 +710,24 @@ export const layer = Layer.effect( return yield* collectFromConnected(yield* InstanceState.get(state), McpCatalog.prompts, "prompts") }) - const resources = Effect.fn("MCP.resources")(function* () { - return yield* collectFromConnected(yield* InstanceState.get(state), McpCatalog.resources, "resources") + const resources = Effect.fn("MCP.resources")(function* (clientName?: string) { + return yield* collectFromConnected( + yield* InstanceState.get(state), + McpCatalog.resources, + "resources", + (resource) => resource.uri, + clientName, + ) + }) + + const resourceTemplates = Effect.fn("MCP.resourceTemplates")(function* (clientName?: string) { + return yield* collectFromConnected( + yield* InstanceState.get(state), + McpCatalog.resourceTemplates, + "resource templates", + (template) => template.uriTemplate, + clientName, + ) }) const withClient = Effect.fnUntraced(function* ( @@ -768,7 +819,7 @@ export const layer = Layer.effect( .join("") yield* auth.updateOAuthState(mcpName, oauthState) let capturedUrl: URL | undefined - const authProvider = new McpOAuthProvider( + const authProvider = new McpOAuthPendingProvider( mcpName, mcpConfig.url, { @@ -794,15 +845,16 @@ export const layer = Layer.effect( return yield* Effect.tryPromise({ try: () => { const client = createClient(directory) - return client - .connect(transport) - .then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult) + return client.connect(transport).then(async () => { + await authProvider.commit() + return { authorizationUrl: "", oauthState, client } satisfies AuthResult + }) }, catch: (error) => error, }).pipe( Effect.catch((error) => { if (error instanceof UnauthorizedError && capturedUrl) { - pendingOAuthTransports.set(mcpName, transport) + pendingOAuthTransports.set(mcpName, { transport, provider: authProvider }) return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult) } return Effect.die(error) @@ -810,7 +862,10 @@ export const layer = Layer.effect( ) }) - const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) { + const authenticate = Effect.fn("MCP.authenticate")(function* ( + mcpName: string, + onAuthorization?: (authorizationUrl: string) => void, + ) { const result = yield* startAuth(mcpName) if (!result.authorizationUrl) { const client = "client" in result ? result.client : undefined @@ -830,10 +885,11 @@ export const layer = Layer.effect( const s = yield* InstanceState.get(state) yield* auth.clearOAuthState(mcpName) - return yield* storeClient(s, mcpName, client, listed, mcpConfig.timeout) + return yield* storeClient(s, mcpName, client, listed, client.getInstructions()?.trim(), mcpConfig.timeout) } const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName) + onAuthorization?.(result.authorizationUrl) yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe( Effect.flatMap((subprocess) => @@ -869,26 +925,28 @@ export const layer = Layer.effect( const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) { yield* requireMcpConfig(mcpName) - const transport = pendingOAuthTransports.get(mcpName) - if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`) + const pending = pendingOAuthTransports.get(mcpName) + if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`) - const result = yield* Effect.tryPromise({ - try: () => transport.finishAuth(authorizationCode).then(() => true as const), - catch: (error) => { - return error - }, - }).pipe(Effect.option) + const error = yield* Effect.tryPromise({ + try: () => pending.transport.finishAuth(authorizationCode), + catch: (error) => error, + }).pipe( + Effect.match({ + onFailure: (error) => (error instanceof Error ? error.message : String(error)), + onSuccess: () => undefined, + }), + ) - if (Option.isNone(result)) { - return { status: "failed", error: "OAuth completion failed" } satisfies Status - } + if (error) return { status: "failed", error: `OAuth completion failed: ${error}` } satisfies Status + yield* Effect.promise(() => pending.provider?.commit() ?? Promise.resolve()) yield* auth.clearCodeVerifier(mcpName) pendingOAuthTransports.delete(mcpName) const mcpConfig = yield* requireMcpConfig(mcpName) - return yield* createAndStore(mcpName, mcpConfig) + return yield* createAndStore(mcpName, { ...mcpConfig, enabled: true }) }) const removeAuth = Effect.fn("MCP.removeAuth")(function* (mcpName: string) { @@ -908,18 +966,25 @@ export const layer = Layer.effect( }) const getAuthStatus = Effect.fn("MCP.getAuthStatus")(function* (mcpName: string) { - const entry = yield* auth.get(mcpName) + const runtimeConfig = (yield* InstanceState.has(state)) + ? (yield* InstanceState.get(state)).config[mcpName] + : undefined + const mcpConfig = runtimeConfig ?? (yield* cfgSvc.get()).mcp?.[mcpName] + if (!mcpConfig || !isMcpConfigured(mcpConfig) || mcpConfig.type !== "remote") return "not_authenticated" + const entry = yield* auth.getForUrl(mcpName, mcpConfig.url) if (!entry?.tokens) return "not_authenticated" - const expired = yield* auth.isTokenExpired(mcpName) - return expired ? "expired" : "authenticated" + if (entry.tokens.expiresAt && entry.tokens.expiresAt < Date.now() / 1000) return "expired" + return "authenticated" }) return Service.of({ status, clients, + instructions, tools, prompts, resources, + resourceTemplates, add, connect, disconnect, @@ -938,16 +1003,10 @@ export const layer = Layer.effect( export type AuthStatus = "authenticated" | "expired" | "not_authenticated" -// --- Per-service runtime --- - -export const defaultLayer = layer.pipe( - Layer.provide(McpAuth.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(FSUtil.defaultLayer), -) - -export const node = LayerNode.make(layer, [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node], +}) export * as MCP from "." diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index 6b88f802a1..84007902b8 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -1,53 +1,14 @@ import { createConnection } from "net" import { createServer } from "http" -import { escapeHtml } from "@/util/html" +import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" +const OAUTH_CALLBACK_HOST = "127.0.0.1" + // Current callback server configuration (may differ from defaults if custom redirectUri is used) let currentPort = OAUTH_CALLBACK_PORT let currentPath = OAUTH_CALLBACK_PATH -const HTML_SUCCESS = ` - - - OpenCode - Authorization Successful - - - -
-

Authorization Successful

-

You can close this window and return to OpenCode.

-
- - -` - -const HTML_ERROR = (error: string) => ` - - - OpenCode - Authorization Failed - - - -
-

Authorization Failed

-

An error occurred during authorization.

-
${escapeHtml(error)}
-
- -` - interface PendingAuth { resolve: (code: string) => void reject: (error: Error) => void @@ -96,7 +57,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). if (!state) { const errorMsg = "Missing required state parameter - potential CSRF attack" res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) - res.end(HTML_ERROR(errorMsg)) + res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" })) return } @@ -110,14 +71,14 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). pending.reject(new Error(errorMsg)) } res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) - res.end(HTML_ERROR(errorMsg)) + res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" })) stopIfIdle() return } if (!code) { res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) - res.end(HTML_ERROR("No authorization code provided")) + res.end(OauthCallbackPage.error("No authorization code provided", { provider: "MCP" })) return } @@ -125,7 +86,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). if (!pendingAuths.has(state)) { const errorMsg = "Invalid or expired state parameter - potential CSRF attack" res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) - res.end(HTML_ERROR(errorMsg)) + res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" })) return } @@ -137,7 +98,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). pending.resolve(code) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) - res.end(HTML_SUCCESS) + res.end(OauthCallbackPage.success({ provider: "MCP" })) stopIfIdle() } @@ -162,7 +123,7 @@ export async function ensureRunning(redirectUri?: string): Promise { server = createServer(handleRequest) await new Promise((resolve, reject) => { - server!.listen(currentPort, () => { + server!.listen(currentPort, OAUTH_CALLBACK_HOST, () => { resolve() }) server!.on("error", reject) diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index aa29777f54..596bfe1d55 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -25,11 +25,11 @@ export interface McpOAuthCallbacks { export class McpOAuthProvider implements OAuthClientProvider { constructor( - private mcpName: string, - private serverUrl: string, - private config: McpOAuthConfig, + protected mcpName: string, + protected serverUrl: string, + protected config: McpOAuthConfig, private callbacks: McpOAuthCallbacks, - private auth: McpAuth.Interface, + protected auth: McpAuth.Interface, ) {} get redirectUrl(): string { @@ -53,7 +53,6 @@ export class McpOAuthProvider implements OAuthClientProvider { } async clientInformation(): Promise { - // Check config first (pre-registered client) if (this.config.clientId) { return { client_id: this.config.clientId, @@ -164,10 +163,7 @@ export class McpOAuthProvider implements OAuthClientProvider { async invalidateCredentials(type: "all" | "client" | "tokens"): Promise { const entry = await Effect.runPromise(this.auth.get(this.mcpName)) - if (!entry) { - return - } - + if (!entry) return switch (type) { case "all": await Effect.runPromise(this.auth.remove(this.mcpName)) @@ -184,6 +180,63 @@ export class McpOAuthProvider implements OAuthClientProvider { } } +export class McpOAuthPendingProvider extends McpOAuthProvider { + private pendingClientInfo?: OAuthClientInformationFull + private pendingTokens?: OAuthTokens + + override async clientInformation(): Promise { + if (!this.config.clientId) return this.pendingClientInfo + return { + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + } + } + + override async saveClientInformation(info: OAuthClientInformationFull): Promise { + this.pendingClientInfo = info + } + + override async tokens(): Promise { + return this.pendingTokens + } + + override async saveTokens(tokens: OAuthTokens): Promise { + this.pendingTokens = tokens + } + + override async invalidateCredentials(type: "all" | "client" | "tokens"): Promise { + if (type === "all" || type === "client") this.pendingClientInfo = undefined + if (type === "all" || type === "tokens") this.pendingTokens = undefined + } + + async commit(): Promise { + if (!this.pendingTokens) return + await Effect.runPromise( + this.auth.set( + this.mcpName, + { + tokens: { + accessToken: this.pendingTokens.access_token, + refreshToken: this.pendingTokens.refresh_token, + expiresAt: this.pendingTokens.expires_in ? Date.now() / 1000 + this.pendingTokens.expires_in : undefined, + scope: this.pendingTokens.scope, + }, + clientInfo: + this.pendingClientInfo && !this.config.clientId + ? { + clientId: this.pendingClientInfo.client_id, + clientSecret: this.pendingClientInfo.client_secret, + clientIdIssuedAt: this.pendingClientInfo.client_id_issued_at, + clientSecretExpiresAt: this.pendingClientInfo.client_secret_expires_at, + } + : undefined, + }, + this.serverUrl, + ), + ) + } +} + export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } /** diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index cd1f935ada..396d192018 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -6,19 +6,8 @@ import { Deferred, Effect, Layer, Context } from "effect" import os from "os" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" -export const Event = { - Asked: EventV2.define({ type: "permission.asked", schema: PermissionV1.Request.fields }), - Replied: EventV2.define({ - type: "permission.replied", - schema: { - sessionID: PermissionV1.Request.fields.sessionID, - requestID: PermissionV1.ID, - reply: PermissionV1.Reply, - }, - }), -} +export const Event = PermissionV1.Event export interface Interface { readonly ask: (input: PermissionV1.AskInput) => Effect.Effect @@ -50,7 +39,7 @@ export function evaluate(permission: string, pattern: string, ...rulesets: Permi export class Service extends Context.Service()("@opencode/Permission") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service @@ -214,17 +203,16 @@ export function merge(...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule[] export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set { const edits = ["edit", "write", "apply_patch"] + const reads = ["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"] return new Set( tools.filter((tool) => { - const permission = edits.includes(tool) ? "edit" : tool + const permission = edits.includes(tool) ? "edit" : reads.includes(tool) ? "read" : tool const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) return rule?.pattern === "*" && rule.action === "deny" }), ) } -export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) - -export const node = LayerNode.make(layer, [EventV2Bridge.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] }) export * as Permission from "." diff --git a/packages/opencode/src/plugin/digitalocean.ts b/packages/opencode/src/plugin/digitalocean.ts index cb6a628506..0aa547a31e 100644 --- a/packages/opencode/src/plugin/digitalocean.ts +++ b/packages/opencode/src/plugin/digitalocean.ts @@ -1,6 +1,7 @@ import type { Hooks, PluginInput } from "@kilocode/plugin" import type { Model } from "@kilocode/sdk/v2" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" import { createServer } from "http" import open from "open" @@ -58,65 +59,6 @@ function buildAuthorizeUrl(state: string): string { return `${DO_AUTHORIZE_URL}?${params.toString()}` } -const HTML_CALLBACK = ` - - - - OpenCode - DigitalOcean Authorization - - - -
-

Finishing sign-in...

-

You can close this window once it says you're signed in.

-
- - -` - async function startOAuthServer(): Promise { if (oauthServer) return oauthServer = createServer((req, res) => { @@ -124,7 +66,7 @@ async function startOAuthServer(): Promise { if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) { res.writeHead(200, { "Content-Type": "text/html" }) - res.end(HTML_CALLBACK) + res.end(OauthCallbackPage.bootstrap({ tokenPath: OAUTH_TOKEN_PATH, provider: "DigitalOcean" })) return } diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index ce4c16add0..90dcf49f6c 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -120,7 +120,7 @@ async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: } } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service @@ -305,12 +305,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), -) - -export const node = LayerNode.make(layer, [EventV2Bridge.node, Config.node, RuntimeFlags.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [EventV2Bridge.node, Config.node, RuntimeFlags.node], +}) export * as Plugin from "." diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index 197eb383df..0eb0e7bd8d 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -5,7 +5,7 @@ import os from "os" import { setTimeout as sleep } from "node:timers/promises" import { createServer } from "http" import { OpenAIWebSocketPool } from "./ws-pool" -import { escapeHtml } from "@/util/html" +import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" const ISSUER = "https://auth.openai.com" @@ -13,6 +13,7 @@ const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" const OAUTH_PORT = 1455 const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"]) +const DISALLOWED_MODELS = new Set(["gpt-5.5-pro"]) interface PkceCodes { verifier: string @@ -137,95 +138,8 @@ async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promis return response.json() } -const HTML_SUCCESS = ` - - - OpenCode - Codex Authorization Successful - - - -
-

Authorization Successful

-

You can close this window and return to OpenCode.

-
- - -` - -export const renderOAuthError = (error: string) => ` - - - OpenCode - Codex Authorization Failed - - - -
-

Authorization Failed

-

An error occurred during authorization.

-
${escapeHtml(error)}
-
- -` +// Kept as a named export for plugin.codex tests; delegates to the shared branded page. +export const renderOAuthError = (error: string) => OauthCallbackPage.error(error, { provider: "ChatGPT" }) interface PendingOAuth { pkce: PkceCodes @@ -286,7 +200,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } .catch((err) => current.reject(err)) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) - res.end(HTML_SUCCESS) + res.end(OauthCallbackPage.success({ provider: "ChatGPT" })) return } @@ -370,6 +284,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug Object.entries(provider.models) .filter(([, model]) => { if (ALLOWED_MODELS.has(model.api.id)) return true + if (DISALLOWED_MODELS.has(model.api.id)) return false const match = model.api.id.match(/^gpt-(\d+\.\d+)/) return match ? parseFloat(match[1]) > 5.4 : false }) diff --git a/packages/opencode/src/plugin/snowflake-cortex.ts b/packages/opencode/src/plugin/snowflake-cortex.ts index f768389944..07a58e4bea 100644 --- a/packages/opencode/src/plugin/snowflake-cortex.ts +++ b/packages/opencode/src/plugin/snowflake-cortex.ts @@ -1,6 +1,7 @@ import type { Hooks, PluginInput } from "@kilocode/plugin" import { OAUTH_DUMMY_KEY } from "../auth" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" import { createServer } from "http" import open from "open" @@ -156,29 +157,6 @@ async function refreshAccessToken(account: string, refreshToken: string) { return token } -const HTML_SUCCESS = ` - - OpenCode - Snowflake Authorization Successful - -
-

Authorization Successful

-

You can close this window and return to OpenCode.

-
- - -` - -const htmlError = (message: string) => ` - - OpenCode - Snowflake Authorization Failed - -
-

Authorization Failed

-
${message}
-
- -` - async function startOAuthServer() { if (oauthServer) return @@ -203,7 +181,7 @@ async function startOAuthServer() { pendingOAuth?.reject(new Error(message)) pendingOAuth = undefined res.writeHead(400, { "Content-Type": "text/html" }) - res.end(htmlError(message)) + res.end(OauthCallbackPage.error(message, { provider: "Snowflake" })) return } @@ -214,7 +192,7 @@ async function startOAuthServer() { const message = errorDescription || error current.reject(new Error(message)) res.writeHead(200, { "Content-Type": "text/html" }) - res.end(htmlError(message)) + res.end(OauthCallbackPage.error(message, { provider: "Snowflake" })) return } @@ -222,7 +200,7 @@ async function startOAuthServer() { const message = "Missing authorization code" current.reject(new Error(message)) res.writeHead(400, { "Content-Type": "text/html" }) - res.end(htmlError(message)) + res.end(OauthCallbackPage.error(message, { provider: "Snowflake" })) return } @@ -231,7 +209,7 @@ async function startOAuthServer() { .catch((err) => current.reject(err instanceof Error ? err : new Error(String(err)))) res.writeHead(200, { "Content-Type": "text/html" }) - res.end(HTML_SUCCESS) + res.end(OauthCallbackPage.success({ provider: "Snowflake" })) }) await new Promise((resolve, reject) => { diff --git a/packages/opencode/src/plugin/tui/runtime.ts b/packages/opencode/src/plugin/tui/runtime.ts index e2216a4b7a..9008a8cf5b 100644 --- a/packages/opencode/src/plugin/tui/runtime.ts +++ b/packages/opencode/src/plugin/tui/runtime.ts @@ -14,6 +14,7 @@ import { import path from "path" import { fileURLToPath } from "url" import { TuiConfig } from "@/config/tui" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { errorData, errorMessage } from "@opencode-ai/tui/util/error" import { isRecord } from "@opencode-ai/tui/util/record" import { resolveHostAttentionSoundPaths } from "@/config/tui-host-attention" @@ -1082,7 +1083,7 @@ async function load(input: { const flags = await Effect.runPromise( Effect.gen(function* () { return yield* RuntimeFlags.Service - }).pipe(Effect.provide(RuntimeFlags.defaultLayer)), + }).pipe(Effect.provide(AppNodeBuilder.build(RuntimeFlags.node))), ) const pluginOrigins = config.plugin_origins ?? (await TuiConfig.pluginOrigins()) const records = Flag.KILO_PURE ? [] : pluginOrigins diff --git a/packages/opencode/src/plugin/xai.ts b/packages/opencode/src/plugin/xai.ts index 4ff90b9d00..0814a71be1 100644 --- a/packages/opencode/src/plugin/xai.ts +++ b/packages/opencode/src/plugin/xai.ts @@ -2,7 +2,7 @@ import type { Hooks, PluginInput } from "@kilocode/plugin" import { OAUTH_DUMMY_KEY } from "../auth" import { createServer } from "http" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { escapeHtml } from "@/util/html" +import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" // Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from // non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships @@ -285,96 +285,6 @@ export async function pollDeviceCodeToken( throw new Error("xAI device authorization timed out") } -const HTML_SUCCESS = ` - - - OpenCode - xAI Authorization Successful - - - -
-

Authorization Successful

-

You can close this window and return to OpenCode.

-
- - -` - -const HTML_ERROR = (error: string) => ` - - - OpenCode - xAI Authorization Failed - - - -
-

Authorization Failed

-

An error occurred during authorization.

-
${escapeHtml(error)}
-
- -` - // CORS allowlist for the loopback callback. The redirect_uri itself is // already bound to 127.0.0.1 and gated by PKCE+state, so we only accept // xAI's own auth origins for additional defense-in-depth on the OPTIONS @@ -425,7 +335,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined res.writeHead(200, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) + res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" })) return } @@ -434,7 +344,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined res.writeHead(400, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) + res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" })) return } @@ -443,7 +353,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined res.writeHead(400, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) + res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" })) return } @@ -455,7 +365,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } .catch((err) => current.reject(err)) res.writeHead(200, { "Content-Type": "text/html" }) - res.end(HTML_SUCCESS) + res.end(OauthCallbackPage.success({ provider: "xAI" })) return } diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 0bbe3d4abe..acf0caed3c 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -1,4 +1,4 @@ -import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" import { Plugin } from "../plugin" import { Format } from "../format" import { LSP } from "@/lsp/lsp" @@ -14,7 +14,7 @@ import { Service } from "./bootstrap-service" export { Service } from "./bootstrap-service" export type { Interface } from "./bootstrap-service" -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { // Yield each bootstrap dep at layer init so `run` itself has R = never. @@ -49,28 +49,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer: Layer.Layer = layer.pipe( - Layer.provide([ - Config.defaultLayer, - Format.defaultLayer, - LSP.defaultLayer, - Plugin.defaultLayer, - Project.defaultLayer, - ShareNext.defaultLayer, - Snapshot.defaultLayer, - Vcs.defaultLayer, - ]), -) - -export const node = LayerNode.make(layer, [ - Config.node, - Format.node, - LSP.node, - Plugin.node, - Project.node, - ShareNext.node, - Snapshot.node, - Vcs.node, -]) +export const node = makeGlobalNode({ + service: Service, + layer: layer, + deps: [Config.node, Format.node, LSP.node, Plugin.node, Project.node, ShareNext.node, Snapshot.node, Vcs.node], +}) export * as InstanceBootstrap from "./bootstrap" diff --git a/packages/opencode/src/project/instance-layer.ts b/packages/opencode/src/project/instance-layer.ts deleted file mode 100644 index a7e2bfcb7b..0000000000 --- a/packages/opencode/src/project/instance-layer.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Effect, Layer } from "effect" -import { InstanceStore } from "./instance-store" - -export const layer = Layer.unwrap( - Effect.promise(async () => { - const { InstanceBootstrap } = await import("./bootstrap") - return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)) - }), -) - -export * as InstanceLayer from "./instance-layer" diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index aab8f60c85..720549ddaf 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -1,4 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { makeGlobalNode, Node } from "@opencode-ai/core/effect/app-node" import { GlobalBus } from "@/bus/global" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { WorkspaceContext } from "@/control-plane/workspace-context" @@ -8,7 +9,6 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" import { type InstanceContext } from "./instance-context" import { InstanceBootstrap } from "./bootstrap-service" -import { InstanceBootstrap as InstanceBootstrapGraph } from "./bootstrap" import * as Project from "./project" export interface LoadInput { @@ -34,7 +34,7 @@ interface Entry { readonly deferred: Deferred.Deferred } -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const project = yield* Project.Service @@ -202,8 +202,12 @@ export const layer: Layer.Layer> export const Event = { - Updated: EventV2.define({ type: "project.updated", schema: Info.fields }), + Updated: Project.Event.Updated, } type Row = typeof ProjectTable.$inferSelect @@ -72,7 +44,7 @@ export function fromRow(row: Row): Info { return { id: row.id, worktree: row.worktree, - vcs: row.vcs ? Schema.decodeUnknownSync(ProjectVcs)(row.vcs) : undefined, + vcs: row.vcs ? Schema.decodeUnknownSync(Project.Vcs)(row.vcs) : undefined, name: row.name ?? undefined, icon, time: { @@ -88,15 +60,15 @@ export function fromRow(row: Row): Info { export const UpdateInput = Schema.Struct({ projectID: ProjectV2.ID, name: Schema.optional(Schema.String), - icon: Schema.optional(ProjectIcon), - commands: Schema.optional(ProjectCommands), + icon: Schema.optional(Project.Icon), + commands: Schema.optional(Project.Commands), }) export type UpdateInput = Types.DeepMutable> export const UpdatePayload = Schema.Struct({ name: Schema.optional(Schema.String), - icon: Schema.optional(ProjectIcon), - commands: Schema.optional(ProjectCommands), + icon: Schema.optional(Project.Icon), + commands: Schema.optional(Project.Commands), }).annotate({ identifier: "ProjectUpdateInput" }) export type UpdatePayload = Types.DeepMutable> @@ -131,11 +103,10 @@ export class Service extends Context.Service()("@opencode/Pr type GitResult = { code: number; text: string; stderr: string } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service - const proc = yield* AppProcess.Service const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const projectV2 = yield* ProjectV2.Service const projectDirectories = yield* ProjectDirectories.Service @@ -168,7 +139,7 @@ export const layer = Layer.effect( }), ) - const fakeVcs = Schema.decodeUnknownSync(Schema.optional(ProjectVcs))(Flag.KILO_FAKE_VCS) + const fakeVcs = Schema.decodeUnknownSync(Schema.optional(Project.Vcs))(Flag.KILO_FAKE_VCS) const scope = yield* Scope.Scope @@ -492,28 +463,21 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.provide(ProjectDirectories.defaultLayer), - Layer.provide(AppProcess.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), -) - export const use = serviceUse(Service) -export const node = LayerNode.make(layer, [ - FSUtil.node, - AppProcess.node, - CrossSpawnSpawner.node, - ProjectV2.node, - ProjectDirectories.node, - EventV2Bridge.node, - RuntimeFlags.node, - Database.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [ + FSUtil.node, + AppProcess.node, + CrossSpawnSpawner.node, + ProjectV2.node, + ProjectDirectories.node, + EventV2Bridge.node, + RuntimeFlags.node, + Database.node, + ], +}) export * as Project from "./project" diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index decb3cfd98..eca56c0501 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,11 +1,12 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Effect, Layer, Context, Schema, Stream, Scope } from "effect" +import { Effect, Layer, Context, Schema, Scope } from "effect" import { formatPatch, structuredPatch } from "diff" import { InstanceState } from "@/effect/instance-state" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Git } from "@/git" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" +import { VcsEvent } from "@opencode-ai/schema/vcs-event" const PATCH_CONTEXT_LINES = 2_147_483_647 const MAX_PATCH_BYTES = 10_000_000 @@ -234,14 +235,7 @@ const track = Effect.fnUntraced(function* ( export const Mode = Schema.Literals(["git", "branch"]) export type Mode = Schema.Schema.Type -export const Event = { - BranchUpdated: EventV2.define({ - type: "vcs.branch.updated", - schema: { - branch: Schema.optional(Schema.String), - }, - }), -} +export const Event = VcsEvent export const Info = Schema.Struct({ branch: Schema.optional(Schema.String), @@ -301,7 +295,7 @@ interface State { export class Service extends Context.Service()("@opencode/Vcs") {} -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const git = yield* Git.Service @@ -424,8 +418,6 @@ export const layer: Layer.Layer("ProviderAuthMethod")({ type: Schema.Literals(["oauth", "api"]), label: Schema.String, - prompts: optionalOmitUndefined(Schema.Array(Prompt)), + prompts: optional(Schema.Array(Prompt)), }) {} export const Methods = Schema.Record(Schema.String, Schema.Array(Method)) @@ -106,7 +106,7 @@ export class Service extends Context.Service()("@opencode/Pr export const use = serviceUse(Service) -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const auth = yield* Auth.Service @@ -224,10 +224,6 @@ export const layer: Layer.Layer = }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)), -) - -export const node = LayerNode.make(layer, [Auth.node, Plugin.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Auth.node, Plugin.node] }) export * as ProviderAuth from "./auth" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 63ad8d0d7f..0ece8e9ead 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -24,7 +24,7 @@ import { InstanceState } from "@/effect/instance-state" import { EffectPromise } from "@/effect/promise" import { FSUtil } from "@opencode-ai/core/fs-util" import { isRecord } from "@/util/record" -import { optionalOmitUndefined } from "@opencode-ai/core/schema" +import { optional } from "@opencode-ai/core/schema" import { ProviderTransform } from "./transform" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -999,8 +999,8 @@ const ProviderCost = Schema.Struct({ input: Schema.Finite, output: Schema.Finite, cache: ProviderCacheCost, - tiers: optionalOmitUndefined(Schema.Array(ProviderCostTier)), - experimentalOver200K: optionalOmitUndefined( + tiers: optional(Schema.Array(ProviderCostTier)), + experimentalOver200K: optional( Schema.Struct({ input: Schema.Finite, output: Schema.Finite, @@ -1011,7 +1011,7 @@ const ProviderCost = Schema.Struct({ const ProviderLimit = Schema.Struct({ context: Schema.Finite, - input: optionalOmitUndefined(Schema.Finite), + input: optional(Schema.Finite), output: Schema.Finite, }) @@ -1020,7 +1020,7 @@ export const Model = Schema.Struct({ providerID: ProviderV2.ID, api: ProviderApiInfo, name: Schema.String, - family: optionalOmitUndefined(Schema.String), + family: optional(Schema.String), capabilities: ProviderCapabilities, cost: ProviderCost, limit: ProviderLimit, @@ -1028,7 +1028,7 @@ export const Model = Schema.Struct({ options: Schema.Record(Schema.String, Schema.Any), headers: Schema.Record(Schema.String, Schema.String), release_date: Schema.String, - variants: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), + variants: optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), }).annotate({ identifier: "Model" }) export type Model = Types.DeepMutable> @@ -1037,7 +1037,7 @@ export const Info = Schema.Struct({ name: Schema.String, source: Schema.Literals(["env", "config", "custom", "api"]), env: Schema.Array(Schema.String), - key: optionalOmitUndefined(Schema.String), + key: optional(Schema.String), options: Schema.Record(Schema.String, Schema.Any), models: Schema.Record(Schema.String, Model), }).annotate({ identifier: "Provider" }) @@ -1076,8 +1076,13 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass()("ProviderInitError", { providerID: ProviderV2.ID, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) { + override get message() { + return `Failed to initialize provider: ${this.providerID}` + } + static isInstance(input: unknown): input is InitError { return input instanceof InitError } } export class NoProvidersError extends Schema.TaggedErrorClass()("ProviderNoProvidersError", {}) { + override get message() { + return "No providers are available" + } + static isInstance(input: unknown): input is NoProvidersError { return input instanceof NoProvidersError } @@ -1101,6 +1114,10 @@ export class NoProvidersError extends Schema.TaggedErrorClass( export class NoModelsError extends Schema.TaggedErrorClass()("ProviderNoModelsError", { providerID: ProviderV2.ID, }) { + override get message() { + return `No models are available for provider: ${this.providerID}` + } + static isInstance(input: unknown): input is NoModelsError { return input instanceof NoModelsError } @@ -1282,7 +1299,7 @@ function modelSuggestions(provider: Info | undefined, modelID: ModelV2.ID, enabl .map((item) => item.id) } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -1851,44 +1868,43 @@ export const layer = Layer.effect( } } - const defaultPriority = [ - "claude-haiku-4-5", - "claude-haiku-4.5", - "3-5-haiku", - "3.5-haiku", - "gemini-3-flash", - "gemini-2.5-flash", - "gpt-5-nano", - ] + // TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments. + if (providerID === ProviderV2.ID.azure || providerID === ProviderV2.ID.make("azure-cognitive-services")) { + return undefined + } + const priority = providerID.startsWith("opencode") - ? ["gpt-5-nano"] + ? ["gpt-nano"] : providerID.startsWith("github-copilot") - ? ["gpt-5-mini", "claude-haiku-4.5", ...defaultPriority] - : defaultPriority - for (const item of priority) { + ? ["gpt-mini", ...smallModelFamilyPriority] + : smallModelFamilyPriority + const models = sortBy( + Object.values(provider.models), + [(model) => model.release_date, "desc"], + [(model) => model.id, "desc"], + ) + for (const family of priority) { + const candidates = models.filter((model) => model.family === family) if (providerID === ProviderV2.ID.amazonBedrock) { const crossRegionPrefixes = ["global.", "us.", "eu."] - const candidates = Object.keys(provider.models).filter((m) => m.includes(item)) - const globalMatch = candidates.find((m) => m.startsWith("global.")) - if (globalMatch) return provider.models[globalMatch] + const globalMatch = candidates.find((model) => model.id.startsWith("global.")) + if (globalMatch) return globalMatch const region = provider.options?.region if (region) { const regionPrefix = region.split("-")[0] if (regionPrefix === "us" || regionPrefix === "eu") { - const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`)) - if (regionalMatch) return provider.models[regionalMatch] + const regionalMatch = candidates.find((model) => model.id.startsWith(`${regionPrefix}.`)) + if (regionalMatch) return regionalMatch } } - const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p))) - if (unprefixed) return provider.models[unprefixed] - } else { - for (const model of Object.keys(provider.models)) { - if (model.includes(item)) return provider.models[model] - } + const unprefixed = candidates.find((model) => !crossRegionPrefixes.some((p) => model.id.startsWith(p))) + if (unprefixed) return unprefixed + continue } + if (candidates[0]) return candidates[0] } return undefined @@ -1918,7 +1934,8 @@ export const layer = Layer.effect( return { providerID: entry.providerID, modelID: entry.modelID } } - const provider = Object.values(s.providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id)) + const configured = Object.keys(cfg.provider ?? {}) + const provider = Object.values(s.providers).find((p) => configured.length === 0 || configured.includes(p.id)) if (!provider) return yield* new NoProvidersError() const [model] = sort(Object.values(provider.models)) if (!model) return yield* new NoModelsError({ providerID: provider.id }) @@ -1932,19 +1949,8 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(ModelsDev.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - ), -) - const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] +const smallModelFamilyPriority = ["gemini-flash", "gpt-nano", "claude-haiku"] export function sort(models: T[]) { return sortBy( models, @@ -1962,14 +1968,10 @@ export function parseModel(model: string) { } } -export const node = LayerNode.make(layer, [ - FSUtil.node, - Config.node, - Auth.node, - Env.node, - Plugin.node, - ModelsDev.node, - RuntimeFlags.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [FSUtil.node, Config.node, Auth.node, Env.node, Plugin.node, ModelsDev.node, RuntimeFlags.node], +}) export * as Provider from "./provider" diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index e459380faa..2a8c3cfb41 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -463,7 +463,9 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re if ( options.store !== true && key && - ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle"].includes(model.api.npm) + ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle", "@ai-sdk/github-copilot"].includes( + model.api.npm, + ) ) { msgs = mapProviderOptions(msgs, (options) => { if (!options?.[key] || !("itemId" in options[key])) return options @@ -605,8 +607,14 @@ function anthropicOpus47OrLater(apiId: string) { return major > 4 || (major === 4 && minor >= 7) } +function anthropicSonnet5OrLater(apiId: string) { + const version = /sonnet-(\d+)(?:[.@-]|$)|claude-(\d+)-sonnet(?:[.@-]|$)/i.exec(apiId) + if (!version) return false + return Number(version[1] ?? version[2]) >= 5 +} + function anthropicAdaptiveEfforts(apiId: string): string[] | null { - if (anthropicOpus47OrLater(apiId) || apiId.includes("fable-5")) { + if (anthropicOpus47OrLater(apiId) || anthropicSonnet5OrLater(apiId) || apiId.includes("fable-5")) { return ["low", "medium", "high", "xhigh", "max"] } if ( @@ -620,7 +628,7 @@ function anthropicAdaptiveEfforts(apiId: string): string[] | null { } function anthropicOmitsThinking(apiId: string) { - return anthropicOpus47OrLater(apiId) || apiId.includes("fable-5") + return anthropicOpus47OrLater(apiId) || anthropicSonnet5OrLater(apiId) || apiId.includes("fable-5") } function googleThinkingLevelEfforts(apiId: string) { @@ -1255,6 +1263,16 @@ const SLUG_OVERRIDES: Record = { } export function providerOptions(model: Provider.Model, options: { [x: string]: any }) { + const usesOpenAIReasoningGate = + model.api.npm === "@ai-sdk/openai" || + model.api.npm === "@ai-sdk/azure" || + model.api.npm === "@ai-sdk/amazon-bedrock/mantle" + const normalized = + usesOpenAIReasoningGate && + (model.capabilities.reasoning || options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) + ? { ...options, forceReasoning: true } + : options + if (model.api.npm === "@ai-sdk/gateway") { // Gateway providerOptions are split across two namespaces: // - `gateway`: gateway-native routing/caching controls (order, only, byok, etc.) @@ -1264,8 +1282,8 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a const i = model.api.id.indexOf("/") const rawSlug = i > 0 ? model.api.id.slice(0, i) : undefined const slug = rawSlug ? (SLUG_OVERRIDES[rawSlug] ?? rawSlug) : undefined - const gateway = options.gateway - const rest = Object.fromEntries(Object.entries(options).filter(([k]) => k !== "gateway")) + const gateway = normalized.gateway + const rest = Object.fromEntries(Object.entries(normalized).filter(([k]) => k !== "gateway")) const has = Object.keys(rest).length > 0 const result: Record = {} @@ -1299,9 +1317,9 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a // providerOptions["openai"], but OpenAIResponsesLanguageModel checks // "azure" first. Pass both so model options work on either code path. if (model.api.npm === "@ai-sdk/azure") { - return { openai: options, azure: options } + return { openai: normalized, azure: normalized } } - return { [key]: options } + return { [key]: normalized } } export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number { diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 61bdc40ee8..8afc141072 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,94 +1,28 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Layer, Schema, Context } from "effect" import { InstanceState } from "@/effect/instance-state" -import { SessionID, MessageID } from "@/session/schema" +import { SessionID } from "@/session/schema" import { QuestionID } from "./schema" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" +import { QuestionV1 } from "@opencode-ai/schema/question-v1" -// Schemas — these are pure data; nothing checks class identity (see PR -// description) so they're plain `Schema.Struct` + type alias. That lets -// `Question.ask` and other internal sites trust the type contract without a -// re-decode to coerce nested class instances. - -export const Option = Schema.Struct({ - label: Schema.String.annotate({ - description: "Display text (1-5 words, concise)", - }), - description: Schema.String.annotate({ - description: "Explanation of choice", - }), -}).annotate({ identifier: "QuestionOption" }) -export type Option = Schema.Schema.Type - -const base = { - question: Schema.String.annotate({ - description: "Complete question", - }), - header: Schema.String.annotate({ - description: "Very short label (max 30 chars)", - }), - options: Schema.Array(Option).annotate({ - description: "Available choices", - }), - multiple: Schema.optional(Schema.Boolean).annotate({ - description: "Allow selecting multiple choices", - }), -} - -export const Info = Schema.Struct({ - ...base, - custom: Schema.optional(Schema.Boolean).annotate({ - description: "Allow typing a custom answer (default: true)", - }), -}).annotate({ identifier: "QuestionInfo" }) -export type Info = Schema.Schema.Type - -export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionPrompt" }) -export type Prompt = Schema.Schema.Type - -export const Tool = Schema.Struct({ - messageID: MessageID, - callID: Schema.String, -}).annotate({ identifier: "QuestionTool" }) -export type Tool = Schema.Schema.Type - -export const Request = Schema.Struct({ - id: QuestionID, - sessionID: SessionID, - questions: Schema.Array(Info).annotate({ - description: "Questions to ask", - }), - tool: Schema.optional(Tool), -}).annotate({ identifier: "QuestionRequest" }) -export type Request = Schema.Schema.Type - -export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" }) -export type Answer = Schema.Schema.Type - -export const Reply = Schema.Struct({ - answers: Schema.Array(Answer).annotate({ - description: "User answers in order of questions (each answer is an array of selected labels)", - }), -}).annotate({ identifier: "QuestionReply" }) -export type Reply = Schema.Schema.Type - -export const Replied = Schema.Struct({ - sessionID: SessionID, - requestID: QuestionID, - answers: Schema.Array(Answer), -}).annotate({ identifier: "QuestionReplied" }) - -export const Rejected = Schema.Struct({ - sessionID: SessionID, - requestID: QuestionID, -}).annotate({ identifier: "QuestionRejected" }) - -export const Event = { - Asked: EventV2.define({ type: "question.asked", schema: Request.fields }), - Replied: EventV2.define({ type: "question.replied", schema: Replied.fields }), - Rejected: EventV2.define({ type: "question.rejected", schema: Rejected.fields }), -} +export const Option = QuestionV1.Option +export type Option = typeof Option.Type +export const Info = QuestionV1.Info +export type Info = typeof Info.Type +export const Prompt = QuestionV1.Prompt +export type Prompt = typeof Prompt.Type +export const Tool = QuestionV1.Tool +export type Tool = typeof Tool.Type +export const Request = QuestionV1.Request +export type Request = typeof Request.Type +export const Answer = QuestionV1.Answer +export type Answer = typeof Answer.Type +export const Reply = QuestionV1.Reply +export type Reply = typeof Reply.Type +export const Replied = QuestionV1.Replied +export const Rejected = QuestionV1.Rejected +export const Event = QuestionV1.Event export class RejectedError extends Schema.TaggedErrorClass()("QuestionRejectedError", {}) { override get message() { @@ -127,7 +61,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Question") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service @@ -222,8 +156,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) - -export const node = LayerNode.make(layer, [EventV2Bridge.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] }) export * as Question from "." diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index 2574594a23..ed7f1edee7 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -1,10 +1,4 @@ -import { Schema } from "effect" +import { QuestionV1 } from "@opencode-ai/schema/question-v1" -import { Identifier } from "@/id/id" -import { Newtype } from "@opencode-ai/core/schema" - -export class QuestionID extends Newtype()("QuestionID", Schema.String.check(Schema.isStartsWith("que"))) { - static ascending(id?: string): QuestionID { - return this.make(Identifier.ascending("question", id)) - } -} +export const QuestionID = QuestionV1.ID +export type QuestionID = typeof QuestionID.Type diff --git a/packages/opencode/src/server/event.ts b/packages/opencode/src/server/event.ts index a581312555..9bb8a8c3b0 100644 --- a/packages/opencode/src/server/event.ts +++ b/packages/opencode/src/server/event.ts @@ -1,10 +1,7 @@ -import { EventV2 } from "@opencode-ai/core/event" import { Schema } from "effect" +import { ServerEvent } from "@opencode-ai/schema/server-event" -export const Event = { - Connected: EventV2.define({ type: "server.connected", schema: {} }), - Disposed: EventV2.define({ type: "global.disposed", schema: {} }), -} +export const Event = ServerEvent export const InstanceDisposed = Schema.Struct({ id: Schema.String, diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 60c4104084..f5076ad080 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -1,6 +1,10 @@ import { Schema } from "effect" import { HttpApi } from "effect/unstable/httpapi" import { EventV2 } from "@opencode-ai/core/event" +import { EventManifest } from "@/event-manifest" +import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" +import { SkillV2 } from "@opencode-ai/core/skill" import { InstanceDisposed } from "@/server/event" import { Question } from "@/question" import { ConfigApi } from "./groups/config" @@ -21,15 +25,15 @@ import { SessionApi } from "./groups/session" import { SyncApi } from "./groups/sync" import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" -import { Api } from "@opencode-ai/server/api" -// GlobalEventSchema snapshots the registry after event-producing groups register their variants. +import { makeApi } from "@opencode-ai/protocol/api" +import { LocationMiddleware } from "@opencode-ai/server/location" +import { SessionLocationMiddleware } from "@opencode-ai/server/middleware/session-location" import { GlobalApi } from "./groups/global" import { Authorization } from "./middleware/authorization" import { SchemaErrorMiddleware } from "./middleware/schema-error" const EventSchema = Schema.Union([ - ...EventV2.registry - .values() + ...EventManifest.Latest.values() .map((definition) => Schema.Struct({ id: EventV2.ID, @@ -41,6 +45,12 @@ const EventSchema = Schema.Union([ InstanceDisposed, ]).annotate({ identifier: "Event" }) +export const ServerApi = makeApi({ + definitions: EventManifest.Latest.values().toArray(), + locationMiddleware: LocationMiddleware, + sessionLocationMiddleware: SessionLocationMiddleware, +}) + export const RootHttpApi = HttpApi.make("opencode-root") .addHttpApi(ControlApi) .addHttpApi(ControlPlaneApi) @@ -70,9 +80,18 @@ export const OpenCodeHttpApi = HttpApi.make("opencode") .addHttpApi(RootHttpApi) .addHttpApi(EventApi) .addHttpApi(InstanceHttpApi) - .addHttpApi(Api) + .addHttpApi(ServerApi) .addHttpApi(PtyConnectApi) - .annotate(HttpApi.AdditionalSchemas, [EventSchema, Question.Replied, Question.Rejected]) + .annotate(HttpApi.AdditionalSchemas, [ + EventSchema, + Question.Replied, + Question.Rejected, + Credential.Value, + Integration.Inputs, + Integration.Method, + Integration.Ref, + SkillV2.Source, + ]) export type RootHttpApiType = typeof RootHttpApi export type InstanceHttpApiType = typeof InstanceHttpApi diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index 87556a1ad6..61daefe8a2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -1,6 +1,6 @@ -import { Config } from "@/config/config" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { EventV2 } from "@opencode-ai/core/event" +import { EventManifest } from "@/event-manifest" import { InstanceDisposed } from "@/server/event" import "@opencode-ai/core/account" import "@/server/event" @@ -13,16 +13,15 @@ const GlobalHealth = Schema.Struct({ version: Schema.String, }) -const SyncEventSchemas = EventV2.registry - .values() +const SyncEventSchemas = EventManifest.Latest.values() .flatMap((definition) => { - if (!definition.sync) return [] + if (!definition.durable) return [] return [ Schema.Struct({ type: Schema.Literal("sync"), id: EventV2.ID, syncEvent: Schema.Struct({ - type: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)), + type: Schema.Literal(EventV2.versionedType(definition.type, definition.durable.version)), id: EventV2.ID, seq: Schema.Finite, aggregateID: Schema.String, @@ -38,8 +37,7 @@ const GlobalEventSchema = Schema.Struct({ project: Schema.optional(Schema.String), workspace: Schema.optional(Schema.String), payload: Schema.Union([ - ...EventV2.registry - .values() + ...EventManifest.Latest.values() .map((definition) => Schema.Struct({ id: EventV2.ID, type: Schema.Literal(definition.type), properties: definition.data }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index bacc4b0457..6a826022a0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -1,6 +1,6 @@ import * as InstanceState from "@/effect/instance-state" import { FileSystem } from "@opencode-ai/core/filesystem" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" @@ -14,7 +14,7 @@ import { InstanceHttpApi } from "../api" export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) => Effect.gen(function* () { const ripgrep = yield* Ripgrep.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const filesystem = Effect.fnUntraced(function* (effect: Effect.Effect) { return yield* effect.pipe( @@ -136,4 +136,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl .handle("content", content) .handle("status", status) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(locationServiceMapLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index 538920b000..6446c9d28b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -6,7 +6,7 @@ import { Pty } from "@opencode-ai/core/pty" import { PtyProtocol } from "@opencode-ai/core/pty/protocol" import { PtyID } from "@opencode-ai/core/pty/schema" import { PtyTicket } from "@opencode-ai/core/pty/ticket" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { Shell } from "@opencode-ai/core/shell" @@ -43,7 +43,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler const tickets = yield* PtyTicket.Service const cors = yield* CorsConfig const plugin = yield* Plugin.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const unregister = registerDisposer((directory) => Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), ) @@ -158,13 +158,13 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler .handle("remove", remove) .handle("connectToken", connectToken) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(locationServiceMapLayer)) export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) => Effect.gen(function* () { const tickets = yield* PtyTicket.Service const cors = yield* CorsConfig - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const unregister = registerDisposer((directory) => Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), ) @@ -270,4 +270,4 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne }), ) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(locationServiceMapLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 8517da276f..2a7266c511 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -152,7 +152,7 @@ function matchLegacyOpenApi(input: Record) { normalizeLegacyErrorResponses(operation) } normalizeLegacyOperation(operation, path, method) - if ((path === "/event" || path === "/global/event") && method === "get") { + if ((path === "/event" || path === "/global/event" || path === "/api/event") && method === "get") { // HttpApi has no first-class SSE response schema, and these handlers are // raw/streaming routes. Document the actual wire protocol explicitly. operation.responses!["200"] = { @@ -162,7 +162,9 @@ function matchLegacyOpenApi(input: Record) { schema: path === "/event" ? { $ref: "#/components/schemas/Event" } - : { $ref: "#/components/schemas/GlobalEvent" }, + : path === "/global/event" + ? { $ref: "#/components/schemas/GlobalEvent" } + : { $ref: "#/components/schemas/V2Event" }, }, }, } diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 09e25de8cb..61e8df7d11 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -51,16 +51,21 @@ import { Worktree } from "@/worktree" import { RuntimeFlags } from "@/effect/runtime-flags" import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilderV1 } from "@/effect/app-node-builder-v1" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { EventV2 } from "@opencode-ai/core/event" import { ModelsDev } from "@opencode-ai/core/models-dev" import { Npm } from "@opencode-ai/core/npm" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectCopy } from "@opencode-ai/core/project/copy" import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" import { lazy } from "@/util/lazy" import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors" import { serveUIEffect } from "@/server/shared/ui" @@ -95,6 +100,10 @@ import { sessionHandlers } from "./handlers/session" import { syncHandlers } from "./handlers/sync" import { tuiHandlers } from "./handlers/tui" import { handlers } from "@opencode-ai/server/handlers" +import { buildLocationServiceMap, LocationServiceMap } from "@opencode-ai/core/location-services" +import { layer as locationLayer } from "@opencode-ai/server/location" +import { sessionLocationLayer } from "@opencode-ai/server/middleware/session-location" +import { PtyEnvironment } from "@opencode-ai/server/pty-environment" import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error" import { workspaceHandlers } from "./handlers/workspace" import { instanceContextLayer } from "./middleware/instance-context" @@ -124,10 +133,10 @@ const cors = (corsOptions?: CorsOptions) => // - ptyConnectApiRoutes: typed WebSocket upgrade route with ticket-aware auth. // - instanceApiRoutes: remaining typed instance routes. // - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it. -const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) -const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) -const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) -const serverHttpApiAuthLayer = serverAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) +const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.layer)) +const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.layer)) +const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.layer)) +const serverHttpApiAuthLayer = serverAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.layer)) const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe( Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]), @@ -221,6 +230,7 @@ const app = LayerNode.group([ Discovery.node, Question.node, Permission.node, + PermissionSaved.node, Todo.node, Session.node, SessionProjector.node, @@ -261,6 +271,8 @@ const app = LayerNode.group([ export function createRoutes( corsOptions?: CorsOptions, ): Layer.Layer { + const locationServiceMapV2 = buildLocationServiceMap() + return Layer.mergeAll( rootApiRoutes, eventApiRoutes, @@ -276,12 +288,24 @@ export function createRoutes( corsVaryFix, fenceLayer, cors(corsOptions), - MoveSession.defaultLayer, + AppNodeBuilderV1.build(MoveSession.node, [[LocationServiceMap.node, locationServiceMapV2]]), HttpServer.layerServices, ]), - Layer.provide(LayerNode.buildLayer(app)), Layer.provide(Layer.succeed(CorsConfig)(corsOptions)), - Layer.provide(Observability.layer), + Layer.provideMerge(Observability.layer), + + Layer.provide(sessionLocationLayer), + Layer.provide(locationLayer), + Layer.provide(PtyEnvironment.layer), + Layer.provide( + AppNodeBuilderV1.build(SessionV2.node, [ + [LocationServiceMap.node, locationServiceMapV2], + [SessionExecution.node, SessionExecutionLocal.node], + ]), + ), + Layer.provide(locationServiceMapV2), + + Layer.provide(AppNodeBuilderV1.build(app)), ) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts index 7cbac4ed5f..7e8eed89a4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts @@ -1,4 +1,5 @@ import { Context, Effect, Layer, Option } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import * as Socket from "effect/unstable/socket/Socket" export const SERVER_CLOSING_EVENT = () => new Socket.CloseEvent(1001, "server closing") @@ -13,7 +14,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/HttpApiWebSocketTracker") {} -export const layer = Layer.sync(Service)(() => { +const layer = Layer.sync(Service)(() => { const sockets = new Set() let closing = false return Service.of({ @@ -44,6 +45,8 @@ export const layer = Layer.sync(Service)(() => { }) }) +export const node = LayerNode.make({ service: Service, layer, deps: [] }) + export const register = (close: Close) => Effect.gen(function* () { const tracker = yield* Effect.serviceOption(Service) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 5145e26d6c..440b992c15 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,6 +1,7 @@ import "./init-projectors" import { NodeHttpServer } from "@effect/platform-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" @@ -102,7 +103,7 @@ function listenerLayer(opts: ListenOptions, port: number) { disableLogger: true, disableListenLog: true, }).pipe( - Layer.provideMerge(WebSocketTracker.layer), + Layer.provideMerge(AppNodeBuilder.build(WebSocketTracker.node)), Layer.provideMerge(serverLayer({ port, hostname: opts.hostname })), // Install a fresh `ConfigProvider` per listener so `Config.string(...)` // reads reflect the current `process.env`. Effect's default diff --git a/packages/opencode/src/server/tui-event.ts b/packages/opencode/src/server/tui-event.ts index 73412b8778..3fb3576db0 100644 --- a/packages/opencode/src/server/tui-event.ts +++ b/packages/opencode/src/server/tui-event.ts @@ -1,53 +1 @@ -import { SessionID } from "@/session/schema" -import { PositiveInt } from "@opencode-ai/core/schema" -import { EventV2 } from "@opencode-ai/core/event" -import { Effect, Schema } from "effect" - -const DEFAULT_TOAST_DURATION = 5000 - -export const TuiEvent = { - PromptAppend: EventV2.define({ type: "tui.prompt.append", schema: { text: Schema.String } }), - CommandExecute: EventV2.define({ - type: "tui.command.execute", - schema: { - command: Schema.Union([ - Schema.Literals([ - "session.list", - "session.new", - "session.share", - "session.interrupt", - "session.compact", - "session.page.up", - "session.page.down", - "session.line.up", - "session.line.down", - "session.half.page.up", - "session.half.page.down", - "session.first", - "session.last", - "prompt.clear", - "prompt.submit", - "agent.cycle", - ]), - Schema.String, - ]), - }, - }), - ToastShow: EventV2.define({ - type: "tui.toast.show", - schema: { - title: Schema.optional(Schema.String), - message: Schema.String, - variant: Schema.Literals(["info", "success", "warning", "error"]), - duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({ - description: "Duration in milliseconds", - }), - }, - }), - SessionSelect: EventV2.define({ - type: "tui.session.select", - schema: { - sessionID: SessionID.annotate({ description: "Session ID to navigate to" }), - }, - }), -} +export { TuiEvent } from "@opencode-ai/schema/tui-event" diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index c7ac963c69..fa439e4eff 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -13,27 +13,17 @@ import { Config } from "@/config/config" import { NotFoundError } from "@/storage/storage" import { Effect, Layer, Context } from "effect" -import * as DateTime from "effect/DateTime" import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" -import { SessionEvent } from "@opencode-ai/core/session/event" -import { SessionMessage } from "@opencode-ai/core/session/message" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { EventV2 } from "@opencode-ai/core/event" import { buildPrompt } from "@opencode-ai/core/session/compaction" +import { SessionCompactionEvent } from "@opencode-ai/schema/session-compaction-event" -export const Event = { - Compacted: EventV2.define({ - type: "session.compacted", - schema: { - sessionID: SessionID, - }, - }), -} +export const Event = SessionCompactionEvent export const PRUNE_MINIMUM = 20_000 export const PRUNE_PROTECT = 40_000 @@ -163,7 +153,7 @@ export class Service extends Context.Service()("@opencode/Se export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -362,18 +352,6 @@ export const layer = Layer.effect( stripMedia: true, toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, }) - const tailIndex = selected.tail_start_id - ? history.findIndex((message) => message.info.id === selected.tail_start_id) - : -1 - const recent = - tailIndex < 0 - ? "" - : JSON.stringify( - yield* MessageV2.toModelMessagesEffect(history.slice(tailIndex), model, { - stripMedia: true, - toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, - }), - ) const ctx = yield* InstanceState.context const msg: SessionV1.Assistant = { id: MessageID.ascending(), @@ -527,25 +505,6 @@ export const layer = Layer.effect( if (processor.message.error) return "stop" if (result === "continue") { - const summary = summaryText( - (yield* session.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)).find( - (item) => item.info.id === msg.id, - ) ?? { - info: msg, - parts: [], - }, - ) - if (flags.experimentalEventSystem) { - if (summary) - yield* events.publish(SessionEvent.Compaction.Ended, { - sessionID: input.sessionID, - messageID: SessionMessage.ID.make(input.parentID), - timestamp: DateTime.makeUnsafe(Date.now()), - reason: input.auto ? "auto" : "manual", - text: summary ?? "", - recent, - }) - } yield* events.publish(Event.Compacted, { sessionID: input.sessionID }) } return result @@ -574,14 +533,6 @@ export const layer = Layer.effect( auto: input.auto, overflow: input.overflow, }) - if (flags.experimentalEventSystem) { - yield* events.publish(SessionEvent.Compaction.Started, { - sessionID: input.sessionID, - messageID: SessionMessage.ID.make(msg.id), - timestamp: DateTime.makeUnsafe(Date.now()), - reason: input.auto ? "auto" : "manual", - }) - } }) return Service.of({ @@ -593,28 +544,19 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(Provider.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionProcessor.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - ), -) - -export const node = LayerNode.make(layer, [ - Config.node, - Session.node, - Agent.node, - Plugin.node, - SessionProcessor.node, - Provider.node, - EventV2Bridge.node, - RuntimeFlags.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [ + Config.node, + Session.node, + Agent.node, + Plugin.node, + SessionProcessor.node, + Provider.node, + EventV2Bridge.node, + RuntimeFlags.node, + ], +}) export * as SessionCompaction from "./compaction" diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index fab57be060..dfbdcd7709 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -1,5 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import path from "path" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Effect, Layer, Context } from "effect" @@ -45,7 +45,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Instruction") {} -export const layer: Layer.Layer< +const layer: Layer.Layer< Service, never, FSUtil.Service | Config.Service | Global.Service | HttpClient.HttpClient | RuntimeFlags.Service @@ -224,18 +224,14 @@ export const layer: Layer.Layer< }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(Global.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(RuntimeFlags.defaultLayer), -) - export function loaded(messages: SessionV1.WithParts[]) { return extract(messages) } -export const node = LayerNode.make(layer, [Config.node, FSUtil.node, Global.node, RuntimeFlags.node, httpClient]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, FSUtil.node, Global.node, RuntimeFlags.node, httpClient], +}) export * as Instruction from "./instruction" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index adacfc4315..a99f8acff2 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,5 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { llmClient } from "@opencode-ai/core/effect/layer-node-platform" +import { llmClient } from "@opencode-ai/core/effect/app-node-platform" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Provider } from "@/provider/provider" import { SessionV1 } from "@opencode-ai/core/v1/session" @@ -8,7 +8,7 @@ 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, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route" +import { LLMClient } from "@opencode-ai/llm/route" import type { LLMClientService } from "@opencode-ai/llm/route" import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" import { ProviderTransform } from "@/provider/transform" @@ -384,32 +384,21 @@ const live: Layer.Layer< }), ) -export const layer = live.pipe(Layer.provide(Permission.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer)) - -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide( - LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer))), - ), - Layer.provide(RuntimeFlags.defaultLayer), - ), -) - export const hasToolCalls = LLMRequestPrep.hasToolCalls -export const node = LayerNode.make(layer, [ - Auth.node, - Config.node, - Provider.node, - Plugin.node, - Permission.node, - EventV2Bridge.node, - llmClient, - RuntimeFlags.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: live, + deps: [ + Auth.node, + Config.node, + Provider.node, + Plugin.node, + Permission.node, + EventV2Bridge.node, + llmClient, + RuntimeFlags.node, + ], +}) export * as LLM from "./llm" diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 265dcb3dbe..6553248d77 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -146,6 +146,16 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre ) const tools = resolveTools(input) + // Codex parity: OpenAI Responses-family providers hardcode `strict: false` + // on every function tool so MCP-sourced and dynamic schemas that don't + // satisfy OpenAI's structured-outputs constraints still register. + if ( + input.model.api.npm === "@ai-sdk/openai" || + input.model.api.npm === "@ai-sdk/azure" || + input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" + ) { + for (const key of Object.keys(tools)) tools[key] = { ...tools[key], strict: false } + } if ( input.model.providerID.includes("github-copilot") && Object.keys(tools).length === 0 && diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1590e08903..1bea9f52c3 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1,5 +1,4 @@ -import { EventV2 } from "@opencode-ai/core/event" -import { SessionID, MessageID, PartID } from "./schema" +import { SessionID, MessageID } from "./schema" import { SessionV1 } from "@opencode-ai/core/v1/session" import { ProviderV2 } from "@opencode-ai/core/provider" import { @@ -12,16 +11,15 @@ import { Info, OutputLengthError, Part, - StructuredOutputError, SubtaskPart, User, WithParts, - type ToolPart, } from "@opencode-ai/core/v1/session" import { NamedError } from "@opencode-ai/core/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { NotFoundError } from "@/storage/storage" import { and } from "drizzle-orm" import { desc } from "drizzle-orm" @@ -58,16 +56,7 @@ export const Event = { Updated: SessionV1.Event.MessageUpdated, Removed: SessionV1.Event.MessageRemoved, PartUpdated: SessionV1.Event.PartUpdated, - PartDelta: EventV2.define({ - type: "message.part.delta", - schema: { - sessionID: SessionID, - messageID: MessageID, - partID: PartID, - field: Schema.String, - delta: Schema.String, - }, - }), + PartDelta: SessionV1.Event.PartDelta, PartRemoved: SessionV1.Event.PartRemoved, } @@ -742,3 +731,4 @@ export function fromError( } export * as MessageV2 from "./message-v2" +export const node = LayerNode.group([Database.node]) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2554315908..d09e6ac711 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -24,13 +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 { SessionEvent } from "@opencode-ai/core/session/event" -import { SessionMessage } from "@opencode-ai/core/session/message" -import { ModelV2 } from "@opencode-ai/core/model" -import { ProviderV2 } from "@opencode-ai/core/provider" -import * as DateTime from "effect/DateTime" -import { RuntimeFlags } from "@/effect/runtime-flags" -import { ToolOutput, Usage, type LLMEvent } from "@opencode-ai/llm" +import { Usage, type LLMEvent } from "@opencode-ai/llm" const DOOM_LOOP_THRESHOLD = 3 export type Result = "compact" | "stop" | "continue" @@ -64,13 +58,10 @@ export interface Interface { } type ToolCall = { - assistantMessageID?: SessionMessage.ID partID: SessionV1.ToolPart["id"] messageID: SessionV1.ToolPart["messageID"] sessionID: SessionV1.ToolPart["sessionID"] done: Deferred.Deferred - inputEnded: boolean - raw: string } interface ProcessorContext extends Input { @@ -80,16 +71,14 @@ interface ProcessorContext extends Input { blocked: boolean needsCompaction: boolean currentText: SessionV1.TextPart | undefined - currentTextID: string | undefined reasoningMap: Record - v2AssistantMessageID: SessionMessage.ID | undefined } type StreamEvent = LLMEvent export class Service extends Context.Service()("@opencode/SessionProcessor") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const session = yield* Session.Service @@ -104,7 +93,6 @@ export const layer = Layer.effect( const status = yield* SessionStatus.Service const image = yield* Image.Service const events = yield* EventV2Bridge.Service - const flags = yield* RuntimeFlags.Service const database = yield* Database.Service const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { @@ -122,11 +110,8 @@ export const layer = Layer.effect( blocked: false, needsCompaction: false, currentText: undefined, - currentTextID: undefined, reasoningMap: {}, - v2AssistantMessageID: undefined, } - const mirrorAssistant = flags.experimentalEventSystem && !input.assistantMessage.summary let aborted = false const parse = (e: unknown) => @@ -141,34 +126,6 @@ export const layer = Layer.effect( if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore) }) - const ensureV2AssistantMessage = Effect.fn("SessionProcessor.ensureV2AssistantMessage")(function* () { - if (ctx.v2AssistantMessageID) return ctx.v2AssistantMessageID - ctx.v2AssistantMessageID = SessionMessage.ID.create() - yield* events.publish(SessionEvent.Step.Started, { - sessionID: ctx.sessionID, - assistantMessageID: ctx.v2AssistantMessageID, - agent: input.assistantMessage.agent, - model: { - id: ModelV2.ID.make(ctx.model.id), - providerID: ProviderV2.ID.make(ctx.model.providerID), - variant: ModelV2.VariantID.make(input.assistantMessage.variant ?? "default"), - }, - snapshot: ctx.snapshot, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - return ctx.v2AssistantMessageID - }) - - const requireV2AssistantMessage = (toolCall?: ToolCall) => - toolCall?.assistantMessageID === undefined - ? Effect.die("V2 tool settlement has no owning assistant message") - : Effect.succeed(toolCall.assistantMessageID) - - const currentV2AssistantMessage = () => - ctx.v2AssistantMessageID === undefined - ? Effect.die("V2 step settlement has no owning assistant message") - : Effect.succeed(ctx.v2AssistantMessageID) - const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) { const call = ctx.toolcalls[toolCallID] if (!call) return undefined @@ -247,17 +204,6 @@ export const layer = Layer.effect( const finishReasoning = Effect.fn("SessionProcessor.finishReasoning")(function* (reasoningID: string) { if (!(reasoningID in ctx.reasoningMap)) return - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - yield* events.publish(SessionEvent.Reasoning.Ended, { - sessionID: ctx.sessionID, - assistantMessageID: yield* currentV2AssistantMessage(), - reasoningID, - text: ctx.reasoningMap[reasoningID].text, - providerMetadata: ctx.reasoningMap[reasoningID].metadata, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } // oxlint-disable-next-line no-self-assign -- reactivity trigger ctx.reasoningMap[reasoningID].text = ctx.reasoningMap[reasoningID].text ctx.reasoningMap[reasoningID].time = { ...ctx.reasoningMap[reasoningID].time, end: Date.now() } @@ -265,33 +211,6 @@ export const layer = Layer.effect( delete ctx.reasoningMap[reasoningID] }) - const flushV2Fragments = Effect.fn("SessionProcessor.flushV2Fragments")(function* () { - if (!mirrorAssistant) return - if (!ctx.assistantMessage.summary && ctx.currentText && ctx.currentTextID) { - yield* events.publish(SessionEvent.Text.Ended, { - sessionID: ctx.sessionID, - assistantMessageID: yield* currentV2AssistantMessage(), - textID: ctx.currentTextID, - text: ctx.currentText.text, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } - yield* Effect.forEach(Object.entries(ctx.reasoningMap), ([reasoningID, part]) => - currentV2AssistantMessage().pipe( - Effect.flatMap((assistantMessageID) => - events.publish(SessionEvent.Reasoning.Ended, { - sessionID: ctx.sessionID, - assistantMessageID, - reasoningID, - text: part.text, - providerMetadata: part.metadata, - timestamp: DateTime.makeUnsafe(Date.now()), - }), - ), - ), - ) - }) - const ensureToolCall = Effect.fn("SessionProcessor.ensureToolCall")(function* (input: { id: string name: string @@ -312,17 +231,6 @@ export const layer = Layer.effect( } return { call: ctx.toolcalls[input.id], part } } - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - const assistantMessageID = mirrorAssistant ? yield* ensureV2AssistantMessage() : undefined - if (assistantMessageID) { - yield* events.publish(SessionEvent.Tool.Input.Started, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: input.id, - name: input.name, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } const part = yield* session.updatePart({ id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -334,13 +242,10 @@ export const layer = Layer.effect( metadata: input.providerExecuted ? { providerExecuted: true } : undefined, } satisfies SessionV1.ToolPart) ctx.toolcalls[input.id] = { - assistantMessageID, done: yield* Deferred.make(), partID: part.id, messageID: part.messageID, sessionID: part.sessionID, - inputEnded: false, - raw: "", } return { call: ctx.toolcalls[input.id], part } }) @@ -372,16 +277,6 @@ export const layer = Layer.effect( switch (value.type) { case "reasoning-start": if (value.id in ctx.reasoningMap) return - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - yield* events.publish(SessionEvent.Reasoning.Started, { - sessionID: ctx.sessionID, - assistantMessageID: yield* ensureV2AssistantMessage(), - reasoningID: value.id, - providerMetadata: value.providerMetadata, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } ctx.reasoningMap[value.id] = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -399,15 +294,6 @@ export const layer = Layer.effect( if (!(value.id in ctx.reasoningMap)) return ctx.reasoningMap[value.id].text += value.text if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata - if (mirrorAssistant) { - yield* events.publish(SessionEvent.Reasoning.Delta, { - sessionID: ctx.sessionID, - assistantMessageID: yield* currentV2AssistantMessage(), - reasoningID: value.id, - delta: value.text, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } yield* session.updatePartDelta({ sessionID: ctx.reasoningMap[value.id].sessionID, messageID: ctx.reasoningMap[value.id].messageID, @@ -432,36 +318,11 @@ export const layer = Layer.effect( return case "tool-input-delta": - { - const toolCall = yield* ensureToolCall(value) - const assistantMessageID = mirrorAssistant ? yield* requireV2AssistantMessage(toolCall.call) : undefined - if (assistantMessageID) { - yield* events.publish(SessionEvent.Tool.Input.Delta, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: value.id, - delta: value.text, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } - ctx.toolcalls[value.id] = { ...toolCall.call, raw: toolCall.call.raw + value.text } - } + yield* ensureToolCall(value) return case "tool-input-end": { - const toolCall = yield* ensureToolCall(value) - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call) - yield* events.publish(SessionEvent.Tool.Input.Ended, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: value.id, - text: toolCall.call.raw, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } - ctx.toolcalls[value.id] = { ...toolCall.call, inputEnded: true } + yield* ensureToolCall(value) return } @@ -469,37 +330,8 @@ export const layer = Layer.effect( if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.name}`) } - const toolCall = yield* ensureToolCall(value) + yield* ensureToolCall(value) const input = isRecord(value.input) ? value.input : { value: value.input } - if (!toolCall.call.inputEnded) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call) - yield* events.publish(SessionEvent.Tool.Input.Ended, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: value.id, - text: toolCall.call.raw, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } - } - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call) - yield* events.publish(SessionEvent.Tool.Called, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: value.id, - tool: value.name, - input, - provider: { - executed: toolCall.part.metadata?.providerExecuted === true, - ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } yield* updateToolCall(value.id, (match) => ({ ...match, tool: value.name, @@ -550,22 +382,6 @@ export const layer = Layer.effect( const toolCall = yield* readToolCall(value.id) if (!toolCall && value.result.type === "error") return if (value.result.type === "error") { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call) - yield* events.publish(SessionEvent.Tool.Failed, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: value.id, - error: { type: "unknown", message: errorMessage(value.result.value) }, - result: value.result, - provider: { - executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true, - ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } yield* failToolCall(value.id, value.result.value) return } @@ -591,81 +407,11 @@ export const layer = Layer.effect( : `${rawOutput.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`, attachments: attachments.length ? attachments : undefined, } - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call) - const content = [ - { type: "text" as const, text: output.output }, - ...(output.attachments?.map( - (item: SessionV1.FilePart) => - ({ - type: "file", - uri: item.url, - mime: item.mime, - name: item.filename, - }) as const, - ) ?? []), - ] - const unsupported = content.find((item) => item.type === "file" && !item.uri.startsWith("data:")) - if (unsupported?.type === "file") { - const error = new Error( - `Tool attachment URI "${unsupported.uri}" must be materialized before durable V2 settlement`, - ) - yield* events.publish(SessionEvent.Tool.Failed, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: value.id, - error: { - type: "unknown", - message: error.message, - }, - provider: { - executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true, - ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - yield* failToolCall(value.id, error) - return - } else - yield* events.publish(SessionEvent.Tool.Success, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: value.id, - structured: output.metadata, - content, - result: value.result, - provider: { - executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true, - ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } yield* completeToolCall(value.id, output) return } case "tool-error": { - const toolCall = yield* readToolCall(value.id) - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call) - yield* events.publish(SessionEvent.Tool.Failed, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: value.id, - error: { - type: "unknown", - message: value.message, - }, - provider: { - executed: toolCall?.part.metadata?.providerExecuted === true, - ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } yield* failToolCall(value.id, value.error ?? new Error(value.message)) return } @@ -675,12 +421,6 @@ export const layer = Layer.effect( case "step-start": if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track() - if (!ctx.assistantMessage.summary) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - yield* ensureV2AssistantMessage() - } - } yield* session.updatePart({ id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -698,21 +438,6 @@ export const layer = Layer.effect( usage: value.usage ?? new Usage({}), metadata: value.providerMetadata, }) - if (!ctx.assistantMessage.summary) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - yield* events.publish(SessionEvent.Step.Ended, { - sessionID: ctx.sessionID, - assistantMessageID: yield* currentV2AssistantMessage(), - finish: value.reason, - cost: usage.cost, - tokens: usage.tokens, - snapshot: completedSnapshot, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - ctx.v2AssistantMessageID = undefined - } - } ctx.assistantMessage.finish = value.reason ctx.assistantMessage.cost += usage.cost ctx.assistantMessage.tokens = usage.tokens @@ -757,17 +482,6 @@ export const layer = Layer.effect( } case "text-start": - if (!ctx.assistantMessage.summary) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - yield* events.publish(SessionEvent.Text.Started, { - sessionID: ctx.sessionID, - assistantMessageID: yield* ensureV2AssistantMessage(), - timestamp: DateTime.makeUnsafe(Date.now()), - textID: value.id, - }) - } - } ctx.currentText = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -777,7 +491,6 @@ export const layer = Layer.effect( time: { start: Date.now() }, metadata: value.providerMetadata, } - ctx.currentTextID = value.id yield* session.updatePart(ctx.currentText) return @@ -785,15 +498,6 @@ export const layer = Layer.effect( if (!ctx.currentText) return ctx.currentText.text += value.text if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata - if (mirrorAssistant) { - yield* events.publish(SessionEvent.Text.Delta, { - sessionID: ctx.sessionID, - assistantMessageID: yield* currentV2AssistantMessage(), - textID: value.id, - delta: value.text, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } yield* session.updatePartDelta({ sessionID: ctx.currentText.sessionID, messageID: ctx.currentText.messageID, @@ -816,18 +520,6 @@ export const layer = Layer.effect( }, { text: ctx.currentText.text }, )).text - if (!ctx.assistantMessage.summary) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - yield* events.publish(SessionEvent.Text.Ended, { - sessionID: ctx.sessionID, - assistantMessageID: yield* currentV2AssistantMessage(), - text: ctx.currentText.text, - timestamp: DateTime.makeUnsafe(Date.now()), - textID: value.id, - }) - } - } { const end = Date.now() ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } @@ -835,7 +527,6 @@ export const layer = Layer.effect( if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata yield* session.updatePart(ctx.currentText) ctx.currentText = undefined - ctx.currentTextID = undefined return case "finish": @@ -864,7 +555,6 @@ export const layer = Layer.effect( ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } yield* session.updatePart(ctx.currentText) ctx.currentText = undefined - ctx.currentTextID = undefined } for (const part of Object.values(ctx.reasoningMap)) { @@ -886,16 +576,6 @@ export const layer = Layer.effect( const match = yield* readToolCall(toolCallID) if (!match) continue const part = match.part - if (mirrorAssistant && match.call.assistantMessageID) { - yield* events.publish(SessionEvent.Tool.Failed, { - sessionID: ctx.sessionID, - assistantMessageID: match.call.assistantMessageID, - callID: toolCallID, - error: { type: "unknown", message: "Tool execution aborted" }, - provider: { executed: part.metadata?.providerExecuted === true }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } const end = Date.now() const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {} yield* session.updatePart({ @@ -922,7 +602,6 @@ export const layer = Layer.effect( stack: e instanceof Error ? e.stack : undefined, }) const error = parse(e) - yield* flushV2Fragments() if (SessionV1.ContextOverflowError.isInstance(error)) { if ((yield* config.get()).compaction?.auto === false && !ctx.assistantMessage.summary) { ctx.assistantMessage.error = error @@ -935,20 +614,6 @@ export const layer = Layer.effect( yield* events.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) return } - if (!ctx.assistantMessage.summary) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - yield* events.publish(SessionEvent.Step.Failed, { - sessionID: ctx.sessionID, - assistantMessageID: yield* ensureV2AssistantMessage(), - error: { - type: "unknown", - message: errorMessage(e), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } - } ctx.assistantMessage.error = error yield* events.publish(Session.Event.Error, { sessionID: ctx.assistantMessage.sessionID, @@ -968,7 +633,6 @@ export const layer = Layer.effect( return yield* Effect.gen(function* () { yield* Effect.gen(function* () { ctx.currentText = undefined - ctx.currentTextID = undefined ctx.reasoningMap = {} yield* status.set(ctx.sessionID, { type: "busy" }) const stream = llm.stream(streamInput) @@ -996,30 +660,13 @@ export const layer = Layer.effect( provider: input.model.providerID, parse, set: (info) => { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - const event = mirrorAssistant - ? events.publish(SessionEvent.Retried, { - sessionID: ctx.sessionID, - attempt: info.attempt, - error: { - message: info.message, - isRetryable: true, - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - : Effect.void - return flushV2Fragments().pipe( - Effect.andThen(event), - Effect.andThen( - status.set(ctx.sessionID, { - type: "retry", - attempt: info.attempt, - message: info.message, - action: info.action, - next: info.next, - }), - ), - ) + return status.set(ctx.sessionID, { + type: "retry", + attempt: info.attempt, + message: info.message, + action: info.action, + next: info.next, + }) }, }), ), @@ -1047,38 +694,23 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(LLM.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(SessionSummary.defaultLayer), - Layer.provide(SessionStatus.defaultLayer), - Layer.provide(Image.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - ), -) - -export const node = LayerNode.make(layer, [ - Session.node, - Config.node, - Snapshot.node, - Agent.node, - LLM.node, - Permission.node, - Plugin.node, - SessionSummary.node, - SessionStatus.node, - Image.node, - EventV2Bridge.node, - RuntimeFlags.node, - Database.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [ + Session.node, + Config.node, + Snapshot.node, + Agent.node, + LLM.node, + Permission.node, + Plugin.node, + SessionSummary.node, + SessionStatus.node, + Image.node, + EventV2Bridge.node, + Database.node, + ], +}) export * as SessionProcessor from "./processor" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index dad796c998..6734a1f5ac 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -49,12 +49,8 @@ import { SessionRunState } from "./run-state" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@opencode-ai/core/database/database" -import { SessionEvent } from "@opencode-ai/core/session/event" -import { SessionMessage } from "@opencode-ai/core/session/message" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/core/session/prompt" -import * as DateTime from "effect/DateTime" import { eq } from "drizzle-orm" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionReminders } from "./reminders" @@ -66,6 +62,14 @@ globalThis.AI_SDK_LOG_WARNINGS = false const decodeMessageInfo = Schema.decodeUnknownExit(SessionV1.Info) const decodeMessagePart = Schema.decodeUnknownExit(SessionV1.Part) +const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024 +const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([ + "application/pdf", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]) const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format. @@ -77,6 +81,18 @@ IMPORTANT: const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.` +function mcpResourceBase64Size(value: string) { + const trimmed = value.replace(/\s/g, "") + const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0 + return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding) +} + +function formatMcpResourceBytes(value: number) { + if (value < 1024) return `${value} B` + if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB` + return `${Math.ceil(value / (1024 * 1024))} MB` +} + function isOrphanedInterruptedTool(part: SessionV1.ToolPart) { // cleanup() marks abandoned tool_use blocks this way after retries/aborts. // They are not pending work and must not trigger an assistant-prefill request. @@ -94,7 +110,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionPrompt") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const status = yield* SessionStatus.Service @@ -500,15 +516,6 @@ export const layer = Layer.effect( }, } yield* sessions.updatePart(part) - if (flags.experimentalEventSystem) { - yield* events.publish(SessionEvent.Shell.Started, { - sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(started), - callID: part.callID, - command: input.command, - }) - } return { msg, part, cwd: ctx.directory } }).pipe(Effect.ensuring(markReady)) @@ -524,14 +531,6 @@ export const layer = Layer.effect( output += "\n\n" + ["", "User aborted the command", ""].join("\n") } const completed = Date.now() - if (flags.experimentalEventSystem) { - yield* events.publish(SessionEvent.Shell.Ended, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(completed), - callID: part.callID, - output, - }) - } if (!msg.time.completed) { msg.time.completed = completed yield* sessions.updateMessage(msg) @@ -542,7 +541,7 @@ export const layer = Layer.effect( time: { ...part.state.time, end: completed }, input: part.state.input, title: "", - metadata: { output, description: "" }, + metadata: { output }, output, } yield* sessions.updatePart(part) @@ -569,7 +568,7 @@ export const layer = Layer.effect( Effect.gen(function* () { output += chunk if (part.state.status === "running") { - part.state.metadata = { output, description: "" } + part.state.metadata = { output } yield* sessions.updatePart(part) } }), @@ -644,12 +643,6 @@ export const layer = Layer.effect( throw error } - const current = yield* db - .select({ agent: SessionTable.agent, model: SessionTable.model }) - .from(SessionTable) - .where(eq(SessionTable.id, input.sessionID)) - .get() - .pipe(Effect.orDie) const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID)) const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID const full = @@ -676,28 +669,22 @@ export const layer = Layer.effect( format: input.format, } - if (current?.agent !== info.agent) { - yield* events.publish(SessionEvent.AgentSwitched, { - sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(info.time.created), - agent: info.agent, - }) - } + const current = yield* sessions.get(input.sessionID).pipe(Effect.orDie) if ( - current?.model?.providerID !== info.model.providerID || - current.model.id !== info.model.modelID || - (current.model.variant === "default" ? undefined : current.model.variant) !== info.model.variant + current.agent !== info.agent || + current.model?.providerID !== info.model.providerID || + current.model?.id !== info.model.modelID || + (current.model?.variant === "default" ? undefined : current.model?.variant) !== info.model.variant ) { - yield* events.publish(SessionEvent.ModelSwitched, { + yield* sessions.setAgentModel({ sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(info.time.created), + agent: info.agent, model: { - id: ModelV2.ID.make(info.model.modelID), - providerID: ProviderV2.ID.make(info.model.providerID), - variant: ModelV2.VariantID.make(info.model.variant ?? "default"), + id: info.model.modelID, + providerID: info.model.providerID, + variant: info.model.variant ?? "default", }, + time: info.time.created, }) } @@ -731,7 +718,8 @@ export const layer = Layer.effect( if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`) const items = Array.isArray(content.contents) ? content.contents : [content.contents] for (const c of items) { - if ("text" in c && c.text) { + if (!c || typeof c !== "object") continue + if ("text" in c && typeof c.text === "string" && c.text) { pieces.push({ messageID: info.id, sessionID: input.sessionID, @@ -739,18 +727,47 @@ export const layer = Layer.effect( synthetic: true, text: c.text, }) - } else if ("blob" in c && c.blob) { - const mime = "mimeType" in c ? c.mimeType : part.mime + } else if ("blob" in c && typeof c.blob === "string" && c.blob) { + const mime = "mimeType" in c && typeof c.mimeType === "string" ? c.mimeType : part.mime + const filename = "uri" in c && typeof c.uri === "string" ? c.uri : part.filename + const size = mcpResourceBase64Size(c.blob) + if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) { + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `[Binary MCP resource omitted: ${filename ?? uri} (${mime}, ${formatMcpResourceBytes(size)}) is not a supported attachment type]`, + }) + continue + } + if (size > MAX_MCP_RESOURCE_BLOB_BYTES) { + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `[Binary MCP resource omitted: ${filename ?? uri} (${mime}, ${formatMcpResourceBytes(size)}) exceeds ${formatMcpResourceBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`, + }) + continue + } pieces.push({ messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, - text: `[Binary content: ${mime}]`, + text: `[Binary MCP resource attached: ${filename ?? uri} (${mime})]`, + }) + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "file", + mime, + filename, + url: `data:${mime};base64,${c.blob}`, }) } } - pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) } else { const error = Cause.squash(exit.cause) yield* Effect.logError("failed to read MCP resource", { error, clientName, uri }) @@ -1028,76 +1045,6 @@ export const layer = Layer.effect( yield* sessions.updateMessage(info) for (const part of parts) yield* sessions.updatePart(part) - const nextPrompt = parts.reduce( - (result, part) => { - if (part.type === "text") { - if (part.synthetic) result.synthetic.push(part.text) - else result.text.push(part.text) - } - if (part.type === "file") { - result.files.push( - new FileAttachment({ - uri: part.url, - mime: part.mime, - name: part.filename, - source: part.source - ? new Source({ - start: part.source.text.start, - end: part.source.text.end, - text: part.source.text.value, - }) - : undefined, - }), - ) - } - if (part.type === "agent") { - result.agents.push( - new AgentAttachment({ - name: part.name, - source: part.source - ? new Source({ - start: part.source.start, - end: part.source.end, - text: part.source.value, - }) - : undefined, - }), - ) - } - return result - }, - { - text: [] as string[], - files: [] as FileAttachment[], - agents: [] as AgentAttachment[], - synthetic: [] as string[], - }, - ) - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { - yield* events.publish(SessionEvent.Prompted, { - sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(info.time.created), - delivery: "steer", - prompt: new Prompt({ - text: nextPrompt.text.join("\n"), - files: nextPrompt.files, - agents: nextPrompt.agents, - }), - }) - } - for (const text of nextPrompt.synthetic) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { - yield* events.publish(SessionEvent.Synthetic, { - sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(info.time.created), - text, - }) - } - } return { info, parts } }, Effect.scoped) @@ -1306,13 +1253,19 @@ export const layer = Layer.effect( yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const [skills, env, instructions, modelMsgs] = yield* Effect.all([ + const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([ sys.skills(agent), sys.environment(model), instruction.system().pipe(Effect.orDie), + sys.mcp(agent, session.permission), MessageV2.toModelMessagesEffect(msgs, model), ]) - const system = [...env, ...instructions, ...(skills ? [skills] : [])] + const system = [ + ...env, + ...instructions, + ...(mcpInstructions ? [mcpInstructions] : []), + ...(skills ? [skills] : []), + ] const format = lastUser.format ?? { type: "text" as const } if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) const result = yield* handle.process({ @@ -1537,40 +1490,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(SessionRunState.defaultLayer), - Layer.provide(SessionStatus.defaultLayer), - Layer.provide(SessionCompaction.defaultLayer), - Layer.provide(SessionProcessor.defaultLayer), - Layer.provide(Command.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(MCP.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(ToolRegistry.defaultLayer), - Layer.provide(Truncate.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionRevert.defaultLayer), - Layer.provide(SessionSummary.defaultLayer), - Layer.provide(Image.defaultLayer), - Layer.provide( - Layer.mergeAll( - Agent.defaultLayer, - Database.defaultLayer, - SystemPrompt.defaultLayer, - LLM.defaultLayer, - CrossSpawnSpawner.defaultLayer, - RuntimeFlags.defaultLayer, - EventV2Bridge.defaultLayer, - ), - ), - ), -) const ModelRef = Schema.Struct({ providerID: ProviderV2.ID, modelID: ModelV2.ID, @@ -1675,33 +1594,37 @@ const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi const placeholderRegex = /\$(\d+)/g const quoteTrimRegex = /^["']|["']$/g -export const node = LayerNode.make(layer, [ - SessionStatus.node, - Session.node, - Agent.node, - Provider.node, - SessionProcessor.node, - SessionCompaction.node, - Plugin.node, - Command.node, - Config.node, - Permission.node, - FSUtil.node, - MCP.node, - LSP.node, - ToolRegistry.node, - Truncate.node, - Image.node, - CrossSpawnSpawner.node, - Instruction.node, - SessionRunState.node, - SessionRevert.node, - SessionSummary.node, - SystemPrompt.node, - LLM.node, - EventV2Bridge.node, - RuntimeFlags.node, - Database.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [ + SessionStatus.node, + Session.node, + Agent.node, + Provider.node, + SessionProcessor.node, + SessionCompaction.node, + Plugin.node, + Command.node, + Config.node, + Permission.node, + FSUtil.node, + MCP.node, + LSP.node, + ToolRegistry.node, + Truncate.node, + Image.node, + CrossSpawnSpawner.node, + Instruction.node, + SessionRunState.node, + SessionRevert.node, + SessionSummary.node, + SystemPrompt.node, + LLM.node, + EventV2Bridge.node, + RuntimeFlags.node, + Database.node, + ], +}) export * as SessionPrompt from "./prompt" diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 04631e4ec0..79fef2cda9 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -25,7 +25,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionRevert") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const sessions = yield* Session.Service @@ -137,24 +137,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(SessionRunState.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(Storage.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(SessionSummary.defaultLayer), - ), -) - -export const node = LayerNode.make(layer, [ - Session.node, - Snapshot.node, - Storage.node, - EventV2Bridge.node, - SessionSummary.node, - SessionRunState.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Session.node, Snapshot.node, Storage.node, EventV2Bridge.node, SessionSummary.node, SessionRunState.node], +}) export * as SessionRevert from "./revert" diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index 9c85191610..5cefdd04a3 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -26,7 +26,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionRunState") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const background = yield* BackgroundJob.Service @@ -108,11 +108,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(BackgroundJob.defaultLayer), - Layer.provide(SessionStatus.defaultLayer), -) - const cancelBackgroundJobs = Effect.fn("SessionRunState.cancelBackgroundJobs")(function* ( background: BackgroundJob.Interface, sessionID: SessionID, @@ -151,6 +146,6 @@ function busyError(sessionID: SessionID) { return new Session.BusyError({ sessionID }) } -export const node = LayerNode.make(layer, [BackgroundJob.node, SessionStatus.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [BackgroundJob.node, SessionStatus.node] }) export * as SessionRunState from "./run-state" diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index 4a49d110c8..162c2a4f45 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -2,14 +2,14 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" import { SessionV2 } from "@opencode-ai/core/session" -import { withStatics } from "@opencode-ai/core/schema" +import { statics } from "@opencode-ai/core/schema" export const SessionID = SessionV2.ID export type SessionID = Schema.Schema.Type export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( Schema.brand("MessageID"), - withStatics((s) => ({ + statics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("message", id)), })), ) @@ -18,7 +18,7 @@ export type MessageID = Schema.Schema.Type export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( Schema.brand("PartID"), - withStatics((s) => ({ + statics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("part", id)), })), ) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 038ec9aa0d..de8c3dc4cb 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -9,11 +9,10 @@ import { Decimal } from "decimal.js" import type { ProviderMetadata, Usage } from "@opencode-ai/llm" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Database } from "@opencode-ai/core/database/database" -import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" import { SessionV2 } from "@opencode-ai/core/session" -import { SessionExecution } from "@opencode-ai/core/session/execution" +import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { NotFoundError } from "@/storage/storage" import { eq } from "drizzle-orm" @@ -38,15 +37,13 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { SessionID, MessageID, PartID } from "./schema" import type { Provider } from "@/provider/provider" -import { Permission } from "@/permission" import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Option, Context, Schema, Types } from "effect" -import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema" +import { NonNegativeInt, optional } from "@opencode-ai/core/schema" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" - -const runtime = makeRuntime(Database.Service, Database.defaultLayer) +import { SessionMessage } from "@opencode-ai/schema/session-message" const parentTitlePrefix = "New session - " const childTitlePrefix = "Child session - " @@ -70,7 +67,14 @@ export function fromRow(row: SessionRow): Info { } : undefined const share = row.share_url ? { url: row.share_url } : undefined - const revert = row.revert ?? undefined + const revert = row.revert + ? { + messageID: MessageID.make(row.revert.messageID), + partID: row.revert.partID ? PartID.make(row.revert.partID) : undefined, + snapshot: row.revert.snapshot, + diff: row.revert.diff, + } + : undefined return { id: row.id, slug: row.slug, @@ -138,7 +142,14 @@ export function toRow(info: Info) { tokens_reasoning: (info.tokens ?? EmptyTokens).reasoning, tokens_cache_read: (info.tokens ?? EmptyTokens).cache.read, tokens_cache_write: (info.tokens ?? EmptyTokens).cache.write, - revert: info.revert ?? null, + revert: info.revert + ? { + messageID: SessionMessage.ID.make(info.revert.messageID), + partID: info.revert.partID, + snapshot: info.revert.snapshot, + diff: info.revert.diff, + } + : null, permission: info.permission, time_created: info.time.created, time_updated: info.time.updated, @@ -165,7 +176,7 @@ const Summary = Schema.Struct({ additions: Schema.Finite, deletions: Schema.Finite, files: Schema.Finite, - diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)), + diffs: optional(Schema.Array(Snapshot.FileDiff)), }) const Tokens = Schema.Struct({ @@ -191,21 +202,21 @@ export const ArchivedTimestamp = Schema.Finite const Time = Schema.Struct({ created: NonNegativeInt, updated: NonNegativeInt, - compacting: optionalOmitUndefined(NonNegativeInt), - archived: optionalOmitUndefined(ArchivedTimestamp), + compacting: optional(NonNegativeInt), + archived: optional(ArchivedTimestamp), }) const Revert = Schema.Struct({ messageID: MessageID, - partID: optionalOmitUndefined(PartID), - snapshot: optionalOmitUndefined(Schema.String), - diff: optionalOmitUndefined(Schema.String), + partID: optional(PartID), + snapshot: optional(Schema.String), + diff: optional(Schema.String), }) const Model = Schema.Struct({ id: ModelV2.ID, providerID: ProviderV2.ID, - variant: optionalOmitUndefined(Schema.String), + variant: optional(Schema.String), }) export const Metadata = Schema.Record(Schema.String, Schema.Any) @@ -214,28 +225,28 @@ export const Info = Schema.Struct({ id: SessionID, slug: Schema.String, projectID: ProjectV2.ID, - workspaceID: optionalOmitUndefined(WorkspaceV2.ID), + workspaceID: optional(WorkspaceV2.ID), directory: Schema.String, - path: optionalOmitUndefined(Schema.String), - parentID: optionalOmitUndefined(SessionID), - summary: optionalOmitUndefined(Summary), - cost: optionalOmitUndefined(Schema.Finite), - tokens: optionalOmitUndefined(Tokens), - share: optionalOmitUndefined(Share), + path: optional(Schema.String), + parentID: optional(SessionID), + summary: optional(Summary), + cost: optional(Schema.Finite), + tokens: optional(Tokens), + share: optional(Share), title: Schema.String, - agent: optionalOmitUndefined(Schema.String), - model: optionalOmitUndefined(Model), + agent: optional(Schema.String), + model: optional(Model), version: Schema.String, - metadata: optionalOmitUndefined(Metadata), + metadata: optional(Metadata), time: Time, - permission: optionalOmitUndefined(PermissionV1.Ruleset), - revert: optionalOmitUndefined(Revert), + permission: optional(PermissionV1.Ruleset), + revert: optional(Revert), }).annotate({ identifier: "Session" }) export type Info = Types.DeepMutable> export const ProjectInfo = Schema.Struct({ id: ProjectV2.ID, - name: optionalOmitUndefined(Schema.String), + name: optional(Schema.String), worktree: Schema.String, }).annotate({ identifier: "ProjectSummary" }) export type ProjectInfo = Types.DeepMutable> @@ -309,69 +320,12 @@ export type GlobalListInput = { archived?: boolean } -const CreatedEventSchema = Schema.Struct({ - sessionID: SessionID, - info: Info, -}) - -const UpdatedShare = Schema.Struct({ - url: Schema.optional(Schema.NullOr(Schema.String)), -}) - -const UpdatedTime = Schema.Struct({ - created: Schema.optional(Schema.NullOr(NonNegativeInt)), - updated: Schema.optional(Schema.NullOr(NonNegativeInt)), - compacting: Schema.optional(Schema.NullOr(NonNegativeInt)), - archived: Schema.optional(Schema.NullOr(ArchivedTimestamp)), -}) - -const UpdatedInfo = Schema.Struct({ - id: Schema.optional(Schema.NullOr(SessionID)), - slug: Schema.optional(Schema.NullOr(Schema.String)), - projectID: Schema.optional(Schema.NullOr(ProjectV2.ID)), - workspaceID: Schema.optional(Schema.NullOr(WorkspaceV2.ID)), - directory: Schema.optional(Schema.NullOr(Schema.String)), - path: Schema.optional(Schema.NullOr(Schema.String)), - parentID: Schema.optional(Schema.NullOr(SessionID)), - summary: Schema.optional(Schema.NullOr(Summary)), - cost: Schema.optional(Schema.Finite), - tokens: Schema.optional(Tokens), - share: Schema.optional(UpdatedShare), - title: Schema.optional(Schema.NullOr(Schema.String)), - agent: Schema.optional(Schema.NullOr(Schema.String)), - model: Schema.optional(Schema.NullOr(Model)), - version: Schema.optional(Schema.NullOr(Schema.String)), - metadata: Schema.optional(Schema.NullOr(Metadata)), - time: Schema.optional(UpdatedTime), - permission: Schema.optional(Schema.NullOr(PermissionV1.Ruleset)), - revert: Schema.optional(Schema.NullOr(Revert)), -}) - -const UpdatedEventSchema = Schema.Struct({ - sessionID: SessionID, - info: UpdatedInfo, -}) - export const Event = { Created: SessionV1.Event.Created, Updated: SessionV1.Event.Updated, Deleted: SessionV1.Event.Deleted, - Diff: EventV2.define({ - type: "session.diff", - schema: { - sessionID: SessionID, - diff: Schema.Array(Snapshot.FileDiff), - }, - }), - Error: EventV2.define({ - type: "session.error", - schema: { - sessionID: Schema.optional(SessionID), - // Reuses SessionV1.Assistant.fields.error (already Schema.optional) so - // the derived schema keeps the same discriminated-union shape on the event stream. - error: SessionV1.Assistant.fields.error, - }, - }), + Diff: SessionV1.Event.Diff, + Error: SessionV1.Event.Error, } export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext) { @@ -476,6 +430,12 @@ export interface Interface { readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect readonly setMetadata: (input: typeof SetMetadataInput.Type) => Effect.Effect + readonly setAgentModel: (input: { + sessionID: SessionID + agent: string + model: NonNullable + time: number + }) => Effect.Effect readonly setPermission: (input: { sessionID: SessionID; permission: PermissionV1.Ruleset }) => Effect.Effect readonly setRevert: (input: { sessionID: SessionID @@ -525,7 +485,7 @@ export type Patch = Omit, "time" | "share" | "summary" | "revert" permission?: Info["permission"] | null } -export const layer: Layer.Layer< +const layer: Layer.Layer< Service, never, BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service @@ -804,6 +764,19 @@ export const layer: Layer.Layer< yield* patch(input.sessionID, { metadata: input.metadata, time: { updated: Date.now() } }).pipe(Effect.orDie) }) + const setAgentModel = Effect.fn("Session.setAgentModel")(function* (input: { + sessionID: SessionID + agent: string + model: NonNullable + time: number + }) { + yield* patch(input.sessionID, { + agent: input.agent, + model: input.model, + time: { updated: input.time }, + }).pipe(Effect.orDie) + }) + const setPermission = Effect.fn("Session.setPermission")(function* (input: { sessionID: SessionID permission: PermissionV1.Ruleset @@ -942,6 +915,7 @@ export const layer: Layer.Layer< setTitle, setArchived, setMetadata, + setAgentModel, setPermission, setRevert, clearRevert, @@ -963,15 +937,6 @@ export const layer: Layer.Layer< }), ) -export const defaultLayer = layer.pipe( - Layer.provide(BackgroundJob.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(SessionExecution.noopLayer), - Layer.provide(SessionV2.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), -) - const cancelBackgroundJobs = Effect.fn("Session.cancelBackgroundJobs")(function* ( background: BackgroundJob.Interface, sessionID: SessionID, @@ -1044,76 +1009,10 @@ function listByProject( ) } -export function* listGlobal(input?: { - directory?: string - roots?: boolean - start?: number - cursor?: number - search?: string - limit?: number - archived?: boolean -}) { - const conditions: SQL[] = [] - - if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) - } - if (input?.roots) { - conditions.push(isNull(SessionTable.parent_id)) - } - if (input?.start) { - conditions.push(gte(SessionTable.time_updated, input.start)) - } - if (input?.cursor) { - conditions.push(lt(SessionTable.time_updated, input.cursor)) - } - if (input?.search) { - conditions.push(like(SessionTable.title, `%${input.search}%`)) - } - if (!input?.archived) { - conditions.push(isNull(SessionTable.time_archived)) - } - - const limit = input?.limit ?? 100 - - const rows = runtime.runSync(({ db }) => { - const query = - conditions.length > 0 - ? db - .select() - .from(SessionTable) - .where(and(...conditions)) - : db.select().from(SessionTable) - return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all().pipe(Effect.orDie) - }) - - const ids = [...new Set(rows.map((row) => row.project_id))] - const projects = new Map() - - if (ids.length > 0) { - const items = runtime.runSync(({ db }) => - db - .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree }) - .from(ProjectTable) - .where(inArray(ProjectTable.id, ids)) - .all() - .pipe(Effect.orDie), - ) - for (const item of items) { - projects.set(item.id, { - id: item.id, - name: item.name ?? undefined, - worktree: item.worktree, - }) - } - } - - for (const row of rows) { - const project = projects.get(row.project_id) ?? null - yield { ...fromRow(row), project } - } -} - -export const node = LayerNode.make(layer, [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node], +}) export * as Session from "./session" diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 68758ea6a3..11140acfee 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -1,53 +1,14 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "./schema" -import { NonNegativeInt } from "@opencode-ai/core/schema" -import { Effect, Layer, Context, Schema } from "effect" +import { Effect, Layer, Context } from "effect" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" +import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event" -export const Info = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("idle"), - }), - Schema.Struct({ - type: Schema.Literal("retry"), - attempt: NonNegativeInt, - message: Schema.String, - action: Schema.optional( - Schema.Struct({ - reason: Schema.String, - provider: Schema.String, - title: Schema.String, - message: Schema.String, - label: Schema.String, - link: Schema.optional(Schema.String), - }), - ), - next: NonNegativeInt, - }), - Schema.Struct({ - type: Schema.Literal("busy"), - }), -]).annotate({ identifier: "SessionStatus" }) -export type Info = Schema.Schema.Type +export const Info = SessionStatusEvent.Info +export type Info = SessionStatusEvent.Info -export const Event = { - Status: EventV2.define({ - type: "session.status", - schema: { - sessionID: SessionID, - status: Info, - }, - }), - // deprecated - Idle: EventV2.define({ - type: "session.idle", - schema: { - sessionID: SessionID, - }, - }), -} +export const Event = SessionStatusEvent export interface Interface { readonly get: (sessionID: SessionID) => Effect.Effect @@ -57,7 +18,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionStatus") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service @@ -90,8 +51,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) - -export const node = LayerNode.make(layer, [EventV2Bridge.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] }) export * as SessionStatus from "./status" diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 370870935a..6484730d0b 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -71,7 +71,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionSummary") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const sessions = yield* Session.Service @@ -145,21 +145,16 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - ), -) - export const DiffInput = Schema.Struct({ sessionID: SessionID, messageID: Schema.optional(MessageID), }) export type DiffInput = Schema.Schema.Type -export const node = LayerNode.make(layer, [Session.node, Snapshot.node, EventV2Bridge.node, Config.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Session.node, Snapshot.node, EventV2Bridge.node, Config.node], +}) export * as SessionSummary from "./summary" diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 74401779d3..6e5b83ec31 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -18,9 +18,10 @@ import { Permission } from "@/permission" import { Skill } from "@/skill" import { AbsolutePath } from "@opencode-ai/core/schema" import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Reference } from "@opencode-ai/core/reference" +import { MCP } from "@/mcp" +import { PermissionV1 } from "@opencode-ai/core/v1/permission" export function provider(model: Provider.Model) { if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3")) @@ -41,21 +42,22 @@ export function provider(model: Provider.Model) { export interface Interface { readonly environment: (model: Provider.Model) => Effect.Effect readonly skills: (agent: Agent.Info) => Effect.Effect + readonly mcp: (agent: Agent.Info, permission?: PermissionV1.Ruleset) => Effect.Effect } export class Service extends Context.Service()("@opencode/SystemPrompt") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const skill = yield* Skill.Service - const locations = yield* LocationServiceMap + const mcp = yield* MCP.Service + const locations = yield* LocationServiceMap.Service return Service.of({ environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) { const ctx = yield* InstanceState.context const references = yield* Effect.gen(function* () { - yield* (yield* PluginBoot.Service).wait() return (yield* (yield* Reference.Service).list()).filter((reference) => reference.description !== undefined) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) return [ @@ -104,14 +106,38 @@ export const layer = Layer.effect( Skill.fmt(list, { verbose: true }), ].join("\n") }), + + mcp: Effect.fn("SystemPrompt.mcp")(function* (agent: Agent.Info, permission?: PermissionV1.Ruleset) { + const ruleset = Permission.merge(agent.permission, permission ?? []) + const instructions = (yield* mcp.instructions()).filter( + (item) => item.tools.length === 0 || Permission.disabled(item.tools, ruleset).size < item.tools.length, + ) + if (instructions.length === 0) return + + return [ + "", + ...instructions.flatMap((item) => [ + ` `, + ...item.instructions.split("\n").map((line) => ` ${line}`), + " ", + ]), + "", + ].join("\n") + }), }) }), ) -export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer), Layer.provide(LocationServiceMap.layer)) +const locationServiceMapNode = LayerNode.make({ + service: LocationServiceMap.Service, + layer: locationServiceMapLayer, + deps: [], +}) -const locationServiceMapNode = LayerNode.make(LocationServiceMap.layer, []) - -export const node = LayerNode.make(layer, [Skill.node, locationServiceMapNode]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Skill.node, MCP.node, locationServiceMapNode], +}) export * as SystemPrompt from "./system" diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 6e9eeba62b..cd80828de6 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -1,46 +1,32 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionID } from "./schema" -import { Effect, Layer, Context, Schema } from "effect" +import { Effect, Layer, Context } from "effect" import { Database } from "@opencode-ai/core/database/database" import { eq } from "drizzle-orm" import { asc } from "drizzle-orm" import { TodoTable } from "@opencode-ai/core/session/sql" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" +import { SessionTodo } from "@opencode-ai/schema/session-todo" -export const Info = Schema.Struct({ - content: Schema.String.annotate({ description: "Brief description of the task" }), - status: Schema.String.annotate({ - description: "Current status of the task: pending, in_progress, completed, cancelled", - }), - priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }), -}).annotate({ identifier: "Todo" }) -export type Info = Schema.Schema.Type +export const Info = SessionTodo.Info +export type Info = SessionTodo.Info -export const Event = { - Updated: EventV2.define({ - type: "todo.updated", - schema: { - sessionID: SessionID, - todos: Schema.Array(Info), - }, - }), -} +export const Event = SessionTodo.Event export interface Interface { - readonly update: (input: { sessionID: SessionID; todos: Info[] }) => Effect.Effect + readonly update: (input: { sessionID: SessionID; todos: ReadonlyArray }) => Effect.Effect readonly get: (sessionID: SessionID) => Effect.Effect } export class Service extends Context.Service()("@opencode/SessionTodo") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service const { db } = yield* Database.Service - const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) { + const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: ReadonlyArray }) { yield* db .transaction((tx) => Effect.gen(function* () { @@ -83,8 +69,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Database.defaultLayer)) - -export const node = LayerNode.make(layer, [EventV2Bridge.node, Database.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node, Database.node] }) export * as Todo from "./todo" diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 87582ce075..376ba8f2b8 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -20,6 +20,21 @@ import { PartID } from "./schema" import { EffectBridge } from "@/effect/bridge" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { isRecord } from "@/util/record" + +const MCP_RESOURCE_TOOLS = { + list: "list_mcp_resources", + listTemplates: "list_mcp_resource_templates", + read: "read_mcp_resource", +} as const +const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024 +const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([ + "application/pdf", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]) export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { agent: Agent.Info @@ -114,6 +129,258 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }) } + const hasMcpResourceServer = Object.values(yield* mcp.clients()).some( + (client) => !!client.getServerCapabilities()?.resources, + ) + if (hasMcpResourceServer) { + tools[MCP_RESOURCE_TOOLS.list] = tool({ + description: + "Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.", + inputSchema: jsonSchema( + ProviderTransform.schema(input.model, { + type: "object", + properties: { + server: { + type: "string", + description: "Optional MCP server name. When omitted, lists resources from every connected server.", + }, + }, + additionalProperties: false, + }), + ), + execute(args, opts) { + return run.promise( + Effect.gen(function* () { + const parsed = parseListMcpResourcesArgs(args) + const ctx = context(toRecord(args), opts) + const clients = yield* mcp.clients() + const resourceServers = Object.entries(clients) + .filter((entry) => !!entry[1].getServerCapabilities()?.resources) + .map((entry) => entry[0]) + .sort((a, b) => a.localeCompare(b)) + if (parsed.server && !resourceServers.includes(parsed.server)) { + throw new Error( + resourceServers.length === 0 + ? `MCP server "${parsed.server}" does not support resources` + : `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`, + ) + } + const permissionPatterns = parsed.server + ? [`mcp:${parsed.server}:*`] + : resourceServers.map((server) => `mcp:${server}:*`) + yield* plugin.trigger( + "tool.execute.before", + { tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId }, + { args }, + ) + yield* ctx.ask({ + permission: "read", + metadata: parsed.server ? { server: parsed.server } : {}, + patterns: permissionPatterns, + always: permissionPatterns, + }) + + const resources = Object.values(yield* mcp.resources(parsed.server)) + const filtered = resources + .filter((resource) => !parsed.server || resource.client === parsed.server) + .toSorted((a, b) => + (a.client + "\u0000" + a.name + "\u0000" + a.uri).localeCompare( + b.client + "\u0000" + b.name + "\u0000" + b.uri, + ), + ) + const content = JSON.stringify({ resources: filtered.map(formatMcpResource) }, null, 2) + const truncated = yield* truncate.output(content, {}, input.agent) + const output = { + title: parsed.server ? `MCP resources: ${parsed.server}` : "MCP resources", + metadata: { + count: filtered.length, + servers: resourceServers, + ...(parsed.server ? { server: parsed.server } : {}), + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + }, + output: truncated.content, + } + yield* plugin.trigger( + "tool.execute.after", + { tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, + output, + ) + if (opts.abortSignal?.aborted) { + yield* input.processor.completeToolCall(opts.toolCallId, output) + } + return output + }), + ) + }, + }) + + tools[MCP_RESOURCE_TOOLS.listTemplates] = tool({ + description: + "Lists resource templates provided by connected MCP servers. Resource templates are parameterized resources that can be read after filling in their URI template.", + inputSchema: jsonSchema( + ProviderTransform.schema(input.model, { + type: "object", + properties: { + server: { + type: "string", + description: + "Optional MCP server name. When omitted, lists resource templates from every connected server.", + }, + }, + additionalProperties: false, + }), + ), + execute(args, opts) { + return run.promise( + Effect.gen(function* () { + const parsed = parseListMcpResourcesArgs(args) + const ctx = context(toRecord(args), opts) + const clients = yield* mcp.clients() + const resourceServers = Object.entries(clients) + .filter((entry) => !!entry[1].getServerCapabilities()?.resources) + .map((entry) => entry[0]) + .sort((a, b) => a.localeCompare(b)) + if (parsed.server && !resourceServers.includes(parsed.server)) { + throw new Error( + resourceServers.length === 0 + ? `MCP server "${parsed.server}" does not support resources` + : `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`, + ) + } + const permissionPatterns = parsed.server + ? [`mcp:${parsed.server}:*`] + : resourceServers.map((server) => `mcp:${server}:*`) + yield* plugin.trigger( + "tool.execute.before", + { tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId }, + { args }, + ) + yield* ctx.ask({ + permission: "read", + metadata: parsed.server ? { server: parsed.server } : {}, + patterns: permissionPatterns, + always: permissionPatterns, + }) + + const templates = Object.values(yield* mcp.resourceTemplates(parsed.server)) + const filtered = templates + .filter((template) => !parsed.server || template.client === parsed.server) + .toSorted((a, b) => + (a.client + "\u0000" + a.name + "\u0000" + a.uriTemplate).localeCompare( + b.client + "\u0000" + b.name + "\u0000" + b.uriTemplate, + ), + ) + const content = JSON.stringify({ resourceTemplates: filtered.map(formatMcpResourceTemplate) }, null, 2) + const truncated = yield* truncate.output(content, {}, input.agent) + const output = { + title: parsed.server ? `MCP resource templates: ${parsed.server}` : "MCP resource templates", + metadata: { + count: filtered.length, + servers: resourceServers, + ...(parsed.server ? { server: parsed.server } : {}), + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + }, + output: truncated.content, + } + yield* plugin.trigger( + "tool.execute.after", + { tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, + output, + ) + if (opts.abortSignal?.aborted) { + yield* input.processor.completeToolCall(opts.toolCallId, output) + } + return output + }), + ) + }, + }) + + tools[MCP_RESOURCE_TOOLS.read] = tool({ + description: + "Read a specific resource from an MCP server using the server name and resource URI. The URI is an MCP identifier and does not need to be a file URL.", + inputSchema: jsonSchema( + ProviderTransform.schema(input.model, { + type: "object", + properties: { + server: { + type: "string", + description: "MCP server name exactly as returned by list_mcp_resources.", + }, + uri: { + type: "string", + description: "Resource URI to read. Use the exact URI string returned by list_mcp_resources.", + }, + }, + required: ["server", "uri"], + additionalProperties: false, + }), + ), + execute(args, opts) { + return run.promise( + Effect.gen(function* () { + const parsed = parseReadMcpResourceArgs(args) + const ctx = context(toRecord(args), opts) + const clients = yield* mcp.clients() + const client = clients[parsed.server] + if (!client) { + throw new Error(`MCP server "${parsed.server}" is not connected`) + } + if (!client.getServerCapabilities()?.resources) { + throw new Error(`MCP server "${parsed.server}" does not support resources`) + } + yield* plugin.trigger( + "tool.execute.before", + { tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId }, + { args }, + ) + yield* ctx.ask({ + permission: "read", + metadata: { server: parsed.server, uri: parsed.uri }, + patterns: [`mcp:${parsed.server}:${parsed.uri}`], + always: [`mcp:${parsed.server}:*`], + }) + + const content = yield* mcp.readResource(parsed.server, parsed.uri) + if (!content) throw new Error(`Failed to read MCP resource: ${parsed.server}/${parsed.uri}`) + + const formatted = formatMcpResourceContent(parsed.server, parsed.uri, content) + const truncated = yield* truncate.output(formatted.text, {}, input.agent) + const output = { + title: `MCP resource: ${parsed.uri}`, + metadata: { + server: parsed.server, + uri: parsed.uri, + contents: formatted.contents, + attachments: formatted.attachments.length, + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + }, + output: truncated.content, + attachments: formatted.attachments.map((attachment) => ({ + ...attachment, + id: PartID.ascending(), + sessionID: ctx.sessionID, + messageID: input.processor.message.id, + })), + } + yield* plugin.trigger( + "tool.execute.after", + { tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, + output, + ) + if (opts.abortSignal?.aborted) { + yield* input.processor.completeToolCall(opts.toolCallId, output) + } + return output + }), + ) + }, + }) + } + for (const [key, item] of Object.entries(yield* mcp.tools())) { const execute = item.execute if (!execute) continue @@ -163,10 +430,24 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const { resource } = contentItem if (resource.text) textParts.push(resource.text) if (resource.blob) { + const mime = resource.mimeType ?? "application/octet-stream" + const size = base64Size(resource.blob) + if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) { + textParts.push( + `[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`, + ) + continue + } + if (size > MAX_MCP_RESOURCE_BLOB_BYTES) { + textParts.push( + `[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`, + ) + continue + } attachments.push({ type: "file", - mime: resource.mimeType ?? "application/octet-stream", - url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`, + mime, + url: `data:${mime};base64,${resource.blob}`, filename: resource.uri, }) } @@ -204,4 +485,99 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { return tools }) +function toRecord(value: unknown) { + if (isRecord(value)) return value + return {} +} + +function parseListMcpResourcesArgs(value: unknown) { + const args = toRecord(value) + return { server: optionalString(args, "server") } +} + +function parseReadMcpResourceArgs(value: unknown) { + const args = toRecord(value) + return { server: requiredString(args, "server"), uri: requiredString(args, "uri") } +} + +function optionalString(args: Record, key: string) { + const value = args[key] + if (value === undefined || value === null || value === "") return undefined + if (typeof value !== "string") throw new Error(`${key} must be a string`) + return value +} + +function requiredString(args: Record, key: string) { + const value = optionalString(args, key) + if (value) return value + throw new Error(`${key} is required`) +} + +function formatMcpResource(resource: MCP.Resource) { + const result = Object.fromEntries(Object.entries(resource).filter((entry) => entry[0] !== "client")) + return { ...result, server: resource.client } +} + +function formatMcpResourceTemplate(template: Record & { client: string }) { + const result = Object.fromEntries(Object.entries(template).filter((entry) => entry[0] !== "client")) + return { ...result, server: template.client } +} + +function formatMcpResourceContent(server: string, uri: string, content: { contents: unknown }) { + const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord) + const text: string[] = [] + const attachments: Omit[] = [] + + for (const item of items) { + const itemUri = typeof item.uri === "string" ? item.uri : uri + const mime = typeof item.mimeType === "string" ? item.mimeType : "application/octet-stream" + if (typeof item.text === "string") { + text.push(`Resource: ${itemUri}\nMIME: ${mime}\n${item.text}`) + continue + } + if (typeof item.blob === "string") { + const size = base64Size(item.blob) + if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) { + text.push( + `[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`, + ) + continue + } + if (size > MAX_MCP_RESOURCE_BLOB_BYTES) { + text.push( + `[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`, + ) + continue + } + text.push(`[Binary MCP resource attached: ${itemUri} (${mime})]`) + attachments.push({ + type: "file", + mime, + url: `data:${mime};base64,${item.blob}`, + filename: itemUri, + }) + continue + } + text.push(`[MCP resource content without text or blob: ${itemUri}]`) + } + + return { + contents: items.length, + attachments, + text: text.join("\n\n") || `MCP resource ${uri} from ${server} returned no contents.`, + } +} + +function base64Size(value: string) { + const trimmed = value.replace(/\s/g, "") + const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0 + return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding) +} + +function formatBytes(value: number) { + if (value < 1024) return `${value} B` + if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB` + return `${Math.ceil(value / (1024 * 1024))} MB` +} + export * as SessionTools from "./tools" diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts index 776e8aa1c8..a4ef77fbfd 100644 --- a/packages/opencode/src/share/session.ts +++ b/packages/opencode/src/share/session.ts @@ -14,7 +14,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionShare") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const cfg = yield* Config.Service @@ -49,13 +49,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(ShareNext.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), -) - -export const node = LayerNode.make(layer, [Config.node, Session.node, ShareNext.node, RuntimeFlags.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, Session.node, ShareNext.node, RuntimeFlags.node], +}) export * as SessionShare from "./session" diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 9c7e9f3590..8ea780c999 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -1,5 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import type * as SDK from "@kilocode/sdk/v2" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect" @@ -109,7 +109,7 @@ function key(item: Data) { } } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const account = yield* Account.Service @@ -362,24 +362,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Account.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Session.defaultLayer), -) - -export const node = LayerNode.make(layer, [ - Account.node, - EventV2Bridge.node, - Config.node, - Database.node, - httpClient, - Provider.node, - Session.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Account.node, EventV2Bridge.node, Config.node, Database.node, httpClient, Provider.node, Session.node], +}) export * as ShareNext from "./share-next" diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index 0495bc637d..b56a67610f 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -1,5 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient, path } from "@opencode-ai/core/effect/layer-node-platform" +import { httpClient, path } from "@opencode-ai/core/effect/app-node-platform" import { NodePath } from "@effect/platform-node" import { Effect, Layer, Path, Schema, Context } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" @@ -13,6 +13,7 @@ const fileConcurrency = 8 class IndexSkill extends Schema.Class("IndexSkill")({ name: Schema.String, files: Schema.Array(Schema.String), + version: Schema.optional(Schema.String), }) {} class Index extends Schema.Class("Index")({ @@ -25,7 +26,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SkillDiscovery") {} -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -76,17 +77,53 @@ export const layer: Layer.Layer Effect.gen(function* () { const root = path.join(cache, skill.name) + const versionFile = path.join(root, ".opencode-version") + const version = skill.version + const current = + version === undefined + ? undefined + : yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined))) - yield* Effect.forEach( - skill.files, - (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)), - { - concurrency: fileConcurrency, - }, - ) - - const md = path.join(root, "SKILL.md") - return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null + if (version === undefined || current === version) { + yield* Effect.forEach( + skill.files, + (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)), + { concurrency: fileConcurrency, discard: true }, + ) + } else { + const token = crypto.randomUUID() + const staging = `${root}.tmp-${token}` + const backup = `${root}.old-${token}` + yield* Effect.gen(function* () { + const downloaded = yield* Effect.forEach( + skill.files, + (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(staging, file)), + { concurrency: fileConcurrency }, + ) + if (!downloaded.every(Boolean)) return + if (!(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie))) return + yield* fs.writeFileString(path.join(staging, ".opencode-version"), version) + yield* Effect.uninterruptible( + Effect.gen(function* () { + const cached = yield* fs.exists(root).pipe(Effect.orDie) + if (cached) yield* fs.rename(root, backup) + yield* fs.rename(staging, root).pipe( + Effect.catch((error) => + Effect.gen(function* () { + if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore) + return yield* Effect.fail(error) + }), + ), + ) + if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore) + }), + ) + }).pipe( + Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })), + Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)), + ) + } + return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ? root : null }), { concurrency: skillConcurrency }, ) @@ -98,12 +135,6 @@ export const layer: Layer.Layer = layer.pipe( - Layer.provide(FetchHttpClient.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(NodePath.layer), -) - -export const node = LayerNode.make(layer, [FSUtil.node, path, httpClient]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, path, httpClient] }) export * as Discovery from "./discovery" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index c51f7786f7..d8317cc2de 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -1,6 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import path from "path" -import { pathToFileURL } from "url" import { Effect, Layer, Context, Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" @@ -17,6 +16,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { Glob } from "@opencode-ai/core/util/glob" import { Discovery } from "./discovery" import { isRecord } from "@/util/record" +import { escapeHtml } from "@/util/html" const CLAUDE_EXTERNAL_DIR = ".claude" const AGENTS_EXTERNAL_DIR = ".agents" @@ -247,7 +247,7 @@ const loadSkills = Effect.fnUntraced(function* ( export class Service extends Context.Service()("@opencode/Skill") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const discovery = yield* Discovery.Service @@ -318,15 +318,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Discovery.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layer), - Layer.provide(RuntimeFlags.defaultLayer), -) - export function fmt(list: Info[], opts: { verbose: boolean }) { const described = list.filter((skill) => skill.description !== undefined) if (described.length === 0) return "No skills are currently available." @@ -339,7 +330,7 @@ export function fmt(list: Info[], opts: { verbose: boolean }) { " ", ` ${skill.name}`, ` ${skill.description}`, - ` ${pathToFileURL(skill.location).href}`, + ` ${escapeHtml(skill.location)}`, " ", ]), "", @@ -354,13 +345,10 @@ export function fmt(list: Info[], opts: { verbose: boolean }) { ].join("\n") } -export const node = LayerNode.make(layer, [ - Discovery.node, - Config.node, - EventV2Bridge.node, - FSUtil.node, - Global.node, - RuntimeFlags.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Discovery.node, Config.node, EventV2Bridge.node, FSUtil.node, Global.node, RuntimeFlags.node], +}) export * as Skill from "." diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index fd25437bb0..4da9bc3ca8 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -9,6 +9,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Hash } from "@opencode-ai/core/util/hash" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" +import { Info } from "@opencode-ai/schema/file-diff" export const Patch = Schema.Struct({ hash: Schema.String, @@ -16,16 +17,7 @@ export const Patch = Schema.Struct({ }) export type Patch = typeof Patch.Type -export const FileDiff = Schema.Struct({ - // Optional because legacy/imported `summary_diffs` on disk may omit - // file details and patch text. Required Schema rejected the whole - // session response and broke session loading on Desktop. - file: Schema.optional(Schema.String), - patch: Schema.optional(Schema.String), - additions: Schema.Finite, - deletions: Schema.Finite, - status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), -}).annotate({ identifier: "SnapshotFileDiff" }) +export const FileDiff = Info export type FileDiff = typeof FileDiff.Type const prune = "7.days" @@ -54,7 +46,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Snapshot") {} -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -82,7 +74,9 @@ export const layer: Layer.Layer ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd] - const feed = (list: string[]) => list.join("\0") + "\0" + const encodeNulTerminatedPaths = (files: string[]) => files.join("\0") + "\0" + const encodeTopLevelLiteralPathspecs = (files: string[]) => + encodeNulTerminatedPaths(files.map((file) => `:(top,literal)${file}`)) const git = Effect.fnUntraced( function* (cmd: string[], opts?: { cwd?: string; env?: Record; stdin?: string }) { @@ -107,6 +101,8 @@ export const layer: Layer.Layer() + // check-ignore treats a leading colon as pathspec magic but accepts and echoes a protective ./ prefix. + const checkIgnorePaths = files.map((item) => (item.startsWith(":") ? `./${item}` : item)) const check = yield* git( [ ...quote, @@ -120,12 +116,17 @@ export const layer: Layer.Layer() - return new Set(check.text.split("\0").filter(Boolean)) + return new Set( + check.text + .split("\0") + .filter(Boolean) + .map((item) => (item.startsWith("./:") ? item.slice(2) : item)), + ) }) const drop = Effect.fnUntraced(function* (files: string[]) { @@ -136,8 +137,8 @@ export const layer: Layer.Layer fs - .stat(path.join(state.directory, item)) + .stat(path.join(state.worktree, item)) .pipe(Effect.catch(() => Effect.void)) .pipe( Effect.map((stat) => { @@ -797,12 +798,10 @@ export const layer: Layer.Layer ({ + statics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("event", id)), })), ) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index d540596d6b..1a2a1f6528 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,5 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { PlanExitTool } from "./plan" import { Session } from "@/session/session" @@ -33,7 +33,6 @@ import { Glob } from "@opencode-ai/core/util/glob" import path from "path" import { pathToFileURL } from "url" import { Effect, Layer, Context } from "effect" -import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Format } from "../format" @@ -80,7 +79,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ToolRegistry") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -315,30 +314,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer - .pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Question.defaultLayer), - Layer.provide(Todo.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(BackgroundJob.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Format.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Truncate.defaultLayer), - ) - .pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)), -) - function isZodType(value: unknown): value is z.ZodType { return typeof value === "object" && value !== null && "_zod" in value } @@ -415,26 +390,31 @@ function isJsonSchemaObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } -export const node = LayerNode.make(layer.pipe(Layer.provide(Ripgrep.defaultLayer)), [ - Config.node, - Plugin.node, - Question.node, - Todo.node, - Agent.node, - Skill.node, - Session.node, - BackgroundJob.node, - Provider.node, - LSP.node, - Instruction.node, - FSUtil.node, - EventV2Bridge.node, - httpClient, - CrossSpawnSpawner.node, - Format.node, - Truncate.node, - RuntimeFlags.node, - Database.node, -]) +export const node = LayerNode.make({ + service: Service, + layer, + deps: [ + Config.node, + Plugin.node, + Question.node, + Todo.node, + Agent.node, + Skill.node, + Session.node, + BackgroundJob.node, + Provider.node, + LSP.node, + Instruction.node, + FSUtil.node, + EventV2Bridge.node, + httpClient, + CrossSpawnSpawner.node, + Format.node, + Truncate.node, + RuntimeFlags.node, + Database.node, + Ripgrep.node, + ], +}) export * as ToolRegistry from "./registry" diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index cdeadb74d6..c83444866e 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -1,14 +1,14 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { withStatics } from "@opencode-ai/core/schema" +import { statics } from "@opencode-ai/core/schema" const toolIdSchema = Schema.String.check(Schema.isStartsWith("tool")).pipe(Schema.brand("ToolID")) export type ToolID = typeof toolIdSchema.Type export const ToolID = toolIdSchema.pipe( - withStatics((schema: typeof toolIdSchema) => ({ + statics((schema: typeof toolIdSchema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("tool", id)), })), ) diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 620378dc10..1e4423e017 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -260,11 +260,7 @@ const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boole return tree }) -const ask = Effect.fn("ShellTool.ask")(function* ( - ctx: Tool.Context, - scan: Scan, - input: { command: string; description: string }, -) { +const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan, input: { command: string }) { if (scan.dirs.size > 0) { const directories = Array.from(scan.dirs) const globs = directories.map((dir) => { @@ -277,7 +273,6 @@ const ask = Effect.fn("ShellTool.ask")(function* ( always: globs, metadata: { command: input.command, - description: input.description, directories, patterns: globs, }, @@ -291,7 +286,6 @@ const ask = Effect.fn("ShellTool.ask")(function* ( always: Array.from(scan.always), metadata: { command: input.command, - description: input.description, }, }) }) @@ -438,7 +432,6 @@ export const ShellTool = Tool.define( cwd: string env: NodeJS.ProcessEnv timeout: number - description: string }, ctx: Tool.Context, ) { @@ -482,7 +475,6 @@ export const ShellTool = Tool.define( yield* ctx.metadata({ metadata: { output: "", - description: input.description, }, }) @@ -523,7 +515,6 @@ export const ShellTool = Tool.define( ctx.metadata({ metadata: { output: last, - description: input.description, }, }), ), @@ -534,7 +525,6 @@ export const ShellTool = Tool.define( return ctx.metadata({ metadata: { output: last, - description: input.description, }, }) }), @@ -593,11 +583,10 @@ export const ShellTool = Tool.define( output += "\n\n\n" + meta.join("\n") + "\n" } return { - title: input.description, + title: input.command, metadata: { output: last || preview(output), exit: code, - description: input.description, truncated: cut, ...(cut && file ? { outputPath: file } : {}), }, @@ -646,7 +635,6 @@ export const ShellTool = Tool.define( cwd, env: yield* shellEnv(ctx, cwd), timeout, - description: params.description, }, ctx, ) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index bec50d98d9..b576b77297 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -7,30 +7,22 @@ import { ShellID } from "./id" const PS = new Set(["powershell", "pwsh"]) const CMD = new Set(["cmd"]) -const descriptions = { - bash: "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - powershell: - 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', - cmd: 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', -} - export type Limits = { maxLines: number maxBytes: number } -export function parameterSchema(description: string) { +export function parameterSchema() { return Schema.Struct({ command: Schema.String.annotate({ description: "The command to execute" }), timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), workdir: Schema.optional(Schema.String).annotate({ description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, }), - description: Schema.String.annotate({ description }), }) } -export const Parameters = parameterSchema(descriptions.bash) +export const Parameters = parameterSchema() export type Parameters = Schema.Schema.Type function renderPrompt(template: string, values: Record) { @@ -103,7 +95,6 @@ function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: num Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms. - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: @@ -155,7 +146,6 @@ Before executing the command, please follow these steps: Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms. - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: @@ -205,7 +195,6 @@ Before executing the command, please follow these steps: Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms. - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching. - Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: @@ -242,7 +231,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul gitCommandRestriction: "git commands", createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.", createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`, - parameterDescription: descriptions.cmd, } } if (isPowerShell) { @@ -264,7 +252,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul ## Summary - <1-3 bullet points> '@`, - parameterDescription: descriptions.powershell, } } return { @@ -280,7 +267,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' ## Summary <1-3 bullet points>`, - parameterDescription: descriptions.bash, } } @@ -300,7 +286,7 @@ export function render(name: string, platform: NodeJS.Platform, limits: Limits, createPrInstruction: selected.createPrInstruction, createPrExample: selected.createPrExample, }), - parameters: parameterSchema(selected.parameterDescription), + parameters: parameterSchema(), } } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 49901182b1..9149bcc5ff 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -1,5 +1,4 @@ import path from "path" -import { pathToFileURL } from "url" import { Effect, Schema } from "effect" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { Skill } from "../skill" @@ -33,7 +32,7 @@ export const SkillTool = Tool.define( }) const dir = path.dirname(info.location) - const base = pathToFileURL(dir).href + const base = dir const files = yield* ripgrep.find({ cwd: dir, pattern: "!**/SKILL.md", diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index 18d21cf61e..69fb0550e8 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -3,19 +3,8 @@ import * as Tool from "./tool" import DESCRIPTION_WRITE from "./todowrite.txt" import { Todo } from "../session/todo" -// Todo.Info is still a zod schema (session/todo.ts). Inline the field shape -// here rather than referencing its `.shape` — the LLM-visible JSON Schema is -// identical, and it removes the last zod dependency from this tool. -const TodoItem = Schema.Struct({ - content: Schema.String.annotate({ description: "Brief description of the task" }), - status: Schema.String.annotate({ - description: "Current status of the task: pending, in_progress, completed, cancelled", - }), - priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }), -}) - export const Parameters = Schema.Struct({ - todos: Schema.mutable(Schema.Array(TodoItem)).annotate({ description: "The updated todo list" }), + todos: Schema.mutable(Schema.Array(Todo.Info)).annotate({ description: "The updated todo list" }), }) type Metadata = { diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 1815643d03..3a48c90a98 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -46,7 +46,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Truncate") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -151,8 +151,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(NodePath.layer)) - -export const node = LayerNode.make(layer, [FSUtil.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node] }) export * as Truncate from "./truncate" diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 8ee485b813..a0c43ed2c3 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,7 +1,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { path } from "@opencode-ai/core/effect/layer-node-platform" +import { path } from "@opencode-ai/core/effect/app-node-platform" import { Global } from "@opencode-ai/core/global" -import { InstanceLayer } from "@/project/instance-layer" import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Database } from "@opencode-ai/core/database/database" @@ -10,31 +9,16 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import type { ProjectV2 } from "@opencode-ai/core/project" import { Slug } from "@opencode-ai/core/util/slug" import { errorMessage } from "../util/error" -import { EventV2 } from "@opencode-ai/core/event" import { GlobalBus } from "@/bus/global" import { Git } from "@/git" import { Effect, Layer, Path, Schema, Scope, Context } from "effect" import { ChildProcess } from "effect/unstable/process" -import { NodePath } from "@effect/platform-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { AppProcess } from "@opencode-ai/core/process" import { InstanceState } from "@/effect/instance-state" +import { WorktreeEvent } from "@opencode-ai/schema/worktree-event" -export const Event = { - Ready: EventV2.define({ - type: "worktree.ready", - schema: { - name: Schema.String, - branch: Schema.optional(Schema.String), - }, - }), - Failed: EventV2.define({ - type: "worktree.failed", - schema: { - message: Schema.String, - }, - }), -} +export const Event = WorktreeEvent export const Info = Schema.Struct({ name: Schema.String, @@ -145,7 +129,7 @@ export class Service extends Context.Service()("@opencode/Wo type GitResult = { code: number; text: string; stderr: string } -export const layer: Layer.Layer< +const layer: Layer.Layer< Service, never, | FSUtil.Service @@ -630,25 +614,10 @@ export const layer: Layer.Layer< }), ) -export const appLayer = layer.pipe( - Layer.provide(Git.defaultLayer), - Layer.provide(AppProcess.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(NodePath.layer), -) - -export const defaultLayer = appLayer.pipe(Layer.provide(InstanceLayer.layer)) - -export const node = LayerNode.make(layer, [ - FSUtil.node, - path, - AppProcess.node, - Git.node, - Project.node, - InstanceStore.node, - Database.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [FSUtil.node, path, AppProcess.node, Git.node, Project.node, InstanceStore.node, Database.node], +}) export * as Worktree from "." diff --git a/packages/opencode/test/account/repo.test.ts b/packages/opencode/test/account/repo.test.ts index 42851fc19d..756d873f9c 100644 --- a/packages/opencode/test/account/repo.test.ts +++ b/packages/opencode/test/account/repo.test.ts @@ -1,4 +1,5 @@ import { expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer, Option } from "effect" import { sql } from "drizzle-orm" @@ -13,9 +14,10 @@ const truncate = Layer.effectDiscard( yield* db.run(sql`DELETE FROM account_state`) yield* db.run(sql`DELETE FROM account`) }), -).pipe(Layer.provide(Database.defaultLayer)) +) +const truncateNode = LayerNode.make({ name: "truncate-account", layer: truncate, deps: [Database.node] }) -const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate)) +const it = testEffect(LayerNode.compile(LayerNode.group([AccountRepo.node, truncateNode]))) it.live("list returns empty when no accounts exist", () => Effect.gen(function* () { diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index 04d425e2c4..0ebe69c239 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -1,4 +1,6 @@ import { expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Duration, Effect, Layer, Option, Schema } from "effect" import { sql } from "drizzle-orm" import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http" @@ -25,15 +27,16 @@ const truncate = Layer.effectDiscard( yield* db.run(sql`DELETE FROM account_state`) yield* db.run(sql`DELETE FROM account`) }), -).pipe(Layer.provide(Database.defaultLayer)) +) +const truncateNode = LayerNode.make({ name: "truncate-account", layer: truncate, deps: [Database.node] }) -const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate)) +const it = testEffect(LayerNode.compile(LayerNode.group([AccountRepo.node, truncateNode]))) const insideEagerRefreshWindow = Duration.toMillis(Duration.minutes(1)) const outsideEagerRefreshWindow = Duration.toMillis(Duration.minutes(10)) const live = (client: HttpClient.HttpClient) => - Account.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) + LayerNode.compile(Account.node, [[httpClient, Layer.succeed(HttpClient.HttpClient, client)]]) const json = (req: Parameters[0], body: unknown, status = 200) => HttpClientResponse.fromWeb( diff --git a/packages/opencode/test/acp/content.test.ts b/packages/opencode/test/acp/content.test.ts index 90f62f9d18..ab5ffdb94a 100644 --- a/packages/opencode/test/acp/content.test.ts +++ b/packages/opencode/test/acp/content.test.ts @@ -99,17 +99,51 @@ describe("acp content conversion", () => { ]) }) - test("resource with text becomes a text part", () => { + test("resource with text becomes a sourced text part", () => { + const result = contentBlockToParts({ + type: "resource", + resource: { + uri: "file:///tmp/context.txt#L12-L14", + mimeType: "text/plain", + text: "context", + }, + }) + expect(result).toHaveLength(1) + expect(result[0]?.type).toBe("text") + if (result[0]?.type === "text") { + expect(result[0].text.endsWith("\ncontext")).toBe(true) + expect(result[0].text.includes("context.txt")).toBe(true) + expect(result[0].text.includes("12")).toBe(true) + } + }) + + test("resource with text uses URI fallback for non-file resources", () => { expect( contentBlockToParts({ type: "resource", resource: { - uri: "file:///tmp/context.txt", - mimeType: "text/plain", + uri: "mcp://server/context", text: "context", }, }), - ).toEqual([{ type: "text", text: "context" }]) + ).toEqual([{ type: "text", text: "[mcp://server/context]\ncontext" }]) + }) + + test("resource with text includes file path", () => { + const result = contentBlockToParts({ + type: "resource", + resource: { + uri: "file:///tmp/context.txt", + mimeType: "text/plain", + text: "context", + }, + }) + expect(result).toHaveLength(1) + expect(result[0]?.type).toBe("text") + if (result[0]?.type === "text") { + expect(result[0].text.endsWith("\ncontext")).toBe(true) + expect(result[0].text.includes("context.txt")).toBe(true) + } }) test("resource with blob and mimeType becomes a data URL file part", () => { diff --git a/packages/opencode/test/acp/directory.test.ts b/packages/opencode/test/acp/directory.test.ts index e274db85cb..75f2d50854 100644 --- a/packages/opencode/test/acp/directory.test.ts +++ b/packages/opencode/test/acp/directory.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Directory } from "@/acp/directory" import { Command } from "@/command" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Provider } from "@/provider/provider" @@ -83,8 +84,9 @@ const snapshot = (directory: string) => { } const fakeLayer = (calls: string[]) => - Directory.layer.pipe( - Layer.provide( + LayerNode.compile(Directory.node, [ + [ + Directory.loaderNode, Layer.succeed( Directory.Loader, Directory.Loader.of({ @@ -95,8 +97,8 @@ const fakeLayer = (calls: string[]) => }), }), ), - ), - ) + ], + ]) describe("ACP directory snapshot", () => { it.effect("two concurrent callers share one load", () => { diff --git a/packages/opencode/test/acp/event.test.ts b/packages/opencode/test/acp/event.test.ts index 7067e7a300..e860b0c57f 100644 --- a/packages/opencode/test/acp/event.test.ts +++ b/packages/opencode/test/acp/event.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test" import type { AgentSideConnection } from "@agentclientprotocol/sdk" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import type { Event, Message, KiloClient, Part, SessionMessageResponse, ToolPart } from "@kilocode/sdk/v2" import { Effect, ManagedRuntime } from "effect" import { ACPEvent } from "@/acp/event" @@ -30,7 +31,7 @@ const pollUntil = async ( } function makeSessionService() { - return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + return ManagedRuntime.make(LayerNode.compile(ACPSession.node)).runSync( ACPSession.Service.use((service) => Effect.succeed(service)), ) } diff --git a/packages/opencode/test/acp/permission.test.ts b/packages/opencode/test/acp/permission.test.ts index 080c8eeceb..b7dc5c093b 100644 --- a/packages/opencode/test/acp/permission.test.ts +++ b/packages/opencode/test/acp/permission.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test" +import { afterEach, describe, expect, it } from "bun:test" import type { AgentSideConnection, RequestPermissionRequest, @@ -6,13 +6,23 @@ import type { SessionUpdate, } from "@agentclientprotocol/sdk" import type { Event, KiloClient } from "@kilocode/sdk/v2" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { createTwoFilesPatch } from "diff" import { Effect, ManagedRuntime } from "effect" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" import { ACPEvent } from "@/acp/event" import { ACPSession } from "@/acp/session" type PermissionEvent = Extract type PermissionReplyParams = Parameters[0] type SessionUpdateParams = Parameters[0] +const cleanupDirs: string[] = [] + +afterEach(async () => { + await Promise.all(cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) const pollUntil = async ( check: () => boolean | Promise, @@ -28,7 +38,7 @@ const pollUntil = async ( } function makeSessionService() { - return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + return ManagedRuntime.make(LayerNode.compile(ACPSession.node)).runSync( ACPSession.Service.use((service) => Effect.succeed(service)), ) } @@ -137,6 +147,14 @@ function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) { .join("") } +async function tempFile(name: string, content: string) { + const dir = await mkdtemp(path.join(tmpdir(), "opencode-acp-permission-")) + cleanupDirs.push(dir) + const file = path.join(dir, name) + await Bun.write(file, content) + return file +} + describe("acp permissions", () => { it("sends requestPermission and replies with the selected outcome", async () => { const harness = createHarness() @@ -151,7 +169,7 @@ describe("acp permissions", () => { toolCall: { toolCallId: "call_1", status: "pending", - title: "bash", + title: "printf hello", rawInput: { command: "printf hello" }, kind: "execute", locations: [], @@ -165,6 +183,116 @@ describe("acp permissions", () => { expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }]) }) + it("uses permission metadata for non-shell titles", async () => { + const harness = createHarness() + await createSession(harness.session, "ses_a") + + harness.subscription.handle( + permissionAsked("ses_a", "perm_fetch", { + permission: "webfetch", + metadata: { + url: "https://example.com/docs", + format: "markdown", + }, + tool: { messageID: "msg_1", callID: "call_1" }, + }), + ) + + await pollUntil(() => harness.replies.length === 1, "webfetch permission was never replied") + + expect(harness.requests[0]?.toolCall).toMatchObject({ + toolCallId: "call_1", + title: "https://example.com/docs", + kind: "fetch", + rawInput: { url: "https://example.com/docs", format: "markdown" }, + }) + }) + + it("includes a diff content block for edit permission metadata", async () => { + const filepath = await tempFile("file.ts", "before\n") + const harness = createHarness() + await createSession(harness.session, "ses_a") + + harness.subscription.handle( + permissionAsked("ses_a", "perm_edit", { + permission: "edit", + metadata: { + filepath, + diff: createTwoFilesPatch(filepath, filepath, "before\n", "after\n"), + }, + tool: { messageID: "msg_1", callID: "call_1" }, + }), + ) + + await pollUntil(() => harness.replies.length === 1, "edit permission was never replied") + + expect(harness.requests[0]?.toolCall).toMatchObject({ + toolCallId: "call_1", + title: filepath, + kind: "edit", + locations: [{ path: filepath }], + content: [ + { + type: "diff", + path: filepath, + oldText: "before\n", + newText: "after\n", + }, + ], + }) + }) + + it("includes per-file diff blocks and locations for apply_patch permission metadata", async () => { + const first = await tempFile("first.ts", "one\n") + const second = await tempFile("second.ts", "alpha\n") + const harness = createHarness() + await createSession(harness.session, "ses_a") + + harness.subscription.handle( + permissionAsked("ses_a", "perm_patch", { + permission: "edit", + metadata: { + filepath: "first.ts, second.ts", + files: [ + { + filePath: first, + relativePath: "first.ts", + patch: createTwoFilesPatch(first, first, "one\n", "two\n"), + }, + { + filePath: second, + relativePath: "second.ts", + patch: createTwoFilesPatch(second, second, "alpha\n", "beta\n"), + }, + ], + }, + tool: { messageID: "msg_1", callID: "call_1" }, + }), + ) + + await pollUntil(() => harness.replies.length === 1, "apply_patch permission was never replied") + + expect(harness.requests[0]?.toolCall).toMatchObject({ + toolCallId: "call_1", + title: "2 files", + locations: [{ path: first }, { path: second }], + content: [ + { + type: "diff", + path: first, + oldText: "one\n", + newText: "two\n", + }, + { + type: "diff", + path: second, + oldText: "alpha\n", + newText: "beta\n", + }, + ], + }) + }) + it("forwards external_directory metadata and locations to requestPermission", async () => { const harness = createHarness() await createSession(harness.session, "ses_a") @@ -189,7 +317,7 @@ describe("acp permissions", () => { toolCall: { toolCallId: "call_1", status: "pending", - title: "external_directory", + title: "Create external directory", rawInput: { command: "mkdir -p /tmp/outside", description: "Create external directory", diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index 0387d4242e..a9d1d2884e 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -10,7 +10,7 @@ import type { SessionConfigSelectOption, SetSessionConfigOptionResponse, } from "@agentclientprotocol/sdk" -import type { KiloClient } from "@kilocode/sdk/v2" +import type { AssistantMessage, KiloClient } from "@kilocode/sdk/v2" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Effect } from "effect" @@ -144,7 +144,10 @@ const provider: Provider.Info = { describe("ACP service sessions", () => { const makeService = ( messages: readonly { info: unknown; parts: readonly unknown[] }[] = [], - options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> }, + options?: { + abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> + prompt?: (input: unknown) => Promise<{ data: { info: ReturnType } }> + }, ) => { const updates: SessionNotification[] = [] const mcpAdds: string[] = [] @@ -193,19 +196,21 @@ describe("ACP service sessions", () => { data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions, }), messages: () => Promise.resolve({ data: messages }), - prompt: (input: unknown) => { - prompts.push(input) - return Promise.resolve({ - data: { - info: assistantInfo({ - input: 100, - output: 40, - reasoning: 7, - cache: { read: 11, write: 13 }, - }), - }, - }) - }, + prompt: + options?.prompt ?? + ((input: unknown) => { + prompts.push(input) + return Promise.resolve({ + data: { + info: assistantInfo({ + input: 100, + output: 40, + reasoning: 7, + cache: { read: 11, write: 13 }, + }), + }, + }) + }), command: (input: unknown) => { commands.push(input) return Promise.resolve({ @@ -389,15 +394,29 @@ describe("ACP service sessions", () => { expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_2", "ses_1"]) }) - it("resumes a session and stores restored state", async () => { - const { service } = makeService([ + it("resumes a session and stores restored state without replaying transcript chunks", async () => { + const { service, updates } = makeService([ { info: { + id: "msg_user", + sessionID: "ses_resume", role: "user", model: { providerID: "test", modelID: "test-model", variant: "high" }, agent: "plan", }, - parts: [], + parts: [{ id: "part_user", sessionID: "ses_resume", messageID: "msg_user", type: "text", text: "hello" }], + }, + { + info: { id: "msg_assistant", sessionID: "ses_resume", role: "assistant" }, + parts: [ + { + id: "part_assistant", + sessionID: "ses_resume", + messageID: "msg_assistant", + type: "text", + text: "hi there", + }, + ], }, ]) const resumed = await Effect.runPromise( @@ -409,6 +428,11 @@ describe("ACP service sessions", () => { expect(select(resumed, "effort")?.currentValue).toBe("high") expect(select(updated, "effort")?.currentValue).toBe("default") + expect( + updates + .map((item) => item.update) + .filter((item) => item.sessionUpdate === "user_message_chunk" || item.sessionUpdate === "agent_message_chunk"), + ).toEqual([]) }) it("closes local ACP state and aborts the backing session best-effort", async () => { @@ -994,6 +1018,52 @@ describe("ACP service sessions", () => { expect(usageUpdates).toEqual([session.sessionId]) }) + it("maps assistant prompt errors to request errors instead of end turn", async () => { + const { service } = makeService([], { + prompt: () => + Promise.resolve({ + data: { + info: assistantInfo( + { input: 8, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + { name: "APIError", data: { message: "Provider request failed", isRetryable: false } }, + ), + }, + }), + }) + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + const error = await Effect.runPromise( + service + .prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }) + .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip), + ) + + expect(error.code).toBe(-32603) + expect(error.message).toBe("Internal error: Provider request failed") + expect(error.data).toEqual({ service: "session", errorName: "APIError" }) + }) + + it("maps aborted assistant prompt errors to cancelled", async () => { + const { service } = makeService([], { + prompt: () => + Promise.resolve({ + data: { + info: assistantInfo( + { input: 8, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, + { name: "MessageAbortedError", data: { message: "Aborted" } }, + ), + }, + }), + }) + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + const result = await Effect.runPromise( + service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }), + ) + + expect(result.stopReason).toBe("cancelled") + }) + it("prompt maps assistant and user audience annotations", async () => { const { service, prompts } = makeService() const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) @@ -1145,13 +1215,17 @@ describe("ACP service sessions", () => { }) }) -function assistantInfo(tokens: UsageService.AssistantTokenCost["tokens"]): UsageService.AssistantMessage { +function assistantInfo( + tokens: UsageService.AssistantTokenCost["tokens"], + error?: AssistantMessage["error"], +): UsageService.AssistantMessage & Pick { return { role: "assistant", providerID: "test", modelID: "test-model", cost: 0, tokens, + ...(error ? { error } : {}), } } diff --git a/packages/opencode/test/acp/session.test.ts b/packages/opencode/test/acp/session.test.ts index c3d41ef08e..89968563c9 100644 --- a/packages/opencode/test/acp/session.test.ts +++ b/packages/opencode/test/acp/session.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import type { McpServer } from "@agentclientprotocol/sdk" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect } from "effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -7,7 +8,7 @@ import * as ACPError from "@/acp/error" import * as ACPSession from "@/acp/session" import { testEffect } from "../lib/effect" -const sessionTest = testEffect(ACPSession.defaultLayer) +const sessionTest = testEffect(LayerNode.compile(ACPSession.node)) const model = (providerID: string, modelID: string): ACPSession.SelectedModel => ({ providerID: ProviderV2.ID.make(providerID), diff --git a/packages/opencode/test/acp/tool.test.ts b/packages/opencode/test/acp/tool.test.ts index 1713f6faf0..a243e10cc7 100644 --- a/packages/opencode/test/acp/tool.test.ts +++ b/packages/opencode/test/acp/tool.test.ts @@ -2,9 +2,11 @@ import { resolve } from "path" import { describe, expect, test } from "bun:test" import { completedToolContent, + completedToolUpdate, completedToolRawOutput, extractImageAttachments, imageContents, + pendingToolCall, shellOutputSnapshot, toLocations, toToolKind, @@ -111,6 +113,85 @@ describe("acp tool conversion", () => { ]) }) + test("sends completed tool calls as partial updates", () => { + expect( + pendingToolCall({ + toolCallId: "tool-1", + toolName: "edit", + state: { + input: { + filePath: "/tmp/file.ts", + oldString: "before", + newString: "after", + }, + }, + }), + ).toMatchObject({ + kind: "edit", + locations: [{ path: "/tmp/file.ts" }], + rawInput: { + filePath: "/tmp/file.ts", + oldString: "before", + newString: "after", + }, + }) + + expect( + completedToolUpdate({ + toolCallId: "tool-1", + toolName: "edit", + state: { + status: "completed", + input: { + filePath: "/tmp/file.ts", + oldString: "before", + newString: "after", + }, + output: "Edit applied successfully.", + }, + }), + ).toEqual({ + toolCallId: "tool-1", + status: "completed", + content: [ + { + type: "content", + content: { type: "text", text: "Edit applied successfully." }, + }, + { + type: "diff", + path: "/tmp/file.ts", + oldText: "before", + newText: "after", + }, + ], + rawOutput: { + output: "Edit applied successfully.", + }, + }) + + expect( + completedToolUpdate({ + toolCallId: "tool-1", + toolName: "edit", + state: { + status: "completed", + input: { + filePath: "/tmp/file.ts", + oldString: "before", + newString: "after", + }, + title: "file.ts", + output: "Edit applied successfully.", + }, + }), + ).toMatchObject({ + toolCallId: "tool-1", + status: "completed", + title: "file.ts", + }) + }) + test("uses clean read display text for completed content", () => { const output = [ "/tmp/file.ts", diff --git a/packages/opencode/test/acp/usage.test.ts b/packages/opencode/test/acp/usage.test.ts index d2ff139c56..06ccfb1f50 100644 --- a/packages/opencode/test/acp/usage.test.ts +++ b/packages/opencode/test/acp/usage.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { SessionNotification } from "@agentclientprotocol/sdk" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { UsageService } from "@/acp/usage" @@ -97,24 +98,26 @@ const fakeLayer = (input: { readonly messages?: Effect.Effect readonly providers?: (directory: string) => Effect.Effect, unknown> }) => - UsageService.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.succeed( - UsageService.MessageLoader, - UsageService.MessageLoader.of({ - messages: () => input.messages ?? Effect.succeed([]), - }), - ), - Layer.succeed( - UsageService.ContextLimitLoader, - UsageService.ContextLimitLoader.of({ - providers: input.providers ?? (() => Effect.succeed(providers())), - }), - ), + LayerNode.compile(UsageService.node, [ + [ + UsageService.messageLoaderNode, + Layer.succeed( + UsageService.MessageLoader, + UsageService.MessageLoader.of({ + messages: () => input.messages ?? Effect.succeed([]), + }), ), - ), - ) + ], + [ + UsageService.contextLimitLoaderNode, + Layer.succeed( + UsageService.ContextLimitLoader, + UsageService.ContextLimitLoader.of({ + providers: input.providers ?? (() => Effect.succeed(providers())), + }), + ), + ], + ]) const connection = (updates: SessionNotification[]) => ({ sessionUpdate(params: SessionNotification) { diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 34e725589e..55c45d6406 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit, Layer } from "effect" import path from "path" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -14,17 +15,11 @@ import { Plugin } from "../../src/plugin" import { Provider } from "../../src/provider/provider" import { Skill } from "../../src/skill" import { Truncate } from "../../src/tool/truncate" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" const agentLayer = (flags: Partial = {}) => - Agent.layer.pipe( - Layer.provide(Plugin.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(LocationServiceMap.layer), - Layer.provide(RuntimeFlags.layer(flags)), + LayerNode.compile( + LayerNode.group([Agent.node, Plugin.node, Provider.node, Auth.node, Config.node, Skill.node, RuntimeFlags.node]), + [[RuntimeFlags.node, RuntimeFlags.layer(flags)]], ) const it = testEffect(agentLayer()) diff --git a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts index a58a5ddf2f..6fc9197d9a 100644 --- a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts +++ b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts @@ -1,4 +1,5 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { expect } from "bun:test" import { Effect } from "effect" import { Agent } from "../../src/agent/agent" @@ -6,7 +7,7 @@ import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permis import { Permission } from "../../src/permission" import { testEffect } from "../lib/effect" -const it = testEffect(Agent.defaultLayer) +const it = testEffect(LayerNode.compile(Agent.node)) function testAgent(input: { name: string diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 4c894b0a01..0249aaeb84 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -1,16 +1,15 @@ import { expect } from "bun:test" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { Effect, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" +import { Npm } from "@opencode-ai/core/npm" +import { Effect } from "effect" import path from "path" import { pathToFileURL } from "url" import { Agent } from "../../src/agent/agent" -import { EventV2Bridge } from "../../src/event-v2-bridge" -import { Config } from "../../src/config/config" -import { Env } from "../../src/env" +import { Account } from "../../src/account/account" +import { Auth } from "../../src/auth" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin" +import { Provider } from "../../src/provider/provider" +import { Skill } from "../../src/skill" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" @@ -18,6 +17,8 @@ import { ProviderTest } from "../fake/provider" import { SkillTest } from "../fake/skill" import { testEffect } from "../lib/effect" import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" // `it.instance` skips InstanceBootstrap so LSP / MCP don't spin up — those // services hang during scope teardown on Windows and aren't needed @@ -25,30 +26,16 @@ import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants" const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href const provider = ProviderTest.fake() -const configLayer = Config.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(AuthTest.empty), - Layer.provide(AccountTest.empty), - Layer.provide(NpmTest.noop), - Layer.provide(FetchHttpClient.layer), +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Agent.node, Plugin.node]), [ + [Auth.node, AuthTest.empty], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], + [Provider.node, provider.layer], + [Skill.node, SkillTest.empty], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })], + ]), ) -const pluginLayer = Plugin.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(configLayer), - Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), -) -const agentLayer = Agent.layer.pipe( - Layer.provide(configLayer), - Layer.provide(AuthTest.empty), - Layer.provide(SkillTest.empty), - Layer.provide(provider.layer), - Layer.provide(pluginLayer), - Layer.provide(LocationServiceMap.layer), - Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), -) - -const it = testEffect(Layer.mergeAll(agentLayer, pluginLayer)) it.instance( "plugin-registered agents appear in Agent.list", diff --git a/packages/opencode/test/auth/auth.test.ts b/packages/opencode/test/auth/auth.test.ts index 58ce6ea718..bb72be66e5 100644 --- a/packages/opencode/test/auth/auth.test.ts +++ b/packages/opencode/test/auth/auth.test.ts @@ -1,12 +1,10 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" import { Auth } from "../../src/auth" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" -const node = CrossSpawnSpawner.defaultLayer - -const it = testEffect(Layer.mergeAll(Auth.defaultLayer, node)) +const it = testEffect(LayerNode.compile(Auth.node)) describe("Auth", () => { it.instance("set normalizes trailing slashes in keys", () => diff --git a/packages/opencode/test/background/job.test.ts b/packages/opencode/test/background/job.test.ts index dbcb484dc6..649f935c08 100644 --- a/packages/opencode/test/background/job.test.ts +++ b/packages/opencode/test/background/job.test.ts @@ -1,9 +1,10 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect } from "effect" import { BackgroundJob } from "@/background/job" import { testEffect } from "../lib/effect" -const it = testEffect(BackgroundJob.defaultLayer) +const it = testEffect(LayerNode.compile(BackgroundJob.node)) describe("background.job", () => { it.instance("tracks started jobs through completion", () => diff --git a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts index 7c93ab3190..1592b00146 100644 --- a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts +++ b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Effect } from "effect" import { fileURLToPath } from "url" @@ -6,7 +7,7 @@ import { InstanceRef } from "../../src/effect/instance-ref" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(FSUtil.defaultLayer) +const it = testEffect(LayerNode.compile(FSUtil.node)) afterEach(async () => { await disposeAllInstances() diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index a672d2acae..e9d3ad2338 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -50,17 +50,21 @@ Positionals: url http://localhost:4096 [string] [required] Options: - -h, --help show help [boolean] - -v, --version show version number [boolean] - --print-logs print logs to stderr [boolean] - --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] - --pure run without external plugins [boolean] - --dir directory to run in [string] - -c, --continue continue the last session [boolean] - -s, --session session id to continue [string] - --fork fork the session when continuing (use with --continue or --session) [boolean] - -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] - -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')[string]" + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --dir directory to run in [string] + -c, --continue continue the last session [boolean] + -s, --session session id to continue [string] + --fork fork the session when continuing (use with --continue or --session) [boolean] + -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] + -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode') + [string] + --mini start the minimal interactive interface [boolean] [default: false] + --no-replay disable mini session history replay on resume and after resize [boolean] + --replay-limit cap visible mini replay to the newest N messages [number]" `; exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode run --help 1`] = ` @@ -72,47 +76,35 @@ Positionals: message message to send [array] [default: []] Options: - -h, --help show help [boolean] - -v, --version show version number [boolean] - --print-logs print logs to stderr [boolean] - --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] - --pure run without external plugins [boolean] - --command the command to run, use message for args [string] - -c, --continue continue the last session [boolean] - -s, --session session id to continue [string] - --fork fork the session before continuing (requires --continue or - --session) [boolean] - --share share the session [boolean] - -m, --model model to use in the format of provider/model [string] - --agent agent to use [string] - --format format: default (formatted) or json (raw JSON events) + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --command the command to run, use message for args [string] + -c, --continue continue the last session [boolean] + -s, --session session id to continue [string] + --fork fork the session before continuing (requires --continue or --session) [boolean] + --share share the session [boolean] + -m, --model model to use in the format of provider/model [string] + --agent agent to use [string] + --format format: default (formatted) or json (raw JSON events) [string] [choices: "default", "json"] [default: "default"] - -f, --file file(s) to attach to message [array] - --title title for the session (uses truncated prompt if no value - provided) [string] - --attach attach to a running opencode server (e.g., - http://localhost:4096) [string] - -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) + -f, --file file(s) to attach to message [array] + --title title for the session (uses truncated prompt if no value provided) [string] + --attach attach to a running opencode server (e.g., http://localhost:4096) [string] + -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] + -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode') [string] - -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or - 'opencode') [string] - --dir directory to run in, path on remote server if attaching - [string] - --port port for the local server (defaults to random port if no value - provided) [number] - --variant model variant (provider-specific reasoning effort, e.g., high, - max, minimal) [string] - --thinking show thinking blocks [boolean] - --replay replay interactive session history on resume and after resize - (use --no-replay to disable) [boolean] [default: true] - --replay-limit cap visible interactive replay to the newest N messages + --dir directory to run in, path on remote server if attaching [string] + --port port for the local server (defaults to random port if no value provided) [number] - -i, --interactive run in direct interactive split-footer mode - [boolean] [default: false] - --dangerously-skip-permissions auto-approve permissions that are not explicitly denied - (dangerous!) [boolean] [default: false] - --demo enable direct interactive demo slash commands; pass one as the - message to run it immediately [boolean] [default: false]" + --variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) + [string] + --thinking show thinking blocks [boolean] + -i, --interactive run in direct interactive split-footer mode [boolean] [default: false] + --auto auto-approve permissions that are not explicitly denied (dangerous!) + [boolean] [default: false]" `; exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode debug --help 1`] = ` diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index edd92120ad..3a14d0d7ec 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -13,7 +13,6 @@ // version (changes per release), so we'd snapshot a moving target. import { describe, expect } from "bun:test" import { Effect } from "effect" -import { EOL } from "os" import { cliIt } from "../../lib/cli-process" import { normalizeForSnapshot, PATH_SEP } from "../../lib/snapshot" @@ -101,7 +100,11 @@ describe("opencode CLI help-text snapshots", () => { Effect.gen(function* () { const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV }) expect(topLevel.exitCode).toBe(0) - expect(topLevel.stderr.endsWith(EOL)).toBe(true) + expect(topLevel.stderr.endsWith("\n")).toBe(true) + expect(topLevel.stderr).toContain("--mini") + expect(topLevel.stderr).not.toContain("--thinking") + expect(topLevel.stderr).not.toContain("--variant") + expect(topLevel.stderr).not.toContain("--demo") const argvs: Array = [...TOP_LEVEL.map((c) => [c] as const), ...SUBCOMMANDS] diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index 0826c5dc79..996a8d6abe 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -5,6 +5,7 @@ // `KILO_CONFIG_CONTENT` providing the test provider config inline. import { describe, expect } from "bun:test" import { Effect } from "effect" +import { reply } from "../../lib/llm-server" import { cliIt } from "../../lib/cli-process" describe("opencode run (non-interactive subprocess)", () => { @@ -17,7 +18,46 @@ describe("opencode run (non-interactive subprocess)", () => { yield* llm.text("hello from the test llm") const result = yield* opencode.run("say hi") opencode.expectExit(result, 0) - expect(result.stdout).toContain("hello from the test llm") + expect(result.stdout).toBe("hello from the test llm\n") + }), + 60_000, + ) + + cliIt.concurrent( + "prints each completed text part in order around a tool continuation", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.push( + reply().text(" before tool ").tool("bash", { + command: "printf tool-output", + description: "Print deterministic output", + }), + ) + yield* llm.text(" after tool ") + + const result = yield* opencode.run("use a tool", { + extraArgs: ["--dangerously-skip-permissions"], + }) + + opencode.expectExit(result, 0) + expect(result.stdout).toBe("before tool\nafter tool\n") + }), + 60_000, + ) + + cliIt.concurrent( + "prints reasoning before text only with --thinking", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.reason(" considering ", { text: " answer " }) + const thinking = yield* opencode.run("think", { extraArgs: ["--thinking"] }) + opencode.expectExit(thinking, 0) + expect(thinking.stdout).toBe("Thinking: considering\nanswer\n") + + yield* llm.reason("hidden", { text: "visible" }) + const plain = yield* opencode.run("think again") + opencode.expectExit(plain, 0) + expect(plain.stdout).toBe("visible\n") }), 60_000, ) @@ -41,19 +81,24 @@ describe("opencode run (non-interactive subprocess)", () => { 30_000, ) - // Locks in the current behavior: when the LLM stream errors mid-response - // (the prompt was accepted, then the upstream provider failed), opencode - // emits a session.error event and the process exits 0 today. - // - // This is debatable — a future cleanup might flip it to exit 1. If you're - // changing this expectation, do it deliberately and say so in the PR. + // The test provider's SSE error item is interpreted by the SDK as an unknown + // finish, not a fatal provider/session error. Lock that distinction in so it + // is not accidentally used as the failure compatibility oracle. cliIt.concurrent( - "mid-stream LLM error still exits 0 today (contract lock-in)", + "unknown stream finish preserves partial output and exits 0", ({ llm, opencode }) => Effect.gen(function* () { + yield* llm.push( + reply().text("partial response").tool("bash", { + command: "printf tool", + description: "Print deterministic output", + }), + ) yield* llm.fail("upstream provider exploded mid-stream") const result = yield* opencode.run("trigger midstream error", { timeoutMs: 30_000 }) expect(result.exitCode).toBe(0) + expect(result.stdout).toBe("partial response\n") + expect(result.stderr).not.toContain("upstream provider exploded mid-stream") }), 60_000, ) @@ -75,10 +120,212 @@ describe("opencode run (non-interactive subprocess)", () => { expect(typeof evt.type).toBe("string") expect(typeof evt.sessionID).toBe("string") } - // At least one `text` event should appear with the LLM's response. - const text = events.find((e) => e.type === "text") - expect(text).toBeDefined() + expect(events.map((event) => event.type)).toEqual(["step_start", "text", "step_finish"]) + expect(events.map(({ timestamp: _, sessionID: __, ...event }) => event)).toEqual([ + { type: "step_start", part: expect.objectContaining({ type: "step-start" }) }, + { + type: "text", + part: expect.objectContaining({ type: "text", text: "structured output" }), + }, + { type: "step_finish", part: expect.objectContaining({ type: "step-finish" }) }, + ]) + expect(result.stdout.endsWith("\n")).toBe(true) + expect( + result.stdout + .split("\n") + .slice(0, -1) + .every((line) => line.length > 0), + ).toBe(true) }), 60_000, ) + + cliIt.concurrent( + "--format json emits a pure error record for a rejected prompt request", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.run("use an unknown model", { + model: "test/nonexistent-model", + format: "json", + }) + + expect(result.exitCode).not.toBe(0) + const events = opencode.parseJsonEvents(result.stdout) + expect(events.map((event) => event.type)).toEqual(["error"]) + expect(events[0]).toEqual({ + type: "error", + timestamp: expect.any(Number), + sessionID: expect.any(String), + error: expect.any(Object), + }) + expect(result.stdout.split("\n").filter(Boolean)).toHaveLength(1) + }), + 30_000, + ) + + cliIt.concurrent( + "--format json preserves reasoning, tool, and continuation ordering", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.push( + reply().reason("reasoning").text("before").tool("bash", { + command: "printf tool", + description: "Print deterministic output", + }), + ) + yield* llm.text("after") + + const result = yield* opencode.run("exercise json records", { + format: "json", + extraArgs: ["--thinking", "--dangerously-skip-permissions"], + }) + + expect(result.exitCode).toBe(0) + const events = opencode.parseJsonEvents(result.stdout) + expect(events.map((event) => event.type)).toEqual([ + "step_start", + "reasoning", + "text", + "tool_use", + "step_finish", + "step_start", + "text", + "step_finish", + ]) + expect(events.find((event) => event.type === "reasoning")?.part).toEqual( + expect.objectContaining({ type: "reasoning", text: "reasoning" }), + ) + expect(events.find((event) => event.type === "tool_use")?.part).toEqual( + expect.objectContaining({ + type: "tool", + tool: "bash", + state: expect.objectContaining({ status: "completed" }), + }), + ) + expect( + result.stdout + .split("\n") + .slice(0, -1) + .every((line) => line.startsWith("{")), + ).toBe(true) + }), + 60_000, + ) + + cliIt.concurrent( + "--format json records partial output for an unknown stream finish", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.push( + reply().text("partial json").tool("bash", { + command: "printf tool", + description: "Print deterministic output", + }), + ) + yield* llm.fail("provider failed") + const result = yield* opencode.run("fail after output", { format: "json" }) + + const events = opencode.parseJsonEvents(result.stdout) + expect(result.exitCode).toBe(0) + expect(events.map((event) => event.type)).toEqual([ + "step_start", + "text", + "tool_use", + "step_finish", + "step_start", + "step_finish", + ]) + expect(events[1]?.part).toEqual(expect.objectContaining({ type: "text", text: "partial json" })) + expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish", reason: "unknown" })) + }), + 60_000, + ) + + cliIt.concurrent( + "rejects requested permissions by default and allows them with the dangerous flag", + ({ home, llm, opencode }) => + Effect.gen(function* () { + yield* llm.tool("bash", { command: "rm -f denied-file", description: "Remove a test file" }) + yield* llm.text("continued after rejection") + const denied = yield* opencode.run("request permission", { permission: { bash: "ask" } }) + opencode.expectExit(denied, 0) + expect(denied.stderr).toContain("permission requested: bash") + expect(denied.stdout).toBe("") + + yield* llm.reset + yield* llm.tool("bash", { command: "rm -f allowed-file", description: "Remove a test file" }) + yield* llm.text("continued after approval") + const allowed = yield* opencode.run("request permission", { + permission: { bash: "ask" }, + extraArgs: ["--dangerously-skip-permissions"], + }) + opencode.expectExit(allowed, 0) + expect(allowed.stderr).not.toContain("permission requested: bash") + expect(allowed.stdout).toContain("continued after approval") + + yield* llm.reset + yield* llm.tool("bash", { command: "touch explicitly-denied", description: "Create a denied marker" }) + yield* llm.text("continued after explicit denial") + const explicitlyDenied = yield* opencode.run("request denied permission", { + permission: { bash: "deny" }, + extraArgs: ["--dangerously-skip-permissions"], + }) + opencode.expectExit(explicitlyDenied, 0) + expect(explicitlyDenied.stdout).toContain("continued after explicit denial") + expect(yield* Effect.promise(() => Bun.file(`${home}/explicitly-denied`).exists())).toBe(false) + }), + 60_000, + ) + + cliIt.live( + "attach mode sends client-local file contents without a shared path", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const source = `${home}/client-only.txt` + const sentinel = "client-only attachment sentinel" + yield* Effect.promise(() => Bun.write(source, sentinel)) + yield* llm.text("attachment received") + const server = yield* opencode.serve() + + const result = yield* opencode.run("read the attachment", { + extraArgs: ["--attach", server.url, `--file=${source}`, "--"], + }) + + opencode.expectExit(result, 0) + const input = JSON.stringify(yield* llm.inputs) + expect(input).toContain(sentinel) + expect(input).not.toContain(`file://${source}`) + }), + 60_000, + ) + + cliIt.concurrent( + "attach mode rejects local directories before prompt admission", + ({ home, opencode }) => + Effect.gen(function* () { + const result = yield* opencode.run("read the directory", { + extraArgs: ["--attach", "http://127.0.0.1:1", `--file=${home}`, "--"], + }) + + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain("Cannot attach local directory without a shared filesystem") + }), + 30_000, + ) + + cliIt.live( + "SIGINT interrupts an active non-interactive run without leaking the process", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.hang + const run = yield* opencode.startRun("wait forever") + yield* llm.wait(1) + run.interrupt() + const result = yield* run.result + + expect(result.exitCode).not.toBe(0) + expect(result.durationMs).toBeLessThan(30_000) + }), + 30_000, + ) }) diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index c9b1d1a92a..8e57a06b4d 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -589,6 +589,38 @@ test("coalesces same-line tool progress into one snapshot", async () => { } }) +test("omits the current directory from bash titles", async () => { + const out = await setup() + + try { + await out.scrollback.append( + toolCommit({ + tool: "bash", + phase: "start", + toolState: "running", + state: { + status: "running", + input: { + command: "pwd", + workdir: process.cwd(), + }, + time: { start: 1 }, + }, + }), + ) + + const commits = claim(out.renderer) + try { + expect(render(commits)).toContain("$ pwd") + expect(render(commits)).not.toContain("Running in .") + } finally { + destroy(commits) + } + } finally { + out.scrollback.destroy() + } +}) + test("renders completed bash output with one blank line after the command and before the next group", async () => { const out = await setup() @@ -615,7 +647,6 @@ test("renders completed bash output with one blank line after the command and be input: { command: "git status", workdir: "/tmp/demo", - description: "Show git status", }, time: { start: 1 }, }, @@ -633,7 +664,6 @@ test("renders completed bash output with one blank line after the command and be input: { command: "git status", workdir: "/tmp/demo", - description: "Show git status", }, time: { start: 1, end: 2 }, }, @@ -645,6 +675,7 @@ test("renders completed bash output with one blank line after the command and be take() const output = lines.join("\n") + expect(output).toContain("# Running in /tmp/demo\n$ git status") expect(output).toContain("$ git status\n\nOn branch demo") expect(output).toContain("nothing to commit, working tree clean\n\noc-run-dev ahead 1") expect(output).not.toContain("nothing to commit, working tree clean\n\n\noc-run-dev ahead 1") @@ -677,7 +708,6 @@ test("inserts a spacer before the next tool after completed multiline bash outpu input: { command: "pwd; ls -la", workdir: "/tmp/demo", - description: "Lists current directory files", }, time: { start: 1 }, }, @@ -695,7 +725,6 @@ test("inserts a spacer before the next tool after completed multiline bash outpu input: { command: "pwd; ls -la", workdir: "/tmp/demo", - description: "Lists current directory files", }, output: ["/tmp/demo", "pwd; ls -la", "/tmp/demo", "total 4", "", ""].join("\n"), title: "pwd; ls -la", @@ -755,7 +784,6 @@ test("does not double-space before completed bash output when inline tool header input: { command: "ls", workdir: "src/cli/cmd/run", - description: "Lists files in run directory", }, time: { start: 1 }, }, @@ -805,7 +833,6 @@ test("does not double-space before completed bash output when inline tool header input: { command: "ls", workdir: "src/cli/cmd/run", - description: "Lists files in run directory", }, output: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n"), title: "ls", diff --git a/packages/opencode/test/cli/run/session-data.test.ts b/packages/opencode/test/cli/run/session-data.test.ts index 1b46fafe3b..d266c091b5 100644 --- a/packages/opencode/test/cli/run/session-data.test.ts +++ b/packages/opencode/test/cli/run/session-data.test.ts @@ -435,7 +435,6 @@ describe("run session data", () => { title: "", metadata: { output: "/tmp/demo\n", - description: "", }, time: { start: 1, end: 2 }, }, @@ -490,7 +489,6 @@ describe("run session data", () => { title: "", metadata: { output: "/tmp/demo\n", - description: "", }, time: { start: 1, end: 2 }, }, diff --git a/packages/opencode/test/cli/run/session-replay.test.ts b/packages/opencode/test/cli/run/session-replay.test.ts index 18ae82d4da..e3356d1831 100644 --- a/packages/opencode/test/cli/run/session-replay.test.ts +++ b/packages/opencode/test/cli/run/session-replay.test.ts @@ -238,7 +238,6 @@ function shellAssistantMessage(id: string, parentID: string): SessionMessages[nu title: "", metadata: { output: "account.ts\n", - description: "", }, time: { start: 200, diff --git a/packages/opencode/test/cli/run/variant.shared.test.ts b/packages/opencode/test/cli/run/variant.shared.test.ts index ee9bb07325..3de324b5e4 100644 --- a/packages/opencode/test/cli/run/variant.shared.test.ts +++ b/packages/opencode/test/cli/run/variant.shared.test.ts @@ -1,5 +1,6 @@ import path from "path" import { NodeFileSystem } from "@effect/platform-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { describe, expect, test } from "bun:test" import { Effect, FileSystem, Layer } from "effect" @@ -98,7 +99,7 @@ function userMessage( } } -const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, NodeFileSystem.layer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(FSUtil.node), NodeFileSystem.layer)) function remap(root: string, file: string) { if (file === Global.Path.state) { @@ -123,7 +124,7 @@ function remappedFs(root: string) { writeJson: (file, data, mode) => fs.writeJson(remap(root, file), data, mode), }) }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) } describe("run variant shared", () => { diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index f79fd40da7..73f87d904b 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from "bun:test" +import { Effect } from "effect" import fs from "fs/promises" import path from "path" +import yargs from "yargs" import { tmpdir } from "../../fixture/fixture" -import { resolveThreadDirectory } from "../../../src/cli/cmd/tui" +import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui" +import { cliIt } from "../../lib/cli-process" describe("tui thread", () => { test("loads the TUI integration lazily", async () => { @@ -33,4 +36,60 @@ describe("tui thread", () => { test("uses the real cwd after resolving a relative project from PWD", async () => { await check(".") }) + + test("resolves a relative mini project from PWD when cwd differs", async () => { + await using pwd = await tmpdir({ git: true }) + await using cwd = await tmpdir({ git: true }) + + expect(resolveThreadDirectory(".", pwd.path, cwd.path)).toBe(pwd.path) + expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path) + }) + + test("parses supported --no-replay forms", async () => { + for (const option of ["--no-replay", "--no-replay=true", "--noReplay"]) { + const args = await yargs([]) + .command({ ...TuiThreadCommand, handler: () => {} }) + .exitProcess(false) + .parse(["--mini", option, "--replay-limit", "10"]) + + expect(args.replay === false || args.noReplay === true).toBe(true) + expect(args.replayLimit).toBe(10) + } + }) + + test("preserves boolean negation for existing options", async () => { + const args = await yargs([]) + .command({ ...TuiThreadCommand, handler: () => {} }) + .exitProcess(false) + .parse(["--mdns", "--no-mdns"]) + + expect(args.mdns).toBe(false) + }) + + cliIt.live("rejects mini-only options without --mini", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["--replay-limit", "10"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--replay-limit requires --mini") + }), + ) + + cliIt.live("routes attached sessions to mini mode", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--mini requires a TTY stdout") + }), + ) + + cliIt.live("rejects network options in mini mode", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["--mini", "--port", "4096"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--port cannot be used with --mini") + }), + ) }) diff --git a/packages/opencode/test/config/agent-color.test.ts b/packages/opencode/test/config/agent-color.test.ts index 664170696e..6b27cafe85 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -1,11 +1,11 @@ import { expect } from "bun:test" -import { Effect, Layer } from "effect" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" import { Config } from "@/config/config" import { Agent as AgentSvc } from "../../src/agent/agent" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Config.defaultLayer, AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Config.node, AgentSvc.node]))) it.instance( "agent color parsed from project config", diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 76931ff417..cd0bca8006 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,13 +1,14 @@ import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Cause, Effect, Exit, Layer, Option } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http" -import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "@/config/config" import { ConfigManaged } from "@/config/managed" import { ConfigParse } from "../../src/config/parse" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Npm } from "@opencode-ai/core/npm" import { InstanceRef } from "../../src/effect/instance-ref" import type { InstanceContext } from "../../src/project/instance-context" @@ -41,13 +42,6 @@ import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" -/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */ -const infra = CrossSpawnSpawner.defaultLayer.pipe( - Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), -) - -const testFlock = EffectFlock.defaultLayer - const unexpectedHttp = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`), ) @@ -104,16 +98,12 @@ const configLayer = ( client?: HttpClient.HttpClient } = {}, ) => - Config.layer.pipe( - Layer.provide(testFlock), - Layer.provide(Env.defaultLayer), - Layer.provide(options.auth ?? AuthTest.empty), - Layer.provide(options.account ?? AccountTest.empty), - Layer.provideMerge(infra), - Layer.provide(NpmTest.noop), - Layer.provide(Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)), - Layer.provideMerge(FSUtil.defaultLayer), - ) + LayerNode.compile(LayerNode.group([Config.node, FSUtil.node, Env.node, CrossSpawnSpawner.node]), [ + [Auth.node, options.auth ?? AuthTest.empty], + [Account.node, options.account ?? AccountTest.empty], + [Npm.node, NpmTest.noop], + [httpClient, Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)], + ]) const layer = configLayer() @@ -171,7 +161,7 @@ const withInstanceDir = (dir: string, effect: Effect.Effect) = Effect.provideService(TestInstance, { directory: dir }), provideInstanceEffect(dir), Effect.provide(testInstanceStoreLayer), - Effect.provide(CrossSpawnSpawner.defaultLayer), + Effect.provide(LayerNode.compile(CrossSpawnSpawner.node)), ) const withGlobalConfigDir = (dir: string, effect: Effect.Effect) => @@ -325,7 +315,7 @@ it.effect("creates global jsonc config with schema when no global configs exist" const content = yield* FSUtil.use.readFileString(path.join(dir, "opencode.jsonc")) expect(content).toContain('"$schema": "https://opencode.ai/config.json"') - }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), ), ) @@ -340,7 +330,7 @@ it.effect("does not create global config when KILO_CONFIG_DIR is set", () => yield* Config.use.get().pipe(provideInstanceEffect(dir)) expect(yield* FSUtil.use.existsSafe(path.join(dir, "opencode.jsonc"))).toBe(false) - }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), ), ) }), @@ -930,7 +920,7 @@ it.effect("does not try to install dependencies in read-only KILO_CONFIG_DIR", ( yield* Effect.addFinalizer(() => FSUtil.use.chmod(readonly, 0o755).pipe(Effect.ignore)) yield* withProcessEnv("KILO_CONFIG_DIR", readonly, Config.use.get().pipe(provideInstanceEffect(dir))) - }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), ) it.effect("installs dependencies in writable KILO_CONFIG_DIR", () => @@ -948,7 +938,7 @@ it.effect("installs dependencies in writable KILO_CONFIG_DIR", () => ) expect(yield* FSUtil.use.readFileString(path.join(configDir, ".gitignore"))).toContain("package-lock.json") - }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), ) // Note: deduplication and serialization of npm installs is now handled by the @@ -1533,16 +1523,12 @@ test("remote well-known config can use FetchHttpClient layer", async () => { Effect.scoped, Effect.provide( Layer.mergeAll( - Config.layer.pipe( - Layer.provide(testFlock), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(wellKnownAuth(server.url.origin)), - Layer.provide(AccountTest.empty), - Layer.provideMerge(infra), - Layer.provide(NpmTest.noop), - Layer.provide(FetchHttpClient.layer), - ), + LayerNode.compile(LayerNode.group([Config.node, FSUtil.node, Env.node, CrossSpawnSpawner.node]), [ + [Auth.node, wellKnownAuth(server.url.origin)], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], + [httpClient, FetchHttpClient.layer], + ]), testInstanceStoreLayer, ), ), diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index cfbcd4adcd..bef01b88ab 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -1,6 +1,8 @@ import { expect } from "bun:test" import path from "path" import { pathToFileURL } from "url" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" @@ -11,7 +13,7 @@ import { TuiConfig } from "../../src/config/tui" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Config.defaultLayer, FSUtil.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Config.node, FSUtil.node]))) const winIt = process.platform === "win32" ? it.instance : it.instance.skip const globalConfigFiles = ["opencode.json", "opencode.jsonc", "tui.json", "tui.jsonc"].map((file) => @@ -69,12 +71,16 @@ const withPlatform = (platform: typeof process.platform, self: Effect.E const getTuiConfig = (directory: string) => TuiConfig.Service.use((svc) => svc.get()).pipe( - Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), + Effect.provide( + AppNodeBuilder.build(TuiConfig.node).pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory))), + ), ) const getTuiPluginOrigins = (directory: string) => TuiConfig.Service.use((svc) => svc.pluginOrigins()).pipe( - Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), + Effect.provide( + AppNodeBuilder.build(TuiConfig.node).pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory))), + ), ) it.instance("keeps server and tui plugin merge semantics aligned", () => @@ -472,6 +478,7 @@ it.instance("resolves keybind lookup from canonical keybinds", () => keybinds: { leader: { key: { name: "g", ctrl: true } }, command_list: "alt+p", + diff_open: "ctrl+j", which_key_toggle: "alt+k", editor_open: "ctrl+e", "prompt.autocomplete.next": "ctrl+j", @@ -487,6 +494,7 @@ it.instance("resolves keybind lookup from canonical keybinds", () => expect(config.keybinds.get("leader")?.[0]?.key).toEqual({ name: "g", ctrl: true }) expect(config.leader_timeout).toBe(1234) expect(config.keybinds.get("command.palette.show")?.[0]?.key).toBe("alt+p") + expect(config.keybinds.get("diff.open")?.[0]?.key).toBe("ctrl+j") expect(config.keybinds.get("session.new")?.[0]?.key).toBe("n") expect(config.keybinds.get("which-key.toggle")?.[0]?.key).toBe("alt+k") expect(config.keybinds.get("which-key.layout.toggle")?.[0]?.key).toBe("ctrl+alt+shift+k") diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index ecf5cd97f2..fafbeb7e4a 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -5,9 +5,8 @@ import Http from "node:http" import path from "node:path" import { NodeHttpServer } from "@effect/platform-node" import { Effect, Exit, Fiber, Layer, Schema } from "effect" -import { FetchHttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" -import { FSUtil } from "@opencode-ai/core/fs-util" import { GlobalBus, type GlobalEvent } from "@/bus/global" import { Database } from "@opencode-ai/core/database/database" import { ProjectV2 } from "@opencode-ai/core/project" @@ -16,6 +15,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { Session as SessionNs } from "@/session/session" import { SessionID } from "@/session/schema" import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { EventSequenceTable } from "@opencode-ai/core/event/sql" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideTmpdirInstance, requireInstance, TestInstance } from "../fixture/fixture" @@ -27,13 +27,10 @@ import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control- import * as Workspace from "../../src/control-plane/workspace" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" -import { Auth } from "@/auth" -import { SessionPrompt } from "@/session/prompt" -import { Project } from "@/project/project" -import { Vcs } from "@/project/vcs" import { RuntimeFlags } from "@/effect/runtime-flags" -import { EventV2Bridge } from "@/event-v2-bridge" import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" const originalEnv = { KILO_AUTH_CONTENT: process.env.KILO_AUTH_CONTENT, @@ -44,26 +41,27 @@ const originalEnv = { } const workspaceLayer = (experimentalWorkspaces: boolean) => - Workspace.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(SessionNs.defaultLayer), - Layer.provide(SessionPrompt.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(Vcs.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))), + AppNodeBuilder.build( + LayerNode.group([ + Workspace.node, + SessionNs.node, + SessionProjector.node, + Database.node, + InstanceStore.node, + Ripgrep.node, + ]), + [ + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces })], + [ + InstanceStore.bootstrapNode, + Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })), + ], + ], ) const testServerLayer = Layer.mergeAll( NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), workspaceLayer(true), - SessionNs.defaultLayer, - Database.defaultLayer, ) const it = testEffect(testServerLayer) diff --git a/packages/opencode/test/effect/app-graph-types.test.ts b/packages/opencode/test/effect/app-graph-types.test.ts deleted file mode 100644 index 527c4daf54..0000000000 --- a/packages/opencode/test/effect/app-graph-types.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { test } from "bun:test" -import { Context, Effect, Layer } from "effect" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" - -class A extends Context.Service()("test/A") {} -class B extends Context.Service()("test/B") {} -class C extends Context.Service()("test/C") {} -class LayerError { - readonly _tag = "LayerError" -} -class NotFoundError { - readonly _tag = "NotFoundError" -} -class DiskError { - readonly _tag = "DiskError" -} -class NetworkError { - readonly _tag = "NetworkError" -} - -const aImplementation = Layer.succeed(A, A.of({ value: "a" })) -const bImplementation = Layer.effect( - B, - Effect.gen(function* () { - yield* A - return B.of({ value: "b" }) - }), -) -const cImplementation = Layer.effect( - C, - Effect.gen(function* () { - yield* A - yield* B - return C.of({ value: "c" }) - }), -) -const failingAImplementation = Layer.effect(A, Effect.fail(new LayerError())) -const notFoundAImplementation = Layer.effect(A, Effect.fail(new NotFoundError())) -const diskAImplementation = Layer.effect(A, Effect.fail(new DiskError())) -const networkAImplementation = Layer.effect(A, Effect.fail(new NetworkError())) -const notFoundOrDiskAImplementation = Layer.effect(A, Effect.fail(new NotFoundError() as NotFoundError | DiskError)) - -type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false -type Assert = T - -type AProvides = Assert, A>> -type ARequires = Assert, never>> -type BProvides = Assert, B>> -type BRequires = Assert, A>> -type CRequires = Assert, A | B>> -void (0 as unknown as AProvides) -void (0 as unknown as ARequires) -void (0 as unknown as BProvides) -void (0 as unknown as BRequires) -void (0 as unknown as CRequires) - -const a = LayerNode.make(aImplementation, []) -const b = LayerNode.make(bImplementation, [a]) -const c = LayerNode.make(cImplementation, [a, b]) -const failingA = LayerNode.make(failingAImplementation, []) -const bWithFailingA = LayerNode.make(bImplementation, [failingA]) -const notFoundA = LayerNode.make(notFoundAImplementation, []) -const diskA = LayerNode.make(diskAImplementation, []) -const networkA = LayerNode.make(networkAImplementation, []) -const notFoundOrDiskA = LayerNode.make(notFoundOrDiskAImplementation, []) - -// @ts-expect-error B requires A -LayerNode.make(bImplementation, []) - -// @ts-expect-error C requires both A and B -LayerNode.make(cImplementation, [a]) - -type ANodeProvides = Assert>> -type BNodeProvides = Assert>> -type CNodeProvides = Assert>> -type FailingANodeError = Assert>> -type DependentNodeError = Assert>> -void (0 as unknown as ANodeProvides) -void (0 as unknown as BNodeProvides) -void (0 as unknown as CNodeProvides) -void (0 as unknown as FailingANodeError) -void (0 as unknown as DependentNodeError) - -const closed = LayerNode.buildLayer(c) -const closedWithError = LayerNode.buildLayer(bWithFailingA) -type ClosedProvides = Assert, C>> -type ClosedRequires = Assert, never>> -type ClosedError = Assert, LayerError>> -void (0 as unknown as ClosedProvides) -void (0 as unknown as ClosedRequires) -void (0 as unknown as ClosedError) - -const replacement = LayerNode.make(Layer.succeed(A, A.of({ value: "a" })), []) -LayerNode.replace(a, Layer.succeed(A, A.of({ value: "a" }))) -LayerNode.replace(notFoundOrDiskA, notFoundAImplementation) -LayerNode.replace(notFoundOrDiskA, diskAImplementation) -LayerNode.replaceWithNode(a, replacement) - -// @ts-expect-error An override for A must still provide A -LayerNode.replaceWithNode(a, b) - -// @ts-expect-error A replacement cannot introduce NetworkError -LayerNode.replace(notFoundOrDiskA, networkAImplementation) - -// @ts-expect-error A replacement layer must not have unresolved dependencies -LayerNode.replace(b, bImplementation) - -test("type exploration compiles", () => {}) diff --git a/packages/opencode/test/effect/app-graph.test.ts b/packages/opencode/test/effect/app-graph.test.ts deleted file mode 100644 index 7ae7a982ba..0000000000 --- a/packages/opencode/test/effect/app-graph.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Cause, Context, Effect, Exit, Layer } from "effect" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" - -const { buildLayer: build, group, replace, replaceWithNode } = LayerNode -const node = LayerNode.make - -class Value extends Context.Service()("test/Value") {} -class Greeting extends Context.Service()("test/Greeting") {} - -const value = LayerNode.make(Layer.succeed(Value, Value.of({ value: "production" })), []) -const greetingImplementation = Layer.effect( - Greeting, - Effect.gen(function* () { - return Greeting.of({ text: `hello ${(yield* Value).value}` }) - }), -) -const greeting = LayerNode.make(greetingImplementation, [value]) - -// @ts-expect-error Greeting requires Value -LayerNode.make(greetingImplementation, []) - -describe("app graph", () => { - test("creates any selected dependency layer", async () => { - const result = Effect.gen(function* () { - return (yield* Greeting).text - }).pipe(Effect.provide(build(greeting))) - - expect(await Effect.runPromise(result)).toBe("hello production") - }) - - test("applies overrides before dependency materialization", async () => { - const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) - const graph = build(greeting, { replacements: [replace(value, replacement)] }) - const result = Effect.gen(function* () { - return (yield* Greeting).text - }).pipe(Effect.provide(graph)) - - expect(await Effect.runPromise(result)).toBe("hello simulation") - }) - - test("acquires a shared dependency once", async () => { - class Shared extends Context.Service()("test/Shared") {} - class Left extends Context.Service()("test/Left") {} - class Right extends Context.Service()("test/Right") {} - let acquisitions = 0 - const shared = node( - Layer.effect( - Shared, - Effect.sync(() => { - acquisitions++ - return Shared.of({ value: "shared" }) - }), - ), - [], - ) - const left = node( - Layer.effect( - Left, - Effect.gen(function* () { - return Left.of({ value: `${(yield* Shared).value}-left` }) - }), - ), - [shared], - ) - const right = node( - Layer.effect( - Right, - Effect.gen(function* () { - return Right.of({ value: `${(yield* Shared).value}-right` }) - }), - ), - [shared], - ) - - const result = Effect.gen(function* () { - return [(yield* Left).value, (yield* Right).value] - }).pipe(Effect.provide(build(group([left, right])))) - - expect(await Effect.runPromise(result)).toEqual(["shared-left", "shared-right"]) - expect(acquisitions).toBe(1) - }) - - test("applies a replacement to every transitive consumer", async () => { - class Left extends Context.Service()("test/ReplacementLeft") {} - class Right extends Context.Service()("test/ReplacementRight") {} - const left = node( - Layer.effect( - Left, - Effect.gen(function* () { - return Left.of({ value: (yield* Value).value }) - }), - ), - [value], - ) - const right = node( - Layer.effect( - Right, - Effect.gen(function* () { - return Right.of({ value: (yield* Value).value }) - }), - ), - [value], - ) - const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) - const graph = build(group([left, right]), { replacements: [replace(value, replacement)] }) - - const result = Effect.gen(function* () { - return [(yield* Left).value, (yield* Right).value] - }).pipe(Effect.provide(graph)) - - expect(await Effect.runPromise(result)).toEqual(["simulation", "simulation"]) - }) - - test("propagates layer acquisition errors", async () => { - class AcquisitionError { - readonly _tag = "AcquisitionError" - } - const failing = node(Layer.effect(Value, Effect.fail(new AcquisitionError())), []) - const exit = await Effect.runPromiseExit(Effect.provide(Value, build(failing))) - - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(AcquisitionError) - }) - - test("groups expose every selected service", async () => { - class Count extends Context.Service()("test/Count") {} - const count = node(Layer.succeed(Count, Count.of({ value: 3 })), []) - const result = Effect.gen(function* () { - return { text: (yield* Value).value, count: (yield* Count).value } - }).pipe(Effect.provide(build(group([value, count])))) - - expect(await Effect.runPromise(result)).toEqual({ text: "production", count: 3 }) - }) - - test("builds an empty group", async () => { - expect(await Effect.runPromise(Effect.succeed("ok").pipe(Effect.provide(build(group([])))))).toBe("ok") - }) - - test("builds replacements with their own dependencies", async () => { - class ReplacementConfig extends Context.Service()( - "test/ReplacementConfig", - ) {} - const replacementConfig = node(Layer.succeed(ReplacementConfig, ReplacementConfig.of({ value: "replacement" })), []) - const replacement = node( - Layer.effect( - Value, - Effect.gen(function* () { - return Value.of({ value: (yield* ReplacementConfig).value }) - }), - ), - [replacementConfig], - ) - const result = Effect.gen(function* () { - return (yield* Greeting).text - }).pipe(Effect.provide(build(greeting, { replacements: [replaceWithNode(value, replacement)] }))) - - expect(await Effect.runPromise(result)).toBe("hello replacement") - }) - - test("does not acquire unreachable replacements", async () => { - let acquisitions = 0 - const unreachable = node(Layer.succeed(Value, Value.of({ value: "unreachable" })), []) - const replacement = Layer.effect( - Value, - Effect.sync(() => { - acquisitions++ - return Value.of({ value: "replacement" }) - }), - ) - - await Effect.runPromise( - Effect.provide(Greeting, build(greeting, { replacements: [replace(unreachable, replacement)] })), - ) - - expect(acquisitions).toBe(0) - }) - - test("rejects a direct cycle", () => { - const cyclic = node(Layer.succeed(Value, Value.of({ value: "cyclic" })), []) - ;(cyclic.dependencies as LayerNode.Node[]).push(cyclic) - - expect(() => build(cyclic)).toThrow("Cycle detected in app graph: layer#1 -> layer#1") - }) - - test("rejects an indirect cycle", () => { - const first = node(Layer.succeed(Value, Value.of({ value: "first" })), []) - const second = node(Layer.succeed(Value, Value.of({ value: "second" })), [first]) - const third = node(Layer.succeed(Value, Value.of({ value: "third" })), [second]) - ;(first.dependencies as LayerNode.Node[]).push(third) - - expect(() => build(first)).toThrow("Cycle detected in app graph: layer#1 -> layer#2 -> layer#3 -> layer#1") - }) - - test("rejects a cycle introduced by a replacement", () => { - const replacement = node(Layer.succeed(Value, Value.of({ value: "replacement" })), []) - const consumer = node(greetingImplementation, [value]) - ;(replacement.dependencies as LayerNode.Node[]).push(consumer) - - expect(() => build(consumer, { replacements: [replaceWithNode(value, replacement)] })).toThrow( - "Cycle detected in app graph: layer#1 -> layer#2 -> layer#1", - ) - }) -}) diff --git a/packages/opencode/test/effect/app-runtime-logger.test.ts b/packages/opencode/test/effect/app-runtime-logger.test.ts index ecebe50446..a0c34244f0 100644 --- a/packages/opencode/test/effect/app-runtime-logger.test.ts +++ b/packages/opencode/test/effect/app-runtime-logger.test.ts @@ -1,4 +1,5 @@ import { expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Context, Deferred, Effect, Fiber, Layer, Logger } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppLayer } from "../../src/effect/app-runtime" @@ -9,7 +10,8 @@ import { attach } from "../../src/effect/run-service" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(CrossSpawnSpawner.defaultLayer) +const observabilityLayer = LayerNode.compile(Observability.node) +const it = testEffect(LayerNode.compile(CrossSpawnSpawner.node)) function check(loggers: ReadonlySet>) { return { @@ -34,7 +36,7 @@ it.live("makeRuntime installs the observability logger", () => ) const current = yield* Dummy.use((svc) => svc.current()).pipe( - Effect.provide(Layer.provideMerge(layer, Observability.layer)), + Effect.provide(Layer.provideMerge(layer, observabilityLayer)), ) expect(current.size).toBeGreaterThan(0) @@ -94,6 +96,6 @@ it.instance( expect(result.directory).toBe(test.directory) expect(result.size).toBeGreaterThan(0) - }).pipe(Effect.provide(Observability.layer)), + }).pipe(Effect.provide(observabilityLayer)), { git: true }, ) diff --git a/packages/opencode/test/effect/config-service.test.ts b/packages/opencode/test/effect/config-service.test.ts index be6f977363..0a1fc605ea 100644 --- a/packages/opencode/test/effect/config-service.test.ts +++ b/packages/opencode/test/effect/config-service.test.ts @@ -10,12 +10,12 @@ class TestConfig extends ConfigService.Service()("@test/ConfigServic }) {} const fromConfig = (input: Record) => - TestConfig.defaultLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input)))) + TestConfig.layer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input)))) const readConfig = TestConfig.useSync((config) => config) describe("ConfigService", () => { - it.effect("defaultLayer parses values from the active ConfigProvider", () => + it.effect("layer parses values from the active ConfigProvider", () => Effect.gen(function* () { const config = yield* readConfig.pipe( Effect.provide( @@ -33,7 +33,7 @@ describe("ConfigService", () => { }), ) - it.effect("defaultLayer applies Effect Config defaults", () => + it.effect("layer applies Effect Config defaults", () => Effect.gen(function* () { const config = yield* readConfig.pipe(Effect.provide(fromConfig({ NAME: "kit" }))) @@ -47,7 +47,7 @@ describe("ConfigService", () => { Effect.gen(function* () { const config = yield* readConfig.pipe( Effect.provide( - TestConfig.layer({ + TestConfig.configLayer({ name: "direct", token: Option.some("parsed"), port: 9000, diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index d983de89a8..148eca8d5c 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -1,5 +1,6 @@ import { expect } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { $ } from "bun" import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" @@ -12,7 +13,7 @@ import { } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(CrossSpawnSpawner.node), testInstanceStoreLayer)) const access = (state: InstanceState.InstanceState, dir: string) => InstanceState.get(state).pipe(provideInstanceEffect(dir)) diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts index cb3178b86f..037b88aa85 100644 --- a/packages/opencode/test/effect/runtime-flags.test.ts +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -1,15 +1,16 @@ import { describe, expect } from "bun:test" import { ConfigProvider, Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { it } from "../lib/effect" const fromConfig = (input: Record) => - RuntimeFlags.defaultLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input)))) + AppNodeBuilder.build(RuntimeFlags.node).pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input)))) const readFlags = RuntimeFlags.Service.useSync((flags) => flags) describe("RuntimeFlags", () => { - it.effect("defaultLayer defaults autoShare to false", () => + it.effect("layer defaults autoShare to false", () => Effect.gen(function* () { const flags = yield* readFlags.pipe(Effect.provide(fromConfig({}))) @@ -17,7 +18,7 @@ describe("RuntimeFlags", () => { }), ) - it.effect("defaultLayer parses plugin flags from the active ConfigProvider", () => + it.effect("layer parses plugin flags from the active ConfigProvider", () => Effect.gen(function* () { const flags = yield* readFlags.pipe( Effect.provide( @@ -64,7 +65,7 @@ describe("RuntimeFlags", () => { }), ) - it.effect("defaultLayer parses KILO_EXPERIMENTAL_LSP_TY", () => + it.effect("layer parses KILO_EXPERIMENTAL_LSP_TY", () => Effect.gen(function* () { const flags = yield* readFlags.pipe( Effect.provide( diff --git a/packages/opencode/test/event-manifest.test.ts b/packages/opencode/test/event-manifest.test.ts new file mode 100644 index 0000000000..da0f80cee5 --- /dev/null +++ b/packages/opencode/test/event-manifest.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { EventManifest as SchemaEventManifest } from "@opencode-ai/schema/event-manifest" +import { Todo } from "@/session/todo" +import { EventManifest } from "@/event-manifest" + +describe("public event manifest", () => { + test("contains every latest public wire type once", () => { + expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions) + expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest) + expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable) + expect(EventManifest.Latest.size).toBe(88) + expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) + expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated) + expect(EventManifest.Latest.has("ide.installed")).toBe(false) + expect(EventManifest.Latest.has("server.connected")).toBe(true) + expect(EventManifest.Latest.has("global.disposed")).toBe(true) + }) + + test("contains only the current step settlement versions", () => { + expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false) + expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended) + }) +}) diff --git a/packages/opencode/test/filesystem/filesystem.test.ts b/packages/opencode/test/filesystem/filesystem.test.ts index 686a21d527..d1ccc1f3ee 100644 --- a/packages/opencode/test/filesystem/filesystem.test.ts +++ b/packages/opencode/test/filesystem/filesystem.test.ts @@ -1,12 +1,11 @@ import { describe, test, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { NodeFileSystem } from "@effect/platform-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { testEffect } from "../lib/effect" import path from "path" -const live = FSUtil.layer.pipe(Layer.provide(NodeFileSystem.layer)) -const { effect: it } = testEffect(live) +const { effect: it } = testEffect(LayerNode.compile(FSUtil.node)) describe("FSUtil", () => { describe("isDir", () => { diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index f9898ede0d..53e3142cdf 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -7,8 +7,10 @@ import { Effect, Context, Layer } from "effect" import type * as PlatformError from "effect/PlatformError" import type * as Scope from "effect/Scope" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceBootstrap } from "../../src/project/bootstrap-service" import type { InstanceContext } from "../../src/project/instance-context" @@ -17,7 +19,9 @@ import { InstanceStore } from "../../src/project/instance-store" import { TestLLMServer } from "../lib/llm-server" const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -export const testInstanceStoreLayer = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)) +export const testInstanceStoreLayer = LayerNode.compile(InstanceStore.node, [ + [InstanceStore.bootstrapNode, noopBootstrap], +]) export async function provideTestInstance(input: { directory: string @@ -204,7 +208,7 @@ export const withTmpdirInstance = Effect.gen(function* () { const directory = yield* tmpdirScoped(options) return yield* self.pipe(Effect.provideService(TestInstance, { directory }), provideInstanceEffect(directory)) - }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)) + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))) export function provideTmpdirServer( self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect, diff --git a/packages/opencode/test/fixture/workspace.ts b/packages/opencode/test/fixture/workspace.ts index 46335d3361..cf75855715 100644 --- a/packages/opencode/test/fixture/workspace.ts +++ b/packages/opencode/test/fixture/workspace.ts @@ -1,5 +1,5 @@ -import { FetchHttpClient } from "effect/unstable/http" -import { Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Database } from "@opencode-ai/core/database/database" import { FSUtil } from "@opencode-ai/core/fs-util" import { Auth } from "../../src/auth" @@ -14,17 +14,21 @@ import { SessionPrompt } from "../../src/session/prompt" import { EventV2Bridge } from "../../src/event-v2-bridge" export const workspaceLayerWithRuntimeFlags = (overrides: Partial) => - Workspace.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionPrompt.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(Vcs.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(RuntimeFlags.layer(overrides)), - Layer.provide(InstanceStore.defaultLayer), - Layer.provide(InstanceBootstrap.defaultLayer), + AppNodeBuilder.build( + LayerNode.group([ + Workspace.node, + Auth.node, + Session.node, + SessionPrompt.node, + Project.node, + Vcs.node, + Database.node, + EventV2Bridge.node, + FSUtil.node, + InstanceStore.node, + ]), + [ + [InstanceStore.bootstrapNode, InstanceBootstrap.node], + [RuntimeFlags.node, RuntimeFlags.layer(overrides)], + ], ) diff --git a/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json b/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json index 625140f991..7194987fce 100644 --- a/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json +++ b/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json @@ -17,7 +17,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" }, "response": { "status": 200, @@ -33,7 +33,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0812d6cbe7a2b19b016a1214d32f6881998bcd9ff2e739d7f2\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTUCQT4XELlBu6r5VHqqtu5Il5WdX4m1upE8li0mPmIwgIykAmUTZWiE0213kmviuAgIrmhhiL4B8DXbWQD2vOEkQMhpZq_UCqc22SOg-4DpQLrebMWkzgAPL618VPu9mXNUIH9BW1sRhPdDSbbtK5_bitzsn-FMJGcO3UN7Ga2RW1Rdvt1M3m7J4MRlTutH8cwY8SthzgvOFEBS-_IrAhiwKVz4Se9Jlu3pVNMqhPF7kdrQOfDYui0v-AT8VrHBVomqekJl_dWESww0eWo6bS1PxZB4cLQHWp9JJi5pEECvU9Ntcz3GxuGJEtTKq5mFcRvCanXHOwZGmbBcWMNdVyikk3fxgIE2g9t8rCKJmhNXznMERtrfG2tey19qWbsVbo2YmBbg_5N02AA4NmEVvdfgHJx58nOfEEc2OZYk0YQ1fHBOkpBnwY61hxtrWFdj48QnTEKuvjAyNpX-KKFmMzL4531yLbEEzpaERlr11fDeoMpKofUoMsg3Jz8aTaZ1CpzI3O7iFzGDEV6gKh8vQYGrKOaOXnfBVDXDo8iJhZywpcQY6xB4NNf4pyyjFkR-vjgvBYV2hejlq2V1j8vQHgy8CsZJ6lW5oaTNMfP76MAHlwUwyMYj-cFmuX0epJdDWv8GDznUpOS-v2X5eNsvyx9qvvcTEMLsKJ--3_odisilj4vPhw16P9fB8eLmvESZmJRYmWM4mO7hPTVXOooOa-zxRHGhRQH9ouUea9UHSuH1A0o54qTEPr-JqYlQggugW449IuYW4HSMNMyeGdUNJfodWRu5cL0VPgk6zwTU3ArBq28FDgG7NZMk3njfCId351GZ8VRlTMA6U522_6FFaZ8-5gxsidOm0WULOwyTTo54tJsJFv2pgYUKs0VFWSwi3rvNMVMOgwOVIdSgZt1hFTxBImZh8HUIXUPvdOVKZzQmWT5M6uOTUsm5xsufhj8m79RuYZh2J0bkVOBzZ1As8zH-4v_r9d7e8464EuWXCln_6LAJdrTYgE2gVfHK0zeUaAMbIKhirOf0AVQZyfVsGvJ_CPqrPE_QSECeSA2D4TSa5Tc_IRY-Fb2_HKNCMEP2uvy\"},{\"type\":\"function_call\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTUCQT4XELlBu6r5VHqqtu5Il5WdX4m1upE8li0mPmIwgIykAmUTZWiE0213kmviuAgIrmhhiL4B8DXbWQD2vOEkQMhpZq_UCqc22SOg-4DpQLrebMWkzgAPL618VPu9mXNUIH9BW1sRhPdDSbbtK5_bitzsn-FMJGcO3UN7Ga2RW1Rdvt1M3m7J4MRlTutH8cwY8SthzgvOFEBS-_IrAhiwKVz4Se9Jlu3pVNMqhPF7kdrQOfDYui0v-AT8VrHBVomqekJl_dWESww0eWo6bS1PxZB4cLQHWp9JJi5pEECvU9Ntcz3GxuGJEtTKq5mFcRvCanXHOwZGmbBcWMNdVyikk3fxgIE2g9t8rCKJmhNXznMERtrfG2tey19qWbsVbo2YmBbg_5N02AA4NmEVvdfgHJx58nOfEEc2OZYk0YQ1fHBOkpBnwY61hxtrWFdj48QnTEKuvjAyNpX-KKFmMzL4531yLbEEzpaERlr11fDeoMpKofUoMsg3Jz8aTaZ1CpzI3O7iFzGDEV6gKh8vQYGrKOaOXnfBVDXDo8iJhZywpcQY6xB4NNf4pyyjFkR-vjgvBYV2hejlq2V1j8vQHgy8CsZJ6lW5oaTNMfP76MAHlwUwyMYj-cFmuX0epJdDWv8GDznUpOS-v2X5eNsvyx9qvvcTEMLsKJ--3_odisilj4vPhw16P9fB8eLmvESZmJRYmWM4mO7hPTVXOooOa-zxRHGhRQH9ouUea9UHSuH1A0o54qTEPr-JqYlQggugW449IuYW4HSMNMyeGdUNJfodWRu5cL0VPgk6zwTU3ArBq28FDgG7NZMk3njfCId351GZ8VRlTMA6U522_6FFaZ8-5gxsidOm0WULOwyTTo54tJsJFv2pgYUKs0VFWSwi3rvNMVMOgwOVIdSgZt1hFTxBImZh8HUIXUPvdOVKZzQmWT5M6uOTUsm5xsufhj8m79RuYZh2J0bkVOBzZ1As8zH-4v_r9d7e8464EuWXCln_6LAJdrTYgE2gVfHK0zeUaAMbIKhirOf0AVQZyfVsGvJ_CPqrPE_QSECeSA2D4TSa5Tc_IRY-Fb2_HKNCMEP2uvy\"},{\"type\":\"function_call\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" }, "response": { "status": 200, diff --git a/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json b/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json index afcfd9edc1..dfb8632919 100644 --- a/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json +++ b/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json @@ -17,7 +17,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" + "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" }, "response": { "status": 200, @@ -35,7 +35,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0fdce240b46054ad016a1214d326848196b269feebe1844759\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTTGeallj_mC3ciDydiTVJLA6bjJfitoj4ftFfWwlxekFNaf_cDNWP3pE6qsvK9gKJNRfbAbpaEVf1qjAhQx53witrmt6H3KaaNJm3wXHG5sEi9gp3nLWK4T76tcVYHG1x6mbbTjEjCvhIuEkn_7Q7lJ1BErkEURYBBMPmkKya2-YuL8XP14Yrko9BA1t56BkwK5U3TFse4nwHI1qi82hdkX_aYAtz6YgbTpf-dvOCBGfeApxWLFotkt355Qy2b6MmPaH6cQwrvLJXOqEzGkwxFcs3mLEKLV103gd8Z5e_OapjJHTv_LarN-WN9C7nCQ0BBHClk4ND3SDdGb-XV665r23RB40GJ3Q9brJALGaJhij4uceXZNYbakZVOxgqLuDnX6EgABwEzrZb7vhVAKCewVYkLDu0LiS1rIvcFT8HpovxaBU2F2kVG7TRvzYewCW9zXWnAR048p5pUvi6zfMzapk8bnl4uM_uD45gp1sMzeSHryai1U0AUO2cLeQV1pA7KJoJBwWlHxo0YNPbDidI2KfByIoI0A7oiKoZ32vJkiwx3BEGePnzb-JQnv1eDXwlimICVKEVPk1BxpUZ2XBoWdUGYR77u5NGmZ2sKh4OM-qIaB0VaChGsCsJLyQ5_MCkeOm9EMjg1cXbIHDzs9jpF2BXlowY1Vw_L-Ve6nzwK7ZcyHM3ij27wEXYO2On6zbN_AqOvX_CFAjI7ktCYF2guftXuVpFCuiqRyDZ6i2RHXMhR77CoPT97sAvXDejN8feNtidqq4OH5uLa3BHYvW0UKfNlBCOL6A6927l4iTKURZznq_mVjLgTHWv9k-ByxP0hC5sIQHyB5hJaD8_svMr4Aqz_vH9Z8HShgjK47NsMQKxGGgaXdnq3xEdwydM-hTG4Pi35o6Kt0bbJ5KTRQ2ObjmnVTG7J__QTKMTrK2S6Ro4VIMrYzaai7BTLa8MGNotj\"},{\"type\":\"function_call\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" + "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTTGeallj_mC3ciDydiTVJLA6bjJfitoj4ftFfWwlxekFNaf_cDNWP3pE6qsvK9gKJNRfbAbpaEVf1qjAhQx53witrmt6H3KaaNJm3wXHG5sEi9gp3nLWK4T76tcVYHG1x6mbbTjEjCvhIuEkn_7Q7lJ1BErkEURYBBMPmkKya2-YuL8XP14Yrko9BA1t56BkwK5U3TFse4nwHI1qi82hdkX_aYAtz6YgbTpf-dvOCBGfeApxWLFotkt355Qy2b6MmPaH6cQwrvLJXOqEzGkwxFcs3mLEKLV103gd8Z5e_OapjJHTv_LarN-WN9C7nCQ0BBHClk4ND3SDdGb-XV665r23RB40GJ3Q9brJALGaJhij4uceXZNYbakZVOxgqLuDnX6EgABwEzrZb7vhVAKCewVYkLDu0LiS1rIvcFT8HpovxaBU2F2kVG7TRvzYewCW9zXWnAR048p5pUvi6zfMzapk8bnl4uM_uD45gp1sMzeSHryai1U0AUO2cLeQV1pA7KJoJBwWlHxo0YNPbDidI2KfByIoI0A7oiKoZ32vJkiwx3BEGePnzb-JQnv1eDXwlimICVKEVPk1BxpUZ2XBoWdUGYR77u5NGmZ2sKh4OM-qIaB0VaChGsCsJLyQ5_MCkeOm9EMjg1cXbIHDzs9jpF2BXlowY1Vw_L-Ve6nzwK7ZcyHM3ij27wEXYO2On6zbN_AqOvX_CFAjI7ktCYF2guftXuVpFCuiqRyDZ6i2RHXMhR77CoPT97sAvXDejN8feNtidqq4OH5uLa3BHYvW0UKfNlBCOL6A6927l4iTKURZznq_mVjLgTHWv9k-ByxP0hC5sIQHyB5hJaD8_svMr4Aqz_vH9Z8HShgjK47NsMQKxGGgaXdnq3xEdwydM-hTG4Pi35o6Kt0bbJ5KTRQ2ObjmnVTG7J__QTKMTrK2S6Ro4VIMrYzaai7BTLa8MGNotj\"},{\"type\":\"function_call\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" }, "response": { "status": 200, diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index e0388a7fd3..72adc848f4 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -1,5 +1,6 @@ import { NodeFileSystem } from "@effect/platform-node" import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { provideTmpdirInstance, testInstanceStoreLayer, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -7,7 +8,9 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Format } from "../../src/format" import * as Formatter from "../../src/format/formatter" -const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer)) +const it = testEffect( + Layer.mergeAll(LayerNode.compile(LayerNode.group([Format.node, CrossSpawnSpawner.node])), NodeFileSystem.layer), +) describe("Format", () => { it.instance("status() returns empty list when no formatters are configured", () => @@ -106,7 +109,11 @@ describe("Format", () => { ) testEffect( - Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer), + Layer.mergeAll( + LayerNode.compile(LayerNode.group([Format.node, CrossSpawnSpawner.node])), + NodeFileSystem.layer, + testInstanceStoreLayer, + ), ).live("status() initializes formatter state per directory", () => Effect.gen(function* () { const a = yield* provideTmpdirInstance(() => Format.use.status(), { diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts index e80b8fa906..56f19a4a46 100644 --- a/packages/opencode/test/git/git.test.ts +++ b/packages/opencode/test/git/git.test.ts @@ -1,4 +1,5 @@ import { $ } from "bun" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" @@ -8,7 +9,7 @@ import { tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" -const it = testEffect(Git.defaultLayer) +const it = testEffect(LayerNode.compile(LayerNode.group([Git.node]))) const scopedTmpdir = (options?: Parameters[0]) => Effect.acquireRelease( diff --git a/packages/opencode/test/image/image.test.ts b/packages/opencode/test/image/image.test.ts index c5d832cd5e..29667f6e2f 100644 --- a/packages/opencode/test/image/image.test.ts +++ b/packages/opencode/test/image/image.test.ts @@ -1,20 +1,18 @@ import { describe, expect } from "bun:test" -import { Cause, Effect, Exit, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Cause, Effect, Exit } from "effect" import { Image } from "@/image/image" +import { Config } from "@/config/config" import { MessageID, PartID, SessionID } from "@/session/schema" import path from "node:path" import { TestConfig } from "../fixture/config" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Image.layer.pipe(Layer.provide(TestConfig.layer())))) +const it = testEffect(LayerNode.compile(Image.node, [[Config.node, TestConfig.layer()]])) const tiny = testEffect( - Layer.mergeAll( - Image.layer.pipe( - Layer.provide( - TestConfig.layer({ get: () => Effect.succeed({ attachment: { image: { max_base64_bytes: 1 } } }) }), - ), - ), - ), + LayerNode.compile(Image.node, [ + [Config.node, TestConfig.layer({ get: () => Effect.succeed({ attachment: { image: { max_base64_bytes: 1 } } }) })], + ]), ) function part(mime: string, data: string) { diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index aaf2a9ea02..3d4b4cba31 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -1,10 +1,13 @@ import { describe, expect } from "bun:test" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Effect, Layer, Stream } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { Installation } from "../../src/installation" import { InstallationChannel } from "@opencode-ai/core/installation/version" -import { AppProcess } from "@opencode-ai/core/process" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" const encoder = new TextEncoder() @@ -52,8 +55,15 @@ function testLayer( httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response, spawnHandler?: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string }, ) { - const appProcess = AppProcess.layer.pipe(Layer.provide(mockSpawner(spawnHandler))) - return Installation.layer.pipe(Layer.provide(mockHttpClient(httpHandler)), Layer.provide(appProcess)) + const spawnerNode = makeGlobalNode({ + service: ChildProcessSpawner.ChildProcessSpawner, + layer: mockSpawner(spawnHandler), + deps: [], + }) + return LayerNode.compile(Installation.node, [ + [httpClient, mockHttpClient(httpHandler)], + [CrossSpawnSpawner.node, spawnerNode], + ]) } describe("installation", () => { diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index 3267b1ba81..3d17eec87d 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -19,8 +19,10 @@ // different return shape — see the TODO at the bottom of OpencodeCli. import { test, type TestOptions } from "bun:test" import { FSUtil } from "@opencode-ai/core/fs-util" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppProcess } from "@opencode-ai/core/process" -import { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect" +import { Deferred, Duration, Effect, Layer, Queue, Schedule, Scope, Stream } from "effect" import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { ChildProcess } from "effect/unstable/process" import path from "node:path" @@ -82,6 +84,11 @@ export type RunResult = { readonly durationMs: number } +export type RunHandle = { + readonly interrupt: () => void + readonly result: Effect.Effect +} + export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record } // Typed equivalent of constructing argv for `opencode run`. New flags should @@ -92,6 +99,7 @@ export type RunOpts = SpawnOpts & { readonly format?: "default" | "json" readonly command?: string readonly printLogs?: boolean + readonly permission?: Record readonly extraArgs?: string[] } @@ -147,6 +155,7 @@ export type AcpHandle = { export type OpencodeCli = { // High-level: run a single prompt against the test model. Short-lived. readonly run: (message: string, opts?: RunOpts) => Effect.Effect + readonly startRun: (message: string, opts?: RunOpts) => Effect.Effect // Spawn `opencode serve` and wait until it's listening. Long-lived: the // returned handle is killed when the caller's Scope closes. Fails if the // listening line doesn't appear within `readyTimeoutMs`. @@ -185,9 +194,12 @@ export function withCliFixture( const fs = yield* FSUtil.Service const appProc = yield* AppProcess.Service - // FileSystem.makeTempDirectoryScoped handles both creation and scope-tied - // cleanup — replaces the old mkdir + addFinalizer pair. - const home = yield* fs.makeTempDirectoryScoped({ prefix: "oc-cli-" }) + const home = yield* fs.makeTempDirectory({ prefix: "oc-cli-" }) + yield* Effect.addFinalizer(() => + fs + .remove(home, { recursive: true }) + .pipe(Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(20)))), Effect.ignore), + ) const configJson = JSON.stringify(testProviderConfig(llm.url)) const env = isolatedEnv(home, configJson) @@ -230,13 +242,13 @@ export function withCliFixture( ) return { exitCode: result.exitCode, - stdout: result.stdout.toString(), - stderr: result.stderr.toString(), + stdout: normalizeLines(result.stdout.toString()), + stderr: normalizeLines(result.stderr.toString()), durationMs: Date.now() - start, } }) - const run = (message: string, opts?: RunOpts): Effect.Effect => { + const runArgs = (message: string, opts?: RunOpts) => { const argv: string[] = ["run"] if (opts?.printLogs) argv.push("--print-logs") argv.push("--model", opts?.model ?? testModelID) @@ -245,9 +257,60 @@ export function withCliFixture( if (opts?.command) argv.push("--command", opts.command) if (opts?.extraArgs) argv.push(...opts.extraArgs) argv.push(message) - return spawn(argv, opts) + return argv } + const runOpts = (opts?: RunOpts): SpawnOpts | undefined => { + if (!opts?.permission) return opts + return { + ...opts, + env: { + ...opts.env, + KILO_CONFIG_CONTENT: JSON.stringify({ + ...testProviderConfig(llm.url), + permission: opts.permission, + }), + }, + } + } + + const run = (message: string, opts?: RunOpts): Effect.Effect => { + return spawn(runArgs(message, opts), runOpts(opts)) + } + + const startRun = Effect.fn("opencode.startRun")(function* (message: string, opts?: RunOpts) { + const start = Date.now() + const options = runOpts(opts) + const proc = yield* Effect.acquireRelease( + Effect.sync(() => + Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...runArgs(message, opts)], { + cwd: home, + env: { ...process.env, ...env, ...options?.env }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }), + ), + (child) => + Effect.promise(() => { + child.kill() + return child.exited + }).pipe(Effect.ignore), + ) + const stdout = new Response(proc.stdout).text() + const stderr = new Response(proc.stderr).text() + + return { + interrupt: () => proc.kill("SIGINT"), + result: Effect.promise(async () => ({ + exitCode: await proc.exited, + stdout: normalizeLines(await stdout), + stderr: normalizeLines(await stderr), + durationMs: Date.now() - start, + })), + } satisfies RunHandle + }) + const serve = Effect.fn("opencode.serve")(function* (opts?: ServeOpts) { const argv = ["serve"] // Default port 0 — let the OS pick a free port, parse the actual one @@ -401,14 +464,18 @@ export function withCliFixture( } satisfies AcpHandle }) - const opencode: OpencodeCli = { run, serve, acp, spawn, expectExit, parseJsonEvents } + const opencode: OpencodeCli = { run, startRun, serve, acp, spawn, expectExit, parseJsonEvents } return yield* fn({ llm, home, opencode }) // FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient` // and hit endpoints on `opencode.serve()` without rolling their own fetch. }).pipe( Effect.provide( - Layer.mergeAll(TestLLMServer.layer, FetchHttpClient.layer, FSUtil.defaultLayer, AppProcess.defaultLayer), + Layer.mergeAll( + TestLLMServer.layer, + FetchHttpClient.layer, + AppNodeBuilder.build(LayerNode.group([FSUtil.node, AppProcess.node])), + ), ), ) } @@ -421,6 +488,10 @@ function parseJsonEvents(stdout: string): Array> { .map((line) => JSON.parse(line) as Record) } +function normalizeLines(value: string) { + return value.replaceAll("\r\n", "\n") +} + // Convenience for the common assertion pattern. Dumps stderr/stdout when // the exit code doesn't match — saves debugging time on CI failures. function expectExit(result: RunResult, expected: number, label = "opencode") { @@ -455,5 +526,10 @@ export const cliIt = { name: string, body: (input: CliFixture) => Effect.Effect, opts?: number | TestOptions, - ) => test.concurrent(name, () => Effect.runPromise(Effect.scoped(withCliFixture(body))), opts), + ) => + (process.platform === "win32" ? test : test.concurrent)( + name, + () => Effect.runPromise(Effect.scoped(withCliFixture(body))), + opts, + ), } diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index 86f3a5dadf..57a7b59d1c 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, spyOn } from "bun:test" import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Layer } from "effect" import { EventV2Bridge } from "@/event-v2-bridge" import { Config } from "@/config/config" @@ -11,19 +12,17 @@ import { TestInstance } from "../fixture/fixture" import { awaitWithTimeout, testEffect } from "../lib/effect" const lspLayer = (flags: Parameters[0] = {}) => - LSP.layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(RuntimeFlags.layer(flags)), - Layer.provideMerge(EventV2Bridge.defaultLayer), - ) + LayerNode.compile(LayerNode.group([LSP.node, Config.node, RuntimeFlags.node, EventV2Bridge.node]), [ + [RuntimeFlags.node, RuntimeFlags.layer(flags)], + ]) -const it = testEffect(Layer.mergeAll(lspLayer(), CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(lspLayer(), LayerNode.compile(CrossSpawnSpawner.node))) const experimentalTyIt = testEffect( - Layer.mergeAll(lspLayer({ experimentalLspTy: true }), CrossSpawnSpawner.defaultLayer), + Layer.mergeAll(lspLayer({ experimentalLspTy: true }), LayerNode.compile(CrossSpawnSpawner.node)), ) const fakeServerPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") const disabledDownloadIt = testEffect( - Layer.mergeAll(lspLayer({ disableLspDownload: true }), CrossSpawnSpawner.defaultLayer), + Layer.mergeAll(lspLayer({ disableLspDownload: true }), LayerNode.compile(CrossSpawnSpawner.node)), ) describe("lsp.spawn", () => { diff --git a/packages/opencode/test/lsp/lifecycle.test.ts b/packages/opencode/test/lsp/lifecycle.test.ts index 5d0313e6d2..17387a6bf8 100644 --- a/packages/opencode/test/lsp/lifecycle.test.ts +++ b/packages/opencode/test/lsp/lifecycle.test.ts @@ -1,13 +1,13 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import path from "path" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" import { LSP } from "@/lsp/lsp" import * as LSPServer from "@/lsp/server" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(LSP.node)) describe("LSP service lifecycle", () => { let spawnSpy: ReturnType diff --git a/packages/opencode/test/mcp/auth.test.ts b/packages/opencode/test/mcp/auth.test.ts index 0fdfe78b2a..5aa099983a 100644 --- a/packages/opencode/test/mcp/auth.test.ts +++ b/packages/opencode/test/mcp/auth.test.ts @@ -1,8 +1,8 @@ import { expect, test } from "bun:test" import { setTimeout as sleep } from "node:timers/promises" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Effect, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { McpAuth } from "../../src/mcp/auth" function authFile() { @@ -10,7 +10,7 @@ function authFile() { let activeWrites = 0 let sawOverlap = false - const layer = Layer.effect( + const fsLayer = Layer.effect( FSUtil.Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -41,14 +41,14 @@ function authFile() { : fs.writeJson(file, value, mode), }) }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(AppNodeBuilder.build(FSUtil.node))) - return { layer, raw: () => raw } + return { fsLayer, raw: () => raw } } -function authService(layer: Layer.Layer) { +function authService(fsLayer: Layer.Layer) { return McpAuth.Service.use((auth) => Effect.succeed(auth)).pipe( - Effect.provide(McpAuth.layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(layer))), + Effect.provide(AppNodeBuilder.build(McpAuth.node, [[FSUtil.node, fsLayer]])), ) } @@ -57,8 +57,8 @@ test("serializes concurrent auth file updates across service instances", async ( await Effect.runPromise( Effect.gen(function* () { - const first = yield* authService(file.layer) - const second = yield* authService(file.layer) + const first = yield* authService(file.fsLayer) + const second = yield* authService(file.fsLayer) yield* Effect.all( [ diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts new file mode 100644 index 0000000000..55cabaef76 --- /dev/null +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test" +import type { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { McpCatalog } from "@/mcp/catalog" + +const options = { toolCallId: "call_mcp", abortSignal: new AbortController().signal } as any + +function clientReturning(result: unknown) { + return { + callTool: async () => result, + } as unknown as Client +} + +function mcpTool() { + return { + name: "screenshot", + description: "Take a screenshot", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + } as any +} + +describe("McpCatalog.convertTool", () => { + test("preserves content when structuredContent is also present", async () => { + const content = [{ type: "image" as const, mimeType: "image/png", data: "AAAA" }] + const structuredContent = { image: { mimeType: "image/png", data: "AAAA" } } + const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content, structuredContent })) + + const output = await converted.execute?.({}, options) + + expect(output).toMatchObject({ content, structuredContent }) + }) + + test("falls back to structuredContent only when content is absent", async () => { + const structuredContent = { results: [{ title: "one" }] } + const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content: [], structuredContent })) + + const output = await converted.execute?.({}, options) + + expect(output).toMatchObject({ + structuredContent, + content: [{ type: "text", text: JSON.stringify(structuredContent) }], + }) + }) +}) diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index c51ed00d32..e6b83d678e 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -1,4 +1,5 @@ import { describe, expect, mock, beforeEach } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect } from "effect" import { testEffect } from "../lib/effect" @@ -46,7 +47,7 @@ beforeEach(() => { // Import MCP after mocking const { MCP } = await import("../../src/mcp/index") -const it = testEffect(MCP.defaultLayer) +const it = testEffect(LayerNode.compile(MCP.node)) describe("mcp.headers", () => { it.instance("headers are passed to transports when oauth is enabled (default)", () => diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 34304624e6..aabb171f93 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -2,6 +2,7 @@ import path from "node:path" import { pathToFileURL } from "node:url" import { expect, mock, beforeEach } from "bun:test" import { ListRootsRequestSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" import { testEffect } from "../lib/effect" @@ -13,10 +14,12 @@ import { TestInstance } from "../fixture/fixture" interface MockClientState { capabilities: { tools?: object; prompts?: object; resources?: object } capabilitiesShouldThrow: boolean + instructions?: string tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> listToolsCalls: number listPromptsCalls: number listResourcesCalls: number + listResourceTemplatesCalls: number getPromptTimeout?: number readResourceTimeout?: number requestCalls: number @@ -26,6 +29,7 @@ interface MockClientState { listResourcesShouldFail: boolean prompts: Array<{ name: string; description?: string }> resources: Array<{ name: string; uri: string; description?: string }> + resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }> toolPages: Record< string, { @@ -38,6 +42,10 @@ interface MockClientState { string, { resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string } > + resourceTemplatePages: Record< + string, + { resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }>; nextCursor?: string } + > closed: boolean clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } } requestHandlers: Map Promise> @@ -67,6 +75,7 @@ function getOrCreateClientState(name?: string): MockClientState { listToolsCalls: 0, listPromptsCalls: 0, listResourcesCalls: 0, + listResourceTemplatesCalls: 0, requestCalls: 0, listToolsShouldFail: false, listToolsError: "listTools failed", @@ -74,9 +83,11 @@ function getOrCreateClientState(name?: string): MockClientState { listResourcesShouldFail: false, prompts: [], resources: [], + resourceTemplates: [], toolPages: {}, promptPages: {}, resourcePages: {}, + resourceTemplatePages: {}, closed: false, requestHandlers: new Map(), notificationHandlers: new Map(), @@ -179,6 +190,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return this._state?.capabilities } + getInstructions() { + return this._state?.instructions + } + async listTools(params?: { cursor?: string }) { if (this._state) this._state.listToolsCalls++ if (this._state?.listToolsShouldFail) { @@ -224,6 +239,13 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return { resources: this._state?.resources ?? [] } } + async listResourceTemplates(params?: { cursor?: string }) { + if (this._state) this._state.listResourceTemplatesCalls++ + const page = this._state?.resourceTemplatePages[params === undefined ? "initial" : (params.cursor ?? "")] + if (page) return page + return { resourceTemplates: this._state?.resourceTemplates ?? [] } + } + async getPrompt(_params: unknown, options?: { timeout?: number }) { if (this._state) this._state.getPromptTimeout = options?.timeout return { messages: [] } @@ -254,7 +276,7 @@ beforeEach(() => { const { MCP } = await import("../../src/mcp/index") const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") -const it = testEffect(MCP.defaultLayer) +const it = testEffect(LayerNode.compile(MCP.node)) function statusName(status: Record | MCPNS.Status, server: string) { if ("status" in status) return status.status @@ -331,6 +353,60 @@ it.instance( { config: { mcp: {} } }, ) +it.instance( + "instructions() returns connected server instructions with tool names", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "guide-server" + const serverState = getOrCreateClientState("guide-server") + serverState.instructions = "Use lookup before mutate." + + yield* mcp.add("guide-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(yield* mcp.instructions()).toContainEqual({ + name: "guide-server", + instructions: "Use lookup before mutate.", + tools: ["guide-server_test_tool"], + }) + }), + ), + { config: { mcp: {} } }, +) + +it.instance( + "instructions() omits empty and disconnected server instructions", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "temporary-server" + getOrCreateClientState("temporary-server").instructions = "Temporary guidance." + + yield* mcp.add("temporary-server", { + type: "local", + command: ["echo", "test"], + }) + yield* mcp.disconnect("temporary-server") + + lastCreatedClientName = "blank-server" + getOrCreateClientState("blank-server").instructions = " " + + yield* mcp.add("blank-server", { + type: "local", + command: ["echo", "test"], + }) + + const instructions = yield* mcp.instructions() + expect(instructions.some((item) => item.name === "temporary-server")).toBe(false) + expect(instructions.some((item) => item.name === "blank-server")).toBe(false) + }), + ), + { config: { mcp: {} } }, +) + it.instance( "follows cursors when listing tools, prompts, and resources", () => @@ -353,6 +429,13 @@ it.instance( initial: { resources: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" }, "resources-2": { resources: [{ name: "resource-two", uri: "test://two" }] }, } + serverState.resourceTemplatePages = { + initial: { + resourceTemplates: [{ name: "template-one", uriTemplate: "test://one/{id}" }], + nextCursor: "resource-templates-2", + }, + "resource-templates-2": { resourceTemplates: [{ name: "template-two", uriTemplate: "test://two/{id}" }] }, + } yield* mcp.add("paged-server", { type: "local", @@ -361,10 +444,15 @@ it.instance( expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"]) expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"]) - expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:resource-one", "paged-server:resource-two"]) + expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"]) + expect(Object.keys(yield* mcp.resourceTemplates())).toEqual([ + "paged-server:test://one/{id}", + "paged-server:test://two/{id}", + ]) expect(serverState.listToolsCalls).toBe(2) expect(serverState.listPromptsCalls).toBe(2) expect(serverState.listResourcesCalls).toBe(2) + expect(serverState.listResourceTemplatesCalls).toBe(2) }), ), { config: { mcp: {} } }, @@ -796,7 +884,10 @@ it.instance( Effect.gen(function* () { lastCreatedClientName = "resource-server" const serverState = getOrCreateClientState("resource-server") - serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }] + serverState.resources = [ + { name: "my-resource", uri: "file:///test.txt", description: "A test resource" }, + { name: "my-resource", uri: "ui://component-state", description: "A second resource with same name" }, + ] yield* mcp.add("resource-server", { type: "local", @@ -804,10 +895,10 @@ it.instance( }) const resources = yield* mcp.resources() - expect(Object.keys(resources).length).toBe(1) - const key = Object.keys(resources)[0] - expect(key).toContain("resource-server") - expect(key).toContain("my-resource") + expect(Object.keys(resources)).toEqual([ + "resource-server:file:///test.txt", + "resource-server:ui://component-state", + ]) }), ), { @@ -863,7 +954,7 @@ it.instance( expect(statusName(result.status, "resource-only-server")).toBe("connected") expect(serverState.listToolsCalls).toBe(0) expect(Object.keys(yield* mcp.tools())).toHaveLength(0) - expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs"]) + expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs://readme"]) expect(serverState.listResourcesCalls).toBe(1) expect(serverState.listPromptsCalls).toBe(0) }), diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 9b46853c79..febbe0d0bd 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -1,4 +1,5 @@ import { expect, mock, beforeEach } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { testEffect } from "../lib/effect" @@ -23,6 +24,8 @@ let simulateAuthFlow = true let connectSucceedsImmediately = false let serverCapabilities: { tools?: object; resources?: object } = { tools: {} } let listToolsCalls = 0 +let finishAuthFails = false +let finishAuthStoresCredentials = false // Mock the transport constructors to simulate OAuth auto-auth on 401 void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ @@ -32,6 +35,10 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ state?: () => Promise redirectToAuthorization?: (url: URL) => Promise saveCodeVerifier?: (v: string) => Promise + tokens?: () => Promise<{ access_token: string } | undefined> + clientInformation?: () => Promise<{ client_id: string } | undefined> + saveClientInformation?: (info: { client_id: string; client_secret?: string }) => Promise + saveTokens?: (tokens: { access_token: string; token_type: string }) => Promise } | undefined constructor(url: URL, options?: { authProvider?: unknown }) { @@ -49,6 +56,8 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ // It calls auth() which eventually calls provider.state(), then // provider.redirectToAuthorization(), then throws UnauthorizedError. if (simulateAuthFlow && this.authProvider) { + if (await this.authProvider.tokens?.()) throw new MockUnauthorizedError() + if (await this.authProvider.clientInformation?.()) throw new MockUnauthorizedError() // The SDK calls provider.state() to get the OAuth state parameter if (this.authProvider.state) { await this.authProvider.state() @@ -65,7 +74,14 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ } throw new MockUnauthorizedError() } - async finishAuth(_code: string) {} + async finishAuth(_code: string) { + if (finishAuthFails) throw new Error("Token exchange failed") + if (finishAuthStoresCredentials) { + await this.authProvider?.saveClientInformation?.({ client_id: "replacement-client" }) + await this.authProvider?.saveTokens?.({ access_token: "replacement-token", token_type: "Bearer" }) + } + } + async close() {} }, })) @@ -99,6 +115,8 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return serverCapabilities } + getInstructions() {} + async listTools() { listToolsCalls++ return { tools: [{ name: "test_tool", inputSchema: { type: "object", properties: {} } }] } @@ -123,6 +141,8 @@ beforeEach(() => { connectSucceedsImmediately = false serverCapabilities = { tools: {} } listToolsCalls = 0 + finishAuthFails = false + finishAuthStoresCredentials = false }) // Import modules after mocking @@ -131,19 +151,13 @@ const { EventV2Bridge } = await import("../../src/event-v2-bridge") const { Config } = await import("../../src/config/config") const { McpAuth } = await import("../../src/mcp/auth") const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider") +const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") const { FSUtil } = await import("@opencode-ai/core/fs-util") const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner") const mcpTest = testEffect( - Layer.mergeAll( - MCP.layer.pipe( - Layer.provide(McpAuth.defaultLayer), - Layer.provideMerge(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - ), - McpAuth.defaultLayer, + LayerNode.compile( + LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node]), ), ) @@ -225,6 +239,83 @@ mcpTest.instance("state() returns existing state when one is saved", () => }), ) +mcpTest.instance( + "failed reauthentication preserves existing credentials", + () => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore)) + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service + const name = "test-reauth-failure" + const url = "https://example.com/mcp" + const clientInfo = { clientId: "dynamic-client", clientSecret: "dynamic-secret" } + + yield* auth.updateClientInfo(name, clientInfo, url) + yield* auth.updateTokens(name, { accessToken: "working-token" }, url) + expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize") + finishAuthFails = true + + expect(yield* mcp.finishAuth(name, "invalid-code")).toEqual({ + status: "failed", + error: "OAuth completion failed: Token exchange failed", + }) + const entry = yield* auth.get(name) + expect(entry?.tokens?.accessToken).toBe("working-token") + expect(entry?.clientInfo).toEqual(clientInfo) + }), + { config: config("test-reauth-failure") }, +) + +mcpTest.instance( + "successful reauthentication commits replacement credentials", + () => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore)) + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service + const name = "test-reauth-success" + const url = "https://example.com/mcp" + + yield* auth.updateClientInfo(name, { clientId: "old-client" }, url) + yield* auth.updateTokens(name, { accessToken: "old-token" }, url) + expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize") + expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token") + finishAuthStoresCredentials = true + connectSucceedsImmediately = true + + expect((yield* mcp.finishAuth(name, "valid-code")).status).toBe("connected") + const entry = yield* auth.get(name) + expect(entry?.tokens?.accessToken).toBe("replacement-token") + expect(entry?.clientInfo?.clientId).toBe("replacement-client") + expect(entry?.serverUrl).toBe(url) + }), + { config: config("test-reauth-success") }, +) + +mcpTest.instance( + "auth status only reports credentials stored for the configured server URL", + () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + expect(transportCalls).toHaveLength(0) + yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "old-token" }, "https://old.example.com/mcp") + + expect(yield* mcp.getAuthStatus("test-status-url")).toBe("not_authenticated") + + yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "current-token" }, "https://example.com/mcp") + expect(yield* mcp.getAuthStatus("test-status-url")).toBe("authenticated") + + yield* McpAuth.use.updateTokens( + "test-status-url", + { accessToken: "expired-token", expiresAt: 1 }, + "https://example.com/mcp", + ) + expect(yield* mcp.getAuthStatus("test-status-url")).toBe("expired") + expect(transportCalls).toHaveLength(0) + }), + { config: config("test-status-url") }, +) + mcpTest.instance( "authenticate() stores a connected client when auth completes without redirect", () => @@ -269,7 +360,7 @@ mcpTest.instance( const result = yield* mcp.authenticate("test-oauth-resources") expect(result.status).toBe("connected") expect(listToolsCalls).toBe(0) - expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs"]) + expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs://readme"]) }), ), { config: config("test-oauth-resources") }, diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index b0dfb06e65..07b157ecc9 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -1,5 +1,6 @@ import { expect, mock, beforeEach } from "bun:test" import { EventEmitter } from "events" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Layer, Option } from "effect" import { awaitWithTimeout, testEffect } from "../lib/effect" import type { MCP as MCPNS } from "../../src/mcp/index" @@ -122,12 +123,8 @@ const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") const { FSUtil } = await import("@opencode-ai/core/fs-util") const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner") const mcpTest = testEffect( - MCP.layer.pipe( - Layer.provide(McpAuth.defaultLayer), - Layer.provideMerge(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(FSUtil.defaultLayer), + LayerNode.compile( + LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node]), ), ) const service = MCP.Service as unknown as Effect.Effect @@ -163,10 +160,10 @@ const trackBrowserOpenFailed = Effect.gen(function* () { return event }) -const authenticateScoped = (name: string) => +const authenticateScoped = (name: string, onAuthorization?: (authorizationUrl: string) => void) => Effect.gen(function* () { const mcp = yield* service - yield* mcp.authenticate(name).pipe( + yield* mcp.authenticate(name, onAuthorization).pipe( Effect.ignore, Effect.catchCause(() => Effect.void), Effect.forkScoped, @@ -225,12 +222,19 @@ mcpTest.instance( const opened = yield* trackBrowserOpen const event = yield* trackBrowserOpenFailed - yield* authenticateScoped("test-oauth-server-3") + const authorization = yield* Deferred.make() + yield* authenticateScoped("test-oauth-server-3", (url) => Deferred.doneUnsafe(authorization, Effect.succeed(url))) const url = yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()", "5 seconds") + const authorizationUrl = yield* awaitWithTimeout( + Deferred.await(authorization), + "Timed out waiting for authorization URL", + "5 seconds", + ) const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis")) expect(failure).toEqual(Option.none()) + expect(authorizationUrl).toBe(url) expect(typeof url).toBe("string") expect(url).toContain("https://") expect(transportCalls.at(-1)?.options.requestInit?.headers).toEqual({ "X-Custom-Header": "custom-value" }) diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts index cac7146580..1666a37142 100644 --- a/packages/opencode/test/mcp/oauth-callback.test.ts +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -1,7 +1,41 @@ import { test, expect, describe, afterEach } from "bun:test" +import { createConnection, createServer as createNetServer } from "net" import { McpOAuthCallback } from "../../src/mcp/oauth-callback" import { parseRedirectUri } from "../../src/mcp/oauth-provider" +async function getFreeLoopbackPort(): Promise { + return new Promise((resolve, reject) => { + const probe = createNetServer() + probe.once("error", reject) + probe.listen(0, "127.0.0.1", () => { + const address = probe.address() + probe.close(() => { + if (typeof address === "object" && address) { + resolve(address.port) + return + } + reject(new Error("Could not allocate a loopback port")) + }) + }) + }) +} + +async function canConnect(host: string, port: number): Promise { + return new Promise((resolve) => { + const socket = createConnection({ host, port }) + const done = (ok: boolean) => { + socket.removeAllListeners() + socket.destroy() + resolve(ok) + } + + socket.setTimeout(500) + socket.once("connect", () => done(true)) + socket.once("error", () => done(false)) + socket.once("timeout", () => done(false)) + }) +} + describe("parseRedirectUri", () => { test("returns defaults when no URI provided", () => { const result = parseRedirectUri() @@ -67,6 +101,14 @@ describe("McpOAuthCallback.ensureRunning", () => { `${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent("The user denied access")}`, ) - expect(await response.text()).toContain('
The user denied access
') + expect(await response.text()).toContain('
The user denied access
') + }) + + test("binds the callback server to IPv4 loopback", async () => { + const port = await getFreeLoopbackPort() + await McpOAuthCallback.ensureRunning(`http://127.0.0.1:${port}/custom/callback`) + + expect(await canConnect("127.0.0.1", port)).toBe(true) + expect(await canConnect("::1", port)).toBe(false) }) }) diff --git a/packages/opencode/test/mcp/oauth-provider.test.ts b/packages/opencode/test/mcp/oauth-provider.test.ts index 64c2cb6687..249c49e8f9 100644 --- a/packages/opencode/test/mcp/oauth-provider.test.ts +++ b/packages/opencode/test/mcp/oauth-provider.test.ts @@ -1,4 +1,5 @@ import { test, expect, describe } from "bun:test" +import { determineScope } from "@modelcontextprotocol/sdk/client/auth.js" import { McpOAuthProvider, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "../../src/mcp/oauth-provider" import type { McpAuth } from "../../src/mcp/auth" @@ -59,3 +60,43 @@ describe("McpOAuthProvider.clientMetadata", () => { expect(provider.clientMetadata.token_endpoint_auth_method).toBe("none") }) }) + +describe("MCP OAuth scope selection", () => { + test("adds offline_access when the authorization server and client support refresh tokens", () => { + expect( + determineScope({ + resourceMetadata: { + resource: "https://mcp.example.com/mcp", + scopes_supported: ["resource.read"], + }, + authServerMetadata: { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + scopes_supported: ["resource.read", "offline_access"], + }, + clientMetadata: makeProvider({}).clientMetadata, + }), + ).toBe("resource.read offline_access") + }) + + test("does not add unsupported authorization server scopes", () => { + expect( + determineScope({ + resourceMetadata: { + resource: "https://mcp.example.com/mcp", + scopes_supported: ["resource.read"], + }, + authServerMetadata: { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + scopes_supported: ["resource.read"], + }, + clientMetadata: makeProvider({}).clientMetadata, + }), + ).toBe("resource.read") + }) +}) diff --git a/packages/opencode/test/patch/patch.test.ts b/packages/opencode/test/patch/patch.test.ts index c1e47a4f1d..0a8dd5d8ad 100644 --- a/packages/opencode/test/patch/patch.test.ts +++ b/packages/opencode/test/patch/patch.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect } from "effect" import * as fs from "fs/promises" import * as path from "path" @@ -7,7 +8,7 @@ import { Patch } from "../../src/patch" import { FSUtil } from "@opencode-ai/core/fs-util" import { testEffect } from "../lib/effect" -const it = testEffect(FSUtil.defaultLayer) +const it = testEffect(LayerNode.compile(FSUtil.node)) describe("Patch namespace", () => { let tempDir: string diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index e5d92c5815..09a71465d5 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -1,11 +1,12 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { describe, test, expect } from "bun:test" import { Effect } from "effect" import { Permission } from "../src/permission" import { Config } from "@/config/config" import { testEffect } from "./lib/effect" -const it = testEffect(Config.defaultLayer) +const it = testEffect(LayerNode.compile(Config.node)) const load = Config.use.get() diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index e784350055..2f2ce98efb 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -4,21 +4,19 @@ import os from "os" import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { EventV2Bridge } from "../../src/event-v2-bridge" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Database } from "@opencode-ai/core/database/database" import { Permission } from "../../src/permission" -import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" -const events = EventV2Bridge.defaultLayer const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const env = Layer.mergeAll( - Permission.layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(events)), - events, - CrossSpawnSpawner.defaultLayer, - InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)), +const env = AppNodeBuilder.build( + LayerNode.group([Permission.node, EventV2Bridge.node, CrossSpawnSpawner.node, InstanceStore.node]), + [[InstanceStore.bootstrapNode, noopBootstrap]], ) const it = testEffect(env) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index e68c1dd7aa..ca8a2ff69a 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -1,46 +1,40 @@ import { describe, expect, test } from "bun:test" import path from "path" import { pathToFileURL } from "url" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { ProviderAuth } from "@/provider/auth" -import { Plugin } from "@/plugin" import { RuntimeFlags } from "@/effect/runtime-flags" -import { Auth } from "@/auth" -import { EventV2Bridge } from "@/event-v2-bridge" import { TestConfig } from "../fixture/config" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ProviderV2 } from "@opencode-ai/core/provider" +import { Config } from "@/config/config" -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node]))) -function layer(directory: string, plugins: string[]) { - return ProviderAuth.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide( - Plugin.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(RuntimeFlags.layer()), - Layer.provide( - TestConfig.layer({ - get: () => - Effect.succeed({ - plugin: plugins, - plugin_origins: plugins.map((plugin) => ({ - spec: plugin, - source: path.join(directory, "opencode.json"), - scope: "local" as const, - })), - }), - directories: () => Effect.succeed([directory]), +function providerAuthLayer(directory: string, plugins: string[]) { + return LayerNode.compile(ProviderAuth.node, [ + [ + Config.node, + TestConfig.layer({ + get: () => + Effect.succeed({ + plugin: plugins, + plugin_origins: plugins.map((plugin) => ({ + spec: plugin, + source: path.join(directory, "opencode.json"), + scope: "local" as const, + })), }), - ), - ), - ), - ) + directories: () => Effect.succeed([directory]), + }), + ], + [RuntimeFlags.node, RuntimeFlags.layer()], + ]) } describe("plugin.auth-override", () => { @@ -73,10 +67,12 @@ describe("plugin.auth-override", () => { const plain = yield* tmpdirScoped({ git: true }) const plugin = pathToFileURL(path.join(pluginDir, "custom-copilot-auth.ts")).href - const methods = yield* ProviderAuth.use.methods().pipe(Effect.provide(layer(tmp.directory, [plugin]))) + const methods = yield* ProviderAuth.use + .methods() + .pipe(Effect.provide(providerAuthLayer(tmp.directory, [plugin]))) const plainMethods = yield* ProviderAuth.use .methods() - .pipe(Effect.provide(layer(plain, [])), provideInstance(plain)) + .pipe(Effect.provide(providerAuthLayer(plain, [])), provideInstance(plain)) const copilot = methods[ProviderV2.ID.make("github-copilot")] expect(copilot).toBeDefined() diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 017bc84d85..005a6e78cb 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -1,17 +1,18 @@ import { afterEach, describe, expect, spyOn } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { FSUtil } from "@opencode-ai/core/fs-util" +import { Config } from "@/config/config" import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const { Plugin } = await import("../../src/plugin/index") const { PluginLoader } = await import("../../src/plugin/loader") const { readPackageThemes } = await import("../../src/plugin/shared") -const { EventV2Bridge } = await import("../../src/event-v2-bridge") const { Npm } = await import("@opencode-ai/core/npm") const { TestConfig } = await import("../fixture/config") const { RuntimeFlags } = await import("../../src/effect/runtime-flags") @@ -20,7 +21,9 @@ afterEach(async () => { await disposeAllInstances() }) -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer)) +const it = testEffect( + Layer.mergeAll(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node])), testInstanceStoreLayer), +) function withTmp( init: (dir: string) => Promise, @@ -45,10 +48,9 @@ function load(dir: string, flags?: Parameters[0]) { yield* plugin.list() }).pipe( Effect.provide( - Plugin.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })), - Layer.provide( + LayerNode.compile(Plugin.node, [ + [ + Config.node, TestConfig.layer({ get: () => Effect.succeed({ @@ -57,8 +59,9 @@ function load(dir: string, flags?: Parameters[0]) { }), directories: () => Effect.succeed([dir]), }), - ), - ), + ], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })], + ]), ), provideInstance(dir), ) diff --git a/packages/opencode/test/plugin/openai-ws.test.ts b/packages/opencode/test/plugin/openai-ws.test.ts index cdcded9da6..7a125824e0 100644 --- a/packages/opencode/test/plugin/openai-ws.test.ts +++ b/packages/opencode/test/plugin/openai-ws.test.ts @@ -559,21 +559,28 @@ describe("plugin.openai.ws-pool", () => { }) test("retries failed websocket streams before using HTTP fallback", async () => { + const attempts: Array<(socket: WebSocket) => void> = [] await using server = await createWebSocketServer((socket) => { socket.once("message", () => { socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" })) + attempts.shift()?.(socket) }) }) const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url, - idleTimeout: 20, streamRetries: 1, }) + const firstAttempt = new Promise((resolve) => attempts.push(resolve)) const first = await fetch(server.url, streamRequest()) - expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket") + const firstSocket = await firstAttempt + firstSocket.terminate() + expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed") + const secondAttempt = new Promise((resolve) => attempts.push(resolve)) const second = await fetch(server.url, streamRequest()) - expect((await readTextError(second.text())).message).toContain("idle timeout waiting for websocket") + const secondSocket = await secondAttempt + secondSocket.terminate() + expect((await readTextError(second.text())).message).toContain("WebSocket closed before response.completed") const third = await fetch(server.url, streamRequest()) expect(await third.text()).toBe("http") diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts index 7bd9e33527..07fd8e2f21 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -1,14 +1,11 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" +import { Effect } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Npm } from "@opencode-ai/core/npm" import path from "path" import { pathToFileURL } from "url" -import { EventV2Bridge } from "../../src/event-v2-bridge" -import { Config } from "../../src/config/config" -import { Env } from "../../src/env" +import { Account } from "../../src/account/account" +import { Auth } from "../../src/auth" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin/index" @@ -19,25 +16,16 @@ import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" -const configLayer = Config.layer.pipe( - Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(AuthTest.empty), - Layer.provide(AccountTest.empty), - Layer.provide(NpmTest.noop), - Layer.provide(FetchHttpClient.layer), -) const it = testEffect( - Layer.mergeAll( - Plugin.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(configLayer), - Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), - ), - CrossSpawnSpawner.defaultLayer, - ), + AppNodeBuilder.build(LayerNode.group([Plugin.node, CrossSpawnSpawner.node]), [ + [Auth.node, AuthTest.empty], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })], + ]), ) const systemHook = "experimental.chat.system.transform" diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 30428980bb..a8dd172521 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -1,63 +1,34 @@ import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Database } from "@opencode-ai/core/database/database" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { Npm } from "@opencode-ai/core/npm" import { Ripgrep } from "@opencode-ai/core/ripgrep" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import path from "path" import { pathToFileURL } from "url" import { Auth } from "../../src/auth" -import { EventV2Bridge } from "../../src/event-v2-bridge" -import { Config } from "../../src/config/config" -import { Env } from "../../src/env" +import { Account } from "../../src/account/account" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Workspace } from "../../src/control-plane/workspace" import { Plugin } from "../../src/plugin/index" -import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" -import { Project } from "../../src/project/project" -import { Vcs } from "../../src/project/vcs" import { InstanceState } from "../../src/effect/instance-state" -import { Session } from "../../src/session/session" -import { SessionPrompt } from "../../src/session/prompt" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" -const configLayer = Config.layer.pipe( - Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(AuthTest.empty), - Layer.provide(AccountTest.empty), - Layer.provide(NpmTest.noop), - Layer.provide(FetchHttpClient.layer), -) -const pluginLayer = Plugin.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(configLayer), - Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), -) const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const workspaceLayer = Workspace.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionPrompt.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(Vcs.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))), - Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })), -) const it = testEffect( - Layer.mergeAll(pluginLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(Ripgrep.defaultLayer)), + AppNodeBuilder.build(LayerNode.group([Plugin.node, Workspace.node, InstanceStore.node, Ripgrep.node]), [ + [Auth.node, AuthTest.empty], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], + [InstanceStore.bootstrapNode, noopBootstrapLayer], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true, experimentalWorkspaces: true })], + ]), ) afterEach(async () => { diff --git a/packages/opencode/test/project/instance-bootstrap.test.ts b/packages/opencode/test/project/instance-bootstrap.test.ts index 5009d6b500..f855024e5d 100644 --- a/packages/opencode/test/project/instance-bootstrap.test.ts +++ b/packages/opencode/test/project/instance-bootstrap.test.ts @@ -2,16 +2,21 @@ import { afterEach, expect } from "bun:test" import { existsSync } from "node:fs" import path from "node:path" import { pathToFileURL } from "node:url" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Cause, Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Effect, Exit, Fiber } from "effect" import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" -import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { waitGlobalBusEvent } from "../server/global-bus" -const it = testEffect(Layer.mergeAll(InstanceLayer.layer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect( + LayerNode.compile(LayerNode.group([InstanceStore.node, CrossSpawnSpawner.node]), [ + [InstanceStore.bootstrapNode, InstanceBootstrap.node], + ]), +) // InstanceBootstrap must run before any code touches the instance — // originally tracked by PRs #25389 and #25449, now a permanent diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 491cfe93d7..f78b99ef7d 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -1,9 +1,10 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Deferred, Effect, Fiber, Layer } from "effect" import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" -import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -15,7 +16,9 @@ const noopBootstrap = Layer.succeed( ) const it = testEffect( - Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(noopBootstrap)), + LayerNode.compile(LayerNode.group([InstanceStore.node, CrossSpawnSpawner.node]), [ + [InstanceStore.bootstrapNode, noopBootstrap], + ]), ) const setBootstrap = (run: Effect.Effect) => diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts index 77de870161..24d8aaaaaf 100644 --- a/packages/opencode/test/project/migrate-global.test.ts +++ b/packages/opencode/test/project/migrate-global.test.ts @@ -9,11 +9,12 @@ import { ProjectV2 } from "@opencode-ai/core/project" import { SessionID } from "../../src/session/schema" import { $ } from "bun" import { tmpdirScoped } from "../fixture/fixture" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect } from "effect" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer, Database.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Project.node, Database.node, CrossSpawnSpawner.node]))) function legacySessionID() { // Global-session migration covers persisted IDs from before prefixed session IDs. diff --git a/packages/opencode/test/project/project-directory.test.ts b/packages/opencode/test/project/project-directory.test.ts index cb458c30f3..112271c2c4 100644 --- a/packages/opencode/test/project/project-directory.test.ts +++ b/packages/opencode/test/project/project-directory.test.ts @@ -2,8 +2,9 @@ import { describe, expect } from "bun:test" import { $ } from "bun" import path from "path" import { eq } from "drizzle-orm" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect } from "effect" import { Hash } from "@opencode-ai/core/util/hash" import { AbsolutePath } from "@opencode-ai/core/schema" import { Database } from "@opencode-ai/core/database/database" @@ -13,7 +14,7 @@ import { Project } from "@/project/project" import { tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Project.defaultLayer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Project.node, Database.node, CrossSpawnSpawner.node]))) function directories(projectID: ProjectV2.ID) { return Database.Service.use(({ db }) => diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 05e205cd87..804b92b08e 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -1,5 +1,4 @@ import { describe, expect } from "bun:test" -import { EventV2Bridge } from "@/event-v2-bridge" import { Project } from "@/project/project" import { $ } from "bun" import path from "path" @@ -15,19 +14,17 @@ import { SessionID } from "@/session/schema" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { Cause, Effect, Exit, Layer, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import { NodePath } from "@effect/platform-node" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { AppProcess } from "@opencode-ai/core/process" import { ProjectV2 } from "@opencode-ai/core/project" -import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" import { RuntimeFlags } from "@/effect/runtime-flags" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" const encoder = new TextEncoder() -const layer = Layer.mergeAll(Project.defaultLayer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer) -const it = testEffect(layer) +const projectTestNode = LayerNode.group([Project.node, Database.node, CrossSpawnSpawner.node]) +const it = testEffect(AppNodeBuilder.build(projectTestNode)) function remoteProjectID(remote: string) { return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`)) @@ -65,41 +62,37 @@ function mockGitFailure(failArg: string) { }), ) }), - ).pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) + ).pipe(Layer.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))) } function projectLayerWithFailure(failArg: string) { - return Project.layer.pipe( - Layer.provide(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))), - Layer.provide(mockGitFailure(failArg)), - Layer.provide(ProjectV2.defaultLayer), - Layer.provide(ProjectDirectories.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(NodePath.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - ) + return AppNodeBuilder.build(Project.node, [ + [ProjectV2.node, projectV2FailureLayer()], + [CrossSpawnSpawner.node, mockGitFailure(failArg)], + ]) } -function projectLayerWithRuntimeFlags(flags: Parameters[0]) { - return Project.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.provide(ProjectDirectories.defaultLayer), - Layer.provide(AppProcess.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(NodePath.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(RuntimeFlags.layer(flags)), +function projectV2FailureLayer() { + return Layer.succeed( + ProjectV2.Service, + ProjectV2.Service.of({ + directories: () => Effect.succeed([]), + resolve: (input) => + Effect.succeed({ + id: ProjectV2.ID.global, + directory: input, + vcs: { type: "git" as const, store: input }, + }), + commit: () => Effect.void, + }), ) } const failureIt = (failArg: string) => - testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer)) + testEffect(AppNodeBuilder.build(projectTestNode, [[Project.node, projectLayerWithFailure(failArg)]])) const iconDiscoveryIt = testEffect( - Layer.provideMerge(projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }), CrossSpawnSpawner.defaultLayer), + AppNodeBuilder.build(projectTestNode, [[RuntimeFlags.node, RuntimeFlags.layer({ experimentalIconDiscovery: true })]]), ) function waitForProjectIcon(id: ProjectV2.ID, attempts = 50): Effect.Effect { diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 21620adaf0..c13b108bc6 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -1,8 +1,9 @@ import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { parsePatch } from "diff" import { Deferred, Effect, Layer } from "effect" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import fs from "fs/promises" import path from "path" import { @@ -24,10 +25,8 @@ import { testEffect } from "../lib/effect" const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" -const layer = Layer.mergeAll( - Vcs.layer.pipe(Layer.provideMerge(Git.defaultLayer), Layer.provideMerge(EventV2Bridge.defaultLayer)), - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, +const layer = LayerNode.compile( + LayerNode.group([Vcs.node, Git.node, EventV2Bridge.node, FSUtil.node, CrossSpawnSpawner.node]), ) const it = testEffect(layer) const worktreeIt = testEffect(Layer.mergeAll(layer, testInstanceStoreLayer)) diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index c717578024..9e061f6d88 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -2,13 +2,15 @@ import { $ } from "bun" import { describe, expect } from "bun:test" import * as fs from "fs/promises" import path from "path" -import { Effect, Layer } from "effect" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" import { Worktree } from "../../src/worktree" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(Worktree.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])) const wintest = process.platform === "win32" ? it.instance : it.instance.skip describe("Worktree.remove", () => { diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index eebd0f55bd..e2a7e74723 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -1,16 +1,20 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber } from "effect" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Git } from "../../src/git" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" import { Worktree } from "../../src/worktree" import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect( - Layer.mergeAll(Worktree.defaultLayer, FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer), + LayerNode.compile(LayerNode.group([Worktree.node, FSUtil.node, Git.node]), [ + [InstanceStore.bootstrapNode, InstanceBootstrap.node], + ]), ) const wintest = process.platform !== "win32" ? it.instance : it.instance.skip diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index d1cc510cd1..6e677631c4 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" import path from "path" import { unlink } from "fs/promises" import { Global } from "@opencode-ai/core/global" @@ -12,7 +13,7 @@ import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node]))) const originalEnv = new Map() diff --git a/packages/opencode/test/provider/digitalocean.test.ts b/packages/opencode/test/provider/digitalocean.test.ts index ca15fdfe03..34d7decabb 100644 --- a/packages/opencode/test/provider/digitalocean.test.ts +++ b/packages/opencode/test/provider/digitalocean.test.ts @@ -1,4 +1,5 @@ import { expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Provider } from "../../src/provider/provider" import { Effect } from "effect" @@ -6,7 +7,7 @@ import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" const DIGITALOCEAN = ProviderV2.ID.make("digitalocean") -const it = testEffect(Provider.defaultLayer) +const it = testEffect(LayerNode.compile(Provider.node)) const withEnv = (values: Record, effect: Effect.Effect) => Effect.acquireUseRelease( diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts index a2c8dbdb8e..7c7b098198 100644 --- a/packages/opencode/test/provider/header-timeout.test.ts +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -1,8 +1,9 @@ import { afterEach, expect } from "bun:test" import { createServer, type Server } from "node:http" import { streamText } from "ai" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect } from "effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" @@ -18,7 +19,7 @@ afterEach(async () => { }) const it = testEffect( - Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer), + LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node, CrossSpawnSpawner.node])), ) it.live("headerTimeout does not abort delayed SSE body after headers arrive", () => diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 6edfc97ca0..18ec8f9fbe 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1,6 +1,8 @@ import { afterEach, expect, test } from "bun:test" import { mkdir, unlink } from "fs/promises" import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Effect, Layer } from "effect" import { ModelsDev } from "@opencode-ai/core/models-dev" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -16,7 +18,8 @@ import { Provider } from "@/provider/provider" import { RuntimeFlags } from "@/effect/runtime-flags" import { Filesystem } from "@/util/filesystem" -import { InstanceLayer } from "@/project/instance-layer" +import { InstanceBootstrap } from "@/project/bootstrap" +import { InstanceStore } from "@/project/instance-store" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -57,14 +60,18 @@ afterEach(async () => { }) const providerLayer = (flags: Partial = {}) => - Provider.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(ModelsDev.defaultLayer), - Layer.provide(RuntimeFlags.layer(flags)), + LayerNode.compile( + LayerNode.group([ + Provider.node, + FSUtil.node, + Env.node, + Config.node, + Auth.node, + Plugin.node, + ModelsDev.node, + RuntimeFlags.node, + ]), + [[RuntimeFlags.node, RuntimeFlags.layer(flags)]], ) const list = Provider.use.list() @@ -77,7 +84,7 @@ const paid = (providers: Record (language as { config: { baseURL: string } }).config.baseURL -const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node]))) const experimentalModels = testEffect(providerLayer({ enableExperimentalModels: true })) const alphaProviderConfig = { @@ -356,6 +363,17 @@ it.instance( { config: { model: "anthropic/claude-sonnet-4-20250514" } }, ) +it.instance( + "defaultModel treats empty provider config as no allowlist", + Effect.gen(function* () { + yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") + const model = yield* Provider.use.defaultModel() + expect(model.providerID).toBeDefined() + expect(model.modelID).toBeDefined() + }), + { config: { provider: {} } }, +) + it.instance( "defaultModel returns a typed error when config excludes every provider", Effect.gen(function* () { @@ -652,6 +670,102 @@ it.instance("getSmallModel returns appropriate small model", () => }), ) +it.instance("getSmallModel prefers Gemini for Google Vertex", () => + Effect.gen(function* () { + yield* set("GOOGLE_VERTEX_PROJECT", "test-project") + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.googleVertex) + expect(model).toBeDefined() + expect(model?.id).toContain("gemini") + }), +) + +it.instance( + "getSmallModel selects the latest model in the preferred family", + Effect.gen(function* () { + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) + expect(model?.id).toBe(ModelV2.ID.make("new-flash")) + }), + { + config: { + provider: { + "test-provider": { + name: "Test Provider", + npm: "@ai-sdk/openai-compatible", + models: { + "old-flash": { family: "gemini-flash", release_date: "2025-01-01" }, + "new-flash": { family: "gemini-flash", release_date: "2026-01-01" }, + "newer-haiku": { family: "claude-haiku", release_date: "2026-06-01" }, + }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + +it.instance( + "getSmallModel matches exact model families", + Effect.gen(function* () { + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) + expect(model?.id).toBe(ModelV2.ID.make("claude-haiku")) + }), + { + config: { + provider: { + "test-provider": { + name: "Test Provider", + npm: "@ai-sdk/openai-compatible", + models: { + "glm-flash": { family: "glm-flash", release_date: "2026-06-01" }, + "claude-haiku": { family: "claude-haiku", release_date: "2026-01-01" }, + }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + +it.instance( + "getSmallModel ignores model IDs without family metadata", + Effect.gen(function* () { + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) + expect(model).toBeUndefined() + }), + { + config: { + provider: { + "test-provider": { + name: "Test Provider", + npm: "@ai-sdk/openai-compatible", + models: { + "gpt-5-nano": { release_date: "2026-01-01" }, + }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + +it.instance("getSmallModel skips inferred models for Azure", () => + Effect.gen(function* () { + yield* set("AZURE_RESOURCE_NAME", "test-resource") + yield* set("AZURE_API_KEY", "test-key") + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.azure) + expect(model).toBeUndefined() + }), +) + +it.instance("getSmallModel skips inferred models for Azure Cognitive Services", () => + Effect.gen(function* () { + yield* set("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "test-resource") + yield* set("AZURE_COGNITIVE_SERVICES_API_KEY", "test-key") + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("azure-cognitive-services")) + expect(model).toBeUndefined() + }), +) + it.instance( "getSmallModel respects config small_model override", Effect.gen(function* () { @@ -1016,6 +1130,8 @@ it.instance("ModelNotFoundError includes suggestions for typos", () => .pipe(Effect.flip) expect(error.suggestions).toBeDefined() expect((error.suggestions ?? []).length).toBeGreaterThan(0) + expect(error.message).toContain("Model not found: anthropic/claude-sonet-4") + expect(error.message).toContain("Did you mean:") }), ) @@ -1646,8 +1762,11 @@ it.instance( // Tests that need plugin file setup or multi-instance flows fall back to a // scoped tmpdir + provideInstance pattern via it.effect. +const instanceStoreLayer = LayerNode.compile(InstanceStore.node, [ + [InstanceStore.bootstrapNode, InstanceBootstrap.node], +]) const provideMultiInstance = (eff: Effect.Effect) => - eff.pipe(Effect.provide(InstanceLayer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)) + eff.pipe(Effect.provide(instanceStoreLayer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))) it.effect("plugin config providers persist after instance dispose", () => Effect.gen(function* () { @@ -1750,7 +1869,7 @@ it.effect("opencode loader keeps paid models when config apiKey is present", () Provider.use .list() .pipe(provideInstanceEffect(directory)) - .pipe(Effect.provide(InstanceLayer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)) + .pipe(Effect.provide(instanceStoreLayer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))) const none = paid(yield* listIn(noneDir)) const keyedCount = paid(yield* listIn(keyedDir)) @@ -1769,7 +1888,7 @@ it.effect("opencode loader keeps paid models when auth exists", () => Provider.use .list() .pipe(provideInstanceEffect(directory)) - .pipe(Effect.provide(InstanceLayer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)) + .pipe(Effect.provide(instanceStoreLayer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))) const none = paid(yield* listIn(noneDir)) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 5fa530155d..912dbb13cb 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -4,6 +4,7 @@ import { ProviderTransform } from "@/provider/transform" import { LLMRequestPrep } from "@/session/llm/request" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { jsonSchema } from "ai" describe("ProviderTransform.options - setCacheKey", () => { const sessionID = "test-session-123" @@ -384,7 +385,12 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { } as any, system: [], messages: [{ role: "user", content: "Hello" }], - tools: {}, + tools: { + lookup: { + description: "Look up a value", + inputSchema: jsonSchema({ type: "object", properties: {} }), + }, + }, provider: { id: "azure", options: { useCompletionUrls: true } } as any, auth: undefined, plugin: { @@ -399,6 +405,7 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { expect(result.params.options.reasoningEffort).toBe("high") expect(result.params.options.reasoningSummary).toBeUndefined() expect(result.params.options.include).toBeUndefined() + expect(result.tools.lookup.strict).toBe(false) }) test("gpt-5.1 should have textVerbosity set to low", () => { @@ -606,6 +613,84 @@ describe("ProviderTransform.providerOptions", () => { }) }) + test("forces reasoning for custom OpenAI package models with explicit effort", () => { + const model = createModel({ + providerID: "meta", + api: { + id: "muse-spark", + url: "https://api.ai.meta.com/v1", + npm: "@ai-sdk/openai", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh", reasoningSummary: "auto" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh", reasoningSummary: "auto" }, + }) + }) + + test("forces reasoning for OpenAI package models marked reasoning-capable", () => { + expect(ProviderTransform.providerOptions(createModel(), { store: false })).toEqual({ + openai: { forceReasoning: true, store: false }, + }) + }) + + test("forces reasoning for explicit effort even when model is not marked reasoning-capable", () => { + const model = createModel({ + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + + test("forces reasoning for Azure OpenAI models with explicit effort", () => { + const model = createModel({ + providerID: "azure", + api: { + id: "custom-gpt-5-deployment", + url: "https://azure.openai.example.com/openai/v1", + npm: "@ai-sdk/azure", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + azure: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + + test("forces reasoning for Bedrock Mantle OpenAI models with explicit effort", () => { + const model = createModel({ + providerID: "amazon-bedrock", + api: { + id: "openai.gpt-5-custom", + url: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + npm: "@ai-sdk/amazon-bedrock/mantle", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + + test("overrides forceReasoning false when reasoning should be forced", () => { + expect( + ProviderTransform.providerOptions(createModel(), { forceReasoning: false, reasoningEffort: "xhigh" }), + ).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + test("uses gateway model provider slug for gateway models", () => { const model = createModel({ providerID: "vercel", @@ -700,7 +785,7 @@ describe("ProviderTransform.providerOptions", () => { }) expect(ProviderTransform.providerOptions(model, { reasoningEffort: "medium" })).toEqual({ - openai: { reasoningEffort: "medium" }, + openai: { forceReasoning: true, reasoningEffort: "medium" }, }) }) @@ -2261,6 +2346,82 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBe("encrypted") }) + test("strips GitHub Copilot itemId from the copilot namespace, preserving other copilot options", () => { + const copilotModel = { + ...openaiModel, + id: "github-copilot/gpt-5.5", + providerID: "github-copilot", + api: { + id: "gpt-5.5", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thinking...", + providerOptions: { + copilot: { itemId: "rs_123", reasoningEncryptedContent: "encrypted" }, + }, + }, + { + // The stale itemId on tool-call parts is what Copilot echoes back as the + // `function_call` item `id`, which is what the upstream connection rejects. + type: "tool-call", + toolCallId: "call_1", + toolName: "bash", + input: { command: "ls" }, + providerOptions: { + copilot: { itemId: "fc_456", reasoningEffort: "medium" }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, copilotModel, { store: false }) as any[] + + expect(result[0].content[0].providerOptions?.copilot?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.copilot?.reasoningEncryptedContent).toBe("encrypted") + expect(result[0].content[1].providerOptions?.copilot?.itemId).toBeUndefined() + expect(result[0].content[1].providerOptions?.copilot?.reasoningEffort).toBe("medium") + }) + + test("leaves a stray openai namespace on a Copilot model untouched, since Copilot's Responses model only reads the copilot namespace", () => { + const copilotModel = { + ...openaiModel, + id: "github-copilot/gpt-5.5", + providerID: "github-copilot", + api: { + id: "gpt-5.5", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + openai: { itemId: "msg_456" }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, copilotModel, { store: false }) as any[] + + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_456") + }) + test("preserves metadata for openai package when store is true", () => { const msgs = [ { @@ -3283,6 +3444,27 @@ describe("ProviderTransform.variants", () => { }) }) + test("anthropic sonnet 5 returns adaptive thinking options with xhigh", () => { + const model = createMockModel({ + id: "anthropic/claude-sonnet-5", + providerID: "gateway", + api: { + id: "anthropic/claude-sonnet-5", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) + test("anthropic opus 4.6 omits display so it keeps the summarized default", () => { const model = createMockModel({ id: "anthropic/claude-opus-4-6", @@ -3870,6 +4052,12 @@ describe("ProviderTransform.variants", () => { efforts: ["low", "medium", "high", "xhigh", "max"], expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, }, + { + name: "sonnet 5", + apiIds: ["claude-sonnet-5", "claude-sonnet-5-20260630"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, { name: "fable 5", apiIds: ["claude-fable-5"], @@ -3967,6 +4155,28 @@ describe("ProviderTransform.variants", () => { effort: "high", }) }) + + test("sonnet 5 uses adaptive reasoning for Vertex model IDs", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "google-vertex-anthropic/claude-sonnet-5@default", + providerID: "google-vertex-anthropic", + api: { + id: "claude-sonnet-5@default", + url: "https://us-central1-aiplatform.googleapis.com", + npm: "@ai-sdk/google-vertex/anthropic", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) }) describe("@ai-sdk/amazon-bedrock", () => { @@ -4040,6 +4250,28 @@ describe("ProviderTransform.variants", () => { }) }) + test("anthropic sonnet 5 returns adaptive reasoning options with xhigh", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "bedrock/anthropic-claude-sonnet-5", + providerID: "bedrock", + api: { + id: "anthropic.claude-sonnet-5", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }) + }) + test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => { const model = createMockModel({ id: "bedrock/llama-4", @@ -4222,6 +4454,12 @@ describe("ProviderTransform.variants", () => { efforts: ["low", "medium", "high", "xhigh", "max"], thinking: { type: "adaptive", display: "summarized" }, }, + { + name: "sonnet 5", + apiIds: ["anthropic--claude-sonnet-5", "anthropic--claude-5-sonnet"], + efforts: ["low", "medium", "high", "xhigh", "max"], + thinking: { type: "adaptive", display: "summarized" }, + }, ]) { for (const apiId of testCase.apiIds) { test(`${testCase.name} ${apiId} returns adaptive thinking variants under modelParams`, () => { diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 5ae076b439..9adb1e0c9f 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit, Fiber, Layer, Queue } from "effect" import { Question } from "../../src/question" import { InstanceRef } from "../../src/effect/instance-ref" @@ -10,16 +11,9 @@ import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { EventV2Bridge } from "../../src/event-v2-bridge" -const it = testEffect( - Layer.mergeAll(Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), CrossSpawnSpawner.defaultLayer), -) -const lifecycle = testEffect( - Layer.mergeAll( - Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), - CrossSpawnSpawner.defaultLayer, - testInstanceStoreLayer, - ), -) +const questionLayer = LayerNode.compile(LayerNode.group([Question.node, EventV2Bridge.node, CrossSpawnSpawner.node])) +const it = testEffect(questionLayer) +const lifecycle = testEffect(Layer.mergeAll(questionLayer, testInstanceStoreLayer)) const askEffect = Effect.fn("QuestionTest.ask")(function* (input: { sessionID: SessionID diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index 21faa82fe3..7a13553af6 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -1,4 +1,6 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { Deferred, Effect, Layer } from "effect" import { Project } from "@/project/project" import { Session as SessionNs } from "@/session/session" @@ -6,7 +8,9 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, Project.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect( + LayerNode.compile(LayerNode.group([SessionNs.node, SessionProjector.node, Project.node, CrossSpawnSpawner.node])), +) const withSession = (input?: Parameters[0]) => Effect.acquireRelease(SessionNs.use.create(input), (created) => diff --git a/packages/opencode/test/server/httpapi-authorization.test.ts b/packages/opencode/test/server/httpapi-authorization.test.ts index 2fb6c4ac1b..211b2e1884 100644 --- a/packages/opencode/test/server/httpapi-authorization.test.ts +++ b/packages/opencode/test/server/httpapi-authorization.test.ts @@ -56,9 +56,9 @@ const v2ApiLayer = HttpRouter.serve( { disableListenLog: true, disableLogger: true }, ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)) -const noAuthLayer = ServerAuth.Config.layer({ password: Option.none(), username: "opencode" }) -const secretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "opencode" }) -const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "kit" }) +const noAuthLayer = ServerAuth.Config.configLayer({ password: Option.none(), username: "opencode" }) +const secretLayer = ServerAuth.Config.configLayer({ password: Option.some("secret"), username: "opencode" }) +const kitSecretLayer = ServerAuth.Config.configLayer({ password: Option.some("secret"), username: "kit" }) const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer))) const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer))) diff --git a/packages/opencode/test/server/httpapi-control-plane.test.ts b/packages/opencode/test/server/httpapi-control-plane.test.ts index 2ddbe532cb..b837bf7556 100644 --- a/packages/opencode/test/server/httpapi-control-plane.test.ts +++ b/packages/opencode/test/server/httpapi-control-plane.test.ts @@ -44,7 +44,7 @@ const apiLayer = HttpRouter.serve( moveSession: (value) => Ref.set(called, value), }), ), - Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })), + Layer.provide(ServerAuth.Config.configLayer({ password: Option.none(), username: "opencode" })), ) const it = testEffect(apiLayer) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 56e6d2e8b8..4f22835648 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -734,11 +734,11 @@ const scenarios: Scenario[] = [ .stream() .status( 200, - (ctx, result) => + (_ctx, result) => Effect.sync(() => { check(result.contentType.includes("text/event-stream"), "v2 event should be an SSE stream") check(result.text.includes("server.connected"), "v2 event should emit initial connection event") - check(!!ctx.directory && result.text.includes(ctx.directory), "v2 event should include the resolved location") + check(!result.text.includes('"location"'), "v2 connection event should not be scoped to a location") }), "status", ), @@ -814,6 +814,20 @@ const scenarios: Scenario[] = [ object(body.location) array(body.data) }), + http.protected + .post("/api/session/{sessionID}/permission", "v2.session.permission.create") + .seeded((ctx) => ctx.session({ title: "Permission create owner" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/permission", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { action: "read", resources: [".env"] }, + })) + .json(200, (body) => { + object(body) + object(body.data) + check(typeof body.data.id === "string", "permission create should return an ID") + check(body.data.effect === "ask", "permission create should create a pending request") + }), http.protected .get("/api/session/{sessionID}/permission", "v2.session.permission.list") .seeded((ctx) => ctx.session({ title: "Permission list owner" })) @@ -822,6 +836,17 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .json(200, data(array)), + http.protected + .get("/api/session/{sessionID}/permission/{requestID}", "v2.session.permission.get") + .seeded((ctx) => ctx.session({ title: "Permission get owner" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/permission/{requestID}", { + sessionID: ctx.state.id, + requestID: "per_httpapi_missing", + }), + headers: ctx.headers(), + })) + .json(404, object, "status"), http.protected .get("/api/session/{sessionID}/question", "v2.session.question.list") .seeded((ctx) => ctx.session({ title: "Question list owner" })) @@ -939,6 +964,7 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .status(400, undefined, "none"), + http.protected.get("/api/session/active", "v2.session.active").json(200, data(object), "none"), http.protected .post("/api/session", "v2.session.create") .at((ctx) => ({ @@ -955,6 +981,24 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .json(200, data(object)), + http.protected + .post("/api/session/{sessionID}/agent", "v2.session.switchAgent") + .seeded((ctx) => ctx.session({ title: "Switch agent" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/agent", { sessionID: ctx.state.id }), + headers: { ...ctx.headers(), "content-type": "application/json" }, + body: { agent: "plan" }, + })) + .status(204, undefined, "none"), + http.protected + .post("/api/session/{sessionID}/model", "v2.session.switchModel") + .seeded((ctx) => ctx.session({ title: "Switch model" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/model", { sessionID: ctx.state.id }), + headers: { ...ctx.headers(), "content-type": "application/json" }, + body: { model: { providerID: "opencode", id: "big-pickle" } }, + })) + .status(204, undefined, "none"), http.protected .get("/api/session/{sessionID}/context", "v2.session.context") .at((ctx) => ({ @@ -962,6 +1006,28 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .json(404, object, "status"), + http.protected + .post("/api/session/{sessionID}/revert/stage", "v2.session.revert.stage") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/revert/stage", { sessionID: "ses_httpapi_missing" }), + headers: { ...ctx.headers(), "content-type": "application/json" }, + body: { messageID: "msg_httpapi_missing" }, + })) + .json(404, object, "status"), + http.protected + .post("/api/session/{sessionID}/revert/clear", "v2.session.revert.clear") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/revert/clear", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .json(404, object, "status"), + http.protected + .post("/api/session/{sessionID}/revert/commit", "v2.session.revert.commit") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/revert/commit", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .json(404, object, "status"), http.protected .get("/api/session/{sessionID}/message", "v2.session.messages") .at((ctx) => ({ @@ -1001,6 +1067,65 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .status(400, undefined, "none"), + http.protected + .get("/api/session/{sessionID}/history", "v2.session.history") + .seeded((ctx) => ctx.session({ title: "Session history" })) + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?${new URLSearchParams({ + after: "0", + limit: "2", + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.data) + check(typeof body.hasMore === "boolean", "Expected a history exhaustion signal") + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/history", "v2.session.history.missing") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/history", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .json(404, object, "status"), + http.protected + .get("/api/session/{sessionID}/history", "v2.session.history.invalid") + .seeded((ctx) => ctx.session({ title: "Invalid history sequence" })) + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?after=-1`, + headers: ctx.headers(), + })) + .json(400, object, "status"), + http.protected + .get("/api/session/{sessionID}/event", "v2.session.events.missing") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/event", { sessionID: "ses_httpapi_missing" })}?after=0`, + headers: ctx.headers(), + })) + .status(404, undefined, "status"), + http.protected + .post("/api/session/{sessionID}/interrupt", "v2.session.interrupt") + .seeded((ctx) => ctx.session({ title: "Interrupt session" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/interrupt", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .status(204, undefined, "none"), + http.protected + .get("/api/session/{sessionID}/message/{messageID}", "v2.session.message.missing") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/message/{messageID}", { + sessionID: "ses_httpapi_missing", + messageID: "msg_httpapi_missing", + }), + headers: ctx.headers(), + })) + .json(404, object, "status"), http.protected .post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid") .seeded((ctx) => ctx.session({ title: "Invalid prompt owner" })) @@ -1130,7 +1255,7 @@ const scenarios: Scenario[] = [ .seeded((ctx) => Effect.gen(function* () { const session = yield* ctx.session({ title: "Todo session" }) - const todos = [{ content: "cover session todo", status: "pending", priority: "high" }] + const todos = [{ content: "cover session todo", status: "pending" as const, priority: "high" as const }] yield* ctx.todos(session.id, todos) return { session, todos } }), diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index 0b36946993..acc1898e32 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -119,5 +119,9 @@ export type Result = | { status: "skip"; scenario: TodoScenario } export type SessionInfo = { id: SessionID; title: string; parentID?: SessionID } -export type TodoInfo = { content: string; status: string; priority: string } +export type TodoInfo = { + content: string + status: "pending" | "in_progress" | "completed" | "cancelled" + priority: "high" | "medium" | "low" +} export type MessageSeed = { info: SessionV1.User; part: SessionV1.TextPart } diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 6b87039f04..1717164356 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Fiber, Layer } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" @@ -15,7 +16,7 @@ import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer)) const testWorktreeMutations = process.platform === "win32" ? it.instance.skip : it.instance function request(path: string, directory: string, init: RequestInit = {}) { diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index 899eda7a33..c5ccc1ca4c 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" -import { Context } from "effect" +import { Context, Effect } from "effect" import path from "path" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { pollWithTimeout } from "../lib/effect" const context = Context.empty() as Context.Context @@ -55,17 +56,26 @@ describe("file HttpApi", () => { await using tmp = await tmpdir({ git: true }) await Bun.write(path.join(tmp.path, "hello.txt"), "needle") - const [text, files, symbols] = await Promise.all([ + const [text, symbols] = await Promise.all([ request(FilePaths.findText, tmp.path, { pattern: "needle" }), - request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }), request(FilePaths.findSymbol, tmp.path, { query: "hello" }), ]) + const files = await Effect.runPromise( + pollWithTimeout( + Effect.promise(async () => { + const response = await request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }) + const body = await response.json() + return body.includes("hello.txt") ? { response, body } : undefined + }), + "file search index was not ready", + ), + ) expect(text.status).toBe(200) expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 })) - expect(files.status).toBe(200) - expect(await files.json()).toContain("hello.txt") + expect(files.response.status).toBe(200) + expect(files.body).toContain("hello.txt") expect(symbols.status).toBe(200) expect(await symbols.json()).toEqual([]) diff --git a/packages/opencode/test/server/httpapi-global.test.ts b/packages/opencode/test/server/httpapi-global.test.ts index 91c5b48314..bcbe7aecbb 100644 --- a/packages/opencode/test/server/httpapi-global.test.ts +++ b/packages/opencode/test/server/httpapi-global.test.ts @@ -38,7 +38,7 @@ const apiLayer = HttpRouter.serve( upgrade: () => Effect.void, }), ), - Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })), + Layer.provide(ServerAuth.Config.configLayer({ password: Option.none(), username: "opencode" })), ) const it = testEffect(apiLayer) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 0b0752eac9..263e2c472b 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -8,11 +8,9 @@ import { mkdir } from "node:fs/promises" import path from "node:path" import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceV2 } from "@opencode-ai/core/workspace" -import { Ripgrep } from "@opencode-ai/core/ripgrep" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { InstanceLayer } from "../../src/project/instance-layer" import { Project } from "../../src/project/project" import { Session } from "../../src/session/session" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" @@ -46,16 +44,7 @@ const testStateLayer = Layer.effectDiscard( const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true }) -const it = testEffect( - Layer.mergeAll( - testStateLayer, - NodeHttpServer.layerTest, - NodeServices.layer, - InstanceLayer.layer, - Project.defaultLayer, - workspaceLayer, - ).pipe(Layer.provide(Ripgrep.defaultLayer)), -) +const it = testEffect(Layer.mergeAll(testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, workspaceLayer)) const instanceContextTestLayer = Layer.mergeAll( instanceContextLayer, diff --git a/packages/opencode/test/server/httpapi-promptasync-context.test.ts b/packages/opencode/test/server/httpapi-promptasync-context.test.ts index eb86407716..044a77fd22 100644 --- a/packages/opencode/test/server/httpapi-promptasync-context.test.ts +++ b/packages/opencode/test/server/httpapi-promptasync-context.test.ts @@ -16,11 +16,9 @@ import * as Socket from "effect/unstable/socket/Socket" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" import { mkdir } from "node:fs/promises" import { registerAdapter } from "../../src/control-plane/adapters" -import { Ripgrep } from "@opencode-ai/core/ripgrep" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { InstanceLayer } from "../../src/project/instance-layer" import { Project } from "../../src/project/project" import { Session } from "../../src/session/session" import { @@ -51,16 +49,7 @@ const testStateLayer = Layer.effectDiscard( const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true }) -const it = testEffect( - Layer.mergeAll( - testStateLayer, - NodeHttpServer.layerTest, - NodeServices.layer, - InstanceLayer.layer, - Project.defaultLayer, - workspaceLayer, - ).pipe(Layer.provide(Ripgrep.defaultLayer)), -) +const it = testEffect(Layer.mergeAll(testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, workspaceLayer)) const instanceContextTestLayer = Layer.mergeAll( instanceContextLayer, diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 0d9649dd86..9ae3c5741b 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Effect, Layer } from "effect" import path from "path" @@ -15,7 +16,7 @@ const testStateLayer = Layer.effectDiscard( ), ) -const it = testEffect(Layer.mergeAll(testStateLayer, FSUtil.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(testStateLayer, LayerNode.compile(FSUtil.node), httpApiLayer)) const projectOptions = { config: { formatter: false, lsp: false } } const providerID = "test-oauth-parity" const oauthURL = "https://example.com/oauth" diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index d13d19c506..310ebae595 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -10,6 +10,8 @@ type OpenApiSchema = { readonly enum?: readonly unknown[] readonly properties?: Record readonly required?: readonly string[] + readonly contentSchema?: OpenApiSchema + readonly contentMediaType?: string } type OpenApiResponse = { readonly description?: string @@ -68,6 +70,20 @@ function isBuiltInEndpointError(name: string) { } describe("PublicApi OpenAPI v2 errors", () => { + test("includes plugin-facing core schemas", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + expect(Object.keys(spec.components.schemas)).toEqual( + expect.arrayContaining([ + "CredentialValue", + "IntegrationInputs", + "IntegrationMethod", + "IntegrationRef", + "SkillV2Source", + ]), + ) + }) + test("documents nested legacy global sync events", () => { const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec const schema = spec.components.schemas.SyncEventSessionCreated @@ -85,6 +101,21 @@ describe("PublicApi OpenAPI v2 errors", () => { }) }) + test("names the v2 event union without the SSE string wrapper collision", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + expect(spec.components.schemas.V2Event1).toBeUndefined() + expect(spec.components.schemas.V2Event?.anyOf?.length).toBeGreaterThan(0) + expect(spec.components.schemas.V2EventStream).toMatchObject({ + type: "string", + contentMediaType: "application/json", + contentSchema: { $ref: "#/components/schemas/V2Event" }, + }) + expect(spec.paths["/api/event"]?.get?.responses?.["200"]?.content?.["text/event-stream"]?.schema).toEqual({ + $ref: "#/components/schemas/V2Event", + }) + }) + test("preserves /api auth responses", () => { const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts index 655e40bf98..9bee35c5d3 100644 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -24,7 +24,7 @@ import { SessionPaths, } from "../../src/server/routes/instance/httpapi/groups/session" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" -import { SessionMessagesQuery } from "@opencode-ai/server/groups/message" +import { SessionMessagesQuery } from "@opencode-ai/protocol/groups/message" import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/server/httpapi-reference.test.ts b/packages/opencode/test/server/httpapi-reference.test.ts index ae6ad49ef2..1579c2ee68 100644 --- a/packages/opencode/test/server/httpapi-reference.test.ts +++ b/packages/opencode/test/server/httpapi-reference.test.ts @@ -4,6 +4,8 @@ import { Server } from "../../src/server/server" import { Global } from "@opencode-ai/core/global" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { Effect } from "effect" +import { pollWithTimeout } from "../lib/effect" afterEach(async () => { await disposeAllInstances() @@ -24,37 +26,36 @@ describe("reference HttpApi", () => { }, }) - const response = await Server.Default().app.request("/api/reference", { - headers: { "x-kilo-directory": tmp.path }, - }) - - expect(response.status).toBe(200) - const body = await response.json() + const body = await Effect.runPromise( + pollWithTimeout( + Effect.promise(async () => { + const response = await Server.Default().app.request("/api/reference", { + headers: { "x-kilo-directory": tmp.path }, + }) + expect(response.status).toBe(200) + const body = await response.json() + return body.data.length === 0 ? undefined : body + }), + "references were not loaded", + ), + ) expect(body).toMatchObject({ location: { directory: tmp.path } }) expect(body.data).toEqual([ { name: "docs", path: path.join(tmp.path, "docs"), - description: null, - hidden: null, source: { type: "local", path: path.join(tmp.path, "docs"), - description: null, - hidden: null, }, }, { name: "effect", path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"), - description: null, - hidden: null, source: { type: "git", repository: "Effect-TS/effect", branch: "main", - description: null, - hidden: null, }, }, ]) diff --git a/packages/opencode/test/server/httpapi-schema-error-body.test.ts b/packages/opencode/test/server/httpapi-schema-error-body.test.ts index c650b3772a..4ea6fd38e8 100644 --- a/packages/opencode/test/server/httpapi-schema-error-body.test.ts +++ b/packages/opencode/test/server/httpapi-schema-error-body.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { HttpClientResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" @@ -16,7 +17,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer)) const text = (response: HttpClientResponse.HttpClientResponse) => response.text diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 181b32a96d..de21b2499b 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -5,12 +5,14 @@ import { Deferred, Effect, Layer } from "effect" import type * as Scope from "effect/Scope" import { HttpServer } from "effect/unstable/http" import { ChildProcessSpawner } from "effect/unstable/process" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Flag } from "@opencode-ai/core/flag/flag" import { createKiloClient } from "@kilocode/sdk/v2" import { validateSession } from "../../src/cli/tui/validate-session" -import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" @@ -22,23 +24,19 @@ import { TestLLMServer } from "../lib/llm-server" import path from "path" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture" -import { awaitWithTimeout, testEffect } from "../lib/effect" +import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect" import { testProviderConfig } from "../lib/test-provider" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Database } from "@opencode-ai/core/database/database" import { httpApiLayer } from "./httpapi-layer" -const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const it = testEffect( - Layer.mergeAll( - FSUtil.defaultLayer, - CrossSpawnSpawner.defaultLayer, - InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)), - Database.defaultLayer, - httpApiLayer, - ), +const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const appLayer = AppNodeBuilder.build( + LayerNode.group([FSUtil.node, CrossSpawnSpawner.node, InstanceStore.node, Database.node, SessionNs.node]), + [[InstanceStore.bootstrapNode, noopBootstrapLayer]], ) +const it = testEffect(Layer.mergeAll(appLayer, httpApiLayer)) const original = { KILO_SERVER_PASSWORD: Flag.KILO_SERVER_PASSWORD, @@ -55,6 +53,7 @@ type TestServices = | FSUtil.Service | ChildProcessSpawner.ChildProcessSpawner | InstanceStore.Service + | SessionNs.Service | HttpServer.HttpServer type TestScope = Scope.Scope | TestServices @@ -323,7 +322,7 @@ function seedMessage(directory: string, sessionID: string) { }) return { message, part } }), - ).pipe(Effect.provide(SessionNs.defaultLayer)), + ), ), ) } @@ -389,7 +388,12 @@ describe("HttpApi SDK", () => { workspaceID, onRequest: (value) => (request = value), }) - const found = yield* call(() => sdk.v2.fs.find({ query: "hello", type: "file" })) + const found = yield* pollWithTimeout( + call(() => sdk.v2.fs.find({ query: "hello", type: "file" })).pipe( + Effect.map((result) => (result.data?.data.length ? result : undefined)), + ), + "SDK file search index was not ready", + ) const url = new URL(request!.url) expect(found.response.status).toBe(200) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index be9b92dad7..b2d096d022 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -7,6 +7,8 @@ import path from "node:path" import { Cause, Config, Effect, Exit, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServer } from "effect/unstable/http" import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Flag } from "@opencode-ai/core/flag/flag" import { Ripgrep } from "@opencode-ai/core/ripgrep" @@ -14,7 +16,6 @@ import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" -import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service" import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" @@ -23,7 +24,6 @@ import * as HttpSessionError from "../../src/server/routes/instance/httpapi/hand import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema" -import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@opencode-ai/core/database/database" import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionMessage } from "@opencode-ai/core/session/message" @@ -35,17 +35,16 @@ import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideInstanceEffect, TestInstance, tmpdirScoped } from "../fixture/fixture" import { TestLLMServer } from "../lib/llm-server" import { testProviderConfig } from "../lib/test-provider" -import { testEffect } from "../lib/effect" +import { pollWithTimeout, testEffect } from "../lib/effect" const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES -const workspaceLayer = Workspace.defaultLayer.pipe( - Layer.provide(InstanceStore.defaultLayer), - Layer.provide(InstanceBootstrap.defaultLayer), +const noopBootstrapLayer = Layer.succeed( + InstanceBootstrapService.Service, + InstanceBootstrapService.Service.of({ run: Effect.void }), ) -const instanceStoreLayer = InstanceStore.defaultLayer.pipe( - Layer.provide( - Layer.succeed(InstanceBootstrapService.Service, InstanceBootstrapService.Service.of({ run: Effect.void })), - ), +const appLayer = AppNodeBuilder.build( + LayerNode.group([InstanceStore.node, Project.node, Session.node, Workspace.node, Database.node, Ripgrep.node]), + [[InstanceStore.bootstrapNode, noopBootstrapLayer]], ) const servedRoutes: Layer.Layer = HttpRouter.serve( HttpApiApp.routes, @@ -59,16 +58,7 @@ const httpApiLayer = servedRoutes.pipe( Layer.provideMerge(NodeHttpServer.layerTest), Layer.provideMerge(NodeServices.layer), ) -const it = testEffect( - Layer.mergeAll( - instanceStoreLayer, - Project.defaultLayer, - Session.defaultLayer, - workspaceLayer, - Database.defaultLayer, - httpApiLayer, - ).pipe(Layer.provide(Ripgrep.defaultLayer)), -) +const it = testEffect(Layer.mergeAll(appLayer, httpApiLayer)) function pathFor(path: string, params: Record) { return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path) @@ -129,7 +119,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri const insertLegacyAssistantMessage = (sessionID: SessionIDType, seq = 1, time = seq) => Effect.gen(function* () { - const message = new SessionMessage.Assistant({ + const message = SessionMessage.Assistant.make({ id: SessionMessage.ID.create(), type: "assistant", agent: "build", @@ -433,7 +423,7 @@ describe("session HttpApi", () => { cwd: sessionDirectory, root: sessionDirectory, }) - }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))), ) it.instance( @@ -582,7 +572,7 @@ describe("session HttpApi", () => { request(`/api/session/${session.id}/prompt`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" } }), + body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" }, resume: false }), }) const first = yield* recordPrompt() const retried = yield* recordPrompt() @@ -625,6 +615,22 @@ describe("session HttpApi", () => { message: "Prompt message ID conflicts with an existing durable record: msg_http_prompt", resource: "msg_http_prompt", }) + + const wakeID = SessionMessage.ID.make("msg_http_wake") + const wake = yield* request(`/api/session/${session.id}/prompt`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ id: wakeID, prompt: { text: "hello again" } }), + }) + expect(wake.status).toBe(200) + const message = yield* pollWithTimeout( + requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${session.id}/message`, { headers }).pipe( + Effect.map(({ data }) => data.find((message) => message.id === wakeID)), + ), + "V2 prompt was not promoted after wake", + "10 seconds", + ) + expect(message).toMatchObject({ id: wakeID, type: "user" }) }), { git: true, config: { formatter: false, lsp: false } }, ) @@ -853,7 +859,7 @@ describe("session HttpApi", () => { pathSession: yield* createSession(), pathlessSession: yield* createSession(), } - }).pipe(Effect.provideService(TestInstance, { directory: currentDir }), Effect.provide(Session.defaultLayer)), + }).pipe(Effect.provideService(TestInstance, { directory: currentDir })), ) yield* clearSessionPath(pathlessSession.id) diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index 62654942cd..1f9edc194e 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, mock } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Context, Effect, Layer } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" @@ -11,7 +12,7 @@ import { httpApiLayer, requestInDirectory } from "./httpapi-layer" const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES const context = Context.empty() as Context.Context -const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(Session.node), httpApiLayer)) afterEach(async () => { mock.restore() diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 017d15225a..fca347a4f6 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -1,7 +1,8 @@ import { createHash } from "node:crypto" import { describe, expect } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" -import { ConfigProvider, Effect, Layer } from "effect" +import { ConfigProvider, Effect, Layer, Option } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { HttpClient, HttpClientRequest, @@ -39,7 +40,15 @@ const testStateLayer = Layer.effectDiscard( }), ) -const it = testEffect(Layer.mergeAll(testStateLayer, FSUtil.defaultLayer, RuntimeFlags.layer())) +const fsUtilLayer = AppNodeBuilder.build(FSUtil.node) +const it = testEffect(Layer.mergeAll(testStateLayer, fsUtilLayer, RuntimeFlags.layer())) + +function authConfigLayer(input?: { password?: string; username?: string }) { + return ServerAuth.Config.configLayer({ + password: input?.password === undefined ? Option.none() : Option.some(input.password), + username: input?.username ?? "opencode", + }) +} function restoreEnv(key: string, value: string | undefined) { if (value === undefined) { @@ -94,18 +103,12 @@ function uiApp(input?: { ) }), ).pipe( - Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))), + Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(authConfigLayer(input)))), Layer.provide([ - FSUtil.defaultLayer, + fsUtilLayer, input?.client ?? httpClient(new Response("ui")), RuntimeFlags.layer({ disableEmbeddedWebUi: input?.disableEmbeddedWebUi ?? false }), HttpServer.layerServices, - ConfigProvider.layer( - ConfigProvider.fromUnknown({ - KILO_SERVER_PASSWORD: input?.password, - KILO_SERVER_USERNAME: input?.username, - }), - ), ]), ), { disableLogger: true }, @@ -141,7 +144,7 @@ function routeOrderingApp() { }), ).pipe( Layer.provide([ - FSUtil.defaultLayer, + fsUtilLayer, RuntimeFlags.layer({ disableEmbeddedWebUi: true }), httpClient(new Response("ui"), (request) => { proxiedUrl = request.url diff --git a/packages/opencode/test/server/httpapi-v2-location.test.ts b/packages/opencode/test/server/httpapi-v2-location.test.ts index 349f29e3a3..fe4826c3f9 100644 --- a/packages/opencode/test/server/httpapi-v2-location.test.ts +++ b/packages/opencode/test/server/httpapi-v2-location.test.ts @@ -1,4 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" import { Context, Schema } from "effect" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { resetDatabase } from "../fixture/db" @@ -19,22 +21,50 @@ function request(route: string, directory: string, init: RequestInit = {}) { } const Event = Schema.Struct({ - id: Schema.String, + id: EventV2.ID, type: Schema.String, - location: Schema.Struct({ - directory: Schema.String, - project: Schema.Struct({ id: Schema.String, directory: Schema.String }), - }), + location: Schema.optional(Location.Ref), data: Schema.Unknown, }) -async function readEvent(reader: ReadableStreamDefaultReader) { - const value = await reader.read() - if (value.done) throw new Error("event stream closed") - return Schema.decodeUnknownSync(Event)(JSON.parse(new TextDecoder().decode(value.value).replace(/^data: /, ""))) +async function* eventStream(body: ReadableStream) { + const reader = body.getReader() + const decoder = new TextDecoder() + let buffer = "" + try { + while (true) { + const boundary = buffer.match(/(?:\r\n|\r|\n){2}/) + if (!boundary || boundary.index === undefined) { + const value = await reader.read() + if (value.done) return + buffer += decoder.decode(value.value, { stream: true }) + continue + } + + const record = buffer.slice(0, boundary.index) + buffer = buffer.slice(boundary.index + boundary[0].length) + const data = record + .split(/\r\n|\r|\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).replace(/^ /, "")) + if (data.length) yield Schema.decodeUnknownSync(Event)(JSON.parse(data.join("\n"))) + } + } finally { + try { + await reader.cancel() + } finally { + reader.releaseLock() + } + } } -async function readEventType(reader: ReadableStreamDefaultReader, type: string) { +async function readEvent(reader: AsyncIterator) { + const value = await reader.next() + if (value.done) throw new Error("event stream closed") + return value.value +} + +async function readEventType(reader: AsyncIterator, type: string) { for (let index = 0; index < 20; index++) { const event = await readEvent(reader) if (event.type === type) return event @@ -48,6 +78,17 @@ afterEach(async () => { }) describe("v2 location HttpApi", () => { + test("decodes EventV2 location refs without resolved project metadata", () => { + expect( + Schema.decodeUnknownSync(Event)({ + id: "evt_test", + type: "file.watcher.updated", + location: { directory: "/tmp/project" }, + data: {}, + }), + ).toMatchObject({ location: { directory: "/tmp/project" } }) + }) + test("returns command and skill snapshots with resolved locations", async () => { await using tmp = await tmpdir({ git: true }) @@ -64,19 +105,22 @@ describe("v2 location HttpApi", () => { } }) - test("streams native EventV2 payloads with resolved locations", async () => { - await using tmp = await tmpdir({ git: true }) - const response = await request("/api/event", tmp.path) - const reader = response.body!.getReader() - expect((await readEvent(reader)).type).toBe("server.connected") + test("streams native EventV2 payloads across locations", async () => { + await using subscriber = await tmpdir({ git: true }) + await using publisher = await tmpdir({ git: true }) + const response = await request("/api/event", subscriber.path) + const reader = eventStream(response.body!) + const connected = await readEvent(reader) + expect(connected.type).toBe("server.connected") + expect(connected.location).toBeUndefined() - const created = await request("/session", tmp.path, { method: "POST" }) + const created = await request("/session", publisher.path, { method: "POST" }) expect(created.status).toBe(200) expect(await readEventType(reader, "session.created")).toMatchObject({ type: "session.created", - location: { directory: tmp.path, project: { directory: tmp.path } }, + location: { directory: publisher.path }, data: { sessionID: expect.any(String) }, }) - await reader.cancel() + await reader.return(undefined) }) }) diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index 8720c3eca6..6e3911f9a6 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -21,7 +21,6 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import { Database } from "@opencode-ai/core/database/database" -import { Ripgrep } from "@opencode-ai/core/ripgrep" import { Project } from "../../src/project/project" import { Session } from "../../src/session/session" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" @@ -55,11 +54,9 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, - Database.defaultLayer, - Project.defaultLayer, workspaceLayer, Socket.layerWebSocketConstructorGlobal, - ).pipe(Layer.provide(Ripgrep.defaultLayer)), + ), ) type ProxiedRequest = { diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index a34c028687..872dee865c 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, mock } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" import { Effect, Layer, Stream } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Flag } from "@opencode-ai/core/flag/flag" import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceV2 } from "@opencode-ai/core/workspace" @@ -23,20 +25,11 @@ import { testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES -const workspaceLayer = Workspace.defaultLayer.pipe( - Layer.provide(InstanceStore.defaultLayer), - Layer.provide(InstanceBootstrap.defaultLayer), -) -const it = testEffect( - Layer.mergeAll( - Project.defaultLayer, - Session.defaultLayer, - workspaceLayer, - InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)), - Database.defaultLayer, - httpApiLayer, - ).pipe(Layer.provide(Ripgrep.defaultLayer)), +const appLayer = AppNodeBuilder.build( + LayerNode.group([Project.node, Session.node, Workspace.node, InstanceStore.node, Database.node, Ripgrep.node]), + [[InstanceStore.bootstrapNode, InstanceBootstrap.node]], ) +const it = testEffect(Layer.mergeAll(appLayer, httpApiLayer)) function request(path: string, directory: string, init: RequestInit = {}) { return requestInDirectory(path, directory, init) diff --git a/packages/opencode/test/server/negative-tokens-regression.test.ts b/packages/opencode/test/server/negative-tokens-regression.test.ts index b23726965f..453aa189d5 100644 --- a/packages/opencode/test/server/negative-tokens-regression.test.ts +++ b/packages/opencode/test/server/negative-tokens-regression.test.ts @@ -6,6 +6,7 @@ // strict `NonNegativeInt` schema then made every load of the message list // fail to encode, killing Desktop boot for every user with such a row. import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { eq } from "drizzle-orm" @@ -21,7 +22,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer)) function seedNegativeTokenSession() { return Effect.gen(function* () { diff --git a/packages/opencode/test/server/project-copy.test.ts b/packages/opencode/test/server/project-copy.test.ts index 5b2deb87a9..0f783622a6 100644 --- a/packages/opencode/test/server/project-copy.test.ts +++ b/packages/opencode/test/server/project-copy.test.ts @@ -2,12 +2,14 @@ import { afterEach, describe, expect } from "bun:test" import { $ } from "bun" import fs from "fs/promises" import path from "path" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { HttpClientResponse } from "effect/unstable/http" import { FSUtil } from "@opencode-ai/core/fs-util" import { Database } from "@opencode-ai/core/database/database" import { Snapshot } from "@/snapshot" -import { InstanceBootstrap } from "@/project/bootstrap-service" +import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -20,9 +22,13 @@ afterEach(async () => { }) const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)) +const testInstanceStore = AppNodeBuilder.build(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]) const it = testEffect( - Layer.mergeAll(FSUtil.defaultLayer, Database.defaultLayer, Snapshot.defaultLayer, testInstanceStore, httpApiLayer), + Layer.mergeAll( + AppNodeBuilder.build(LayerNode.group([FSUtil.node, Database.node, Snapshot.node])), + testInstanceStore, + httpApiLayer, + ), ) function request(directory: string, url: string, init: RequestInit = {}) { diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index 9deac21af9..4474bea7d4 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect } from "bun:test" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Effect, Layer } from "effect" import { HttpClientResponse } from "effect/unstable/http" import path from "path" import { InstanceRef } from "../../src/effect/instance-ref" -import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Snapshot } from "../../src/snapshot" @@ -19,9 +21,11 @@ afterEach(async () => { }) const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)) +const testInstanceStore = AppNodeBuilder.build(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]) -const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, Snapshot.defaultLayer, testInstanceStore, httpApiLayer)) +const it = testEffect( + Layer.mergeAll(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Snapshot.node])), testInstanceStore, httpApiLayer), +) function request(directory: string, url: string, init: RequestInit = {}) { return requestInDirectory(url, directory, init) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index a4ad3793bc..cf27c74cbe 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,11 +1,12 @@ import { afterEach, describe, expect, mock } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { Session as SessionNs } from "@/session/session" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), httpApiLayer)) afterEach(async () => { mock.restore() diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts index 5982627b04..d2a4211ff1 100644 --- a/packages/opencode/test/server/session-diff-missing-patch.test.ts +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -11,6 +11,7 @@ * asserts that GET /session//diff returns 200 with empty data. */ import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" @@ -24,7 +25,7 @@ import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Storage.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Storage.node])), httpApiLayer)) afterEach(async () => { await disposeAllInstances() diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 213e3cdce3..354a578b23 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Database } from "@opencode-ai/core/database/database" import { SessionProjector } from "@opencode-ai/core/session/projector" import { Session as SessionNs } from "@/session/session" @@ -9,24 +11,12 @@ import path from "path" import { SessionTable } from "@opencode-ai/core/session/sql" import { eq } from "drizzle-orm" import { testEffect } from "../lib/effect" -import { EventV2Bridge } from "@/event-v2-bridge" -import { Storage } from "@/storage/storage" import { RuntimeFlags } from "@/effect/runtime-flags" -import { BackgroundJob } from "@/background/job" const layer = (experimentalWorkspaces: boolean) => - Layer.mergeAll( - Database.defaultLayer, - SessionNs.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Storage.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(SessionProjector.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })), - Layer.provide(BackgroundJob.defaultLayer), - ), - ) + AppNodeBuilder.build(LayerNode.group([Database.node, SessionNs.node, SessionProjector.node]), [ + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces })], + ]) const it = testEffect(layer(false)) const itWorkspaces = testEffect(layer(true)) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index 58d6ff04f9..d9d0528ee4 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { HttpClientResponse } from "effect/unstable/http" import { Session as SessionNs } from "@/session/session" @@ -12,7 +13,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), httpApiLayer)) const model = { providerID: ProviderV2.ID.make("test"), diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index a9bc84202b..82a8ad9395 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -1,11 +1,12 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { Session } from "@/session/session" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(Session.node), httpApiLayer)) describe("tui.selectSession endpoint", () => { it.instance( diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 63276bfe19..c02ea5911d 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -7,12 +7,9 @@ import { APICallError } from "ai" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect" import * as Stream from "effect/Stream" import { Config } from "@/config/config" -import { Image } from "@/image/image" -import { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" import { Token } from "@/util/token" -import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { provideTmpdirInstance, TestInstance } from "../fixture/fixture" import { Session as SessionNs } from "@/session/session" @@ -22,10 +19,10 @@ import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionProjector } from "@opencode-ai/core/session/projector" -import type { Provider } from "@/provider/provider" +import { Provider } from "@/provider/provider" import * as SessionProcessorModule from "../../src/session/processor" -import { Snapshot } from "../../src/snapshot" import { ProviderTest } from "../fake/provider" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -34,6 +31,8 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { LLMEvent, Usage } from "@opencode-ai/llm" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" const summary = Layer.succeed( SessionSummary.Service, @@ -209,7 +208,7 @@ function fake( } satisfies SessionProcessorModule.SessionProcessor.Handle } -function layer(result: "continue" | "compact") { +function processorLayer(result: "continue" | "compact") { return Layer.succeed( SessionProcessorModule.SessionProcessor.Service, SessionProcessorModule.SessionProcessor.Service.of({ @@ -220,38 +219,28 @@ function layer(result: "continue" | "compact") { function cfg(compaction?: ConfigV1.Info["compaction"]) { const base = Schema.decodeUnknownSync(ConfigV1.Info)({}) as ConfigV1.Info - return TestConfig.layer({ - get: () => Effect.succeed({ ...base, compaction }), - }) + return Layer.succeed(Config.Service, TestConfig.make({ get: () => Effect.succeed({ ...base, compaction }) })) } -const deps = Layer.mergeAll( - wide().layer, - layer("continue"), - Agent.defaultLayer, - Plugin.defaultLayer, - EventV2Bridge.defaultLayer, - Config.defaultLayer, - RuntimeFlags.layer({ experimentalEventSystem: true }), - Database.defaultLayer, - EventV2Bridge.defaultLayer, -) - -const env = Layer.mergeAll( - SessionNs.defaultLayer, - Database.defaultLayer, - EventV2Bridge.defaultLayer, - CrossSpawnSpawner.defaultLayer, - SessionCompaction.layer.pipe(Layer.provide(SessionNs.defaultLayer), Layer.provideMerge(deps)), -) +const defaultProvider = wide() +const compactionTestNode = LayerNode.group([ + SessionCompaction.node, + SessionNs.node, + SessionProjector.node, + Database.node, + EventV2Bridge.node, + CrossSpawnSpawner.node, +]) +const env = AppNodeBuilder.build(compactionTestNode, [ + [Provider.node, defaultProvider.layer], + [SessionProcessorModule.SessionProcessor.node, processorLayer("continue")], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], +]) const it = testEffect(env) -const compactionEnv = Layer.mergeAll( - SessionNs.defaultLayer, - Database.defaultLayer, - EventV2Bridge.defaultLayer, - CrossSpawnSpawner.defaultLayer, +const compactionEnv = AppNodeBuilder.build( + LayerNode.group([SessionNs.node, SessionProjector.node, Database.node, EventV2Bridge.node, CrossSpawnSpawner.node]), ) const itCompaction = testEffect(compactionEnv) @@ -259,7 +248,7 @@ type CompactionProcessOptions = { result?: "continue" | "compact" llm?: Layer.Layer plugin?: Layer.Layer - provider?: ReturnType + provider?: ReturnType config?: Layer.Layer } @@ -268,30 +257,25 @@ function withCompaction(options?: CompactionProcessOptions) { } function compactionProcessLayer(options?: CompactionProcessOptions) { - const events = EventV2Bridge.defaultLayer - const status = SessionStatus.layer.pipe(Layer.provide(events)) - const processor = options?.llm - ? SessionProcessorModule.SessionProcessor.layer.pipe( - Layer.provide(summary), - Layer.provide(Image.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provide(status), - ) - : layer(options?.result ?? "continue") - return Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, events, status).pipe( - Layer.provide(SessionNs.defaultLayer), - Layer.provide((options?.provider ?? wide()).layer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(options?.llm ?? LLM.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(options?.plugin ?? Plugin.defaultLayer), - Layer.provide(status), - Layer.provide(events), - Layer.provide(options?.config ?? Config.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provide(EventV2Bridge.defaultLayer), - ) + const replacements: LayerNode.Replacements = [ + [Provider.node, (options?.provider ?? wide()).layer], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], + [SessionSummary.node, summary], + ] + if (!options?.llm) { + return AppNodeBuilder.build(compactionTestNode, [ + ...replacements, + [SessionProcessorModule.SessionProcessor.node, processorLayer(options?.result ?? "continue")], + ...(options?.plugin ? ([[Plugin.node, options.plugin]] as const) : []), + ...(options?.config ? ([[Config.node, options.config]] as const) : []), + ]) + } + return AppNodeBuilder.build(compactionTestNode, [ + ...replacements, + [LLM.node, options.llm], + ...(options?.plugin ? ([[Plugin.node, options.plugin]] as const) : []), + ...(options?.config ? ([[Config.node, options.config]] as const) : []), + ]) } function createSummaryCompaction(sessionID: SessionID) { @@ -317,7 +301,7 @@ function llm() { push(stream: Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream)) { queue.push(stream) }, - layer: Layer.succeed( + llmLayer: Layer.succeed( LLM.Service, LLM.Service.of({ stream: (input) => { @@ -613,8 +597,7 @@ describe("session.compaction.create", () => { }) const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe( - Effect.provide(SessionExecution.noopLayer), - Effect.provide(SessionV2.defaultLayer), + Effect.provide(AppNodeBuilder.build(SessionV2.node, [[SessionExecution.node, SessionExecution.noopLayer]])), ) expect(v2.at(-1)).toMatchObject({ type: "compaction", @@ -854,12 +837,12 @@ describe("session.compaction.process", () => { const msg = yield* createUserMessage(session.id, "hello") const msgs = yield* ssn.messages({ sessionID: session.id }) const done = yield* Deferred.make() - let seen = false + const seen: string[] = [] const unsub = yield* events.listen((evt) => { + seen.push(evt.type) if (evt.type !== SessionCompaction.Event.Compacted.type) return Effect.void if ((evt.data as typeof SessionCompaction.Event.Compacted.data.Type).sessionID !== session.id) return Effect.void - seen = true Deferred.doneUnsafe(done, Effect.void) return Effect.void }) @@ -874,7 +857,8 @@ describe("session.compaction.process", () => { yield* Deferred.await(done).pipe(Effect.timeout("500 millis")) expect(result).toBe("continue") - expect(seen).toBe(true) + expect(seen).toContain(SessionCompaction.Event.Compacted.type) + expect(seen.filter((type) => type.startsWith("session.next."))).toEqual([]) }), ) @@ -1011,7 +995,7 @@ describe("session.compaction.process", () => { expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBeUndefined() expect(captured).toContain("yyyy") - }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) })) + }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) })) }, { git: true }, ) @@ -1048,7 +1032,7 @@ describe("session.compaction.process", () => { expect(part?.tail_start_id).toBeUndefined() expect(captured).toContain("recent image turn") expect(captured).toContain("Attached image/png: big.png") - }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) + }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) }, { git: true }, ) @@ -1099,7 +1083,7 @@ describe("session.compaction.process", () => { expect(filtered[1]?.info.role).toBe("assistant") expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) - }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) + }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) }, { git: true }, ) @@ -1250,7 +1234,7 @@ describe("session.compaction.process", () => { }) .pipe(Effect.forkChild) - yield* Deferred.await(ready).pipe(Effect.timeout("1 second")) + yield* Deferred.await(ready).pipe(Effect.timeout("5 seconds")) const start = Date.now() yield* Fiber.interrupt(fiber) const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis")) @@ -1260,9 +1244,10 @@ describe("session.compaction.process", () => { expect(Cause.hasInterrupts(exit.cause)).toBe(true) expect(Date.now() - start).toBeLessThan(250) } - }).pipe(withCompaction({ llm: stub.layer })) + }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, + { timeout: 10_000 }, ) itCompaction.instance( @@ -1332,7 +1317,7 @@ describe("session.compaction.process", () => { expect(summary?.parts.some((part) => part.type === "reasoning")).toBe(false) // Sanity: the text part still got through. expect(summary?.parts.some((part) => part.type === "text" && part.text === "summary")).toBe(true) - }).pipe(withCompaction({ llm: stub.layer })) + }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, ) @@ -1368,7 +1353,7 @@ describe("session.compaction.process", () => { expect(summary?.info.role).toBe("assistant") expect(summary?.parts.some((part) => part.type === "tool")).toBe(false) - }).pipe(withCompaction({ llm: stub.layer })) + }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, ) @@ -1405,7 +1390,7 @@ describe("session.compaction.process", () => { expect(captured).not.toContain("keep this turn") expect(captured).not.toContain("and this one too") expect(captured).not.toContain("What did we do so far?") - }).pipe(withCompaction({ llm: stub.layer })) + }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, ) @@ -1447,7 +1432,7 @@ describe("session.compaction.process", () => { expect(captured.match(/summary one/g)?.length).toBe(1) expect(captured).toContain("## Constraints & Preferences") expect(captured).toContain("## Progress") - }).pipe(withCompaction({ llm: stub.layer })) + }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, ) @@ -1489,7 +1474,7 @@ describe("session.compaction.process", () => { expect( filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), ).toBe(true) - }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })) + }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })) }) itCompaction.instance( diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 53ccf06e12..cdbc6e66d7 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -2,34 +2,42 @@ import { describe, expect, test } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import path from "path" import { Effect, FileSystem, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" -import { NodeFileSystem } from "@effect/platform-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { FSUtil } from "@opencode-ai/core/fs-util" import { Instruction } from "../../src/session/instruction" import type { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { Global } from "@opencode-ai/core/global" import { RuntimeFlags } from "../../src/effect/runtime-flags" -import { provideInstance, provideTmpdirInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" +import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { TestConfig } from "../fixture/config" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { InstanceStore } from "@/project/instance-store" +import { InstanceBootstrap } from "@/project/bootstrap" +import { Config } from "@/config/config" -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer)) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([CrossSpawnSpawner.node, LayerNodePlatform.filesystem, InstanceStore.node]), [ + [ + InstanceBootstrap.node, + Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })), + ], + ]), +) -const configLayer = TestConfig.layer() +const configLayer = Layer.succeed(Config.Service, TestConfig.make()) const instructionLayer = (global: Partial, flags: Partial = {}) => - Instruction.layer.pipe( - Layer.provide(configLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Global.layerWith(global)), - Layer.provide(RuntimeFlags.layer(flags)), - ) + AppNodeBuilder.build(Instruction.node, [ + [Config.node, configLayer], + [Global.node, Global.layerWith(global)], + [RuntimeFlags.node, RuntimeFlags.layer(flags)], + ]) const provideInstruction = (global: Partial, flags?: Partial) => diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index bdc6ea979a..d850b9a662 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -1,6 +1,5 @@ import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { FSUtil } from "@opencode-ai/core/fs-util" import { ModelsDev } from "@opencode-ai/core/models-dev" import { HttpRecorder } from "@opencode-ai/http-recorder" import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" @@ -10,14 +9,11 @@ import { Effect, Layer, Option, Schema, Stream } from "effect" import path from "node:path" import z from "zod" import { Auth } from "@/auth" -import { Config } from "@/config/config" -import { Plugin } from "@/plugin" import { Provider } from "@/provider/provider" import { Filesystem } from "@/util/filesystem" import { LLMEvent, LLMResponse } from "@opencode-ai/llm" -import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route" -import { Env } from "@/env" +import { RequestExecutor } from "@opencode-ai/llm/route" import { RuntimeFlags } from "@/effect/runtime-flags" import type { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" @@ -26,6 +22,9 @@ import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" const FIXTURES_DIR = path.join(import.meta.dir, "../fixtures/recordings") @@ -240,7 +239,7 @@ const redactRecordedBody = (body: string) => function authLayer(scenario: RecordedScenario) { const replayAuth = shouldRecord ? scenario.recordAuth?.() : scenario.replayAuth - if (!replayAuth) return Auth.defaultLayer + if (!replayAuth) return undefined return Layer.mock(Auth.Service)({ get: (providerID) => Effect.succeed(providerID === scenario.providerID ? replayAuth : undefined), all: () => Effect.succeed({ [scenario.providerID]: replayAuth }), @@ -262,15 +261,6 @@ const modelsFixture = Filesystem.readJson>( function recordedNativeLLMLayer(scenario: RecordedScenario) { const auth = authLayer(scenario) - const provider = Provider.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(auth), - Layer.provide(Plugin.defaultLayer), - Layer.provide(ModelsDev.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - ) // Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real. const metadata = { provider: scenario.providerID, @@ -290,21 +280,11 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) { redactor: HttpRecorderInternal.Redactor.make(redact), }) : HttpRecorder.http(scenario.cassette, { directory: FIXTURES_DIR, metadata, redact }) - const recordedClient = LLMClient.layer.pipe( - Layer.provide(Layer.mergeAll(RequestExecutor.layer.pipe(Layer.provide(recordedHttp)), WebSocketExecutor.layer)), - ) - - return Layer.mergeAll( - provider, - LLM.layer.pipe( - Layer.provide(auth), - Layer.provide(Config.defaultLayer), - Layer.provide(provider), - Layer.provide(Plugin.defaultLayer), - Layer.provide(recordedClient), - Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: true })), - ), - ) + return AppNodeBuilder.build(LayerNode.group([Provider.node, LLM.node]), [ + [LayerNodePlatform.requestExecutor, RequestExecutor.layer.pipe(Layer.provide(recordedHttp))], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalNativeLlm: true })], + ...(auth ? ([[Auth.node, auth]] as const) : []), + ]) } const writeConfig = (directory: string, scenario: RecordedScenario, model: ModelsDev.Provider["models"][string]) => diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index 702bb67e39..dd4d9cc174 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -3,6 +3,7 @@ import { LLMEvent, ToolFailure } from "@opencode-ai/llm" import { LLMClient, RequestExecutor, WebSocketExecutor, type LLMClientShape } from "@opencode-ai/llm/route" import { jsonSchema, tool, type ModelMessage, type Tool } from "ai" import { Effect, Fiber, Layer, Stream } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import { LLMNative } from "@/session/llm/native-request" import { LLMNativeRuntime } from "@/session/llm/native-runtime" import type { Provider } from "@/provider/provider" @@ -73,7 +74,11 @@ const providerInfo: Provider.Info = { } const it = testEffect( - LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer))), + LLMClient.layer.pipe( + Layer.provide( + Layer.mergeAll(RequestExecutor.layer.pipe(Layer.provide(FetchHttpClient.layer)), WebSocketExecutor.layer), + ), + ), ) function responsesStream(chunks: unknown[]) { @@ -115,10 +120,9 @@ const storedSession = { const openAIResponses = { user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }), assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }), - openaiReasoning: (text: string, options: { readonly itemId: string; readonly encryptedContent: string }) => ({ + openaiReasoning: (text: string, encryptedContent: string) => ({ type: "reasoning", - id: options.itemId, - encrypted_content: options.encryptedContent, + encrypted_content: encryptedContent, summary: [{ type: "summary_text", text }], }), } @@ -657,10 +661,7 @@ describe("session.llm-native.request", () => { expectedBody: { input: [ openAIResponses.user("What changed?"), - openAIResponses.openaiReasoning("Checked the previous diff.", { - itemId: "rs_1", - encryptedContent: "encrypted-state", - }), + openAIResponses.openaiReasoning("Checked the previous diff.", "encrypted-state"), openAIResponses.assistant("The parser changed."), openAIResponses.user("Summarize it."), ], @@ -683,7 +684,7 @@ describe("session.llm-native.request", () => { ], providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } }, expectedBody: { - input: [{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" }], + input: [{ type: "reasoning", summary: [], encrypted_content: "encrypted-state" }], include: ["reasoning.encrypted_content"], store: false, }, diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 0c5dadaf17..45b25a1a03 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -9,13 +9,10 @@ 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, WebSocketExecutor } from "@opencode-ai/llm/route" -import { Auth } from "@/auth" -import { Config } from "@/config/config" +import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" import { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { ModelsDev } from "@opencode-ai/core/models-dev" -import { Plugin } from "@/plugin" import { testEffect } from "../lib/effect" import type { Agent } from "../../src/agent/agent" @@ -27,6 +24,9 @@ import { LLMAISDK } from "@/session/llm/ai-sdk" import { Session as SessionNs } from "@/session/session" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" type ConfigModel = NonNullable[string]["models"]>[string] @@ -52,15 +52,13 @@ const openAIConfig = (model: ModelsDev.Provider["models"][string], baseURL: stri } } -const it = testEffect(Layer.mergeAll(LLM.defaultLayer, Provider.defaultLayer)) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([LLM.node, Provider.node]))) // LLM.stream returns a Stream, not an Effect, so we can't use the serviceUse proxy. const drain = (input: LLM.StreamInput) => LLM.Service.use((svc) => svc.stream(input).pipe(Stream.runDrain)) -// drainWith builds an isolated runtime so the custom layer fully owns LLM and -// its transitive deps — `Effect.provide(layer)` over an existing runtime layers -// the new services on top, but transitive Service overrides (e.g. RequestExecutor) -// resolved through the outer LLM.defaultLayer leak through. +// drainWith builds an isolated runtime so custom replacements fully own LLM and +// its transitive deps. const drainWith = (layer: Layer.Layer, input: LLM.StreamInput) => Effect.gen(function* () { const ctx = yield* InstanceRef @@ -75,15 +73,16 @@ const drainWith = (layer: Layer.Layer, input: LLM.StreamInput) => ) }) -function llmLayerWithExecutor(executor: Layer.Layer, flags: Partial = {}) { - return LLM.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(executor, WebSocketExecutor.layer)))), - Layer.provide(RuntimeFlags.layer(flags)), - ) +function llmLayerWithExecutor( + options: { + executor?: Layer.Layer + flags?: Partial + } = {}, +) { + return AppNodeBuilder.build(LLM.node, [ + [RuntimeFlags.node, RuntimeFlags.layer(options.flags)], + ...(options.executor ? ([[LayerNodePlatform.requestExecutor, options.executor]] as const) : []), + ]) } describe("session.llm.hasToolCalls", () => { @@ -1129,14 +1128,10 @@ describe("session.llm.stream", () => { } satisfies Agent.Info yield* drainWith( - LLM.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(failingNativeClient), - Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: false })), - ), + AppNodeBuilder.build(LLM.node, [ + [LayerNodePlatform.llmClient, failingNativeClient], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalNativeLlm: false })], + ]), { user: { id: MessageID.make("msg_user-native-flag-off"), @@ -1199,7 +1194,7 @@ describe("session.llm.stream", () => { temperature: 0.2, } satisfies Agent.Info - yield* drainWith(llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }), { + yield* drainWith(llmLayerWithExecutor({ flags: { experimentalNativeLlm: true } }), { user: { id: MessageID.make("msg_user-native"), sessionID, @@ -1282,7 +1277,7 @@ describe("session.llm.stream", () => { permission: [{ permission: "*", pattern: "*", action: "allow" }], } satisfies Agent.Info - yield* drainWith(llmLayerWithExecutor(executor, { experimentalNativeLlm: true }), { + yield* drainWith(llmLayerWithExecutor({ executor, flags: { experimentalNativeLlm: true } }), { user: { id: MessageID.make("msg_user-native-injected-tool"), sessionID, @@ -1314,6 +1309,7 @@ describe("session.llm.stream", () => { type: "function", name: "lookup", description: "Lookup data", + strict: false, parameters: { type: "object", properties: { query: { type: "string" } }, @@ -1370,7 +1366,7 @@ describe("session.llm.stream", () => { permission: [{ permission: "*", pattern: "*", action: "allow" }], } satisfies Agent.Info - yield* drainWith(llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }), { + yield* drainWith(llmLayerWithExecutor({ flags: { experimentalNativeLlm: true } }), { user: { id: MessageID.make("msg_user-native-tool"), sessionID, @@ -1402,6 +1398,7 @@ describe("session.llm.stream", () => { type: "function", name: "lookup", description: "Lookup data", + strict: false, parameters: { type: "object", properties: { query: { type: "string" } }, diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index c0b65b5e57..b67c982ebc 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { Database } from "@opencode-ai/core/database/database" -import { Effect, Layer, Option } from "effect" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { Effect, Option } from "effect" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" @@ -11,7 +12,7 @@ import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, Database.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([SessionNs.node, MessageV2.node, SessionProjector.node]))) const withSession = ( fn: (input: { session: SessionNs.Interface; sessionID: SessionID }) => Effect.Effect, diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index c8f40d0de1..5287605436 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -24,7 +24,6 @@ import { raw, reply, TestLLMServer } from "../lib/llm-server" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionProjector } from "@opencode-ai/core/session/projector" import { LLMEvent } from "@opencode-ai/llm" @@ -177,10 +176,13 @@ const root = LayerNode.group([ CrossSpawnSpawner.node, ]) const replacements = [ - LayerNode.replace(SessionSummary.node, summary), - LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })), -] -const env = LayerNode.buildLayer(LayerNode.group([root, LayerNode.make(TestLLMServer.layer, [])]), { replacements }) + [SessionSummary.node, summary], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], +] as const +const env = LayerNode.compile( + LayerNode.group([root, LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] })]), + replacements, +) const it = testEffect(env) @@ -204,9 +206,7 @@ const providerErrorLLM = Layer.succeed( ), }), ) -const providerErrorEnv = LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(LLM.node, providerErrorLLM)], -}) +const providerErrorEnv = LayerNode.compile(root, [...replacements, [LLM.node, providerErrorLLM]]) const itProviderError = testEffect(providerErrorEnv) const fragmentFailureLLM = Layer.succeed( @@ -223,9 +223,7 @@ const fragmentFailureLLM = Layer.succeed( ), }), ) -const fragmentFailureEnv = LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(LLM.node, fragmentFailureLLM)], -}) +const fragmentFailureEnv = LayerNode.compile(root, [...replacements, [LLM.node, fragmentFailureLLM]]) const itFragmentFailure = testEffect(fragmentFailureEnv) const boot = Effect.fn("test.boot")(function* () { @@ -978,10 +976,9 @@ itProviderError.live("session.processor effect tests fail provider-executed erro const parent = yield* user(chat.id, "provider tool error") const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) const mdl = yield* provider.getModel(ref.providerID, ref.modelID) - const settlements: Array = [] + const seen: string[] = [] const off = yield* events.listen((event) => { - if (event.type === SessionEvent.Tool.Failed.type) - settlements.push(event as typeof SessionEvent.Tool.Failed.Type) + seen.push(event.type) return Effect.void }) const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl }) @@ -1008,19 +1005,15 @@ itProviderError.live("session.processor effect tests fail provider-executed erro const call = parts.find((part): part is SessionV1.ToolPart => part.type === "tool") expect(call?.state.status).toBe("error") if (call?.state.status === "error") expect(call.state.error).toBe("provider boom") - expect(settlements).toHaveLength(1) - expect(settlements[0]?.data).toMatchObject({ - callID: "call-1", - error: { type: "unknown", message: "provider boom" }, - result: { type: "error", value: "provider boom" }, - provider: { executed: true }, - }) + expect(seen).toContain(MessageV2.Event.PartUpdated.type) + expect(seen).toContain(MessageV2.Event.Updated.type) + expect(seen.filter((type) => type.startsWith("session.next."))).toEqual([]) }), { config: cfg }, ), ) -itFragmentFailure.live("session.processor effect tests flush partial v2 fragments before step failure", () => +itFragmentFailure.live("session.processor effect tests retain partial legacy parts without v2 events", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { @@ -1032,14 +1025,8 @@ itFragmentFailure.live("session.processor effect tests flush partial v2 fragment const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) const mdl = yield* provider.getModel(ref.providerID, ref.modelID) const seen: string[] = [] - let text: string | undefined - let reasoning: string | undefined const off = yield* events.listen((event) => { seen.push(event.type) - if (event.type === SessionEvent.Text.Ended.type) - text = (event.data as typeof SessionEvent.Text.Ended.data.Type).text - if (event.type === SessionEvent.Reasoning.Ended.type) - reasoning = (event.data as typeof SessionEvent.Reasoning.Ended.data.Type).text return Effect.void }) const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl }) @@ -1064,12 +1051,16 @@ itFragmentFailure.live("session.processor effect tests flush partial v2 fragment ).toBe("stop") yield* off - const failed = seen.indexOf(SessionEvent.Step.Failed.type) - expect(failed).toBeGreaterThan(-1) - expect(seen.indexOf(SessionEvent.Text.Ended.type)).toBeLessThan(failed) - expect(seen.indexOf(SessionEvent.Reasoning.Ended.type)).toBeLessThan(failed) - expect(text).toBe("partial") - expect(reasoning).toBe("thinking") + const parts = yield* MessageV2.parts(msg.id) + expect(parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "text", text: "partial" }), + expect.objectContaining({ type: "reasoning", text: "thinking" }), + ]), + ) + expect(seen).toContain(MessageV2.Event.PartUpdated.type) + expect(seen).toContain(Session.Event.Error.type) + expect(seen.filter((type) => type.startsWith("session.next."))).toEqual([]) }), { config: cfg }, ), diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 5cd97f78e8..491ad06aaf 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,14 +1,14 @@ -import { NodeFileSystem } from "@effect/platform-node" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { eq } from "drizzle-orm" import { EventV2Bridge } from "@/event-v2-bridge" -import { FetchHttpClient } from "effect/unstable/http" import { expect } from "bun:test" import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import path from "path" -import { fileURLToPath, pathToFileURL } from "url" +import { fileURLToPath } from "url" import { NamedError } from "@opencode-ai/core/util/error" import { Agent as AgentSvc } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" @@ -56,6 +56,7 @@ import { reply, TestLLMServer } from "../lib/llm-server" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" const summary = Layer.succeed( SessionSummary.Service, @@ -108,28 +109,32 @@ function errorTool(parts: SessionV1.Part[]) { return part?.state.status === "error" ? (part as ErrorToolPart) : undefined } -const mcp = Layer.succeed( - MCP.Service, - MCP.Service.of({ - status: () => Effect.succeed({}), - clients: () => Effect.succeed({}), - tools: () => Effect.succeed({}), - prompts: () => Effect.succeed({}), - resources: () => Effect.succeed({}), - add: () => Effect.succeed({ status: { status: "disabled" as const } }), - connect: () => Effect.void, - disconnect: () => Effect.void, - getPrompt: () => Effect.succeed(undefined), - readResource: () => Effect.succeed(undefined), - startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), - authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"), - finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), - removeAuth: () => Effect.void, - supportsOAuth: () => Effect.succeed(false), - hasStoredTokens: () => Effect.succeed(false), - getAuthStatus: () => Effect.succeed("not_authenticated" as const), - }), -) +function makeMcp(instructions: MCP.ServerInstructions[] = []) { + return Layer.succeed( + MCP.Service, + MCP.Service.of({ + status: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + instructions: () => Effect.succeed(instructions), + tools: () => Effect.succeed({}), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + resourceTemplates: () => Effect.succeed({}), + add: () => Effect.succeed({ status: { status: "disabled" as const } }), + connect: () => Effect.void, + disconnect: () => Effect.void, + getPrompt: () => Effect.succeed(undefined), + readResource: () => Effect.succeed(undefined), + startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + removeAuth: () => Effect.void, + supportsOAuth: () => Effect.succeed(false), + hasStoredTokens: () => Effect.succeed(false), + getAuthStatus: () => Effect.succeed("not_authenticated" as const), + }), + ) +} const lsp = Layer.succeed( LSP.Service, @@ -151,10 +156,6 @@ const lsp = Layer.succeed( }), ) -const status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)) -const run = SessionRunState.layer.pipe(Layer.provide(status)) -const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) - const processorCreateStarted: Array<() => void> = [] const blockingProcessor = Layer.succeed( SessionProcessor.Service, @@ -163,83 +164,95 @@ const blockingProcessor = Layer.succeed( }), ) -function makePrompt(input?: { processor?: "blocking" }) { - const deps = Layer.mergeAll( - Session.defaultLayer, - Snapshot.defaultLayer, - LLM.defaultLayer, - Env.defaultLayer, - AgentSvc.defaultLayer, - Command.defaultLayer, - Permission.defaultLayer, - Plugin.defaultLayer, - Config.defaultLayer, - ProviderSvc.defaultLayer, - lsp, - mcp, - FSUtil.defaultLayer, - BackgroundJob.defaultLayer, - status, - Database.defaultLayer, - EventV2Bridge.defaultLayer, - ).pipe(Layer.provideMerge(infra)) - const question = Question.layer.pipe(Layer.provideMerge(deps)) - const todo = Todo.layer.pipe(Layer.provideMerge(deps)) - const registry = ToolRegistry.layer.pipe( - Layer.provide(Skill.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(Format.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(todo), - Layer.provideMerge(question), - Layer.provideMerge(deps), - ) - const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = - input?.processor === "blocking" - ? blockingProcessor - : SessionProcessor.layer.pipe( - Layer.provide(summary), - Layer.provide(Image.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(deps), - ) - const compact = SessionCompaction.layer.pipe( - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(proc), - Layer.provideMerge(deps), - ) - return SessionPrompt.layer.pipe( - Layer.provide(SessionRevert.defaultLayer), - Layer.provide(Image.defaultLayer), - Layer.provide(summary), - Layer.provideMerge(run), - Layer.provideMerge(compact), - Layer.provideMerge(proc), - Layer.provideMerge(registry), - Layer.provideMerge(trunc), - Layer.provide(Instruction.defaultLayer), - Layer.provide(SystemPrompt.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(deps), - Layer.provide(summary), - ) +const runtimeFlags = RuntimeFlags.layer({ experimentalEventSystem: true }) + +const testLLMServerNode = LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }) + +const promptRoot = LayerNode.group([ + SessionPrompt.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + LLM.node, + Env.node, + AgentSvc.node, + Command.node, + Permission.node, + Plugin.node, + Config.node, + ProviderSvc.node, + LSP.node, + MCP.node, + FSUtil.node, + BackgroundJob.node, + SessionStatus.node, + SessionRunState.node, + Database.node, + EventV2Bridge.node, + Question.node, + Todo.node, + ToolRegistry.node, + Skill.node, + Git.node, + Ripgrep.node, + Format.node, + Truncate.node, + SessionProcessor.node, + Image.node, + SessionCompaction.node, + SessionRevert.node, + Instruction.node, + SystemPrompt.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, +]) + +function makePrompt(input?: { mcpInstructions?: MCP.ServerInstructions[]; processor?: "blocking" }) { + const replacements = [ + [SessionSummary.node, summary], + [LSP.node, lsp], + [MCP.node, makeMcp(input?.mcpInstructions)], + [RuntimeFlags.node, runtimeFlags], + ] as const + if (input?.processor === "blocking") { + return LayerNode.compile(promptRoot, [...replacements, [SessionProcessor.node, blockingProcessor]]) + } + return LayerNode.compile(promptRoot, replacements) } -function makeHttp(input?: { processor?: "blocking" }) { - return Layer.mergeAll(TestLLMServer.layer, makePrompt(input)) +function makeHttp(input?: { mcpInstructions?: MCP.ServerInstructions[]; processor?: "blocking" }) { + const root = LayerNode.group([promptRoot, testLLMServerNode]) + const replacements = [ + [SessionSummary.node, summary], + [LSP.node, lsp], + [MCP.node, makeMcp(input?.mcpInstructions)], + [RuntimeFlags.node, runtimeFlags], + ] as const + if (input?.processor === "blocking") { + return LayerNode.compile(root, [...replacements, [SessionProcessor.node, blockingProcessor]]) + } + return LayerNode.compile(root, replacements) } -function makeHttpNoLLMServer(input?: { processor?: "blocking" }) { +function makeHttpNoLLMServer(input?: { mcpInstructions?: MCP.ServerInstructions[]; processor?: "blocking" }) { return makePrompt(input) } const it = testEffect(makeHttp()) const noLLMServer = testEffect(makeHttpNoLLMServer()) const raceNoLLMServer = testEffect(makeHttpNoLLMServer({ processor: "blocking" })) +const withMcpInstructions = testEffect( + makeHttp({ + mcpInstructions: [ + { + name: "guide-server", + instructions: "Use lookup before mutate.", + tools: ["guide-server_lookup"], + }, + ], + }), +) const unix = process.platform !== "win32" ? it.instance : it.instance.skip const unixNoLLMServer = process.platform !== "win32" ? noLLMServer.instance : noLLMServer.instance.skip @@ -295,11 +308,6 @@ const writeText = Effect.fn("test.writeText")(function* (file: string, text: str yield* fs.writeWithDirs(file, text) }) -const ensureDir = Effect.fn("test.ensureDir")(function* (dir: string) { - const fs = yield* FSUtil.Service - yield* fs.ensureDir(dir) -}) - const writeConfig = Effect.fn("test.writeConfig")(function* (dir: string, config: Partial) { yield* writeText( path.join(dir, "opencode.json"), @@ -506,6 +514,80 @@ it.instance("loop calls LLM and returns assistant message", () => }), ) +withMcpInstructions.instance( + "loop includes MCP instructions in model system context", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* llm.hang + yield* user(chat.id, "hello") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* awaitWithTimeout(llm.wait(1), "timed out waiting for MCP instruction request", "10 seconds") + + const hits = yield* llm.hits + const body = JSON.stringify(hits[0]?.body) + expect(body).toContain('') + expect(body).toContain("Use lookup before mutate.") + yield* Fiber.interrupt(fiber) + }), + 15_000, +) + +it.instance("legacy prompt emits message events without session.next events", () => + Effect.gen(function* () { + const events = yield* EventV2Bridge.Service + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Pinned", + agent: "plan", + model: { providerID: ProviderV2.ID.make("old"), id: ModelV2.ID.make("old-model") }, + }) + const seen: string[] = [] + const off = yield* events.listen((event) => { + seen.push(event.type) + return Effect.void + }) + + const first = yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + const second = yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "again" }], + }) + yield* off + + expect(first.info.role).toBe("user") + expect(second.info.role).toBe("user") + if (first.info.role === "user" && second.info.role === "user") { + expect(first.info.model).toEqual(ref) + expect(second.info.model).toEqual(ref) + } + expect(yield* sessions.get(chat.id)).toMatchObject({ + agent: "build", + model: { providerID: ref.providerID, id: ref.modelID }, + }) + expect(seen).toContain(Session.Event.Updated.type) + expect(seen).toContain(MessageV2.Event.Updated.type) + expect(seen).toContain(MessageV2.Event.PartUpdated.type) + expect(seen.filter((type) => type.startsWith("session.next."))).toEqual([]) + }), +) + it.instance("loop surfaces content-filter finishes as session errors", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) @@ -606,8 +688,12 @@ noLLMServer.instance.skip( }) const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( - Effect.provide(SessionExecution.noopLayer), - Effect.provide(SessionV2.defaultLayer), + Effect.provide( + LayerNode.compile(SessionV2.node, [ + [SessionExecution.node, SessionExecution.noopLayer], + [LocationServiceMap.node, locationServiceMapLayer], + ]), + ), ) const { db } = yield* Database.Service const row = yield* db @@ -994,56 +1080,52 @@ it.instance( // Cancel semantics -it.instance( - "cancel interrupts loop and resolves with an assistant message", - () => - Effect.gen(function* () { - const { llm } = yield* useServerConfig(providerCfg) - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* seed(chat.id) +it.instance("cancel interrupts loop and resolves with an assistant message", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* seed(chat.id) - yield* llm.hang + yield* llm.hang - yield* user(chat.id, "more") + yield* user(chat.id, "more") - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) - yield* prompt.cancel(chat.id) - const exit = yield* Fiber.await(fiber) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value.info.role).toBe("assistant") - } - }), - 3_000, + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + yield* waitForBusy(chat.id) + yield* prompt.cancel(chat.id) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + } + }), ) -it.instance( - "cancel records MessageAbortedError on interrupted process", - () => - Effect.gen(function* () { - const { llm } = yield* useServerConfig(providerCfg) - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* llm.hang - yield* user(chat.id, "hello") +it.instance("cancel records MessageAbortedError on interrupted process", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + yield* user(chat.id, "hello") - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) - yield* prompt.cancel(chat.id) - const exit = yield* Fiber.await(fiber) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - const info = exit.value.info - if (info.role === "assistant") { - expect(info.error?.name).toBe("MessageAbortedError") - } + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + yield* waitForBusy(chat.id) + yield* prompt.cancel(chat.id) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + const info = exit.value.info + if (info.role === "assistant") { + expect(info.error?.name).toBe("MessageAbortedError") } - }), - 3_000, + } + }), ) raceNoLLMServer.instance( @@ -1242,7 +1324,7 @@ it.instance( } }), { git: true }, - 3_000, + 10_000, ) // Queue semantics @@ -1262,124 +1344,115 @@ noLLMServer.instance("concurrent loop callers get same result", () => }), ) -it.instance( - "concurrent loop callers all receive same error result", - () => - Effect.gen(function* () { - const { llm } = yield* useServerConfig(providerCfg) - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) +it.instance("concurrent loop callers all receive same error result", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) - yield* llm.fail("boom") - yield* user(chat.id, "hello") + yield* llm.fail("boom") + yield* user(chat.id, "hello") - const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], { - concurrency: "unbounded", + const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], { + concurrency: "unbounded", + }) + expect(a.info.id).toBe(b.info.id) + expect(a.info.role).toBe("assistant") + }), +) + +it.instance("prompt submitted during an active run is included in the next LLM input", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const gate = yield* Deferred.make() + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + yield* llm.hold("first", deferredAsPromise(gate)) + yield* llm.text("second") + + const a = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "first" }], }) - expect(a.info.id).toBe(b.info.id) - expect(a.info.role).toBe("assistant") - }), - 3_000, + .pipe(Effect.forkChild) + + yield* llm.wait(1) + yield* waitForBusy(chat.id) + + const id = MessageID.ascending() + const b = yield* prompt + .prompt({ + sessionID: chat.id, + messageID: id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "second" }], + }) + .pipe(Effect.forkChild) + + yield* pollWithTimeout( + sessions + .messages({ sessionID: chat.id }) + .pipe( + Effect.map((msgs) => (msgs.some((msg) => msg.info.role === "user" && msg.info.id === id) ? true : undefined)), + ), + "timed out waiting for second prompt to save", + ) + + yield* Deferred.succeed(gate, void 0) + + const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + expect(Exit.isSuccess(ea)).toBe(true) + expect(Exit.isSuccess(eb)).toBe(true) + expect(yield* llm.calls).toBe(2) + + const msgs = yield* sessions.messages({ sessionID: chat.id }) + const assistants = msgs.filter((msg) => msg.info.role === "assistant") + expect(assistants).toHaveLength(2) + const last = assistants.at(-1) + if (!last || last.info.role !== "assistant") throw new Error("expected second assistant") + expect(last.info.parentID).toBe(id) + expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) + + const inputs = yield* llm.inputs + expect(inputs).toHaveLength(2) + const messages = inputs.at(-1)?.messages + if (!Array.isArray(messages)) throw new Error("expected LLM messages") + expect(messages.at(-1)).toEqual({ role: "user", content: "second" }) + }), ) -it.instance( - "prompt submitted during an active run is included in the next LLM input", - () => - Effect.gen(function* () { - const { llm } = yield* useServerConfig(providerCfg) - const gate = yield* Deferred.make() - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) +it.instance("assertNotBusy fails with BusyError when loop running", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const run = yield* SessionRunState.Service + const sessions = yield* Session.Service + yield* llm.hang - yield* llm.hold("first", deferredAsPromise(gate)) - yield* llm.text("second") + const chat = yield* sessions.create({}) + yield* user(chat.id, "hi") - const a = yield* prompt - .prompt({ - sessionID: chat.id, - agent: "build", - model: ref, - parts: [{ type: "text", text: "first" }], - }) - .pipe(Effect.forkChild) + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + yield* waitForBusy(chat.id) - yield* llm.wait(1) + const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) + expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "SessionBusyError", sessionID: chat.id }) + } - const id = MessageID.ascending() - const b = yield* prompt - .prompt({ - sessionID: chat.id, - messageID: id, - agent: "build", - model: ref, - parts: [{ type: "text", text: "second" }], - }) - .pipe(Effect.forkChild) - - yield* pollWithTimeout( - sessions - .messages({ sessionID: chat.id }) - .pipe( - Effect.map((msgs) => - msgs.some((msg) => msg.info.role === "user" && msg.info.id === id) ? true : undefined, - ), - ), - "timed out waiting for second prompt to save", - ) - - yield* Deferred.succeed(gate, void 0) - - const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) - expect(Exit.isSuccess(ea)).toBe(true) - expect(Exit.isSuccess(eb)).toBe(true) - expect(yield* llm.calls).toBe(2) - - const msgs = yield* sessions.messages({ sessionID: chat.id }) - const assistants = msgs.filter((msg) => msg.info.role === "assistant") - expect(assistants).toHaveLength(2) - const last = assistants.at(-1) - if (!last || last.info.role !== "assistant") throw new Error("expected second assistant") - expect(last.info.parentID).toBe(id) - expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) - - const inputs = yield* llm.inputs - expect(inputs).toHaveLength(2) - const messages = inputs.at(-1)?.messages - if (!Array.isArray(messages)) throw new Error("expected LLM messages") - expect(messages.at(-1)).toEqual({ role: "user", content: "second" }) - }), - 3_000, -) - -it.instance( - "assertNotBusy fails with BusyError when loop running", - () => - Effect.gen(function* () { - const { llm } = yield* useServerConfig(providerCfg) - const prompt = yield* SessionPrompt.Service - const run = yield* SessionRunState.Service - const sessions = yield* Session.Service - yield* llm.hang - - const chat = yield* sessions.create({}) - yield* user(chat.id, "hi") - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) - - const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) - expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "SessionBusyError", sessionID: chat.id }) - } - - yield* prompt.cancel(chat.id) - yield* Fiber.await(fiber) - }), - 3_000, + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), ) noLLMServer.instance("assertNotBusy succeeds when idle", () => @@ -1395,31 +1468,29 @@ noLLMServer.instance("assertNotBusy succeeds when idle", () => // Shell semantics -it.instance( - "shell rejects with BusyError when loop running", - () => - Effect.gen(function* () { - const { llm } = yield* useServerConfig(providerCfg) - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* llm.hang - yield* user(chat.id, "hi") +it.instance("shell rejects with BusyError when loop running", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + yield* user(chat.id, "hi") - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + yield* waitForBusy(chat.id) - const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) - expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "SessionBusyError", sessionID: chat.id }) - } + const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) + expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "SessionBusyError", sessionID: chat.id }) + } - yield* prompt.cancel(chat.id) - yield* Fiber.await(fiber) - }), - 3_000, + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), ) unixNoLLMServer( @@ -1630,7 +1701,7 @@ it.instance( expect(yield* llm.calls).toBe(1) }), { git: true }, - 3_000, + 10_000, ) it.instance( @@ -1669,7 +1740,7 @@ it.instance( expect(yield* llm.calls).toBe(1) }), { git: true }, - 3_000, + 10_000, ) unix( @@ -1711,11 +1782,17 @@ unixNoLLMServer( withSh(() => Effect.gen(function* () { const { prompt, run, chat } = yield* boot() + const { directory: dir } = yield* TestInstance + const afs = yield* FSUtil.Service + const ready = path.join(dir, ".shell-ready") const sh = yield* prompt - .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) + .shell({ sessionID: chat.id, agent: "build", command: ": > '.shell-ready'; sleep 30" }) .pipe(Effect.forkChild) - yield* waitForBusy(chat.id) + yield* pollWithTimeout( + afs.existsSafe(ready).pipe(Effect.map((exists) => (exists ? (true as const) : undefined))), + "shell never created readiness marker", + ) yield* prompt.cancel(chat.id) @@ -1805,7 +1882,6 @@ unix( yield* llm.tool("bash", { command: 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; printf truncation-ready; sleep 30', - description: "Print many lines", timeout: 30_000, workdir: path.resolve(dir), }) @@ -2127,45 +2203,43 @@ it.instance("does not loop empty assistant turns for a simple reply", () => }), ) -it.instance( - "records aborted errors when prompt is cancelled mid-stream", - () => - Effect.gen(function* () { - const { llm } = yield* useServerConfig(providerCfg) - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "Prompt cancel regression" }) +it.instance("records aborted errors when prompt is cancelled mid-stream", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Prompt cancel regression" }) - yield* llm.hang + yield* llm.hang - const fiber = yield* prompt - .prompt({ - sessionID: session.id, - agent: "build", - parts: [{ type: "text", text: "Cancel me" }], - }) - .pipe(Effect.forkChild) + const fiber = yield* prompt + .prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Cancel me" }], + }) + .pipe(Effect.forkChild) - yield* llm.wait(1) - yield* prompt.cancel(session.id) + yield* llm.wait(1) + yield* waitForBusy(session.id) + yield* prompt.cancel(session.id) - const exit = yield* Fiber.await(fiber) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value.info.role).toBe("assistant") - if (exit.value.info.role === "assistant") { - expect(exit.value.info.error?.name).toBe("MessageAbortedError") - } + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + if (exit.value.info.role === "assistant") { + expect(exit.value.info.error?.name).toBe("MessageAbortedError") } + } - const msgs = yield* sessions.messages({ sessionID: session.id }) - const last = msgs.findLast((msg) => msg.info.role === "assistant") - expect(last?.info.role).toBe("assistant") - if (last?.info.role === "assistant") { - expect(last.info.error?.name).toBe("MessageAbortedError") - } - }), - 3_000, + const msgs = yield* sessions.messages({ sessionID: session.id }) + const last = msgs.findLast((msg) => msg.info.role === "assistant") + expect(last?.info.role).toBe("assistant") + if (last?.info.role === "assistant") { + expect(last.info.error?.name).toBe("MessageAbortedError") + } + }), ) // Agent variant diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index f5edf1af24..30ac879a6a 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionV1 } from "@opencode-ai/core/v1/session" import type { NamedError } from "@opencode-ai/core/util/error" import { APICallError } from "ai" import { setTimeout as sleep } from "node:timers/promises" -import { Effect, Layer, Schedule, Schema } from "effect" +import { Effect, Schedule, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" @@ -15,7 +16,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" const providerID = ProviderV2.ID.make("test") const retryProvider = "test" -const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([SessionStatus.node, CrossSpawnSpawner.node]))) function apiError(headers?: Record): SessionV1.APIError { return Schema.decodeUnknownSync(SessionV1.APIError.Schema)( diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index 4e71a6a36c..d3d7ba7aae 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -1,29 +1,28 @@ import { describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import fs from "fs/promises" import path from "path" -import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect } from "effect" import { Session } from "@/session/session" import { SessionRevert } from "../../src/session/revert" import { MessageV2 } from "../../src/session/message-v2" import { Snapshot } from "../../src/snapshot" import { MessageID, PartID, SessionID } from "../../src/session/schema" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -const env = Layer.mergeAll( - Session.defaultLayer, - SessionRevert.defaultLayer, - Snapshot.defaultLayer, - CrossSpawnSpawner.defaultLayer, +const it = testEffect( + LayerNode.compile( + LayerNode.group([Session.node, SessionRevert.node, Snapshot.node, SessionProjector.node, CrossSpawnSpawner.node]), + ), ) -const it = testEffect(env) - const user = Effect.fn("test.user")(function* (sessionID: SessionID, agent = "default") { const session = yield* Session.Service return yield* session.updateMessage({ diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index 1323c2aba6..3bcbd55d4b 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -256,7 +256,7 @@ describe("Todo.Info", () => { const decode = decodeUnknown(Todo.Info) test("three-field round-trip", () => { - const input = { content: "do a thing", status: "pending", priority: "high" } + const input = Todo.Info.make({ content: "do a thing", status: "pending", priority: "high" }) expect(decode(input)).toEqual(input) }) }) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index c82f713d2b..d109181986 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -1,6 +1,5 @@ import { describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { SessionProjector } from "@opencode-ai/core/session/projector" import { Deferred, Effect, Exit, Layer } from "effect" @@ -8,26 +7,32 @@ import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" +import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { Storage } from "@/storage/storage" import { RuntimeFlags } from "@/effect/runtime-flags" -import { BackgroundJob } from "@/background/job" import { EventV2Bridge } from "@/event-v2-bridge" import { GlobalBus } from "@/bus/global" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { InstanceStore } from "@/project/instance-store" +import { InstanceBootstrap } from "@/project/bootstrap" const it = testEffect( - Layer.mergeAll( - SessionNs.layer.pipe( - Layer.provide(Storage.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provideMerge(EventV2Bridge.defaultLayer), - Layer.provide(SessionProjector.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), - Layer.provide(BackgroundJob.defaultLayer), - ), - CrossSpawnSpawner.defaultLayer, - testInstanceStoreLayer, + AppNodeBuilder.build( + LayerNode.group([ + SessionNs.node, + EventV2Bridge.node, + SessionProjector.node, + CrossSpawnSpawner.node, + InstanceStore.node, + ]), + [ + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces: false })], + [ + InstanceBootstrap.node, + Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })), + ], + ], ), ) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 8a3701e125..1265237840 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -37,9 +37,11 @@ const mcp = Layer.succeed( MCP.Service.of({ status: () => Effect.succeed({}), clients: () => Effect.succeed({}), + instructions: () => Effect.succeed([]), tools: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), + resourceTemplates: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), connect: () => Effect.void, disconnect: () => Effect.void, @@ -82,16 +84,14 @@ const root = LayerNode.group([ SessionSummary.node, Database.node, CrossSpawnSpawner.node, - LayerNode.make(TestLLMServer.layer, []), + LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }), ]) const it = testEffect( - LayerNode.buildLayer(root, { - replacements: [ - LayerNode.replace(MCP.node, mcp), - LayerNode.replace(LSP.node, lsp), - LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })), - ], - }), + LayerNode.compile(root, [ + [MCP.node, mcp], + [LSP.node, lsp], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], + ]), ) const providerCfg = (url: string) => ({ @@ -139,7 +139,6 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}` yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), "bash", { command, - description: "create test file", }) yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done") diff --git a/packages/opencode/test/session/structured-output-integration.test.ts b/packages/opencode/test/session/structured-output-integration.test.ts index 319b3bd728..df5755f89f 100644 --- a/packages/opencode/test/session/structured-output-integration.test.ts +++ b/packages/opencode/test/session/structured-output-integration.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Ripgrep } from "@opencode-ai/core/ripgrep" -import { Effect, Layer } from "effect" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Session } from "@/session/session" import { SessionPrompt } from "../../src/session/prompt" import { MessageV2 } from "../../src/session/message-v2" @@ -9,9 +11,7 @@ import { testEffect } from "../lib/effect" // Skip tests if no API key is available const hasApiKey = !!process.env.ANTHROPIC_API_KEY -const it = testEffect( - Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer).pipe(Layer.provide(Ripgrep.defaultLayer)), -) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([SessionPrompt.node, Session.node, Ripgrep.node]))) const live = hasApiKey ? it.instance : it.instance.skip describe("StructuredOutput Integration", () => { diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 69cec7bdcc..1484bfd631 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -1,11 +1,12 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import type { Agent } from "../../src/agent/agent" import { NamedError } from "@opencode-ai/core/util/error" import { Skill } from "../../src/skill" import { Permission } from "../../src/permission" import { SystemPrompt } from "../../src/session/system" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { MCP } from "../../src/mcp" import { testEffect } from "../lib/effect" const skills: Skill.Info[] = [ @@ -42,9 +43,27 @@ const build: Agent.Info = { } const it = testEffect( - SystemPrompt.layer.pipe( - Layer.provide(LocationServiceMap.layer), - Layer.provide( + LayerNode.compile(SystemPrompt.node, [ + [ + MCP.node, + Layer.mock(MCP.Service, { + instructions: () => + Effect.succeed([ + { + name: "guide-server", + instructions: "Use lookup before mutate.", + tools: [], + }, + { + name: "tool-server", + instructions: "Prefer search before update.", + tools: ["tool-server_search", "tool-server_update"], + }, + ]), + }), + ], + [ + Skill.node, Layer.succeed( Skill.Service, Skill.Service.of({ @@ -59,8 +78,8 @@ const it = testEffect( available: () => Effect.succeed(skills), }), ), - ), - ), + ], + ]), ) describe("session.system", () => { @@ -83,4 +102,41 @@ describe("session.system", () => { expect(output).not.toContain("manual-skill") }), ) + + it.effect("MCP output includes connected server instructions", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const output = yield* prompt.mcp(build) + + expect(output).toBe( + [ + "", + ' ', + " Use lookup before mutate.", + " ", + ' ', + " Prefer search before update.", + " ", + "", + ].join("\n"), + ) + }), + ) + + it.effect("MCP output omits servers when all advertised tools are denied", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const output = yield* prompt.mcp(build, Permission.fromConfig({ "tool-server_*": "deny" })) + + expect(output).toBe( + [ + "", + ' ', + " Use lookup before mutate.", + " ", + "", + ].join("\n"), + ) + }), + ) }) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 7bc76ed905..fe036be8c9 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect } from "bun:test" import { Effect, Exit, Layer, Option } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -19,7 +19,7 @@ import { provideTmpdirInstance } from "../fixture/fixture" import { resetDatabase } from "../fixture/db" import { pollWithTimeout, testEffect } from "../lib/effect" -const env = LayerNode.buildLayer(CrossSpawnSpawner.node) +const env = LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node])) const it = testEffect(env) const json = (req: Parameters[0], body: unknown, status = 200) => @@ -34,13 +34,13 @@ const json = (req: Parameters[0], body: unkno const none = HttpClient.make(() => Effect.die("unexpected http call")) function requestLayer(client: HttpClient.HttpClient) { - return LayerNode.buildLayer(LayerNode.group([ShareNext.node, AccountRepo.node]), { - replacements: [LayerNode.replace(httpClient, Layer.succeed(HttpClient.HttpClient, client))], - }) + const replacement = [httpClient, Layer.succeed(HttpClient.HttpClient, client)] as const + return LayerNode.compile(LayerNode.group([ShareNext.node, AccountRepo.node]), [replacement]) } function integrationLayer(client: HttpClient.HttpClient) { - return LayerNode.buildLayer( + const replacement = [httpClient, Layer.succeed(HttpClient.HttpClient, client)] as const + return LayerNode.compile( LayerNode.group([ ShareNext.node, EventV2Bridge.node, @@ -49,9 +49,7 @@ function integrationLayer(client: HttpClient.HttpClient) { AccountRepo.node, Database.node, ]), - { - replacements: [LayerNode.replace(httpClient, Layer.succeed(HttpClient.HttpClient, client))], - }, + [replacement], ) } diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index 5dc5d5195b..bdb60dfddc 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -1,6 +1,7 @@ import { describe, expect, beforeAll, afterAll } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Discovery } from "../../src/skill/discovery" import { Global } from "@opencode-ai/core/global" import { Filesystem } from "@/util/filesystem" @@ -11,10 +12,14 @@ import { testEffect } from "../lib/effect" let CLOUDFLARE_SKILLS_URL: string let server: ReturnType let downloadCount = 0 +let mutableVersion = "1" +let mutableContent = "# Old" +let mutableDownloadCount = 0 +let mutableFiles = ["SKILL.md"] const fixturePath = path.join(import.meta.dir, "../fixture/skills") const cacheDir = path.join(Global.Path.cache, "skills") -const it = testEffect(Layer.mergeAll(Discovery.defaultLayer, FSUtil.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Discovery.node, FSUtil.node]))) beforeAll(async () => { await rm(cacheDir, { recursive: true, force: true }) @@ -24,6 +29,15 @@ beforeAll(async () => { async fetch(req) { const url = new URL(req.url) + if (url.pathname === "/mutable/index.json") { + return Response.json({ skills: [{ name: "mutable", version: mutableVersion, files: mutableFiles }] }) + } + if (url.pathname === "/mutable/mutable/SKILL.md") { + mutableDownloadCount++ + return new Response(mutableContent) + } + if (url.pathname === "/mutable/mutable/old.md") return new Response("old reference") + // route /.well-known/skills/* to the fixture directory if (url.pathname.startsWith("/.well-known/skills/")) { const filePath = url.pathname.replace("/.well-known/skills/", "") @@ -136,4 +150,37 @@ describe("Discovery.pull", () => { expect(downloadCount).toBe(firstCount) }), ) + + it.live("refreshes a remote skill when its version changes", () => + Effect.gen(function* () { + yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true })) + mutableVersion = "1" + mutableContent = "# Old" + mutableDownloadCount = 0 + mutableFiles = ["SKILL.md", "old.md"] + const discovery = yield* Discovery.Service + const url = `http://localhost:${server.port}/mutable/` + + const first = yield* discovery.pull(url) + expect(yield* Effect.promise(() => Bun.file(path.join(first[0], "SKILL.md")).text())).toBe("# Old") + + mutableVersion = "2" + mutableContent = "# Partial" + mutableFiles = ["SKILL.md", "missing.md"] + const second = yield* discovery.pull(url) + expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# Old") + expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).text())).toBe("old reference") + + mutableVersion = "3" + mutableContent = "# New" + mutableFiles = ["SKILL.md"] + yield* discovery.pull(url) + expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# New") + expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).exists())).toBe(false) + expect(mutableDownloadCount).toBe(3) + + yield* discovery.pull(url) + expect(mutableDownloadCount).toBe(3) + }), + ) }) diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 1a3ec258a9..0f035dab1e 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { Skill } from "../../src/skill" import { Discovery } from "../../src/skill/discovery" @@ -13,33 +14,19 @@ import { testEffect } from "../lib/effect" import path from "path" import fs from "fs/promises" -const node = CrossSpawnSpawner.defaultLayer +const node = LayerNode.compile(CrossSpawnSpawner.node) -const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node, testInstanceStoreLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(Skill.node), node, testInstanceStoreLayer)) const itWithoutClaudeCodeSkills = testEffect( Layer.mergeAll( - Skill.layer.pipe( - Layer.provide(Discovery.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layer), - Layer.provide(RuntimeFlags.layer({ disableClaudeCodeSkills: true })), - ), + LayerNode.compile(Skill.node, [[RuntimeFlags.node, RuntimeFlags.layer({ disableClaudeCodeSkills: true })]]), node, testInstanceStoreLayer, ), ) const itWithoutExternalSkills = testEffect( Layer.mergeAll( - Skill.layer.pipe( - Layer.provide(Discovery.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layer), - Layer.provide(RuntimeFlags.layer({ disableExternalSkills: true })), - ), + LayerNode.compile(Skill.node, [[RuntimeFlags.node, RuntimeFlags.layer({ disableExternalSkills: true })]]), node, testInstanceStoreLayer, ), @@ -77,6 +64,33 @@ const withHome = (home: string, self: Effect.Effect) => ) describe("skill", () => { + it.effect("formats verbose locations as XML-safe filesystem paths", () => + Effect.sync(() => { + const output = Skill.fmt( + [ + { + name: "tagged-skill", + description: "A tagged skill.", + location: "/tmp/plugin.git#v1.3.0/SKILL.md", + content: "", + }, + { + name: "built-in-skill", + description: "A built-in skill.", + location: "", + content: "", + }, + ], + { verbose: true }, + ) + + expect(output).toContain("/tmp/plugin.git#v1.3.0/SKILL.md") + expect(output).toContain("<built-in>") + expect(output).not.toContain("file://") + expect(output).not.toContain("%23") + }), + ) + it.live("discovers skills from .opencode/skill/ directory", () => provideTmpdirInstance( (dir) => diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 208bc0e169..a39624087c 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect } from "bun:test" import { $ } from "bun" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import fs from "fs/promises" import path from "path" @@ -15,7 +16,11 @@ import { } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer)) +const it = testEffect( + Layer.mergeAll(LayerNode.compile(LayerNode.group([Snapshot.node, FSUtil.node])), testInstanceStoreLayer), +) +// Windows forbids both * and : in directory names. +const nonWindowsIt = process.platform === "win32" ? it.live.skip : it.live // Git always outputs /-separated paths internally. Snapshot.patch() joins them // with path.join (which produces \ on Windows) then normalizes back to /. @@ -72,11 +77,12 @@ const withTrackedSnapshot = ( }) const bootstrapScoped = Effect.fn("SnapshotTest.bootstrapScoped")(function* () { - const dir = yield* tmpdirScoped({ git: true }).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)) + const dir = yield* tmpdirScoped({ git: true }).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))) return { path: dir, extra: yield* initialize(dir) } }) -const scopedGitTmpdir = () => tmpdirScoped({ git: true }).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)) +const scopedGitTmpdir = () => + tmpdirScoped({ git: true }).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))) const cleanupWorktree = (repo: string, worktree: string, files: string[] = []) => Effect.promise(async () => { @@ -448,6 +454,97 @@ it.live( }), ) +it.live( + "subdirectory snapshots include scoped changes only", + Effect.gen(function* () { + const dir = yield* scopedGitTmpdir() + const frontend = path.join(dir, "frontend") + yield* write(`${frontend}/tracked.txt`, "initial") + yield* write(`${frontend}/deleted.txt`, "initial") + yield* write(`${dir}/backend/tracked.txt`, "initial") + yield* write(`${dir}/backend/deleted.txt`, "initial") + yield* exec(dir, ["git", "add", "."]) + yield* exec(dir, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${frontend}/tracked.txt`, "changed") + yield* write(`${frontend}/untracked.txt`, "new") + yield* rm(`${frontend}/deleted.txt`) + yield* write(`${dir}/backend/tracked.txt`, "changed") + yield* rm(`${dir}/backend/deleted.txt`) + const patch = yield* snapshot.patch(before!) + const diff = yield* snapshot.diff(before!) + expect(patch.files).toContain(fwd(frontend, "tracked.txt")) + expect(patch.files).toContain(fwd(frontend, "untracked.txt")) + expect(patch.files).toContain(fwd(frontend, "deleted.txt")) + expect(patch.files).not.toContain(fwd(dir, "backend", "tracked.txt")) + expect(patch.files).not.toContain(fwd(dir, "backend", "deleted.txt")) + expect(diff).not.toContain("backend/tracked.txt") + expect(diff).not.toContain("backend/deleted.txt") + }).pipe(provideInstance(frontend)) + }), +) + +nonWindowsIt( + "subdirectory snapshots treat wildcard characters literally", + Effect.gen(function* () { + const dir = yield* scopedGitTmpdir() + const subdir = path.join(dir, "src*") + yield* write(`${subdir}/file.txt`, "initial") + yield* write(`${subdir}/later-ignored.txt`, "initial") + yield* write(`${dir}/srca/file.txt`, "initial") + yield* exec(dir, ["git", "add", "."]) + yield* exec(dir, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${subdir}/file.txt`, "changed") + yield* write(`${subdir}/later-ignored.txt`, "changed") + yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n") + yield* write(`${dir}/srca/file.txt`, "changed") + const patch = yield* snapshot.patch(before!) + const diff = yield* snapshot.diff(before!) + expect(patch.files).toContain(fwd(subdir, "file.txt")) + expect(patch.files).toContain(fwd(subdir, ".gitignore")) + expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt")) + expect(patch.files).not.toContain(fwd(dir, "srca", "file.txt")) + expect(diff).toContain("src*/later-ignored.txt") + expect(diff).toContain("deleted file mode") + expect(diff).not.toContain("srca/file.txt") + }).pipe(provideInstance(subdir)) + }), +) + +nonWindowsIt( + "subdirectory snapshots treat leading colons literally", + Effect.gen(function* () { + const dir = yield* scopedGitTmpdir() + const subdir = path.join(dir, ":src") + yield* write(`${subdir}/kept.txt`, "initial") + yield* write(`${subdir}/later-ignored.txt`, "initial") + yield* exec(dir, ["git", "add", "."]) + yield* exec(dir, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${subdir}/kept.txt`, "changed") + yield* write(`${subdir}/later-ignored.txt`, "changed") + yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n") + const patch = yield* snapshot.patch(before!) + const diff = yield* snapshot.diff(before!) + expect(patch.files).toContain(fwd(subdir, "kept.txt")) + expect(patch.files).toContain(fwd(subdir, ".gitignore")) + expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt")) + expect(diff).toContain(":src/later-ignored.txt") + expect(diff).toContain("deleted file mode") + }).pipe(provideInstance(subdir)) + }), +) + it.instance( "gitignore changes", withTrackedSnapshot(({ tmp, snapshot, before }) => diff --git a/packages/opencode/test/storage/storage.test.ts b/packages/opencode/test/storage/storage.test.ts index afb2e93755..a3f3aef446 100644 --- a/packages/opencode/test/storage/storage.test.ts +++ b/packages/opencode/test/storage/storage.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Exit, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -11,7 +12,7 @@ import { testEffect } from "../lib/effect" const dir = path.join(Global.Path.data, "storage") -const it = testEffect(Layer.mergeAll(Storage.defaultLayer, FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Storage.node, FSUtil.node, CrossSpawnSpawner.node]))) const scope = Effect.fnUntraced(function* () { const root = ["storage_test", crypto.randomUUID()] @@ -50,14 +51,14 @@ function remappedFs(root: string) { fs.glob(pattern, options?.cwd ? { ...options, cwd: remap(root, options.cwd) } : options), }) }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) } // Layer.fresh forces a new Storage instance — without it, Effect's in-test layer cache // returns the outer testEffect's Storage (which uses the real FSUtil), not a new // one built on top of remappedFs. const remappedStorage = (root: string) => - Layer.fresh(Storage.layer.pipe(Layer.provide(remappedFs(root)), Layer.provide(Git.defaultLayer))) + Layer.fresh(LayerNode.compile(Storage.node, [[FSUtil.node, remappedFs(root)]])) describe("Storage", () => { it.live("round-trips JSON content", () => diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index b187b191c1..51ff867ea4 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -24,23 +24,6 @@ exports[`tool parameters JSON Schema (wire shape) bash 1`] = ` "description": "The command to execute", "type": "string", }, - "description": { - "description": -"Clear, concise description of what this command does in 5-10 words. Examples: -Input: ls -Output: Lists files in current directory - -Input: git status -Output: Shows working tree status - -Input: npm install -Output: Installs package dependencies - -Input: mkdir foo -Output: Creates directory 'foo'" -, - "type": "string", - }, "timeout": { "description": "Optional timeout in milliseconds", "exclusiveMinimum": 0, @@ -55,7 +38,6 @@ Output: Creates directory 'foo'" }, "required": [ "command", - "description", ], "type": "object", } diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 01a09add87..e394d8084f 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import path from "path" import * as fs from "fs/promises" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit, Layer } from "effect" import { ApplyPatchTool } from "../../src/tool/apply_patch" import { LSP } from "@/lsp/lsp" @@ -14,13 +15,8 @@ import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" const it = testEffect( - Layer.mergeAll( - LSP.defaultLayer, - FSUtil.defaultLayer, - Format.defaultLayer, - EventV2Bridge.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, + LayerNode.compile( + LayerNode.group([LSP.node, FSUtil.node, Format.node, EventV2Bridge.node, Truncate.node, Agent.node]), ), ) diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 12db535518..46155a5091 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { EditTool } from "../../src/tool/edit" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -30,13 +31,8 @@ afterEach(async () => { await disposeAllInstances() }) -const layer = Layer.mergeAll( - LSP.defaultLayer, - FSUtil.defaultLayer, - Format.defaultLayer, - EventV2Bridge.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, +const layer = LayerNode.compile( + LayerNode.group([LSP.node, FSUtil.node, Format.node, EventV2Bridge.node, Truncate.node, Agent.node]), ) const it = testEffect(layer) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 69a48bad7a..d43accfb70 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -1,4 +1,5 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { describe, expect } from "bun:test" import path from "path" import { Effect } from "effect" @@ -11,7 +12,7 @@ import type { Permission } from "../../src/permission" import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" -const it = testEffect(CrossSpawnSpawner.defaultLayer) +const it = testEffect(LayerNode.compile(CrossSpawnSpawner.node)) const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 9be9dc0361..a8557a5706 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -1,6 +1,7 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { describe, expect } from "bun:test" import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit, Layer } from "effect" import { GlobTool } from "../../src/tool/glob" import { SessionID, MessageID } from "../../src/session/schema" @@ -20,13 +21,8 @@ import { Permission } from "../../src/permission" import type * as Tool from "../../src/tool/tool" const toolLayer = (flags: Partial = {}) => - Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Ripgrep.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, - Git.defaultLayer, + LayerNode.compile( + LayerNode.group([CrossSpawnSpawner.node, FSUtil.node, Ripgrep.node, Truncate.node, Agent.node, Git.node]), ) const it = testEffect(toolLayer()) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 8f346cd8ec..05865266d1 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -3,6 +3,7 @@ import { describe, expect } from "bun:test" import fs from "fs/promises" import os from "os" import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { GrepTool } from "../../src/tool/grep" import { provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture" @@ -22,13 +23,8 @@ import { Git } from "@/git" import { Filesystem } from "@/util/filesystem" const toolLayer = (flags: Partial = {}) => - Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Ripgrep.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, - Git.defaultLayer, + LayerNode.compile( + LayerNode.group([CrossSpawnSpawner.node, FSUtil.node, Ripgrep.node, Truncate.node, Agent.node, Git.node]), ) const it = testEffect(toolLayer()) diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index ddcf14e9ae..8735fdb7b2 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -1,5 +1,6 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import path from "path" import { Agent } from "../../src/agent/agent" @@ -56,7 +57,9 @@ const lsp = Layer.succeed( ) const it = testEffect( - Layer.mergeAll(Agent.defaultLayer, FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, lsp), + LayerNode.compile(LayerNode.group([Agent.node, FSUtil.node, CrossSpawnSpawner.node, Truncate.node, LSP.node]), [ + [LSP.node, lsp], + ]), ) const init = Effect.fn("LspToolTest.init")(function* () { diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 4e56c61d23..9c540daad0 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -106,19 +106,16 @@ describe("tool parameters", () => { }) describe("shell", () => { - test("accepts minimum: command + description", () => { - expect(parse(Shell, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) + test("accepts command", () => { + expect(parse(Shell, { command: "ls" })).toEqual({ command: "ls" }) }) test("accepts optional timeout + workdir", () => { - const parsed = parse(Shell, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) + const parsed = parse(Shell, { command: "ls", timeout: 5000, workdir: "/tmp" }) expect(parsed.timeout).toBe(5000) expect(parsed.workdir).toBe("/tmp") }) - test("rejects missing description", () => { - expect(accepts(Shell, { command: "ls" })).toBe(false) - }) test("rejects missing command", () => { - expect(accepts(Shell, { description: "list" })).toBe(false) + expect(accepts(Shell, {})).toBe(false) }) }) diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 0bbc58d442..d8c6df77e8 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -1,10 +1,10 @@ import { describe, expect } from "bun:test" -import { Effect, Fiber, Layer, Queue } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect, Fiber, Queue } from "effect" import { QuestionTool } from "../../src/tool/question" import { Question } from "../../src/question" import { SessionID, MessageID } from "../../src/session/schema" import { Agent } from "../../src/agent/agent" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Truncate } from "@/tool/truncate" import { testEffect } from "../lib/effect" import { EventV2Bridge } from "../../src/event-v2-bridge" @@ -21,12 +21,7 @@ const ctx = { } const it = testEffect( - Layer.mergeAll( - Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), - CrossSpawnSpawner.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, - ), + LayerNode.compile(LayerNode.group([Question.node, EventV2Bridge.node, Truncate.node, Agent.node])), ) const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) { diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 076b5fe3a7..cce90b5bb2 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -1,5 +1,6 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit, Layer, Stream } from "effect" import path from "path" import { Agent } from "../../src/agent/agent" @@ -44,14 +45,16 @@ const ctx = { } const readLayer = (flags: Partial = {}) => - Layer.mergeAll( - Agent.defaultLayer, - FSUtil.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Instruction.defaultLayer, - LSP.defaultLayer, - Ripgrep.defaultLayer, - Truncate.defaultLayer, + LayerNode.compile( + LayerNode.group([ + Agent.node, + FSUtil.node, + CrossSpawnSpawner.node, + Instruction.node, + LSP.node, + Ripgrep.node, + Truncate.node, + ]), ) const it = testEffect(Layer.mergeAll(readLayer(), testInstanceStoreLayer)) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 31b7ede834..cbdcb62980 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -50,16 +50,12 @@ const brokenPluginLayer = Layer.succeed( const root = LayerNode.group([ToolRegistry.node, Agent.node]) const replacements = [ - LayerNode.replace(Config.node, configLayer), - LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer()), -] + [Config.node, configLayer], + [RuntimeFlags.node, RuntimeFlags.layer()], +] as const -const it = testEffect(LayerNode.buildLayer(root, { replacements })) -const withBrokenPlugin = testEffect( - LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(Plugin.node, brokenPluginLayer)], - }), -) +const it = testEffect(LayerNode.compile(root, replacements)) +const withBrokenPlugin = testEffect(LayerNode.compile(root, [...replacements, [Plugin.node, brokenPluginLayer]])) afterEach(async () => { await disposeAllInstances() diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index cec85cd89b..e2fccb32e0 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -1,5 +1,6 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit, Layer } from "effect" import type * as Scope from "effect/Scope" import os from "os" @@ -22,13 +23,17 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { InstanceStore } from "@/project/instance-store" const shellLayer = Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Plugin.defaultLayer, - Truncate.defaultLayer, - Config.defaultLayer, - Agent.defaultLayer, - RuntimeFlags.defaultLayer, + LayerNode.compile( + LayerNode.group([ + CrossSpawnSpawner.node, + FSUtil.node, + Plugin.node, + Truncate.node, + Config.node, + Agent.node, + RuntimeFlags.node, + ]), + ), testInstanceStoreLayer, ) const it = testEffect(shellLayer) @@ -182,7 +187,6 @@ describe("tool.shell", () => { Effect.gen(function* () { const result = yield* run({ command: "echo test", - description: "Echo test message", }) expect(result.metadata.exit).toBe(0) expect(result.metadata.output).toContain("test") @@ -204,7 +208,6 @@ describe("tool.shell", () => { const result = yield* bash.execute( { command: "echo fallback", - description: "Echo fallback text", }, ctx, ) @@ -227,7 +230,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "echo hello", - description: "Echo hello", }, capture(requests), ) @@ -249,7 +251,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "echo foo && echo bar", - description: "Echo twice", }, capture(requests), ) @@ -273,7 +274,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Write-Host foo; if ($?) { Write-Host bar }", - description: "Check PowerShell conditional", }, capture(requests), ) @@ -303,7 +303,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "Remove-Item -Recurse tmp", - description: "Remove a temp directory", }, capture(requests, err), ), @@ -331,7 +330,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `cat ${file}`, - description: "Read wildcard path", }, capture(requests, err), ), @@ -359,7 +357,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `echo $(cat "${file}")`, - description: "Read nested bash file", }, capture(requests), ) @@ -389,7 +386,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`, - description: "Copy Windows ini", }, capture(requests, err), ), @@ -415,7 +411,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `Write-Output $(Get-Content ${file})`, - description: "Read nested PowerShell file", }, capture(requests), ) @@ -446,7 +441,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "C:../outside.txt"', - description: "Read drive-relative file", }, capture(requests, err), ), @@ -474,7 +468,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "$HOME/.ssh/config"', - description: "Read home config", }, capture(requests, err), ), @@ -503,7 +496,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "$PWD/../outside.txt"', - description: "Read pwd-relative file", }, capture(requests, err), ), @@ -531,7 +523,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "$PSHOME/outside.txt"', - description: "Read pshome file", }, capture(requests, err), ), @@ -567,7 +558,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`, - description: "Read Windows ini with missing env", }, capture(requests, err), ), @@ -598,7 +588,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Get-Content $env:WINDIR/win.ini", - description: "Read Windows ini from env", }, capture(requests), ) @@ -626,7 +615,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, - description: "Read Windows ini from FileSystem provider", }, capture(requests, err), ), @@ -655,7 +643,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "Get-Content ${env:WINDIR}/win.ini", - description: "Read Windows ini from braced env", }, capture(requests, err), ), @@ -682,7 +669,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Set-Location C:/Windows", - description: "Change location", }, capture(requests), ) @@ -710,7 +696,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Write-Output ('a' * 3)", - description: "Write repeated text", }, capture(requests), ) @@ -736,7 +721,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`, - description: "Read Windows ini with cmd", }, capture(requests), ) @@ -761,7 +745,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "cd ../", - description: "Change to parent directory", }, capture(requests, err), ), @@ -786,7 +769,6 @@ describe("tool.shell permissions", () => { { command: "echo ok", workdir: os.tmpdir(), - description: "Echo from temp dir", }, capture(requests, err), ), @@ -817,7 +799,6 @@ describe("tool.shell permissions", () => { { command: "echo ok", workdir: dir, - description: "Echo from external dir", }, capture(requests, err), ), @@ -850,7 +831,6 @@ describe("tool.shell permissions", () => { { command: "echo ok", workdir: "/tmp", - description: "Echo from Git Bash tmp", }, capture(requests, err), ), @@ -878,7 +858,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "cat /tmp/opencode-does-not-exist", - description: "Read Git Bash tmp file", }, capture(requests, err), ), @@ -910,7 +889,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `cat ${filepath}`, - description: "Read external file", }, capture(requests, err), ), @@ -922,7 +900,6 @@ describe("tool.shell permissions", () => { expect(extDirReq!.always).toContain(expected) expect(extDirReq!.metadata).toMatchObject({ command: `cat ${filepath}`, - description: "Read external file", directories: [outerTmp], patterns: [expected], }) @@ -942,7 +919,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `rm -rf ${path.join(tmp, "nested")}`, - description: "Remove nested dir", }, capture(requests), ) @@ -963,7 +939,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "git log --oneline -5", - description: "Git log", }, capture(requests), ) @@ -985,7 +960,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "cd .", - description: "Stay in current directory", }, capture(requests), ) @@ -1004,12 +978,9 @@ describe("tool.shell permissions", () => { Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] - expect( - yield* fail( - { command: "echo test > output.txt", description: "Redirect test output" }, - capture(requests, err), - ), - ).toMatchObject({ message: err.message }) + expect(yield* fail({ command: "echo test > output.txt" }, capture(requests, err))).toMatchObject({ + message: err.message, + }) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() expect(bashReq!.patterns).toContain("echo test > output.txt") @@ -1025,7 +996,7 @@ describe("tool.shell permissions", () => { tmp, Effect.gen(function* () { const requests: Array> = [] - yield* run({ command: "ls -la", description: "List" }, capture(requests)) + yield* run({ command: "ls -la" }, capture(requests)) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() expect(bashReq!.always[0]).toBe("ls *") @@ -1047,7 +1018,6 @@ describe("tool.shell abort", () => { const res = yield* run( { command: `echo before && sleep 30`, - description: "Long running command", }, { ...ctx, @@ -1078,7 +1048,6 @@ describe("tool.shell abort", () => { Effect.gen(function* () { const result = yield* run({ command: `sleep 60`, - description: "Timeout test", timeout: 500, }) expect(result.output).toContain("shell tool terminated command after exceeding timeout") @@ -1099,7 +1068,6 @@ describe("tool.shell abort", () => { const result = yield* tool.execute( { command: `sleep 60`, - description: "Default timeout test", }, ctx, ) @@ -1116,7 +1084,6 @@ describe("tool.shell abort", () => { Effect.gen(function* () { const result = yield* run({ command: `echo stdout_msg && echo stderr_msg >&2`, - description: "Stderr test", }) expect(result.output).toContain("stdout_msg") expect(result.output).toContain("stderr_msg") @@ -1132,7 +1099,6 @@ describe("tool.shell abort", () => { Effect.gen(function* () { const result = yield* run({ command: `exit 42`, - description: "Non-zero exit", }) expect(result.metadata.exit).toBe(42) }), @@ -1147,7 +1113,6 @@ describe("tool.shell abort", () => { const result = yield* run( { command: `echo first && sleep 0.1 && echo second`, - description: "Streaming test", }, { ...ctx, @@ -1174,7 +1139,6 @@ describe("tool.shell truncation", () => { const lineCount = Truncate.MAX_LINES + 500 const result = yield* run({ command: fill("lines", lineCount), - description: "Generate lines exceeding limit", }) mustTruncate(result) expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) @@ -1190,7 +1154,6 @@ describe("tool.shell truncation", () => { const byteCount = Truncate.MAX_BYTES + 10000 const result = yield* run({ command: fill("bytes", byteCount), - description: "Generate bytes exceeding limit", }) mustTruncate(result) expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) @@ -1205,7 +1168,6 @@ describe("tool.shell truncation", () => { Effect.gen(function* () { const result = yield* run({ command: fill("lines", 1), - description: "Generate one line", }) expect((result.metadata as { truncated?: boolean }).truncated).toBe(false) expect(result.output).toContain("1") @@ -1220,7 +1182,6 @@ describe("tool.shell truncation", () => { const lineCount = Truncate.MAX_LINES + 100 const result = yield* run({ command: fill("lines", lineCount), - description: "Generate lines for file check", }) mustTruncate(result) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 5603c090a8..382ceafd6e 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -1,10 +1,10 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { Cause, Effect, Exit, Layer } from "effect" import { afterEach, describe, expect } from "bun:test" import path from "path" -import { pathToFileURL } from "url" import type { Permission } from "../../src/permission" import type { Tool } from "@/tool/tool" import { SkillTool } from "../../src/tool/skill" @@ -27,9 +27,7 @@ afterEach(async () => { await disposeAllInstances() }) -const node = CrossSpawnSpawner.defaultLayer - -const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node).pipe(Layer.provide(Ripgrep.defaultLayer))) +const it = testEffect(LayerNode.compile(LayerNode.group([ToolRegistry.node, CrossSpawnSpawner.node, Ripgrep.node]))) describe("tool.skill", () => { it.instance("execute returns skill content block with files", () => @@ -90,7 +88,7 @@ Use this skill. expect(requests[0].always).toContain("tool-skill") expect(result.metadata.dir).toBe(skill) expect(result.output).toContain(``) - expect(result.output).toContain(`Base directory for this skill: ${pathToFileURL(skill).href}`) + expect(result.output).toContain(`Base directory for this skill: ${skill}`) expect(result.output).toContain(`${file}`) }), ) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 97bb7db065..6238a6a077 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { Deferred, Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" @@ -33,20 +35,25 @@ const ref = { } const layer = (flags: Partial = {}) => - Layer.mergeAll( - Agent.defaultLayer, - BackgroundJob.defaultLayer, - EventV2Bridge.defaultLayer, - Config.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Session.defaultLayer, - SessionRunState.defaultLayer, - SessionStatus.defaultLayer, - Truncate.defaultLayer, - ToolRegistry.defaultLayer, - Database.defaultLayer, - RuntimeFlags.layer(flags), - ).pipe(Layer.provide(Ripgrep.defaultLayer)) + LayerNode.compile( + LayerNode.group([ + Agent.node, + BackgroundJob.node, + EventV2Bridge.node, + Config.node, + CrossSpawnSpawner.node, + Session.node, + SessionProjector.node, + SessionRunState.node, + SessionStatus.node, + Truncate.node, + ToolRegistry.node, + Database.node, + RuntimeFlags.node, + Ripgrep.node, + ]), + [[RuntimeFlags.node, RuntimeFlags.layer(flags)]], + ) const it = testEffect(layer()) const background = testEffect(layer({ experimentalBackgroundSubagents: true })) diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index 08e0604362..e254e5050e 100644 --- a/packages/opencode/test/tool/tool-define.test.ts +++ b/packages/opencode/test/tool/tool-define.test.ts @@ -1,12 +1,13 @@ import { describe, expect } from "bun:test" -import { Cause, Effect, Exit, Layer, Schema } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Cause, Effect, Exit, Schema } from "effect" import { Agent } from "../../src/agent/agent" import { MessageID, SessionID } from "../../src/session/schema" import { Tool } from "@/tool/tool" import { Truncate } from "@/tool/truncate" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Truncate.node, Agent.node]))) const params = Schema.Struct({ input: Schema.String }) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 6e65b5f54c..d575a58ffa 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -1,8 +1,9 @@ import { describe, test, expect } from "bun:test" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" -import { NodeFileSystem } from "@effect/platform-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { filesystem } from "@opencode-ai/core/effect/app-node-platform" import { FSUtil } from "@opencode-ai/core/fs-util" -import { Effect, FileSystem, Layer } from "effect" +import { Effect, FileSystem } from "effect" import { Truncate } from "@/tool/truncate" import { Config } from "@/config/config" import { Identifier } from "../../src/id/id" @@ -15,15 +16,12 @@ import { TestConfig } from "../fixture/config" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const ROOT = path.resolve(import.meta.dir, "..", "..") -const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer, FSUtil.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem]))) const configuredLayer = (cfg: ConfigV1.Info) => - Layer.mergeAll( - Truncate.defaultLayer, - NodeFileSystem.layer, - FSUtil.defaultLayer, - TestConfig.layer({ get: () => Effect.succeed(cfg) }), - ) + LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem, Config.node]), [ + [Config.node, TestConfig.layer({ get: () => Effect.succeed(cfg) })], + ]) const configuredIt = (cfg: ConfigV1.Info) => testEffect(configuredLayer(cfg)) describe("Truncate", () => { diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index fdf5210b9c..05599a6784 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -1,6 +1,8 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Effect, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" +import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { Agent } from "../../src/agent/agent" import { Truncate } from "@/tool/truncate" import { WebFetchTool } from "../../src/tool/webfetch" @@ -8,7 +10,11 @@ import { SessionID, MessageID } from "../../src/session/schema" import { Tool } from "@/tool/tool" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)) +const it = testEffect( + LayerNode.compile(LayerNode.group([httpClient, Truncate.node, Agent.node]), [ + [httpClient, FetchHttpClient.layer as Layer.Layer], + ]), +) const ctx = { sessionID: SessionID.make("ses_test"), diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 63a2a52aa9..4f897dc875 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import path from "path" import fs from "fs/promises" @@ -31,14 +32,16 @@ afterEach(async () => { }) const it = testEffect( - Layer.mergeAll( - LSP.defaultLayer, - FSUtil.defaultLayer, - EventV2Bridge.defaultLayer, - Format.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, + LayerNode.compile( + LayerNode.group([ + LSP.node, + FSUtil.node, + EventV2Bridge.node, + Format.node, + CrossSpawnSpawner.node, + Truncate.node, + Agent.node, + ]), ), ) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index dc34f365f9..c627523f97 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.4.15", + "version": "7.4.16", "type": "module", "license": "MIT", "scripts": { @@ -11,7 +11,11 @@ "exports": { ".": "./src/index.ts", "./tool": "./src/tool.ts", - "./tui": "./src/tui.ts" + "./tui": "./src/tui.ts", + "./v2/effect": "./src/v2/effect/index.ts", + "./v2/effect/integration": "./src/v2/effect/integration.ts", + "./v2/effect/plugin": "./src/v2/effect/plugin.ts", + "./v2/promise": "./src/v2/promise/index.ts" }, "files": [ "dist" @@ -19,7 +23,8 @@ "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", - "zod": "catalog:" + "zod": "catalog:", + "@ai-sdk/provider": "3.0.8" }, "peerDependencies": { "@opentui/core": ">=0.3.4", diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 02fddba1dc..0ea19a23eb 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -382,7 +382,7 @@ export type TuiState = { worktree: string directory: string } - readonly vcs: { branch?: string } | undefined + readonly vcs: { branch?: string; default_branch?: string } | undefined session: { count: () => number get: (sessionID: string) => Session | undefined diff --git a/packages/plugin/src/v2/effect/PLAN.md b/packages/plugin/src/v2/effect/PLAN.md new file mode 100644 index 0000000000..e454257848 --- /dev/null +++ b/packages/plugin/src/v2/effect/PLAN.md @@ -0,0 +1,515 @@ +# V2 Plugin System Implementation Plan + +## Status + +This document describes the agreed target design for the V2 plugin system. It is an implementation plan, not documentation for the current API. + +## Goals + +- Internal and external plugins use the same public plugin API. +- Effect plugins import `@kilocode/plugin/v2/effect`, not `@opencode-ai/core`. +- Public domain values use generated `@kilocode/sdk` types. +- Core may retain branded IDs, decoded Effect schemas, and internal service types. +- Plugins may register replayable domain transforms and runtime hooks imperatively during setup. +- Registrations are scoped, independently disposable, ordered, and removable. +- Dynamic sources such as models.dev, config files, and skill directories can rebuild one domain without reloading the entire Location. +- The initial implementation covers the Effect API. A Promise API will be designed afterward as a wrapper over the same capabilities. + +## Authoring Model + +A plugin setup effect receives `PluginHost` and imperatively registers transforms and hooks. + +```ts +export const Plugin = define({ + id: "example", + effect: (ctx) => + Effect.gen(function* () { + yield* ctx.agent.transform( + Effect.fn(function* (agent) { + agent.update("reviewer", (item) => { + item.description = "Reviews code for regressions" + item.mode = "subagent" + }) + }), + ) + + yield* ctx.tool.hook( + "execute.before", + Effect.fn(function* (event) { + event.args.update(sanitizeArgs) + }), + ) + }), +}) +``` + +Plugin setup does not return hooks. + +## Public Naming + +Settled names: + +- Replayable domain registration: `transform` +- Explicit domain replay: `rebuild` +- Runtime callback registration: `hook` +- Registration cleanup: `dispose` +- Event domain: singular `event` +- Other domains are singular: `agent`, `command`, `integration`, `reference`, `session`, `skill`, and `tool`; `catalog` remains `catalog` +- Hook names use dotted lifecycle names such as `"execute.before"` and `"execute.after"` + +## Transform API + +Each transformable domain exposes: + +```ts +interface TransformDomain { + transform(callback: (editor: Editor) => Effect.Effect): Effect.Effect + + rebuild(): Effect.Effect +} +``` + +The actual callback may be represented with the project's normal `Effect.fn` style. + +```ts +const registration = + yield * + ctx.catalog.transform( + Effect.fn(function* (catalog) { + const integration = yield* ctx.integration.get("anthropic") + if (!integration) return + + catalog.provider.update("anthropic", (provider) => { + provider.name = "Anthropic" + }) + }), + ) +``` + +Transforms may perform arbitrary Effects, including reads from other PluginHost services, filesystem I/O, and network I/O. Reads from another domain observe that domain's latest committed state. + +Transforms have no typed error channel. Unexpected failures are defects. + +## Transform Semantics + +- Every call to `transform()` creates an independent registration. +- Multiple transforms from one plugin and domain are allowed. +- Transform order is plugin registration order, then transform registration order within the plugin. +- A transform is automatically removed when its registration scope closes. +- `Registration.dispose` removes it early and is idempotent. +- Registering or disposing a transform automatically rebuilds its domain. +- During bulk plugin boot, automatic rebuilds are deferred and each affected domain is rebuilt once after the batch. +- `rebuild()` waits until replay and finalization complete. +- `rebuild()` always replays every active transform for the domain. +- Rebuilds are serialized and coalesced. Calls arriving during an active rebuild schedule at most one additional rebuild. +- A rebuild captures its registration list at the start. Concurrent registration changes affect the next rebuild. +- Transforms may not register or dispose transforms while replaying. Such changes are rejected or deferred by the runtime. +- Calling `rebuild()` for the currently rebuilding domain from one of its transforms is rejected. +- Rebuilding another domain from a transform is deferred until the current transform finishes. + +## Registration API + +Transforms and runtime hooks return the same Effect registration type. + +```ts +interface Registration { + readonly dispose: Effect.Effect +} +``` + +Registration behavior: + +- Automatically attached to the current `Scope.Scope` +- Explicitly disposable before scope closure +- Disposal affects future replays or invocations +- An in-flight rebuild or hook invocation uses the registration snapshot captured when it started and is allowed to finish + +## Runtime Hook API + +Domains expose runtime interception through `hook()`. + +```ts +const registration = + yield * + ctx.tool.hook( + "execute.before", + Effect.fn(function* (event) { + event.args.update(sanitizeArgs) + }), + ) +``` + +Runtime hook behavior: + +- Multiple registrations for the same hook are allowed. +- Hooks run sequentially in plugin and registration order. +- Later hooks observe mutations made by earlier hooks. +- Hook registration is scope-owned and independently disposable. +- Disposal affects future invocations; an in-flight invocation finishes using its captured registration snapshot. +- Runtime hooks are not replayed during domain rebuilds. +- Runtime hook callbacks have no typed error channel. + +## Hook Contexts + +Each hook receives one purpose-built context object rather than separate input/output parameters. + +```ts +ctx.tool.hook("execute.before", (event) => { + event.args.update((args) => ({ + ...args, + timeout: 30, + })) +}) +``` + +Hook context objects may contain: + +- Readonly SDK-typed operation data +- Purpose-built methods for allowed mutations +- Capability methods where the operation requires more than field assignment + +They must not expose core drafts or unrestricted internal objects. + +## Domain Transforms Versus Runtime Hooks + +Both use the same low-level scoped registration registry, but consumers invoke them differently. + +```ts +ctx.tool.transform(...) // replayed to build effective tool registry state +ctx.tool.hook(...) // invoked at a live tool operation boundary +``` + +The shared low-level machinery owns registration order, scope cleanup, disposal, and snapshots. Each domain owns when its transforms or runtime hooks execute. + +## Event API + +The Effect API exposes the existing event system as typed streams using generated SDK event discriminants. + +```ts +ctx.event.subscribe("catalog.updated") +// Stream.Stream +``` + +Example: + +```ts +yield * + ctx.event.subscribe("catalog.updated").pipe( + Stream.runForEach(() => ctx.agent.rebuild()), + Effect.forkScoped, + ) +``` + +The plugin package derives event payload types from the generated SDK `Event` union: + +```ts +type EventMap = { + [Item in Event as Item["type"]]: Item +} +``` + +Core resolves the public event type string to its internal event definition and delegates to `EventV2.Service.subscribe`. + +## Domain State Model + +Each transformable core service continues to own: + +- Base state +- Effective committed state +- Editor creation +- Ordered transform registrations for that domain +- Rebuild serialization and coalescing +- Core finalization +- Commit and post-commit events + +The initial implementation should evolve the existing generic `State` helper rather than create a central cross-domain state manager. + +```text +base state +→ replay active transforms in order +→ core domain finalization +→ commit effective state +→ publish updated event +``` + +No cross-domain transform or transaction API is included. + +## Finalization + +Each domain has one plugin transform phase followed by core finalization. + +Core finalization is for invariants and materialization, not plugin extension behavior. + +Examples: + +- Catalog policy filtering and validation +- Reference repository materialization +- Integration connection projection +- Index construction +- Post-commit update events + +Finalizers should distinguish pre-commit work from post-commit notification. Update events should publish after the new state is visible. + +## Plugin Order + +The default distribution uses an opinionated internal order: + +```text +1. Built-in agents, commands, and skills +2. Base data sources such as models.dev +3. Configuration projections +4. Provider-specific normalization and authentication +5. External user plugins +6. Core domain finalization +``` + +For catalog transforms: + +```text +models.dev +→ config provider overrides +→ built-in provider normalization +→ user catalog transforms +→ catalog finalization +``` + +This replaces the current distinction between setup-installed State transforms and catalog hooks invoked from the catalog finalizer. + +Replacing a plugin with the same ID retains its existing order position. The old plugin is disabled before the replacement setup starts. + +## Boot Batching + +Plugin boot runs in an internal registration batch. + +```text +begin batch +→ initialize plugins sequentially +→ register transforms and hooks +→ collect affected domains +→ rebuild each affected domain once +→ end batch +``` + +Registration itself is not staged per plugin. If setup fails, closing the plugin's child scope removes every registration made before the failure. + +Outside a batch, transform registration and disposal rebuild immediately. + +## Models.dev Example + +Models.dev performs effectful reads directly from its transforms and rebuilds affected domains after refresh. + +```ts +export const ModelsDevPlugin = define({ + id: "models-dev", + effect: (ctx) => + Effect.gen(function* () { + const modelsDev = yield* ModelsDev.Service + const event = yield* EventV2.Service + + yield* ctx.integration.transform( + Effect.fn(function* (integration) { + const data = yield* modelsDev.get() + applyIntegrations(data, integration) + }), + ) + + yield* ctx.catalog.transform( + Effect.fn(function* (catalog) { + const data = yield* modelsDev.get() + applyCatalog(data, catalog) + }), + ) + + yield* event.subscribe(ModelsDev.Event.Refreshed).pipe( + Stream.runForEach( + Effect.fn(function* () { + yield* ctx.integration.rebuild() + yield* ctx.catalog.rebuild() + }), + ), + Effect.forkScoped({ startImmediately: true }), + ) + }), +}) +``` + +The two domains rebuild sequentially. This plan does not add a cross-domain atomic transaction. + +## Config Watcher Example + +```ts +export const ConfigPlugin = define({ + id: "config", + effect: (ctx) => + Effect.gen(function* () { + const config = yield* ConfigSource.Service + + yield* ctx.agent.transform( + Effect.fn(function* (agent) { + applyAgentConfig(yield* config.get(), agent) + }), + ) + + yield* ctx.command.transform( + Effect.fn(function* (command) { + applyCommandConfig(yield* config.get(), command) + }), + ) + + yield* config.changes.pipe( + Stream.runForEach( + Effect.fn(function* () { + yield* ctx.agent.rebuild() + yield* ctx.command.rebuild() + }), + ), + Effect.forkScoped, + ) + }), +}) +``` + +## Cross-Domain Read Example + +A transform may read another committed service. It must still arrange for its own domain to rebuild when that dependency changes. + +```ts +export const AnthropicAgentPlugin = define({ + id: "anthropic-agent", + effect: (ctx) => + Effect.gen(function* () { + yield* ctx.agent.transform( + Effect.fn(function* (agent) { + const providers = yield* ctx.catalog.provider.list() + if (!providers.some((provider) => provider.id === "anthropic")) return + + agent.update("anthropic-reviewer", (item) => { + item.description = "Reviews code using Anthropic" + item.mode = "subagent" + item.model = { + providerID: "anthropic", + id: "claude-sonnet", + } + }) + }), + ) + + yield* ctx.event.subscribe("catalog.updated").pipe( + Stream.runForEach(() => ctx.agent.rebuild()), + Effect.forkScoped, + ) + }), +}) +``` + +The runtime does not infer cross-domain dependencies. + +## Embedding API Compatibility + +The imperative registration model maps naturally to a future application embedding API: + +```ts +const registration = oc.agent.transform((agent) => { + agent.update("reviewer", configureReviewer) +}) + +registration.dispose() +``` + +An application registration is stored as an application-level plugin registration. It attaches to every current Location and is installed during future Location boot. Disposal removes all current attachments and prevents future attachment. + +The Effect implementation remains the canonical runtime. Promise and embedding wrappers are deferred until after the Effect API is stable. + +## Migration Plan + +### 1. Define Public Contracts + +- Define `PluginHost` domain capabilities in `@kilocode/plugin/v2/effect`. +- Define SDK-typed editors for agent, catalog, command, integration, reference, skill, and tool. +- Define typed runtime hook maps per domain. +- Define `Registration`. +- Define typed `event.subscribe(type)`. + +### 2. Generalize Registration Machinery + +- Add one low-level scoped registration registry used by transforms and runtime hooks. +- Preserve plugin order and registration order. +- Support idempotent disposal and registration snapshots. +- Retain plugin position during same-ID replacement. + +### 3. Evolve State + +- Replace the current returned transform-slot updater with direct `transform(callback)` registration. +- Support Effectful callbacks. +- Add public `rebuild()`. +- Add rebuild serialization and coalescing. +- Add boot batching that defers automatic rebuilds. +- Move update event publication after commit. + +### 4. Expand Domain Transform Hooks + +- Agent +- Catalog +- Command +- Integration +- Reference +- Skill +- Tool + +### 5. Migrate Existing Plugins + +- Built-in agent transform +- Built-in command transform +- Built-in skill transform +- Models.dev catalog and integration transforms +- Config transforms +- OpenAI integration transform +- Provider catalog transforms + +### 6. Migrate Runtime Hooks + +- AI SDK resolution +- Language model resolution +- Tool execution hooks +- Session prompt/context hooks as required + +### 7. Remove Returned Hooks + +- Remove `HookFunctions` as the plugin setup return value. +- Remove catalog's special finalizer-triggered plugin hook path. +- Remove `plugin.added` catalog mutation handling. +- Make add/remove/replacement rely on scoped registration and domain rebuilds. + +### 8. Add Event Adapter + +- Build the SDK event discriminant map. +- Resolve public type strings to internal EventV2 definitions. +- Return typed Effect streams. + +### 9. Verification + +- Transform order is deterministic. +- Multiple transforms per plugin/domain compose. +- Registration and disposal rebuild automatically outside boot batches. +- Boot performs one rebuild per affected domain. +- Plugin setup failure removes prior registrations. +- Same-ID replacement retains order and disables the old plugin first. +- Rebuilds serialize and coalesce. +- Registration changes during replay affect the next rebuild. +- Same-domain recursive rebuild is rejected. +- Cross-domain rebuild requests from transforms are deferred. +- Hook execution is sequential and snapshot-based. +- Models.dev refresh replays config and provider transforms. +- Config and skill watcher refreshes remove stale entries. +- Plugin removal restores prior effective state. +- Events observe newly committed state. + +## Deferred Decisions + +- Promise API shape +- Typed error model +- Transform timeouts +- Cross-domain atomic rebuilds +- Automatic dependency tracking +- Whole-Location generation reload +- Exact editors and runtime hooks not required by current plugins diff --git a/packages/plugin/src/v2/effect/README.md b/packages/plugin/src/v2/effect/README.md new file mode 100644 index 0000000000..2f8a9d143e --- /dev/null +++ b/packages/plugin/src/v2/effect/README.md @@ -0,0 +1,111 @@ +# OpenCode V2 Effect Plugin API + +The Effect plugin API grants plugins two in-process capabilities: + +- `hook` installs behavior at an OpenCode extension point. +- `reload` reruns every transform hook for a stateful domain. + +The public server client will be exposed separately. It is intentionally not part of `PluginContext` yet. + +## Defining A Plugin + +```ts +import { define } from "@kilocode/plugin/v2/effect" +import { Effect } from "effect" + +export const Plugin = define({ + id: "example", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform((catalog) => { + catalog.provider.update("example", (provider) => { + provider.name = "Example" + }) + }) + }), +}) +``` + +Plugin setup registers hooks imperatively. It does not return a hook object. + +Configuration supplied for the plugin is available as `ctx.options`. + +Registrations are owned by the plugin scope. Closing the scope removes them automatically; a registration may also be removed early through `dispose`. + +## Transform Hooks + +Transform hooks contribute to stateful domains: + +```ts +yield * + ctx.agent.transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code for regressions" + item.mode = "subagent" + }) + }) +``` + +OpenCode rebuilds the domain when a transform is registered or disposed. A rebuild starts from fresh domain state and runs every active transform in registration order. + +Available transform hooks are namespaced by domain: + +```ts +ctx.agent.transform +ctx.catalog.transform +ctx.command.transform +ctx.integration.transform +ctx.reference.transform +ctx.skill.transform +``` + +## Runtime Hooks + +Runtime hooks intercept live operations rather than rebuilding domain state: + +```ts +yield * + ctx.aisdk.sdk( + Effect.fn(function* (event) { + if (event.package !== "@ai-sdk/xai") return + const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) + event.sdk = mod.createXai(event.options) + }), + ) + +yield * + ctx.aisdk.language((event) => { + if (event.model.providerID !== "xai") return + event.language = event.sdk.responses(event.model.api.id) + }) +``` + +Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks. + +## Reloading A Domain + +When data captured by a transform changes, reload the affected domain: + +```ts +let data = yield * loadCatalog() + +yield * + ctx.catalog.transform((catalog) => { + applyCatalog(data, catalog) + }) + +data = yield * loadCatalog() +yield * ctx.catalog.reload() +``` + +Reload belongs to the domain, not an individual registration. `ctx.catalog.reload()` reruns every active catalog transform and publishes the rebuilt catalog. + +Available reload operations are: + +```ts +ctx.agent.reload() +ctx.catalog.reload() +ctx.command.reload() +ctx.integration.reload() +ctx.reference.reload() +ctx.skill.reload() +``` diff --git a/packages/plugin/src/v2/effect/agent.ts b/packages/plugin/src/v2/effect/agent.ts new file mode 100644 index 0000000000..9763f4dafa --- /dev/null +++ b/packages/plugin/src/v2/effect/agent.ts @@ -0,0 +1,14 @@ +import type { AgentV2Info } from "@kilocode/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export interface AgentDraft { + list(): readonly AgentV2Info[] + get(id: string): AgentV2Info | undefined + default(id: string | undefined): void + update(id: string, update: (agent: AgentV2Info) => void): void + remove(id: string): void +} + +export type AgentHooks = Hooks<{ + transform: AgentDraft +}> diff --git a/packages/plugin/src/v2/effect/aisdk.ts b/packages/plugin/src/v2/effect/aisdk.ts new file mode 100644 index 0000000000..fbc024892d --- /dev/null +++ b/packages/plugin/src/v2/effect/aisdk.ts @@ -0,0 +1,18 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" +import type { ModelV2Info } from "@kilocode/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export type AISDKHooks = Hooks<{ + sdk: { + readonly model: ModelV2Info + readonly package: string + readonly options: Record + sdk?: any + } + language: { + readonly model: ModelV2Info + readonly sdk: any + readonly options: Record + language?: LanguageModelV3 + } +}> diff --git a/packages/plugin/src/v2/effect/catalog.ts b/packages/plugin/src/v2/effect/catalog.ts new file mode 100644 index 0000000000..bba86173ee --- /dev/null +++ b/packages/plugin/src/v2/effect/catalog.ts @@ -0,0 +1,29 @@ +import type { ModelV2Info, ProviderV2Info } from "@kilocode/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export interface CatalogProviderRecord { + readonly provider: ProviderV2Info + readonly models: ReadonlyMap +} + +export interface CatalogDraft { + readonly provider: { + list(): readonly CatalogProviderRecord[] + get(providerID: string): CatalogProviderRecord | undefined + update(providerID: string, update: (provider: ProviderV2Info) => void): void + remove(providerID: string): void + } + readonly model: { + get(providerID: string, modelID: string): ModelV2Info | undefined + update(providerID: string, modelID: string, update: (model: ModelV2Info) => void): void + remove(providerID: string, modelID: string): void + readonly default: { + get(): { providerID: string; modelID: string } | undefined + set(providerID: string, modelID: string): void + } + } +} + +export type CatalogHooks = Hooks<{ + transform: CatalogDraft +}> diff --git a/packages/plugin/src/v2/effect/command.ts b/packages/plugin/src/v2/effect/command.ts new file mode 100644 index 0000000000..c78188d0b8 --- /dev/null +++ b/packages/plugin/src/v2/effect/command.ts @@ -0,0 +1,13 @@ +import type { CommandV2Info } from "@kilocode/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export interface CommandDraft { + list(): readonly CommandV2Info[] + get(name: string): CommandV2Info | undefined + update(name: string, update: (command: CommandV2Info) => void): void + remove(name: string): void +} + +export type CommandHooks = Hooks<{ + transform: CommandDraft +}> diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts new file mode 100644 index 0000000000..9089334ee3 --- /dev/null +++ b/packages/plugin/src/v2/effect/context.ts @@ -0,0 +1,22 @@ +import type { PluginOptions } from "../options.js" +import type { AgentHooks } from "./agent.js" +import type { AISDKHooks } from "./aisdk.js" +import type { CatalogHooks } from "./catalog.js" +import type { CommandHooks } from "./command.js" +import type { IntegrationHooks } from "./integration.js" +import type { PluginDomain } from "./plugin.js" +import type { ReferenceHooks } from "./reference.js" +import type { SkillHooks } from "./skill.js" +import type { Reload } from "./registration.js" + +export interface PluginContext { + readonly options: PluginOptions + readonly agent: AgentHooks & Reload + readonly aisdk: AISDKHooks + readonly catalog: CatalogHooks & Reload + readonly command: CommandHooks & Reload + readonly integration: IntegrationHooks & Reload + readonly plugin: PluginDomain + readonly reference: ReferenceHooks & Reload + readonly skill: SkillHooks & Reload +} diff --git a/packages/plugin/src/v2/effect/event.ts b/packages/plugin/src/v2/effect/event.ts new file mode 100644 index 0000000000..92ca78c28e --- /dev/null +++ b/packages/plugin/src/v2/effect/event.ts @@ -0,0 +1,10 @@ +import type { Event as SDKEvent } from "@kilocode/sdk/v2/types" +import type { Stream } from "effect" + +export type EventMap = { + [Item in SDKEvent as Item["type"]]: Item +} + +export interface Event { + subscribe(type: Type): Stream.Stream +} diff --git a/packages/plugin/src/v2/effect/filesystem.ts b/packages/plugin/src/v2/effect/filesystem.ts new file mode 100644 index 0000000000..57e0661448 --- /dev/null +++ b/packages/plugin/src/v2/effect/filesystem.ts @@ -0,0 +1,17 @@ +import type { FileSystemEntry } from "@kilocode/sdk/v2/types" +import type { Effect } from "effect" + +export interface FileSystem { + read(input: { readonly path: string }): Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }> + list(input?: { readonly path?: string }): Effect.Effect + find(input: { + readonly query: string + readonly type?: "file" | "directory" + readonly limit?: number + }): Effect.Effect + glob(input: { + readonly pattern: string + readonly path?: string + readonly limit?: number + }): Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts new file mode 100644 index 0000000000..f13614a54d --- /dev/null +++ b/packages/plugin/src/v2/effect/index.ts @@ -0,0 +1,3 @@ +export type { PluginContext } from "./context.js" +export { define } from "./plugin.js" +export type { Plugin } from "./plugin.js" diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts new file mode 100644 index 0000000000..6eb778d45a --- /dev/null +++ b/packages/plugin/src/v2/effect/integration.ts @@ -0,0 +1,63 @@ +import type { + ConnectionInfo, + CredentialOAuth, + CredentialValue, + IntegrationEnvMethod, + IntegrationInputs, + IntegrationKeyMethod, + IntegrationMethod, + IntegrationOAuthMethod, + IntegrationRef, +} from "@kilocode/sdk/v2/types" +import type { Effect, Scope } from "effect" +import type { Hooks } from "./registration.js" + +export type IntegrationOAuthAuthorization = { + readonly url: string + readonly instructions: string +} & ( + | { + readonly mode: "auto" + readonly callback: Effect.Effect + } + | { + readonly mode: "code" + readonly callback: (code: string) => Effect.Effect + } +) +export type IntegrationOAuthMethodRegistration = { + readonly integrationID: string + readonly method: IntegrationOAuthMethod + readonly authorize: (inputs: IntegrationInputs) => Effect.Effect + readonly refresh?: (credential: CredentialOAuth) => Effect.Effect + readonly label?: (credential: CredentialOAuth) => string | undefined +} +export type IntegrationMethodRegistration = + | IntegrationOAuthMethodRegistration + | { + readonly integrationID: string + readonly method: IntegrationKeyMethod + } + | { + readonly integrationID: string + readonly method: IntegrationEnvMethod + } + +export interface IntegrationDraft { + list(): readonly IntegrationRef[] + get(id: string): IntegrationRef | undefined + update(id: string, update: (integration: IntegrationRef) => void): void + remove(id: string): void + readonly method: { + list(integrationID: string): readonly IntegrationMethod[] + update(input: IntegrationMethodRegistration): void + remove(integrationID: string, method: IntegrationMethod): void + } +} + +export interface IntegrationHooks extends Hooks<{ transform: IntegrationDraft }> { + readonly connection: { + readonly active: (integrationID: string) => Effect.Effect + readonly resolve: (connection: ConnectionInfo) => Effect.Effect + } +} diff --git a/packages/plugin/src/v2/effect/location.ts b/packages/plugin/src/v2/effect/location.ts new file mode 100644 index 0000000000..bc546a3b17 --- /dev/null +++ b/packages/plugin/src/v2/effect/location.ts @@ -0,0 +1,6 @@ +export interface Location { + readonly directory: string + readonly project: { + readonly directory: string + } +} diff --git a/packages/plugin/src/v2/effect/npm.ts b/packages/plugin/src/v2/effect/npm.ts new file mode 100644 index 0000000000..4cb96c32d1 --- /dev/null +++ b/packages/plugin/src/v2/effect/npm.ts @@ -0,0 +1,11 @@ +import type { Effect } from "effect" + +export interface Npm { + add(pkg: string): Effect.Effect< + { + readonly directory: string + readonly entrypoint?: string + }, + unknown + > +} diff --git a/packages/plugin/src/v2/effect/path.ts b/packages/plugin/src/v2/effect/path.ts new file mode 100644 index 0000000000..f9045cc32d --- /dev/null +++ b/packages/plugin/src/v2/effect/path.ts @@ -0,0 +1,8 @@ +export interface Path { + readonly home: string + readonly data: string + readonly cache: string + readonly config: string + readonly state: string + readonly temp: string +} diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts new file mode 100644 index 0000000000..54224fa831 --- /dev/null +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -0,0 +1,16 @@ +import type { Effect, Scope } from "effect" +import type { PluginContext } from "./context.js" + +export interface Plugin { + readonly id: string + readonly effect: (context: PluginContext) => Effect.Effect +} + +export function define(plugin: Plugin) { + return plugin +} + +export interface PluginDomain { + readonly add: (plugin: Plugin) => Effect.Effect + readonly remove: (id: string) => Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/reference.ts b/packages/plugin/src/v2/effect/reference.ts new file mode 100644 index 0000000000..08aa350e5b --- /dev/null +++ b/packages/plugin/src/v2/effect/reference.ts @@ -0,0 +1,12 @@ +import type { ReferenceGitSource, ReferenceLocalSource } from "@kilocode/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export interface ReferenceDraft { + add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void + remove(name: string): void + list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[] +} + +export type ReferenceHooks = Hooks<{ + transform: ReferenceDraft +}> diff --git a/packages/plugin/src/v2/effect/registration.ts b/packages/plugin/src/v2/effect/registration.ts new file mode 100644 index 0000000000..dfe5626397 --- /dev/null +++ b/packages/plugin/src/v2/effect/registration.ts @@ -0,0 +1,15 @@ +import type { Effect, Scope } from "effect" + +export interface Registration { + readonly dispose: Effect.Effect +} + +export interface Reload { + readonly reload: () => Effect.Effect +} + +export type Hooks = { + readonly [Name in keyof Spec]: ( + callback: (input: Spec[Name]) => Effect.Effect | void, + ) => Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/skill.ts b/packages/plugin/src/v2/effect/skill.ts new file mode 100644 index 0000000000..3b039a893b --- /dev/null +++ b/packages/plugin/src/v2/effect/skill.ts @@ -0,0 +1,11 @@ +import type { SkillV2Source } from "@kilocode/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export interface SkillDraft { + source(source: SkillV2Source): void + list(): readonly SkillV2Source[] +} + +export type SkillHooks = Hooks<{ + transform: SkillDraft +}> diff --git a/packages/plugin/src/v2/options.ts b/packages/plugin/src/v2/options.ts new file mode 100644 index 0000000000..2b210f943b --- /dev/null +++ b/packages/plugin/src/v2/options.ts @@ -0,0 +1 @@ +export type PluginOptions = Readonly> diff --git a/packages/plugin/src/v2/promise/README.md b/packages/plugin/src/v2/promise/README.md new file mode 100644 index 0000000000..3f509eaef6 --- /dev/null +++ b/packages/plugin/src/v2/promise/README.md @@ -0,0 +1,103 @@ +# OpenCode V2 Promise Plugin API + +The Promise plugin API is the async/await equivalent of `@kilocode/plugin/v2/effect`. It grants plugins the same two in-process capabilities: + +- `hook` installs behavior at an OpenCode extension point. +- `reload` reruns every transform hook for a stateful domain. + +The only difference from the Effect API is the async boundary: hook callbacks, hook registration, `reload`, and `Registration.dispose` use Promises instead of Effects. + +## Defining A Plugin + +```ts +import { define } from "@kilocode/plugin/v2/promise" + +export const Plugin = define({ + id: "example", + setup: async (ctx) => { + await ctx.catalog.transform((catalog) => { + catalog.provider.update("example", (provider) => { + provider.name = "Example" + }) + }) + }, +}) +``` + +Plugin setup registers hooks imperatively. It does not return a hook object. + +Configuration supplied for the plugin is available as `ctx.options`. + +A registration may be removed early through `dispose`: + +```ts +const registration = await ctx.catalog.transform(applyCatalog) +await registration.dispose() +``` + +## Transform Hooks + +Transform hooks contribute to stateful domains. The draft editor is synchronous; the callback may be `async` when it needs to await other work: + +```ts +await ctx.agent.transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code for regressions" + item.mode = "subagent" + }) +}) +``` + +Available transform hooks are namespaced by domain: + +```ts +ctx.agent.transform +ctx.catalog.transform +ctx.command.transform +ctx.integration.transform +ctx.reference.transform +ctx.skill.transform +``` + +## Runtime Hooks + +Runtime hooks intercept live operations: + +```ts +await ctx.aisdk.sdk(async (event) => { + if (event.package !== "@ai-sdk/xai") return + const mod = await import("@ai-sdk/xai") + event.sdk = mod.createXai(event.options) +}) + +await ctx.aisdk.language((event) => { + if (event.model.providerID !== "xai") return + event.language = event.sdk.responses(event.model.api.id) +}) +``` + +## Reloading A Domain + +When data captured by a transform changes, reload the affected domain: + +```ts +let data = await loadCatalog() + +await ctx.catalog.transform((catalog) => { + applyCatalog(data, catalog) +}) + +data = await loadCatalog() +await ctx.catalog.reload() +``` + +Available reload operations are: + +```ts +ctx.agent.reload() +ctx.catalog.reload() +ctx.command.reload() +ctx.integration.reload() +ctx.reference.reload() +ctx.skill.reload() +``` diff --git a/packages/plugin/src/v2/promise/agent.ts b/packages/plugin/src/v2/promise/agent.ts new file mode 100644 index 0000000000..bec589146a --- /dev/null +++ b/packages/plugin/src/v2/promise/agent.ts @@ -0,0 +1,8 @@ +import type { AgentDraft } from "../effect/agent.js" +import type { Hooks } from "./registration.js" + +export type { AgentDraft } + +export type AgentHooks = Hooks<{ + transform: AgentDraft +}> diff --git a/packages/plugin/src/v2/promise/aisdk.ts b/packages/plugin/src/v2/promise/aisdk.ts new file mode 100644 index 0000000000..fbc024892d --- /dev/null +++ b/packages/plugin/src/v2/promise/aisdk.ts @@ -0,0 +1,18 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" +import type { ModelV2Info } from "@kilocode/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export type AISDKHooks = Hooks<{ + sdk: { + readonly model: ModelV2Info + readonly package: string + readonly options: Record + sdk?: any + } + language: { + readonly model: ModelV2Info + readonly sdk: any + readonly options: Record + language?: LanguageModelV3 + } +}> diff --git a/packages/plugin/src/v2/promise/catalog.ts b/packages/plugin/src/v2/promise/catalog.ts new file mode 100644 index 0000000000..70842e94b7 --- /dev/null +++ b/packages/plugin/src/v2/promise/catalog.ts @@ -0,0 +1,8 @@ +import type { CatalogDraft, CatalogProviderRecord } from "../effect/catalog.js" +import type { Hooks } from "./registration.js" + +export type { CatalogDraft, CatalogProviderRecord } + +export type CatalogHooks = Hooks<{ + transform: CatalogDraft +}> diff --git a/packages/plugin/src/v2/promise/command.ts b/packages/plugin/src/v2/promise/command.ts new file mode 100644 index 0000000000..cdc5f8268a --- /dev/null +++ b/packages/plugin/src/v2/promise/command.ts @@ -0,0 +1,8 @@ +import type { CommandDraft } from "../effect/command.js" +import type { Hooks } from "./registration.js" + +export type { CommandDraft } + +export type CommandHooks = Hooks<{ + transform: CommandDraft +}> diff --git a/packages/plugin/src/v2/promise/context.ts b/packages/plugin/src/v2/promise/context.ts new file mode 100644 index 0000000000..9089334ee3 --- /dev/null +++ b/packages/plugin/src/v2/promise/context.ts @@ -0,0 +1,22 @@ +import type { PluginOptions } from "../options.js" +import type { AgentHooks } from "./agent.js" +import type { AISDKHooks } from "./aisdk.js" +import type { CatalogHooks } from "./catalog.js" +import type { CommandHooks } from "./command.js" +import type { IntegrationHooks } from "./integration.js" +import type { PluginDomain } from "./plugin.js" +import type { ReferenceHooks } from "./reference.js" +import type { SkillHooks } from "./skill.js" +import type { Reload } from "./registration.js" + +export interface PluginContext { + readonly options: PluginOptions + readonly agent: AgentHooks & Reload + readonly aisdk: AISDKHooks + readonly catalog: CatalogHooks & Reload + readonly command: CommandHooks & Reload + readonly integration: IntegrationHooks & Reload + readonly plugin: PluginDomain + readonly reference: ReferenceHooks & Reload + readonly skill: SkillHooks & Reload +} diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts new file mode 100644 index 0000000000..8287955657 --- /dev/null +++ b/packages/plugin/src/v2/promise/index.ts @@ -0,0 +1,12 @@ +export type { PluginContext } from "./context.js" +export type { PluginOptions } from "../options.js" +export { define } from "./plugin.js" +export type { Plugin, PluginDomain } from "./plugin.js" +export type { Registration, Reload } from "./registration.js" +export type { AgentDraft, AgentHooks } from "./agent.js" +export type { AISDKHooks } from "./aisdk.js" +export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" +export type { CommandDraft, CommandHooks } from "./command.js" +export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" +export type { ReferenceDraft, ReferenceHooks } from "./reference.js" +export type { SkillDraft, SkillHooks } from "./skill.js" diff --git a/packages/plugin/src/v2/promise/integration.ts b/packages/plugin/src/v2/promise/integration.ts new file mode 100644 index 0000000000..7f516655c4 --- /dev/null +++ b/packages/plugin/src/v2/promise/integration.ts @@ -0,0 +1,14 @@ +import type { IntegrationDraft, IntegrationMethodRegistration } from "../effect/integration.js" +import type { CredentialValue } from "@kilocode/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export type { IntegrationDraft, IntegrationMethodRegistration } + +export interface IntegrationHooks extends Hooks<{ transform: IntegrationDraft }> { + readonly connection: { + readonly active: (integrationID: string) => Promise + readonly resolve: ( + connection: import("@kilocode/sdk/v2/types").ConnectionInfo, + ) => Promise + } +} diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts new file mode 100644 index 0000000000..ab59fb95fc --- /dev/null +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -0,0 +1,15 @@ +import type { PluginContext } from "./context.js" + +export interface Plugin { + readonly id: string + readonly setup: (context: PluginContext) => Promise | void +} + +export function define(plugin: Plugin) { + return plugin +} + +export interface PluginDomain { + readonly add: (plugin: Plugin) => Promise + readonly remove: (id: string) => Promise +} diff --git a/packages/plugin/src/v2/promise/reference.ts b/packages/plugin/src/v2/promise/reference.ts new file mode 100644 index 0000000000..f4b7f8b839 --- /dev/null +++ b/packages/plugin/src/v2/promise/reference.ts @@ -0,0 +1,8 @@ +import type { ReferenceDraft } from "../effect/reference.js" +import type { Hooks } from "./registration.js" + +export type { ReferenceDraft } + +export type ReferenceHooks = Hooks<{ + transform: ReferenceDraft +}> diff --git a/packages/plugin/src/v2/promise/registration.ts b/packages/plugin/src/v2/promise/registration.ts new file mode 100644 index 0000000000..5e0ae7f480 --- /dev/null +++ b/packages/plugin/src/v2/promise/registration.ts @@ -0,0 +1,11 @@ +export interface Registration { + readonly dispose: () => Promise +} + +export interface Reload { + readonly reload: () => Promise +} + +export type Hooks = { + readonly [Name in keyof Spec]: (callback: (input: Spec[Name]) => Promise | void) => Promise +} diff --git a/packages/plugin/src/v2/promise/skill.ts b/packages/plugin/src/v2/promise/skill.ts new file mode 100644 index 0000000000..2efc35f818 --- /dev/null +++ b/packages/plugin/src/v2/promise/skill.ts @@ -0,0 +1,8 @@ +import type { SkillDraft } from "../effect/skill.js" +import type { Hooks } from "./registration.js" + +export type { SkillDraft } + +export type SkillHooks = Hooks<{ + transform: SkillDraft +}> diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 0000000000..f538635b18 --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/protocol", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + "./*": "./src/*.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@opencode-ai/schema": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + }, + "version": "7.4.16" +} diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts new file mode 100644 index 0000000000..7b422d0600 --- /dev/null +++ b/packages/protocol/src/api.ts @@ -0,0 +1,86 @@ +import { Context } from "effect" +import { HttpApi, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi" +import { SchemaErrorMiddleware } from "./middleware/schema-error" +import { MessageGroup } from "./groups/message" +import { ModelGroup } from "./groups/model" +import { ProviderGroup } from "./groups/provider" +import { makeSessionGroup } from "./groups/session" +import { makePermissionGroup } from "./groups/permission" +import { FileSystemGroup } from "./groups/fs" +import { CommandGroup } from "./groups/command" +import { SkillGroup } from "./groups/skill" +import { EventGroup, makeEventGroup } from "./groups/event" +import type { Definition } from "@opencode-ai/schema/event" +import { AgentGroup } from "./groups/agent" +import { HealthGroup } from "./groups/health" +import { PtyGroup } from "./groups/pty" +import { makeQuestionGroup } from "./groups/question" +import { ReferenceGroup } from "./groups/reference" +import { Authorization } from "./middleware/authorization" +import { LocationGroup } from "./groups/location" +import { IntegrationGroup } from "./groups/integration" +import { CredentialGroup } from "./groups/credential" +import { ProjectCopyGroup } from "./groups/project-copy" + +// Protocol owns middleware placement, while Server injects concrete keys so Core service identities stay downstream. +const makeApiFromGroup = < + const Group extends HttpApiGroup.Any, + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>( + eventGroup: Group, + locationMiddleware: Context.Key, + sessionLocationMiddleware: Context.Key, +) => + HttpApi.make("server") + .add(HealthGroup) + .add(LocationGroup.middleware(locationMiddleware)) + .add(AgentGroup.middleware(locationMiddleware)) + .add(makeSessionGroup(sessionLocationMiddleware)) + .add(MessageGroup.middleware(sessionLocationMiddleware)) + .add(ModelGroup.middleware(locationMiddleware)) + .add(ProviderGroup.middleware(locationMiddleware)) + .add(IntegrationGroup.middleware(locationMiddleware)) + .add(CredentialGroup.middleware(locationMiddleware)) + .add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware)) + .add(FileSystemGroup.middleware(locationMiddleware)) + .add(CommandGroup.middleware(locationMiddleware)) + .add(SkillGroup.middleware(locationMiddleware)) + .add(eventGroup) + .add(PtyGroup.middleware(locationMiddleware)) + .add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware)) + .add(ReferenceGroup.middleware(locationMiddleware)) + .add(ProjectCopyGroup.middleware(locationMiddleware)) + .annotateMerge( + OpenApi.annotations({ + title: "opencode HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + .middleware(Authorization) + .middleware(SchemaErrorMiddleware) + +export const makeApi = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>(options: { + readonly definitions: ReadonlyArray + readonly locationMiddleware: Context.Key + readonly sessionLocationMiddleware: Context.Key +}) => + makeApiFromGroup(makeEventGroup(options.definitions), options.locationMiddleware, options.sessionLocationMiddleware) + +export const makeDefaultApi = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>(options: { + readonly locationMiddleware: Context.Key + readonly sessionLocationMiddleware: Context.Key +}) => makeApiFromGroup(EventGroup, options.locationMiddleware, options.sessionLocationMiddleware) diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts new file mode 100644 index 0000000000..3b1eced63a --- /dev/null +++ b/packages/protocol/src/errors.ts @@ -0,0 +1,111 @@ +import { Schema } from "effect" + +export class InvalidRequestError extends Schema.TaggedErrorClass()( + "InvalidRequestError", + { + message: Schema.String, + kind: Schema.optional(Schema.String), + field: Schema.optional(Schema.String), + }, + { httpApiStatus: 400 }, +) {} + +export class UnauthorizedError extends Schema.TaggedErrorClass()( + "UnauthorizedError", + { message: Schema.String }, + { httpApiStatus: 401 }, +) {} + +export class ConflictError extends Schema.TaggedErrorClass()( + "ConflictError", + { + message: Schema.String, + resource: Schema.optional(Schema.String), + }, + { httpApiStatus: 409 }, +) {} + +export class ServiceUnavailableError extends Schema.TaggedErrorClass()( + "ServiceUnavailableError", + { + message: Schema.String, + service: Schema.optional(Schema.String), + }, + { httpApiStatus: 503 }, +) {} + +export class UnknownError extends Schema.TaggedErrorClass()( + "UnknownError", + { + message: Schema.String, + ref: Schema.optional(Schema.String), + }, + { httpApiStatus: 500 }, +) {} + +export class ProviderNotFoundError extends Schema.TaggedErrorClass()( + "ProviderNotFoundError", + { + providerID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class SessionNotFoundError extends Schema.TaggedErrorClass()( + "SessionNotFoundError", + { + sessionID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class MessageNotFoundError extends Schema.TaggedErrorClass()( + "MessageNotFoundError", + { + sessionID: Schema.String, + messageID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class InvalidCursorError extends Schema.TaggedErrorClass()( + "InvalidCursorError", + { message: Schema.String }, + { httpApiStatus: 400 }, +) {} + +export class PermissionNotFoundError extends Schema.TaggedErrorClass()( + "PermissionNotFoundError", + { + requestID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class QuestionNotFoundError extends Schema.TaggedErrorClass()( + "QuestionNotFoundError", + { + requestID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class ForbiddenError extends Schema.TaggedErrorClass()( + "ForbiddenError", + { message: Schema.String }, + { httpApiStatus: 403 }, +) {} + +export class PtyNotFoundError extends Schema.TaggedErrorClass()( + "PtyNotFoundError", + { + ptyID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} diff --git a/packages/protocol/src/groups/agent.ts b/packages/protocol/src/groups/agent.ts new file mode 100644 index 0000000000..0d499e187f --- /dev/null +++ b/packages/protocol/src/groups/agent.ts @@ -0,0 +1,20 @@ +import { Agent } from "@opencode-ai/schema/agent" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const AgentGroup = HttpApiGroup.make("server.agent").add( + HttpApiEndpoint.get("agent.list", "/api/agent", { + query: LocationQuery, + success: Location.response(Schema.Array(Agent.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.agent.list", + summary: "List agents", + description: "Retrieve currently registered agents.", + }), + ), +) diff --git a/packages/protocol/src/groups/command.ts b/packages/protocol/src/groups/command.ts new file mode 100644 index 0000000000..eac33cc292 --- /dev/null +++ b/packages/protocol/src/groups/command.ts @@ -0,0 +1,27 @@ +import { Command } from "@opencode-ai/schema/command" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const CommandGroup = HttpApiGroup.make("server.command") + .add( + HttpApiEndpoint.get("command.list", "/api/command", { + query: LocationQuery, + success: Location.response(Schema.Array(Command.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.command.list", + summary: "List commands", + description: "Retrieve currently registered commands.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "commands", + description: "Experimental command routes.", + }), + ) diff --git a/packages/protocol/src/groups/credential.ts b/packages/protocol/src/groups/credential.ts new file mode 100644 index 0000000000..4f6ce8461b --- /dev/null +++ b/packages/protocol/src/groups/credential.ts @@ -0,0 +1,37 @@ +import { Credential } from "@opencode-ai/schema/credential" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const CredentialGroup = HttpApiGroup.make("server.credential") + .add( + HttpApiEndpoint.patch("credential.update", "/api/credential/:credentialID", { + params: { credentialID: Credential.ID }, + query: LocationQuery, + payload: Schema.Struct({ label: Schema.String }), + success: HttpApiSchema.NoContent, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.credential.update", + summary: "Update credential", + description: "Update a stored credential label.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("credential.remove", "/api/credential/:credentialID", { + params: { credentialID: Credential.ID }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.credential.remove", + summary: "Remove credential", + description: "Remove a stored integration credential.", + }), + ), + ) diff --git a/packages/protocol/src/groups/event.ts b/packages/protocol/src/groups/event.ts new file mode 100644 index 0000000000..a6fc5692ea --- /dev/null +++ b/packages/protocol/src/groups/event.ts @@ -0,0 +1,56 @@ +import { Event } from "@opencode-ai/schema/event" +import { EventManifest } from "@opencode-ai/schema/event-manifest" +import { Location } from "@opencode-ai/schema/location" +import type { Definition } from "@opencode-ai/schema/event" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" + +const fields = { + id: Event.ID, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })), + location: Schema.optional(Location.Ref), +} + +const schema = >(definitions: Definitions) => + Schema.Union([ + ...definitions, + ...(definitions.some((definition) => definition.type === "server.connected") + ? [] + : [ + Schema.Struct({ + ...fields, + type: Schema.Literal("server.connected"), + data: Schema.Struct({}), + }).annotate({ identifier: "V2Event.server.connected" }), + ]), + ]).annotate({ identifier: "V2Event" }) + +const make = >(definitions: Definitions) => { + const EventSchema = schema(definitions) + return { + schema: EventSchema, + group: HttpApiGroup.make("server.event") + .add( + HttpApiEndpoint.get("event.subscribe", "/api/event", { + success: HttpApiSchema.StreamSse({ data: EventSchema }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.event.subscribe", + summary: "Subscribe to events", + description: "Subscribe to native event payloads for the server.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." })), + } +} + +export const makeEventGroup = >(definitions: Definitions) => + make(definitions).group + +const event = make(EventManifest.ServerDefinitions) +export const EventGroup = event.group +export const OpenCodeEvent = event.schema +export type OpenCodeEvent = typeof OpenCodeEvent.Type +export type OpenCodeEventEncoded = typeof OpenCodeEvent.Encoded diff --git a/packages/protocol/src/groups/fs.ts b/packages/protocol/src/groups/fs.ts new file mode 100644 index 0000000000..f5fc00e021 --- /dev/null +++ b/packages/protocol/src/groups/fs.ts @@ -0,0 +1,68 @@ +import { FileSystem } from "@opencode-ai/schema/filesystem" +import { Location } from "@opencode-ai/schema/location" +import { PositiveInt, RelativePath } from "@opencode-ai/schema/schema" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +const ListQuery = Schema.Struct({ + ...LocationQuery.fields, + path: RelativePath.pipe(Schema.optional), +}) + +const FindQuery = Schema.Struct({ + ...LocationQuery.fields, + query: FileSystem.FindInput.fields.query, + type: FileSystem.FindInput.fields.type, + limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional), +}) + +export const FileSystemGroup = HttpApiGroup.make("server.fs") + .add( + HttpApiEndpoint.get("fs.read", "/api/fs/read/*", { + query: LocationQuery, + success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.fs.read", + summary: "Read file", + description: "Serve one file relative to the requested location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("fs.list", "/api/fs/list", { + query: ListQuery, + success: Location.response(Schema.Array(FileSystem.Entry)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.fs.list", + summary: "List directory", + description: "List direct children of one directory relative to the requested location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("fs.find", "/api/fs/find", { + query: FindQuery, + success: Location.response(Schema.Array(FileSystem.Entry)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.fs.find", + summary: "Find files", + description: "Find recursively ranked filesystem entries relative to the requested location.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "filesystem", + description: "Experimental location-scoped filesystem routes.", + }), + ) diff --git a/packages/protocol/src/groups/health.ts b/packages/protocol/src/groups/health.ts new file mode 100644 index 0000000000..18618164f0 --- /dev/null +++ b/packages/protocol/src/groups/health.ts @@ -0,0 +1,14 @@ +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +export const HealthGroup = HttpApiGroup.make("server.health").add( + HttpApiEndpoint.get("health.get", "/api/health", { + success: Schema.Struct({ healthy: Schema.Literal(true) }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.health.get", + summary: "Check server health", + description: "Check whether the API server is ready to accept requests.", + }), + ), +) diff --git a/packages/protocol/src/groups/integration.ts b/packages/protocol/src/groups/integration.ts new file mode 100644 index 0000000000..304681d330 --- /dev/null +++ b/packages/protocol/src/groups/integration.ts @@ -0,0 +1,130 @@ +import { Integration } from "@opencode-ai/schema/integration" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { InvalidRequestError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +const Inputs = Schema.Record(Schema.String, Schema.String) + +export const IntegrationGroup = HttpApiGroup.make("server.integration") + .add( + HttpApiEndpoint.get("integration.list", "/api/integration", { + query: LocationQuery, + success: Location.response(Schema.Array(Integration.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.list", + summary: "List integrations", + description: "Retrieve available integrations and their authentication methods.", + }), + ), + ) + .add( + HttpApiEndpoint.get("integration.get", "/api/integration/:integrationID", { + params: { integrationID: Integration.ID }, + query: LocationQuery, + success: Location.response(Schema.UndefinedOr(Integration.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.get", + summary: "Get integration", + description: "Retrieve one integration and its authentication methods.", + }), + ), + ) + .add( + HttpApiEndpoint.post("integration.connect.key", "/api/integration/:integrationID/connect/key", { + params: { integrationID: Integration.ID }, + query: LocationQuery, + payload: Schema.Struct({ + key: Schema.String, + label: Schema.optional(Schema.String), + }), + success: HttpApiSchema.NoContent, + error: InvalidRequestError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.connect.key", + summary: "Connect with key", + description: "Run a key authentication method and store the resulting credential.", + }), + ), + ) + .add( + HttpApiEndpoint.post("integration.connect.oauth", "/api/integration/:integrationID/connect/oauth", { + params: { integrationID: Integration.ID }, + query: LocationQuery, + payload: Schema.Struct({ + methodID: Integration.MethodID, + inputs: Inputs, + label: Schema.optional(Schema.String), + }), + success: Location.response(Integration.Attempt), + error: InvalidRequestError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.connect.oauth", + summary: "Begin OAuth connection", + description: "Start an OAuth attempt and return the authorization details.", + }), + ), + ) + .add( + HttpApiEndpoint.get("integration.attempt.status", "/api/integration/attempt/:attemptID", { + params: { attemptID: Integration.AttemptID }, + query: LocationQuery, + success: Location.response(Integration.AttemptStatus), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.attempt.status", + summary: "Get OAuth attempt status", + description: "Poll the current status of an OAuth attempt.", + }), + ), + ) + .add( + HttpApiEndpoint.post("integration.attempt.complete", "/api/integration/attempt/:attemptID/complete", { + params: { attemptID: Integration.AttemptID }, + query: LocationQuery, + payload: Schema.Struct({ code: Schema.optional(Schema.String) }), + success: HttpApiSchema.NoContent, + error: InvalidRequestError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.attempt.complete", + summary: "Complete OAuth connection", + description: "Complete a code-based OAuth attempt and store the resulting credential.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("integration.attempt.cancel", "/api/integration/attempt/:attemptID", { + params: { attemptID: Integration.AttemptID }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.attempt.cancel", + summary: "Cancel OAuth connection", + description: "Cancel an OAuth attempt and release its resources.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ title: "integrations", description: "Integration discovery and authentication routes." }), + ) diff --git a/packages/protocol/src/groups/location.ts b/packages/protocol/src/groups/location.ts new file mode 100644 index 0000000000..1752bae9be --- /dev/null +++ b/packages/protocol/src/groups/location.ts @@ -0,0 +1,42 @@ +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +export const LocationQuery = Schema.Struct({ + location: Schema.optional( + Schema.Struct({ + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), + }), + ), +}).annotate({ identifier: "LocationQuery" }) + +export const locationQueryOpenApi = OpenApi.annotations({ + transform: (operation) => { + const parameters = operation.parameters + if (!Array.isArray(parameters)) return operation + return { + ...operation, + parameters: parameters.map((parameter) => + parameter?.name === "location" && parameter?.in === "query" + ? { ...parameter, style: "deepObject", explode: true } + : parameter, + ), + } + }, +}) + +export const LocationGroup = HttpApiGroup.make("server.location").add( + HttpApiEndpoint.get("location.get", "/api/location", { + query: LocationQuery, + success: Location.Info, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.location.get", + summary: "Get location", + description: "Resolve the requested location or the server default location.", + }), + ), +) diff --git a/packages/protocol/src/groups/message.ts b/packages/protocol/src/groups/message.ts new file mode 100644 index 0000000000..7ace0ada99 --- /dev/null +++ b/packages/protocol/src/groups/message.ts @@ -0,0 +1,51 @@ +import { Session } from "@opencode-ai/schema/session" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors" + +export const SessionMessagesQuery = Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), + ).annotate({ + description: "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Message order for the first page. Use desc for newest first or asc for oldest first.", + }), + cursor: Schema.optional( + Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + ), +}).annotate({ identifier: "SessionMessagesQuery" }) + +export const MessageGroup = HttpApiGroup.make("server.message") + .add( + HttpApiEndpoint.get("session.messages", "/api/session/:sessionID/message", { + params: { sessionID: Session.ID }, + query: SessionMessagesQuery, + success: Schema.Struct({ + data: Schema.Array(SessionMessage.Message), + cursor: Schema.Struct({ + previous: Schema.String.pipe(Schema.optional), + next: Schema.String.pipe(Schema.optional), + }), + }).annotate({ identifier: "SessionMessagesResponse" }), + error: [InvalidCursorError, SessionNotFoundError, UnknownError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.messages", + summary: "Get session messages", + description: + "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "messages", + description: "Experimental message routes.", + }), + ) diff --git a/packages/protocol/src/groups/model.ts b/packages/protocol/src/groups/model.ts new file mode 100644 index 0000000000..9125f95289 --- /dev/null +++ b/packages/protocol/src/groups/model.ts @@ -0,0 +1,29 @@ +import { Model } from "@opencode-ai/schema/model" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { ServiceUnavailableError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const ModelGroup = HttpApiGroup.make("server.model") + .add( + HttpApiEndpoint.get("model.list", "/api/model", { + query: LocationQuery, + success: Location.response(Schema.Array(Model.Info)), + error: ServiceUnavailableError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.model.list", + summary: "List models", + description: "Retrieve available models ordered by release date.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "models", + description: "Experimental model routes.", + }), + ) diff --git a/packages/protocol/src/groups/permission.ts b/packages/protocol/src/groups/permission.ts new file mode 100644 index 0000000000..4370e18a71 --- /dev/null +++ b/packages/protocol/src/groups/permission.ts @@ -0,0 +1,137 @@ +import { Agent } from "@opencode-ai/schema/agent" +import { Location } from "@opencode-ai/schema/location" +import { Permission } from "@opencode-ai/schema/permission" +import { PermissionSaved } from "@opencode-ai/schema/permission-saved" +import { Project } from "@opencode-ai/schema/project" +import { Session } from "@opencode-ai/schema/session" +import { Context, Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { PermissionNotFoundError, SessionNotFoundError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const makePermissionGroup = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>( + locationMiddleware: Context.Key, + sessionLocationMiddleware: Context.Key, +) => + HttpApiGroup.make("server.permission") + .add( + HttpApiEndpoint.get("permission.request.list", "/api/permission/request", { + query: LocationQuery, + success: Location.response(Schema.Array(Permission.Request)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.permission.request.list", + summary: "List pending permission requests", + description: "Retrieve pending permission requests for a location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("permission.saved.list", "/api/permission/saved", { + query: Schema.Struct({ projectID: Project.ID.pipe(Schema.optional) }), + success: Schema.Struct({ data: Schema.Array(PermissionSaved.Info) }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.permission.saved.list", + summary: "List saved permissions", + description: "Retrieve saved permissions, optionally filtered by project.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("permission.saved.remove", "/api/permission/saved/:id", { + params: { id: PermissionSaved.ID }, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.permission.saved.remove", + summary: "Remove saved permission", + description: "Remove a saved permission by ID.", + }), + ), + ) + // Effect applies group middleware only to endpoints already added; session endpoints use session placement below. + .middleware(locationMiddleware) + .add( + HttpApiEndpoint.post("session.permission.create", "/api/session/:sessionID/permission", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + id: Permission.ID.pipe(Schema.optional), + action: Permission.Request.fields.action, + resources: Permission.Request.fields.resources, + save: Permission.Request.fields.save, + metadata: Permission.Request.fields.metadata, + source: Permission.Request.fields.source, + agent: Agent.ID.pipe(Schema.optional), + }), + success: Schema.Struct({ + data: Schema.Struct({ id: Permission.ID, effect: Permission.Effect }), + }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.permission.create", + summary: "Create permission request", + description: "Evaluate and, when approval is required, create a permission request for a session.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.permission.list", "/api/session/:sessionID/permission", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Schema.Array(Permission.Request) }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.permission.list", + summary: "List session permission requests", + description: "Retrieve pending permission requests owned by a session.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.permission.get", "/api/session/:sessionID/permission/:requestID", { + params: { sessionID: Session.ID, requestID: Permission.ID }, + success: Schema.Struct({ data: Permission.Request }), + error: [SessionNotFoundError, PermissionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.permission.get", + summary: "Get permission request", + description: "Retrieve a pending permission request owned by a session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.permission.reply", "/api/session/:sessionID/permission/:requestID/reply", { + params: { sessionID: Session.ID, requestID: Permission.ID }, + payload: Schema.Struct({ + reply: Permission.Reply, + message: Schema.String.pipe(Schema.optional), + }), + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, PermissionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.permission.reply", + summary: "Reply to pending permission request", + description: "Respond to a pending permission request owned by a session.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "permissions", description: "Experimental permission routes." })) diff --git a/packages/protocol/src/groups/project-copy.ts b/packages/protocol/src/groups/project-copy.ts new file mode 100644 index 0000000000..c4f0240fe3 --- /dev/null +++ b/packages/protocol/src/groups/project-copy.ts @@ -0,0 +1,56 @@ +import { ProjectCopy } from "@opencode-ai/schema/project-copy" +import { Project } from "@opencode-ai/schema/project" +import { Schema, Struct } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +const root = "/experimental/project/:projectID/copy" + +export class ProjectCopyError extends Schema.ErrorClass("ProjectCopyError")( + { + name: Schema.Literal("ProjectCopyError"), + data: Schema.Struct({ + message: Schema.String, + forceRequired: Schema.optional(Schema.Boolean), + }), + }, + { httpApiStatus: 400 }, +) {} + +const CreatePayload = Schema.Struct(Struct.omit(ProjectCopy.CreateInput.fields, ["projectID", "sourceDirectory"])) +const RemovePayload = Schema.Struct(Struct.omit(ProjectCopy.RemoveInput.fields, ["projectID"])) + +export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy") + .add( + HttpApiEndpoint.post("projectCopy.create", root, { + params: { projectID: Project.ID }, + query: LocationQuery, + payload: CreatePayload, + success: ProjectCopy.Copy, + error: ProjectCopyError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.create" })), + ) + .add( + HttpApiEndpoint.delete("projectCopy.remove", root, { + params: { projectID: Project.ID }, + query: LocationQuery, + payload: RemovePayload, + success: HttpApiSchema.NoContent, + error: ProjectCopyError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.remove" })), + ) + .add( + HttpApiEndpoint.post("projectCopy.refresh", `${root}/refresh`, { + params: { projectID: Project.ID }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + error: ProjectCopyError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.refresh" })), + ) + .annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." })) diff --git a/packages/protocol/src/groups/provider.ts b/packages/protocol/src/groups/provider.ts new file mode 100644 index 0000000000..9089b1a09c --- /dev/null +++ b/packages/protocol/src/groups/provider.ts @@ -0,0 +1,45 @@ +import { Provider } from "@opencode-ai/schema/provider" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { ProviderNotFoundError, ServiceUnavailableError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const ProviderGroup = HttpApiGroup.make("server.provider") + .add( + HttpApiEndpoint.get("provider.list", "/api/provider", { + query: LocationQuery, + success: Location.response(Schema.Array(Provider.Info)), + error: ServiceUnavailableError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.provider.list", + summary: "List providers", + description: "Retrieve active AI providers so clients can show provider availability and configuration.", + }), + ), + ) + .add( + HttpApiEndpoint.get("provider.get", "/api/provider/:providerID", { + params: { providerID: Provider.ID }, + query: LocationQuery, + success: Location.response(Provider.Info), + error: [ProviderNotFoundError, ServiceUnavailableError], + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.provider.get", + summary: "Get provider", + description: "Retrieve a single AI provider so clients can inspect its availability and endpoint settings.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "providers", + description: "Experimental provider routes.", + }), + ) diff --git a/packages/protocol/src/groups/pty.ts b/packages/protocol/src/groups/pty.ts new file mode 100644 index 0000000000..fce7421228 --- /dev/null +++ b/packages/protocol/src/groups/pty.ts @@ -0,0 +1,142 @@ +import { Pty } from "@opencode-ai/schema/pty" +import { PtyTicket } from "@opencode-ai/schema/pty-ticket" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { ForbiddenError, PtyNotFoundError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const PTY_CONNECT_TICKET_QUERY = "ticket" +export const PTY_CONNECT_TOKEN_HEADER = "x-kilo-ticket" +export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1" + +const PTY_CONNECT_PATH = /^\/api\/pty\/[^/]+\/connect$/ + +// Authorization middleware skips credential checks when this matches; the PTY connect handler +// is then responsible for consuming and validating the ticket. +export function hasPtyConnectTicketURL(url: URL) { + return PTY_CONNECT_PATH.test(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY) +} + +export const PtyGroup = HttpApiGroup.make("server.pty") + .add( + HttpApiEndpoint.get("pty.list", "/api/pty", { + query: LocationQuery, + success: Location.response(Schema.Array(Pty.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.list", + summary: "List PTY sessions", + description: "List PTY sessions for a location, including exited sessions retained until removal.", + }), + ), + ) + .add( + HttpApiEndpoint.post("pty.create", "/api/pty", { + query: LocationQuery, + payload: Pty.CreateInput, + success: Location.response(Pty.Info), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.create", + summary: "Create PTY session", + description: "Create a pseudo-terminal session for a location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("pty.get", "/api/pty/:ptyID", { + params: { ptyID: Pty.ID }, + query: LocationQuery, + success: Location.response(Pty.Info), + error: PtyNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.get", + summary: "Get PTY session", + description: "Get one PTY session, including its exit code once exited.", + }), + ), + ) + .add( + HttpApiEndpoint.put("pty.update", "/api/pty/:ptyID", { + params: { ptyID: Pty.ID }, + query: LocationQuery, + payload: Pty.UpdateInput, + success: Location.response(Pty.Info), + error: PtyNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.update", + summary: "Update PTY session", + description: "Update the title or viewport size of one PTY session.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("pty.remove", "/api/pty/:ptyID", { + params: { ptyID: Pty.ID }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + error: PtyNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.remove", + summary: "Remove PTY session", + description: "Terminate and remove one PTY session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("pty.connectToken", "/api/pty/:ptyID/connect-token", { + params: { ptyID: Pty.ID }, + query: LocationQuery, + success: Location.response(PtyTicket.ConnectToken), + error: [ForbiddenError, PtyNotFoundError], + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.connectToken", + summary: "Create PTY WebSocket token", + description: "Create a short-lived single-use ticket for opening a PTY WebSocket connection.", + }), + ), + ) + .add( + // Query fields are decoded in the raw handler after the existence check so a missing + // session responds with an empty 404 before any upgrade work. + HttpApiEndpoint.get("pty.connect", "/api/pty/:ptyID/connect", { + params: { ptyID: Pty.ID }, + success: Schema.Boolean, + error: [ForbiddenError, PtyNotFoundError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.connect", + summary: "Connect to PTY session", + description: "Establish a WebSocket connection streaming PTY output and accepting terminal input.", + transform: (operation) => ({ + ...operation, + parameters: [ + ...(operation.parameters ?? []), + ...["location[directory]", "location[workspace]", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({ + in: "query", + name, + schema: { type: "string" }, + })), + ], + }), + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental location-scoped PTY routes." })) diff --git a/packages/protocol/src/groups/question.ts b/packages/protocol/src/groups/question.ts new file mode 100644 index 0000000000..e6d862334d --- /dev/null +++ b/packages/protocol/src/groups/question.ts @@ -0,0 +1,84 @@ +import { Question } from "@opencode-ai/schema/question" +import { Location } from "@opencode-ai/schema/location" +import { Session } from "@opencode-ai/schema/session" +import { Context, Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { QuestionNotFoundError, SessionNotFoundError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const makeQuestionGroup = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>( + locationMiddleware: Context.Key, + sessionLocationMiddleware: Context.Key, +) => + HttpApiGroup.make("server.question") + .add( + HttpApiEndpoint.get("question.request.list", "/api/question/request", { + query: LocationQuery, + success: Location.response(Schema.Array(Question.Request)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.question.request.list", + summary: "List pending question requests", + description: "Retrieve pending question requests for a location.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." })) + // Effect applies group middleware only to endpoints already added; session endpoints use session placement below. + .middleware(locationMiddleware) + .add( + HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Schema.Array(Question.Request) }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.question.list", + summary: "List session question requests", + description: "Retrieve pending question requests owned by a session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", { + params: { sessionID: Session.ID, requestID: Question.ID }, + payload: Question.Reply, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, QuestionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.question.reply", + summary: "Reply to pending question request", + description: "Answer a pending question request owned by a session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.question.reject", "/api/session/:sessionID/question/:requestID/reject", { + params: { sessionID: Session.ID, requestID: Question.ID }, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, QuestionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.question.reject", + summary: "Reject pending question request", + description: "Reject a pending question request owned by a session.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ title: "session questions", description: "Experimental session question routes." }), + ) diff --git a/packages/protocol/src/groups/reference.ts b/packages/protocol/src/groups/reference.ts new file mode 100644 index 0000000000..d953cd530a --- /dev/null +++ b/packages/protocol/src/groups/reference.ts @@ -0,0 +1,27 @@ +import { Location } from "@opencode-ai/schema/location" +import { Reference } from "@opencode-ai/schema/reference" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const ReferenceGroup = HttpApiGroup.make("server.reference") + .add( + HttpApiEndpoint.get("reference.list", "/api/reference", { + query: LocationQuery, + success: Location.response(Schema.Array(Reference.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.reference.list", + summary: "List references", + description: "List references available in the requested location.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "reference", + description: "Location-scoped project references.", + }), + ) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts new file mode 100644 index 0000000000..8ce85ef796 --- /dev/null +++ b/packages/protocol/src/groups/session.ts @@ -0,0 +1,379 @@ +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { SessionInput } from "@opencode-ai/schema/session-input" +import { PromptInput } from "@opencode-ai/schema/prompt-input" +import { Session } from "@opencode-ai/schema/session" +import { Project } from "@opencode-ai/schema/project" +import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema" +import { Workspace } from "@opencode-ai/schema/workspace" +import { Context, Effect, Encoding, Result, Schema, Struct } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { + ConflictError, + InvalidCursorError, + InvalidRequestError, + MessageNotFoundError, + ServiceUnavailableError, + SessionNotFoundError, + UnknownError, +} from "../errors" +import { Agent } from "@opencode-ai/schema/agent" +import { Model } from "@opencode-ai/schema/model" +import { Location } from "@opencode-ai/schema/location" +import { Revert } from "@opencode-ai/schema/revert" +import { SessionEvent } from "@opencode-ai/schema/session-event" + +const SessionsQueryFields = { + workspace: Workspace.ID.pipe(Schema.optional), + limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Session order for the first page. Use desc for newest first or asc for oldest first.", + }), + search: Schema.optional(Schema.String), +} + +const SessionsDirectoryQuery = Schema.Struct({ + ...SessionsQueryFields, + directory: AbsolutePath, +}) + +const SessionsProjectQuery = Schema.Struct({ + ...SessionsQueryFields, + project: Project.ID, + subpath: RelativePath.pipe(Schema.optional), +}) + +const SessionsAllQuery = Schema.Struct(SessionsQueryFields) + +const withCursor = (schema: Schema.Struct) => + schema.mapFields((fields) => ({ + ...Struct.omit(fields, ["limit"]), + anchor: Session.ListAnchor, + })) + +const SessionsCursorInput = Schema.Union([ + withCursor(SessionsDirectoryQuery), + withCursor(SessionsProjectQuery), + withCursor(SessionsAllQuery), +]) +const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput) +const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson) +const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson) +const invalidCursor = "Invalid cursor" as const + +export const SessionsCursor = Schema.String.pipe( + Schema.brand("SessionsCursor"), + statics((schema) => { + const make = schema.make.bind(schema) + return { + make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))), + parse: (input: string) => + Effect.suspend(() => { + const result = Encoding.decodeBase64UrlString(input) + return Result.isFailure(result) + ? Effect.fail(invalidCursor) + : decodeSessionsCursor(result.success).pipe(Effect.mapError(() => invalidCursor)) + }), + } + }), +) +export type SessionsCursor = typeof SessionsCursor.Type + +const SessionActive = Schema.Struct({ + type: Schema.Literal("running"), +}).annotate({ identifier: "SessionActive" }) + +const SessionHistoryLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(100)) + +export const SessionHistoryQuery = Schema.Struct({ + limit: Schema.NumberFromString.pipe(Schema.decodeTo(SessionHistoryLimit), Schema.optional), + after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), +}) + +const SessionsQueryCursor = SessionsCursor.annotate({ + description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.", +}) + +export const SessionsQuery = Schema.Struct({ + ...SessionsQueryFields, + directory: AbsolutePath.pipe(Schema.optional), + project: Project.ID.pipe(Schema.optional), + subpath: RelativePath.pipe(Schema.optional), + cursor: SessionsQueryCursor.pipe(Schema.optional), +}).annotate({ identifier: "SessionsQuery" }) + +export const makeSessionGroup = (sessionLocationMiddleware: Context.Key) => + HttpApiGroup.make("server.session") + .add( + HttpApiEndpoint.get("session.list", "/api/session", { + query: SessionsQuery, + success: Schema.Struct({ + data: Schema.Array(Session.Info), + cursor: Schema.Struct({ + previous: SessionsCursor.pipe(Schema.optional), + next: SessionsCursor.pipe(Schema.optional), + }), + }).annotate({ identifier: "SessionsResponse" }), + error: [InvalidCursorError, InvalidRequestError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.list", + summary: "List sessions", + description: + "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.create", "/api/session", { + payload: Schema.Struct({ + id: Session.ID.pipe(Schema.optional), + agent: Agent.ID.pipe(Schema.optional), + model: Model.Ref.pipe(Schema.optional), + location: Location.Ref.pipe(Schema.optional), + }), + success: Schema.Struct({ data: Session.Info }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.create", + summary: "Create session", + description: "Create a session at the requested location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.active", "/api/session/active", { + success: Schema.Struct({ data: Schema.Record(Session.ID, SessionActive) }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.active", + summary: "List active sessions", + description: + "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.get", "/api/session/:sessionID", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Session.Info }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.get", + summary: "Get session", + description: "Retrieve a session by ID.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ agent: Agent.ID }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.switchAgent", + summary: "Switch session agent", + description: "Switch the agent used by subsequent provider turns.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.switchModel", "/api/session/:sessionID/model", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ model: Model.Ref }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.switchModel", + summary: "Switch session model", + description: "Switch the model used by subsequent provider turns.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + id: SessionMessage.ID.pipe(Schema.optional), + prompt: PromptInput.Prompt, + delivery: SessionInput.Delivery.pipe(Schema.optional), + resume: Schema.Boolean.pipe(Schema.optional), + }), + success: Schema.Struct({ data: SessionInput.Admitted }), + error: [ConflictError, SessionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.prompt", + summary: "Send message", + description: "Durably admit one session input and schedule agent-loop execution unless resume is false.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, ServiceUnavailableError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.compact", + summary: "Compact session", + description: "Compact a session conversation.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.wait", "/api/session/:sessionID/wait", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, ServiceUnavailableError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.wait", + summary: "Wait for session", + description: "Wait for a session agent loop to become idle.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.revert.stage", "/api/session/:sessionID/revert/stage", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }), + success: Schema.Struct({ data: Revert.State }), + error: [MessageNotFoundError, SessionNotFoundError, UnknownError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.revert.stage", + summary: "Stage session revert", + description: "Stage or move a reversible session boundary and optionally apply its file changes.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.revert.clear", "/api/session/:sessionID/revert/clear", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, UnknownError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.clear", summary: "Clear staged revert" })), + ) + .add( + HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/revert/commit", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ identifier: "v2.session.revert.commit", summary: "Commit staged revert" }), + ), + ) + .add( + HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }), + error: [SessionNotFoundError, UnknownError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.context", + summary: "Get session context", + description: "Retrieve the active context messages for a session (all messages after the last compaction).", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.history", "/api/session/:sessionID/history", { + params: { sessionID: Session.ID }, + query: SessionHistoryQuery, + success: Schema.Struct({ + data: Schema.Array(SessionEvent.Durable), + hasMore: Schema.Boolean, + }).annotate({ identifier: "SessionHistory" }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.history", + summary: "Get session history", + description: + "Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.events", "/api/session/:sessionID/event", { + params: { sessionID: Session.ID }, + query: { + after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), + }, + success: HttpApiSchema.StreamSse({ data: SessionEvent.Durable }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.events", + summary: "Subscribe to session events", + description: "Replay durable events after an aggregate sequence, then continue with new durable events.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.interrupt", "/api/session/:sessionID/interrupt", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.interrupt", + summary: "Interrupt session execution", + description: "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.message", "/api/session/:sessionID/message/:messageID", { + params: { sessionID: Session.ID, messageID: SessionMessage.ID }, + success: Schema.Struct({ data: SessionMessage.Message }), + error: [SessionNotFoundError, MessageNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.message", + summary: "Get session message", + description: "Retrieve one projected message owned by the Session.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "sessions", + description: "Experimental session routes.", + }), + ) diff --git a/packages/protocol/src/groups/skill.ts b/packages/protocol/src/groups/skill.ts new file mode 100644 index 0000000000..ab998a538e --- /dev/null +++ b/packages/protocol/src/groups/skill.ts @@ -0,0 +1,27 @@ +import { Skill } from "@opencode-ai/schema/skill" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const SkillGroup = HttpApiGroup.make("server.skill") + .add( + HttpApiEndpoint.get("skill.list", "/api/skill", { + query: LocationQuery, + success: Location.response(Schema.Array(Skill.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.skill.list", + summary: "List skills", + description: "Retrieve currently registered skills.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "skills", + description: "Experimental skill routes.", + }), + ) diff --git a/packages/protocol/src/middleware/authorization.ts b/packages/protocol/src/middleware/authorization.ts new file mode 100644 index 0000000000..ed1c3caf66 --- /dev/null +++ b/packages/protocol/src/middleware/authorization.ts @@ -0,0 +1,6 @@ +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { UnauthorizedError } from "../errors" + +export class Authorization extends HttpApiMiddleware.Service()("@opencode/HttpApiAuthorization", { + error: UnauthorizedError, +}) {} diff --git a/packages/protocol/src/middleware/schema-error.ts b/packages/protocol/src/middleware/schema-error.ts new file mode 100644 index 0000000000..635ecec197 --- /dev/null +++ b/packages/protocol/src/middleware/schema-error.ts @@ -0,0 +1,7 @@ +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { InvalidRequestError } from "../errors" + +export class SchemaErrorMiddleware extends HttpApiMiddleware.Service()( + "@opencode/HttpApiSchemaError", + { error: InvalidRequestError }, +) {} diff --git a/packages/protocol/sst-env.d.ts b/packages/protocol/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/protocol/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/protocol/test/session-cursor.test.ts b/packages/protocol/test/session-cursor.test.ts new file mode 100644 index 0000000000..2680c962e1 --- /dev/null +++ b/packages/protocol/test/session-cursor.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { SessionHistoryQuery, SessionsCursor } from "../src/groups/session" +import { Session } from "@opencode-ai/schema/session" + +describe("SessionsCursor", () => { + test("round trips without Node globals", async () => { + const input = { + workspace: undefined, + search: "protocol", + order: "desc" as const, + anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const }, + } + const cursor = SessionsCursor.make(input) + + expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input) + }) +}) + +describe("SessionHistoryQuery", () => { + test("decodes numeric paging inputs", async () => { + const query = await Effect.runPromise(Schema.decodeUnknownEffect(SessionHistoryQuery)({ after: "3", limit: "10" })) + + expect(query).toEqual({ after: 3, limit: 10 }) + }) +}) diff --git a/packages/protocol/tsconfig.json b/packages/protocol/tsconfig.json new file mode 100644 index 0000000000..00ef125468 --- /dev/null +++ b/packages/protocol/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/schema/AGENTS.md b/packages/schema/AGENTS.md new file mode 100644 index 0000000000..1d5747d539 --- /dev/null +++ b/packages/schema/AGENTS.md @@ -0,0 +1,88 @@ +# Schema Package Guide + +`@opencode-ai/schema` owns browser-safe wire and storage contracts shared by protocol, server, core, and generated SDKs. Keep runtime behavior, service layers, side effects, and host-local implementation details in the domain package that owns them. + +## Package Boundary + +- Preserve the dependency direction: `@opencode-ai/schema <- @opencode-ai/protocol <- @opencode-ai/server`. +- Schema values should be serializable contract definitions, not service implementations or runtime registries. +- A domain may keep a minimal public wire contract here when SDK generation needs it, but do not move the broader runtime model into Schema just because an event is public. `plugin.added` is the current example: Schema may own the minimum browser-safe event payload, while plugin runtime behavior stays outside Schema. +- The root barrel exports canonical current domain contracts. Specialized event modules, manifests, infrastructure modules, and V1 contracts use direct entrypoints instead of becoming first-class root exports. + +## Current Versus V1 + +- Current contracts are unversioned: use names like `Session`, `Permission`, `Question`, and identifiers like `Permission.Request`. +- Legacy contracts retained for active compatibility, persistence, or migration are explicitly `V1`: use names like `SessionV1`, `PermissionV1`, and identifiers like `PermissionV1.Request`. +- Do not preserve `V2` as the permanent name for the replacement architecture. Remove `V2` from current namespaces, brands, and identifiers as the contracts are normalized. +- Retained V1 contracts should live under a dedicated `src/v1/` subtree once the V1 isolation PR runs. New/current code must not depend on that subtree. +- V1 coexistence is temporary. Keep compatibility entrypoints only where migration requires them, and delete the V1 subtree when the legacy runtime is retired. +- `@opencode-ai/protocol` and `@opencode-ai/sdk-next` are current `/api/...` surfaces. + +## Events + +- Classify event definitions by protocol role before adding them to a public manifest: `current`, `shared transitional`, or `V1-only`. +- Being emitted by V1 is not enough to include an event in Protocol or SDK Next. +- Keep clearly V1-only events, such as `message.updated` and `message.part.*`, out of the current Protocol/SDK Next event surface unless a current-client requirement is documented. +- Keep compatibility events available only to the existing App/TUI/CLI compatibility surface while they are still needed. +- Preserve a single canonical event definition. Do not duplicate definitions for generation convenience. + +## Module Shape + +- Use one canonical exported value for each contract. Avoid bridge aliases such as `PluginID`, `PluginEvent`, `PtyInfo`, `PtyEvent`, and `SessionTodoInfo`. +- Prefer importing the schema module namespace and reading canonical members, for example `Plugin.ID` or `SessionTodo.Info`. +- Core may compose Schema contracts with runtime behavior into a deliberate domain facade, but the facade must re-export the exact canonical Schema value. Do not create a second schema identity. +- Use flat top-level exports plus the package's existing namespace projection pattern, for example `export * as SessionMessage from "./session-message"`. +- Keep standalone ID modules only when they prevent real cycles or heavy dependency edges. Inline one-off IDs into their owning contract module when no cycle exists. + +## Naming + +- Exported schema values and namespace objects use `PascalCase`. +- Schema-building functions and combinators use `camelCase`. +- The package's static-method combinator is `statics(...)`. +- Keep descriptive schema value names such as `PositiveInt`, `NonNegativeInt`, `AbsolutePath`, `RelativePath`, and `DateTimeUtcFromMillis`. + +## Optional Fields And Defaults + +- Use the package `optional(...)` helper for optional object properties, including nested structs and event payloads, so encoded objects omit `undefined` keys. +- Use raw `Schema.optional(...)` only when preserving `undefined` as an explicitly encoded property is intentional and documented. +- External convenience defaults are normally decode-only with `Schema.withDecodingDefault(...)`. +- Add constructor defaults only when the domain value itself requires construction-time normalization. + +## Public Types + +- Public `Schema.Struct` records use same-name interfaces: + + ```ts + export interface Info extends Schema.Schema.Type {} + export const Info = Schema.Struct({ ... }) + ``` + +- Use type aliases for unions, scalars, arrays, branded scalar types, and event payload helper types. +- Closed documented string sets use `Schema.Literals(...)`. If arbitrary strings are valid, document the field as arbitrary rather than listing a closed set. + +## Mutability + +- Public Schema contracts are readonly by default. +- Do not use `Schema.mutable(...)` in public contracts for runtime convenience. +- Runtime code that needs mutation should opt in at the boundary with `Types.DeepMutable`, a purpose-built draft type, or another explicit mutable API. + +## Unknown Values + +- Current public contracts avoid `Schema.Any`. +- Use `Schema.Json` for values that must be JSON-serializable. +- Use `Schema.Unknown` for genuinely opaque values that require consumer-side narrowing. +- Keep `Schema.Any` only at an explicitly unsafe compatibility boundary with a documented reason. + +## IDs And Identifiers + +- Current ID constructors expose `create()`. +- Directional constructors such as `ascending()` or `descending()` remain only where ordering semantics are part of the public contract or compatibility requires the old method. +- New generated ID schemas must validate exactly the prefix they emit, including the underscore. +- Do not tighten legacy loose ID validators without an explicit compatibility and migration decision; existing callers and tests may rely on accepted non-canonical IDs. +- Reusable exported public schemas get stable, domain-qualified identifiers such as `Model.Ref` or `Agent.Color`. +- Public schema identifiers and brands must be unique and stable. Private one-use nested schemas may remain anonymous. + +## Tests For Contract Changes + +- Add focused tests when changing contract behavior or generated surface. +- Cover optional properties omitting `undefined`, no accidental current-contract `Schema.Any`, stable and unique public identifiers, exact facade/schema identity, and current Protocol manifests excluding V1-only events. diff --git a/packages/schema/package.json b/packages/schema/package.json new file mode 100644 index 0000000000..577df3666b --- /dev/null +++ b/packages/schema/package.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/schema", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + }, + "version": "7.4.16" +} diff --git a/packages/schema/src/agent.ts b/packages/schema/src/agent.ts new file mode 100644 index 0000000000..adf7aec282 --- /dev/null +++ b/packages/schema/src/agent.ts @@ -0,0 +1,38 @@ +export * as Agent from "./agent" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Model } from "./model" +import { Permission } from "./permission" +import { Provider } from "./provider" +import { PositiveInt, statics } from "./schema" + +export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID")) +export type ID = typeof ID.Type + +export const Color = Schema.Union([ + Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), + Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), +]).annotate({ identifier: "Agent.Color" }) +export type Color = typeof Color.Type + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + id: ID, + model: Model.Ref.pipe(optional), + request: Provider.Request, + system: Schema.String.pipe(optional), + description: Schema.String.pipe(optional), + mode: Schema.Literals(["subagent", "primary", "all"]), + hidden: Schema.Boolean, + color: Color.pipe(optional), + steps: PositiveInt.pipe(optional), + permissions: Permission.Ruleset, +}) + .annotate({ identifier: "AgentV2.Info" }) + .pipe( + statics((schema) => ({ + empty: (id: ID) => + schema.make({ id, request: { headers: {}, body: {} }, mode: "all", hidden: false, permissions: [] }), + })), + ) diff --git a/packages/schema/src/catalog.ts b/packages/schema/src/catalog.ts new file mode 100644 index 0000000000..54abb5b128 --- /dev/null +++ b/packages/schema/src/catalog.ts @@ -0,0 +1,6 @@ +export * as Catalog from "./catalog" + +import { define, inventory } from "./event" + +const Updated = define({ type: "catalog.updated", schema: {} }) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/command.ts b/packages/schema/src/command.ts new file mode 100644 index 0000000000..2bdf3a95c5 --- /dev/null +++ b/packages/schema/src/command.ts @@ -0,0 +1,15 @@ +export * as Command from "./command" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Model } from "./model" + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + name: Schema.String, + template: Schema.String, + description: Schema.String.pipe(optional), + agent: Schema.String.pipe(optional), + model: Model.Ref.pipe(optional), + subtask: Schema.Boolean.pipe(optional), +}).annotate({ identifier: "CommandV2.Info" }) diff --git a/packages/schema/src/connection.ts b/packages/schema/src/connection.ts new file mode 100644 index 0000000000..2bacb977a8 --- /dev/null +++ b/packages/schema/src/connection.ts @@ -0,0 +1,22 @@ +export * as Connection from "./connection" + +import { Schema } from "effect" +import { Credential } from "./credential" + +export interface CredentialInfo extends Schema.Schema.Type {} +export const CredentialInfo = Schema.Struct({ + type: Schema.Literal("credential"), + id: Credential.ID, + label: Schema.String, +}).annotate({ identifier: "Connection.CredentialInfo" }) + +export interface EnvInfo extends Schema.Schema.Type {} +export const EnvInfo = Schema.Struct({ + type: Schema.Literal("env"), + name: Schema.String, +}).annotate({ identifier: "Connection.EnvInfo" }) + +export const Info = Schema.Union([CredentialInfo, EnvInfo]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Connection.Info" }) +export type Info = typeof Info.Type diff --git a/packages/schema/src/credential.ts b/packages/schema/src/credential.ts new file mode 100644 index 0000000000..e54e2f680b --- /dev/null +++ b/packages/schema/src/credential.ts @@ -0,0 +1,35 @@ +export * as Credential from "./credential" + +import { Schema } from "effect" +import { optional } from "./schema" +import { IntegrationMethodID } from "./integration-id" +import { ascending } from "./identifier" +import { NonNegativeInt, statics } from "./schema" + +export const ID = Schema.String.pipe( + Schema.brand("Credential.ID"), + statics((schema) => ({ create: () => schema.make("cred_" + ascending()) })), +) +export type ID = typeof ID.Type + +export interface OAuth extends Schema.Schema.Type {} +export const OAuth = Schema.Struct({ + type: Schema.Literal("oauth"), + methodID: IntegrationMethodID, + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, + metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), +}).annotate({ identifier: "Credential.OAuth" }) + +export interface Key extends Schema.Schema.Type {} +export const Key = Schema.Struct({ + type: Schema.Literal("key"), + key: Schema.String, + metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), +}).annotate({ identifier: "Credential.Key" }) + +export const Value = Schema.Union([OAuth, Key]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Credential.Value" }) +export type Value = Schema.Schema.Type diff --git a/packages/schema/src/durable-event-manifest.ts b/packages/schema/src/durable-event-manifest.ts new file mode 100644 index 0000000000..acdcb3e9d5 --- /dev/null +++ b/packages/schema/src/durable-event-manifest.ts @@ -0,0 +1,15 @@ +export * as DurableEventManifest from "./durable-event-manifest" + +import { Event } from "./event" +import { SessionEvent } from "./session-event" +import { SessionV1 } from "./session-v1" + +export const SessionDurable = { + definitions: Event.durable(SessionEvent.DurableDefinitions), + schema: SessionEvent.Durable, +} as const + +export const Durable = Event.durable([ + ...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined), + ...SessionEvent.DurableDefinitions, +]) diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts new file mode 100644 index 0000000000..b681362e83 --- /dev/null +++ b/packages/schema/src/event-manifest.ts @@ -0,0 +1,84 @@ +export * as EventManifest from "./event-manifest" + +import { Catalog } from "./catalog" +import { Durable } from "./durable-event-manifest" +import { Event } from "./event" +import { FileSystem } from "./filesystem" +import { FileSystemWatcher } from "./filesystem-watcher" +import { InstallationEvent } from "./installation-event" +import { Integration } from "./integration" +import { LegacyEvent } from "./legacy-event" +import { LspEvent } from "./lsp-event" +import { McpEvent } from "./mcp-event" +import { ModelsDev } from "./models-dev" +import { Permission } from "./permission" +import { PermissionV1 } from "./permission-v1" +import { Plugin } from "./plugin" +import { Project } from "./project" +import { ProjectDirectories } from "./project-directories" +import { Pty } from "./pty" +import { Question } from "./question" +import { QuestionV1 } from "./question-v1" +import { Reference } from "./reference" +import { ServerEvent } from "./server-event" +import { SessionCompactionEvent } from "./session-compaction-event" +import { SessionEvent } from "./session-event" +import { SessionStatusEvent } from "./session-status-event" +import { SessionTodo } from "./session-todo" +import { SessionV1 } from "./session-v1" +import { TuiEvent } from "./tui-event" +import { VcsEvent } from "./vcs-event" +import { WorkspaceEvent } from "./workspace-event" +import { WorktreeEvent } from "./worktree-event" + +const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined) +const sessionV1LiveDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable === undefined) + +const coreDefinitions = Event.inventory(...sessionV1DurableDefinitions, ...SessionEvent.Definitions) + +const foundationDefinitions = Event.inventory( + ...ModelsDev.Event.Definitions, + ...Integration.Event.Definitions, + ...Catalog.Event.Definitions, + ...coreDefinitions, +) + +const featureDefinitions = Event.inventory( + ...FileSystem.Event.Definitions, + ...Reference.Event.Definitions, + ...Permission.Event.Definitions, + ...Plugin.Event.Definitions, + ...ProjectDirectories.Event.Definitions, + ...FileSystemWatcher.Event.Definitions, + ...Pty.Event.Definitions, + ...Question.Event.Definitions, +) + +export const ServerDefinitions = Event.inventory( + ...foundationDefinitions, + ...featureDefinitions, + ...SessionTodo.Event.Definitions, +) + +export const Definitions = Event.inventory( + ...foundationDefinitions, + ...sessionV1LiveDefinitions, + ...InstallationEvent.Definitions, + ...featureDefinitions, + ...SessionTodo.Event.Definitions, + ...LspEvent.Definitions, + ...PermissionV1.Event.Definitions, + ...TuiEvent.Definitions, + ...McpEvent.Definitions, + ...LegacyEvent.Definitions, + ...Project.Event.Definitions, + ...SessionStatusEvent.Definitions, + ...QuestionV1.Event.Definitions, + ...SessionCompactionEvent.Definitions, + ...VcsEvent.Definitions, + ...WorkspaceEvent.Definitions, + ...WorktreeEvent.Definitions, + ...ServerEvent.Definitions, +) +export const Latest = Event.latest(Definitions) +export { Durable } diff --git a/packages/schema/src/event.ts b/packages/schema/src/event.ts new file mode 100644 index 0000000000..0d6ec9775a --- /dev/null +++ b/packages/schema/src/event.ts @@ -0,0 +1,125 @@ +export * as Event from "./event" + +import { Schema } from "effect" +import { optional } from "./schema" +import { ascending } from "./identifier" +import { Location } from "./location" +import { statics } from "./schema" + +export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( + Schema.brand("Event.ID"), + statics((schema) => ({ create: () => schema.make("evt_" + ascending()) })), +) +export type ID = typeof ID.Type + +export type Definition< + Type extends string = string, + DataSchema extends Schema.Codec = Schema.Codec, +> = Schema.Top & { + readonly type: Type + readonly durable?: { + readonly version: number + readonly aggregate: string + } + readonly data: DataSchema +} + +export type Data = Schema.Schema.Type + +export type Payload = { + readonly id: ID + readonly type: D["type"] + readonly data: Data + readonly durable?: { + readonly aggregateID: string + readonly seq: number + readonly version: number + } + readonly location?: Location.Ref + readonly metadata?: Record +} + +export function define< + const Type extends string, + const Fields extends Readonly>>, +>(input: { + readonly type: Type + readonly durable?: { + readonly version: number + readonly aggregate: string + } + readonly schema: Fields +}) { + const data = Schema.Struct(input.schema) + return Schema.Struct({ + id: ID, + metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), + type: Schema.Literal(input.type), + durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })), + location: optional(Location.Ref), + data, + }) + .annotate({ identifier: input.type }) + .pipe( + statics(() => ({ + type: input.type, + ...(input.durable === undefined ? {} : { durable: input.durable }), + data, + })), + ) satisfies Definition +} + +export function inventory>(...definitions: Definitions) { + return Object.freeze(definitions) +} + +export function latest(definitions: ReadonlyArray) { + return readonlyMap( + definitions.reduce((result, definition) => { + const existing = result.get(definition.type) + if (!existing) { + result.set(definition.type, definition) + return result + } + if (definition.durable && existing.durable && definition.durable.version !== existing.durable.version) { + if (definition.durable.version > existing.durable.version) result.set(definition.type, definition) + return result + } + if (definition !== existing) throw new Error(`Duplicate latest event definition for ${definition.type}`) + return result + }, new Map()), + ) +} + +export function versionedType(type: string, version: number) { + return `${type}.${version}` +} + +export function durable>(definitions: Definitions) { + return readonlyMap( + definitions.reduce((result, definition) => { + if (!definition.durable) return result + const key = versionedType(definition.type, definition.durable.version) + if (result.has(key)) throw new Error(`Duplicate durable event definition for ${key}`) + result.set(key, definition) + return result + }, new Map()), + ) +} + +function readonlyMap(map: Map): ReadonlyMap { + const result: ReadonlyMap = Object.freeze({ + get size() { + return map.size + }, + entries: () => map.entries(), + forEach: (callback: (value: Value, key: Key, map: ReadonlyMap) => void, thisArg?: unknown) => + map.forEach((value, key) => callback.call(thisArg, value, key, result)), + get: (key: Key) => map.get(key), + has: (key: Key) => map.has(key), + keys: () => map.keys(), + values: () => map.values(), + [Symbol.iterator]: () => map[Symbol.iterator](), + }) + return result +} diff --git a/packages/schema/src/file-diff.ts b/packages/schema/src/file-diff.ts new file mode 100644 index 0000000000..45226df28e --- /dev/null +++ b/packages/schema/src/file-diff.ts @@ -0,0 +1,13 @@ +export * as FileDiff from "./file-diff" + +import { Schema } from "effect" +import { optional } from "./schema" + +export const Info = Schema.Struct({ + file: optional(Schema.String), + patch: optional(Schema.String), + additions: Schema.Finite, + deletions: Schema.Finite, + status: optional(Schema.Literals(["added", "deleted", "modified"])), +}).annotate({ identifier: "SnapshotFileDiff" }) +export interface Info extends Schema.Schema.Type {} diff --git a/packages/schema/src/filesystem-watcher.ts b/packages/schema/src/filesystem-watcher.ts new file mode 100644 index 0000000000..5e4da777ca --- /dev/null +++ b/packages/schema/src/filesystem-watcher.ts @@ -0,0 +1,13 @@ +export * as FileSystemWatcher from "./filesystem-watcher" + +import { Schema } from "effect" +import { define, inventory } from "./event" + +const Updated = define({ + type: "file.watcher.updated", + schema: { + file: Schema.String, + event: Schema.Literals(["add", "change", "unlink"]), + }, +}) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/filesystem.ts b/packages/schema/src/filesystem.ts new file mode 100644 index 0000000000..1998c1b9fd --- /dev/null +++ b/packages/schema/src/filesystem.ts @@ -0,0 +1,40 @@ +export * as FileSystem from "./filesystem" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" + +const Edited = define({ + type: "file.edited", + schema: { file: Schema.String }, +}) +export const Event = { Edited, Definitions: inventory(Edited) } + +export interface Entry extends Schema.Schema.Type {} +export const Entry = Schema.Struct({ + path: RelativePath, + type: Schema.Literals(["file", "directory"]), +}).annotate({ identifier: "FileSystem.Entry" }) + +export interface Submatch extends Schema.Schema.Type {} +export const Submatch = Schema.Struct({ + text: Schema.String, + start: NonNegativeInt, + end: NonNegativeInt, +}).annotate({ identifier: "FileSystem.Submatch" }) + +export interface Match extends Schema.Schema.Type {} +export const Match = Schema.Struct({ + entry: Entry, + line: PositiveInt, + offset: NonNegativeInt, + text: Schema.String, + submatches: Schema.Array(Submatch), +}).annotate({ identifier: "FileSystem.Match" }) + +export class FindInput extends Schema.Class("FileSystem.FindInput")({ + query: Schema.String, + type: Schema.Literals(["file", "directory"]).pipe(optional), + limit: PositiveInt.pipe(optional), +}) {} diff --git a/packages/schema/src/ide-event.ts b/packages/schema/src/ide-event.ts new file mode 100644 index 0000000000..ca42186021 --- /dev/null +++ b/packages/schema/src/ide-event.ts @@ -0,0 +1,13 @@ +export * as IdeEvent from "./ide-event" + +import { Schema } from "effect" +import { Event } from "./event" + +export const Installed = Event.define({ + type: "ide.installed", + schema: { + ide: Schema.String, + }, +}) + +export const Definitions = Event.inventory(Installed) diff --git a/packages/schema/src/identifier.ts b/packages/schema/src/identifier.ts new file mode 100644 index 0000000000..9812a673fb --- /dev/null +++ b/packages/schema/src/identifier.ts @@ -0,0 +1,30 @@ +const length = 26 +const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +let lastTimestamp = 0 +let counter = 0 + +export function ascending() { + return create(false) +} + +export function descending() { + return create(true) +} + +export function create(descending: boolean, timestamp = Date.now()) { + if (timestamp !== lastTimestamp) { + lastTimestamp = timestamp + counter = 0 + } + counter++ + + const current = BigInt(timestamp) * 0x1000n + BigInt(counter) + const value = descending ? ~current : current + const time = Array.from({ length: 6 }, (_, index) => + Number((value >> BigInt(40 - 8 * index)) & 0xffn) + .toString(16) + .padStart(2, "0"), + ).join("") + const bytes = crypto.getRandomValues(new Uint8Array(length - 12)) + return time + Array.from(bytes, (byte) => chars[byte % 62]).join("") +} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts new file mode 100644 index 0000000000..b7c8e5110f --- /dev/null +++ b/packages/schema/src/index.ts @@ -0,0 +1,28 @@ +export { Agent } from "./agent" +export { Command } from "./command" +export { Connection } from "./connection" +export { Credential } from "./credential" +export { Event } from "./event" +export { FileSystem } from "./filesystem" +export { Integration } from "./integration" +export { LLM } from "./llm" +export { Location } from "./location" +export { Model } from "./model" +export { Permission } from "./permission" +export { PermissionSaved } from "./permission-saved" +export { Project } from "./project" +export { ProjectCopy } from "./project-copy" +export { Provider } from "./provider" +export { Reference } from "./reference" +export { Revert } from "./revert" +export { Session } from "./session" +export { SessionInput } from "./session-input" +export { SessionMessage } from "./session-message" +export { Skill } from "./skill" +export { Pty } from "./pty" +export { PtyTicket } from "./pty-ticket" +export { Question } from "./question" +export { Workspace } from "./workspace" +export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt" +export { PromptInput } from "./prompt-input" +export * from "./schema" diff --git a/packages/schema/src/installation-event.ts b/packages/schema/src/installation-event.ts new file mode 100644 index 0000000000..69ecf67084 --- /dev/null +++ b/packages/schema/src/installation-event.ts @@ -0,0 +1,20 @@ +export * as InstallationEvent from "./installation-event" + +import { Schema } from "effect" +import { Event } from "./event" + +export const Updated = Event.define({ + type: "installation.updated", + schema: { + version: Schema.String, + }, +}) + +export const UpdateAvailable = Event.define({ + type: "installation.update-available", + schema: { + version: Schema.String, + }, +}) + +export const Definitions = Event.inventory(Updated, UpdateAvailable) diff --git a/packages/schema/src/integration-id.ts b/packages/schema/src/integration-id.ts new file mode 100644 index 0000000000..2590dfe9f6 --- /dev/null +++ b/packages/schema/src/integration-id.ts @@ -0,0 +1,7 @@ +import { Schema } from "effect" + +export const IntegrationID = Schema.String.pipe(Schema.brand("Integration.ID")) +export type IntegrationID = typeof IntegrationID.Type + +export const IntegrationMethodID = Schema.String.pipe(Schema.brand("Integration.MethodID")) +export type IntegrationMethodID = typeof IntegrationMethodID.Type diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts new file mode 100644 index 0000000000..1345318da1 --- /dev/null +++ b/packages/schema/src/integration.ts @@ -0,0 +1,129 @@ +export * as Integration from "./integration" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { Connection } from "./connection" +import { ascending } from "./identifier" +import { statics } from "./schema" +import { IntegrationID, IntegrationMethodID } from "./integration-id" + +export const ID = IntegrationID +export type ID = typeof ID.Type + +export const MethodID = IntegrationMethodID +export type MethodID = typeof MethodID.Type + +export interface When extends Schema.Schema.Type {} +export const When = Schema.Struct({ + key: Schema.String, + op: Schema.Literals(["eq", "neq"]), + value: Schema.String, +}).annotate({ identifier: "Integration.When" }) + +export interface TextPrompt extends Schema.Schema.Type {} +export const TextPrompt = Schema.Struct({ + type: Schema.Literal("text"), + key: Schema.String, + message: Schema.String, + placeholder: optional(Schema.String), + when: optional(When), +}).annotate({ identifier: "Integration.TextPrompt" }) + +export interface SelectPrompt extends Schema.Schema.Type {} +export const SelectPrompt = Schema.Struct({ + type: Schema.Literal("select"), + key: Schema.String, + message: Schema.String, + options: Schema.Array( + Schema.Struct({ + label: Schema.String, + value: Schema.String, + hint: optional(Schema.String), + }), + ), + when: optional(When), +}).annotate({ identifier: "Integration.SelectPrompt" }) + +export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type")) +export type Prompt = typeof Prompt.Type + +export interface OAuthMethod extends Schema.Schema.Type {} +export const OAuthMethod = Schema.Struct({ + id: MethodID, + type: Schema.Literal("oauth"), + label: Schema.String, + prompts: optional(Schema.Array(Prompt)), +}).annotate({ identifier: "Integration.OAuthMethod" }) + +export interface KeyMethod extends Schema.Schema.Type {} +export const KeyMethod = Schema.Struct({ + type: Schema.Literal("key"), + label: optional(Schema.String), +}).annotate({ identifier: "Integration.KeyMethod" }) + +export interface EnvMethod extends Schema.Schema.Type {} +export const EnvMethod = Schema.Struct({ + type: Schema.Literal("env"), + names: Schema.Array(Schema.String), +}).annotate({ identifier: "Integration.EnvMethod" }) + +export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Integration.Method" }) +export type Method = typeof Method.Type + +export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" }) +export type Inputs = typeof Inputs.Type + +const Updated = define({ + type: "integration.updated", + schema: {}, +}) +const ConnectionUpdated = define({ + type: "integration.connection.updated", + schema: { integrationID: ID }, +}) +export const Event = { Updated, ConnectionUpdated, Definitions: inventory(Updated, ConnectionUpdated) } + +export interface Ref extends Schema.Schema.Type {} +export const Ref = Schema.Struct({ + id: ID, + name: Schema.String, +}).annotate({ identifier: "Integration.Ref" }) + +export class Info extends Schema.Class("Integration.Info")({ + id: ID, + name: Schema.String, + methods: Schema.Array(Method), + connections: Schema.Array(Connection.Info), +}) {} + +export const AttemptID = Schema.String.pipe( + Schema.brand("Integration.AttemptID"), + statics((schema) => ({ create: () => schema.make("con_" + ascending()) })), +) +export type AttemptID = typeof AttemptID.Type + +const AttemptTime = Schema.Struct({ + created: Schema.Number, + expires: Schema.Number, +}) + +export class Attempt extends Schema.Class("Integration.Attempt")({ + attemptID: AttemptID, + url: Schema.String, + instructions: Schema.String, + mode: Schema.Literals(["auto", "code"]), + time: AttemptTime, +}) {} + +export const AttemptStatus = Schema.Union([ + Schema.Struct({ status: Schema.Literal("pending"), time: AttemptTime }), + Schema.Struct({ status: Schema.Literal("complete"), time: AttemptTime }), + Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: AttemptTime }), + Schema.Struct({ status: Schema.Literal("expired"), time: AttemptTime }), +]) + .pipe(Schema.toTaggedUnion("status")) + .annotate({ identifier: "Integration.AttemptStatus" }) +export type AttemptStatus = typeof AttemptStatus.Type diff --git a/packages/schema/src/legacy-event.ts b/packages/schema/src/legacy-event.ts new file mode 100644 index 0000000000..c7115992a6 --- /dev/null +++ b/packages/schema/src/legacy-event.ts @@ -0,0 +1 @@ +export * from "./v1/legacy-event" diff --git a/packages/schema/src/llm.ts b/packages/schema/src/llm.ts new file mode 100644 index 0000000000..44101dd876 --- /dev/null +++ b/packages/schema/src/llm.ts @@ -0,0 +1,28 @@ +export * as LLM from "./llm" + +import { Schema } from "effect" +import { optional } from "./schema" + +export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)).annotate({ + identifier: "LLM.ProviderMetadata", +}) +export type ProviderMetadata = Schema.Schema.Type + +export interface ToolTextContent extends Schema.Schema.Type {} +export const ToolTextContent = Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, +}).annotate({ identifier: "Tool.TextContent" }) + +export interface ToolFileContent extends Schema.Schema.Type {} +export const ToolFileContent = Schema.Struct({ + type: Schema.Literal("file"), + uri: Schema.String, + mime: Schema.String, + name: optional(Schema.String), +}).annotate({ identifier: "Tool.FileContent" }) + +export const ToolContent = Schema.Union([ToolTextContent, ToolFileContent]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "LLM.ToolContent" }) +export type ToolContent = Schema.Schema.Type diff --git a/packages/schema/src/location.ts b/packages/schema/src/location.ts new file mode 100644 index 0000000000..c01ce36372 --- /dev/null +++ b/packages/schema/src/location.ts @@ -0,0 +1,25 @@ +export * as Location from "./location" + +import { Schema } from "effect" +import { AbsolutePath, optional } from "./schema" +import { ProjectID } from "./project-id" +import { WorkspaceID } from "./workspace-id" + +export interface Ref extends Schema.Schema.Type {} +export const Ref = Schema.Struct({ + directory: AbsolutePath, + workspaceID: optional(WorkspaceID), +}).annotate({ identifier: "Location.Ref" }) + +export class Info extends Schema.Class("Location.Info")({ + directory: AbsolutePath, + workspaceID: optional(WorkspaceID), + project: Schema.Struct({ + id: ProjectID, + directory: AbsolutePath, + }), +}) {} + +export function response(data: S) { + return Schema.Struct({ location: Info, data }) +} diff --git a/packages/schema/src/lsp-event.ts b/packages/schema/src/lsp-event.ts new file mode 100644 index 0000000000..b690846966 --- /dev/null +++ b/packages/schema/src/lsp-event.ts @@ -0,0 +1,7 @@ +export * as LspEvent from "./lsp-event" + +import { Event } from "./event" + +export const Updated = Event.define({ type: "lsp.updated", schema: {} }) + +export const Definitions = Event.inventory(Updated) diff --git a/packages/schema/src/mcp-event.ts b/packages/schema/src/mcp-event.ts new file mode 100644 index 0000000000..1d050df927 --- /dev/null +++ b/packages/schema/src/mcp-event.ts @@ -0,0 +1,21 @@ +export * as McpEvent from "./mcp-event" + +import { Schema } from "effect" +import { Event } from "./event" + +export const ToolsChanged = Event.define({ + type: "mcp.tools.changed", + schema: { + server: Schema.String, + }, +}) + +export const BrowserOpenFailed = Event.define({ + type: "mcp.browser.open.failed", + schema: { + mcpName: Schema.String, + url: Schema.String, + }, +}) + +export const Definitions = Event.inventory(ToolsChanged, BrowserOpenFailed) diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts new file mode 100644 index 0000000000..10fa175e24 --- /dev/null +++ b/packages/schema/src/model.ts @@ -0,0 +1,106 @@ +export * as Model from "./model" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Provider } from "./provider" +import { statics } from "./schema" + +export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID")) +export type ID = typeof ID.Type + +export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) +export type VariantID = typeof VariantID.Type + +export const Ref = Schema.Struct({ + id: ID, + providerID: Provider.ID, + variant: VariantID.pipe(optional), +}).annotate({ identifier: "Model.Ref" }) +export interface Ref extends Schema.Schema.Type {} + +export const Family = Schema.String.pipe(Schema.brand("Family")) +export type Family = typeof Family.Type + +export interface Capabilities extends Schema.Schema.Type {} +export const Capabilities = Schema.Struct({ + tools: Schema.Boolean, + input: Schema.Array(Schema.String), + output: Schema.Array(Schema.String), +}).annotate({ identifier: "Model.Capabilities" }) + +export interface Cost extends Schema.Schema.Type {} +export const Cost = Schema.Struct({ + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Int, + }).pipe(optional), + input: Schema.Finite, + output: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}).annotate({ identifier: "Model.Cost" }) + +export const Api = Schema.Union([ + Schema.Struct({ + id: ID, + ...Provider.AISDK.fields, + }), + Schema.Struct({ + id: ID, + ...Provider.Native.fields, + }), +]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Model.Api" }) +export type Api = typeof Api.Type + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + id: ID, + providerID: Provider.ID, + family: Family.pipe(optional), + name: Schema.String, + api: Api, + capabilities: Capabilities, + request: Schema.Struct({ + ...Provider.Request.fields, + variant: Schema.String.pipe(optional), + }), + variants: Schema.Struct({ + id: VariantID, + ...Provider.Request.fields, + }).pipe(Schema.Array), + time: Schema.Struct({ + released: Schema.Finite, + }), + cost: Schema.Array(Cost), + status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + enabled: Schema.Boolean, + limit: Schema.Struct({ + context: Schema.Int, + input: Schema.Int.pipe(optional), + output: Schema.Int, + }), +}) + .annotate({ identifier: "ModelV2.Info" }) + .pipe( + statics((schema) => ({ + empty: (providerID: Provider.ID, modelID: ID) => + schema.make({ + id: modelID, + providerID, + name: modelID, + api: { id: modelID, type: "native", settings: {} }, + capabilities: { tools: false, input: [], output: [] }, + request: { headers: {}, body: {} }, + variants: [], + time: { released: 0 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 0, output: 0 }, + }), + })), + ) diff --git a/packages/schema/src/models-dev.ts b/packages/schema/src/models-dev.ts new file mode 100644 index 0000000000..4432bc4591 --- /dev/null +++ b/packages/schema/src/models-dev.ts @@ -0,0 +1,9 @@ +export * as ModelsDev from "./models-dev" + +import { define, inventory } from "./event" + +const Refreshed = define({ + type: "models-dev.refreshed", + schema: {}, +}) +export const Event = { Refreshed, Definitions: inventory(Refreshed) } diff --git a/packages/schema/src/permission-saved.ts b/packages/schema/src/permission-saved.ts new file mode 100644 index 0000000000..969a6dc25c --- /dev/null +++ b/packages/schema/src/permission-saved.ts @@ -0,0 +1,20 @@ +export * as PermissionSaved from "./permission-saved" + +import { Schema } from "effect" +import { ascending } from "./identifier" +import { ProjectID } from "./project-id" +import { statics } from "./schema" + +export const ID = Schema.String.pipe( + Schema.brand("PermissionSaved.ID"), + statics((schema) => ({ create: () => schema.make("psv_" + ascending()) })), +) +export type ID = typeof ID.Type + +export const Info = Schema.Struct({ + id: ID, + projectID: ProjectID, + action: Schema.String, + resource: Schema.String, +}).annotate({ identifier: "PermissionSaved.Info" }) +export interface Info extends Schema.Schema.Type {} diff --git a/packages/schema/src/permission-v1.ts b/packages/schema/src/permission-v1.ts new file mode 100644 index 0000000000..558fec83da --- /dev/null +++ b/packages/schema/src/permission-v1.ts @@ -0,0 +1 @@ +export * from "./v1/permission" diff --git a/packages/schema/src/permission.ts b/packages/schema/src/permission.ts new file mode 100644 index 0000000000..25d776284f --- /dev/null +++ b/packages/schema/src/permission.ts @@ -0,0 +1,65 @@ +export * as Permission from "./permission" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { ascending } from "./identifier" +import { SessionID } from "./session-id" +import { statics } from "./schema" + +export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( + Schema.brand("PermissionV2.ID"), + statics((schema) => ({ create: (id?: string) => schema.make(id ?? "per_" + ascending()) })), +) +export type ID = typeof ID.Type + +export const Source = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("tool"), + messageID: Schema.String, + callID: Schema.String, + }), +]).annotate({ identifier: "PermissionV2.Source" }) +export type Source = typeof Source.Type + +const RequestFields = { + sessionID: SessionID, + action: Schema.String, + resources: Schema.Array(Schema.String), + save: Schema.Array(Schema.String).pipe(optional), + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), + source: Source.pipe(optional), +} + +export const Request = Schema.Struct({ + id: ID, + ...RequestFields, +}).annotate({ identifier: "PermissionV2.Request" }) +export interface Request extends Schema.Schema.Type {} + +export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" }) +export type Reply = typeof Reply.Type + +const Asked = define({ type: "permission.v2.asked", schema: Request.fields }) +const Replied = define({ + type: "permission.v2.replied", + schema: { + sessionID: SessionID, + requestID: ID, + reply: Reply, + }, +}) +export const Event = { Asked, Replied, Definitions: inventory(Asked, Replied) } + +export const Effect = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Effect" }) +export type Effect = typeof Effect.Type + +export interface Rule extends Schema.Schema.Type {} +export const Rule = Schema.Struct({ + action: Schema.String, + resource: Schema.String, + effect: Effect, +}).annotate({ identifier: "PermissionV2.Rule" }) + +export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" }) +export type Ruleset = typeof Ruleset.Type diff --git a/packages/schema/src/plugin.ts b/packages/schema/src/plugin.ts new file mode 100644 index 0000000000..c23f79582e --- /dev/null +++ b/packages/schema/src/plugin.ts @@ -0,0 +1,13 @@ +export * as Plugin from "./plugin" + +import { Schema } from "effect" +import { define, inventory } from "./event" + +export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) +export type ID = typeof ID.Type + +const Added = define({ + type: "plugin.added", + schema: { id: ID }, +}) +export const Event = { Added, Definitions: inventory(Added) } diff --git a/packages/schema/src/project-copy.ts b/packages/schema/src/project-copy.ts new file mode 100644 index 0000000000..850b87bcb3 --- /dev/null +++ b/packages/schema/src/project-copy.ts @@ -0,0 +1,30 @@ +export * as ProjectCopy from "./project-copy" + +import { Schema } from "effect" +import { optional } from "./schema" +import { ProjectID } from "./project-id" +import { AbsolutePath } from "./schema" + +export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID")) +export type StrategyID = typeof StrategyID.Type + +export const CreateInput = Schema.Struct({ + projectID: ProjectID, + strategy: StrategyID, + sourceDirectory: AbsolutePath, + directory: AbsolutePath, + name: optional(Schema.String), +}).annotate({ identifier: "ProjectCopy.CreateInput" }) +export interface CreateInput extends Schema.Schema.Type {} + +export const RemoveInput = Schema.Struct({ + projectID: ProjectID, + directory: AbsolutePath, + force: Schema.Boolean, +}).annotate({ identifier: "ProjectCopy.RemoveInput" }) +export interface RemoveInput extends Schema.Schema.Type {} + +export const Copy = Schema.Struct({ + directory: AbsolutePath, +}).annotate({ identifier: "ProjectCopy.Copy" }) +export interface Copy extends Schema.Schema.Type {} diff --git a/packages/schema/src/project-directories.ts b/packages/schema/src/project-directories.ts new file mode 100644 index 0000000000..e6cbde8649 --- /dev/null +++ b/packages/schema/src/project-directories.ts @@ -0,0 +1,10 @@ +export * as ProjectDirectories from "./project-directories" + +import { define, inventory } from "./event" +import { Project } from "./project" + +const Updated = define({ + type: "project.directories.updated", + schema: { projectID: Project.ID }, +}) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/project-id.ts b/packages/schema/src/project-id.ts new file mode 100644 index 0000000000..8d2f061517 --- /dev/null +++ b/packages/schema/src/project-id.ts @@ -0,0 +1,8 @@ +import { Schema } from "effect" +import { statics } from "./schema" + +export const ProjectID = Schema.String.pipe( + Schema.brand("Project.ID"), + statics((schema) => ({ global: schema.make("global") })), +) +export type ProjectID = typeof ProjectID.Type diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts new file mode 100644 index 0000000000..7e73530a6d --- /dev/null +++ b/packages/schema/src/project.ts @@ -0,0 +1,44 @@ +export * as Project from "./project" + +import { Schema } from "effect" +import { define, inventory } from "./event" +import { NonNegativeInt, optional } from "./schema" +import { ProjectID } from "./project-id" + +export const ID = ProjectID +export type ID = typeof ID.Type + +export const Vcs = Schema.Literal("git").annotate({ identifier: "Project.Vcs" }) +export const Icon = Schema.Struct({ + url: optional(Schema.String), + override: optional(Schema.String), + color: optional(Schema.String), +}).annotate({ identifier: "Project.Icon" }) +export interface Icon extends Schema.Schema.Type {} +export const Commands = Schema.Struct({ + start: optional( + Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }), + ), +}).annotate({ identifier: "Project.Commands" }) +export interface Commands extends Schema.Schema.Type {} +export const Time = Schema.Struct({ + created: NonNegativeInt, + updated: NonNegativeInt, + initialized: optional(NonNegativeInt), +}).annotate({ identifier: "Project.Time" }) +export interface Time extends Schema.Schema.Type {} + +export const Info = Schema.Struct({ + id: ID, + worktree: Schema.String, + vcs: optional(Vcs), + name: optional(Schema.String), + icon: optional(Icon), + commands: optional(Commands), + time: Time, + sandboxes: Schema.Array(Schema.String), +}).annotate({ identifier: "Project" }) +export interface Info extends Schema.Schema.Type {} + +const Updated = define({ type: "project.updated", schema: Info.fields }) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/prompt-input.ts b/packages/schema/src/prompt-input.ts new file mode 100644 index 0000000000..f2a0d460df --- /dev/null +++ b/packages/schema/src/prompt-input.ts @@ -0,0 +1,26 @@ +export * as PromptInput from "./prompt-input" + +import { Schema } from "effect" +import { AgentAttachment, Source } from "./prompt" +import { optional, statics } from "./schema" + +export interface FileAttachment extends Schema.Schema.Type {} +export const FileAttachment = Schema.Struct({ + uri: Schema.String, + name: Schema.String.pipe(optional), + description: Schema.String.pipe(optional), + source: Source.pipe(optional), +}) + .annotate({ identifier: "PromptInput.FileAttachment" }) + .pipe( + statics((schema) => ({ + create: (input: FileAttachment) => schema.make(input), + })), + ) + +export interface Prompt extends Schema.Schema.Type {} +export const Prompt = Schema.Struct({ + text: Schema.String, + files: Schema.Array(FileAttachment).pipe(optional), + agents: Schema.Array(AgentAttachment).pipe(optional), +}).annotate({ identifier: "PromptInput" }) diff --git a/packages/schema/src/prompt.ts b/packages/schema/src/prompt.ts new file mode 100644 index 0000000000..376700cc7a --- /dev/null +++ b/packages/schema/src/prompt.ts @@ -0,0 +1,57 @@ +import { Schema } from "effect" +import { optional } from "./schema" +import { statics } from "./schema" + +export interface Source extends Schema.Schema.Type {} +export const Source = Schema.Struct({ + start: Schema.Finite, + end: Schema.Finite, + text: Schema.String, +}).annotate({ identifier: "Prompt.Source" }) + +export interface FileAttachment extends Schema.Schema.Type {} +export const FileAttachment = Schema.Struct({ + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(optional), + description: Schema.String.pipe(optional), + source: Source.pipe(optional), +}) + .annotate({ identifier: "Prompt.FileAttachment" }) + .pipe( + statics((schema) => ({ + create: (input: FileAttachment) => + schema.make({ + uri: input.uri, + mime: input.mime, + name: input.name, + description: input.description, + source: input.source, + }), + })), + ) + +export interface AgentAttachment extends Schema.Schema.Type {} +export const AgentAttachment = Schema.Struct({ + name: Schema.String, + source: Source.pipe(optional), +}).annotate({ identifier: "Prompt.AgentAttachment" }) + +export interface Prompt extends Schema.Schema.Type {} +export const Prompt = Schema.Struct({ + text: Schema.String, + files: Schema.Array(FileAttachment).pipe(optional), + agents: Schema.Array(AgentAttachment).pipe(optional), +}) + .annotate({ identifier: "Prompt" }) + .pipe( + statics((schema) => ({ + equivalence: Schema.toEquivalence(schema), + fromUserMessage: (input: Pick) => + schema.make({ + text: input.text, + ...(input.files === undefined ? {} : { files: input.files }), + ...(input.agents === undefined ? {} : { agents: input.agents }), + }), + })), + ) diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts new file mode 100644 index 0000000000..51ff4b3791 --- /dev/null +++ b/packages/schema/src/provider.ts @@ -0,0 +1,72 @@ +export * as Provider from "./provider" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Integration } from "./integration" +import { statics } from "./schema" + +export const ID = Schema.String.pipe( + Schema.brand("ProviderV2.ID"), + statics((schema) => ({ + opencode: schema.make("opencode"), + anthropic: schema.make("anthropic"), + openai: schema.make("openai"), + google: schema.make("google"), + googleVertex: schema.make("google-vertex"), + githubCopilot: schema.make("github-copilot"), + amazonBedrock: schema.make("amazon-bedrock"), + azure: schema.make("azure"), + openrouter: schema.make("openrouter"), + mistral: schema.make("mistral"), + gitlab: schema.make("gitlab"), + })), +) +export type ID = typeof ID.Type + +export interface AISDK extends Schema.Schema.Type {} +export const AISDK = Schema.Struct({ + type: Schema.Literal("aisdk"), + package: Schema.String, + url: Schema.String.pipe(optional), + settings: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), +}).annotate({ identifier: "Provider.AISDK" }) + +export interface Native extends Schema.Schema.Type {} +export const Native = Schema.Struct({ + type: Schema.Literal("native"), + url: Schema.String.pipe(optional), + settings: Schema.Record(Schema.String, Schema.Unknown), +}).annotate({ identifier: "Provider.Native" }) + +export const Api = Schema.Union([AISDK, Native]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Provider.Api" }) +export type Api = typeof Api.Type + +export interface Request extends Schema.Schema.Type {} +export const Request = Schema.Struct({ + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Record(Schema.String, Schema.Json), +}).annotate({ identifier: "Provider.Request" }) + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + id: ID, + integrationID: Integration.ID.pipe(optional), + name: Schema.String, + disabled: Schema.Boolean.pipe(optional), + api: Api, + request: Request, +}) + .annotate({ identifier: "ProviderV2.Info" }) + .pipe( + statics((schema) => ({ + empty: (id: ID) => + schema.make({ + id, + name: id, + api: { type: "native", settings: {} }, + request: { headers: {}, body: {} }, + }), + })), + ) diff --git a/packages/schema/src/pty-ticket.ts b/packages/schema/src/pty-ticket.ts new file mode 100644 index 0000000000..9b258575a9 --- /dev/null +++ b/packages/schema/src/pty-ticket.ts @@ -0,0 +1,10 @@ +export * as PtyTicket from "./pty-ticket" + +import { Schema } from "effect" +import { PositiveInt } from "./schema" + +export const ConnectToken = Schema.Struct({ + ticket: Schema.String, + expires_in: PositiveInt, +}).annotate({ identifier: "PtyTicket.ConnectToken" }) +export interface ConnectToken extends Schema.Schema.Type {} diff --git a/packages/schema/src/pty.ts b/packages/schema/src/pty.ts new file mode 100644 index 0000000000..1392ece55f --- /dev/null +++ b/packages/schema/src/pty.ts @@ -0,0 +1,58 @@ +export * as Pty from "./pty" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { ascending } from "./identifier" +import { NonNegativeInt, PositiveInt, statics } from "./schema" + +const IDSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID")) + +export const ID = IDSchema.pipe( + statics((schema: typeof IDSchema) => { + const create = () => schema.make("pty_" + ascending()) + return { + create, + ascending: (id?: string) => (id === undefined ? create() : schema.make(id)), + } + }), +) +export type ID = typeof ID.Type + +export const Info = Schema.Struct({ + id: ID, + title: Schema.String, + command: Schema.String, + args: Schema.Array(Schema.String), + cwd: Schema.String, + status: Schema.Literals(["running", "exited"]), + pid: NonNegativeInt, + exitCode: optional(NonNegativeInt), +}).annotate({ identifier: "Pty" }) +export interface Info extends Schema.Schema.Type {} + +const Created = define({ type: "pty.created", schema: { info: Info } }) +const Updated = define({ type: "pty.updated", schema: { info: Info } }) +const Exited = define({ type: "pty.exited", schema: { id: ID, exitCode: NonNegativeInt } }) +const Deleted = define({ type: "pty.deleted", schema: { id: ID } }) +export const Event = { Created, Updated, Exited, Deleted, Definitions: inventory(Created, Updated, Exited, Deleted) } + +export const CreateInput = Schema.Struct({ + command: optional(Schema.String), + args: optional(Schema.Array(Schema.String)), + cwd: optional(Schema.String), + title: optional(Schema.String), + env: optional(Schema.Record(Schema.String, Schema.String)), +}) +export interface CreateInput extends Schema.Schema.Type {} + +export const UpdateInput = Schema.Struct({ + title: optional(Schema.String), + size: optional( + Schema.Struct({ + rows: PositiveInt, + cols: PositiveInt, + }), + ), +}) +export interface UpdateInput extends Schema.Schema.Type {} diff --git a/packages/schema/src/question-v1.ts b/packages/schema/src/question-v1.ts new file mode 100644 index 0000000000..4bb237244e --- /dev/null +++ b/packages/schema/src/question-v1.ts @@ -0,0 +1 @@ +export * from "./v1/question" diff --git a/packages/schema/src/question.ts b/packages/schema/src/question.ts new file mode 100644 index 0000000000..aba5ebfbaf --- /dev/null +++ b/packages/schema/src/question.ts @@ -0,0 +1,86 @@ +export * as Question from "./question" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { ascending } from "./identifier" +import { SessionID } from "./session-id" +import { statics } from "./schema" + +export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe( + Schema.brand("QuestionV2.ID"), + statics((schema) => { + const create = () => schema.make("que_" + ascending()) + return { + create, + ascending: (id?: string) => (id === undefined ? create() : schema.make(id)), + } + }), +) +export type ID = typeof ID.Type + +export const Option = Schema.Struct({ + label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }), + description: Schema.String.annotate({ description: "Explanation of choice" }), +}).annotate({ identifier: "QuestionV2.Option" }) +export interface Option extends Schema.Schema.Type {} + +const base = { + question: Schema.String.annotate({ description: "Complete question" }), + header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }), + options: Schema.Array(Option).annotate({ description: "Available choices" }), + multiple: Schema.Boolean.pipe(optional).annotate({ description: "Allow selecting multiple choices" }), +} + +export const Info = Schema.Struct({ + ...base, + custom: Schema.Boolean.pipe(optional).annotate({ + description: "Allow typing a custom answer (default: true)", + }), +}).annotate({ identifier: "QuestionV2.Info" }) +export interface Info extends Schema.Schema.Type {} + +export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionV2.Prompt" }) +export interface Prompt extends Schema.Schema.Type {} + +export const Tool = Schema.Struct({ + messageID: Schema.String, + callID: Schema.String, +}).annotate({ identifier: "QuestionV2.Tool" }) +export interface Tool extends Schema.Schema.Type {} + +export const Request = Schema.Struct({ + id: ID, + sessionID: SessionID, + questions: Schema.Array(Info).annotate({ description: "Questions to ask" }), + tool: Tool.pipe(optional), +}).annotate({ identifier: "QuestionV2.Request" }) +export interface Request extends Schema.Schema.Type {} + +export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionV2.Answer" }) +export type Answer = typeof Answer.Type + +export const Reply = Schema.Struct({ + answers: Schema.Array(Answer).annotate({ + description: "User answers in order of questions (each answer is an array of selected labels)", + }), +}).annotate({ identifier: "QuestionV2.Reply" }) +export interface Reply extends Schema.Schema.Type {} + +const Asked = define({ type: "question.v2.asked", schema: Request.fields }) +const Replied = define({ + type: "question.v2.replied", + schema: { + sessionID: SessionID, + requestID: ID, + answers: Schema.Array(Answer), + }, +}) +const Rejected = define({ + type: "question.v2.rejected", + schema: { + sessionID: SessionID, + requestID: ID, + }, +}) +export const Event = { Asked, Replied, Rejected, Definitions: inventory(Asked, Replied, Rejected) } diff --git a/packages/schema/src/reference.ts b/packages/schema/src/reference.ts new file mode 100644 index 0000000000..84b623a274 --- /dev/null +++ b/packages/schema/src/reference.ts @@ -0,0 +1,39 @@ +export * as Reference from "./reference" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { AbsolutePath } from "./schema" + +const Updated = define({ type: "reference.updated", schema: {} }) +export const Event = { Updated, Definitions: inventory(Updated) } + +export interface LocalSource extends Schema.Schema.Type {} +export const LocalSource = Schema.Struct({ + type: Schema.Literal("local"), + path: AbsolutePath, + description: Schema.String.pipe(optional), + hidden: Schema.Boolean.pipe(optional), +}).annotate({ identifier: "Reference.LocalSource" }) + +export interface GitSource extends Schema.Schema.Type {} +export const GitSource = Schema.Struct({ + type: Schema.Literal("git"), + repository: Schema.String, + branch: Schema.String.pipe(optional), + description: Schema.String.pipe(optional), + hidden: Schema.Boolean.pipe(optional), +}).annotate({ identifier: "Reference.GitSource" }) + +export const Source = Schema.Union([LocalSource, GitSource]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Reference.Source" }) +export type Source = typeof Source.Type + +export class Info extends Schema.Class("Reference.Info")({ + name: Schema.String, + path: AbsolutePath, + description: Schema.String.pipe(optional), + hidden: Schema.Boolean.pipe(optional), + source: Source, +}) {} diff --git a/packages/schema/src/revert.ts b/packages/schema/src/revert.ts new file mode 100644 index 0000000000..05222d5398 --- /dev/null +++ b/packages/schema/src/revert.ts @@ -0,0 +1,24 @@ +export * as Revert from "./revert" + +import { Schema } from "effect" +import { optional } from "./schema" +import { NonNegativeInt, RelativePath } from "./schema" +import { SessionMessage } from "./session-message" + +export const FileDiff = Schema.Struct({ + path: RelativePath, + status: Schema.Literals(["added", "modified", "deleted"]), + additions: NonNegativeInt, + deletions: NonNegativeInt, + patch: Schema.String, +}).annotate({ identifier: "File.Diff" }) +export interface FileDiff extends Schema.Schema.Type {} + +export const State = Schema.Struct({ + messageID: SessionMessage.ID, + partID: Schema.String.pipe(optional), + snapshot: Schema.String.pipe(optional), + diff: Schema.String.pipe(optional), + files: Schema.Array(FileDiff).pipe(optional), +}).annotate({ identifier: "Revert.State" }) +export interface State extends Schema.Schema.Type {} diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts new file mode 100644 index 0000000000..d19a39b970 --- /dev/null +++ b/packages/schema/src/schema.ts @@ -0,0 +1,30 @@ +import { DateTime, Option, Schema, SchemaGetter } from "effect" + +export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) +export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) + +export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath")) +export type RelativePath = typeof RelativePath.Type + +export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath")) +export type AbsolutePath = typeof AbsolutePath.Type + +export const optional = (schema: S) => + Schema.optionalKey(schema).pipe( + Schema.decodeTo(Schema.optional(Schema.toType(schema)), { + decode: SchemaGetter.passthrough({ strict: false }), + encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)), + }), + ) + +export const statics = + >(methods: (schema: S) => M) => + (schema: S): S & M => + Object.assign(schema, methods(schema)) + +export const DateTimeUtcFromMillis = Schema.Finite.pipe( + Schema.decodeTo(Schema.DateTimeUtc, { + decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)), + encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)), + }), +) diff --git a/packages/schema/src/server-event.ts b/packages/schema/src/server-event.ts new file mode 100644 index 0000000000..9d8ac2d470 --- /dev/null +++ b/packages/schema/src/server-event.ts @@ -0,0 +1,8 @@ +export * as ServerEvent from "./server-event" + +import { Event } from "./event" + +export const Connected = Event.define({ type: "server.connected", schema: {} }) +export const Disposed = Event.define({ type: "global.disposed", schema: {} }) + +export const Definitions = Event.inventory(Connected, Disposed) diff --git a/packages/schema/src/session-compaction-event.ts b/packages/schema/src/session-compaction-event.ts new file mode 100644 index 0000000000..ed1169ea67 --- /dev/null +++ b/packages/schema/src/session-compaction-event.ts @@ -0,0 +1,13 @@ +export * as SessionCompactionEvent from "./session-compaction-event" + +import { Event } from "./event" +import { SessionID } from "./session-id" + +export const Compacted = Event.define({ + type: "session.compacted", + schema: { + sessionID: SessionID, + }, +}) + +export const Definitions = Event.inventory(Compacted) diff --git a/packages/schema/src/session-delivery.ts b/packages/schema/src/session-delivery.ts new file mode 100644 index 0000000000..9b678dabf9 --- /dev/null +++ b/packages/schema/src/session-delivery.ts @@ -0,0 +1,6 @@ +export * as SessionDelivery from "./session-delivery" + +import { Schema } from "effect" + +export const Delivery = Schema.Literals(["steer", "queue"]) +export type Delivery = typeof Delivery.Type diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts new file mode 100644 index 0000000000..3a559c3e38 --- /dev/null +++ b/packages/schema/src/session-event.ts @@ -0,0 +1,521 @@ +export * as SessionEvent from "./session-event" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Event } from "./event" +import { ProviderMetadata, ToolContent } from "./llm" +import { Delivery } from "./session-delivery" +import { Model } from "./model" +import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "./schema" +import { FileAttachment, Prompt } from "./prompt" +import { SessionID } from "./session-id" +import { Location } from "./location" +import { SessionMessage } from "./session-message" +import { Revert } from "./revert" + +export { FileAttachment } + +export const Source = Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + text: Schema.String, +}).annotate({ + identifier: "session.next.event.source", +}) +export interface Source extends Schema.Schema.Type {} + +const Base = { + timestamp: DateTimeUtcFromMillis, + sessionID: SessionID, +} +const PromptFields = { + ...Base, + messageID: SessionMessage.ID, + prompt: Prompt, + delivery: Delivery, +} + +const options = { + durable: { + aggregate: "sessionID", + version: 1, + }, +} as const +const stepSettlementOptions = { + durable: { + aggregate: "sessionID", + version: 2, + }, +} as const + +export const UnknownError = SessionMessage.UnknownError +export type UnknownError = SessionMessage.UnknownError + +export const AgentSwitched = Event.define({ + type: "session.next.agent.switched", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + agent: Schema.String, + }, +}) +export type AgentSwitched = typeof AgentSwitched.Type + +export const ModelSwitched = Event.define({ + type: "session.next.model.switched", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + model: Model.Ref, + }, +}) +export type ModelSwitched = typeof ModelSwitched.Type + +export const Moved = Event.define({ + type: "session.next.moved", + ...options, + schema: { + ...Base, + location: Location.Ref, + subdirectory: RelativePath.pipe(optional), + }, +}) +export type Moved = typeof Moved.Type + +export const Prompted = Event.define({ + type: "session.next.prompted", + ...options, + schema: PromptFields, +}) +export type Prompted = typeof Prompted.Type + +export const PromptAdmitted = Event.define({ + type: "session.next.prompt.admitted", + ...options, + schema: PromptFields, +}) +export type PromptAdmitted = typeof PromptAdmitted.Type + +export const ContextUpdated = Event.define({ + type: "session.next.context.updated", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + text: Schema.String, + }, +}) +export type ContextUpdated = typeof ContextUpdated.Type + +export const Synthetic = Event.define({ + type: "session.next.synthetic", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + text: Schema.String, + }, +}) +export type Synthetic = typeof Synthetic.Type + +export namespace Shell { + export const Started = Event.define({ + type: "session.next.shell.started", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + callID: Schema.String, + command: Schema.String, + }, + }) + export type Started = typeof Started.Type + + export const Ended = Event.define({ + type: "session.next.shell.ended", + ...options, + schema: { + ...Base, + callID: Schema.String, + output: Schema.String, + }, + }) + export type Ended = typeof Ended.Type +} + +export namespace Step { + export const Started = Event.define({ + type: "session.next.step.started", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + agent: Schema.String, + model: Model.Ref, + snapshot: Schema.String.pipe(optional), + }, + }) + export type Started = typeof Started.Type + + export const Ended = Event.define({ + type: "session.next.step.ended", + ...stepSettlementOptions, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + finish: Schema.String, + cost: Schema.Finite, + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), + snapshot: Schema.String.pipe(optional), + files: Schema.Array(RelativePath).pipe(optional), + }, + }) + export type Ended = typeof Ended.Type + + export const Failed = Event.define({ + type: "session.next.step.failed", + ...stepSettlementOptions, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + error: UnknownError, + }, + }) + export type Failed = typeof Failed.Type +} + +export namespace Text { + export const Started = Event.define({ + type: "session.next.text.started", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + textID: Schema.String, + }, + }) + export type Started = typeof Started.Type + + // Stream fragments are live-only; Text.Ended is the replayable full-value boundary. + export const Delta = Event.define({ + type: "session.next.text.delta", + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + textID: Schema.String, + delta: Schema.String, + }, + }) + export type Delta = typeof Delta.Type + + export const Ended = Event.define({ + type: "session.next.text.ended", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + textID: Schema.String, + text: Schema.String, + }, + }) + export type Ended = typeof Ended.Type +} + +export namespace Reasoning { + export const Started = Event.define({ + type: "session.next.reasoning.started", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + reasoningID: Schema.String, + providerMetadata: ProviderMetadata.pipe(optional), + }, + }) + export type Started = typeof Started.Type + + // Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary. + export const Delta = Event.define({ + type: "session.next.reasoning.delta", + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + reasoningID: Schema.String, + delta: Schema.String, + }, + }) + export type Delta = typeof Delta.Type + + export const Ended = Event.define({ + type: "session.next.reasoning.ended", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + reasoningID: Schema.String, + text: Schema.String, + providerMetadata: ProviderMetadata.pipe(optional), + }, + }) + export type Ended = typeof Ended.Type +} + +export namespace Tool { + const ToolBase = { + ...Base, + assistantMessageID: SessionMessage.ID, + callID: Schema.String, + } + + export namespace Input { + export const Started = Event.define({ + type: "session.next.tool.input.started", + ...options, + schema: { + ...ToolBase, + name: Schema.String, + }, + }) + export type Started = typeof Started.Type + + // Stream fragments are live-only; Input.Ended is the replayable raw-input boundary. + export const Delta = Event.define({ + type: "session.next.tool.input.delta", + schema: { + ...ToolBase, + delta: Schema.String, + }, + }) + export type Delta = typeof Delta.Type + + export const Ended = Event.define({ + type: "session.next.tool.input.ended", + ...options, + schema: { + ...ToolBase, + text: Schema.String, + }, + }) + export type Ended = typeof Ended.Type + } + + export const Called = Event.define({ + type: "session.next.tool.called", + ...options, + schema: { + ...ToolBase, + tool: Schema.String, + input: Schema.Record(Schema.String, Schema.Unknown), + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: ProviderMetadata.pipe(optional), + }), + }, + }) + export type Called = typeof Called.Type + + /** + * Replayable bounded running-tool state. Tools should checkpoint semantic + * transitions or at a bounded cadence, not persist every stdout/stderr chunk. + */ + export const Progress = Event.define({ + type: "session.next.tool.progress", + ...options, + schema: { + ...ToolBase, + structured: Schema.Record(Schema.String, Schema.Unknown), + content: Schema.Array(ToolContent), + }, + }) + export type Progress = typeof Progress.Type + + export const Success = Event.define({ + type: "session.next.tool.success", + ...options, + schema: { + ...ToolBase, + structured: Schema.Record(Schema.String, Schema.Unknown), + content: Schema.Array(ToolContent), + outputPaths: Schema.Array(Schema.String).pipe(optional), + result: Schema.Unknown.pipe(optional), + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: ProviderMetadata.pipe(optional), + }), + }, + }) + export type Success = typeof Success.Type + + export const Failed = Event.define({ + type: "session.next.tool.failed", + ...options, + schema: { + ...ToolBase, + error: UnknownError, + result: Schema.Unknown.pipe(optional), + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: ProviderMetadata.pipe(optional), + }), + }, + }) + export type Failed = typeof Failed.Type +} + +export const RetryError = Schema.Struct({ + message: Schema.String, + statusCode: Schema.Finite.pipe(optional), + isRetryable: Schema.Boolean, + responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(optional), + responseBody: Schema.String.pipe(optional), + metadata: Schema.Record(Schema.String, Schema.String).pipe(optional), +}).annotate({ + identifier: "session.next.retry_error", +}) +export interface RetryError extends Schema.Schema.Type {} + +export const Retried = Event.define({ + type: "session.next.retried", + ...options, + schema: { + ...Base, + attempt: Schema.Finite, + error: RetryError, + }, +}) +export type Retried = typeof Retried.Type + +export namespace Compaction { + export const Started = Event.define({ + type: "session.next.compaction.started", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), + }, + }) + export type Started = typeof Started.Type + + export const Delta = Event.define({ + type: "session.next.compaction.delta", + schema: { + ...Base, + messageID: SessionMessage.ID, + text: Schema.String, + }, + }) + export type Delta = typeof Delta.Type + + export const Ended = Event.define({ + type: "session.next.compaction.ended", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + reason: Started.data.fields.reason, + text: Schema.String, + recent: Schema.String, + }, + }) + export type Ended = typeof Ended.Type +} + +export namespace RevertEvent { + export const Staged = Event.define({ + type: "session.next.revert.staged", + ...options, + schema: { ...Base, revert: Revert.State }, + }) + export const Cleared = Event.define({ type: "session.next.revert.cleared", ...options, schema: Base }) + export const Committed = Event.define({ + type: "session.next.revert.committed", + ...options, + schema: { ...Base, messageID: SessionMessage.ID }, + }) +} + +export const DurableDefinitions = Event.inventory( + AgentSwitched, + ModelSwitched, + Moved, + Prompted, + PromptAdmitted, + ContextUpdated, + Synthetic, + Shell.Started, + Shell.Ended, + Step.Started, + Step.Ended, + Step.Failed, + Text.Started, + Text.Ended, + Tool.Input.Started, + Tool.Input.Ended, + Tool.Called, + Tool.Progress, + Tool.Success, + Tool.Failed, + Reasoning.Started, + Reasoning.Ended, + Retried, + Compaction.Started, + Compaction.Ended, + RevertEvent.Staged, + RevertEvent.Cleared, + RevertEvent.Committed, +) + +export const Definitions = Event.inventory( + AgentSwitched, + ModelSwitched, + Moved, + Prompted, + PromptAdmitted, + ContextUpdated, + Synthetic, + Shell.Started, + Shell.Ended, + Step.Started, + Step.Ended, + Step.Failed, + Text.Started, + Text.Delta, + Text.Ended, + Reasoning.Started, + Reasoning.Delta, + Reasoning.Ended, + Tool.Input.Started, + Tool.Input.Delta, + Tool.Input.Ended, + Tool.Called, + Tool.Progress, + Tool.Success, + Tool.Failed, + Retried, + Compaction.Started, + Compaction.Delta, + Compaction.Ended, + RevertEvent.Staged, + RevertEvent.Cleared, + RevertEvent.Committed, +) + +export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "SessionDurableEvent" }) +export type DurableEvent = typeof Durable.Type + +export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type")) +export type Event = typeof All.Type +export type Type = Event["type"] diff --git a/packages/schema/src/session-id.ts b/packages/schema/src/session-id.ts new file mode 100644 index 0000000000..3603ebe703 --- /dev/null +++ b/packages/schema/src/session-id.ts @@ -0,0 +1,15 @@ +import { Schema } from "effect" +import { descending } from "./identifier" +import { statics } from "./schema" + +export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe( + Schema.brand("SessionID"), + statics((schema) => { + const create = () => schema.make("ses_" + descending()) + return { + create, + descending: (id?: string) => (id === undefined ? create() : schema.make(id)), + } + }), +) +export type SessionID = typeof SessionID.Type diff --git a/packages/schema/src/session-input.ts b/packages/schema/src/session-input.ts new file mode 100644 index 0000000000..40babac105 --- /dev/null +++ b/packages/schema/src/session-input.ts @@ -0,0 +1,23 @@ +export * as SessionInput from "./session-input" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Prompt } from "./prompt" +import { DateTimeUtcFromMillis, NonNegativeInt } from "./schema" +import { SessionDelivery } from "./session-delivery" +import { SessionID } from "./session-id" +import { SessionMessage } from "./session-message" + +export const Delivery = SessionDelivery.Delivery +export type Delivery = SessionDelivery.Delivery + +export interface Admitted extends Schema.Schema.Type {} +export const Admitted = Schema.Struct({ + admittedSeq: NonNegativeInt, + id: SessionMessage.ID, + sessionID: SessionID, + prompt: Prompt, + delivery: Delivery, + timeCreated: DateTimeUtcFromMillis, + promotedSeq: NonNegativeInt.pipe(optional), +}).annotate({ identifier: "SessionInput.Admitted" }) diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts new file mode 100644 index 0000000000..58ff532063 --- /dev/null +++ b/packages/schema/src/session-message.ts @@ -0,0 +1,213 @@ +export * as SessionMessage from "./session-message" + +import { Schema } from "effect" +import { optional } from "./schema" +import { ProviderMetadata, ToolContent } from "./llm" +import { Model } from "./model" +import { FileAttachment, Prompt } from "./prompt" +import { DateTimeUtcFromMillis, RelativePath, statics } from "./schema" +import { SessionID } from "./session-id" +import { ascending } from "./identifier" + +export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe( + Schema.brand("Session.Message.ID"), + statics((schema) => ({ create: () => schema.make("msg_" + ascending()) })), +) +export type ID = typeof ID.Type + +export interface UnknownError extends Schema.Schema.Type {} +export const UnknownError = Schema.Struct({ + type: Schema.Literal("unknown"), + message: Schema.String, +}).annotate({ identifier: "Session.Error.Unknown" }) + +const Base = { + id: ID, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), + time: Schema.Struct({ created: DateTimeUtcFromMillis }), +} + +export interface AgentSwitched extends Schema.Schema.Type {} +export const AgentSwitched = Schema.Struct({ + ...Base, + type: Schema.Literal("agent-switched"), + agent: Schema.String, +}).annotate({ identifier: "Session.Message.AgentSwitched" }) + +export interface ModelSwitched extends Schema.Schema.Type {} +export const ModelSwitched = Schema.Struct({ + ...Base, + type: Schema.Literal("model-switched"), + model: Model.Ref, +}).annotate({ identifier: "Session.Message.ModelSwitched" }) + +export interface User extends Schema.Schema.Type {} +export const User = Schema.Struct({ + ...Base, + text: Prompt.fields.text, + files: Prompt.fields.files, + agents: Prompt.fields.agents, + type: Schema.Literal("user"), +}).annotate({ identifier: "Session.Message.User" }) + +export interface Synthetic extends Schema.Schema.Type {} +export const Synthetic = Schema.Struct({ + ...Base, + sessionID: SessionID, + text: Schema.String, + type: Schema.Literal("synthetic"), +}).annotate({ identifier: "Session.Message.Synthetic" }) + +export interface System extends Schema.Schema.Type {} +export const System = Schema.Struct({ + ...Base, + type: Schema.Literal("system"), + text: Schema.String, +}).annotate({ identifier: "Session.Message.System" }) + +export interface Shell extends Schema.Schema.Type {} +export const Shell = Schema.Struct({ + ...Base, + type: Schema.Literal("shell"), + callID: Schema.String, + command: Schema.String, + output: Schema.String, + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + completed: DateTimeUtcFromMillis.pipe(optional), + }), +}).annotate({ identifier: "Session.Message.Shell" }) + +export interface ToolStatePending extends Schema.Schema.Type {} +export const ToolStatePending = Schema.Struct({ + status: Schema.Literal("pending"), + input: Schema.String, +}).annotate({ identifier: "Session.Message.ToolState.Pending" }) + +export interface ToolStateRunning extends Schema.Schema.Type {} +export const ToolStateRunning = Schema.Struct({ + status: Schema.Literal("running"), + input: Schema.Record(Schema.String, Schema.Unknown), + structured: Schema.Record(Schema.String, Schema.Unknown), + content: ToolContent.pipe(Schema.Array), +}).annotate({ identifier: "Session.Message.ToolState.Running" }) + +export interface ToolStateCompleted extends Schema.Schema.Type {} +export const ToolStateCompleted = Schema.Struct({ + status: Schema.Literal("completed"), + input: Schema.Record(Schema.String, Schema.Unknown), + attachments: FileAttachment.pipe(Schema.Array, optional), + content: ToolContent.pipe(Schema.Array), + outputPaths: Schema.Array(Schema.String).pipe(optional), + structured: Schema.Record(Schema.String, Schema.Unknown), + result: Schema.Unknown.pipe(optional), +}).annotate({ identifier: "Session.Message.ToolState.Completed" }) + +export interface ToolStateError extends Schema.Schema.Type {} +export const ToolStateError = Schema.Struct({ + status: Schema.Literal("error"), + input: Schema.Record(Schema.String, Schema.Unknown), + content: ToolContent.pipe(Schema.Array), + structured: Schema.Record(Schema.String, Schema.Unknown), + error: UnknownError, + result: Schema.Unknown.pipe(optional), +}).annotate({ identifier: "Session.Message.ToolState.Error" }) + +export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( + Schema.toTaggedUnion("status"), +) +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError + +export interface AssistantTool extends Schema.Schema.Type {} +export const AssistantTool = Schema.Struct({ + type: Schema.Literal("tool"), + id: Schema.String, + name: Schema.String, + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: ProviderMetadata.pipe(optional), + resultMetadata: ProviderMetadata.pipe(optional), + }).pipe(optional), + state: ToolState, + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + ran: DateTimeUtcFromMillis.pipe(optional), + completed: DateTimeUtcFromMillis.pipe(optional), + pruned: DateTimeUtcFromMillis.pipe(optional), + }), +}).annotate({ identifier: "Session.Message.Assistant.Tool" }) + +export interface AssistantText extends Schema.Schema.Type {} +export const AssistantText = Schema.Struct({ + type: Schema.Literal("text"), + id: Schema.String, + text: Schema.String, +}).annotate({ identifier: "Session.Message.Assistant.Text" }) + +export interface AssistantReasoning extends Schema.Schema.Type {} +export const AssistantReasoning = Schema.Struct({ + type: Schema.Literal("reasoning"), + id: Schema.String, + text: Schema.String, + providerMetadata: ProviderMetadata.pipe(optional), + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + completed: DateTimeUtcFromMillis.pipe(optional), + }).pipe(optional), +}).annotate({ identifier: "Session.Message.Assistant.Reasoning" }) + +export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( + Schema.toTaggedUnion("type"), +) +export type AssistantContent = AssistantText | AssistantReasoning | AssistantTool + +export interface Assistant extends Schema.Schema.Type {} +export const Assistant = Schema.Struct({ + ...Base, + type: Schema.Literal("assistant"), + agent: Schema.String, + model: Model.Ref, + content: AssistantContent.pipe(Schema.Array), + snapshot: Schema.Struct({ + start: Schema.String.pipe(optional), + end: Schema.String.pipe(optional), + files: Schema.Array(RelativePath).pipe(optional), + }).pipe(optional), + finish: Schema.String.pipe(optional), + cost: Schema.Finite.pipe(optional), + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite }), + }).pipe(optional), + error: UnknownError.pipe(optional), + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + completed: DateTimeUtcFromMillis.pipe(optional), + }), +}).annotate({ identifier: "Session.Message.Assistant" }) + +export interface Compaction extends Schema.Schema.Type {} +export const Compaction = Schema.Struct({ + type: Schema.Literal("compaction"), + reason: Schema.Literals(["auto", "manual"]), + summary: Schema.String, + recent: Schema.String, + ...Base, +}).annotate({ identifier: "Session.Message.Compaction" }) + +export const Message = Schema.Union([ + AgentSwitched, + ModelSwitched, + User, + Synthetic, + System, + Shell, + Assistant, + Compaction, +]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Session.Message" }) +export type Message = AgentSwitched | ModelSwitched | User | Synthetic | System | Shell | Assistant | Compaction +export type Type = Message["type"] diff --git a/packages/schema/src/session-status-event.ts b/packages/schema/src/session-status-event.ts new file mode 100644 index 0000000000..f6a3022bcb --- /dev/null +++ b/packages/schema/src/session-status-event.ts @@ -0,0 +1,51 @@ +export * as SessionStatusEvent from "./session-status-event" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Event } from "./event" +import { NonNegativeInt } from "./schema" +import { SessionID } from "./session-id" + +export const Info = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("idle"), + }), + Schema.Struct({ + type: Schema.Literal("retry"), + attempt: NonNegativeInt, + message: Schema.String, + action: optional( + Schema.Struct({ + reason: Schema.String, + provider: Schema.String, + title: Schema.String, + message: Schema.String, + label: Schema.String, + link: optional(Schema.String), + }), + ), + next: NonNegativeInt, + }), + Schema.Struct({ + type: Schema.Literal("busy"), + }), +]).annotate({ identifier: "SessionStatus" }) +export type Info = Schema.Schema.Type + +export const Status = Event.define({ + type: "session.status", + schema: { + sessionID: SessionID, + status: Info, + }, +}) + +// deprecated +export const Idle = Event.define({ + type: "session.idle", + schema: { + sessionID: SessionID, + }, +}) + +export const Definitions = Event.inventory(Status, Idle) diff --git a/packages/schema/src/session-todo.ts b/packages/schema/src/session-todo.ts new file mode 100644 index 0000000000..7f6a72bf00 --- /dev/null +++ b/packages/schema/src/session-todo.ts @@ -0,0 +1,25 @@ +export * as SessionTodo from "./session-todo" + +import { Schema } from "effect" +import { define, inventory } from "./event" +import { SessionID } from "./session-id" + +export const Info = Schema.Struct({ + content: Schema.String.annotate({ description: "Brief description of the task" }), + status: Schema.String.annotate({ + description: "Current status of the task: pending, in_progress, completed, cancelled", + }), + priority: Schema.String.annotate({ + description: "Priority level of the task: high, medium, low", + }), +}).annotate({ identifier: "Todo" }) +export interface Info extends Schema.Schema.Type {} + +const Updated = define({ + type: "todo.updated", + schema: { + sessionID: SessionID, + todos: Schema.Array(Info), + }, +}) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/session-v1.ts b/packages/schema/src/session-v1.ts new file mode 100644 index 0000000000..22455dd412 --- /dev/null +++ b/packages/schema/src/session-v1.ts @@ -0,0 +1 @@ +export * from "./v1/session" diff --git a/packages/schema/src/session.ts b/packages/schema/src/session.ts new file mode 100644 index 0000000000..937705eeb2 --- /dev/null +++ b/packages/schema/src/session.ts @@ -0,0 +1,51 @@ +export * as Session from "./session" + +import { Schema } from "effect" +import { Agent } from "./agent" +import { Location } from "./location" +import { Model } from "./model" +import { Project } from "./project" +import { DateTimeUtcFromMillis, optional, RelativePath } from "./schema" +import { SessionEvent } from "./session-event" +import { SessionID } from "./session-id" +import { Revert } from "./revert" + +export const ID = SessionID +export type ID = SessionID + +export const Event = SessionEvent + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + id: ID, + parentID: ID.pipe(optional), + projectID: Project.ID, + agent: Agent.ID.pipe(optional), + model: Model.Ref.pipe(optional), + cost: Schema.Finite, + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + updated: DateTimeUtcFromMillis, + archived: DateTimeUtcFromMillis.pipe(optional), + }), + title: Schema.String, + location: Location.Ref, + subpath: RelativePath.pipe(optional), + revert: Revert.State.pipe(optional), +}).annotate({ identifier: "SessionV2.Info" }) + +export const ListAnchor = Schema.Struct({ + id: ID, + time: Schema.Finite, + direction: Schema.Literals(["previous", "next"]), +}).annotate({ identifier: "Session.ListAnchor" }) +export interface ListAnchor extends Schema.Schema.Type {} diff --git a/packages/schema/src/skill.ts b/packages/schema/src/skill.ts new file mode 100644 index 0000000000..ec299180ed --- /dev/null +++ b/packages/schema/src/skill.ts @@ -0,0 +1,55 @@ +export * as Skill from "./skill" + +import { Schema } from "effect" +import { optional } from "./schema" +import { AbsolutePath } from "./schema" + +export interface DirectorySource extends Schema.Schema.Type {} +export const DirectorySource = Schema.Struct({ + type: Schema.Literal("directory"), + path: AbsolutePath, +}).annotate({ identifier: "SkillV2.DirectorySource" }) + +export interface UrlSource extends Schema.Schema.Type {} +export const UrlSource = Schema.Struct({ + type: Schema.Literal("url"), + url: Schema.String, +}).annotate({ identifier: "SkillV2.UrlSource" }) + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + name: Schema.String, + description: Schema.String.pipe(optional), + slash: Schema.Boolean.pipe(optional), + location: AbsolutePath, + content: Schema.String, +}).annotate({ identifier: "SkillV2.Info" }) + +export interface EmbeddedSource extends Schema.Schema.Type {} +export const EmbeddedSource = Schema.Struct({ + type: Schema.Literal("embedded"), + skill: Schema.suspend(() => Info), +}).annotate({ identifier: "SkillV2.EmbeddedSource" }) + +export type Source = DirectorySource | UrlSource | EmbeddedSource +export const Source = Object.assign( + Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe( + Schema.toTaggedUnion("type"), + Schema.annotate({ identifier: "SkillV2.Source" }), + ), + { + equals: (a: Source, b: Source) => { + if (a.type !== b.type) return false + if (a.type === "directory" && b.type === "directory") return a.path === b.path + if (a.type === "url" && b.type === "url") return a.url === b.url + if (a.type === "embedded" && b.type === "embedded") return a.skill.name === b.skill.name + return false + }, + key: (source: Source) => + source.type === "directory" + ? `directory:${source.path}` + : source.type === "url" + ? `url:${source.url}` + : `embedded:${source.skill.name}`, + }, +) diff --git a/packages/schema/src/tui-event.ts b/packages/schema/src/tui-event.ts new file mode 100644 index 0000000000..800094e61e --- /dev/null +++ b/packages/schema/src/tui-event.ts @@ -0,0 +1,59 @@ +export * as TuiEvent from "./tui-event" + +import { Effect, Schema } from "effect" +import { optional } from "./schema" +import { Event } from "./event" +import { PositiveInt } from "./schema" +import { SessionID } from "./session-id" + +const DEFAULT_TOAST_DURATION = 5000 + +export const PromptAppend = Event.define({ type: "tui.prompt.append", schema: { text: Schema.String } }) + +export const CommandExecute = Event.define({ + type: "tui.command.execute", + schema: { + command: Schema.Union([ + Schema.Literals([ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle", + ]), + Schema.String, + ]), + }, +}) + +export const ToastShow = Event.define({ + type: "tui.toast.show", + schema: { + title: optional(Schema.String), + message: Schema.String, + variant: Schema.Literals(["info", "success", "warning", "error"]), + duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({ + description: "Duration in milliseconds", + }), + }, +}) + +export const SessionSelect = Event.define({ + type: "tui.session.select", + schema: { + sessionID: SessionID.annotate({ description: "Session ID to navigate to" }), + }, +}) + +export const Definitions = Event.inventory(PromptAppend, CommandExecute, ToastShow, SessionSelect) diff --git a/packages/schema/src/v1/legacy-event.ts b/packages/schema/src/v1/legacy-event.ts new file mode 100644 index 0000000000..d87902dad5 --- /dev/null +++ b/packages/schema/src/v1/legacy-event.ts @@ -0,0 +1,18 @@ +export * as LegacyEvent from "./legacy-event" + +import { Schema } from "effect" +import { define, inventory } from "../event" +import { SessionID } from "../session-id" +import { SessionV1 } from "./session" + +export const CommandExecuted = define({ + type: "command.executed", + schema: { + name: Schema.String, + sessionID: SessionID, + arguments: Schema.String, + messageID: SessionV1.MessageID, + }, +}) + +export const Definitions = inventory(CommandExecuted) diff --git a/packages/schema/src/v1/permission.ts b/packages/schema/src/v1/permission.ts new file mode 100644 index 0000000000..9096f4f249 --- /dev/null +++ b/packages/schema/src/v1/permission.ts @@ -0,0 +1,66 @@ +export * as PermissionV1 from "./permission" + +import { Schema } from "effect" +import { define, inventory } from "../event" +import { ascending } from "../identifier" +import { Project } from "../project" +import { statics } from "../schema" +import { SessionID } from "../session-id" + +export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( + Schema.brand("PermissionID"), + statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + ascending()) })), +) +export type ID = typeof ID.Type + +export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" }) +export type Action = typeof Action.Type + +export const Rule = Schema.Struct({ permission: Schema.String, pattern: Schema.String, action: Action }).annotate({ + identifier: "PermissionRule", +}) +export type Rule = typeof Rule.Type + +export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" }) +export type Ruleset = typeof Ruleset.Type + +export const Request = Schema.Struct({ + id: ID, + sessionID: SessionID, + permission: Schema.String, + patterns: Schema.Array(Schema.String), + metadata: Schema.Record(Schema.String, Schema.Unknown), + always: Schema.Array(Schema.String), + tool: Schema.optional(Schema.Struct({ messageID: Schema.String, callID: Schema.String })), +}).annotate({ identifier: "PermissionRequest" }) +export type Request = typeof Request.Type + +export const Reply = Schema.Literals(["once", "always", "reject"]) +export type Reply = typeof Reply.Type + +export const ReplyBody = Schema.Struct({ reply: Reply, message: Schema.optional(Schema.String) }).annotate({ + identifier: "PermissionReplyBody", +}) +export type ReplyBody = typeof ReplyBody.Type + +export const Approval = Schema.Struct({ projectID: Project.ID, patterns: Schema.Array(Schema.String) }).annotate({ + identifier: "PermissionApproval", +}) +export type Approval = typeof Approval.Type + +export const AskInput = Schema.Struct({ ...Request.fields, id: Schema.optional(ID), ruleset: Ruleset }).annotate({ + identifier: "PermissionAskInput", +}) +export type AskInput = typeof AskInput.Type + +export const ReplyInput = Schema.Struct({ requestID: ID, ...ReplyBody.fields }).annotate({ + identifier: "PermissionReplyInput", +}) +export type ReplyInput = typeof ReplyInput.Type + +const Asked = define({ type: "permission.asked", schema: Request.fields }) +const Replied = define({ + type: "permission.replied", + schema: { sessionID: SessionID, requestID: ID, reply: Reply }, +}) +export const Event = { Asked, Replied, Definitions: inventory(Asked, Replied) } diff --git a/packages/schema/src/v1/question.ts b/packages/schema/src/v1/question.ts new file mode 100644 index 0000000000..e566996472 --- /dev/null +++ b/packages/schema/src/v1/question.ts @@ -0,0 +1,66 @@ +export * as QuestionV1 from "./question" + +import { Schema } from "effect" +import { define, inventory } from "../event" +import { ascending } from "../identifier" +import { statics } from "../schema" +import { SessionID } from "../session-id" +import { SessionV1 } from "./session" + +export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe( + Schema.brand("QuestionID"), + statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "que_" + ascending()) })), +) + +export const Option = Schema.Struct({ + label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }), + description: Schema.String.annotate({ description: "Explanation of choice" }), +}).annotate({ identifier: "QuestionOption" }) + +const base = { + question: Schema.String.annotate({ description: "Complete question" }), + header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }), + options: Schema.Array(Option).annotate({ description: "Available choices" }), + multiple: Schema.optional(Schema.Boolean).annotate({ description: "Allow selecting multiple choices" }), +} + +export const Info = Schema.Struct({ + ...base, + custom: Schema.optional(Schema.Boolean).annotate({ description: "Allow typing a custom answer (default: true)" }), +}).annotate({ identifier: "QuestionInfo" }) +export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionPrompt" }) +export const Tool = Schema.Struct({ messageID: SessionV1.MessageID, callID: Schema.String }).annotate({ + identifier: "QuestionTool", +}) +export const Request = Schema.Struct({ + id: ID, + sessionID: SessionID, + questions: Schema.Array(Info).annotate({ description: "Questions to ask" }), + tool: Schema.optional(Tool), +}).annotate({ identifier: "QuestionRequest" }) +export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" }) +export const Reply = Schema.Struct({ + answers: Schema.Array(Answer).annotate({ + description: "User answers in order of questions (each answer is an array of selected labels)", + }), +}).annotate({ identifier: "QuestionReply" }) +export const Replied = Schema.Struct({ + sessionID: SessionID, + requestID: ID, + answers: Schema.Array(Answer), +}).annotate({ + identifier: "QuestionReplied", +}) +export const Rejected = Schema.Struct({ sessionID: SessionID, requestID: ID }).annotate({ + identifier: "QuestionRejected", +}) + +const Asked = define({ type: "question.asked", schema: Request.fields }) +const RepliedEvent = define({ type: "question.replied", schema: Replied.fields }) +const RejectedEvent = define({ type: "question.rejected", schema: Rejected.fields }) +export const Event = { + Asked, + Replied: RepliedEvent, + Rejected: RejectedEvent, + Definitions: inventory(Asked, RepliedEvent, RejectedEvent), +} diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts new file mode 100644 index 0000000000..75e9282f11 --- /dev/null +++ b/packages/schema/src/v1/session.ts @@ -0,0 +1,676 @@ +export * as SessionV1 from "./session" + +import { Effect, Schema, Types } from "effect" +import { define, inventory } from "../event" +import { FileDiff } from "../file-diff" +import { Project } from "../project" +import { Provider } from "../provider" +import { Model } from "../model" +import { NonNegativeInt, optional, statics } from "../schema" +import { ascending } from "../identifier" +import { SessionID } from "../session-id" +import { WorkspaceID } from "../workspace-id" +import { PermissionV1 } from "./permission" + +const Timestamp = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) + +export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( + Schema.brand("MessageID"), + statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "msg_" + ascending()) })), +) +export type MessageID = typeof MessageID.Type + +export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( + Schema.brand("PartID"), + statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "prt_" + ascending()) })), +) +export type PartID = typeof PartID.Type + +const namedError = (name: Name, fields: Fields) => { + const schema = Schema.Struct({ name: Schema.Literal(name), data: Schema.Struct(fields) }).annotate({ + identifier: name, + }) + return { Schema: schema, EffectSchema: schema } +} + +export const OutputLengthError = namedError("MessageOutputLengthError", {}) + +export const AuthError = namedError("ProviderAuthError", { + providerID: Schema.String, + message: Schema.String, +}) + +export const AbortedError = namedError("MessageAbortedError", { message: Schema.String }) +export const StructuredOutputError = namedError("StructuredOutputError", { + message: Schema.String, + retries: NonNegativeInt, +}) +export const APIError = namedError("APIError", { + message: Schema.String, + statusCode: Schema.optional(NonNegativeInt), + isRetryable: Schema.Boolean, + responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), + responseBody: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) +export type APIError = Schema.Schema.Type +export const ContextOverflowError = namedError("ContextOverflowError", { + message: Schema.String, + responseBody: Schema.optional(Schema.String), +}) +export const ContentFilterError = namedError("ContentFilterError", { + message: Schema.String, +}) + +export class OutputFormatText extends Schema.Class("OutputFormatText")({ + type: Schema.Literal("text"), +}) {} + +export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ + type: Schema.Literal("json_schema"), + schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), + retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), +}) {} + +export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ + discriminator: "type", + identifier: "OutputFormat", +}) +export type OutputFormat = Schema.Schema.Type + +const partBase = { + id: PartID, + sessionID: SessionID, + messageID: MessageID, +} + +export const SnapshotPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("snapshot"), + snapshot: Schema.String, +}).annotate({ identifier: "SnapshotPart" }) +export type SnapshotPart = Types.DeepMutable> + +export const PatchPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("patch"), + hash: Schema.String, + files: Schema.Array(Schema.String), +}).annotate({ identifier: "PatchPart" }) +export type PatchPart = Types.DeepMutable> + +export const TextPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("text"), + text: Schema.String, + synthetic: Schema.optional(Schema.Boolean), + ignored: Schema.optional(Schema.Boolean), + time: Schema.optional( + Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), + ), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "TextPart" }) +export type TextPart = Types.DeepMutable> + +export const ReasoningPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("reasoning"), + text: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), +}).annotate({ identifier: "ReasoningPart" }) +export type ReasoningPart = Types.DeepMutable> + +const filePartSourceBase = { + text: Schema.Struct({ + value: Schema.String, + start: Schema.Finite, + end: Schema.Finite, + }).annotate({ identifier: "FilePartSourceText" }), +} + +export const Range = Schema.Struct({ + start: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), + end: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), +}).annotate({ identifier: "Range" }) +export type Range = typeof Range.Type + +export const FileSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("file"), + path: Schema.String, +}).annotate({ identifier: "FileSource" }) + +export const SymbolSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("symbol"), + path: Schema.String, + range: Range, + name: Schema.String, + kind: NonNegativeInt, +}).annotate({ identifier: "SymbolSource" }) + +export const ResourceSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("resource"), + clientName: Schema.String, + uri: Schema.String, +}).annotate({ identifier: "ResourceSource" }) + +export const FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({ + discriminator: "type", + identifier: "FilePartSource", +}) + +export const FilePart = Schema.Struct({ + ...partBase, + type: Schema.Literal("file"), + mime: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, + source: Schema.optional(FilePartSource), +}).annotate({ identifier: "FilePart" }) +export type FilePart = Types.DeepMutable> + +export const AgentPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("agent"), + name: Schema.String, + source: Schema.optional( + Schema.Struct({ + value: Schema.String, + start: NonNegativeInt, + end: NonNegativeInt, + }), + ), +}).annotate({ identifier: "AgentPart" }) +export type AgentPart = Types.DeepMutable> + +export const CompactionPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("compaction"), + auto: Schema.Boolean, + overflow: Schema.optional(Schema.Boolean), + tail_start_id: Schema.optional(MessageID), +}).annotate({ identifier: "CompactionPart" }) +export type CompactionPart = Types.DeepMutable> + +export const SubtaskPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("subtask"), + prompt: Schema.String, + description: Schema.String, + agent: Schema.String, + model: Schema.optional( + Schema.Struct({ + providerID: Provider.ID, + modelID: Model.ID, + }), + ), + command: Schema.optional(Schema.String), +}).annotate({ identifier: "SubtaskPart" }) +export type SubtaskPart = Types.DeepMutable> + +export const RetryPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("retry"), + attempt: NonNegativeInt, + error: APIError.EffectSchema, + time: Schema.Struct({ + created: NonNegativeInt, + }), +}).annotate({ identifier: "RetryPart" }) +export type RetryPart = Omit>, "error"> & { + error: APIError +} + +export const StepStartPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("step-start"), + snapshot: Schema.optional(Schema.String), +}).annotate({ identifier: "StepStartPart" }) +export type StepStartPart = Types.DeepMutable> + +export const StepFinishPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("step-finish"), + reason: Schema.String, + snapshot: Schema.optional(Schema.String), + cost: Schema.Finite, + tokens: Schema.Struct({ + total: Schema.optional(Schema.Finite), + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), +}).annotate({ identifier: "StepFinishPart" }) +export type StepFinishPart = Types.DeepMutable> + +export const ToolStatePending = Schema.Struct({ + status: Schema.Literal("pending"), + input: Schema.Record(Schema.String, Schema.Any), + raw: Schema.String, +}).annotate({ identifier: "ToolStatePending" }) +export type ToolStatePending = Types.DeepMutable> + +export const ToolStateRunning = Schema.Struct({ + status: Schema.Literal("running"), + input: Schema.Record(Schema.String, Schema.Any), + title: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + }), +}).annotate({ identifier: "ToolStateRunning" }) +export type ToolStateRunning = Types.DeepMutable> + +export const ToolStateCompleted = Schema.Struct({ + status: Schema.Literal("completed"), + input: Schema.Record(Schema.String, Schema.Any), + output: Schema.String, + title: Schema.String, + metadata: Schema.Record(Schema.String, Schema.Any), + time: Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + compacted: Schema.optional(NonNegativeInt), + }), + attachments: Schema.optional(Schema.Array(FilePart)), +}).annotate({ identifier: "ToolStateCompleted" }) +export type ToolStateCompleted = Types.DeepMutable> + +export const ToolStateError = Schema.Struct({ + status: Schema.Literal("error"), + input: Schema.Record(Schema.String, Schema.Any), + error: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + }), +}).annotate({ identifier: "ToolStateError" }) +export type ToolStateError = Types.DeepMutable> + +export const ToolState = Schema.Union([ + ToolStatePending, + ToolStateRunning, + ToolStateCompleted, + ToolStateError, +]).annotate({ + discriminator: "status", + identifier: "ToolState", +}) +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError + +export const ToolPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("tool"), + callID: Schema.String, + tool: Schema.String, + state: ToolState, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "ToolPart" }) +export type ToolPart = Omit>, "state"> & { + state: ToolState +} + +const messageBase = { + id: MessageID, + sessionID: partBase.sessionID, +} + +export const User = Schema.Struct({ + ...messageBase, + role: Schema.Literal("user"), + time: Schema.Struct({ + created: Timestamp, + }), + format: Schema.optional(Format), + summary: Schema.optional( + Schema.Struct({ + title: Schema.optional(Schema.String), + body: Schema.optional(Schema.String), + diffs: Schema.Array(FileDiff.Info), + }), + ), + agent: Schema.String, + model: Schema.Struct({ + providerID: Provider.ID, + modelID: Model.ID, + variant: Schema.optional(Schema.String), + }), + system: Schema.optional(Schema.String), + tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), +}).annotate({ identifier: "UserMessage" }) +export type User = Types.DeepMutable> + +export const Part = Schema.Union([ + TextPart, + SubtaskPart, + ReasoningPart, + FilePart, + ToolPart, + StepStartPart, + StepFinishPart, + SnapshotPart, + PatchPart, + AgentPart, + RetryPart, + CompactionPart, +]).annotate({ discriminator: "type", identifier: "Part" }) +export type Part = + | TextPart + | SubtaskPart + | ReasoningPart + | FilePart + | ToolPart + | StepStartPart + | StepFinishPart + | SnapshotPart + | PatchPart + | AgentPart + | RetryPart + | CompactionPart + +const AssistantErrorSchema = Schema.Union([ + AuthError.EffectSchema, + namedError("UnknownError", { message: Schema.String, ref: Schema.optional(Schema.String) }).EffectSchema, + OutputLengthError.EffectSchema, + AbortedError.EffectSchema, + StructuredOutputError.EffectSchema, + ContextOverflowError.EffectSchema, + ContentFilterError.EffectSchema, + APIError.EffectSchema, +]).annotate({ discriminator: "name" }) +type AssistantError = Schema.Schema.Type + +export const TextPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("text"), + text: Schema.String, + synthetic: Schema.optional(Schema.Boolean), + ignored: Schema.optional(Schema.Boolean), + time: Schema.optional( + Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), + ), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "TextPartInput" }) +export type TextPartInput = Types.DeepMutable> + +export const FilePartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("file"), + mime: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, + source: Schema.optional(FilePartSource), +}).annotate({ identifier: "FilePartInput" }) +export type FilePartInput = Types.DeepMutable> + +export const AgentPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("agent"), + name: Schema.String, + source: Schema.optional( + Schema.Struct({ + value: Schema.String, + start: NonNegativeInt, + end: NonNegativeInt, + }), + ), +}).annotate({ identifier: "AgentPartInput" }) +export type AgentPartInput = Types.DeepMutable> + +export const SubtaskPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("subtask"), + prompt: Schema.String, + description: Schema.String, + agent: Schema.String, + model: Schema.optional( + Schema.Struct({ + providerID: Provider.ID, + modelID: Model.ID, + }), + ), + command: Schema.optional(Schema.String), +}).annotate({ identifier: "SubtaskPartInput" }) +export type SubtaskPartInput = Types.DeepMutable> + +export const Assistant = Schema.Struct({ + ...messageBase, + role: Schema.Literal("assistant"), + time: Schema.Struct({ + created: NonNegativeInt, + completed: Schema.optional(NonNegativeInt), + }), + error: Schema.optional(AssistantErrorSchema), + parentID: MessageID, + modelID: Model.ID, + providerID: Provider.ID, + mode: Schema.String, + agent: Schema.String, + path: Schema.Struct({ + cwd: Schema.String, + root: Schema.String, + }), + summary: Schema.optional(Schema.Boolean), + cost: Schema.Finite, + tokens: Schema.Struct({ + total: Schema.optional(Schema.Finite), + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), + structured: Schema.optional(Schema.Any), + variant: Schema.optional(Schema.String), + finish: Schema.optional(Schema.String), +}).annotate({ identifier: "AssistantMessage" }) +export type Assistant = Omit>, "error"> & { + error?: AssistantError +} + +export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) +export type Info = User | Assistant + +export const WithParts = Schema.Struct({ + info: Info, + parts: Schema.Array(Part), +}) +export type WithParts = { + info: Info + parts: Part[] +} + +const options = { + durable: { + aggregate: "sessionID", + version: 1, + }, +} as const + +const SessionSummary = Schema.Struct({ + additions: Schema.Finite, + deletions: Schema.Finite, + files: Schema.Finite, + diffs: optional(Schema.Array(FileDiff.Info)), +}) + +const SessionTokens = Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) + +const SessionShare = Schema.Struct({ + url: Schema.String, +}) + +const SessionRevert = Schema.Struct({ + messageID: MessageID, + partID: optional(PartID), + snapshot: optional(Schema.String), + diff: optional(Schema.String), +}) + +const SessionModel = Schema.Struct({ + id: Model.ID, + providerID: Provider.ID, + variant: optional(Schema.String), +}) + +export const SessionInfo = Schema.Struct({ + id: SessionID, + slug: Schema.String, + projectID: Project.ID, + workspaceID: optional(WorkspaceID), + directory: Schema.String, + path: optional(Schema.String), + parentID: optional(SessionID), + summary: optional(SessionSummary), + cost: optional(Schema.Finite), + tokens: optional(SessionTokens), + share: optional(SessionShare), + title: Schema.String, + agent: optional(Schema.String), + model: optional(SessionModel), + version: Schema.String, + metadata: optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + created: NonNegativeInt, + updated: NonNegativeInt, + compacting: optional(NonNegativeInt), + archived: optional(Schema.Finite), + }), + permission: optional(PermissionV1.Ruleset), + revert: optional(SessionRevert), +}).annotate({ identifier: "Session" }) +export type SessionInfo = typeof SessionInfo.Type + +const events = { + Created: define({ + type: "session.created", + ...options, + schema: { + sessionID: SessionID, + info: SessionInfo, + }, + }), + Updated: define({ + type: "session.updated", + ...options, + schema: { + sessionID: SessionID, + info: SessionInfo, + }, + }), + Deleted: define({ + type: "session.deleted", + ...options, + schema: { + sessionID: SessionID, + info: SessionInfo, + }, + }), + MessageUpdated: define({ + type: "message.updated", + ...options, + schema: { + sessionID: SessionID, + info: Info, + }, + }), + MessageRemoved: define({ + type: "message.removed", + ...options, + schema: { + sessionID: SessionID, + messageID: MessageID, + }, + }), + PartUpdated: define({ + type: "message.part.updated", + ...options, + schema: { + sessionID: SessionID, + part: Part, + time: Schema.Finite, + }, + }), + PartRemoved: define({ + type: "message.part.removed", + ...options, + schema: { + sessionID: SessionID, + messageID: MessageID, + partID: PartID, + }, + }), +} + +export const PartDelta = define({ + type: "message.part.delta", + schema: { + sessionID: SessionID, + messageID: MessageID, + partID: PartID, + field: Schema.String, + delta: Schema.String, + }, +}) + +export const Diff = define({ + type: "session.diff", + schema: { + sessionID: SessionID, + diff: Schema.Array(FileDiff.Info), + }, +}) + +export const Error = define({ + type: "session.error", + schema: { + sessionID: Schema.optional(SessionID), + error: Assistant.fields.error, + }, +}) + +export const Event = { + ...events, + PartDelta, + Diff, + Error, + Definitions: inventory( + events.Created, + events.Updated, + events.Deleted, + events.MessageUpdated, + events.MessageRemoved, + events.PartUpdated, + events.PartRemoved, + PartDelta, + Diff, + Error, + ), +} diff --git a/packages/schema/src/vcs-event.ts b/packages/schema/src/vcs-event.ts new file mode 100644 index 0000000000..1c0d720dc4 --- /dev/null +++ b/packages/schema/src/vcs-event.ts @@ -0,0 +1,14 @@ +export * as VcsEvent from "./vcs-event" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Event } from "./event" + +export const BranchUpdated = Event.define({ + type: "vcs.branch.updated", + schema: { + branch: optional(Schema.String), + }, +}) + +export const Definitions = Event.inventory(BranchUpdated) diff --git a/packages/schema/src/workspace-event.ts b/packages/schema/src/workspace-event.ts new file mode 100644 index 0000000000..82e15e0771 --- /dev/null +++ b/packages/schema/src/workspace-event.ts @@ -0,0 +1,32 @@ +export * as WorkspaceEvent from "./workspace-event" + +import { Schema } from "effect" +import { Event } from "./event" +import { WorkspaceID } from "./workspace-id" + +export const ConnectionStatus = Schema.Struct({ + workspaceID: WorkspaceID, + status: Schema.Literals(["connected", "connecting", "disconnected", "error"]), +}).annotate({ identifier: "WorkspaceEvent.ConnectionStatus" }) +export interface ConnectionStatus extends Schema.Schema.Type {} + +export const Ready = Event.define({ + type: "workspace.ready", + schema: { + name: Schema.String, + }, +}) + +export const Failed = Event.define({ + type: "workspace.failed", + schema: { + message: Schema.String, + }, +}) + +export const Status = Event.define({ + type: "workspace.status", + schema: ConnectionStatus.fields, +}) + +export const Definitions = Event.inventory(Ready, Failed, Status) diff --git a/packages/schema/src/workspace-id.ts b/packages/schema/src/workspace-id.ts new file mode 100644 index 0000000000..e43e9673f6 --- /dev/null +++ b/packages/schema/src/workspace-id.ts @@ -0,0 +1,19 @@ +import { Schema } from "effect" +import { ascending } from "./identifier" +import { statics } from "./schema" + +export const WorkspaceID = Schema.String.check(Schema.isStartsWith("wrk")).pipe( + Schema.brand("WorkspaceV2.ID"), + statics((schema) => { + const create = () => schema.make("wrk_" + ascending()) + return { + ascending: (id?: string) => { + if (!id) return create() + if (!id.startsWith("wrk")) throw new Error(`ID ${id} does not start with wrk`) + return schema.make(id) + }, + create, + } + }), +) +export type WorkspaceID = typeof WorkspaceID.Type diff --git a/packages/schema/src/workspace.ts b/packages/schema/src/workspace.ts new file mode 100644 index 0000000000..ce35bf3b24 --- /dev/null +++ b/packages/schema/src/workspace.ts @@ -0,0 +1,9 @@ +export * as Workspace from "./workspace" + +import { WorkspaceEvent } from "./workspace-event" +import { WorkspaceID } from "./workspace-id" + +export const ID = WorkspaceID +export type ID = WorkspaceID + +export const Event = WorkspaceEvent diff --git a/packages/schema/src/worktree-event.ts b/packages/schema/src/worktree-event.ts new file mode 100644 index 0000000000..c42ea5821e --- /dev/null +++ b/packages/schema/src/worktree-event.ts @@ -0,0 +1,22 @@ +export * as WorktreeEvent from "./worktree-event" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Event } from "./event" + +export const Ready = Event.define({ + type: "worktree.ready", + schema: { + name: Schema.String, + branch: optional(Schema.String), + }, +}) + +export const Failed = Event.define({ + type: "worktree.failed", + schema: { + message: Schema.String, + }, +}) + +export const Definitions = Event.inventory(Ready, Failed) diff --git a/packages/schema/sst-env.d.ts b/packages/schema/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/schema/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/schema/test/compatibility.test.ts b/packages/schema/test/compatibility.test.ts new file mode 100644 index 0000000000..fa2cf265c4 --- /dev/null +++ b/packages/schema/test/compatibility.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test" +import { FileSystem } from "../src/filesystem" + +describe("schema compatibility", () => { + test("moved class schemas remain constructible", () => { + const input = new FileSystem.FindInput({ query: "src" }) + expect(input).toBeInstanceOf(FileSystem.FindInput) + expect(input.query).toBe("src") + }) +}) diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts new file mode 100644 index 0000000000..cf83dbb288 --- /dev/null +++ b/packages/schema/test/contract-hygiene.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { Agent } from "../src/agent" +import { FileSystem } from "../src/filesystem" +import { Model } from "../src/model" +import { Project } from "../src/project" +import { Pty } from "../src/pty" +import { Question } from "../src/question" +import { Session } from "../src/session" +import { SessionEvent } from "../src/session-event" +import { SessionTodo } from "../src/session-todo" +import { optional } from "../src/schema" + +describe("contract hygiene", () => { + test("optional properties preserve transformations and omit undefined while encoding", () => { + const Value = Schema.Struct({ value: optional(Schema.FiniteFromString) }) + expect(Schema.decodeUnknownSync(Value)({ value: "1" })).toEqual({ value: 1 }) + expect(Schema.encodeSync(Value)({ value: 1 })).toEqual({ value: "1" }) + expect(Schema.encodeSync(Value)({ value: undefined })).toEqual({}) + }) + + test("todo status and priority preserve arbitrary strings", () => { + const decode = Schema.decodeUnknownSync(SessionTodo.Info) + expect(decode({ content: "ship", status: "waiting", priority: "urgent" })).toEqual({ + content: "ship", + status: "waiting", + priority: "urgent", + }) + }) + + test("current ID constructors expose create", () => { + expect(Question.ID.create()).toStartWith("que_") + expect(Pty.ID.create()).toStartWith("pty_") + }) + + test("reusable public identifiers are stable and unique", () => { + const identifiers = [ + Agent.Color, + FileSystem.Submatch, + Model.Ref, + Model.Capabilities, + Model.Cost, + Model.Api, + Project.Icon, + Project.Commands, + Project.Time, + Project.Info, + Pty.Info, + Session.ListAnchor, + ].map((schema) => schema.ast.annotations?.identifier) + + expect(identifiers.every((identifier) => typeof identifier === "string")).toBe(true) + expect(new Set(identifiers).size).toBe(identifiers.length) + }) + + test("current source avoids Any and mutable contract wrappers", async () => { + const files = [...new Bun.Glob("*.ts").scanSync(new URL("../src", import.meta.url).pathname)].filter( + (file) => !file.endsWith("-v1.ts"), + ) + const source = await Promise.all( + files.map((file) => Bun.file(new URL(`../src/${file}`, import.meta.url)).text()), + ).then((values) => values.join("\n")) + + expect(source).not.toContain("Schema.Any") + expect(source).not.toContain("Schema.mutable") + }) +}) diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts new file mode 100644 index 0000000000..5694afdd30 --- /dev/null +++ b/packages/schema/test/event-manifest.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { FileSystem, Integration, Permission, Project, Reference, Session, Workspace } from "../src" +import { EventManifest } from "../src/event-manifest" +import { IdeEvent } from "../src/ide-event" +import { SessionEvent } from "../src/session-event" +import { SessionTodo } from "../src/session-todo" +import { SessionV1 } from "../src/session-v1" +import { WorkspaceEvent } from "../src/workspace-event" + +describe("public event manifest", () => { + test("owns the complete public event surface", () => { + expect(EventManifest.ServerDefinitions.length).toBe(55) + expect(EventManifest.Definitions.length).toBe(85) + expect(SessionV1.Event.Definitions).toEqual([ + SessionV1.Event.Created, + SessionV1.Event.Updated, + SessionV1.Event.Deleted, + SessionV1.Event.MessageUpdated, + SessionV1.Event.MessageRemoved, + SessionV1.Event.PartUpdated, + SessionV1.Event.PartRemoved, + SessionV1.Event.PartDelta, + SessionV1.Event.Diff, + SessionV1.Event.Error, + ]) + expect(EventManifest.Latest.size).toBe(85) + expect(EventManifest.Durable.size).toBe(32) + }) + + test("uses canonical definitions for current public events", () => { + expect(Session.Event).toBe(SessionEvent) + expect(Session.Event.Definitions).toBe(SessionEvent.Definitions) + expect(Workspace.Event).toBe(WorkspaceEvent) + expect(Workspace.Event.Definitions).toBe(WorkspaceEvent.Definitions) + expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) + expect(EventManifest.Latest.get("todo.updated")).toBe(SessionTodo.Event.Updated) + expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated) + expect(Project.Event.Definitions).toEqual([Project.Event.Updated]) + expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Edited]) + expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated]) + expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied]) + expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) + expect(EventManifest.Latest.has("ide.installed")).toBe(false) + expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) + expect(EventManifest.Definitions.slice(40, 43)).toEqual([ + SessionV1.Event.PartDelta, + SessionV1.Event.Diff, + SessionV1.Event.Error, + ]) + expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false) + expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended) + }) +}) diff --git a/packages/schema/test/event.test.ts b/packages/schema/test/event.test.ts new file mode 100644 index 0000000000..380faa5a4a --- /dev/null +++ b/packages/schema/test/event.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { Event } from "../src/event" + +describe("public event schemas", () => { + test("definition is pure", () => { + const definitions = Event.inventory() + Event.define({ type: "test.pure", schema: { value: Schema.String } }) + expect(definitions).toEqual([]) + }) + + test("latest selection is independent of declaration order", () => { + const historical = Event.define({ + type: "test.versioned", + durable: { aggregate: "id", version: 1 }, + schema: { id: Schema.String }, + }) + const current = Event.define({ + type: "test.versioned", + durable: { aggregate: "id", version: 2 }, + schema: { id: Schema.String, value: Schema.String }, + }) + + expect(Event.latest([historical, current]).get(current.type)).toBe(current) + expect(Event.latest([current, historical]).get(current.type)).toBe(current) + }) + + test("durable definitions are indexed by type and version", () => { + const definition = Event.define({ + type: "test.durable", + durable: { aggregate: "id", version: 1 }, + schema: { id: Schema.String }, + }) + + expect(Event.durable([definition]).get("test.durable.1")).toBe(definition) + }) +}) diff --git a/packages/schema/test/legacy-event.test.ts b/packages/schema/test/legacy-event.test.ts new file mode 100644 index 0000000000..e43c5681f2 --- /dev/null +++ b/packages/schema/test/legacy-event.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { LegacyEvent } from "../src/legacy-event" +import { PermissionV1 } from "../src/permission-v1" +import { QuestionV1 } from "../src/question-v1" +import { Project } from "../src/project" +import { SessionV1 } from "../src/session-v1" + +describe("legacy public event schemas", () => { + test("owns all SessionV1 definitions", () => { + expect(SessionV1.Event.Definitions.map((event) => event.type)).toEqual([ + "session.created", + "session.updated", + "session.deleted", + "message.updated", + "message.removed", + "message.part.updated", + "message.part.removed", + "message.part.delta", + "session.diff", + "session.error", + ]) + const durable = SessionV1.Event.Definitions.filter((event) => event.durable !== undefined) + expect(durable).toHaveLength(7) + expect(durable.every((event) => event.durable?.aggregate === "sessionID")).toBe(true) + expect(durable.every((event) => event.durable?.version === 1)).toBe(true) + }) + + test("owns the legacy transient public definitions", () => { + expect([ + SessionV1.PartDelta.type, + SessionV1.Diff.type, + SessionV1.Error.type, + PermissionV1.Event.Asked.type, + PermissionV1.Event.Replied.type, + QuestionV1.Event.Asked.type, + QuestionV1.Event.Replied.type, + QuestionV1.Event.Rejected.type, + Project.Event.Updated.type, + LegacyEvent.CommandExecuted.type, + ]).toEqual([ + "message.part.delta", + "session.diff", + "session.error", + "permission.asked", + "permission.replied", + "question.asked", + "question.replied", + "question.rejected", + "project.updated", + "command.executed", + ]) + }) +}) diff --git a/packages/schema/test/v1-isolation.test.ts b/packages/schema/test/v1-isolation.test.ts new file mode 100644 index 0000000000..2710d85567 --- /dev/null +++ b/packages/schema/test/v1-isolation.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test" +import { LegacyEvent } from "../src/legacy-event" +import { PermissionV1 } from "../src/permission-v1" +import { QuestionV1 } from "../src/question-v1" +import { SessionV1 } from "../src/session-v1" +import { LegacyEvent as IsolatedLegacyEvent } from "../src/v1/legacy-event" +import { PermissionV1 as IsolatedPermissionV1 } from "../src/v1/permission" +import { QuestionV1 as IsolatedQuestionV1 } from "../src/v1/question" +import { SessionV1 as IsolatedSessionV1 } from "../src/v1/session" + +test("compatibility entrypoints preserve isolated V1 schema identity", () => { + expect(LegacyEvent).toBe(IsolatedLegacyEvent) + expect(PermissionV1).toBe(IsolatedPermissionV1) + expect(QuestionV1).toBe(IsolatedQuestionV1) + expect(SessionV1).toBe(IsolatedSessionV1) +}) + +test("current source does not import the V1 subtree directly", async () => { + const allowed = new Set(["legacy-event.ts", "permission-v1.ts", "question-v1.ts", "session-v1.ts"]) + const files = [...new Bun.Glob("*.ts").scanSync(new URL("../src", import.meta.url).pathname)].filter( + (file) => !allowed.has(file), + ) + const directImports = await Promise.all( + files.map(async (file) => ({ file, source: await Bun.file(new URL(`../src/${file}`, import.meta.url)).text() })), + ).then((values) => values.filter((value) => value.source.includes('from "./v1/'))) + + expect(directImports).toEqual([]) +}) diff --git a/packages/schema/tsconfig.json b/packages/schema/tsconfig.json new file mode 100644 index 0000000000..00ef125468 --- /dev/null +++ b/packages/schema/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/sdk-next/README.md b/packages/sdk-next/README.md new file mode 100644 index 0000000000..8f584c8a52 --- /dev/null +++ b/packages/sdk-next/README.md @@ -0,0 +1,29 @@ +# @opencode-ai/sdk-next + +Effect-native scoped OpenCode host for in-process applications. This transitional package will replace the existing generated `@kilocode/sdk` after its consumers migrate. + +The SDK executes Server's assembled HTTP router in memory. It opens no listener and performs no network I/O, while preserving the same routing, middleware, handlers, codecs, and errors as the network client. + +```ts +import { OpenCode } from "@opencode-ai/sdk-next" + +const opencode = yield * OpenCode.create() +const session = yield * opencode.sessions.get({ sessionID }) +``` + +It also exports `Tool` and exposes local-only `tools.register(...)`, replacing the former `@opencode-ai/core/public` facade. Registration uses Core's host-level `ApplicationTools` service shared by the host's Locations; each Location retains its own `ToolRegistry` for overlay, lookup, and settlement. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations. + +`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message. + +The same constructor is available as a service Layer: + +```ts +const program = Effect.gen(function* () { + const opencode = yield* OpenCode.Service + return yield* opencode.sessions.get({ sessionID }) +}) + +yield * program.pipe(Effect.provide(OpenCode.layer)) +``` + +`OpenCode.layer` adapts `OpenCode.create()` for dependency injection; it does not define another host implementation. diff --git a/packages/sdk-next/package.json b/packages/sdk-next/package.json new file mode 100644 index 0000000000..a0ca87bd36 --- /dev/null +++ b/packages/sdk-next/package.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/sdk-next", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "bun test --timeout 5000", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@opencode-ai/client": "workspace:*", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/server": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + }, + "version": "7.4.16" +} diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts new file mode 100644 index 0000000000..fc23219fc4 --- /dev/null +++ b/packages/sdk-next/src/index.ts @@ -0,0 +1,17 @@ +export * as OpenCode from "./opencode" +export * as Tool from "./tool" + +export { ClientError } from "@opencode-ai/client/effect" +export { + AbsolutePath, + Agent, + Location, + Model, + Prompt, + Provider, + RelativePath, + Session, + SessionInput, + SessionMessage, +} from "@opencode-ai/client/effect" +export type { OpenCodeEvent } from "@opencode-ai/client/effect" diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts new file mode 100644 index 0000000000..096b46d4b7 --- /dev/null +++ b/packages/sdk-next/src/opencode.ts @@ -0,0 +1,49 @@ +import { OpenCode } from "@opencode-ai/client/effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" +import { createEmbeddedRoutes } from "@opencode-ai/server/routes" +import { Context, Effect, Layer, Scope } from "effect" +import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" + +export const create = Effect.fn("OpenCode.create")(function* () { + const scope = yield* Scope.Scope + const memoMap = yield* Layer.makeMemoMap + const context = yield* Layer.buildWithMemoMap( + AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, PermissionSaved.node])), + memoMap, + scope, + ) + const tools = Context.get(context, ApplicationTools.Service) + const permissions = Context.get(context, PermissionSaved.Service) + const web = yield* Effect.acquireRelease( + Effect.sync(() => + HttpRouter.toWebHandler( + createEmbeddedRoutes().pipe( + HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)), + Layer.provide(HttpServer.layerServices), + ), + { disableLogger: true, memoMap }, + ), + ), + (web) => Effect.promise(web.dispose), + ) + const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => web.handler(new Request(input, init)), { + preconnect: () => undefined, + }) satisfies typeof globalThis.fetch + const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, fetch), + ) + return { + ...client, + tools: { register: tools.register }, + } +}) + +export type Interface = Effect.Success> + +export class Service extends Context.Service()("@opencode-ai/sdk-next/OpenCode") {} + +export const layer = Layer.effect(Service, create()) diff --git a/packages/sdk-next/src/tool.ts b/packages/sdk-next/src/tool.ts new file mode 100644 index 0000000000..4b572a6260 --- /dev/null +++ b/packages/sdk-next/src/tool.ts @@ -0,0 +1,2 @@ +export { Failure, RegistrationError, make } from "@opencode-ai/core/tool/tool" +export type { AnyTool, Content, Context, Definition } from "@opencode-ai/core/tool/tool" diff --git a/packages/sdk-next/sst-env.d.ts b/packages/sdk-next/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/sdk-next/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts new file mode 100644 index 0000000000..05d4ae1cd6 --- /dev/null +++ b/packages/sdk-next/test/embedded.test.ts @@ -0,0 +1,212 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Deferred, Effect, Latch, Option, Schema, Stream } from "effect" +import type { OpenCodeEvent } from "../src" + +test("embedded client uses the real router and handlers", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-")) + const database = Flag.KILO_DB + Flag.KILO_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Provider, Session, Tool } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + const model = Model.Ref.make({ id: Model.ID.make("embedded"), providerID: Provider.ID.make("test") }) + + try { + const program = Effect.gen(function* () { + const opencode = yield* OpenCode.create() + yield* opencode.tools.register({ + embedded_tool: Tool.make({ + description: "Embedded test tool", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ ok: true }), + }), + }) + + const created = yield* opencode.sessions.create({ + id: sessionID, + agent: Agent.ID.make("build"), + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + yield* opencode.sessions.switchModel({ sessionID, model }) + const selected = yield* opencode.sessions.get({ sessionID }) + const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) }) + const active = yield* opencode.sessions.active() + const admitted = yield* opencode.sessions.prompt({ + sessionID, + prompt: Prompt.make({ text: "Do not run" }), + resume: false, + }) + const context = yield* opencode.sessions.context({ sessionID }) + const wake = yield* opencode.sessions.prompt({ + sessionID, + prompt: Prompt.make({ text: "Promote this input" }), + }) + const prompted = yield* opencode.sessions.events({ sessionID }).pipe( + Stream.filter((event) => event.type === "session.next.prompted" && event.data.messageID === wake.id), + Stream.runHead, + Effect.timeout("10 seconds"), + Effect.map(Option.getOrThrow), + ) + const wakeContext = yield* opencode.sessions.context({ sessionID }) + const event = yield* opencode.sessions + .events({ sessionID }) + .pipe(Stream.take(1), Stream.runHead, Effect.map(Option.getOrUndefined)) + const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe( + Option.getOrThrow, + ) + const message = yield* opencode.sessions.message({ sessionID, messageID: modelMessage.id }) + yield* opencode.sessions.interrupt({ sessionID }) + const other = yield* opencode.sessions.create({ + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + const missingSessionID = Session.ID.make(`ses_missing_${crypto.randomUUID()}`) + const missing = yield* Effect.all( + [ + opencode.sessions.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip), + opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip), + opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip), + ], + { concurrency: "unbounded" }, + ) + const missingMessage = yield* Effect.flip( + opencode.sessions.message({ + sessionID: other.id, + messageID: modelMessage.id, + }), + ) + + expect(created.id).toBe(sessionID) + expect(selected.model?.id).toBe(model.id) + expect(selected.model?.providerID).toBe(model.providerID) + expect(page.data.some((session) => session.id === sessionID)).toBe(true) + expect(active).toEqual({}) + expect(admitted.sessionID).toBe(sessionID) + expect(prompted.type).toBe("session.next.prompted") + expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" })) + expect(context.some((message) => message.type === "model-switched")).toBe(true) + expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } }) + expect(message).toEqual(modelMessage) + expect(missing.map((error) => error._tag)).toEqual([ + "SessionNotFoundError", + "SessionNotFoundError", + "SessionNotFoundError", + ]) + expect(missingMessage._tag).toBe("MessageNotFoundError") + }) + await Effect.runPromise(Effect.scoped(program)) + } finally { + Flag.KILO_DB = database + await rm(directory, { recursive: true, force: true }) + } +}) + +test("Location-owned runner events reach the ready global client", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-events-")) + const database = Flag.KILO_DB + Flag.KILO_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Location, OpenCode, Prompt, Session } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + + try { + const program = Effect.gen(function* () { + const opencode = yield* OpenCode.create() + const connected = yield* Latch.make(false) + const prompted = yield* Deferred.make() + yield* opencode.events.subscribe().pipe( + Stream.runForEach((event) => + event.type === "server.connected" + ? connected.open + : event.type === "session.next.prompted" && event.data.sessionID === sessionID + ? Deferred.succeed(prompted, event).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.forkScoped, + ) + yield* connected.await + yield* opencode.sessions.create({ + id: sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + yield* opencode.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Observe this input" }) }) + + const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds")) + expect(event.durable).toEqual(expect.objectContaining({ aggregateID: sessionID, seq: expect.any(Number) })) + }) + await Effect.runPromise(Effect.scoped(program)) + } finally { + Flag.KILO_DB = database + await rm(directory, { recursive: true, force: true }) + } +}, 10_000) + +test("independent embedded hosts do not share live notifications", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-hosts-")) + const database = Flag.KILO_DB + Flag.KILO_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Agent, Location, OpenCode, Session } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + + try { + const program = Effect.gen(function* () { + const first = yield* OpenCode.create() + const second = yield* OpenCode.create() + const firstReady = yield* Latch.make(false) + const secondReady = yield* Latch.make(false) + const firstEvent = yield* Latch.make(false) + const secondEvent = yield* Latch.make(false) + const observe = (ready: Latch.Latch, event: Latch.Latch) => + Stream.runForEach((notification: OpenCodeEvent) => + notification.type === "server.connected" + ? ready.open + : notification.type === "session.next.agent.switched" && notification.data.sessionID === sessionID + ? event.open + : Effect.void, + ) + + yield* first.events.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped) + yield* second.events.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped) + yield* Effect.all([firstReady.await, secondReady.await], { discard: true }) + yield* first.sessions.create({ + id: sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + yield* first.sessions.switchAgent({ sessionID, agent: Agent.ID.make("plan") }) + + yield* firstEvent.await.pipe(Effect.timeout("2 seconds")) + expect(Option.isNone(yield* secondEvent.await.pipe(Effect.timeoutOption("100 millis")))).toBe(true) + }) + await Effect.runPromise(Effect.scoped(program)) + } finally { + Flag.KILO_DB = database + await rm(directory, { recursive: true, force: true }) + } +}, 10_000) + +test("embedded client is available as a Layer service", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-")) + const database = Flag.KILO_DB + Flag.KILO_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Location, OpenCode, Session } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + + try { + const created = await Effect.runPromise( + Effect.gen(function* () { + const opencode = yield* OpenCode.Service + return yield* opencode.sessions.create({ + id: sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + }).pipe(Effect.provide(OpenCode.layer), Effect.scoped), + ) + + expect(created.id).toBe(sessionID) + } finally { + Flag.KILO_DB = database + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/sdk-next/test/import-boundaries.test.ts b/packages/sdk-next/test/import-boundaries.test.ts new file mode 100644 index 0000000000..f6a7d178e2 --- /dev/null +++ b/packages/sdk-next/test/import-boundaries.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { join, resolve, sep } from "node:path" + +const directory = resolve(import.meta.dir, "..") +const client = resolve(import.meta.dir, "../../client") +const core = resolve(import.meta.dir, "../../core") +const server = resolve(import.meta.dir, "../../server") + +test("bundles the client and in-memory host", async () => { + const inputs = await bundleInputs() + + expect(within(inputs, client).length).toBeGreaterThan(0) + expect(within(inputs, core).length).toBeGreaterThan(0) + expect(within(inputs, server).length).toBeGreaterThan(0) +}) + +async function bundleInputs() { + const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-")) + const entrypoint = join(temporary, "index.ts") + const metafile = join(temporary, "meta.json") + try { + await Bun.write(entrypoint, 'export * from "@opencode-ai/sdk-next"') + const child = Bun.spawn( + [ + process.execPath, + "build", + entrypoint, + "--target=bun", + "--format=esm", + "--packages=bundle", + `--metafile=${metafile}`, + `--outdir=${join(temporary, "out")}`, + ], + { cwd: directory, stdout: "pipe", stderr: "pipe" }, + ) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + if (exitCode !== 0) throw new Error(stdout + stderr) + const metadata = await Bun.file(metafile).json() + return Object.keys(metadata.inputs).map((input) => resolve(directory, input)) + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} + +function within(inputs: ReadonlyArray, directory: string) { + const prefix = directory.endsWith(sep) ? directory : directory + sep + return inputs.filter((input) => input === directory || input.startsWith(prefix)) +} diff --git a/packages/sdk-next/tsconfig.json b/packages/sdk-next/tsconfig.json new file mode 100644 index 0000000000..00ef125468 --- /dev/null +++ b/packages/sdk-next/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index b34ead971f..df6dda0453 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,10 +1,11 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.4.15", + "version": "7.4.16", "type": "module", "license": "MIT", "scripts": { + "test": "bun test", "typecheck": "tsgo --noEmit", "build": "bun ./script/build.ts" }, @@ -15,7 +16,8 @@ "./v2": "./src/v2/index.ts", "./v2/client": "./src/v2/client.ts", "./v2/gen/client": "./src/v2/gen/client/index.ts", - "./v2/server": "./src/v2/server.ts" + "./v2/server": "./src/v2/server.ts", + "./v2/types": "./src/v2/gen/types.gen.ts" }, "files": [ "dist" diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 9e18f14abb..52e087e5e2 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -13,6 +13,37 @@ const opencode = path.resolve(dir, "../../opencode") await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) +const document = (await Bun.file("./openapi.json").json()) as { + components?: { schemas?: Record } + [key: string]: unknown +} +const schemas = document.components?.schemas +if (schemas) { + const reachable = new Set() + const visit = (value: unknown) => { + if (Array.isArray(value)) { + value.forEach(visit) + return + } + if (typeof value !== "object" || value === null) return + for (const [key, child] of Object.entries(value)) { + if (key === "$ref" && typeof child === "string" && child.startsWith("#/components/schemas/")) { + const name = child.slice("#/components/schemas/".length) + if (reachable.has(name)) continue + reachable.add(name) + visit(schemas[name]) + } else { + visit(child) + } + } + } + visit({ ...document, components: { ...document.components, schemas: undefined } }) + for (const name of Object.keys(schemas)) { + if (/^SessionNext\w+1$/.test(name) && !reachable.has(name)) delete schemas[name] + } + await Bun.write("./openapi.json", JSON.stringify(document)) +} + await createClient({ input: "./openapi.json", output: { @@ -40,6 +71,29 @@ await createClient({ ], }) +const generatedTypes = await Bun.file("./src/v2/gen/types.gen.ts").text() +if (/export type SessionNext\w+1 =/.test(generatedTypes)) { + throw new Error("Session history generated duplicate Session event variants") +} +const historyTypesPatched = generatedTypes.replace( + /(export type V2SessionHistoryData = \{[\s\S]*?query\?: \{\s*limit\?: )string([;,]\s*after\?: )string/, + "$1number$2number", +) +if (historyTypesPatched === generatedTypes) { + throw new Error("Session history numeric query patch did not apply") +} +await Bun.write("./src/v2/gen/types.gen.ts", historyTypesPatched) + +const generatedSdk = await Bun.file("./src/v2/gen/sdk.gen.ts").text() +const historySdkPatched = generatedSdk.replace( + /(Get session history[\s\S]*?parameters: \{\s*sessionID: string[;,]\s*limit\?: )string([;,]\s*after\?: )string/, + "$1number$2number", +) +if (historySdkPatched === generatedSdk) { + throw new Error("Session history numeric SDK patch did not apply") +} +await Bun.write("./src/v2/gen/sdk.gen.ts", historySdkPatched) + // Patch a @hey-api/openapi-ts codegen bug: SseFn incorrectly passes the // endpoint's TError into the second generic of ServerSentEventsResult, which // is the AsyncGenerator's TReturn slot. Iterator return values have nothing diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 0e8e1edea9..706b060d89 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -111,6 +111,7 @@ import type { McpRemoteConfig, McpStatusErrors, McpStatusResponses, + ModelRef, MoveSessionDestination, OutputFormat, Part as Part2, @@ -128,17 +129,20 @@ import type { PermissionRespondResponses, PermissionRuleset, PermissionV2Reply, + PermissionV2Source, + ProjectCommands, ProjectCurrentErrors, ProjectCurrentResponses, ProjectDirectoriesErrors, ProjectDirectoriesResponses, + ProjectIcon, ProjectInitGitErrors, ProjectInitGitResponses, ProjectListErrors, ProjectListResponses, ProjectUpdateErrors, ProjectUpdateResponses, - Prompt, + PromptInput, ProviderAuthErrors, ProviderAuthResponses, ProviderListErrors, @@ -329,18 +333,32 @@ import type { V2QuestionRequestListResponses, V2ReferenceListErrors, V2ReferenceListResponses, + V2SessionActiveErrors, + V2SessionActiveResponses, V2SessionCompactErrors, V2SessionCompactResponses, V2SessionContextErrors, V2SessionContextResponses, V2SessionCreateErrors, V2SessionCreateResponses, + V2SessionEventsErrors, + V2SessionEventsResponses, V2SessionGetErrors, V2SessionGetResponses, + V2SessionHistoryErrors, + V2SessionHistoryResponses, + V2SessionInterruptErrors, + V2SessionInterruptResponses, V2SessionListErrors, V2SessionListResponses, + V2SessionMessageErrors, + V2SessionMessageResponses, V2SessionMessagesErrors, V2SessionMessagesResponses, + V2SessionPermissionCreateErrors, + V2SessionPermissionCreateResponses, + V2SessionPermissionGetErrors, + V2SessionPermissionGetResponses, V2SessionPermissionListErrors, V2SessionPermissionListResponses, V2SessionPermissionReplyErrors, @@ -353,6 +371,16 @@ import type { V2SessionQuestionRejectResponses, V2SessionQuestionReplyErrors, V2SessionQuestionReplyResponses, + V2SessionRevertClearErrors, + V2SessionRevertClearResponses, + V2SessionRevertCommitErrors, + V2SessionRevertCommitResponses, + V2SessionRevertStageErrors, + V2SessionRevertStageResponses, + V2SessionSwitchAgentErrors, + V2SessionSwitchAgentResponses, + V2SessionSwitchModelErrors, + V2SessionSwitchModelResponses, V2SessionWaitErrors, V2SessionWaitResponses, V2SkillListErrors, @@ -2601,17 +2629,8 @@ export class Project extends HeyApiClient { directory?: string workspace?: string name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } + icon?: ProjectIcon + commands?: ProjectCommands }, options?: Options, ) { @@ -5064,6 +5083,91 @@ export class Agent extends HeyApiClient { } } +export class Revert extends HeyApiClient { + /** + * Stage session revert + * + * Stage or move a reversible session boundary and optionally apply its file changes. + */ + public stage( + parameters: { + sessionID: string + messageID?: string + files?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "messageID" }, + { in: "body", key: "files" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionRevertStageResponses, + V2SessionRevertStageErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/revert/stage", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Clear staged revert + */ + public clear( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).post< + V2SessionRevertClearResponses, + V2SessionRevertClearErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/revert/clear", + ...options, + ...params, + }) + } + + /** + * Commit staged revert + */ + public commit( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).post< + V2SessionRevertCommitResponses, + V2SessionRevertCommitErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/revert/commit", + ...options, + ...params, + }) + } +} + export class Permission2 extends HeyApiClient { /** * List session permission requests @@ -5088,6 +5192,93 @@ export class Permission2 extends HeyApiClient { }) } + /** + * Create permission request + * + * Evaluate and, when approval is required, create a permission request for a session. + */ + public create( + parameters: { + sessionID: string + id?: string + action?: string + resources?: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source + agent?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "id" }, + { in: "body", key: "action" }, + { in: "body", key: "resources" }, + { in: "body", key: "save" }, + { in: "body", key: "metadata" }, + { in: "body", key: "source" }, + { in: "body", key: "agent" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionPermissionCreateResponses, + V2SessionPermissionCreateErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/permission", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Get permission request + * + * Retrieve a pending permission request owned by a session. + */ + public get( + parameters: { + sessionID: string + requestID: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "requestID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + V2SessionPermissionGetResponses, + V2SessionPermissionGetErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/permission/{requestID}", + ...options, + ...params, + }) + } + /** * Reply to pending permission request * @@ -5284,11 +5475,7 @@ export class Session3 extends HeyApiClient { parameters?: { id?: string agent?: string - model?: { - id: string - providerID: string - variant?: string - } + model?: ModelRef location?: LocationRef }, options?: Options, @@ -5318,6 +5505,18 @@ export class Session3 extends HeyApiClient { }) } + /** + * List active sessions + * + * Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive. + */ + public active(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/api/session/active", + ...options, + }) + } + /** * Get session * @@ -5337,6 +5536,84 @@ export class Session3 extends HeyApiClient { }) } + /** + * Switch session agent + * + * Switch the agent used by subsequent provider turns. + */ + public switchAgent( + parameters: { + sessionID: string + agent?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "agent" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionSwitchAgentResponses, + V2SessionSwitchAgentErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/agent", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Switch session model + * + * Switch the model used by subsequent provider turns. + */ + public switchModel( + parameters: { + sessionID: string + model?: ModelRef + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "model" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionSwitchModelResponses, + V2SessionSwitchModelErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/model", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Send message * @@ -5346,7 +5623,7 @@ export class Session3 extends HeyApiClient { parameters: { sessionID: string id?: string - prompt?: Prompt + prompt?: PromptInput delivery?: "steer" | "queue" resume?: boolean }, @@ -5435,6 +5712,117 @@ export class Session3 extends HeyApiClient { }) } + /** + * Get session history + * + * Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages. + */ + public history( + parameters: { + sessionID: string + limit?: number + after?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "limit" }, + { in: "query", key: "after" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/history", + ...options, + ...params, + }) + } + + /** + * Subscribe to session events + * + * Replay durable events after an aggregate sequence, then continue with new durable events. + */ + public events( + parameters: { + sessionID: string + after?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "after" }, + ], + }, + ], + ) + return (options?.client ?? this.client).sse.get({ + url: "/api/session/{sessionID}/event", + ...options, + ...params, + }) + } + + /** + * Interrupt session execution + * + * Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. + */ + public interrupt( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/interrupt", + ...options, + ...params, + }) + } + + /** + * Get session message + * + * Retrieve one projected message owned by the Session. + */ + public message( + parameters: { + sessionID: string + messageID: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "messageID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/message/{messageID}", + ...options, + ...params, + }) + } + /** * Get session messages * @@ -5469,6 +5857,11 @@ export class Session3 extends HeyApiClient { }) } + private _revert?: Revert + get revert(): Revert { + return (this._revert ??= new Revert({ client: this.client })) + } + private _permission?: Permission2 get permission(): Permission2 { return (this._permission ??= new Permission2({ client: this.client })) @@ -6157,22 +6550,12 @@ export class Event2 extends HeyApiClient { /** * Subscribe to events * - * Subscribe to native event payloads for a location. + * Subscribe to native event payloads for the server. */ - public subscribe( - parameters?: { - location?: { - directory?: string - workspace?: string - } - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + public subscribe(options?: Options) { return (options?.client ?? this.client).sse.get({ url: "/api/event", ...options, - ...params, }) } } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e2add116e0..5e067f3afb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -6,8 +6,8 @@ export type ClientOptions = { export type Event = | EventModelsDevRefreshed - | EventPluginAdded | EventIntegrationUpdated + | EventIntegrationConnectionUpdated | EventCatalogUpdated | EventSessionCreated | EventSessionUpdated @@ -21,8 +21,6 @@ export type Event = | EventSessionNextMoved | EventSessionNextPrompted | EventSessionNextPromptAdmitted - | EventSessionNextPromptPromoted - | EventSessionNextInterruptRequested | EventSessionNextContextUpdated | EventSessionNextSynthetic | EventSessionNextShellStarted @@ -47,15 +45,19 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded + | EventSessionNextRevertStaged + | EventSessionNextRevertCleared + | EventSessionNextRevertCommitted | EventMessagePartDelta | EventSessionDiff | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable | EventFileEdited + | EventReferenceUpdated | EventPermissionV2Asked | EventPermissionV2Replied - | EventReferenceUpdated + | EventPluginAdded | EventProjectDirectoriesUpdated | EventFileWatcherUpdated | EventPtyCreated @@ -739,16 +741,16 @@ export type GlobalEvent = { } | { id: string - type: "plugin.added" + type: "integration.updated" properties: { - id: string + [key: string]: unknown } } | { id: string - type: "integration.updated" + type: "integration.connection.updated" properties: { - [key: string]: unknown + integrationID: string } } | { @@ -833,11 +835,7 @@ export type GlobalEvent = { timestamp: number sessionID: string messageID: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef } } | { @@ -872,25 +870,6 @@ export type GlobalEvent = { delivery: "steer" | "queue" } } - | { - id: string - type: "session.next.prompt.promoted" - properties: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - timeCreated: number - } - } - | { - id: string - type: "session.next.interrupt.requested" - properties: { - timestamp: number - sessionID: string - } - } | { id: string type: "session.next.context.updated" @@ -940,11 +919,7 @@ export type GlobalEvent = { sessionID: string assistantMessageID: string agent: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef snapshot?: string } } @@ -967,6 +942,7 @@ export type GlobalEvent = { } } snapshot?: string + files?: Array } } | { @@ -1019,11 +995,7 @@ export type GlobalEvent = { sessionID: string assistantMessageID: string reasoningID: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } | { @@ -1046,11 +1018,7 @@ export type GlobalEvent = { assistantMessageID: string reasoningID: string text: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } | { @@ -1100,11 +1068,7 @@ export type GlobalEvent = { } provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -1119,7 +1083,7 @@ export type GlobalEvent = { structured: { [key: string]: unknown } - content: Array + content: Array } } | { @@ -1133,16 +1097,12 @@ export type GlobalEvent = { structured: { [key: string]: unknown } - content: Array + content: Array outputPaths?: Array result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -1158,11 +1118,7 @@ export type GlobalEvent = { result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -1208,6 +1164,32 @@ export type GlobalEvent = { recent: string } } + | { + id: string + type: "session.next.revert.staged" + properties: { + timestamp: number + sessionID: string + revert: RevertState + } + } + | { + id: string + type: "session.next.revert.cleared" + properties: { + timestamp: number + sessionID: string + } + } + | { + id: string + type: "session.next.revert.committed" + properties: { + timestamp: number + sessionID: string + messageID: string + } + } | { id: string type: "message.part.delta" @@ -1264,6 +1246,13 @@ export type GlobalEvent = { file: string } } + | { + id: string + type: "reference.updated" + properties: { + [key: string]: unknown + } + } | { id: string type: "permission.v2.asked" @@ -1290,9 +1279,9 @@ export type GlobalEvent = { } | { id: string - type: "reference.updated" + type: "plugin.added" properties: { - [key: string]: unknown + id: string } } | { @@ -1493,24 +1482,11 @@ export type GlobalEvent = { properties: { id: string worktree: string - vcs?: "git" + vcs?: ProjectVcs name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - time: { - created: number - updated: number - initialized?: number - } + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime sandboxes: Array } } @@ -1637,8 +1613,6 @@ export type GlobalEvent = { | SyncEventSessionNextMoved | SyncEventSessionNextPrompted | SyncEventSessionNextPromptAdmitted - | SyncEventSessionNextPromptPromoted - | SyncEventSessionNextInterruptRequested | SyncEventSessionNextContextUpdated | SyncEventSessionNextSynthetic | SyncEventSessionNextShellStarted @@ -1659,6 +1633,9 @@ export type GlobalEvent = { | SyncEventSessionNextRetried | SyncEventSessionNextCompactionStarted | SyncEventSessionNextCompactionEnded + | SyncEventSessionNextRevertStaged + | SyncEventSessionNextRevertCleared + | SyncEventSessionNextRevertCommitted } /** @@ -2442,24 +2419,11 @@ export type McpServerNotFoundError = { export type Project = { id: string worktree: string - vcs?: "git" + vcs?: ProjectVcs name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - time: { - created: number - updated: number - initialized?: number - } + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime sandboxes: Array } @@ -2726,12 +2690,22 @@ export type InvalidCursorError = { message: string } +export type SessionActive = { + type: "running" +} + export type SessionNotFoundError = { _tag: "SessionNotFoundError" sessionID: string message: string } +export type PromptInput = { + text: string + files?: Array + agents?: Array +} + export type ConflictError = { _tag: "ConflictError" message: string @@ -2744,12 +2718,56 @@ export type ServiceUnavailableError = { service?: string } +export type MessageNotFoundError = { + _tag: "MessageNotFoundError" + sessionID: string + messageID: string + message: string +} + export type UnknownError1 = { _tag: "UnknownError" message: string ref?: string } +export type SessionDurableEvent = + | SessionNextAgentSwitched + | SessionNextModelSwitched + | SessionNextMoved + | SessionNextPrompted + | SessionNextPromptAdmitted + | SessionNextContextUpdated + | SessionNextSynthetic + | SessionNextShellStarted + | SessionNextShellEnded + | SessionNextStepStarted + | SessionNextStepEnded + | SessionNextStepFailed + | SessionNextTextStarted + | SessionNextTextEnded + | SessionNextToolInputStarted + | SessionNextToolInputEnded + | SessionNextToolCalled + | SessionNextToolProgress + | SessionNextToolSuccess + | SessionNextToolFailed + | SessionNextReasoningStarted + | SessionNextReasoningEnded + | SessionNextRetried + | SessionNextCompactionStarted + | SessionNextCompactionEnded + | SessionNextRevertStaged + | SessionNextRevertCleared + | SessionNextRevertCommitted + +export type SessionHistory = { + data: Array + hasMore: boolean +} + +export type SessionDurableEventStream = string + export type SessionMessagesResponse = { data: Array cursor: { @@ -2764,6 +2782,163 @@ export type ProviderNotFoundError = { message: string } +export type OutputFormat1 = + | { + type: "text" + } + | { + type: "json_schema" + schema: JsonSchema + retryCount?: number + } + +export type SessionStatus2 = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.status" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + status: SessionStatus + } +} + +export type QuestionReplied2 = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.replied" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + answers: Array + } +} + +export type QuestionRejected2 = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.rejected" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + } +} + +export type V2Event = + | ModelsDevRefreshed + | IntegrationUpdated + | IntegrationConnectionUpdated + | CatalogUpdated + | SessionCreated + | SessionUpdated + | SessionDeleted + | MessageUpdated + | MessageRemoved + | MessagePartUpdated + | MessagePartRemoved + | SessionNextAgentSwitched + | SessionNextModelSwitched + | SessionNextMoved + | SessionNextPrompted + | SessionNextPromptAdmitted + | SessionNextContextUpdated + | SessionNextSynthetic + | SessionNextShellStarted + | SessionNextShellEnded + | SessionNextStepStarted + | SessionNextStepEnded + | SessionNextStepFailed + | SessionNextTextStarted + | SessionNextTextDelta + | SessionNextTextEnded + | SessionNextReasoningStarted + | SessionNextReasoningDelta + | SessionNextReasoningEnded + | SessionNextToolInputStarted + | SessionNextToolInputDelta + | SessionNextToolInputEnded + | SessionNextToolCalled + | SessionNextToolProgress + | SessionNextToolSuccess + | SessionNextToolFailed + | SessionNextRetried + | SessionNextCompactionStarted + | SessionNextCompactionDelta + | SessionNextCompactionEnded + | SessionNextRevertStaged + | SessionNextRevertCleared + | SessionNextRevertCommitted + | MessagePartDelta + | SessionDiff + | SessionError + | InstallationUpdated + | InstallationUpdateAvailable + | FileEdited + | ReferenceUpdated + | PermissionV2Asked + | PermissionV2Replied + | PluginAdded + | ProjectDirectoriesUpdated + | FileWatcherUpdated + | PtyCreated + | PtyUpdated + | PtyExited + | PtyDeleted + | QuestionV2Asked + | QuestionV2Replied + | QuestionV2Rejected + | TodoUpdated + | LspUpdated + | PermissionAsked + | PermissionReplied + | TuiPromptAppend + | TuiCommandExecute + | TuiToastShow + | TuiSessionSelect + | McpToolsChanged + | McpBrowserOpenFailed + | CommandExecuted + | ProjectUpdated + | SessionStatus2 + | SessionIdle + | QuestionAsked + | QuestionReplied2 + | QuestionRejected2 + | SessionCompacted + | VcsBranchUpdated + | WorkspaceReady + | WorkspaceFailed + | WorkspaceStatus + | WorktreeReady + | WorktreeFailed + | ServerConnected + | GlobalDisposed + +export type V2EventStream = string + export type ForbiddenError = { _tag: "ForbiddenError" message: string @@ -2836,10 +3011,31 @@ export type EventTuiSessionSelect2 = { } } +export type CredentialValue = CredentialOAuth | CredentialKey + +export type IntegrationInputs = { + [key: string]: string +} + +export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod + +export type IntegrationRef = { + id: string + name: string +} + +export type SkillV2Source = SkillV2DirectorySource | SkillV2UrlSource | SkillV2EmbeddedSource + export type MoveSessionDestination = { directory: string } +export type ModelRef = { + id: string + providerID: string + variant?: string +} + export type LocationRef = { directory: string workspaceID?: string @@ -2869,6 +3065,12 @@ export type SessionErrorUnknown = { message: string } +export type LlmProviderMetadata = { + [key: string]: { + [key: string]: unknown + } +} + export type ToolTextContent = { type: "text" text: string @@ -2881,6 +3083,8 @@ export type ToolFileContent = { name?: string } +export type LlmToolContent = ToolTextContent | ToolFileContent + export type SessionNextRetryError = { message: string statusCode?: number @@ -2894,6 +3098,22 @@ export type SessionNextRetryError = { } } +export type FileDiff = { + path: string + status: "added" | "modified" | "deleted" + additions: number + deletions: number + patch: string +} + +export type RevertState = { + messageID: string + partID?: string + snapshot?: string + diff?: string + files?: Array +} + export type PermissionV2Source = { type: "tool" messageID: string @@ -2937,6 +3157,27 @@ export type QuestionV2Tool = { export type QuestionV2Answer = Array +export type ProjectVcs = "git" + +export type ProjectIcon = { + url?: string + override?: string + color?: string +} + +export type ProjectCommands = { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string +} + +export type ProjectTime = { + created: number + updated: number + initialized?: number +} + export type EventServerInstanceDisposed = { id: string type: "server.instance.disposed" @@ -3081,11 +3322,7 @@ export type SyncEventSessionNextModelSwitched = { timestamp: number sessionID: string messageID: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef } } } @@ -3143,39 +3380,6 @@ export type SyncEventSessionNextPromptAdmitted = { } } -export type SyncEventSessionNextPromptPromoted = { - type: "sync" - id: string - syncEvent: { - type: "session.next.prompt.promoted.1" - id: string - seq: number - aggregateID: string - data: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - timeCreated: number - } - } -} - -export type SyncEventSessionNextInterruptRequested = { - type: "sync" - id: string - syncEvent: { - type: "session.next.interrupt.requested.1" - id: string - seq: number - aggregateID: string - data: { - timestamp: number - sessionID: string - } - } -} - export type SyncEventSessionNextContextUpdated = { type: "sync" id: string @@ -3258,11 +3462,7 @@ export type SyncEventSessionNextStepStarted = { sessionID: string assistantMessageID: string agent: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef snapshot?: string } } @@ -3292,6 +3492,7 @@ export type SyncEventSessionNextStepEnded = { } } snapshot?: string + files?: Array } } } @@ -3361,11 +3562,7 @@ export type SyncEventSessionNextReasoningStarted = { sessionID: string assistantMessageID: string reasoningID: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } } @@ -3384,11 +3581,7 @@ export type SyncEventSessionNextReasoningEnded = { assistantMessageID: string reasoningID: string text: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } } @@ -3448,11 +3641,7 @@ export type SyncEventSessionNextToolCalled = { } provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -3474,7 +3663,7 @@ export type SyncEventSessionNextToolProgress = { structured: { [key: string]: unknown } - content: Array + content: Array } } } @@ -3495,16 +3684,12 @@ export type SyncEventSessionNextToolSuccess = { structured: { [key: string]: unknown } - content: Array + content: Array outputPaths?: Array result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -3527,11 +3712,7 @@ export type SyncEventSessionNextToolFailed = { result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -3575,7 +3756,7 @@ export type SyncEventSessionNextCompactionEnded = { type: "sync" id: string syncEvent: { - type: "session.next.compaction.ended.2" + type: "session.next.compaction.ended.1" id: string seq: number aggregateID: string @@ -3590,6 +3771,53 @@ export type SyncEventSessionNextCompactionEnded = { } } +export type SyncEventSessionNextRevertStaged = { + type: "sync" + id: string + syncEvent: { + type: "session.next.revert.staged.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + revert: RevertState + } + } +} + +export type SyncEventSessionNextRevertCleared = { + type: "sync" + id: string + syncEvent: { + type: "session.next.revert.cleared.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + } + } +} + +export type SyncEventSessionNextRevertCommitted = { + type: "sync" + id: string + syncEvent: { + type: "session.next.revert.committed.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + } + } +} + export type ConfigV2ReferenceGit = { repository: string branch?: string @@ -3616,6 +3844,16 @@ export type ProjectDirectories = Array<{ strategy?: string }> +export type PtyTicketConnectToken = { + ticket: string + expires_in: number +} + +export type WorkspaceEventConnectionStatus = { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" +} + export type LocationInfo = { directory: string workspaceID?: string @@ -3625,6 +3863,17 @@ export type LocationInfo = { } } +export type ProviderRequest = { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } +} + +export type AgentColor = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + export type PermissionV2Effect = "allow" | "deny" | "ask" export type PermissionV2Rule = { @@ -3637,24 +3886,13 @@ export type PermissionV2Ruleset = Array export type AgentV2Info = { id: string - model?: { - id: string - providerID: string - variant?: string - } - request: { - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - } + model?: ModelRef + request: ProviderRequest system?: string description?: string mode: "subagent" | "primary" | "all" hidden: boolean - color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + color?: AgentColor steps?: number permissions: PermissionV2Ruleset } @@ -3664,11 +3902,7 @@ export type SessionV2Info = { parentID?: string projectID: string agent?: string - model?: { - id: string - providerID: string - variant?: string - } + model?: ModelRef cost: number tokens: { input: number @@ -3687,6 +3921,14 @@ export type SessionV2Info = { title: string location: LocationRef subpath?: string + revert?: RevertState +} + +export type PromptInputFileAttachment = { + uri: string + name?: string + description?: string + source?: PromptSource } export type SessionInputAdmitted = { @@ -3720,11 +3962,7 @@ export type SessionMessageModelSwitched = { created: number } type: "model-switched" - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef } export type SessionMessageUser = { @@ -3791,10 +4029,10 @@ export type SessionMessageAssistantReasoning = { type: "reasoning" id: string text: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } + providerMetadata?: LlmProviderMetadata + time?: { + created: number + completed?: number } } @@ -3811,7 +4049,7 @@ export type SessionMessageToolStateRunning = { structured: { [key: string]: unknown } - content: Array + content: Array } export type SessionMessageToolStateCompleted = { @@ -3820,7 +4058,7 @@ export type SessionMessageToolStateCompleted = { [key: string]: unknown } attachments?: Array - content: Array + content: Array outputPaths?: Array structured: { [key: string]: unknown @@ -3833,7 +4071,7 @@ export type SessionMessageToolStateError = { input: { [key: string]: unknown } - content: Array + content: Array structured: { [key: string]: unknown } @@ -3847,16 +4085,8 @@ export type SessionMessageAssistantTool = { name: string provider?: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } - resultMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata + resultMetadata?: LlmProviderMetadata } state: | SessionMessageToolStatePending @@ -3882,15 +4112,12 @@ export type SessionMessageAssistant = { } type: "assistant" agent: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef content: Array snapshot?: { start?: string end?: string + files?: Array } finish?: string cost?: number @@ -3930,34 +4157,660 @@ export type SessionMessage = | SessionMessageAssistant | SessionMessageCompaction +export type SessionNextAgentSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.agent.switched" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + agent: string + } +} + +export type SessionNextModelSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.model.switched" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + model: ModelRef + } +} + +export type SessionNextMoved = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.moved" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + location: LocationRef + subdirectory?: string + } +} + +export type SessionNextPrompted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.prompted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type SessionNextPromptAdmitted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.prompt.admitted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type SessionNextContextUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.context.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type SessionNextSynthetic = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.synthetic" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type SessionNextShellStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.shell.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + callID: string + command: string + } +} + +export type SessionNextShellEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.shell.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type SessionNextStepStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + agent: string + model: ModelRef + snapshot?: string + } +} + +export type SessionNextStepEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + files?: Array + } +} + +export type SessionNextStepFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + error: SessionErrorUnknown + } +} + +export type SessionNextTextStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.text.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + } +} + +export type SessionNextTextEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.text.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + text: string + } +} + +export type SessionNextToolInputStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.input.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + name: string + } +} + +export type SessionNextToolInputEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.input.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + text: string + } +} + +export type SessionNextToolCalled = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.called" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type SessionNextToolProgress = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.progress" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type SessionNextToolSuccess = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.success" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + outputPaths?: Array + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type SessionNextToolFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + error: SessionErrorUnknown + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type SessionNextReasoningStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.reasoning.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + providerMetadata?: LlmProviderMetadata + } +} + +export type SessionNextReasoningEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.reasoning.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + text: string + providerMetadata?: LlmProviderMetadata + } +} + +export type SessionNextRetried = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.retried" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type SessionNextCompactionStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.compaction.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + } +} + +export type SessionNextCompactionEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.compaction.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + text: string + recent: string + } +} + +export type SessionNextRevertStaged = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.revert.staged" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + revert: RevertState + } +} + +export type SessionNextRevertCleared = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.revert.cleared" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + } +} + +export type SessionNextRevertCommitted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.revert.committed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + } +} + +export type ModelApi = + | { + id: string + type: "aisdk" + package: string + url?: string + settings?: { + [key: string]: unknown + } + } + | { + id: string + type: "native" + url?: string + settings: { + [key: string]: unknown + } + } + +export type ModelCapabilities = { + tools: boolean + input: Array + output: Array +} + +export type ModelCost = { + tier?: { + type: "context" + size: number + } + input: number + output: number + cache: { + read: number + write: number + } +} + export type ModelV2Info = { id: string providerID: string family?: string name: string - api: - | { - id: string - type: "aisdk" - package: string - url?: string - settings?: { - [key: string]: unknown - } - } - | { - id: string - type: "native" - url?: string - settings: { - [key: string]: unknown - } - } - capabilities: { - tools: boolean - input: Array - output: Array - } + api: ModelApi + capabilities: ModelCapabilities request: { headers: { [key: string]: string @@ -3965,19 +4818,6 @@ export type ModelV2Info = { body: { [key: string]: unknown } - generation?: { - maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - temperature?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - topP?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - topK?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - seed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - stop?: Array - } - options?: { - [key: string]: unknown - } variant?: string } variants: Array<{ @@ -3988,35 +4828,11 @@ export type ModelV2Info = { body: { [key: string]: unknown } - generation?: { - maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - temperature?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - topP?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - topK?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - seed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - stop?: Array - } - options?: { - [key: string]: unknown - } }> time: { - released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + released: number } - cost: Array<{ - tier?: { - type: "context" - size: number - } - input: number - output: number - cache: { - read: number - write: number - } - }> + cost: Array status: "alpha" | "beta" | "deprecated" | "active" enabled: boolean limit: { @@ -4026,34 +4842,32 @@ export type ModelV2Info = { } } +export type ProviderAisdk = { + type: "aisdk" + package: string + url?: string + settings?: { + [key: string]: unknown + } +} + +export type ProviderNative = { + type: "native" + url?: string + settings: { + [key: string]: unknown + } +} + +export type ProviderApi = ProviderAisdk | ProviderNative + export type ProviderV2Info = { id: string + integrationID?: string name: string disabled?: boolean - api: - | { - type: "aisdk" - package: string - url?: string - settings?: { - [key: string]: unknown - } - } - | { - type: "native" - url?: string - settings: { - [key: string]: unknown - } - } - request: { - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - } + api: ProviderApi + request: ProviderRequest } export type IntegrationWhen = { @@ -4115,7 +4929,7 @@ export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo export type IntegrationInfo = { id: string name: string - methods: Array + methods: Array connections: Array } @@ -4130,6 +4944,37 @@ export type IntegrationAttempt = { } } +export type IntegrationAttemptStatus = + | { + status: "pending" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "complete" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "failed" + message: string + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "expired" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + export type PermissionV2Request = { id: string sessionID: string @@ -4152,7 +4997,6 @@ export type PermissionSavedInfo = { export type FileSystemEntry = { path: string type: "file" | "directory" - mime: string } export type CommandV2Info = { @@ -4160,11 +5004,7 @@ export type CommandV2Info = { template: string description?: string agent?: string - model?: { - id: string - providerID: string - variant?: string - } + model?: ModelRef subtask?: boolean } @@ -4176,6 +5016,1090 @@ export type SkillV2Info = { content: string } +export type ModelsDevRefreshed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "models-dev.refreshed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type IntegrationUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "integration.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type IntegrationConnectionUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "integration.connection.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + integrationID: string + } +} + +export type CatalogUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "catalog.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type SessionCreated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.created" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + info: Session + } +} + +export type SessionUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + info: Session + } +} + +export type SessionDeleted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.deleted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + info: Session + } +} + +export type MessageUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + info: Message + } +} + +export type MessageRemoved = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.removed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + messageID: string + } +} + +export type MessagePartUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.part.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + part: Part + time: number + } +} + +export type MessagePartRemoved = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.part.removed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + messageID: string + partID: string + } +} + +export type SessionNextTextDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.text.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + delta: string + } +} + +export type SessionNextReasoningDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.reasoning.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + delta: string + } +} + +export type SessionNextToolInputDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.input.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + delta: string + } +} + +export type SessionNextCompactionDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.compaction.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type MessagePartDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.part.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } +} + +export type SessionDiff = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.diff" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + diff: Array + } +} + +export type SessionError = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.error" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ContentFilterError + | ApiError + } +} + +export type InstallationUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "installation.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + version: string + } +} + +export type InstallationUpdateAvailable = { + id: string + metadata?: { + [key: string]: unknown + } + type: "installation.update-available" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + version: string + } +} + +export type FileEdited = { + id: string + metadata?: { + [key: string]: unknown + } + type: "file.edited" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + file: string + } +} + +export type ReferenceUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "reference.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type PermissionV2Asked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "permission.v2.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source + } +} + +export type PermissionV2Replied = { + id: string + metadata?: { + [key: string]: unknown + } + type: "permission.v2.replied" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + reply: PermissionV2Reply + } +} + +export type PluginAdded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "plugin.added" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + } +} + +export type ProjectDirectoriesUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "project.directories.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + projectID: string + } +} + +export type FileWatcherUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "file.watcher.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + file: string + event: "add" | "change" | "unlink" + } +} + +export type PtyCreated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "pty.created" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + info: Pty + } +} + +export type PtyUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "pty.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + info: Pty + } +} + +export type PtyExited = { + id: string + metadata?: { + [key: string]: unknown + } + type: "pty.exited" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + exitCode: number + } +} + +export type PtyDeleted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "pty.deleted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + } +} + +export type QuestionV2Asked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.v2.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionV2Tool + } +} + +export type QuestionV2Replied = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.v2.replied" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + answers: Array + } +} + +export type QuestionV2Rejected = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.v2.rejected" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + } +} + +export type TodoUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "todo.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + todos: Array + } +} + +export type LspUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "lsp.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type PermissionAsked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "permission.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } + } +} + +export type PermissionReplied = { + id: string + metadata?: { + [key: string]: unknown + } + type: "permission.replied" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + +export type TuiPromptAppend = { + id: string + metadata?: { + [key: string]: unknown + } + type: "tui.prompt.append" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + text: string + } +} + +export type TuiCommandExecute = { + id: string + metadata?: { + [key: string]: unknown + } + type: "tui.command.execute" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type TuiToastShow = { + id: string + metadata?: { + [key: string]: unknown + } + type: "tui.toast.show" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type TuiSessionSelect = { + id: string + metadata?: { + [key: string]: unknown + } + type: "tui.session.select" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type McpToolsChanged = { + id: string + metadata?: { + [key: string]: unknown + } + type: "mcp.tools.changed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + server: string + } +} + +export type McpBrowserOpenFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "mcp.browser.open.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + mcpName: string + url: string + } +} + +export type CommandExecuted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "command.executed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + name: string + sessionID: string + arguments: string + messageID: string + } +} + +export type ProjectUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "project.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + worktree: string + vcs?: ProjectVcs + name?: string + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime + sandboxes: Array + } +} + +export type SessionIdle = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.idle" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + } +} + +export type QuestionAsked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionTool + } +} + +export type SessionCompacted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.compacted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + } +} + +export type VcsBranchUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "vcs.branch.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + branch?: string + } +} + +export type WorkspaceReady = { + id: string + metadata?: { + [key: string]: unknown + } + type: "workspace.ready" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + name: string + } +} + +export type WorkspaceFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "workspace.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + message: string + } +} + +export type WorkspaceStatus = { + id: string + metadata?: { + [key: string]: unknown + } + type: "workspace.status" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + +export type WorktreeReady = { + id: string + metadata?: { + [key: string]: unknown + } + type: "worktree.ready" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + name: string + branch?: string + } +} + +export type WorktreeFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "worktree.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + message: string + } +} + +export type ServerConnected = { + id: string + metadata?: { + [key: string]: unknown + } + type: "server.connected" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type GlobalDisposed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "global.disposed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + export type QuestionV2Request = { id: string sessionID: string @@ -4208,12 +6132,14 @@ export type ReferenceGitSource = { hidden?: boolean } +export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource + export type ReferenceInfo = { name: string path: string description?: string hidden?: boolean - source: ReferenceLocalSource | ReferenceGitSource + source: ReferenceSource } export type ProjectCopyCopy = { @@ -4228,14 +6154,6 @@ export type EventModelsDevRefreshed = { } } -export type EventPluginAdded = { - id: string - type: "plugin.added" - properties: { - id: string - } -} - export type EventIntegrationUpdated = { id: string type: "integration.updated" @@ -4244,6 +6162,14 @@ export type EventIntegrationUpdated = { } } +export type EventIntegrationConnectionUpdated = { + id: string + type: "integration.connection.updated" + properties: { + integrationID: string + } +} + export type EventCatalogUpdated = { id: string type: "catalog.updated" @@ -4335,11 +6261,7 @@ export type EventSessionNextModelSwitched = { timestamp: number sessionID: string messageID: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef } } @@ -4378,27 +6300,6 @@ export type EventSessionNextPromptAdmitted = { } } -export type EventSessionNextPromptPromoted = { - id: string - type: "session.next.prompt.promoted" - properties: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - timeCreated: number - } -} - -export type EventSessionNextInterruptRequested = { - id: string - type: "session.next.interrupt.requested" - properties: { - timestamp: number - sessionID: string - } -} - export type EventSessionNextContextUpdated = { id: string type: "session.next.context.updated" @@ -4452,11 +6353,7 @@ export type EventSessionNextStepStarted = { sessionID: string assistantMessageID: string agent: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef snapshot?: string } } @@ -4480,6 +6377,7 @@ export type EventSessionNextStepEnded = { } } snapshot?: string + files?: Array } } @@ -4537,11 +6435,7 @@ export type EventSessionNextReasoningStarted = { sessionID: string assistantMessageID: string reasoningID: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } @@ -4566,11 +6460,7 @@ export type EventSessionNextReasoningEnded = { assistantMessageID: string reasoningID: string text: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } @@ -4624,11 +6514,7 @@ export type EventSessionNextToolCalled = { } provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -4644,7 +6530,7 @@ export type EventSessionNextToolProgress = { structured: { [key: string]: unknown } - content: Array + content: Array } } @@ -4659,16 +6545,12 @@ export type EventSessionNextToolSuccess = { structured: { [key: string]: unknown } - content: Array + content: Array outputPaths?: Array result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -4685,11 +6567,7 @@ export type EventSessionNextToolFailed = { result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -4740,6 +6618,35 @@ export type EventSessionNextCompactionEnded = { } } +export type EventSessionNextRevertStaged = { + id: string + type: "session.next.revert.staged" + properties: { + timestamp: number + sessionID: string + revert: RevertState + } +} + +export type EventSessionNextRevertCleared = { + id: string + type: "session.next.revert.cleared" + properties: { + timestamp: number + sessionID: string + } +} + +export type EventSessionNextRevertCommitted = { + id: string + type: "session.next.revert.committed" + properties: { + timestamp: number + sessionID: string + messageID: string + } +} + export type EventMessagePartDelta = { id: string type: "message.part.delta" @@ -4802,6 +6709,14 @@ export type EventFileEdited = { } } +export type EventReferenceUpdated = { + id: string + type: "reference.updated" + properties: { + [key: string]: unknown + } +} + export type EventPermissionV2Asked = { id: string type: "permission.v2.asked" @@ -4828,11 +6743,11 @@ export type EventPermissionV2Replied = { } } -export type EventReferenceUpdated = { +export type EventPluginAdded = { id: string - type: "reference.updated" + type: "plugin.added" properties: { - [key: string]: unknown + id: string } } @@ -4999,24 +6914,11 @@ export type EventProjectUpdated = { properties: { id: string worktree: string - vcs?: "git" + vcs?: ProjectVcs name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - time: { - created: number - updated: number - initialized?: number - } + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime sandboxes: Array } } @@ -5145,6 +7047,40 @@ export type EventGlobalDisposed = { } } +export type CredentialOAuth = { + type: "oauth" + methodID: string + refresh: string + access: string + expires: number + metadata?: { + [key: string]: unknown + } +} + +export type CredentialKey = { + type: "key" + key: string + metadata?: { + [key: string]: unknown + } +} + +export type SkillV2DirectorySource = { + type: "directory" + path: string +} + +export type SkillV2UrlSource = { + type: "url" + url: string +} + +export type SkillV2EmbeddedSource = { + type: "embedded" + skill: SkillV2Info +} + export type BadRequestError = { name: "BadRequest" data: { @@ -6848,17 +8784,8 @@ export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGi export type ProjectUpdateData = { body?: { name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } + icon?: ProjectIcon + commands?: ProjectCommands } path: { projectID: string @@ -7195,10 +9122,7 @@ export type PtyConnectTokenResponses = { /** * WebSocket connect token */ - 200: { - ticket: string - expires_in: number - } + 200: PtyTicketConnectToken } export type PtyConnectTokenResponse = PtyConnectTokenResponses[keyof PtyConnectTokenResponses] @@ -9223,10 +11147,7 @@ export type ExperimentalWorkspaceStatusResponses = { /** * Workspace status */ - 200: Array<{ - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - }> + 200: Array } export type ExperimentalWorkspaceStatusResponse = @@ -9448,11 +11369,7 @@ export type V2SessionCreateData = { body: { id?: string agent?: string - model?: { - id: string - providerID: string - variant?: string - } + model?: ModelRef location?: LocationRef } path?: never @@ -9484,6 +11401,39 @@ export type V2SessionCreateResponses = { export type V2SessionCreateResponse = V2SessionCreateResponses[keyof V2SessionCreateResponses] +export type V2SessionActiveData = { + body?: never + path?: never + query?: never + url: "/api/session/active" +} + +export type V2SessionActiveErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2SessionActiveError = V2SessionActiveErrors[keyof V2SessionActiveErrors] + +export type V2SessionActiveResponses = { + /** + * Success + */ + 200: { + data: { + [key: string]: unknown | SessionActive + } + } +} + +export type V2SessionActiveResponse = V2SessionActiveResponses[keyof V2SessionActiveResponses] + export type V2SessionGetData = { body?: never path: { @@ -9521,10 +11471,84 @@ export type V2SessionGetResponses = { export type V2SessionGetResponse = V2SessionGetResponses[keyof V2SessionGetResponses] +export type V2SessionSwitchAgentData = { + body: { + agent: string + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/agent" +} + +export type V2SessionSwitchAgentErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionSwitchAgentError = V2SessionSwitchAgentErrors[keyof V2SessionSwitchAgentErrors] + +export type V2SessionSwitchAgentResponses = { + /** + * + */ + 204: void +} + +export type V2SessionSwitchAgentResponse = V2SessionSwitchAgentResponses[keyof V2SessionSwitchAgentResponses] + +export type V2SessionSwitchModelData = { + body: { + model: ModelRef + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/model" +} + +export type V2SessionSwitchModelErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionSwitchModelError = V2SessionSwitchModelErrors[keyof V2SessionSwitchModelErrors] + +export type V2SessionSwitchModelResponses = { + /** + * + */ + 204: void +} + +export type V2SessionSwitchModelResponse = V2SessionSwitchModelResponses[keyof V2SessionSwitchModelResponses] + export type V2SessionPromptData = { body: { id?: string - prompt: Prompt + prompt: PromptInput delivery?: "steer" | "queue" resume?: boolean } @@ -9645,6 +11669,124 @@ export type V2SessionWaitResponses = { export type V2SessionWaitResponse = V2SessionWaitResponses[keyof V2SessionWaitResponses] +export type V2SessionRevertStageData = { + body: { + messageID: string + files?: boolean + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/revert/stage" +} + +export type V2SessionRevertStageErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * MessageNotFoundError | SessionNotFoundError + */ + 404: MessageNotFoundError | SessionNotFoundError + /** + * UnknownError + */ + 500: UnknownError1 +} + +export type V2SessionRevertStageError = V2SessionRevertStageErrors[keyof V2SessionRevertStageErrors] + +export type V2SessionRevertStageResponses = { + /** + * Success + */ + 200: { + data: RevertState + } +} + +export type V2SessionRevertStageResponse = V2SessionRevertStageResponses[keyof V2SessionRevertStageResponses] + +export type V2SessionRevertClearData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/revert/clear" +} + +export type V2SessionRevertClearErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError + /** + * UnknownError + */ + 500: UnknownError1 +} + +export type V2SessionRevertClearError = V2SessionRevertClearErrors[keyof V2SessionRevertClearErrors] + +export type V2SessionRevertClearResponses = { + /** + * + */ + 204: void +} + +export type V2SessionRevertClearResponse = V2SessionRevertClearResponses[keyof V2SessionRevertClearResponses] + +export type V2SessionRevertCommitData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/revert/commit" +} + +export type V2SessionRevertCommitErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionRevertCommitError = V2SessionRevertCommitErrors[keyof V2SessionRevertCommitErrors] + +export type V2SessionRevertCommitResponses = { + /** + * + */ + 204: void +} + +export type V2SessionRevertCommitResponse = V2SessionRevertCommitResponses[keyof V2SessionRevertCommitResponses] + export type V2SessionContextData = { body?: never path: { @@ -9686,6 +11828,158 @@ export type V2SessionContextResponses = { export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses] +export type V2SessionHistoryData = { + body?: never + path: { + sessionID: string + } + query?: { + limit?: number + after?: number + } + url: "/api/session/{sessionID}/history" +} + +export type V2SessionHistoryErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionHistoryError = V2SessionHistoryErrors[keyof V2SessionHistoryErrors] + +export type V2SessionHistoryResponses = { + /** + * SessionHistory + */ + 200: SessionHistory +} + +export type V2SessionHistoryResponse = V2SessionHistoryResponses[keyof V2SessionHistoryResponses] + +export type V2SessionEventsData = { + body?: never + path: { + sessionID: string + } + query?: { + after?: string + } + url: "/api/session/{sessionID}/event" +} + +export type V2SessionEventsErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionEventsError = V2SessionEventsErrors[keyof V2SessionEventsErrors] + +export type V2SessionEventsResponses = { + /** + * Success + */ + 200: { + id: string + event: string + data: SessionDurableEventStream + } +} + +export type V2SessionEventsResponse = V2SessionEventsResponses[keyof V2SessionEventsResponses] + +export type V2SessionInterruptData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/interrupt" +} + +export type V2SessionInterruptErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionInterruptError = V2SessionInterruptErrors[keyof V2SessionInterruptErrors] + +export type V2SessionInterruptResponses = { + /** + * + */ + 204: void +} + +export type V2SessionInterruptResponse = V2SessionInterruptResponses[keyof V2SessionInterruptResponses] + +export type V2SessionMessageData = { + body?: never + path: { + sessionID: string + messageID: string + } + query?: never + url: "/api/session/{sessionID}/message/{messageID}" +} + +export type V2SessionMessageErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | MessageNotFoundError + */ + 404: MessageNotFoundError | SessionNotFoundError +} + +export type V2SessionMessageError = V2SessionMessageErrors[keyof V2SessionMessageErrors] + +export type V2SessionMessageResponses = { + /** + * Success + */ + 200: { + data: SessionMessage + } +} + +export type V2SessionMessageResponse = V2SessionMessageResponses[keyof V2SessionMessageResponses] + export type V2SessionMessagesData = { body?: never path: { @@ -10092,36 +12386,7 @@ export type V2IntegrationAttemptStatusResponses = { */ 200: { location: LocationInfo - data: - | { - status: "pending" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } - | { - status: "complete" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } - | { - status: "failed" - message: string - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } - | { - status: "expired" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } + data: IntegrationAttemptStatus } } @@ -10380,6 +12645,95 @@ export type V2SessionPermissionListResponses = { export type V2SessionPermissionListResponse = V2SessionPermissionListResponses[keyof V2SessionPermissionListResponses] +export type V2SessionPermissionCreateData = { + body: { + id?: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source + agent?: string + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/permission" +} + +export type V2SessionPermissionCreateErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionPermissionCreateError = V2SessionPermissionCreateErrors[keyof V2SessionPermissionCreateErrors] + +export type V2SessionPermissionCreateResponses = { + /** + * Success + */ + 200: { + data: { + id: string + effect: PermissionV2Effect + } + } +} + +export type V2SessionPermissionCreateResponse = + V2SessionPermissionCreateResponses[keyof V2SessionPermissionCreateResponses] + +export type V2SessionPermissionGetData = { + body?: never + path: { + sessionID: string + requestID: string + } + query?: never + url: "/api/session/{sessionID}/permission/{requestID}" +} + +export type V2SessionPermissionGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | PermissionNotFoundError + */ + 404: PermissionNotFoundError | SessionNotFoundError +} + +export type V2SessionPermissionGetError = V2SessionPermissionGetErrors[keyof V2SessionPermissionGetErrors] + +export type V2SessionPermissionGetResponses = { + /** + * Success + */ + 200: { + data: PermissionV2Request + } +} + +export type V2SessionPermissionGetResponse = V2SessionPermissionGetResponses[keyof V2SessionPermissionGetResponses] + export type V2SessionPermissionReplyData = { body: { reply: PermissionV2Reply @@ -10609,12 +12963,7 @@ export type V2SkillListResponse = V2SkillListResponses[keyof V2SkillListResponse export type V2EventSubscribeData = { body?: never path?: never - query?: { - location?: { - directory?: string - workspace?: string - } - } + query?: never url: "/api/event" } @@ -10633,9 +12982,9 @@ export type V2EventSubscribeError = V2EventSubscribeErrors[keyof V2EventSubscrib export type V2EventSubscribeResponses = { /** - * Success + * Event stream */ - 200: string + 200: V2Event } export type V2EventSubscribeResponse = V2EventSubscribeResponses[keyof V2EventSubscribeResponses] @@ -10895,10 +13244,7 @@ export type V2PtyConnectTokenResponses = { */ 200: { location: LocationInfo - data: { - ticket: string - expires_in: number - } + data: PtyTicketConnectToken } } diff --git a/packages/sdk/js/test/session-history.test.ts b/packages/sdk/js/test/session-history.test.ts new file mode 100644 index 0000000000..44a9743331 --- /dev/null +++ b/packages/sdk/js/test/session-history.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test" +import type { V2SessionHistoryData } from "../src/v2/gen/types.gen" + +test("uses numeric Session history positions", () => { + const input = { + path: { sessionID: "ses_test" }, + query: { after: 1, limit: 50 }, + url: "/api/session/{sessionID}/history", + } satisfies V2SessionHistoryData + + expect(input.query.after).toBe(1) +}) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index b0cf1678c7..b30c3beb35 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3846,29 +3846,10 @@ "type": "string" }, "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectIcon" }, "commands": { - "type": "object", - "properties": { - "start": { - "type": "string", - "description": "Startup script to run when creating a new workspace (worktree)" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectCommands" } }, "additionalProperties": false @@ -4541,19 +4522,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "ticket": { - "type": "string" - }, - "expires_in": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "required": ["ticket", "expires_in"], - "additionalProperties": false, - "description": "WebSocket connect token" + "$ref": "#/components/schemas/PtyTicketConnectToken" } } } @@ -9612,19 +9581,7 @@ "schema": { "type": "array", "items": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"], - "additionalProperties": false + "$ref": "#/components/schemas/WorkspaceEventConnectionStatus" }, "description": "Workspace status" } @@ -10210,20 +10167,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "location": { "$ref": "#/components/schemas/LocationRef" @@ -10243,6 +10187,66 @@ ] } }, + "/api/session/active": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.active", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "patternProperties": { + "^ses": { + "$ref": "#/components/schemas/SessionActive" + } + } + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.", + "summary": "List active sessions", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.active({\n ...\n})" + } + ] + } + }, "/api/session/{sessionID}": { "get": { "tags": ["sessions"], @@ -10325,6 +10329,176 @@ ] } }, + "/api/session/{sessionID}/agent": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.switchAgent", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Switch the agent used by subsequent provider turns.", + "summary": "Switch session agent", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "agent": { + "type": "string" + } + }, + "required": ["agent"], + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.switchAgent({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/model": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.switchModel", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Switch the model used by subsequent provider turns.", + "summary": "Switch session model", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "model": { + "$ref": "#/components/schemas/ModelRef" + } + }, + "required": ["model"], + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.switchModel({\n ...\n})" + } + ] + } + }, "/api/session/{sessionID}/prompt": { "post": { "tags": ["sessions"], @@ -10420,7 +10594,7 @@ "pattern": "^msg_" }, "prompt": { - "$ref": "#/components/schemas/Prompt" + "$ref": "#/components/schemas/PromptInput" }, "delivery": { "type": "string", @@ -10601,6 +10775,266 @@ ] } }, + "/api/session/{sessionID}/revert/stage": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.revert.stage", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/RevertState" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "MessageNotFoundError | SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError1" + } + } + } + } + }, + "description": "Stage or move a reversible session boundary and optionally apply its file changes.", + "summary": "Stage session revert", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "files": { + "type": "boolean" + } + }, + "required": ["messageID"], + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.revert.stage({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/revert/clear": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.revert.clear", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError1" + } + } + } + } + }, + "summary": "Clear staged revert", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.revert.clear({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/revert/commit": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.revert.commit", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "summary": "Commit staged revert", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.revert.commit({\n ...\n})" + } + ] + } + }, "/api/session/{sessionID}/context": { "get": { "tags": ["sessions"], @@ -10696,6 +11130,416 @@ ] } }, + "/api/session/{sessionID}/history": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.history", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "after", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "SessionHistory", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionHistory" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages.", + "summary": "Get session history", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.history({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/event": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.events", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + }, + { + "name": "after", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "text/event-stream": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "event": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/SessionDurableEventStream" + } + }, + "required": ["id", "event", "data"], + "additionalProperties": false + }, + "x-effect-stream": { + "encoding": "sse", + "causeSchema": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Fail"] + }, + "error": { + "not": {} + } + }, + "required": ["_tag", "error"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Die"] + }, + "defect": {} + }, + "required": ["_tag", "defect"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Interrupt"] + }, + "fiberId": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": ["_tag", "fiberId"], + "additionalProperties": false + } + ] + } + }, + "errorSchema": { + "not": {} + }, + "failureEvent": "effect/httpapi/stream/failure" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Replay durable events after an aggregate sequence, then continue with new durable events.", + "summary": "Subscribe to session events", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.events({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/interrupt": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.interrupt", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op.", + "summary": "Interrupt session execution", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.interrupt({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/message/{messageID}": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.message", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + }, + { + "name": "messageID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^msg_" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionMessage" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | MessageNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve one projected message owned by the Session.", + "summary": "Get session message", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.message({\n ...\n})" + } + ] + } + }, "/api/session/{sessionID}/message": { "get": { "tags": ["messages"], @@ -11530,267 +12374,7 @@ "$ref": "#/components/schemas/LocationInfo" }, "data": { - "anyOf": [ - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["pending"] - }, - "time": { - "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "expires": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - } - }, - "required": ["created", "expires"], - "additionalProperties": false - } - }, - "required": ["status", "time"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["complete"] - }, - "time": { - "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "expires": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - } - }, - "required": ["created", "expires"], - "additionalProperties": false - } - }, - "required": ["status", "time"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["failed"] - }, - "message": { - "type": "string" - }, - "time": { - "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "expires": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - } - }, - "required": ["created", "expires"], - "additionalProperties": false - } - }, - "required": ["status", "message", "time"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["expired"] - }, - "time": { - "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "expires": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - } - }, - "required": ["created", "expires"], - "additionalProperties": false - } - }, - "required": ["status", "time"], - "additionalProperties": false - } - ] + "$ref": "#/components/schemas/IntegrationAttemptStatus" } }, "required": ["location", "data"], @@ -12341,6 +12925,139 @@ } }, "/api/session/{sessionID}/permission": { + "post": { + "tags": ["permissions"], + "operationId": "v2.session.permission.create", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per" + }, + "effect": { + "$ref": "#/components/schemas/PermissionV2Effect" + } + }, + "required": ["id", "effect"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Evaluate and, when approval is required, create a permission request for a session.", + "summary": "Create permission request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per" + }, + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "source": { + "$ref": "#/components/schemas/PermissionV2Source" + }, + "agent": { + "type": "string" + } + }, + "required": ["action", "resources"], + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.permission.create({\n ...\n})" + } + ] + }, "get": { "tags": ["permissions"], "operationId": "v2.session.permission.list", @@ -12425,6 +13142,100 @@ ] } }, + "/api/session/{sessionID}/permission/{requestID}": { + "get": { + "tags": ["permissions"], + "operationId": "v2.session.permission.get", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^per" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/PermissionV2Request" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | PermissionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve a pending permission request owned by a session.", + "summary": "Get permission request", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.permission.get({\n ...\n})" + } + ] + } + }, "/api/session/{sessionID}/permission/{requestID}/reply": { "post": { "tags": ["permissions"], @@ -12955,35 +13766,15 @@ "get": { "tags": ["events"], "operationId": "v2.event.subscribe", - "parameters": [ - { - "name": "location", - "in": "query", - "schema": { - "type": "object", - "properties": { - "directory": { - "type": "string" - }, - "workspace": { - "type": "string" - } - }, - "additionalProperties": false - }, - "required": false, - "style": "deepObject", - "explode": true - } - ], + "parameters": [], "security": [], "responses": { "200": { - "description": "Success", + "description": "Event stream", "content": { "text/event-stream": { "schema": { - "type": "string" + "$ref": "#/components/schemas/V2Event" } } } @@ -13009,7 +13800,7 @@ } } }, - "description": "Subscribe to native event payloads for a location.", + "description": "Subscribe to native event payloads for the server.", "summary": "Subscribe to events", "x-codeSamples": [ { @@ -13558,18 +14349,7 @@ "$ref": "#/components/schemas/LocationInfo" }, "data": { - "type": "object", - "properties": { - "ticket": { - "type": "string" - }, - "expires_in": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "required": ["ticket", "expires_in"], - "additionalProperties": false + "$ref": "#/components/schemas/PtyTicketConnectToken" } }, "required": ["location", "data"], @@ -14482,10 +15262,10 @@ "$ref": "#/components/schemas/EventModels-devRefreshed" }, { - "$ref": "#/components/schemas/EventPluginAdded" + "$ref": "#/components/schemas/EventIntegrationUpdated" }, { - "$ref": "#/components/schemas/EventIntegrationUpdated" + "$ref": "#/components/schemas/EventIntegrationConnectionUpdated" }, { "$ref": "#/components/schemas/EventCatalogUpdated" @@ -14526,12 +15306,6 @@ { "$ref": "#/components/schemas/EventSessionNextPromptAdmitted" }, - { - "$ref": "#/components/schemas/EventSessionNextPromptPromoted" - }, - { - "$ref": "#/components/schemas/EventSessionNextInterruptRequested" - }, { "$ref": "#/components/schemas/EventSessionNextContextUpdated" }, @@ -14604,6 +15378,15 @@ { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, + { + "$ref": "#/components/schemas/EventSessionNextRevertStaged" + }, + { + "$ref": "#/components/schemas/EventSessionNextRevertCleared" + }, + { + "$ref": "#/components/schemas/EventSessionNextRevertCommitted" + }, { "$ref": "#/components/schemas/EventMessagePartDelta" }, @@ -14622,6 +15405,9 @@ { "$ref": "#/components/schemas/EventFileEdited" }, + { + "$ref": "#/components/schemas/EventReferenceUpdated" + }, { "$ref": "#/components/schemas/EventPermissionV2Asked" }, @@ -14629,7 +15415,7 @@ "$ref": "#/components/schemas/EventPermissionV2Replied" }, { - "$ref": "#/components/schemas/EventReferenceUpdated" + "$ref": "#/components/schemas/EventPluginAdded" }, { "$ref": "#/components/schemas/EventProjectDirectoriesUpdated" @@ -16654,17 +17440,11 @@ }, "type": { "type": "string", - "enum": ["plugin.added"] + "enum": ["integration.updated"] }, "properties": { "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": ["id"], - "additionalProperties": false + "properties": {} } }, "required": ["id", "type", "properties"], @@ -16679,11 +17459,17 @@ }, "type": { "type": "string", - "enum": ["integration.updated"] + "enum": ["integration.connection.updated"] }, "properties": { "type": "object", - "properties": {} + "properties": { + "integrationID": { + "type": "string" + } + }, + "required": ["integrationID"], + "additionalProperties": false } }, "required": ["id", "type", "properties"], @@ -16982,20 +17768,7 @@ "pattern": "^msg_" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" } }, "required": ["timestamp", "sessionID", "messageID", "model"], @@ -17120,74 +17893,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "type": { - "type": "string", - "enum": ["session.next.prompt.promoted"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - }, - "timeCreated": { - "type": "number" - } - }, - "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "type": { - "type": "string", - "enum": ["session.next.interrupt.requested"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["timestamp", "sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, { "type": "object", "properties": { @@ -17363,20 +18068,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "snapshot": { "type": "string" @@ -17451,6 +18143,12 @@ }, "snapshot": { "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } } }, "required": ["timestamp", "sessionID", "assistantMessageID", "finish", "cost", "tokens"], @@ -17639,10 +18337,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], @@ -17723,10 +18418,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], @@ -17894,10 +18586,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -17945,14 +18634,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } } }, @@ -17997,14 +18679,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } }, "outputPaths": { @@ -18021,10 +18696,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -18085,10 +18757,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -18253,6 +18922,100 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.staged"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "revert": { + "$ref": "#/components/schemas/RevertState" + } + }, + "required": ["timestamp", "sessionID", "revert"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.cleared"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.committed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + } + }, + "required": ["timestamp", "sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -18453,6 +19216,25 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["reference.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -18546,11 +19328,17 @@ }, "type": { "type": "string", - "enum": ["reference.updated"] + "enum": ["plugin.added"] }, "properties": { "type": "object", - "properties": {} + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false } }, "required": ["id", "type", "properties"], @@ -19216,55 +20004,19 @@ "type": "string" }, "vcs": { - "type": "string", - "enum": ["git"] + "$ref": "#/components/schemas/ProjectVcs" }, "name": { "type": "string" }, "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectIcon" }, "commands": { - "type": "object", - "properties": { - "start": { - "type": "string", - "description": "Startup script to run when creating a new workspace (worktree)" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectCommands" }, "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "initialized": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["created", "updated"], - "additionalProperties": false + "$ref": "#/components/schemas/ProjectTime" }, "sandboxes": { "type": "array", @@ -19701,12 +20453,6 @@ { "$ref": "#/components/schemas/SyncEventSessionNextPromptAdmitted" }, - { - "$ref": "#/components/schemas/SyncEventSessionNextPromptPromoted" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextInterruptRequested" - }, { "$ref": "#/components/schemas/SyncEventSessionNextContextUpdated" }, @@ -19766,6 +20512,15 @@ }, { "$ref": "#/components/schemas/SyncEventSessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextRevertStaged" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextRevertCleared" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextRevertCommitted" } ] } @@ -21943,55 +22698,19 @@ "type": "string" }, "vcs": { - "type": "string", - "enum": ["git"] + "$ref": "#/components/schemas/ProjectVcs" }, "name": { "type": "string" }, "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectIcon" }, "commands": { - "type": "object", - "properties": { - "start": { - "type": "string", - "description": "Startup script to run when creating a new workspace (worktree)" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectCommands" }, "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "initialized": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["created", "updated"], - "additionalProperties": false + "$ref": "#/components/schemas/ProjectTime" }, "sandboxes": { "type": "array", @@ -22788,6 +23507,17 @@ "required": ["_tag", "message"], "additionalProperties": false }, + "SessionActive": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["running"] + } + }, + "required": ["type"], + "additionalProperties": false + }, "SessionNotFoundError": { "type": "object", "properties": { @@ -22805,6 +23535,28 @@ "required": ["_tag", "sessionID", "message"], "additionalProperties": false }, + "PromptInput": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptInputFileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptAgentAttachment" + } + } + }, + "required": ["text"], + "additionalProperties": false + }, "ConflictError": { "type": "object", "properties": { @@ -22839,6 +23591,26 @@ "required": ["_tag", "message"], "additionalProperties": false }, + "MessageNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["MessageNotFoundError"] + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "sessionID", "messageID", "message"], + "additionalProperties": false + }, "UnknownError1": { "type": "object", "properties": { @@ -22856,6 +23628,117 @@ "required": ["_tag", "message"], "additionalProperties": false }, + "SessionDurableEvent": { + "oneOf": [ + { + "$ref": "#/components/schemas/SessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/SessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/SessionNextMoved" + }, + { + "$ref": "#/components/schemas/SessionNextPrompted" + }, + { + "$ref": "#/components/schemas/SessionNextPromptAdmitted" + }, + { + "$ref": "#/components/schemas/SessionNextContextUpdated" + }, + { + "$ref": "#/components/schemas/SessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/SessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/SessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/SessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/SessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/SessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/SessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/SessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/SessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/SessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/SessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/SessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/SessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/SessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/SessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/SessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/SessionNextRetried" + }, + { + "$ref": "#/components/schemas/SessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/SessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/SessionNextRevertStaged" + }, + { + "$ref": "#/components/schemas/SessionNextRevertCleared" + }, + { + "$ref": "#/components/schemas/SessionNextRevertCommitted" + } + ] + }, + "SessionHistory": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionDurableEvent" + } + }, + "hasMore": { + "type": "boolean" + } + }, + "required": ["data", "hasMore"], + "additionalProperties": false + }, + "SessionDurableEventStream": { + "type": "string", + "contentSchema": { + "$ref": "#/components/schemas/SessionDurableEvent" + }, + "contentMediaType": "application/json" + }, "SessionMessagesResponse": { "type": "object", "properties": { @@ -22898,6 +23781,475 @@ "required": ["_tag", "providerID", "message"], "additionalProperties": false }, + "OutputFormat1": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["json_schema"] + }, + "schema": { + "$ref": "#/components/schemas/JSONSchema" + }, + "retryCount": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["type", "schema"], + "additionalProperties": false + } + ] + }, + "session.status": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.status"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "question.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.replied"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "question.rejected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.rejected"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2Event": { + "anyOf": [ + { + "$ref": "#/components/schemas/Models-devRefreshed" + }, + { + "$ref": "#/components/schemas/IntegrationUpdated" + }, + { + "$ref": "#/components/schemas/IntegrationConnectionUpdated" + }, + { + "$ref": "#/components/schemas/CatalogUpdated" + }, + { + "$ref": "#/components/schemas/SessionCreated" + }, + { + "$ref": "#/components/schemas/SessionUpdated" + }, + { + "$ref": "#/components/schemas/SessionDeleted" + }, + { + "$ref": "#/components/schemas/MessageUpdated" + }, + { + "$ref": "#/components/schemas/MessageRemoved" + }, + { + "$ref": "#/components/schemas/MessagePartUpdated" + }, + { + "$ref": "#/components/schemas/MessagePartRemoved" + }, + { + "$ref": "#/components/schemas/SessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/SessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/SessionNextMoved" + }, + { + "$ref": "#/components/schemas/SessionNextPrompted" + }, + { + "$ref": "#/components/schemas/SessionNextPromptAdmitted" + }, + { + "$ref": "#/components/schemas/SessionNextContextUpdated" + }, + { + "$ref": "#/components/schemas/SessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/SessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/SessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/SessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/SessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/SessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/SessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/SessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/SessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/SessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/SessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/SessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/SessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/SessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/SessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/SessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/SessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/SessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/SessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/SessionNextRetried" + }, + { + "$ref": "#/components/schemas/SessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/SessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/SessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/SessionNextRevertStaged" + }, + { + "$ref": "#/components/schemas/SessionNextRevertCleared" + }, + { + "$ref": "#/components/schemas/SessionNextRevertCommitted" + }, + { + "$ref": "#/components/schemas/MessagePartDelta" + }, + { + "$ref": "#/components/schemas/SessionDiff" + }, + { + "$ref": "#/components/schemas/SessionError" + }, + { + "$ref": "#/components/schemas/InstallationUpdated" + }, + { + "$ref": "#/components/schemas/InstallationUpdate-available" + }, + { + "$ref": "#/components/schemas/FileEdited" + }, + { + "$ref": "#/components/schemas/ReferenceUpdated" + }, + { + "$ref": "#/components/schemas/PermissionV2Asked" + }, + { + "$ref": "#/components/schemas/PermissionV2Replied" + }, + { + "$ref": "#/components/schemas/PluginAdded" + }, + { + "$ref": "#/components/schemas/ProjectDirectoriesUpdated" + }, + { + "$ref": "#/components/schemas/FileWatcherUpdated" + }, + { + "$ref": "#/components/schemas/PtyCreated" + }, + { + "$ref": "#/components/schemas/PtyUpdated" + }, + { + "$ref": "#/components/schemas/PtyExited" + }, + { + "$ref": "#/components/schemas/PtyDeleted" + }, + { + "$ref": "#/components/schemas/QuestionV2Asked" + }, + { + "$ref": "#/components/schemas/QuestionV2Replied" + }, + { + "$ref": "#/components/schemas/QuestionV2Rejected" + }, + { + "$ref": "#/components/schemas/TodoUpdated" + }, + { + "$ref": "#/components/schemas/LspUpdated" + }, + { + "$ref": "#/components/schemas/PermissionAsked" + }, + { + "$ref": "#/components/schemas/PermissionReplied" + }, + { + "$ref": "#/components/schemas/TuiPromptAppend" + }, + { + "$ref": "#/components/schemas/TuiCommandExecute" + }, + { + "$ref": "#/components/schemas/TuiToastShow" + }, + { + "$ref": "#/components/schemas/TuiSessionSelect" + }, + { + "$ref": "#/components/schemas/McpToolsChanged" + }, + { + "$ref": "#/components/schemas/McpBrowserOpenFailed" + }, + { + "$ref": "#/components/schemas/CommandExecuted" + }, + { + "$ref": "#/components/schemas/ProjectUpdated" + }, + { + "$ref": "#/components/schemas/session.status" + }, + { + "$ref": "#/components/schemas/SessionIdle" + }, + { + "$ref": "#/components/schemas/QuestionAsked" + }, + { + "$ref": "#/components/schemas/question.replied" + }, + { + "$ref": "#/components/schemas/question.rejected" + }, + { + "$ref": "#/components/schemas/SessionCompacted" + }, + { + "$ref": "#/components/schemas/VcsBranchUpdated" + }, + { + "$ref": "#/components/schemas/WorkspaceReady" + }, + { + "$ref": "#/components/schemas/WorkspaceFailed" + }, + { + "$ref": "#/components/schemas/WorkspaceStatus" + }, + { + "$ref": "#/components/schemas/WorktreeReady" + }, + { + "$ref": "#/components/schemas/WorktreeFailed" + }, + { + "$ref": "#/components/schemas/ServerConnected" + }, + { + "$ref": "#/components/schemas/GlobalDisposed" + } + ] + }, + "V2EventStream": { + "type": "string", + "contentSchema": { + "$ref": "#/components/schemas/V2Event" + }, + "contentMediaType": "application/json" + }, "ForbiddenError": { "type": "object", "properties": { @@ -23085,6 +24437,61 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "CredentialValue": { + "anyOf": [ + { + "$ref": "#/components/schemas/CredentialOAuth" + }, + { + "$ref": "#/components/schemas/CredentialKey" + } + ] + }, + "IntegrationInputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "IntegrationMethod": { + "anyOf": [ + { + "$ref": "#/components/schemas/IntegrationOAuthMethod" + }, + { + "$ref": "#/components/schemas/IntegrationKeyMethod" + }, + { + "$ref": "#/components/schemas/IntegrationEnvMethod" + } + ] + }, + "IntegrationRef": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], + "additionalProperties": false + }, + "SkillV2Source": { + "anyOf": [ + { + "$ref": "#/components/schemas/SkillV2DirectorySource" + }, + { + "$ref": "#/components/schemas/SkillV2UrlSource" + }, + { + "$ref": "#/components/schemas/SkillV2EmbeddedSource" + } + ] + }, "MoveSessionDestination": { "type": "object", "properties": { @@ -23095,6 +24502,22 @@ "required": ["directory"], "additionalProperties": false }, + "ModelRef": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, "LocationRef": { "type": "object", "properties": { @@ -23174,6 +24597,12 @@ "required": ["type", "message"], "additionalProperties": false }, + "LLMProviderMetadata": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, "ToolTextContent": { "type": "object", "properties": { @@ -23208,6 +24637,16 @@ "required": ["type", "uri", "mime"], "additionalProperties": false }, + "LLMToolContent": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + }, "SessionNextRetry_error": { "type": "object", "properties": { @@ -23239,6 +24678,57 @@ "required": ["message", "isRetryable"], "additionalProperties": false }, + "FileDiff": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["added", "modified", "deleted"] + }, + "additions": { + "type": "integer", + "minimum": 0 + }, + "deletions": { + "type": "integer", + "minimum": 0 + }, + "patch": { + "type": "string" + } + }, + "required": ["path", "status", "additions", "deletions", "patch"], + "additionalProperties": false + }, + "RevertState": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "partID": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileDiff" + } + } + }, + "required": ["messageID"], + "additionalProperties": false + }, "PermissionV2Source": { "type": "object", "properties": { @@ -23322,6 +24812,54 @@ "type": "string" } }, + "ProjectVcs": { + "type": "string", + "enum": ["git"] + }, + "ProjectIcon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": false + }, + "ProjectCommands": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" + } + }, + "additionalProperties": false + }, + "ProjectTime": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "initialized": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, "EventServerInstanceDisposed": { "type": "object", "properties": { @@ -23805,20 +25343,7 @@ "pattern": "^msg_" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" } }, "required": ["timestamp", "sessionID", "messageID", "model"], @@ -24010,116 +25535,6 @@ "required": ["type", "id", "syncEvent"], "additionalProperties": false }, - "SyncEventSessionNextPromptPromoted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "id": { - "type": "string", - "pattern": "^evt_" - }, - "syncEvent": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["session.next.prompt.promoted.1"] - }, - "id": { - "type": "string", - "pattern": "^evt_" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - }, - "timeCreated": { - "type": "number" - } - }, - "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], - "additionalProperties": false - } - }, - "required": ["type", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - } - }, - "required": ["type", "id", "syncEvent"], - "additionalProperties": false - }, - "SyncEventSessionNextInterruptRequested": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "id": { - "type": "string", - "pattern": "^evt_" - }, - "syncEvent": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["session.next.interrupt.requested.1"] - }, - "id": { - "type": "string", - "pattern": "^evt_" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["timestamp", "sessionID"], - "additionalProperties": false - } - }, - "required": ["type", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - } - }, - "required": ["type", "id", "syncEvent"], - "additionalProperties": false - }, "SyncEventSessionNextContextUpdated": { "type": "object", "properties": { @@ -24396,20 +25811,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "snapshot": { "type": "string" @@ -24505,6 +25907,12 @@ }, "snapshot": { "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } } }, "required": ["timestamp", "sessionID", "assistantMessageID", "finish", "cost", "tokens"], @@ -24738,10 +26146,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], @@ -24804,10 +26209,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], @@ -24999,10 +26401,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -25071,14 +26470,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } } }, @@ -25144,14 +26536,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } }, "outputPaths": { @@ -25168,10 +26553,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -25253,10 +26635,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -25404,7 +26783,7 @@ "properties": { "type": { "type": "string", - "enum": ["session.next.compaction.ended.2"] + "enum": ["session.next.compaction.ended.1"] }, "id": { "type": "string", @@ -25452,6 +26831,163 @@ "required": ["type", "id", "syncEvent"], "additionalProperties": false }, + "SyncEventSessionNextRevertStaged": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "id": { + "type": "string", + "pattern": "^evt_" + }, + "syncEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["session.next.revert.staged.1"] + }, + "id": { + "type": "string", + "pattern": "^evt_" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "revert": { + "$ref": "#/components/schemas/RevertState" + } + }, + "required": ["timestamp", "sessionID", "revert"], + "additionalProperties": false + } + }, + "required": ["type", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + } + }, + "required": ["type", "id", "syncEvent"], + "additionalProperties": false + }, + "SyncEventSessionNextRevertCleared": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "id": { + "type": "string", + "pattern": "^evt_" + }, + "syncEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["session.next.revert.cleared.1"] + }, + "id": { + "type": "string", + "pattern": "^evt_" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["type", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + } + }, + "required": ["type", "id", "syncEvent"], + "additionalProperties": false + }, + "SyncEventSessionNextRevertCommitted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "id": { + "type": "string", + "pattern": "^evt_" + }, + "syncEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["session.next.revert.committed.1"] + }, + "id": { + "type": "string", + "pattern": "^evt_" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + } + }, + "required": ["timestamp", "sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["type", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + } + }, + "required": ["type", "id", "syncEvent"], + "additionalProperties": false + }, "ConfigV2ReferenceGit": { "type": "object", "properties": { @@ -25524,6 +27060,35 @@ "additionalProperties": false } }, + "PtyTicketConnectToken": { + "type": "object", + "properties": { + "ticket": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["ticket", "expires_in"], + "additionalProperties": false + }, + "WorkspaceEventConnectionStatus": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + }, "LocationInfo": { "type": "object", "properties": { @@ -25551,6 +27116,34 @@ "required": ["directory", "project"], "additionalProperties": false }, + "ProviderRequest": { + "type": "object", + "properties": { + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + } + }, + "required": ["headers", "body"], + "additionalProperties": false + }, + "AgentColor": { + "anyOf": [ + { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + { + "type": "string", + "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] + } + ] + }, "PermissionV2Effect": { "type": "string", "enum": ["allow", "deny", "ask"] @@ -25584,36 +27177,10 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "request": { - "type": "object", - "properties": { - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - } - }, - "required": ["headers", "body"], - "additionalProperties": false + "$ref": "#/components/schemas/ProviderRequest" }, "system": { "type": "string" @@ -25629,16 +27196,7 @@ "type": "boolean" }, "color": { - "anyOf": [ - { - "type": "string", - "pattern": "^#[0-9a-fA-F]{6}$" - }, - { - "type": "string", - "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] - } - ] + "$ref": "#/components/schemas/AgentColor" }, "steps": { "type": "integer", @@ -25669,20 +27227,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "cost": { "type": "number" @@ -25740,11 +27285,33 @@ }, "subpath": { "type": "string" + }, + "revert": { + "$ref": "#/components/schemas/RevertState" } }, "required": ["id", "projectID", "cost", "tokens", "time", "title", "location"], "additionalProperties": false }, + "PromptInputFileAttachment": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" + } + }, + "required": ["uri"], + "additionalProperties": false + }, "SessionInputAdmitted": { "type": "object", "properties": { @@ -25834,20 +27401,7 @@ "enum": ["model-switched"] }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" } }, "required": ["id", "time", "type", "model"], @@ -26033,10 +27587,20 @@ "type": "string" }, "providerMetadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + }, + "time": { "type": "object", - "additionalProperties": { - "type": "object" - } + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false } }, "required": ["type", "id", "text"], @@ -26072,14 +27636,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } } }, @@ -26105,14 +27662,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } }, "outputPaths": { @@ -26142,14 +27692,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } }, "structured": { @@ -26183,16 +27726,10 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" }, "resultMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -26268,20 +27805,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "content": { "type": "array", @@ -26307,6 +27831,12 @@ }, "end": { "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } } }, "additionalProperties": false @@ -26419,6 +27949,1880 @@ } ] }, + "SessionNextAgentSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.agent.switched"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "agent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextModelSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.model.switched"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "model": { + "$ref": "#/components/schemas/ModelRef" + } + }, + "required": ["timestamp", "sessionID", "messageID", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextMoved": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.moved"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "subdirectory": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "location"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextPrompted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.prompted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextPromptAdmitted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.prompt.admitted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextContextUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.context.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextSynthetic": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.synthetic"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextShellStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "callID", "command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextShellEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextStepStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.step.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/ModelRef" + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextStepEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.step.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextStepFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.step.failed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextTextStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.text.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "textID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextTextEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.text.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "textID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolInputStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolInputEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolCalled": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.called"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolProgress": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.progress"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLMToolContent" + } + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolSuccess": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.success"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLMToolContent" + } + }, + "outputPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.failed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextReasoningStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "reasoningID": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextReasoningEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextRetried": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.retried"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "attempt": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextCompactionStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "reason"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextCompactionEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + }, + "text": { + "type": "string" + }, + "recent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "reason", "text", "recent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextRevertStaged": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.staged"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "revert": { + "$ref": "#/components/schemas/RevertState" + } + }, + "required": ["timestamp", "sessionID", "revert"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextRevertCleared": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.cleared"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextRevertCommitted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.committed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + } + }, + "required": ["timestamp", "sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "ModelApi": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["id", "type", "package"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["native"] + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["id", "type", "settings"], + "additionalProperties": false + } + ] + }, + "ModelCapabilities": { + "type": "object", + "properties": { + "tools": { + "type": "boolean" + }, + "input": { + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["tools", "input", "output"], + "additionalProperties": false + }, + "ModelCost": { + "type": "object", + "properties": { + "tier": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["context"] + }, + "size": { + "type": "integer" + } + }, + "required": ["type", "size"], + "additionalProperties": false + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "cache"], + "additionalProperties": false + }, "ModelV2Info": { "type": "object", "properties": { @@ -26435,73 +29839,10 @@ "type": "string" }, "api": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["aisdk"] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": ["id", "type", "package"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["native"] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": ["id", "type", "settings"], - "additionalProperties": false - } - ] + "$ref": "#/components/schemas/ModelApi" }, "capabilities": { - "type": "object", - "properties": { - "tools": { - "type": "boolean" - }, - "input": { - "type": "array", - "items": { - "type": "string" - } - }, - "output": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["tools", "input", "output"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelCapabilities" }, "request": { "type": "object", @@ -26515,182 +29856,6 @@ "body": { "type": "object" }, - "generation": { - "type": "object", - "properties": { - "maxTokens": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "temperature": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "topP": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "topK": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "frequencyPenalty": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "presencePenalty": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "seed": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "stop": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - }, - "options": { - "type": "object" - }, "variant": { "type": "string" } @@ -26714,182 +29879,6 @@ }, "body": { "type": "object" - }, - "generation": { - "type": "object", - "properties": { - "maxTokens": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "temperature": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "topP": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "topK": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "frequencyPenalty": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "presencePenalty": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "seed": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "stop": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - }, - "options": { - "type": "object" } }, "required": ["id", "headers", "body"], @@ -26900,27 +29889,7 @@ "type": "object", "properties": { "released": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] + "type": "number" } }, "required": ["released"], @@ -26929,44 +29898,7 @@ "cost": { "type": "array", "items": { - "type": "object", - "properties": { - "tier": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["context"] - }, - "size": { - "type": "integer" - } - }, - "required": ["type", "size"], - "additionalProperties": false - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "cache"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelCost" } }, "status": { @@ -27009,12 +29941,62 @@ ], "additionalProperties": false }, + "ProviderAISDK": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["type", "package"], + "additionalProperties": false + }, + "ProviderNative": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["native"] + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["type", "settings"], + "additionalProperties": false + }, + "ProviderApi": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAISDK" + }, + { + "$ref": "#/components/schemas/ProviderNative" + } + ] + }, "ProviderV2Info": { "type": "object", "properties": { "id": { "type": "string" }, + "integrationID": { + "type": "string" + }, "name": { "type": "string" }, @@ -27022,61 +30004,10 @@ "type": "boolean" }, "api": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["aisdk"] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": ["type", "package"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["native"] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": ["type", "settings"], - "additionalProperties": false - } - ] + "$ref": "#/components/schemas/ProviderApi" }, "request": { - "type": "object", - "properties": { - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - } - }, - "required": ["headers", "body"], - "additionalProperties": false + "$ref": "#/components/schemas/ProviderRequest" } }, "required": ["id", "name", "api", "request"], @@ -27275,17 +30206,7 @@ "methods": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/IntegrationOAuthMethod" - }, - { - "$ref": "#/components/schemas/IntegrationKeyMethod" - }, - { - "$ref": "#/components/schemas/IntegrationEnvMethod" - } - ] + "$ref": "#/components/schemas/IntegrationMethod" } }, "connections": { @@ -27371,6 +30292,269 @@ "required": ["attemptID", "url", "instructions", "mode", "time"], "additionalProperties": false }, + "IntegrationAttemptStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["pending"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "time"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["complete"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "time"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["failed"] + }, + "message": { + "type": "string" + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "message", "time"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["expired"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "time"], + "additionalProperties": false + } + ] + }, "PermissionV2Request": { "type": "object", "properties": { @@ -27435,12 +30619,9 @@ "type": { "type": "string", "enum": ["file", "directory"] - }, - "mime": { - "type": "string" } }, - "required": ["path", "type", "mime"], + "required": ["path", "type"], "additionalProperties": false }, "CommandV2Info": { @@ -27459,20 +30640,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "subtask": { "type": "boolean" @@ -27503,6 +30671,3010 @@ "required": ["name", "location", "content"], "additionalProperties": false }, + "Models-devRefreshed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["models-dev.refreshed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "IntegrationUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["integration.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "IntegrationConnectionUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["integration.connection.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "integrationID": { + "type": "string" + } + }, + "required": ["integrationID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "CatalogUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["catalog.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionCreated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.created"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.deleted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "MessageUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["message.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "MessageRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["message.removed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "MessagePartUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["message.part.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "number" + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "MessagePartRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["message.part.removed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextTextDelta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.text.delta"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "textID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextReasoningDelta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.delta"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolInputDelta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.delta"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextCompactionDelta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.delta"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "MessagePartDelta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["message.part.delta"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "field": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID", "field", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionDiff": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.diff"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["sessionID", "diff"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionError": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.error"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/ContentFilterError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "InstallationUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["installation.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "InstallationUpdate-available": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["installation.update-available"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "FileEdited": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["file.edited"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "ReferenceUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["reference.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PermissionV2Asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["permission.v2.asked"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "source": { + "$ref": "#/components/schemas/PermissionV2Source" + } + }, + "required": ["id", "sessionID", "action", "resources"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PermissionV2Replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["permission.v2.replied"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^per" + }, + "reply": { + "$ref": "#/components/schemas/PermissionV2Reply" + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PluginAdded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["plugin.added"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "ProjectDirectoriesUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["project.directories.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "projectID": { + "type": "string" + } + }, + "required": ["projectID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "FileWatcherUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["file.watcher.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PtyCreated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["pty.created"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PtyUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["pty.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PtyExited": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["pty.exited"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty" + }, + "exitCode": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["id", "exitCode"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PtyDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["pty.deleted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "QuestionV2Asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.v2.asked"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2Info" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionV2Tool" + } + }, + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "QuestionV2Replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.v2.replied"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2Answer" + } + } + }, + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "QuestionV2Rejected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.v2.rejected"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "TodoUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["todo.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "LspUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["lsp.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PermissionAsked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["permission.asked"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"], + "additionalProperties": false + } + }, + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PermissionReplied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["permission.replied"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^per" + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "TuiPromptAppend": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["tui.prompt.append"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "TuiCommandExecute": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["tui.command.execute"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "TuiToastShow": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "TuiSessionSelect": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["tui.session.select"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses", + "description": "Session ID to navigate to" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "McpToolsChanged": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["mcp.tools.changed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "McpBrowserOpenFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["mcp.browser.open.failed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "mcpName": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["mcpName", "url"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "CommandExecuted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["command.executed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "arguments": { + "type": "string" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["name", "sessionID", "arguments", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "ProjectUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["project.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "$ref": "#/components/schemas/ProjectVcs" + }, + "name": { + "type": "string" + }, + "icon": { + "$ref": "#/components/schemas/ProjectIcon" + }, + "commands": { + "$ref": "#/components/schemas/ProjectCommands" + }, + "time": { + "$ref": "#/components/schemas/ProjectTime" + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "worktree", "time", "sandboxes"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionIdle": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.idle"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "QuestionAsked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.asked"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionTool" + } + }, + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionCompacted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.compacted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "VcsBranchUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["vcs.branch.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "WorkspaceReady": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["workspace.ready"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "WorkspaceFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["workspace.failed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "WorkspaceStatus": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["workspace.status"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "WorktreeReady": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["worktree.ready"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "WorktreeFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["worktree.failed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "ServerConnected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["server.connected"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "GlobalDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["global.disposed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, "QuestionV2Request": { "type": "object", "properties": { @@ -27585,6 +33757,16 @@ "required": ["type", "repository"], "additionalProperties": false }, + "ReferenceSource": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReferenceLocalSource" + }, + { + "$ref": "#/components/schemas/ReferenceGitSource" + } + ] + }, "ReferenceInfo": { "type": "object", "properties": { @@ -27601,14 +33783,7 @@ "type": "boolean" }, "source": { - "anyOf": [ - { - "$ref": "#/components/schemas/ReferenceLocalSource" - }, - { - "$ref": "#/components/schemas/ReferenceGitSource" - } - ] + "$ref": "#/components/schemas/ReferenceSource" } }, "required": ["name", "path", "source"], @@ -27643,31 +33818,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventPluginAdded": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "type": { - "type": "string", - "enum": ["plugin.added"] - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": ["id"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventIntegrationUpdated": { "type": "object", "properties": { @@ -27687,6 +33837,31 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventIntegrationConnectionUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["integration.connection.updated"] + }, + "properties": { + "type": "object", + "properties": { + "integrationID": { + "type": "string" + } + }, + "required": ["integrationID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventCatalogUpdated": { "type": "object", "properties": { @@ -27980,20 +34155,7 @@ "pattern": "^msg_" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" } }, "required": ["timestamp", "sessionID", "messageID", "model"], @@ -28118,74 +34280,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextPromptPromoted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "type": { - "type": "string", - "enum": ["session.next.prompt.promoted"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - }, - "timeCreated": { - "type": "number" - } - }, - "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextInterruptRequested": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "type": { - "type": "string", - "enum": ["session.next.interrupt.requested"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["timestamp", "sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventSessionNextContextUpdated": { "type": "object", "properties": { @@ -28361,20 +34455,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "snapshot": { "type": "string" @@ -28449,6 +34530,12 @@ }, "snapshot": { "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } } }, "required": ["timestamp", "sessionID", "assistantMessageID", "finish", "cost", "tokens"], @@ -28637,10 +34724,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], @@ -28721,10 +34805,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], @@ -28892,10 +34973,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -28943,14 +35021,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } } }, @@ -28995,14 +35066,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } }, "outputPaths": { @@ -29019,10 +35083,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -29075,10 +35136,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -29243,6 +35301,100 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventSessionNextRevertStaged": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.staged"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "revert": { + "$ref": "#/components/schemas/RevertState" + } + }, + "required": ["timestamp", "sessionID", "revert"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextRevertCleared": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.cleared"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextRevertCommitted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.committed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + } + }, + "required": ["timestamp", "sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventMessagePartDelta": { "type": "object", "properties": { @@ -29443,6 +35595,25 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventReferenceUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["reference.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventPermissionV2Asked": { "type": "object", "properties": { @@ -29527,7 +35698,7 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventReferenceUpdated": { + "EventPluginAdded": { "type": "object", "properties": { "id": { @@ -29536,11 +35707,17 @@ }, "type": { "type": "string", - "enum": ["reference.updated"] + "enum": ["plugin.added"] }, "properties": { "type": "object", - "properties": {} + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false } }, "required": ["id", "type", "properties"], @@ -30068,55 +36245,19 @@ "type": "string" }, "vcs": { - "type": "string", - "enum": ["git"] + "$ref": "#/components/schemas/ProjectVcs" }, "name": { "type": "string" }, "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectIcon" }, "commands": { - "type": "object", - "properties": { - "start": { - "type": "string", - "description": "Startup script to run when creating a new workspace (worktree)" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectCommands" }, "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "initialized": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["created", "updated"], - "additionalProperties": false + "$ref": "#/components/schemas/ProjectTime" }, "sandboxes": { "type": "array", @@ -30514,6 +36655,92 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "CredentialOAuth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["oauth"] + }, + "methodID": { + "type": "string" + }, + "refresh": { + "type": "string" + }, + "access": { + "type": "string" + }, + "expires": { + "type": "integer", + "minimum": 0 + }, + "metadata": { + "type": "object" + } + }, + "required": ["type", "methodID", "refresh", "access", "expires"], + "additionalProperties": false + }, + "CredentialKey": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["key"] + }, + "key": { + "type": "string" + }, + "metadata": { + "type": "object" + } + }, + "required": ["type", "key"], + "additionalProperties": false + }, + "SkillV2DirectorySource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["directory"] + }, + "path": { + "type": "string" + } + }, + "required": ["type", "path"], + "additionalProperties": false + }, + "SkillV2UrlSource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["url"] + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + "SkillV2EmbeddedSource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["embedded"] + }, + "skill": { + "$ref": "#/components/schemas/SkillV2Info" + } + }, + "required": ["type", "skill"], + "additionalProperties": false + }, "BadRequestError": { "type": "object", "required": ["name", "data"], diff --git a/packages/server/package.json b/packages/server/package.json index 5d78e0a436..ec85f7d001 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "7.4.15", + "version": "7.4.16", "private": true, "type": "module", "license": "MIT", @@ -13,6 +13,7 @@ }, "dependencies": { "@opencode-ai/core": "workspace:*", + "@opencode-ai/protocol": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:" }, diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index 42573e0648..981ad28db9 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -1,50 +1,8 @@ -import { HttpApi, OpenApi } from "effect/unstable/httpapi" -import { SchemaErrorMiddleware } from "./middleware/schema-error" -import { MessageGroup } from "./groups/message" -import { ModelGroup } from "./groups/model" -import { ProviderGroup } from "./groups/provider" -import { SessionGroup } from "./groups/session" -import { PermissionGroup } from "./groups/permission" -import { FileSystemGroup } from "./groups/fs" -import { CommandGroup } from "./groups/command" -import { SkillGroup } from "./groups/skill" -import { EventGroup } from "./groups/event" -import { AgentGroup } from "./groups/agent" -import { HealthGroup } from "./groups/health" -import { PtyGroup } from "./groups/pty" -import { QuestionGroup } from "./groups/question" -import { ReferenceGroup } from "./groups/reference" -import { Authorization } from "./middleware/authorization" -import { LocationGroup } from "./groups/location" -import { IntegrationGroup } from "./groups/integration" -import { CredentialGroup } from "./groups/credential" -import { ProjectCopyGroup } from "./groups/project-copy" +import { makeDefaultApi } from "@opencode-ai/protocol/api" +import { LocationMiddleware } from "./location" +import { SessionLocationMiddleware } from "./middleware/session-location" -export const Api = HttpApi.make("server") - .add(HealthGroup) - .add(LocationGroup) - .add(AgentGroup) - .add(SessionGroup) - .add(MessageGroup) - .add(ModelGroup) - .add(ProviderGroup) - .add(IntegrationGroup) - .add(CredentialGroup) - .add(PermissionGroup) - .add(FileSystemGroup) - .add(CommandGroup) - .add(SkillGroup) - .add(EventGroup) - .add(PtyGroup) - .add(QuestionGroup) - .add(ReferenceGroup) - .add(ProjectCopyGroup) - .annotateMerge( - OpenApi.annotations({ - title: "opencode HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - .middleware(Authorization) - .middleware(SchemaErrorMiddleware) +export const Api = makeDefaultApi({ + locationMiddleware: LocationMiddleware, + sessionLocationMiddleware: SessionLocationMiddleware, +}) diff --git a/packages/server/src/auth.ts b/packages/server/src/auth.ts index 5822b651f6..0528711b7f 100644 --- a/packages/server/src/auth.ts +++ b/packages/server/src/auth.ts @@ -18,11 +18,11 @@ export type Info = { } export class Config extends Context.Service()("@opencode/ServerAuthConfig") { - static layer(input: Info) { + static configLayer(input: Info) { return Layer.succeed(this, this.of(input)) } - static get defaultLayer() { + static get layer() { return Layer.effect( this, Effect.gen(function* () { diff --git a/packages/server/src/groups/agent.ts b/packages/server/src/groups/agent.ts deleted file mode 100644 index c9dd5398c5..0000000000 --- a/packages/server/src/groups/agent.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { AgentV2 } from "@opencode-ai/core/agent" -import { Location } from "@opencode-ai/core/location" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" - -export const AgentGroup = HttpApiGroup.make("server.agent") - .add( - HttpApiEndpoint.get("agent.list", "/api/agent", { - query: LocationQuery, - success: Location.response(Schema.Array(AgentV2.Info)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.agent.list", - summary: "List agents", - description: "Retrieve currently registered agents.", - }), - ), - ) - .middleware(LocationMiddleware) diff --git a/packages/server/src/groups/event.ts b/packages/server/src/groups/event.ts deleted file mode 100644 index 83ccdf98f7..0000000000 --- a/packages/server/src/groups/event.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" - -const Event = Schema.Struct({ - id: EventV2.ID, - type: Schema.String, - location: Location.Info.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - version: Schema.Number.pipe(Schema.optional), - data: Schema.Unknown, -}) - -export const EventGroup = HttpApiGroup.make("server.event") - .add( - HttpApiEndpoint.get("event.subscribe", "/api/event", { - query: LocationQuery, - success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.event.subscribe", - summary: "Subscribe to events", - description: "Subscribe to native event payloads for a location.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." })) - .middleware(LocationMiddleware) - -export type Event = typeof Event.Type diff --git a/packages/server/src/groups/location.ts b/packages/server/src/groups/location.ts deleted file mode 100644 index 6f8e5da915..0000000000 --- a/packages/server/src/groups/location.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { FileSystem } from "@opencode-ai/core/filesystem" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { WorkspaceV2 } from "@opencode-ai/core/workspace" -import { Effect, Layer, Schema } from "effect" -import { HttpServerRequest } from "effect/unstable/http" -import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi" - -export const LocationQuery = Schema.Struct({ - location: Schema.optional( - Schema.Struct({ - directory: Schema.optional(Schema.String), - workspace: Schema.optional(Schema.String), - }), - ), -}).annotate({ identifier: "LocationQuery" }) - -export const locationQueryOpenApi = OpenApi.annotations({ - transform: (operation) => { - const parameters = operation.parameters - if (!Array.isArray(parameters)) return operation - return { - ...operation, - parameters: parameters.map((parameter) => - parameter?.name === "location" && parameter?.in === "query" - ? { ...parameter, style: "deepObject", explode: true } - : parameter, - ), - } - }, -}) - -export function response(data: Effect.Effect) { - return Effect.gen(function* () { - const location = yield* Location.Service - return { - location: new Location.Info({ - directory: location.directory, - workspaceID: location.workspaceID, - project: location.project, - }), - data: yield* data, - } - }) -} - -export type LocationServices = Layer.Success> - -export class LocationMiddleware extends HttpApiMiddleware.Service< - LocationMiddleware, - { - provides: LocationServices - } ->()("@opencode/HttpApiLocation") {} - -export const LocationGroup = HttpApiGroup.make("server.location") - .add( - HttpApiEndpoint.get("location.get", "/api/location", { - query: LocationQuery, - success: Location.Info, - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.location.get", - summary: "Get location", - description: "Resolve the requested location or the server default location.", - }), - ), - ) - .middleware(LocationMiddleware) - -function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref { - const query = new URL(request.url, "http://localhost").searchParams - const workspaceID = query.get("location[workspace]") || request.headers["x-kilo-workspace"] - const directory = - query.get("location[directory]") || - (request.headers["x-kilo-directory"] ? decode(request.headers["x-kilo-directory"]) : process.cwd()) - return Location.Ref.make({ - directory: AbsolutePath.make(directory), - workspaceID: workspaceID ? WorkspaceV2.ID.make(workspaceID) : undefined, - }) -} - -function decode(input: string) { - try { - return decodeURIComponent(input) - } catch { - return input - } -} - -export const layer = Layer.effect( - LocationMiddleware, - Effect.gen(function* () { - const locations = yield* LocationServiceMap - return LocationMiddleware.of((effect) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest - return yield* effect.pipe(Effect.provide(locations.get(ref(request)))) - }), - ) - }), -) diff --git a/packages/server/src/groups/permission.ts b/packages/server/src/groups/permission.ts deleted file mode 100644 index a5b9e89a96..0000000000 --- a/packages/server/src/groups/permission.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { PermissionV2 } from "@opencode-ai/core/permission" -import { Location } from "@opencode-ai/core/location" -import { PermissionSaved } from "@opencode-ai/core/permission/saved" -import { ProjectV2 } from "@opencode-ai/core/project" -import { SessionV2 } from "@opencode-ai/core/session" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { PermissionNotFoundError, SessionNotFoundError } from "../errors" -import { SessionLocationMiddleware } from "../middleware/session-location" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" - -export const PermissionGroup = HttpApiGroup.make("server.permission") - .add( - HttpApiEndpoint.get("permission.request.list", "/api/permission/request", { - query: LocationQuery, - success: Location.response(Schema.Array(PermissionV2.Request)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.permission.request.list", - summary: "List pending permission requests", - description: "Retrieve pending permission requests for a location.", - }), - ), - ) - .add( - HttpApiEndpoint.get("permission.saved.list", "/api/permission/saved", { - query: Schema.Struct({ projectID: ProjectV2.ID.pipe(Schema.optional) }), - success: Schema.Struct({ data: Schema.Array(PermissionSaved.Info) }), - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.permission.saved.list", - summary: "List saved permissions", - description: "Retrieve saved permissions, optionally filtered by project.", - }), - ), - ) - .add( - HttpApiEndpoint.delete("permission.saved.remove", "/api/permission/saved/:id", { - params: { id: PermissionSaved.ID }, - success: HttpApiSchema.NoContent, - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.permission.saved.remove", - summary: "Remove saved permission", - description: "Remove a saved permission by ID.", - }), - ), - ) - .middleware(LocationMiddleware) - .add( - HttpApiEndpoint.get("session.permission.list", "/api/session/:sessionID/permission", { - params: { sessionID: SessionV2.ID }, - success: Schema.Struct({ data: Schema.Array(PermissionV2.Request) }), - error: SessionNotFoundError, - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.permission.list", - summary: "List session permission requests", - description: "Retrieve pending permission requests owned by a session.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.permission.reply", "/api/session/:sessionID/permission/:requestID/reply", { - params: { sessionID: SessionV2.ID, requestID: PermissionV2.ID }, - payload: Schema.Struct({ - reply: PermissionV2.Reply, - message: Schema.String.pipe(Schema.optional), - }), - success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, PermissionNotFoundError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.permission.reply", - summary: "Reply to pending permission request", - description: "Respond to a pending permission request owned by a session.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "permissions", description: "Experimental permission routes." })) diff --git a/packages/server/src/groups/question.ts b/packages/server/src/groups/question.ts deleted file mode 100644 index cb8932129e..0000000000 --- a/packages/server/src/groups/question.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { QuestionV2 } from "@opencode-ai/core/question" -import { Location } from "@opencode-ai/core/location" -import { SessionV2 } from "@opencode-ai/core/session" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { QuestionNotFoundError, SessionNotFoundError } from "../errors" -import { SessionLocationMiddleware } from "../middleware/session-location" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" - -export const QuestionGroup = HttpApiGroup.make("server.question") - .add( - HttpApiEndpoint.get("question.request.list", "/api/question/request", { - query: LocationQuery, - success: Location.response(Schema.Array(QuestionV2.Request)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.question.request.list", - summary: "List pending question requests", - description: "Retrieve pending question requests for a location.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." })) - .middleware(LocationMiddleware) - .add( - HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", { - params: { sessionID: SessionV2.ID }, - success: Schema.Struct({ data: Schema.Array(QuestionV2.Request) }), - error: SessionNotFoundError, - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.question.list", - summary: "List session question requests", - description: "Retrieve pending question requests owned by a session.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", { - params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID }, - payload: QuestionV2.Reply, - success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, QuestionNotFoundError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.question.reply", - summary: "Reply to pending question request", - description: "Answer a pending question request owned by a session.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.question.reject", "/api/session/:sessionID/question/:requestID/reject", { - params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID }, - success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, QuestionNotFoundError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.question.reject", - summary: "Reject pending question request", - description: "Reject a pending question request owned by a session.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ title: "session questions", description: "Experimental session question routes." }), - ) diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts deleted file mode 100644 index a208de82f0..0000000000 --- a/packages/server/src/groups/session.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { SessionMessage } from "@opencode-ai/core/session/message" -import { SessionInput } from "@opencode-ai/core/session/input" -import { Prompt } from "@opencode-ai/core/session/prompt" -import { SessionV2 } from "@opencode-ai/core/session" -import { ProjectV2 } from "@opencode-ai/core/project" -import { AbsolutePath, PositiveInt, RelativePath, withStatics } from "@opencode-ai/core/schema" -import { WorkspaceV2 } from "@opencode-ai/core/workspace" -import { Schema, Struct } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { - ConflictError, - InvalidCursorError, - InvalidRequestError, - ServiceUnavailableError, - SessionNotFoundError, - UnknownError, -} from "../errors" -import { SessionLocationMiddleware } from "../middleware/session-location" -import { AgentV2 } from "@opencode-ai/core/agent" -import { ModelV2 } from "@opencode-ai/core/model" -import { Location } from "@opencode-ai/core/location" - -const SessionsQueryFields = { - workspace: WorkspaceV2.ID.pipe(Schema.optional), - limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({ - description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", - }), - order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ - description: "Session order for the first page. Use desc for newest first or asc for oldest first.", - }), - search: Schema.optional(Schema.String), -} - -const SessionsDirectoryQuery = Schema.Struct({ - ...SessionsQueryFields, - directory: AbsolutePath, -}) - -const SessionsProjectQuery = Schema.Struct({ - ...SessionsQueryFields, - project: ProjectV2.ID, - subpath: RelativePath.pipe(Schema.optional), -}) - -const SessionsAllQuery = Schema.Struct(SessionsQueryFields) - -const withCursor = (schema: Schema.Struct) => - schema.mapFields((fields) => ({ - ...Struct.omit(fields, ["limit"]), - anchor: SessionV2.ListAnchor, - })) - -const SessionsCursorInput = Schema.Union([ - withCursor(SessionsDirectoryQuery), - withCursor(SessionsProjectQuery), - withCursor(SessionsAllQuery), -]) -const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput) -const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson) -const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson) - -export const SessionsCursor = Schema.String.pipe( - Schema.brand("SessionsCursor"), - withStatics((schema) => { - const make = schema.make - return { - make: (input: typeof SessionsCursorInput.Type) => - make(Buffer.from(encodeSessionsCursor(input)).toString("base64url")), - parse: (input: string) => decodeSessionsCursor(Buffer.from(input, "base64url").toString("utf8")), - } - }), -) -export type SessionsCursor = typeof SessionsCursor.Type - -const SessionsCursorQuery = Schema.Struct({ - cursor: SessionsCursor.annotate({ - description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.", - }), - limit: SessionsQueryFields.limit, -}) - -export const SessionsQuery = Schema.Struct({ - ...SessionsQueryFields, - directory: AbsolutePath.pipe(Schema.optional), - project: ProjectV2.ID.pipe(Schema.optional), - subpath: RelativePath.pipe(Schema.optional), - cursor: SessionsCursorQuery.fields.cursor.pipe(Schema.optional), -}).annotate({ identifier: "SessionsQuery" }) - -export const SessionGroup = HttpApiGroup.make("server.session") - .add( - HttpApiEndpoint.get("session.list", "/api/session", { - query: SessionsQuery, - success: Schema.Struct({ - data: Schema.Array(SessionV2.Info), - cursor: Schema.Struct({ - previous: SessionsCursor.pipe(Schema.optional), - next: SessionsCursor.pipe(Schema.optional), - }), - }).annotate({ identifier: "SessionsResponse" }), - error: [InvalidCursorError, InvalidRequestError], - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.list", - summary: "List sessions", - description: - "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.create", "/api/session", { - payload: Schema.Struct({ - id: SessionV2.ID.pipe(Schema.optional), - agent: AgentV2.ID.pipe(Schema.optional), - model: ModelV2.Ref.pipe(Schema.optional), - location: Location.Ref.pipe(Schema.optional), - }), - success: Schema.Struct({ data: SessionV2.Info }), - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.create", - summary: "Create session", - description: "Create a session at the requested location.", - }), - ), - ) - .add( - HttpApiEndpoint.get("session.get", "/api/session/:sessionID", { - params: { sessionID: SessionV2.ID }, - success: Schema.Struct({ data: SessionV2.Info }), - error: SessionNotFoundError, - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.get", - summary: "Get session", - description: "Retrieve a session by ID.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", { - params: { sessionID: SessionV2.ID }, - payload: Schema.Struct({ - id: SessionMessage.ID.pipe(Schema.optional), - prompt: Prompt, - delivery: SessionInput.Delivery.pipe(Schema.optional), - resume: Schema.Boolean.pipe(Schema.optional), - }), - success: Schema.Struct({ data: SessionInput.Admitted }), - error: [ConflictError, SessionNotFoundError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.prompt", - summary: "Send message", - description: "Durably admit one session input and schedule agent-loop execution unless resume is false.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { - params: { sessionID: SessionV2.ID }, - success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, ServiceUnavailableError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.compact", - summary: "Compact session", - description: "Compact a session conversation.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.wait", "/api/session/:sessionID/wait", { - params: { sessionID: SessionV2.ID }, - success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, ServiceUnavailableError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.wait", - summary: "Wait for session", - description: "Wait for a session agent loop to become idle.", - }), - ), - ) - .add( - HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", { - params: { sessionID: SessionV2.ID }, - success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }), - error: [SessionNotFoundError, UnknownError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.context", - summary: "Get session context", - description: "Retrieve the active context messages for a session (all messages after the last compaction).", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "sessions", - description: "Experimental session routes.", - }), - ) diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index d26c1618f0..3a5e2e777e 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -1,10 +1,4 @@ -import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { PermissionSaved } from "@opencode-ai/core/permission/saved" -import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { Layer } from "effect" -import { layer as locationLayer } from "./groups/location" -import { sessionLocationLayer } from "./middleware/session-location" import { MessageHandler } from "./handlers/message" import { ModelHandler } from "./handlers/model" import { ProviderHandler } from "./handlers/provider" @@ -19,11 +13,9 @@ import { HealthHandler } from "./handlers/health" import { PtyHandler } from "./handlers/pty" import { QuestionHandler } from "./handlers/question" import { ReferenceHandler } from "./handlers/reference" -import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" import { LocationHandler } from "./handlers/location" import { IntegrationHandler } from "./handlers/integration" import { CredentialHandler } from "./handlers/credential" -import { Credential } from "@opencode-ai/core/credential" import { ProjectCopyHandler } from "./handlers/project-copy" export const handlers = Layer.mergeAll( @@ -45,13 +37,4 @@ export const handlers = Layer.mergeAll( QuestionHandler, ReferenceHandler, ProjectCopyHandler, -).pipe( - Layer.provide(sessionLocationLayer), - Layer.provide(locationLayer), - Layer.provide(SessionV2.defaultLayer), - Layer.provide(SessionExecutionLocal.defaultLayer), - Layer.provide(PermissionSaved.defaultLayer), - Layer.provide(PtyTicket.defaultLayer), - Layer.provide(LocationServiceMap.layer), - Layer.provide(Credential.defaultLayer), ) diff --git a/packages/server/src/handlers/agent.ts b/packages/server/src/handlers/agent.ts index cbde76dbce..c1511e3c62 100644 --- a/packages/server/src/handlers/agent.ts +++ b/packages/server/src/handlers/agent.ts @@ -1,14 +1,12 @@ import { AgentV2 } from "@opencode-ai/core/agent" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { response } from "../groups/location" +import { response } from "../location" export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers) => handlers.handle("agent.list", () => Effect.gen(function* () { - yield* PluginBoot.Service.use((plugin) => plugin.wait()) return yield* response(AgentV2.Service.use((agent) => agent.all())) }), ), diff --git a/packages/server/src/handlers/command.ts b/packages/server/src/handlers/command.ts index b09d52bee9..bf41e79f83 100644 --- a/packages/server/src/handlers/command.ts +++ b/packages/server/src/handlers/command.ts @@ -1,8 +1,7 @@ import { CommandV2 } from "@opencode-ai/core/command" -import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { response } from "../groups/location" +import { response } from "../location" export const CommandHandler = HttpApiBuilder.group(Api, "server.command", (handlers) => handlers.handle("command.list", () => response(CommandV2.Service.use((command) => command.list()))), diff --git a/packages/server/src/handlers/event.ts b/packages/server/src/handlers/event.ts index 65ec78a7cb..47e94731ee 100644 --- a/packages/server/src/handlers/event.ts +++ b/packages/server/src/handlers/event.ts @@ -1,17 +1,19 @@ import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" -import { Effect, Stream } from "effect" +import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" +import { Effect, Schema, Stream } from "effect" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import * as Sse from "effect/unstable/encoding/Sse" import { Api } from "../api" +const subscriberCapacity = 256 + function eventData(data: unknown): Sse.Event { return { _tag: "Event", event: "message", id: undefined, - data: JSON.stringify(data), + data: JSON.stringify(Schema.encodeUnknownSync(OpenCodeEvent)(data)), } } @@ -20,34 +22,21 @@ export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) const events = yield* EventV2.Service return handlers.handleRaw("event.subscribe", () => Effect.gen(function* () { - const location = yield* Location.Service const connected = { id: EventV2.ID.create(), type: "server.connected", - location: new Location.Info({ - directory: location.directory, - workspaceID: location.workspaceID, - project: location.project, - }), data: {}, } + const output = Stream.unwrap( + Effect.gen(function* () { + // Acquiring the bounded stream installs its listener before readiness is observable. + const live = yield* EventV2.allBounded(events, subscriberCapacity) + return Stream.make(connected).pipe(Stream.concat(live)) + }), + ).pipe(Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode())) + const heartbeat = Stream.tick("15 seconds").pipe(Stream.map(() => ": heartbeat\n\n")) return HttpServerResponse.stream( - Stream.make(connected).pipe( - Stream.concat( - events - .all() - .pipe( - Stream.filter( - (event) => - event.location?.directory === location.directory && - event.location.workspaceID === location.workspaceID, - ), - ), - ), - Stream.map(eventData), - Stream.pipeThroughChannel(Sse.encode()), - Stream.encodeText, - ), + output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText), { contentType: "text/event-stream", headers: { diff --git a/packages/server/src/handlers/fs.ts b/packages/server/src/handlers/fs.ts index 963bf51d85..c7d1d43bab 100644 --- a/packages/server/src/handlers/fs.ts +++ b/packages/server/src/handlers/fs.ts @@ -4,7 +4,7 @@ import { Effect } from "effect" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { response } from "../groups/location" +import { response } from "../location" export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) => Effect.gen(function* () { diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index d7c651e847..6c29d58776 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -2,8 +2,8 @@ import { Integration } from "@opencode-ai/core/integration" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { InvalidRequestError } from "../errors" -import { response } from "../groups/location" +import { InvalidRequestError } from "@opencode-ai/protocol/errors" +import { response } from "../location" const authorize = (effect: Effect.Effect) => effect.pipe( diff --git a/packages/server/src/handlers/message.ts b/packages/server/src/handlers/message.ts index eb5758c247..93734c628d 100644 --- a/packages/server/src/handlers/message.ts +++ b/packages/server/src/handlers/message.ts @@ -3,7 +3,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { Effect, Schema } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors" +import { InvalidCursorError, SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors" const DefaultMessagesLimit = 50 @@ -60,10 +60,7 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }), Effect.andThen( Effect.fail( - new UnknownError({ - message: "Unexpected server error. Check server logs for details.", - ref, - }), + new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }), ), ), ) diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index 71a3f9d850..36639ae7b1 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -1,15 +1,8 @@ import { Catalog } from "@opencode-ai/core/catalog" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { ServiceUnavailableError } from "../errors" -import { response } from "../groups/location" - -const catalogUnavailable = new ServiceUnavailableError({ - message: "Model catalog is unavailable", - service: "catalog", -}) +import { response } from "../location" export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) => Effect.gen(function* () { @@ -17,8 +10,6 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) "model.list", Effect.fn(function* () { const catalog = yield* Catalog.Service - const pluginBoot = yield* PluginBoot.Service - yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) return yield* response(catalog.model.available()) }), ) diff --git a/packages/server/src/handlers/permission.ts b/packages/server/src/handlers/permission.ts index 34fa3d428e..0425c14996 100644 --- a/packages/server/src/handlers/permission.ts +++ b/packages/server/src/handlers/permission.ts @@ -4,8 +4,8 @@ import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { PermissionNotFoundError } from "../errors" -import { response } from "../groups/location" +import { PermissionNotFoundError, SessionNotFoundError } from "@opencode-ai/protocol/errors" +import { response } from "../location" function missingRequest(id: PermissionV2.ID) { return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` }) @@ -20,6 +20,35 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", return yield* response((yield* PermissionV2.Service).list()) }), ) + .handle( + "session.permission.create", + Effect.fn(function* (ctx) { + const permission = yield* PermissionV2.Service + return { + data: yield* permission + .ask({ + id: ctx.payload.id, + sessionID: ctx.params.sessionID, + action: ctx.payload.action, + resources: ctx.payload.resources, + save: ctx.payload.save, + metadata: ctx.payload.metadata, + source: ctx.payload.source, + agent: ctx.payload.agent, + }) + .pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + } + }), + ) .handle( "session.permission.list", Effect.fn(function* (ctx) { @@ -27,6 +56,14 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", return { data: yield* permission.forSession(ctx.params.sessionID) } }), ) + .handle( + "session.permission.get", + Effect.fn(function* (ctx) { + const request = yield* (yield* PermissionV2.Service).get(ctx.params.requestID) + if (!request || request.sessionID !== ctx.params.sessionID) return yield* missingRequest(ctx.params.requestID) + return { data: request } + }), + ) .handle( "session.permission.reply", Effect.fn(function* (ctx) { diff --git a/packages/server/src/handlers/project-copy.ts b/packages/server/src/handlers/project-copy.ts index 91b48fb1d5..3733db6771 100644 --- a/packages/server/src/handlers/project-copy.ts +++ b/packages/server/src/handlers/project-copy.ts @@ -4,7 +4,7 @@ import { Git } from "@opencode-ai/core/git" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { ProjectCopyError } from "../groups/project-copy" +import { ProjectCopyError } from "@opencode-ai/protocol/groups/project-copy" export const ProjectCopyHandler = HttpApiBuilder.group(Api, "server.projectCopy", (handlers) => Effect.succeed( diff --git a/packages/server/src/handlers/provider.ts b/packages/server/src/handlers/provider.ts index 81e5e93ac7..c3f25ab0df 100644 --- a/packages/server/src/handlers/provider.ts +++ b/packages/server/src/handlers/provider.ts @@ -1,16 +1,9 @@ import { Catalog } from "@opencode-ai/core/catalog" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" -import { ProviderV2 } from "@opencode-ai/core/provider" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { ProviderNotFoundError, ServiceUnavailableError } from "../errors" -import { response } from "../groups/location" - -const catalogUnavailable = new ServiceUnavailableError({ - message: "Provider catalog is unavailable", - service: "catalog", -}) +import { ProviderNotFoundError } from "@opencode-ai/protocol/errors" +import { response } from "../location" export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) => Effect.gen(function* () { @@ -19,8 +12,6 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han "provider.list", Effect.fn(function* () { const catalog = yield* Catalog.Service - const pluginBoot = yield* PluginBoot.Service - yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) return yield* response(catalog.provider.available()) }), ) @@ -28,18 +19,13 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han "provider.get", Effect.fn(function* (ctx) { const catalog = yield* Catalog.Service - const pluginBoot = yield* PluginBoot.Service - yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) - return yield* response(catalog.provider.get(ctx.params.providerID)).pipe( - Effect.catchTag("CatalogV2.ProviderNotFound", (error) => - Effect.fail( - new ProviderNotFoundError({ - providerID: error.providerID, - message: `Provider not found: ${error.providerID}`, - }), - ), - ), - ) + const provider = yield* catalog.provider.get(ctx.params.providerID) + if (!provider) + return yield* new ProviderNotFoundError({ + providerID: ctx.params.providerID, + message: `Provider not found: ${ctx.params.providerID}`, + }) + return yield* response(Effect.succeed(provider)) }), ) }), diff --git a/packages/server/src/handlers/pty.ts b/packages/server/src/handlers/pty.ts index a59afb3b31..cda2cf43e9 100644 --- a/packages/server/src/handlers/pty.ts +++ b/packages/server/src/handlers/pty.ts @@ -8,9 +8,13 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" import { Api } from "../api" import { CorsConfig, isAllowedRequestOrigin } from "../cors" -import { ForbiddenError, PtyNotFoundError } from "../errors" -import { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE } from "../groups/pty" -import { response } from "../groups/location" +import { ForbiddenError, PtyNotFoundError } from "@opencode-ai/protocol/errors" +import { + PTY_CONNECT_TICKET_QUERY, + PTY_CONNECT_TOKEN_HEADER, + PTY_CONNECT_TOKEN_HEADER_VALUE, +} from "@opencode-ai/protocol/groups/pty" +import { response } from "../location" import { PtyEnvironment } from "../pty-environment" const ticketScope = Effect.gen(function* () { diff --git a/packages/server/src/handlers/question.ts b/packages/server/src/handlers/question.ts index 151557c508..954afe0df5 100644 --- a/packages/server/src/handlers/question.ts +++ b/packages/server/src/handlers/question.ts @@ -2,8 +2,8 @@ import { QuestionV2 } from "@opencode-ai/core/question" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { QuestionNotFoundError } from "../errors" -import { response } from "../groups/location" +import { QuestionNotFoundError } from "@opencode-ai/protocol/errors" +import { response } from "../location" function missingRequest(id: QuestionV2.ID) { return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` }) diff --git a/packages/server/src/handlers/reference.ts b/packages/server/src/handlers/reference.ts index 894f76aa8e..543c9790dc 100644 --- a/packages/server/src/handlers/reference.ts +++ b/packages/server/src/handlers/reference.ts @@ -1,7 +1,7 @@ import { Reference } from "@opencode-ai/core/reference" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { response } from "../groups/location" +import { response } from "../location" export const ReferenceHandler = HttpApiBuilder.group(Api, "server.reference", (handlers) => handlers.handle("reference.list", () => response(Reference.Service.use((reference) => reference.list()))), diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 66383cfbff..5b7d354b04 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,18 +1,20 @@ import { SessionV2 } from "@opencode-ai/core/session" -import { DateTime, Effect } from "effect" +import { DateTime, Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { SessionsCursor } from "../groups/session" +import { SessionsCursor } from "@opencode-ai/protocol/groups/session" import { ConflictError, InvalidCursorError, + MessageNotFoundError, ServiceUnavailableError, SessionNotFoundError, UnknownError, -} from "../errors" +} from "@opencode-ai/protocol/errors" import { AbsolutePath } from "@opencode-ai/core/schema" const DefaultSessionsLimit = 50 +const DefaultSessionHistoryLimit = 50 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => Effect.gen(function* () { @@ -75,6 +77,16 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.active", + Effect.fn(function* () { + return { + data: Object.fromEntries( + Array.from(yield* session.active, (sessionID) => [sessionID, { type: "running" as const }]), + ), + } + }), + ) .handle( "session.get", Effect.fn(function* (ctx) { @@ -92,6 +104,38 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.switchAgent", + Effect.fn(function* (ctx) { + yield* session.switchAgent({ sessionID: ctx.params.sessionID, agent: ctx.payload.agent }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.switchModel", + Effect.fn(function* (ctx) { + yield* session.switchModel({ sessionID: ctx.params.sessionID, model: ctx.payload.model }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.prompt", Effect.fn(function* (ctx) { @@ -173,6 +217,90 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl return HttpApiSchema.NoContent.make() }), ) + .handle( + "session.revert.stage", + Effect.fn(function* (ctx) { + return { + data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + Effect.catchTag( + "Session.MessageNotFoundError", + (error) => + new MessageNotFoundError({ + sessionID: error.sessionID, + messageID: error.messageID, + message: `Message not found: ${error.messageID}`, + }), + ), + Effect.catchTag("Snapshot.Error", (error) => { + const ref = `err_${crypto.randomUUID().slice(0, 8)}` + return Effect.logError("failed to stage session revert", { cause: error }).pipe( + Effect.andThen( + Effect.fail( + new UnknownError({ + message: "Unexpected server error. Check server logs for details.", + ref, + }), + ), + ), + ) + }), + ), + } + }), + ) + .handle( + "session.revert.clear", + Effect.fn(function* (ctx) { + yield* session.revert.clear(ctx.params.sessionID).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + Effect.catchTag("Snapshot.Error", (error) => { + const ref = `err_${crypto.randomUUID().slice(0, 8)}` + return Effect.logError("failed to clear session revert", { cause: error }).pipe( + Effect.andThen( + Effect.fail( + new UnknownError({ + message: "Unexpected server error. Check server logs for details.", + ref, + }), + ), + ), + ) + }), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.revert.commit", + Effect.fn(function* (ctx) { + yield* session.revert.commit(ctx.params.sessionID).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.context", Effect.fn(function* (ctx) { @@ -192,10 +320,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }), Effect.andThen( Effect.fail( - new UnknownError({ - message: "Unexpected server error. Check server logs for details.", - ref, - }), + new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }), ), ), ) @@ -204,5 +329,57 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.history", + Effect.fn(function* (ctx) { + return yield* session + .history({ + sessionID: ctx.params.sessionID, + after: ctx.query.after, + limit: ctx.query.limit ?? DefaultSessionHistoryLimit, + }) + .pipe( + Effect.map((page) => ({ + data: page.events, + hasMore: page.hasMore, + })), + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ) + }), + ) + .handle( + "session.events", + Effect.fn((ctx) => + Effect.succeed( + session.events({ sessionID: ctx.params.sessionID, after: ctx.query.after }).pipe(Stream.orDie), + ), + ), + ) + .handle( + "session.interrupt", + Effect.fn(function* (ctx) { + yield* session.interrupt(ctx.params.sessionID) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.message", + Effect.fn(function* (ctx) { + const message = yield* session.message(ctx.params) + if (message) return { data: message } + return yield* new MessageNotFoundError({ + sessionID: ctx.params.sessionID, + messageID: ctx.params.messageID, + message: `Message not found: ${ctx.params.messageID}`, + }) + }), + ) }), ) diff --git a/packages/server/src/handlers/skill.ts b/packages/server/src/handlers/skill.ts index cf3f92c2a8..8ffeaca8ea 100644 --- a/packages/server/src/handlers/skill.ts +++ b/packages/server/src/handlers/skill.ts @@ -1,7 +1,7 @@ import { SkillV2 } from "@opencode-ai/core/skill" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { response } from "../groups/location" +import { response } from "../location" export const SkillHandler = HttpApiBuilder.group(Api, "server.skill", (handlers) => handlers.handle("skill.list", () => response(SkillV2.Service.use((skill) => skill.list()))), diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts new file mode 100644 index 0000000000..ea5191302a --- /dev/null +++ b/packages/server/src/location.ts @@ -0,0 +1,60 @@ +import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap } from "@opencode-ai/core/location-services" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { Effect, Layer } from "effect" +import { HttpServerRequest } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" + +export type LocationServices = Layer.Success> + +export class LocationMiddleware extends HttpApiMiddleware.Service()( + "@opencode/HttpApiLocation", +) {} + +export function response(data: Effect.Effect) { + return Effect.gen(function* () { + const location = yield* Location.Service + return { + location: new Location.Info({ + directory: location.directory, + workspaceID: location.workspaceID, + project: location.project, + }), + data: yield* data, + } + }) +} + +function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref { + const query = new URL(request.url, "http://localhost").searchParams + const workspaceID = query.get("location[workspace]") || request.headers["x-kilo-workspace"] + const directory = + query.get("location[directory]") || + (request.headers["x-kilo-directory"] ? decode(request.headers["x-kilo-directory"]) : process.cwd()) + return Location.Ref.make({ + directory: AbsolutePath.make(directory), + workspaceID: workspaceID ? WorkspaceV2.ID.make(workspaceID) : undefined, + }) +} + +function decode(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +export const layer = Layer.effect( + LocationMiddleware, + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + return LocationMiddleware.of((effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + return yield* effect.pipe(Effect.provide(locations.get(ref(request)))) + }), + ) + }), +) diff --git a/packages/server/src/middleware/authorization.ts b/packages/server/src/middleware/authorization.ts index 5a8dae205d..cc785ee247 100644 --- a/packages/server/src/middleware/authorization.ts +++ b/packages/server/src/middleware/authorization.ts @@ -1,17 +1,14 @@ import { ServerAuth } from "../auth" -import { UnauthorizedError } from "../errors" -import { hasPtyConnectTicketURL } from "../groups/pty" +import { UnauthorizedError } from "@opencode-ai/protocol/errors" +import { Authorization } from "@opencode-ai/protocol/middleware/authorization" +export { Authorization } from "@opencode-ai/protocol/middleware/authorization" +import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApiMiddleware } from "effect/unstable/httpapi" const AUTH_TOKEN_QUERY = "auth_token" const WWW_AUTHENTICATE = 'Basic realm="Secure Area"' -export class Authorization extends HttpApiMiddleware.Service()("@opencode/HttpApiAuthorization", { - error: UnauthorizedError, -}) {} - function emptyCredential() { return { username: "", password: Redacted.make("") } } diff --git a/packages/server/src/middleware/schema-error.ts b/packages/server/src/middleware/schema-error.ts index 8061a524bd..37013285b9 100644 --- a/packages/server/src/middleware/schema-error.ts +++ b/packages/server/src/middleware/schema-error.ts @@ -1,6 +1,8 @@ import { Effect } from "effect" import { HttpApiMiddleware } from "effect/unstable/httpapi" -import { InvalidRequestError } from "../errors" +import { InvalidRequestError } from "@opencode-ai/protocol/errors" +import { SchemaErrorMiddleware } from "@opencode-ai/protocol/middleware/schema-error" +export { SchemaErrorMiddleware } from "@opencode-ai/protocol/middleware/schema-error" const REASON_LIMIT = 1024 @@ -9,11 +11,6 @@ function truncateReason(reason: string) { return reason.slice(0, REASON_LIMIT) + `... (${reason.length - REASON_LIMIT} more chars)` } -export class SchemaErrorMiddleware extends HttpApiMiddleware.Service()( - "@opencode/HttpApiSchemaError", - { error: InvalidRequestError }, -) {} - export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => { const reason = truncateReason(error.cause.message) return Effect.logWarning("schema rejection").pipe( diff --git a/packages/server/src/middleware/session-location.ts b/packages/server/src/middleware/session-location.ts index 7306cf76b8..86fa80f75c 100644 --- a/packages/server/src/middleware/session-location.ts +++ b/packages/server/src/middleware/session-location.ts @@ -1,5 +1,5 @@ import { Database } from "@opencode-ai/core/database/database" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" @@ -9,14 +9,12 @@ import { eq } from "drizzle-orm" import { Effect, Layer, Schema } from "effect" import { HttpRouter } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" -import { InvalidRequestError, SessionNotFoundError } from "../errors" -import type { LocationServices } from "../groups/location" +import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors" +import type { LocationServices } from "../location" export class SessionLocationMiddleware extends HttpApiMiddleware.Service< SessionLocationMiddleware, - { - provides: LocationServices - } + { provides: LocationServices } >()("@opencode/HttpApiSessionLocation", { error: [InvalidRequestError, SessionNotFoundError], }) {} @@ -27,7 +25,7 @@ export const sessionLocationLayer = Layer.effect( SessionLocationMiddleware, Effect.gen(function* () { const { db } = yield* Database.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service return SessionLocationMiddleware.of((effect) => Effect.gen(function* () { diff --git a/packages/server/src/pty-environment.ts b/packages/server/src/pty-environment.ts index fe3a375cdc..3e18a6f925 100644 --- a/packages/server/src/pty-environment.ts +++ b/packages/server/src/pty-environment.ts @@ -1,6 +1,7 @@ export * as PtyEnvironment from "./pty-environment" import { Context, Effect, Layer } from "effect" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" export interface Interface { readonly get: (input: { directory: string; cwd: string }) => Effect.Effect> @@ -8,9 +9,11 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ServerPtyEnvironment") {} -export const defaultLayer = Layer.succeed( +export const layer = Layer.succeed( Service, Service.of({ get: () => Effect.succeed({}), }), ) + +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index da5cda1399..cc1b1ae6a5 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -1,7 +1,17 @@ import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EventV2 } from "@opencode-ai/core/event" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" +import { Credential } from "@opencode-ai/core/credential" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { PtyTicket } from "@opencode-ai/core/pty/ticket" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Layer, Option } from "effect" import { Api } from "./api" @@ -10,22 +20,45 @@ import { handlers } from "./handlers" import { authorizationLayer } from "./middleware/authorization" import { schemaErrorLayer } from "./middleware/schema-error" import { PtyEnvironment } from "./pty-environment" +import { layer as locationLayer } from "./location" +import { sessionLocationLayer } from "./middleware/session-location" + +const applicationServices = LayerNode.group([ + Database.node, + EventV2.node, + httpClient, + ToolOutputStore.cleanupNode, + SessionV2.node, + PermissionSaved.node, + PtyTicket.node, + Credential.node, + PtyEnvironment.node, + LocationServiceMap.node, +]) export function createRoutes(password?: string) { + return makeRoutes( + password + ? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) }) + : ServerAuth.Config.layer, + ) +} + +export function createEmbeddedRoutes() { + return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() })) +} + +function makeRoutes(auth: Layer.Layer) { + const serviceLayer = AppNodeBuilder.build(applicationServices, [[SessionExecution.node, SessionExecutionLocal.node]]) + return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( Layer.provide(handlers), - Layer.provide(PtyEnvironment.defaultLayer), + Layer.provide(sessionLocationLayer), + Layer.provide(locationLayer), Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), - Layer.provide( - password - ? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) }) - : ServerAuth.Config.defaultLayer, - ), - Layer.provide(LocationServiceMap.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(FetchHttpClient.layer), + Layer.provide(auth), + Layer.provide(serviceLayer), ) } diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json new file mode 100644 index 0000000000..258f7e186c --- /dev/null +++ b/packages/session-ui/package.json @@ -0,0 +1,67 @@ +{ + "name": "@opencode-ai/session-ui", + "version": "7.4.16", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + "./*": "./src/components/*.tsx", + "./session-diff": "./src/components/session-diff.ts", + "./message-file": "./src/components/message-file.ts", + "./message-part-text": "./src/components/message-part-text.ts", + "./markdown-stream": "./src/components/markdown-stream.ts", + "./markdown-cache": "./src/components/markdown-cache.tsx", + "./line-comment-styles": "./src/components/line-comment-styles.ts", + "./pierre": "./src/pierre/index.ts", + "./pierre/*": "./src/pierre/*.ts", + "./context": "./src/context/index.ts", + "./context/*": "./src/context/*.tsx", + "./styles": "./src/styles/index.css", + "./v2/*.css": "./src/v2/components/*.css", + "./v2/*": "./src/v2/components/*.tsx" + }, + "scripts": { + "typecheck": "tsgo --noEmit", + "test": "bun test src --only-failures" + }, + "devDependencies": { + "@tsconfig/node22": "catalog:", + "@types/bun": "catalog:", + "@types/katex": "0.16.7", + "@types/luxon": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + }, + "dependencies": { + "@kobalte/core": "catalog:", + "@opencode-ai/core": "workspace:*", + "@kilocode/sdk": "workspace:*", + "@opencode-ai/ui": "workspace:*", + "@pierre/diffs": "catalog:", + "@shikijs/stream": "catalog:", + "@shikijs/transformers": "3.9.2", + "@solid-primitives/bounds": "0.1.3", + "@solid-primitives/event-listener": "2.4.5", + "@solid-primitives/media": "2.3.3", + "@solid-primitives/resize-observer": "2.1.3", + "@solidjs/meta": "catalog:", + "@solidjs/router": "catalog:", + "diff": "catalog:", + "dompurify": "3.3.1", + "fuzzysort": "catalog:", + "katex": "0.16.27", + "luxon": "catalog:", + "marked": "catalog:", + "marked-katex-extension": "5.1.6", + "marked-shiki": "catalog:", + "morphdom": "2.7.8", + "motion": "12.34.5", + "remeda": "catalog:", + "remend": "catalog:", + "shiki": "catalog:", + "solid-js": "catalog:", + "solid-list": "catalog:", + "strip-ansi": "7.1.2" + } +} diff --git a/packages/session-ui/src/components/apply-patch-file.test.ts b/packages/session-ui/src/components/apply-patch-file.test.ts new file mode 100644 index 0000000000..f7a8e77881 --- /dev/null +++ b/packages/session-ui/src/components/apply-patch-file.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { patchFiles } from "./apply-patch-file" +import { text } from "./session-diff" + +describe("apply patch file", () => { + test("parses patch metadata from the server", () => { + const file = patchFiles([ + { + filePath: "/tmp/a.ts", + relativePath: "a.ts", + type: "update", + patch: + "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n", + additions: 1, + deletions: 1, + }, + ])[0] + + expect(file).toBeDefined() + expect(file?.view.fileDiff.name).toBe("a.ts") + expect(file?.view.fileDiff.isPartial).toBe(false) + expect(text(file!.view, "deletions")).toBe("one\ntwo\n") + expect(text(file!.view, "additions")).toBe("one\nthree\n") + }) + + test("keeps legacy before and after payloads working", () => { + const file = patchFiles([ + { + filePath: "/tmp/a.ts", + relativePath: "a.ts", + type: "update", + before: "one\n", + after: "two\n", + additions: 1, + deletions: 1, + }, + ])[0] + + expect(file).toBeDefined() + expect(text(file!.view, "deletions")).toBe("one\n") + expect(text(file!.view, "additions")).toBe("two\n") + }) +}) diff --git a/packages/session-ui/src/components/apply-patch-file.ts b/packages/session-ui/src/components/apply-patch-file.ts new file mode 100644 index 0000000000..8e0c540826 --- /dev/null +++ b/packages/session-ui/src/components/apply-patch-file.ts @@ -0,0 +1,78 @@ +import { normalize, type ViewDiff } from "./session-diff" + +type Kind = "add" | "update" | "delete" | "move" + +type Raw = { + filePath?: string + relativePath?: string + type?: Kind + patch?: string + diff?: string + before?: string + after?: string + additions?: number + deletions?: number + movePath?: string +} + +export type ApplyPatchFile = { + filePath: string + relativePath: string + type: Kind + additions: number + deletions: number + movePath?: string + view: ViewDiff +} + +function kind(value: unknown) { + if (value === "add" || value === "update" || value === "delete" || value === "move") return value +} + +function status(type: Kind): "added" | "deleted" | "modified" { + if (type === "add") return "added" + if (type === "delete") return "deleted" + return "modified" +} + +export function patchFile(raw: unknown): ApplyPatchFile | undefined { + if (!raw || typeof raw !== "object") return + + const value = raw as Raw + const type = kind(value.type) + const filePath = typeof value.filePath === "string" ? value.filePath : undefined + const relativePath = typeof value.relativePath === "string" ? value.relativePath : filePath + const patch = typeof value.patch === "string" ? value.patch : typeof value.diff === "string" ? value.diff : undefined + const before = typeof value.before === "string" ? value.before : undefined + const after = typeof value.after === "string" ? value.after : undefined + + if (!type || !filePath || !relativePath) return + if (!patch && before === undefined && after === undefined) return + + const additions = typeof value.additions === "number" ? value.additions : 0 + const deletions = typeof value.deletions === "number" ? value.deletions : 0 + const movePath = typeof value.movePath === "string" ? value.movePath : undefined + + return { + filePath, + relativePath, + type, + additions, + deletions, + movePath, + view: normalize({ + file: relativePath, + patch, + before, + after, + additions, + deletions, + status: status(type), + }), + } +} + +export function patchFiles(raw: unknown) { + if (!Array.isArray(raw)) return [] + return raw.map(patchFile).filter((file): file is ApplyPatchFile => !!file) +} diff --git a/packages/session-ui/src/components/basic-tool.css b/packages/session-ui/src/components/basic-tool.css new file mode 100644 index 0000000000..7df483afe6 --- /dev/null +++ b/packages/session-ui/src/components/basic-tool.css @@ -0,0 +1,279 @@ +[data-component="tool-trigger"] { + content-visibility: auto; + width: 100%; + display: flex; + align-items: center; + align-self: stretch; + gap: 0px; + justify-content: flex-start; + + &[data-clickable="true"] { + cursor: pointer; + } + + [data-slot="basic-tool-tool-trigger-content"] { + flex: 0 1 auto; + width: auto; + max-width: calc(100% - 24px); + min-width: 0; + display: flex; + align-items: center; + align-self: stretch; + gap: 8px; + } + + [data-slot="basic-tool-tool-indicator"] { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + + [data-slot="basic-tool-tool-spinner"] { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--v2-text-text-faint); + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + + [data-slot="icon-svg"] { + flex-shrink: 0; + } + + [data-slot="basic-tool-tool-info"] { + flex: 0 1 auto; + min-width: 0; + max-width: 100%; + font-size: 14px; + } + + [data-slot="basic-tool-tool-info-structured"] { + flex: 0 1 auto; + width: auto; + max-width: 100%; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 8px; + justify-content: flex-start; + } + + [data-slot="basic-tool-tool-info-main"] { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + overflow: hidden; + } + + [data-slot="basic-tool-tool-title"] { + flex-shrink: 0; + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--v2-text-text-base); + + &.capitalize { + text-transform: capitalize; + } + + &.agent-title { + color: var(--v2-text-text-base); + font-weight: var(--font-weight-medium); + } + } + + [data-slot="basic-tool-tool-subtitle"] { + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-family-sans); + font-variant-numeric: tabular-nums; + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--v2-text-text-muted); + + &.clickable { + cursor: pointer; + text-decoration: underline; + transition: color 0.15s ease; + + &:hover { + color: var(--v2-text-text-muted); + } + } + + &.subagent-link { + color: var(--text-interactive-base); + text-decoration: none; + text-underline-offset: 2px; + font-weight: var(--font-weight-regular); + + &:hover { + color: var(--text-interactive-base); + text-decoration: underline; + } + + &:active { + color: var(--text-interactive-base); + } + + &:visited { + color: var(--text-interactive-base); + } + } + } + + [data-slot="basic-tool-tool-arg"] { + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-family-sans); + font-variant-numeric: tabular-nums; + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--v2-text-text-muted); + } + + [data-slot="basic-tool-tool-action"] { + display: inline-flex; + align-items: center; + flex-shrink: 0; + } +} + +[data-component="task-tool-card"] { + width: 100%; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 6px; + border: 0.5px solid var(--border-weak-base, rgba(255, 255, 255, 0.08)); + background: color-mix(in srgb, var(--background-base) 92%, transparent); + transition: + border-color 0.15s ease, + background-color 0.15s ease, + color 0.15s ease; + + [data-slot="basic-tool-tool-info-structured"] { + flex: 1 1 auto; + min-width: 0; + } + + [data-slot="basic-tool-tool-info-main"] { + flex: 1 1 auto; + min-width: 0; + align-items: center; + } + + [data-component="task-tool-spinner"] { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + + [data-component="task-tool-action"] { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--v2-text-text-faint); + margin-left: auto; + opacity: 0; + transition: + opacity 0.15s ease, + color 0.15s ease; + } + + [data-component="task-tool-title"] { + flex-shrink: 0; + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + text-transform: capitalize; + } + + [data-slot="basic-tool-tool-subtitle"] { + color: var(--v2-text-text-base); + } + + &:hover, + &:focus-visible { + border-color: var(--border-weak-base, rgba(255, 255, 255, 0.08)); + background: color-mix(in srgb, var(--background-stronger) 88%, transparent); + + [data-component="task-tool-action"] { + opacity: 1; + } + } +} + +body:not([data-new-layout]) { + [data-component="tool-trigger"] { + [data-slot="basic-tool-tool-spinner"] { + color: var(--text-weak); + } + + [data-slot="basic-tool-tool-title"], + [data-slot="basic-tool-tool-title"].agent-title { + color: var(--text-strong); + } + + [data-slot="basic-tool-tool-subtitle"], + [data-slot="basic-tool-tool-subtitle"].clickable:hover, + [data-slot="basic-tool-tool-arg"] { + color: var(--text-base); + } + } + + [data-component="task-tool-card"] { + border-width: 1px; + + [data-component="task-tool-action"] { + color: var(--icon-weak); + } + + [data-slot="basic-tool-tool-subtitle"] { + color: var(--text-strong); + } + } +} diff --git a/packages/session-ui/src/components/basic-tool.stories.tsx b/packages/session-ui/src/components/basic-tool.stories.tsx new file mode 100644 index 0000000000..5fb366c016 --- /dev/null +++ b/packages/session-ui/src/components/basic-tool.stories.tsx @@ -0,0 +1,133 @@ +// @ts-nocheck +import { createSignal } from "solid-js" +import * as mod from "./basic-tool" +import { create } from "@opencode-ai/ui/storybook/scaffold" + +const docs = `### Overview +Expandable tool panel with a structured trigger and optional details. + +Use structured triggers for consistent layout; custom triggers allowed. + +### API +- Required: \`icon\` and \`trigger\` (structured or custom JSX). +- Optional: \`status\`, \`defaultOpen\`, \`forceOpen\`, \`defer\`, \`locked\`. + +### Variants and states +- Pending/running status animates the title via TextShimmer. + +### Behavior +- Uses Collapsible; can defer content rendering until open. +- Locked state prevents closing. + +### Accessibility +- TODO: confirm trigger semantics and aria labeling. + +### Theming/tokens +- Uses \`data-component="tool-trigger"\` and related slots. + +` + +const story = create({ + title: "UI/Basic Tool", + mod, + args: { + icon: "mcp", + defaultOpen: true, + trigger: { + title: "Basic Tool", + subtitle: "Example subtitle", + args: ["--flag", "value"], + }, + children: "Details content", + }, +}) + +export default { + title: "UI/Basic Tool", + id: "components-basic-tool", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const Pending = { + args: { + status: "pending", + trigger: { + title: "Running tool", + subtitle: "Working...", + }, + children: "Progress details", + }, +} + +export const Locked = { + args: { + locked: true, + trigger: { + title: "Locked tool", + subtitle: "Cannot close", + }, + children: "Locked details", + }, +} + +export const Deferred = { + args: { + defer: true, + defaultOpen: false, + trigger: { + title: "Deferred tool", + subtitle: "Content mounts on open", + }, + children: "Deferred content", + }, +} + +export const ForceOpen = { + args: { + forceOpen: true, + trigger: { + title: "Forced open", + subtitle: "Cannot close", + }, + children: "Forced content", + }, +} + +export const HideDetails = { + args: { + hideDetails: true, + trigger: { + title: "Summary only", + subtitle: "Details hidden", + }, + children: "Hidden content", + }, +} + +export const SubtitleAction = { + render: () => { + const [message, setMessage] = createSignal("Subtitle not clicked") + return ( +
+
{message()}
+ setMessage("Subtitle clicked")} + > + Subtitle action details + +
+ ) + }, +} diff --git a/packages/session-ui/src/components/basic-tool.tsx b/packages/session-ui/src/components/basic-tool.tsx new file mode 100644 index 0000000000..4f2a4ced6f --- /dev/null +++ b/packages/session-ui/src/components/basic-tool.tsx @@ -0,0 +1,339 @@ +import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type Accessor, type JSX } from "solid-js" +import { animate, type AnimationPlaybackControls } from "motion" +import { useI18n } from "@opencode-ai/ui/context/i18n" +import { createStore } from "solid-js/store" +import { Collapsible } from "@opencode-ai/ui/collapsible" +import type { IconProps } from "@opencode-ai/ui/icon" +import { TextShimmer } from "@opencode-ai/ui/text-shimmer" + +export type TriggerTitle = { + title: string + titleClass?: string + subtitle?: string + subtitleClass?: string + args?: string[] + argsClass?: string + action?: JSX.Element +} + +const isTriggerTitle = (val: any): val is TriggerTitle => { + return ( + typeof val === "object" && val !== null && "title" in val && (typeof Node === "undefined" || !(val instanceof Node)) + ) +} + +export interface BasicToolProps { + icon: IconProps["name"] + trigger: TriggerTitle | JSX.Element | ((open: Accessor) => JSX.Element) + children?: JSX.Element + status?: string + hideDetails?: boolean + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + forceOpen?: boolean + defer?: boolean + locked?: boolean + animated?: boolean + onSubtitleClick?: () => void + onTriggerClick?: JSX.EventHandlerUnion + triggerHref?: string + clickable?: boolean +} + +const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 } +const deferredMounts: Array<{ active: boolean; fn: () => void }> = [] +let deferredFrame: number | undefined + +function flushDeferredMounts() { + while (deferredMounts.length > 0) { + // Timeline tools are mounted top-to-bottom, but the viewport starts at the latest turn. + // Pop from the end so heavy default-open bodies near the bottom become interactive first. + const item = deferredMounts.pop()! + if (item.active) { + deferredFrame = deferredMounts.length > 0 ? requestAnimationFrame(flushDeferredMounts) : undefined + item.fn() + return + } + } + deferredFrame = undefined +} + +function scheduleDeferredFlush() { + if (deferredFrame !== undefined) return + deferredFrame = requestAnimationFrame(() => { + deferredFrame = requestAnimationFrame(flushDeferredMounts) + }) +} + +function scheduleDeferredMount(fn: () => void) { + const item = { active: true, fn } + deferredMounts.push(item) + scheduleDeferredFlush() + return () => { + item.active = false + } +} + +function scheduleFrameMount(fn: () => void) { + const frame = requestAnimationFrame(fn) + return () => cancelAnimationFrame(frame) +} + +export function BasicTool(props: BasicToolProps) { + const [state, setState] = createStore({ + open: props.defaultOpen ?? false, + ready: !props.defer && (props.defaultOpen ?? false), + }) + const open = () => props.open ?? state.open + const ready = () => state.ready + const pending = () => props.status === "pending" || props.status === "running" + const hasChildren = () => (props.defer ? "children" in props : props.children) + const dynamicTrigger = typeof props.trigger === "function" ? props.trigger(open) : undefined + + let cancelReady: (() => void) | undefined + + const cancel = () => { + cancelReady?.() + cancelReady = undefined + } + + const scheduleReady = (initial = false) => { + cancel() + cancelReady = (initial ? scheduleDeferredMount : scheduleFrameMount)(() => { + cancelReady = undefined + if (!open()) return + setState("ready", true) + }) + } + + onCleanup(cancel) + + onMount(() => { + if (props.defer && open()) scheduleReady(true) + }) + + const setOpen = (value: boolean) => { + if (props.open === undefined) setState("open", value) + props.onOpenChange?.(value) + } + + createEffect(() => { + if (!props.forceOpen) return + if (open()) return + setOpen(true) + }) + + createEffect( + on( + open, + (value) => { + if (!props.defer) return + if (!value) { + cancel() + setState("ready", false) + return + } + + scheduleReady() + }, + { defer: true }, + ), + ) + + // Animated height for collapsible open/close + let contentRef: HTMLDivElement | undefined + let heightAnim: AnimationPlaybackControls | undefined + const initialOpen = open() + + createEffect( + on( + open, + (isOpen) => { + if (!props.animated || !contentRef) return + heightAnim?.stop() + if (isOpen) { + contentRef.style.overflow = "hidden" + heightAnim = animate(contentRef, { height: "auto" }, SPRING) + void heightAnim.finished.then(() => { + if (!contentRef || !open()) return + contentRef.style.overflow = "visible" + contentRef.style.height = "auto" + }) + } else { + contentRef.style.overflow = "hidden" + heightAnim = animate(contentRef, { height: "0px" }, SPRING) + } + }, + { defer: true }, + ), + ) + + onCleanup(() => { + heightAnim?.stop() + }) + + const handleOpenChange = (value: boolean) => { + if (pending()) return + if (props.locked && !value) return + setOpen(value) + } + + const trigger = () => ( +
+
+
+ + {dynamicTrigger} + + {(title) => ( +
+
+ + + + + + { + if (props.onSubtitleClick) { + e.stopPropagation() + props.onSubtitleClick() + } + }} + > + {title().subtitle} + + + + + {(arg) => ( + + {arg} + + )} + + + +
+ + {title().action} + +
+ )} +
+ {props.trigger as JSX.Element} +
+
+
+ + + +
+ ) + + return ( + + + {trigger()} + + } + > + {(href) => ( + + {trigger()} + + )} + + +
+ {props.children} +
+
+ + + {props.children} + + +
+ ) +} + +function label(input: Record | undefined) { + const keys = ["description", "query", "url", "filePath", "path", "pattern", "name"] + return keys.map((key) => input?.[key]).find((value): value is string => typeof value === "string" && value.length > 0) +} + +function args(input: Record | undefined) { + if (!input) return [] + const skip = new Set(["description", "query", "url", "filePath", "path", "pattern", "name"]) + return Object.entries(input) + .filter(([key]) => !skip.has(key)) + .flatMap(([key, value]) => { + if (typeof value === "string") return [`${key}=${value}`] + if (typeof value === "number") return [`${key}=${value}`] + if (typeof value === "boolean") return [`${key}=${value}`] + return [] + }) + .slice(0, 3) +} + +export function GenericTool(props: { + tool: string + status?: string + hideDetails?: boolean + input?: Record +}) { + const i18n = useI18n() + + return ( + + ) +} diff --git a/packages/session-ui/src/components/dock-prompt.stories.tsx b/packages/session-ui/src/components/dock-prompt.stories.tsx new file mode 100644 index 0000000000..f75b04bc21 --- /dev/null +++ b/packages/session-ui/src/components/dock-prompt.stories.tsx @@ -0,0 +1,62 @@ +// @ts-nocheck +import * as mod from "./dock-prompt" +import { create } from "@opencode-ai/ui/storybook/scaffold" + +const docs = `### Overview +Docked prompt layout for questions and permission requests. + +Use with form controls or confirmation buttons in the footer. + +### API +- Required: \`kind\` (question | permission), \`header\`, \`children\`, \`footer\`. +- Optional: \`ref\` for measuring or focus management. + +### Variants and states +- Question and permission layouts (data attributes). + +### Behavior +- Pure layout component; behavior handled by parent. + +### Accessibility +- Ensure header and footer content provide clear context and actions. + +### Theming/tokens +- Uses \`data-component="dock-prompt"\` with kind data attribute. + +` + +const story = create({ + title: "UI/DockPrompt", + mod, + args: { + kind: "question", + header: "Header", + children: "Prompt content", + footer: "Footer", + }, +}) + +export default { + title: "UI/DockPrompt", + id: "components-dock-prompt", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const Permission = { + args: { + kind: "permission", + header: "Allow access?", + children: "This action needs permission to proceed.", + footer: "Approve or deny", + }, +} diff --git a/packages/session-ui/src/components/dock-prompt.tsx b/packages/session-ui/src/components/dock-prompt.tsx new file mode 100644 index 0000000000..75563e47b8 --- /dev/null +++ b/packages/session-ui/src/components/dock-prompt.tsx @@ -0,0 +1,23 @@ +import type { JSX } from "solid-js" +import { DockShell, DockTray } from "@opencode-ai/ui/dock-surface" + +export function DockPrompt(props: { + kind: "question" | "permission" + header: JSX.Element + children: JSX.Element + footer: JSX.Element + ref?: (el: HTMLDivElement) => void + onKeyDown?: JSX.EventHandlerUnion +}) { + const slot = (name: string) => `${props.kind}-${name}` + + return ( +
+ +
{props.header}
+
{props.children}
+
+ {props.footer} +
+ ) +} diff --git a/packages/session-ui/src/components/file-media.tsx b/packages/session-ui/src/components/file-media.tsx new file mode 100644 index 0000000000..f4dbb40a2f --- /dev/null +++ b/packages/session-ui/src/components/file-media.tsx @@ -0,0 +1,267 @@ +import type { FileContent } from "@kilocode/sdk/v2" +import { createEffect, createMemo, createResource, Match, on, Show, Switch, type JSX } from "solid-js" +import { useI18n } from "@opencode-ai/ui/context/i18n" +import { + dataUrlFromMediaValue, + hasMediaValue, + isBinaryContent, + mediaKindFromPath, + normalizeMimeType, + svgTextFromValue, +} from "../pierre/media" + +export type FileMediaOptions = { + mode?: "auto" | "off" + path?: string + current?: unknown + before?: unknown + after?: unknown + deleted?: boolean + readFile?: (path: string) => Promise + onLoad?: () => void + onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void +} + +function mediaValue(cfg: FileMediaOptions, mode: "image" | "audio") { + if (cfg.current !== undefined) return cfg.current + if (mode === "image") return cfg.after ?? cfg.before + return cfg.after ?? cfg.before +} + +export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX.Element }) { + const i18n = useI18n() + const cfg = () => props.media + const kind = createMemo(() => { + const media = cfg() + if (!media || media.mode === "off") return + return mediaKindFromPath(media.path) + }) + + const isBinary = createMemo(() => { + const media = cfg() + if (!media || media.mode === "off") return false + if (kind()) return false + return isBinaryContent(media.current as any) + }) + + const onLoad = () => props.media?.onLoad?.() + + const deleted = createMemo(() => { + const media = cfg() + const k = kind() + if (!media || !k) return false + if (media.deleted) return true + if (k === "svg") return false + if (media.current !== undefined) return false + return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any) + }) + + const direct = createMemo(() => { + const media = cfg() + const k = kind() + if (!media || (k !== "image" && k !== "audio")) return + return dataUrlFromMediaValue(mediaValue(media, k), k) + }) + + const request = createMemo(() => { + const media = cfg() + const k = kind() + if (!media || (k !== "image" && k !== "audio")) return + if (media.current !== undefined) return + if (deleted()) return + if (direct()) return + if (!media.path || !media.readFile) return + + return { + key: `${k}:${media.path}`, + kind: k, + path: media.path, + readFile: media.readFile, + onError: media.onError, + } + }) + + const [loaded] = createResource(request, async (input) => { + return input.readFile(input.path).then( + (result) => { + const src = dataUrlFromMediaValue(result as any, input.kind) + if (!src) { + input.onError?.({ kind: input.kind }) + return { key: input.key, error: true as const } + } + + return { + key: input.key, + src, + mime: input.kind === "audio" ? normalizeMimeType(result?.mimeType) : undefined, + } + }, + () => { + input.onError?.({ kind: input.kind }) + return { key: input.key, error: true as const } + }, + ) + }) + + const remote = createMemo(() => { + const input = request() + const value = loaded() + if (!input || !value || value.key !== input.key) return + return value + }) + + const src = createMemo(() => { + const value = remote() + return direct() ?? (value && "src" in value ? value.src : undefined) + }) + const status = createMemo(() => { + if (direct()) return "ready" as const + if (!request()) return "idle" as const + if (loaded.loading) return "loading" as const + if (remote()?.error) return "error" as const + if (src()) return "ready" as const + return "idle" as const + }) + const audioMime = createMemo(() => { + const value = remote() + return value && "mime" in value ? value.mime : undefined + }) + + const svgSource = createMemo(() => { + const media = cfg() + if (!media || kind() !== "svg") return + return svgTextFromValue(media.current as any) + }) + const svgSrc = createMemo(() => { + const media = cfg() + if (!media || kind() !== "svg") return + return dataUrlFromMediaValue(media.current as any, "svg") + }) + const svgInvalid = createMemo(() => { + const media = cfg() + if (!media || kind() !== "svg") return + if (svgSource() !== undefined) return + if (!hasMediaValue(media.current as any)) return + return [media.path, media.current] as const + }) + + createEffect( + on( + svgInvalid, + (value) => { + if (!value) return + cfg()?.onError?.({ kind: "svg" }) + }, + { defer: true }, + ), + ) + + const kindLabel = (value: "image" | "audio") => + i18n.t(value === "image" ? "ui.fileMedia.kind.image" : "ui.fileMedia.kind.audio") + + return ( + + + { + const media = cfg() + const k = kind() + if (!media || (k !== "image" && k !== "audio")) return props.fallback() + const label = kindLabel(k) + + if (deleted()) { + return ( +
+ {i18n.t("ui.fileMedia.state.removed", { kind: label })} +
+ ) + } + if (status() === "loading") { + return ( +
+ {i18n.t("ui.fileMedia.state.loading", { kind: label })} +
+ ) + } + if (status() === "error") { + return ( +
+ {i18n.t("ui.fileMedia.state.error", { kind: label })} +
+ ) + } + return ( +
+ {i18n.t("ui.fileMedia.state.unavailable", { kind: label })} +
+ ) + })()} + > + {(value) => { + const k = kind() + if (k !== "image" && k !== "audio") return props.fallback() + if (k === "image") { + return ( +
+ {cfg()?.path} +
+ ) + } + + return ( +
+ +
+ ) + }} +
+
+ + {(() => { + if (svgSource() === undefined && svgSrc() == null) return props.fallback() + + return ( +
+ {props.fallback()} + + {(value) => ( +
+ {cfg()?.path} +
+ )} +
+
+ ) + })()} +
+ +
+
+ {cfg()?.path?.split("/").pop() ?? i18n.t("ui.fileMedia.binary.title")} +
+
+ {(() => { + const path = cfg()?.path + if (!path) return i18n.t("ui.fileMedia.binary.description.default") + return i18n.t("ui.fileMedia.binary.description.path", { path }) + })()} +
+
+
+ {props.fallback()} +
+ ) +} diff --git a/packages/session-ui/src/components/file-search.tsx b/packages/session-ui/src/components/file-search.tsx new file mode 100644 index 0000000000..181ff9fea8 --- /dev/null +++ b/packages/session-ui/src/components/file-search.tsx @@ -0,0 +1,72 @@ +import { Portal } from "solid-js/web" +import { useI18n } from "@opencode-ai/ui/context/i18n" +import { Icon } from "@opencode-ai/ui/icon" + +export function FileSearchBar(props: { + pos: () => { top: number; right: number } + query: () => string + index: () => number + count: () => number + setInput: (el: HTMLInputElement) => void + onInput: (value: string) => void + onKeyDown: (event: KeyboardEvent) => void + onClose: () => void + onPrev: () => void + onNext: () => void +}) { + const i18n = useI18n() + + return ( + +
e.stopPropagation()} + > + + props.onInput(e.currentTarget.value)} + onKeyDown={(e) => props.onKeyDown(e as KeyboardEvent)} + /> +
+ {props.count() ? `${props.index() + 1}/${props.count()}` : "0/0"} +
+
+ + +
+ +
+
+ ) +} diff --git a/packages/session-ui/src/components/file-ssr.tsx b/packages/session-ui/src/components/file-ssr.tsx new file mode 100644 index 0000000000..68b5568edc --- /dev/null +++ b/packages/session-ui/src/components/file-ssr.tsx @@ -0,0 +1,197 @@ +import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs" +import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" +import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js" +import { Dynamic, isServer } from "solid-js/web" +import { useWorkerPool } from "@opencode-ai/ui/context/worker-pool" +import { createDefaultOptions, styleVariables } from "../pierre" +import { markCommentedDiffLines } from "../pierre/commented-lines" +import { fixDiffSelection } from "../pierre/diff-selection" +import { + applyViewerScheme, + clearReadyWatcher, + createReadyWatcher, + notifyShadowReady, + observeViewerScheme, +} from "../pierre/file-runtime" +import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer" +import { File, type DiffFileProps, type FileProps } from "./file" + +type DiffPreload = PreloadMultiFileDiffResult | PreloadFileDiffResult + +type SSRDiffFileProps = DiffFileProps & { + preloadedDiff: DiffPreload +} + +function DiffSSRViewer(props: SSRDiffFileProps) { + let container!: HTMLDivElement + let fileDiffRef!: HTMLElement + let fileDiffInstance: FileDiff | undefined + let sharedVirtualizer: NonNullable> | undefined + + const ready = createReadyWatcher() + const workerPool = useWorkerPool(props.diffStyle) + + const [local, others] = splitProps(props, [ + "mode", + "media", + "fileDiff", + "before", + "after", + "class", + "classList", + "annotations", + "selectedLines", + "commentedLines", + "onLineSelected", + "onLineSelectionEnd", + "onLineNumberSelectionEnd", + "onRendered", + "preloadedDiff", + ]) + + const getRoot = () => fileDiffRef?.shadowRoot ?? undefined + + const getVirtualizer = () => { + if (sharedVirtualizer) return sharedVirtualizer.virtualizer + const result = acquireVirtualizer(container) + if (!result) return + sharedVirtualizer = result + return result.virtualizer + } + + const setSelectedLines = (range: DiffFileProps["selectedLines"], attempt = 0) => { + const diff = fileDiffInstance + if (!diff) return + + const fixed = fixDiffSelection(getRoot(), range ?? null) + if (fixed === undefined) { + if (attempt >= 120) return + requestAnimationFrame(() => setSelectedLines(range ?? null, attempt + 1)) + return + } + + diff.setSelectedLines(fixed) + } + + const notifyRendered = () => { + notifyShadowReady({ + state: ready, + container, + getRoot, + isReady: (root) => root.querySelector("[data-line]") != null, + settleFrames: 1, + onReady: () => { + setSelectedLines(local.selectedLines ?? null) + local.onRendered?.() + }, + }) + } + + onMount(() => { + if (isServer) return + + onCleanup(observeViewerScheme(() => fileDiffRef)) + + const virtualizer = getVirtualizer() + const annotations = local.annotations ?? local.preloadedDiff.annotations ?? [] + fileDiffInstance = virtualizer + ? new VirtualizedFileDiff( + { + ...createDefaultOptions(props.diffStyle), + ...others, + ...local.preloadedDiff.options, + }, + virtualizer, + virtualMetrics, + workerPool, + ) + : new FileDiff( + { + ...createDefaultOptions(props.diffStyle), + ...others, + ...local.preloadedDiff.options, + }, + workerPool, + ) + + applyViewerScheme(fileDiffRef) + + // @ts-expect-error private field required for hydration + fileDiffInstance.fileContainer = fileDiffRef + fileDiffInstance.hydrate( + local.fileDiff + ? { + fileDiff: local.fileDiff, + lineAnnotations: annotations, + fileContainer: fileDiffRef, + containerWrapper: container, + prerenderedHTML: local.preloadedDiff.prerenderedHTML, + } + : { + oldFile: local.before + ? { ...local.before, contents: typeof local.before.contents === "string" ? local.before.contents : "" } + : local.before, + newFile: local.after + ? { ...local.after, contents: typeof local.after.contents === "string" ? local.after.contents : "" } + : local.after, + lineAnnotations: annotations, + fileContainer: fileDiffRef, + containerWrapper: container, + prerenderedHTML: local.preloadedDiff.prerenderedHTML, + }, + ) + + notifyRendered() + }) + + createEffect(() => { + const diff = fileDiffInstance + if (!diff) return + diff.setLineAnnotations(local.annotations ?? []) + diff.rerender() + }) + + createEffect(() => { + setSelectedLines(local.selectedLines ?? null) + }) + + createEffect(() => { + const ranges = local.commentedLines ?? [] + requestAnimationFrame(() => { + const root = getRoot() + if (!root) return + markCommentedDiffLines(root, ranges) + }) + }) + + onCleanup(() => { + clearReadyWatcher(ready) + fileDiffInstance?.cleanUp() + sharedVirtualizer?.release() + sharedVirtualizer = undefined + }) + + return ( +
+ + +