diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 96234eb25d..da82bde2e1 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report an issue that should be fixed +description: Report an issue that should be fixed (avoid pasting giant AI generated summaries or your issue may be closed/ignored) body: - type: textarea id: description diff --git a/.opencode/skills/improve-codebase-architecture/DEEPENING.md b/.opencode/skills/improve-codebase-architecture/DEEPENING.md deleted file mode 100644 index c52fdfd99f..0000000000 --- a/.opencode/skills/improve-codebase-architecture/DEEPENING.md +++ /dev/null @@ -1,37 +0,0 @@ -# Deepening - -How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**. - -## Dependency categories - -When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam. - -### 1. In-process - -Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed. - -### 2. Local-substitutable - -Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface. - -### 3. Remote but owned (Ports & Adapters) - -Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter. - -Recommendation shape: _"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."_ - -### 4. True external (Mock) - -Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter. - -## Seam discipline - -- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection. -- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them. - -## Testing strategy: replace, don't layer - -- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them. -- Write new tests at the deepened module's interface. The **interface is the test surface**. -- Tests assert on observable outcomes through the interface, not internal state. -- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface. diff --git a/.opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md b/.opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md deleted file mode 100644 index 3197723a0d..0000000000 --- a/.opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +++ /dev/null @@ -1,44 +0,0 @@ -# Interface Design - -When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best. - -Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**, **leverage**. - -## Process - -### 1. Frame the problem space - -Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate: - -- The constraints any new interface would need to satisfy -- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md)) -- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete - -Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel. - -### 2. Spawn sub-agents - -Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module. - -Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint: - -- Agent 1: "Minimize the interface — aim for 1–3 entry points max. Maximise leverage per entry point." -- Agent 2: "Maximise flexibility — support many use cases and extension." -- Agent 3: "Optimise for the most common caller — make the default case trivial." -- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies." - -Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language. - -Each sub-agent outputs: - -1. Interface (types, methods, params — plus invariants, ordering, error modes) -2. Usage example showing how callers use it -3. What the implementation hides behind the seam -4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md)) -5. Trade-offs — where leverage is high, where it's thin - -### 3. Present and compare - -Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**. - -After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu. diff --git a/.opencode/skills/improve-codebase-architecture/LANGUAGE.md b/.opencode/skills/improve-codebase-architecture/LANGUAGE.md deleted file mode 100644 index dd9b60fea0..0000000000 --- a/.opencode/skills/improve-codebase-architecture/LANGUAGE.md +++ /dev/null @@ -1,53 +0,0 @@ -# Language - -Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point. - -## Terms - -**Module** -Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, class, package, or tier-spanning slice. -_Avoid_: unit, component, service. - -**Interface** -Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics. -_Avoid_: API, signature (too narrow — those refer only to the type-level surface). - -**Implementation** -What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise. - -**Depth** -Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation. - -**Seam** _(from Michael Feathers)_ -A place where you can alter behaviour without editing in that place. The _location_ at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it. -_Avoid_: boundary (overloaded with DDD's bounded context). - -**Adapter** -A concrete thing that satisfies an interface at a seam. Describes _role_ (what slot it fills), not substance (what's inside). - -**Leverage** -What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests. - -**Locality** -What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere. - -## Principles - -- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface. -- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep. -- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test _past_ the interface, the module is probably the wrong shape. -- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it. - -## Relationships - -- A **Module** has exactly one **Interface** (the surface it presents to callers and tests). -- **Depth** is a property of a **Module**, measured against its **Interface**. -- A **Seam** is where a **Module**'s **Interface** lives. -- An **Adapter** sits at a **Seam** and satisfies the **Interface**. -- **Depth** produces **Leverage** for callers and **Locality** for maintainers. - -## Rejected framings - -- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead. -- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know. -- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**. diff --git a/.opencode/skills/improve-codebase-architecture/SKILL.md b/.opencode/skills/improve-codebase-architecture/SKILL.md deleted file mode 100644 index 05984a6096..0000000000 --- a/.opencode/skills/improve-codebase-architecture/SKILL.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -name: improve-codebase-architecture -description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable. ---- - -# Improve Codebase Architecture - -Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability. - -## Glossary - -Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md). - -- **Module** — anything with an interface and an implementation (function, class, package, slice). -- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature. -- **Implementation** — the code inside. -- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation. -- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. (Use this, not "boundary.") -- **Adapter** — a concrete thing satisfying an interface at a seam. -- **Leverage** — what callers get from depth. -- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place. - -Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list): - -- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep. -- **The interface is the test surface.** -- **One adapter = hypothetical seam. Two adapters = real seam.** - -This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate. - -## Process - -### 1. Explore - -Read the project's domain glossary and any ADRs in the area you're touching first. - -Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction: - -- Where does understanding one concept require bouncing between many small modules? -- Where are modules **shallow** — interface nearly as complex as the implementation? -- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)? -- Where do tightly-coupled modules leak across their seams? -- Which parts of the codebase are untested, or hard to test through their current interface? - -Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want. - -### 2. Present candidates - -Present a numbered list of deepening opportunities. For each candidate: - -- **Files** — which files/modules are involved -- **Problem** — why the current architecture is causing friction -- **Solution** — plain English description of what would change -- **Benefits** — explained in terms of locality and leverage, and also in how tests would improve - -**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." - -**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids. - -Do NOT propose interfaces yet. Ask the user: "Which of these would you like to explore?" - -### 3. Grilling loop - -Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive. - -Side effects happen inline as decisions crystallize: - -- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist. -- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. -- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md). -- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md). diff --git a/bun.lock b/bun.lock index 9cdf59bf28..d787d6f55d 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -121,7 +121,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -148,7 +148,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -170,7 +170,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -194,7 +194,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -214,7 +214,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.15.11", + "version": "1.15.12", "bin": { "opencode": "./bin/opencode", }, @@ -252,7 +252,7 @@ "ai-gateway-provider": "3.1.2", "cross-spawn": "catalog:", "effect": "catalog:", - "gitlab-ai-provider": "6.7.0", + "gitlab-ai-provider": "6.8.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "immer": "11.1.4", @@ -276,7 +276,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@zip.js/zip.js": "2.7.62", "drizzle-orm": "catalog:", @@ -313,12 +313,12 @@ "zod-openapi": "5.4.6", }, "optionalDependencies": { - "@lydell/node-pty-darwin-arm64": "1.2.0-beta.10", - "@lydell/node-pty-darwin-x64": "1.2.0-beta.10", - "@lydell/node-pty-linux-arm64": "1.2.0-beta.10", - "@lydell/node-pty-linux-x64": "1.2.0-beta.10", - "@lydell/node-pty-win32-arm64": "1.2.0-beta.10", - "@lydell/node-pty-win32-x64": "1.2.0-beta.10", + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", + "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", + "@lydell/node-pty-linux-x64": "1.2.0-beta.12", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", + "@lydell/node-pty-win32-x64": "1.2.0-beta.12", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", @@ -331,7 +331,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -345,7 +345,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -375,7 +375,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -391,7 +391,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -404,7 +404,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -422,7 +422,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.15.11", + "version": "1.15.12", "bin": { "opencode": "./bin/opencode", }, @@ -479,6 +479,7 @@ "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", + "@types/ws": "8.18.1", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", "ai-gateway-provider": "3.1.2", @@ -492,7 +493,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.7.0", + "gitlab-ai-provider": "6.8.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -520,6 +521,7 @@ "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "which": "6.0.1", + "ws": "8.21.0", "xdg-basedir": "5.1.0", "yargs": "18.0.0", "zod": "catalog:", @@ -560,7 +562,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -576,9 +578,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.15", - "@opentui/keymap": ">=0.2.15", - "@opentui/solid": ">=0.2.15", + "@opentui/core": ">=0.2.16", + "@opentui/keymap": ">=0.2.16", + "@opentui/solid": ">=0.2.16", }, "optionalPeers": [ "@opentui/core", @@ -598,7 +600,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "cross-spawn": "catalog:", }, @@ -613,7 +615,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -626,8 +628,9 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { + "@ibm/plex": "6.4.1", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -637,6 +640,7 @@ "effect": "catalog:", "nitro": "3.0.1-alpha.1", "solid-js": "catalog:", + "sst": "catalog:", "vite": "catalog:", }, "devDependencies": { @@ -649,7 +653,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -668,7 +672,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -708,7 +712,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -757,7 +761,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -821,13 +825,13 @@ "@effect/sql-sqlite-bun": "4.0.0-beta.66", "@hono/zod-validator": "0.4.2", "@kobalte/core": "0.13.11", - "@lydell/node-pty": "1.2.0-beta.10", + "@lydell/node-pty": "1.2.0-beta.12", "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.15", - "@opentui/keymap": "0.2.15", - "@opentui/solid": "0.2.15", + "@opentui/core": "0.2.16", + "@opentui/keymap": "0.2.16", + "@opentui/solid": "0.2.16", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1505,19 +1509,19 @@ "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], - "@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.10", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.10", "@lydell/node-pty-darwin-x64": "1.2.0-beta.10", "@lydell/node-pty-linux-arm64": "1.2.0-beta.10", "@lydell/node-pty-linux-x64": "1.2.0-beta.10", "@lydell/node-pty-win32-arm64": "1.2.0-beta.10", "@lydell/node-pty-win32-x64": "1.2.0-beta.10" } }, "sha512-Fv+A3+MZVA8qhkBIZsM1E6dCdHNMyXXz22mAYiMWd03LlyK///F3OH6CKPX9mj4id7LUlxpr45yPzyBVy9aDPw=="], + "@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.12", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", "@lydell/node-pty-linux-x64": "1.2.0-beta.12", "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", "@lydell/node-pty-win32-x64": "1.2.0-beta.12" } }, "sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g=="], - "@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.2.0-beta.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-C+eqDyRNHRYvx7RaHj6VVCx6nCpRBPuuxhTcc3JH3GuBMoxTsYeY4GkWH2XOktrgbAq1BG8e/Y8bu/wNQreCEw=="], + "@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.2.0-beta.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tqaifcY9Cr41SblO1+FLzh8oxxtkNhuW9Dhl22lKme9BreYvKvxEZcdPIXTuqkJc5tagOEC4QHShKmJjLyLXLQ=="], - "@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.2.0-beta.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-aZoIK6HtJO5BiT4ELm683U4dyHtt8b7wNgq3NJqYAQwSXrcPv576Z8vY3BIulVxfcFkht/SPLKou9TtdFXdNpg=="], + "@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.2.0-beta.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-4LrS5pCJwqHKDVf1zS2gyNV0m4hKAXch+XZNhbZ6LY8uwVL8BhchzQBO40Os5anuRxRCWzHpw4Sp64Ie8q7E4Q=="], - "@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.2.0-beta.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-0cKX2iMyXFNBE4fGtGK6B7IkdXcDMZajyEDoGMOgQQs/DDtoI5tSPcBcqNY9VitVrsRQA8+gFt6eKYU9Ye/lUA=="], + "@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.2.0-beta.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-Sx+A71x5BDGHt9ansfrtGxwq2VFVDWvJUAdlUL0Hv0qeiJUfts+hgopx+CgT4PSwahKjdEgtu0+FAfY9rICKRw=="], - "@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.2.0-beta.10", "", { "os": "linux", "cpu": "x64" }, "sha512-J9HnxvSzEeMH748+Ul1VrmCLWMo7iCVJy9EGijRR62+YO/Yk5GaCydUTZ+KzlH0/X5aTrgt5cfiof4vx45tRRg=="], + "@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.2.0-beta.12", "", { "os": "linux", "cpu": "x64" }, "sha512-bJzs94njofYhGg/UDqW1nj0dtvvu+2OvxMY+RlLS1T17VgcktKoIR6PuenTwE5HJ/D6StCPADmXcT0nNsCKmIQ=="], - "@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.2.0-beta.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-PlDJpJX/pnKyy6OmADKzhf+INZDDnzTBGaI0LT4laVNc6NblZNqUSkCMjLFWbeakeuQp0VG37M49WQSN9FDfeA=="], + "@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.2.0-beta.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-p7POgjVEiFaBC3/y+AKuV1FzePCsJ6HmZDv2XK+jBZSfwP8+uBAw181ZiKYN1YuRa/XpmBGaWezcI8hZkbW++g=="], - "@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.2.0-beta.10", "", { "os": "win32", "cpu": "x64" }, "sha512-ExFgWrzyldNAMi45U9PLIOu+g/RatP+f0c/dZxaooifME6yLW32BoHveH26/TtoAjZyJrc2iL0u48pgnR1fzmg=="], + "@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.2.0-beta.12", "", { "os": "win32", "cpu": "x64" }, "sha512-IDFa00g7qUDGUYgByrUBJtC+mOjYVt/8KYyWivCg5JjGOHbBUACUQZLl0jTWmnr+tld/UyTpX90a2PY6oTVtRw=="], "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], @@ -1717,23 +1721,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.15", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.15", "@opentui/core-darwin-x64": "0.2.15", "@opentui/core-linux-arm64": "0.2.15", "@opentui/core-linux-x64": "0.2.15", "@opentui/core-win32-arm64": "0.2.15", "@opentui/core-win32-x64": "0.2.15" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-YGHttdZWScMcSvtYgZkLR6VhUO1OoUiQzwYjZgIusf5eCkPLD8PapH+PTMVqAiX16CHO6JxfMlkHv5qDiHAccQ=="], + "@opentui/core": ["@opentui/core@0.2.16", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.16", "@opentui/core-darwin-x64": "0.2.16", "@opentui/core-linux-arm64": "0.2.16", "@opentui/core-linux-x64": "0.2.16", "@opentui/core-win32-arm64": "0.2.16", "@opentui/core-win32-x64": "0.2.16" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-4vWN15Zc3nsXJlOiHhhpqkBXD+wrNFKxCPtiTiillZYDRre+XsZogVTOOGUDwaBIC23OSxq7imezLmmtShVBEA=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aFb2Yp+oqDu3h6VCWi7xpQ9yjpKSQcROzGGfHgqC6Nd3U+uiLfPJBkmiI87iK0opCggCFj5TkKI004050DmGjg=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KimiHE0j7EsTB5P8doW0lr1eH5iZKLPKWQO+tmy1VcdYr/TzqhdHSvGuJXrZvfTFi9/rV57Eq0d7964Ri9O0vQ=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-4fCwRCfTtUgS/5QcSEkSuBjgQymSOUWXgrXG2ycrf3Swi0QhKDA/pVjwLrUJ6eF+/8mQyQSEV72T8MxMO3M2qg=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-KgQBGjiucw4e7gM+R8qOzHWBFhjCY1IfCrGjW3Wzxv2hKUlL+mPhelaeJwnEqtNxMUdVTYjlwlu3IHxslXMJWQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-C6WqEI3VkXatXraMgSFXZjEXq0pzURGjRpFAJZYmuVDmpqE57o7E80Np2UkdZ6m5kpJDt4mRyu3krc/P825iNQ=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SVMVgnC7LVEm+yVZKdmmhRBj/xAT94PanT+UCcHxaCWK+OLmv/AX+ohHq2m0odup6iXcEqj+7mAltO9fgJLFIg=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-kCX3CMTns6DMCFDNTDV4sjmBKyA/iEvzaVhl/jYi4JRIVT2zcy1lo+lhXT5mPgYHmJZu8Uye6j3Zi3c7Z2Me5A=="], - "@opentui/keymap": ["@opentui/keymap@0.2.15", "", { "dependencies": { "@opentui/core": "0.2.15" }, "peerDependencies": { "@opentui/react": "0.2.15", "@opentui/solid": "0.2.15", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wqQp6y7P2jZZJiOMwupxjGryuSWCs+njjglwW/xny9J17gomBmUvTIcIIWNG0Jv+EGO9ScBzCScGlwBHFhHyYw=="], + "@opentui/keymap": ["@opentui/keymap@0.2.16", "", { "dependencies": { "@opentui/core": "0.2.16" }, "peerDependencies": { "@opentui/react": "0.2.16", "@opentui/solid": "0.2.16", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YBLQfNLbU2kx49bjEY9rrFoNlvIoi5qNJfRcOt6frvnR3C6MLl0/8hZY+vMQ2PEQWeEiNejFnl1lQw+z4Nk2FQ=="], - "@opentui/solid": ["@opentui/solid@0.2.15", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.15", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-CViepAjsCWXwrLndMt+qlLo7cooVX7DXwSJHNizw7mfrRJtOPzSYJZCIk1vF4IJTWffCHygoYMe3uSeKvzAcbw=="], + "@opentui/solid": ["@opentui/solid@0.2.16", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.16", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2Q+v1PPpXXr+sALi9Aj6I5Jvo7xDfbmstYjRLL7lW3Hghh9i7ONQKpt/gyDDRbhSsYrhxKYTNenF9OxgoXkTHg=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -3417,7 +3421,7 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.7.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-J7apROAmDwDJcLPcbQ9YQiPhyFN1Vvt/f3ugsePaUnzh3sedfHCHGETgG0Mm0dJCB2rC7DYwmV/sgxLQcQ/sjw=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.8.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-KwHASXkHtDcgrzTXZVp9Dyx6t8m9nK0R2fCm47MWcxxQ1kOBt3f2LZugtu1kOby8i4Sbd+kvBSYM66PGkDclng=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -5139,7 +5143,7 @@ "write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="], - "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], @@ -5497,6 +5501,8 @@ "@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], "@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], @@ -5639,6 +5645,8 @@ "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + "@opencode-ai/core/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], "@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], @@ -5649,6 +5657,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/script/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "@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=="], @@ -5959,6 +5969,8 @@ "miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="], + "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -5991,6 +6003,8 @@ "opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], + "opencode/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "opencode-poe-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], @@ -6075,6 +6089,8 @@ "storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "storybook/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "storybook-solidjs-vite/vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], diff --git a/bunfig.toml b/bunfig.toml index 47c4ac5396..546f04843e 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Only install newly resolved package versions published at least 3 days ago. minimumReleaseAge = 259200 -minimumReleaseAgeExcludes = ["@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-x64", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid"] +minimumReleaseAgeExcludes = ["@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-x64", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider"] [test] root = "./do-not-run-tests-from-root" diff --git a/infra/stats.ts b/infra/stats.ts index 28d50fe0e3..107e8b9f23 100644 --- a/infra/stats.ts +++ b/infra/stats.ts @@ -1,4 +1,5 @@ import { lakeAthenaWorkgroup, lakeCatalog, lakeCluster, lakeQueryPermissions, lakeRegion, tableBucket } from "./lake" +import { EMAILOCTOPUS_API_KEY } from "./app" const domain = (() => { if ($app.stage === "production") return "stats.opencode.ai" @@ -163,15 +164,15 @@ new sst.x.DevCommand("StatsStudio", { // APP //////////////// -// export const app = new sst.cloudflare.x.SolidStart("Stats", { -// path: "packages/stats/app", -// buildCommand: "bun run build", -// domain, -// link: [database], -// environment: { -// PUBLIC_URL: `https://${domain}`, -// }, -// }) +export const app = new sst.cloudflare.x.SolidStart("Stats", { + path: "packages/stats/app", + buildCommand: "bun run build", + domain, + link: [database, EMAILOCTOPUS_API_KEY], + environment: { + PUBLIC_URL: `https://${domain}/stats`, + }, +}) //////////////// // SERVICES diff --git a/nix/hashes.json b/nix/hashes.json index 163c77be6a..dbec36dc8a 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-FT8N4SBP7OmVu73OwNyPJvBoxFd2+IXzNnFubB8y6J0=", - "aarch64-linux": "sha256-rGst01voY1/qr/fIMEgfL2zep/+W2RhjJh5pKs+/P3s=", - "aarch64-darwin": "sha256-+oTdR3CuR88Ra3QQsDn7ixNwJf0YjOxySTSiRsPPUOg=", - "x86_64-darwin": "sha256-3ZkB83KWtp6N7u9xhzmCouv27UuisXYvr2SBap4k++E=" + "x86_64-linux": "sha256-8SdC+4/wZP4Zsco9pt+a9al9MTl+t7RXyDNjJ50pdDo=", + "aarch64-linux": "sha256-lexzZyM0kV1LQSHef7yv7p0bmttYw4y0Oxj9e687eQk=", + "aarch64-darwin": "sha256-Ru3+YPqDauEREew4r2INhay+0eyXjnZ0Z7NuW+pbzBk=", + "x86_64-darwin": "sha256-1TLZjb5qvSVVZx7Pv0esHcXJxNx8/7VPG7Li2+ey0x0=" } } diff --git a/package.json b/package.json index 86db5cd125..a403ff1e31 100644 --- a/package.json +++ b/package.json @@ -38,9 +38,9 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.15", - "@opentui/keymap": "0.2.15", - "@opentui/solid": "0.2.15", + "@opentui/core": "0.2.16", + "@opentui/keymap": "0.2.16", + "@opentui/solid": "0.2.16", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", @@ -87,7 +87,7 @@ "@sentry/vite-plugin": "4.6.0", "solid-js": "1.9.10", "vite-plugin-solid": "2.11.10", - "@lydell/node-pty": "1.2.0-beta.10" + "@lydell/node-pty": "1.2.0-beta.12" } }, "devDependencies": { diff --git a/packages/app/package.json b/packages/app/package.json index 9f15cd7634..cefda5301b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.15.11", + "version": "1.15.12", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index 6f3953f148..e1ea007bce 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -178,7 +178,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType type Setter = Child[1] - const current = createMemo(() => serverSync.child(directory)) + const current = createMemo(() => serverSync.child(directory, { mcp: true })) const target = (directory?: string) => { if (!directory || directory === directory) return current() return serverSync.child(directory) diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index b75ae5cc32..8117e6139d 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -10,6 +10,7 @@ const provider = { all: new Map(), connected: [], default: {} } satisfies Normal describe("bootstrapDirectory", () => { test("marks a loading directory partial during bootstrap and complete after success", async () => { + const mcpReads: string[] = [] const [store, setStore] = createStore({ status: "loading", agent: [], @@ -44,6 +45,7 @@ describe("bootstrapDirectory", () => { await bootstrapDirectory({ directory: "/project", + mcp: false, global: { config: {} satisfies Config, path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, @@ -55,10 +57,20 @@ describe("bootstrapDirectory", () => { config: { get: async () => ({ data: {} }) }, session: { status: async () => ({ data: {} }) }, vcs: { get: async () => ({ data: undefined }) }, - command: { list: async () => ({ data: [] }) }, + command: { + list: async () => { + mcpReads.push("command") + return { data: [] } + }, + }, permission: { list: async () => ({ data: [] }) }, question: { list: async () => ({ data: [] }) }, - mcp: { status: async () => ({ data: {} }) }, + mcp: { + status: async () => { + mcpReads.push("status") + return { data: {} } + }, + }, provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, } as unknown as OpencodeClient, store, @@ -74,5 +86,6 @@ describe("bootstrapDirectory", () => { await new Promise((resolve) => setTimeout(resolve, 80)) expect(store.status).toBe("complete") + expect(mcpReads).toEqual([]) }) }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index b6e2f10853..ccbfc2aae9 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -198,6 +198,7 @@ export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) => export async function bootstrapDirectory(input: { directory: string + mcp: boolean sdk: OpencodeClient store: Store setStore: SetStoreFunction @@ -250,7 +251,7 @@ export async function bootstrapDirectory(input: { if (next) input.vcsCache.setStore("value", next) }), ), - () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), + input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))), () => retry(() => input.sdk.permission.list().then((x) => { @@ -304,7 +305,7 @@ export async function bootstrapDirectory(input: { }), ), () => Promise.resolve(input.loadSessions(input.directory)), - () => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)), + input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk))), () => input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => { const project = getFilename(input.directory) diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index d140b3e766..7c4adb5216 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -6,6 +6,7 @@ import type { State } from "./types" import type { QueryOptionsApi } from "../server-sync" let createChildStoreManager: typeof import("./child-store").createChildStoreManager +const queryGroups: Array<() => { queries: Array<{ enabled?: boolean }> }> = [] const child = () => createStore({} as State) const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse @@ -48,12 +49,15 @@ beforeAll(async () => { persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true], })) mock.module("@tanstack/solid-query", () => ({ - useQueries: () => [ - { isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } }, - { isLoading: false, data: {} }, - { isLoading: false, data: [] }, - { isLoading: false, data: provider }, - ], + useQueries: (options: () => { queries: Array<{ enabled?: boolean }> }) => { + queryGroups.push(options) + return [ + { isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } }, + { isLoading: false, data: {} }, + { isLoading: false, data: [] }, + { isLoading: false, data: provider }, + ] + }, })) createChildStoreManager = (await import("./child-store")).createChildStoreManager @@ -73,6 +77,7 @@ describe("createChildStoreManager", () => { isBooting: () => false, isLoadingSessions: () => false, onBootstrap() {}, + onMcp() {}, onDispose() {}, translate: (key) => key, queryOptions: queryOptionsApi, @@ -103,6 +108,7 @@ describe("createChildStoreManager", () => { onBootstrap(directory) { bootstraps.push(directory) }, + onMcp() {}, onDispose() {}, translate: (key) => key, queryOptions: queryOptionsApi, @@ -121,4 +127,45 @@ describe("createChildStoreManager", () => { dispose() } }) + + test("enables MCP only when requested for the directory", () => { + let manager: ReturnType | undefined + const offset = queryGroups.length + const mcpLoads: string[] = [] + + const dispose = createOwner((owner) => { + manager = createChildStoreManager({ + owner, + isBooting: () => false, + isLoadingSessions: () => false, + onBootstrap() {}, + onMcp(directory) { + mcpLoads.push(directory) + }, + onDispose() {}, + translate: (key) => key, + queryOptions: queryOptionsApi, + global: { provider }, + }) + }) + + try { + if (!manager) throw new Error("manager required") + const [, setStore] = manager.child("/project", { bootstrap: false }) + const queries = queryGroups[offset] + if (!queries) throw new Error("queries required") + expect(queries().queries[1]?.enabled).toBe(false) + + setStore("status", "complete") + manager.child("/project", { bootstrap: false, mcp: true }) + expect(queries().queries[1]?.enabled).toBe(true) + expect(mcpLoads).toEqual(["/project"]) + + manager.disableMcp("/project") + expect(queries().queries[1]?.enabled).toBe(false) + expect(manager.mcp("/project")).toBe(false) + } finally { + dispose() + } + }) }) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index db6dd31010..99da39ebb0 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -1,4 +1,4 @@ -import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" +import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" import type { VcsInfo } from "@opencode-ai/sdk/v2/client" @@ -24,6 +24,7 @@ export function createChildStoreManager(input: { isBooting: (directory: string) => boolean isLoadingSessions: (directory: string) => boolean onBootstrap: (directory: string) => void + onMcp: (directory: string, setStore: SetStoreFunction) => void onDispose: (directory: string) => void translate: (key: string, vars?: Record) => string queryOptions: QueryOptionsApi @@ -39,6 +40,8 @@ export function createChildStoreManager(input: { const pins = new Map() const ownerPins = new WeakMap>() const disposers = new Map void>() + const mcpDirectories = new Set() + const mcpToggles = new Map void>() const markKey = (key: DirectoryKey) => { if (!key) return @@ -110,6 +113,8 @@ export function createChildStoreManager(input: { metaCache.delete(key) iconCache.delete(key) lifecycle.delete(key) + mcpDirectories.delete(key) + mcpToggles.delete(key) const dispose = disposers.get(key) if (dispose) { dispose() @@ -173,11 +178,12 @@ export function createChildStoreManager(input: { createRoot((dispose) => { const initialMeta = meta[0].value const initialIcon = icon[0].value + const [mcpEnabled, setMcpEnabled] = createSignal(false) const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({ queries: [ input.queryOptions.path(key), - input.queryOptions.mcp(key), + { ...input.queryOptions.mcp(key), enabled: mcpEnabled() }, input.queryOptions.lsp(key), input.queryOptions.providers(key), ], @@ -236,6 +242,7 @@ export function createChildStoreManager(input: { }) children[key] = child disposers.set(key, dispose) + mcpToggles.set(key, setMcpEnabled) const onPersistedInit = (init: Promise | string | null, run: () => void) => { if (!(init instanceof Promise)) return @@ -274,6 +281,7 @@ export function createChildStoreManager(input: { const key = directoryKey(directory) const childStore = ensureChild(directory) pinForOwner(key) + if (options.mcp) enableMcp(directory, key, childStore) const shouldBootstrap = options.bootstrap ?? true if (shouldBootstrap && childStore[0].status === "loading") { input.onBootstrap(directory) @@ -284,6 +292,7 @@ export function createChildStoreManager(input: { function peek(directory: string, options: ChildOptions = {}) { const key = directoryKey(directory) const childStore = ensureChild(directory) + if (options.mcp) enableMcp(directory, key, childStore) const shouldBootstrap = options.bootstrap ?? true if (shouldBootstrap && childStore[0].status === "loading") { input.onBootstrap(directory) @@ -291,6 +300,19 @@ export function createChildStoreManager(input: { return childStore } + function enableMcp(directory: string, key: DirectoryKey, childStore: [Store, SetStoreFunction]) { + if (mcpDirectories.has(key)) return + mcpDirectories.add(key) + mcpToggles.get(key)?.(true) + if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1]) + } + + function disableMcp(directory: string) { + const key = directoryKey(directory) + if (!mcpDirectories.delete(key)) return + mcpToggles.get(key)?.(false) + } + function projectMeta(directory: string, patch: ProjectMeta) { const key = directoryKey(directory) const [store, setStore] = ensureChild(directory) @@ -330,6 +352,8 @@ export function createChildStoreManager(input: { pin, unpin, pinned, + mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)), + disableMcp, disposeDirectory, runEviction, vcsCache, diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 77b5b0c78f..8db24b904b 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -98,6 +98,7 @@ export type IconCache = { export type ChildOptions = { bootstrap?: boolean + mcp?: boolean } export type DirState = { diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 8b177e96c1..ad7e700cd7 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -31,6 +31,7 @@ import { PathKey } from "@/utils/path-key" import { createDirSyncContext } from "./directory-sync" import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context" import { createRefCountMap } from "@/utils/refcount" +import { retry } from "@opencode-ai/core/util/retry" type GlobalStore = { ready: boolean @@ -205,6 +206,19 @@ export function createServerSyncContext() { onBootstrap: (directory) => { void bootstrapInstance(directory) }, + onMcp: (directory, setStore) => { + void retry(() => + sdkFor(directory) + .command.list() + .then((x) => setStore("command", x.data ?? [])), + ).catch((err) => { + showToast({ + variant: "error", + title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), + description: formatServerError(err, language.t), + }) + }) + }, onDispose: (directory) => { const key = directoryKey(directory) queue.clear(key) @@ -311,6 +325,7 @@ export function createServerSyncContext() { const sdk = sdkFor(directory) await bootstrapDirectory({ directory, + mcp: children.mcp(key), global: { config: globalStore.config, path: globalStore.path, @@ -437,6 +452,7 @@ export function createServerSyncContext() { }, child: children.child, peek: children.peek, + disableMcp: children.disableMcp, queryOptions: queryOptionsApi, // bootstrap, updateConfig: updateConfigMutation.mutateAsync, @@ -454,7 +470,11 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple return { ...sync, - createDirSyncContext: createRefCountMap((dir) => createDirSyncContext(dir, sync)), + createDirSyncContext: createRefCountMap( + (dir) => createDirSyncContext(dir, sync), + (dir) => sync.disableMcp(dir), + directoryKey, + ), } }, }) diff --git a/packages/app/src/utils/refcount.test.ts b/packages/app/src/utils/refcount.test.ts new file mode 100644 index 0000000000..f48d80db35 --- /dev/null +++ b/packages/app/src/utils/refcount.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createRefCountMap } from "./refcount" +import { pathKey } from "./path-key" + +describe("createRefCountMap", () => { + test("removes an item after its last owner is disposed", () => { + const removed: string[] = [] + const map = createRefCountMap( + (key) => key, + (key) => removed.push(key), + ) + const first = createRoot((dispose) => { + map("/project") + return dispose + }) + const second = createRoot((dispose) => { + map("/project") + return dispose + }) + + first() + expect(removed).toEqual([]) + second() + expect(removed).toEqual(["/project"]) + }) + + test("keeps equivalent path consumers until the last owner is disposed", () => { + const removed: string[] = [] + const map = createRefCountMap( + (key) => key, + (key) => removed.push(key), + pathKey, + ) + const first = createRoot((dispose) => { + map("C:\\repo") + return dispose + }) + const second = createRoot((dispose) => { + map("C:/repo/") + return dispose + }) + + first() + expect(removed).toEqual([]) + second() + expect(removed).toEqual(["C:/repo"]) + }) +}) diff --git a/packages/app/src/utils/refcount.ts b/packages/app/src/utils/refcount.ts index 6877d82f2e..c284491cce 100644 --- a/packages/app/src/utils/refcount.ts +++ b/packages/app/src/utils/refcount.ts @@ -1,26 +1,32 @@ import { onCleanup } from "solid-js" -export function createRefCountMap(create: (key: string) => T) { +export function createRefCountMap( + create: (key: string) => T, + remove?: (key: string) => void, + identity: (key: string) => string = (key) => key, +) { const items = new Map() const refCounts = new Map() return (key: string) => { + const id = identity(key) onCleanup(() => { - refCounts.set(key, (refCounts.get(key) ?? 0) - 1) - if (refCounts.get(key) === 0) { - items.delete(key) - refCounts.delete(key) + refCounts.set(id, (refCounts.get(id) ?? 0) - 1) + if (refCounts.get(id) === 0) { + remove?.(id) + items.delete(id) + refCounts.delete(id) } }) - const cached = items.get(key) + const cached = items.get(id) if (cached) { - refCounts.set(key, (refCounts.get(key) ?? 0) + 1) + refCounts.set(id, (refCounts.get(id) ?? 0) + 1) return cached } const item = create(key) - items.set(key, item) - refCounts.set(key, 1) + items.set(id, item) + refCounts.set(id, 1) return item } } diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 1ad25968e6..e962c2f6e2 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.15.11", + "version": "1.15.12", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/src/component/go-referral.tsx b/packages/console/app/src/component/go-referral.tsx index fd4d5c8b1a..d7ba1d7d51 100644 --- a/packages/console/app/src/component/go-referral.tsx +++ b/packages/console/app/src/component/go-referral.tsx @@ -2,6 +2,7 @@ import { action, json, query, useAction, useSubmission } from "@solidjs/router" import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" import { getRequestEvent } from "solid-js/web" import { Referral } from "@opencode-ai/console-core/referral.js" +import { Actor } from "@opencode-ai/console-core/actor.js" import { withActor } from "~/context/auth.withActor" import { Modal } from "~/component/modal" import { IconCheck, IconCopy } from "~/component/icon" @@ -9,6 +10,7 @@ import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time" import { queryLiteSubscription } from "~/routes/workspace/[id]/go/lite-section" +import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite" import "./go-referral.css" type GoReferralSummary = Awaited> @@ -25,7 +27,21 @@ const emptyUsagePreview = { export const queryGoReferral = query(async (workspaceID: string) => { "use server" - return withActor(() => Referral.summary(), workspaceID) + return withActor(async () => { + const event = getRequestEvent() + const referralCode = referralCodeFromCookieHeader(event?.request.headers.get("cookie") ?? null) + if (referralCode) { + await Referral.createFromAccount({ + accountID: Actor.account(), + referralCode, + }).catch((error) => { + console.error("Referral create failed", error) + }) + event?.response.headers.append("set-cookie", clearReferralCookie()) + } + + return Referral.summary() + }, workspaceID) }, "go.referral.get") export const queryGoReferralUsagePreview = query(async (workspaceID: string, referralID?: string) => { @@ -65,6 +81,8 @@ function rewardDescriptionKey(source: GoReferralReward["source"]) { function rewardActionKey(reward: GoReferralReward, hasActiveGo: boolean) { if (reward.status === "applied") return "workspace.referral.reward.action.applied" as const + if (reward.status === "pending" && reward.source === "inviter") + return "workspace.referral.reward.source.pendingInviter" as const if (reward.status === "pending" || !hasActiveGo) return "workspace.referral.reward.action.subscribeUnlock" as const return "workspace.referral.reward.action.view" as const } diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 550d8207e6..f9efb62e55 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -249,7 +249,7 @@ export const dict = { "go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع", "go.meta.description": - "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وQwen3.5 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.", + "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.", "go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع", "go.hero.body": "يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.", @@ -298,7 +298,7 @@ export const dict = { "go.problem.item2": "حدود سخية ووصول موثوق", "go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين", "go.problem.item4": - "يتضمن GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وQwen3.5 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash", + "يتضمن GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash", "go.how.title": "كيف يعمل Go", "go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.", "go.how.step1.title": "أنشئ حسابًا", @@ -322,7 +322,7 @@ export const dict = { "go.faq.a2": "يتضمن Go النماذج المدرجة أدناه، مع حدود سخية وإتاحة موثوقة.", "go.faq.q3": "هل Go هو نفسه Zen؟", "go.faq.a3": - "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وQwen3.5 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.", + "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.", "go.faq.q4": "كم تكلفة Go؟", "go.faq.a4.p1.beforePricing": "تكلفة Go", "go.faq.a4.p1.pricingLink": "$5 للشهر الأول", @@ -345,7 +345,7 @@ export const dict = { "go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟", "go.faq.a9": - "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وQwen3.5 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", + "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", "zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.", "zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index b6f1a4d622..7cb326a5a2 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -253,7 +253,7 @@ export const dict = { "go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos", "go.meta.description": - "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.hero.title": "Modelos de codificação de baixo custo para todos", "go.hero.body": "O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.", @@ -303,7 +303,7 @@ export const dict = { "go.problem.item2": "Limites generosos e acesso confiável", "go.problem.item3": "Feito para o maior número possível de programadores", "go.problem.item4": - "Inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash", + "Inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash", "go.how.title": "Como o Go funciona", "go.how.body": "O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "O Go inclui os modelos listados abaixo, com limites generosos e acesso confiável.", "go.faq.q3": "O Go é o mesmo que o Zen?", "go.faq.a3": - "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.faq.q4": "Quanto custa o Go?", "go.faq.a4.p1.beforePricing": "O Go custa", "go.faq.a4.p1.pricingLink": "$5 no primeiro mês", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?", "go.faq.a9": - "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", + "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", "zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} não suportado", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index b8a315e7e5..7c6312e0a0 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -251,7 +251,7 @@ export const dict = { "go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle", "go.meta.description": - "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.hero.title": "Kodningsmodeller til lav pris for alle", "go.hero.body": "Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "Generøse grænser og pålidelig adgang", "go.problem.item3": "Bygget til så mange programmører som muligt", "go.problem.item4": - "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash", + "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash", "go.how.title": "Hvordan Go virker", "go.how.body": "Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.", @@ -326,7 +326,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellerne nedenfor med generøse grænser og pålidelig adgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.faq.q4": "Hvad koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -349,7 +349,7 @@ export const dict = { "go.faq.q9": "Hvad er forskellen på gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", + "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", "zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.", "zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 42bca9c7ce..de5237cca0 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -253,7 +253,7 @@ export const dict = { "go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle", "go.meta.description": - "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.", + "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.", "go.hero.title": "Kostengünstige Coding-Modelle für alle", "go.hero.body": "Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.", @@ -302,7 +302,7 @@ export const dict = { "go.problem.item2": "Großzügige Limits und zuverlässiger Zugang", "go.problem.item3": "Für so viele Programmierer wie möglich gebaut", "go.problem.item4": - "Beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash", + "Beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash", "go.how.title": "Wie Go funktioniert", "go.how.body": "Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.", @@ -328,7 +328,7 @@ export const dict = { "go.faq.a2": "Go umfasst die unten aufgeführten Modelle mit großzügigen Limits und zuverlässigem Zugriff.", "go.faq.q3": "Ist Go dasselbe wie Zen?", "go.faq.a3": - "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.", + "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.", "go.faq.q4": "Wie viel kostet Go?", "go.faq.a4.p1.beforePricing": "Go kostet", "go.faq.a4.p1.pricingLink": "$5 im ersten Monat", @@ -352,7 +352,7 @@ export const dict = { "go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?", "go.faq.a9": - "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", + "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", "zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.", "zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index bbd269d52a..950fa7543d 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -248,7 +248,7 @@ export const dict = { "go.title": "OpenCode Go | Low cost coding models for everyone", "go.meta.description": - "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.", + "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.", "go.hero.title": "Low cost coding models for everyone", "go.hero.body": "Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.", @@ -296,7 +296,7 @@ export const dict = { "go.problem.item2": "Generous limits and reliable access", "go.problem.item3": "Built for as many programmers as possible", "go.problem.item4": - "Includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash", + "Includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash", "go.how.title": "How Go works", "go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.", "go.how.step1.title": "Create an account", @@ -321,7 +321,7 @@ export const dict = { "go.faq.a2": "Go includes the models listed below, with generous limits and reliable access.", "go.faq.q3": "Is Go the same as Zen?", "go.faq.a3": - "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.", + "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.", "go.faq.q4": "How much does Go cost?", "go.faq.a4.p1.beforePricing": "Go costs", "go.faq.a4.p1.pricingLink": "$5 first month", @@ -345,7 +345,7 @@ export const dict = { "go.faq.q9": "What is the difference between free models and Go?", "go.faq.a9": - "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", + "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", "zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.", "zen.api.error.modelNotSupported": "Model {{model}} is not supported", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 813bffd497..0027736015 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -254,7 +254,7 @@ export const dict = { "go.title": "OpenCode Go | Modelos de programación de bajo coste para todos", "go.meta.description": - "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.", + "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.", "go.hero.title": "Modelos de programación de bajo coste para todos", "go.hero.body": "Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Límites generosos y acceso fiable", "go.problem.item3": "Creado para tantos programadores como sea posible", "go.problem.item4": - "Incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash", + "Incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash", "go.how.title": "Cómo funciona Go", "go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.", "go.how.step1.title": "Crear una cuenta", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "Go incluye los modelos que se indican abajo, con límites generosos y acceso confiable.", "go.faq.q3": "¿Es Go lo mismo que Zen?", "go.faq.a3": - "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.", + "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.", "go.faq.q4": "¿Cuánto cuesta Go?", "go.faq.a4.p1.beforePricing": "Go cuesta", "go.faq.a4.p1.pricingLink": "$5 el primer mes", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?", "go.faq.a9": - "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", + "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", "zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} no soportado", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index f788e9d686..2918706fc9 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -255,7 +255,7 @@ export const dict = { "go.title": "OpenCode Go | Modèles de code à faible coût pour tous", "go.meta.description": - "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.", + "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.", "go.hero.title": "Modèles de code à faible coût pour tous", "go.hero.body": "Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Limites généreuses et accès fiable", "go.problem.item3": "Conçu pour autant de programmeurs que possible", "go.problem.item4": - "Inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash", + "Inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash", "go.how.title": "Comment fonctionne Go", "go.how.body": "Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.", @@ -330,7 +330,7 @@ export const dict = { "go.faq.a2": "Go inclut les modèles ci-dessous, avec des limites généreuses et un accès fiable.", "go.faq.q3": "Est-ce que Go est la même chose que Zen ?", "go.faq.a3": - "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.", + "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.", "go.faq.q4": "Combien coûte Go ?", "go.faq.a4.p1.beforePricing": "Go coûte", "go.faq.a4.p1.pricingLink": "$5 le premier mois", @@ -353,7 +353,7 @@ export const dict = { "Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.", "go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?", "go.faq.a9": - "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", + "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", "zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.", "zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 1195f8182c..5735df45fa 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -251,7 +251,7 @@ export const dict = { "go.title": "OpenCode Go | Modelli di coding a basso costo per tutti", "go.meta.description": - "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.hero.title": "Modelli di coding a basso costo per tutti", "go.hero.body": "Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "Limiti generosi e accesso affidabile", "go.problem.item3": "Costruito per il maggior numero possibile di programmatori", "go.problem.item4": - "Include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash", + "Include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash", "go.how.title": "Come funziona Go", "go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.", "go.how.step1.title": "Crea un account", @@ -325,7 +325,7 @@ export const dict = { "go.faq.a2": "Go include i modelli elencati di seguito, con limiti generosi e accesso affidabile.", "go.faq.q3": "Go è lo stesso di Zen?", "go.faq.a3": - "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.faq.q4": "Quanto costa Go?", "go.faq.a4.p1.beforePricing": "Go costa", "go.faq.a4.p1.pricingLink": "$5 il primo mese", @@ -349,7 +349,7 @@ export const dict = { "go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?", "go.faq.a9": - "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", + "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", "zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.", "zen.api.error.modelNotSupported": "Modello {{model}} non supportato", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index d33df6a4da..29759e91cf 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -250,7 +250,7 @@ export const dict = { "go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル", "go.meta.description": - "Goは最初の月$5、その後$10/月で、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。", + "Goは最初の月$5、その後$10/月で、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。", "go.hero.title": "すべての人のための低価格なコーディングモデル", "go.hero.body": "Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "十分な制限と安定したアクセス", "go.problem.item3": "できるだけ多くのプログラマーのために構築", "go.problem.item4": - "GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashを含む", + "GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashを含む", "go.how.title": "Goの仕組み", "go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。", "go.how.step1.title": "アカウントを作成", @@ -325,7 +325,7 @@ export const dict = { "go.faq.a2": "Go には、十分な利用上限と安定したアクセスを備えた、以下のモデルが含まれます。", "go.faq.q3": "GoはZenと同じですか?", "go.faq.a3": - "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", + "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", "go.faq.q4": "Goの料金は?", "go.faq.a4.p1.beforePricing": "Goは", "go.faq.a4.p1.pricingLink": "最初の月$5", @@ -349,7 +349,7 @@ export const dict = { "go.faq.q9": "無料モデルとGoの違いは何ですか?", "go.faq.a9": - "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", + "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", "zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。", "zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 3c06a5d356..dfa6704b80 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -247,7 +247,7 @@ export const dict = { "go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델", "go.meta.description": - "Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.", + "Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.", "go.hero.title": "모두를 위한 저비용 코딩 모델", "go.hero.body": "Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.", @@ -297,7 +297,7 @@ export const dict = { "go.problem.item2": "넉넉한 한도와 안정적인 액세스", "go.problem.item3": "가능한 한 많은 프로그래머를 위해 제작됨", "go.problem.item4": - "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 포함", + "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 포함", "go.how.title": "Go 작동 방식", "go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.", "go.how.step1.title": "계정 생성", @@ -321,7 +321,7 @@ export const dict = { "go.faq.a2": "Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 아래 모델이 포함됩니다.", "go.faq.q3": "Go는 Zen과 같은가요?", "go.faq.a3": - "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", + "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", "go.faq.q4": "Go 비용은 얼마인가요?", "go.faq.a4.p1.beforePricing": "Go 비용은", "go.faq.a4.p1.pricingLink": "첫 달 $5", @@ -344,7 +344,7 @@ export const dict = { "go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?", "go.faq.a9": - "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", + "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", "zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.", "zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 7f76e4eff5..1418a42c83 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -251,7 +251,7 @@ export const dict = { "go.title": "OpenCode Go | Rimelige kodemodeller for alle", "go.meta.description": - "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.hero.title": "Rimelige kodemodeller for alle", "go.hero.body": "Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "Rause grenser og pålitelig tilgang", "go.problem.item3": "Bygget for så mange programmerere som mulig", "go.problem.item4": - "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash", + "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash", "go.how.title": "Hvordan Go fungerer", "go.how.body": "Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.", @@ -326,7 +326,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellene nedenfor, med høye grenser og pålitelig tilgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.faq.q4": "Hva koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -350,7 +350,7 @@ export const dict = { "go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", + "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", "zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.", "zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 8186cf3593..d931106917 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -252,7 +252,7 @@ export const dict = { "go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego", "go.meta.description": - "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.", + "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.", "go.hero.title": "Niskokosztowe modele do kodowania dla każdego", "go.hero.body": "Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.", @@ -301,7 +301,7 @@ export const dict = { "go.problem.item2": "Hojne limity i niezawodny dostęp", "go.problem.item3": "Stworzony dla jak największej liczby programistów", "go.problem.item4": - "Zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash", + "Zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash", "go.how.title": "Jak działa Go", "go.how.body": "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.", @@ -327,7 +327,7 @@ export const dict = { "go.faq.a2": "Go obejmuje poniższe modele z wysokimi limitami i niezawodnym dostępem.", "go.faq.q3": "Czy Go to to samo co Zen?", "go.faq.a3": - "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.", + "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.", "go.faq.q4": "Ile kosztuje Go?", "go.faq.a4.p1.beforePricing": "Go kosztuje", "go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc", @@ -351,7 +351,7 @@ export const dict = { "go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?", "go.faq.a9": - "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", + "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", "zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.", "zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 2031e61287..1f0ce4f3a0 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -255,7 +255,7 @@ export const dict = { "go.title": "OpenCode Go | Недорогие модели для кодинга для всех", "go.meta.description": - "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.", + "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.", "go.hero.title": "Недорогие модели для кодинга для всех", "go.hero.body": "Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.", @@ -305,7 +305,7 @@ export const dict = { "go.problem.item2": "Щедрые лимиты и надежный доступ", "go.problem.item3": "Создан для максимального числа программистов", "go.problem.item4": - "Включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash", + "Включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash", "go.how.title": "Как работает Go", "go.how.body": "Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.", @@ -331,7 +331,7 @@ export const dict = { "go.faq.a2": "Go включает перечисленные ниже модели с щедрыми лимитами и надежным доступом.", "go.faq.q3": "Go — это то же самое, что и Zen?", "go.faq.a3": - "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.", + "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.", "go.faq.q4": "Сколько стоит Go?", "go.faq.a4.p1.beforePricing": "Go стоит", "go.faq.a4.p1.pricingLink": "$5 за первый месяц", @@ -355,7 +355,7 @@ export const dict = { "go.faq.q9": "В чем разница между бесплатными моделями и Go?", "go.faq.a9": - "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", + "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", "zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.", "zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index ac9de47b66..724bb5c3da 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -250,7 +250,7 @@ export const dict = { "go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", "go.meta.description": - "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash", + "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash", "go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", "go.hero.body": "Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน", @@ -298,7 +298,7 @@ export const dict = { "go.problem.item2": "ขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้", "go.problem.item3": "สร้างขึ้นเพื่อโปรแกรมเมอร์จำนวนมากที่สุดเท่าที่จะเป็นไปได้", "go.problem.item4": - "รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash", + "รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash", "go.how.title": "Go ทำงานอย่างไร", "go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้", "go.how.step1.title": "สร้างบัญชี", @@ -323,7 +323,7 @@ export const dict = { "go.faq.a2": "Go รวมโมเดลด้านล่างนี้ พร้อมขีดจำกัดที่มากและการเข้าถึงที่เชื่อถือได้", "go.faq.q3": "Go เหมือนกับ Zen หรือไม่?", "go.faq.a3": - "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้", + "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้", "go.faq.q4": "Go ราคาเท่าไหร่?", "go.faq.a4.p1.beforePricing": "Go ราคา", "go.faq.a4.p1.pricingLink": "$5 เดือนแรก", @@ -346,7 +346,7 @@ export const dict = { "go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?", "go.faq.a9": - "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", + "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", "zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง", "zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index e9f83a9356..0805eb7e9b 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -253,7 +253,7 @@ export const dict = { "go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri", "go.meta.description": - "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.", + "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.", "go.hero.title": "Herkes için düşük maliyetli kodlama modelleri", "go.hero.body": "Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.", @@ -303,7 +303,7 @@ export const dict = { "go.problem.item2": "Cömert limitler ve güvenilir erişim", "go.problem.item3": "Mümkün olduğunca çok programcı için geliştirildi", "go.problem.item4": - "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir", + "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir", "go.how.title": "Go nasıl çalışır?", "go.how.body": "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "Go, aşağıda listelenen modelleri cömert limitler ve güvenilir erişimle sunar.", "go.faq.q3": "Go, Zen ile aynı mı?", "go.faq.a3": - "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", + "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", "go.faq.q4": "Go ne kadar?", "go.faq.a4.p1.beforePricing": "Go'nun maliyeti", "go.faq.a4.p1.pricingLink": "İlk ay $5", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?", "go.faq.a9": - "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", + "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", "zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.", "zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor", diff --git a/packages/console/app/src/i18n/uk.ts b/packages/console/app/src/i18n/uk.ts index 57542edfe1..b82c367ddb 100644 --- a/packages/console/app/src/i18n/uk.ts +++ b/packages/console/app/src/i18n/uk.ts @@ -252,7 +252,7 @@ export const dict = { "go.title": "OpenCode Go | Недорогі моделі кодування для всіх", "go.meta.description": - "Go починається від $5 за перший місяць, потім $10/місяць, з generous 5-годинними лімітами запитів для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash.", + "Go починається від $5 за перший місяць, потім $10/місяць, з generous 5-годинними лімітами запитів для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash.", "go.hero.title": "Недорогі моделі кодування для всіх", "go.hero.body": "Go надає агентне програмування програмістам у всьому світі, пропонуючи щедрі ліміти та надійний доступ до найкращих моделей з відкритим кодом.", @@ -301,7 +301,7 @@ export const dict = { "go.problem.item2": "Щедрі ліміти та надійний доступ", "go.problem.item3": "Створено для якомога більшої кількості програмістів", "go.problem.item4": - "Включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash", + "Включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash", "go.how.title": "Як працює Go", "go.how.body": "Go починається від $5 за перший місяць, потім $10/місяць. Використовуйте з OpenCode або будь-яким агентом.", @@ -327,7 +327,7 @@ export const dict = { "go.faq.a2": "Go включає моделі, перелічені нижче, із щедрими лімітами та надійним доступом.", "go.faq.q3": "Чи Go те саме, що Zen?", "go.faq.a3": - "Ні. Zen — це плата за використання, тоді як Go починається від $5 за перший місяць, потім $10/місяць, із щедрими лімітами та надійним доступом до моделей з відкритим кодом.", + "Ні. Zen — це плата за використання, тоді як Go починається від $5 за перший місяць, потім $10/місяць, із щедрими лімітами та надійним доступом до моделей з відкритим кодом GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash.", "go.faq.q4": "Скільки коштує Go?", "go.faq.a4.p1.beforePricing": "Go коштує", "go.faq.a4.p1.pricingLink": "$5 за перший місяць", @@ -350,7 +350,7 @@ export const dict = { "go.faq.q9": "Яка різниця між безкоштовними моделями та Go?", "go.faq.a9": - "Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash із вищими лімітами.", + "Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash із вищими лімітами.", "zen.api.error.rateLimitExceeded": "Перевищено ліміт запитів. Спробуйте пізніше.", "zen.api.error.modelNotSupported": "Модель {{model}} не підтримується", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 3e5a35ec76..9fbd0b5b1e 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -241,7 +241,7 @@ export const dict = { "go.title": "OpenCode Go | 人人可用的低成本编程模型", "go.meta.description": - "Go 首月 $5,之后 $10/月,提供对 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。", + "Go 首月 $5,之后 $10/月,提供对 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。", "go.hero.title": "人人可用的低成本编程模型", "go.hero.body": "Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。", @@ -289,7 +289,7 @@ export const dict = { "go.problem.item2": "充裕的限额和可靠的访问", "go.problem.item3": "为尽可能多的程序员打造", "go.problem.item4": - "包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash", + "包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash", "go.how.title": "Go 如何工作", "go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "创建账户", @@ -311,7 +311,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的限额和可靠的访问。", "go.faq.q3": "Go 和 Zen 一样吗?", "go.faq.a3": - "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。", + "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。", "go.faq.q4": "Go 多少钱?", "go.faq.a4.p1.beforePricing": "Go 费用为", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -333,7 +333,7 @@ export const dict = { "go.faq.q9": "免费模型和 Go 之间的区别是什么?", "go.faq.a9": - "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, Qwen3.5 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", + "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", "zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。", "zen.api.error.modelNotSupported": "不支持模型 {{model}}", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index 72686ada80..4a782621bd 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -241,7 +241,7 @@ export const dict = { "go.title": "OpenCode Go | 低成本全民編碼模型", "go.meta.description": - "Go 首月 $5,之後 $10/月,提供對 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。", + "Go 首月 $5,之後 $10/月,提供對 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。", "go.hero.title": "低成本全民編碼模型", "go.hero.body": "Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。", @@ -289,7 +289,7 @@ export const dict = { "go.problem.item2": "寬裕的限額與穩定存取", "go.problem.item3": "專為盡可能多的程式設計師打造", "go.problem.item4": - "包含 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash", + "包含 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash", "go.how.title": "Go 如何運作", "go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "建立帳號", @@ -311,7 +311,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的額度與穩定的存取。", "go.faq.q3": "Go 與 Zen 一樣嗎?", "go.faq.a3": - "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。", + "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。", "go.faq.q4": "Go 費用是多少?", "go.faq.a4.p1.beforePricing": "Go 費用為", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -333,7 +333,7 @@ export const dict = { "go.faq.q9": "免費模型與 Go 有什麼區別?", "go.faq.a9": - "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、Qwen3.5 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", + "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", "zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。", "zen.api.error.modelNotSupported": "不支援模型 {{model}}", diff --git a/packages/console/app/src/lib/stats-proxy.ts b/packages/console/app/src/lib/stats-proxy.ts new file mode 100644 index 0000000000..54b9893a5b --- /dev/null +++ b/packages/console/app/src/lib/stats-proxy.ts @@ -0,0 +1,37 @@ +import type { APIEvent } from "@solidjs/start/server" +import { Resource } from "@opencode-ai/console-resource" + +export async function statsProxy(evt: APIEvent) { + const req = evt.request.clone() + const targetUrl = new URL(req.url) + targetUrl.protocol = "https:" + targetUrl.hostname = Resource.App.stage === "production" ? "stats.opencode.ai" : "stats.dev.opencode.ai" + targetUrl.port = "" + + if (targetUrl.pathname.startsWith("/stats/_build/")) { + targetUrl.pathname = targetUrl.pathname.slice("/stats".length) + } + + const response = await fetch(targetUrl, { + method: req.method, + headers: req.headers, + body: req.body, + }) + + if (!response.headers.get("content-type")?.includes("text/html")) return response + + const headers = new Headers(response.headers) + headers.delete("content-encoding") + headers.delete("content-length") + headers.delete("etag") + + return new Response(rewriteStatsHtml(await response.text()), { + status: response.status, + statusText: response.statusText, + headers, + }) +} + +function rewriteStatsHtml(html: string) { + return html.replaceAll('"/_build/', '"/stats/_build/').replaceAll("'/_build/", "'/stats/_build/") +} diff --git a/packages/console/app/src/routes/auth/[...callback].ts b/packages/console/app/src/routes/auth/[...callback].ts index b1b942ee72..00bb89406f 100644 --- a/packages/console/app/src/routes/auth/[...callback].ts +++ b/packages/console/app/src/routes/auth/[...callback].ts @@ -1,11 +1,9 @@ import { redirect } from "@solidjs/router" import type { APIEvent } from "@solidjs/start/server" -import { Referral } from "@opencode-ai/console-core/referral.js" import { AuthClient } from "~/context/auth" import { useAuthSession } from "~/context/auth" import { i18n } from "~/i18n" import { localeFromRequest, route } from "~/lib/language" -import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite" export async function GET(input: APIEvent) { const url = new URL(input.request.url) @@ -19,7 +17,6 @@ export async function GET(input: APIEvent) { if (result.err) throw new Error(result.err.message) const decoded = AuthClient.decode(result.tokens.access, {} as any) if (decoded.err) throw new Error(decoded.err.message) - const referralCode = referralCodeFromCookieHeader(input.request.headers.get("cookie")) const session = await useAuthSession() const id = decoded.subject.properties.accountID await session.update((value) => { @@ -35,15 +32,8 @@ export async function GET(input: APIEvent) { current: id, } }) - if (decoded.subject.properties.newAccount && referralCode) { - await Referral.createFromAccount({ accountID: id, referralCode }).catch((error) => { - console.error("Referral create failed", error) - }) - } const next = url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "") - const response = redirect(route(locale, next)) - if (referralCode) response.headers.append("set-cookie", clearReferralCookie()) - return response + return redirect(route(locale, next)) } catch (e: any) { return new Response( JSON.stringify({ diff --git a/packages/console/app/src/routes/go/index.css b/packages/console/app/src/routes/go/index.css index 25ae00e5f8..45b61d217e 100644 --- a/packages/console/app/src/routes/go/index.css +++ b/packages/console/app/src/routes/go/index.css @@ -90,6 +90,7 @@ body { [data-page="go"] { background: var(--color-background); + overflow-x: clip; --padding: 5rem; --vertical-padding: 4rem; diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index 111ff11c4f..bccdc9e97a 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -31,7 +31,6 @@ const models = [ { name: "MiMo-V2.5", provider: "Xiaomi MiMo" }, { name: "Qwen3.7 Max", provider: "Alibaba Cloud Model Studio" }, { name: "Qwen3.6 Plus", provider: "Alibaba Cloud Model Studio" }, - { name: "Qwen3.5 Plus", provider: "Alibaba Cloud Model Studio" }, { name: "MiniMax M2.7", provider: "MiniMax" }, { name: "MiniMax M2.5", provider: "MiniMax" }, { name: "DeepSeek V4 Pro", provider: "DeepSeek" }, @@ -68,7 +67,6 @@ function LimitsGraph(props: { href: string }) { { id: "qwen3.6-plus", name: "Qwen3.6 Plus", req: 3300, d: "280ms" }, { id: "minimax-m2.7", name: "MiniMax M2.7", req: 3400, d: "300ms" }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "200ms" }, - { id: "qwen3.5-plus", name: "Qwen3.5 Plus", req: 10200, d: "360ms" }, { id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" }, ] diff --git a/packages/console/app/src/routes/stats/[...path].ts b/packages/console/app/src/routes/stats/[...path].ts new file mode 100644 index 0000000000..d1899215df --- /dev/null +++ b/packages/console/app/src/routes/stats/[...path].ts @@ -0,0 +1,8 @@ +import { statsProxy } from "~/lib/stats-proxy" + +export const GET = statsProxy +export const POST = statsProxy +export const PUT = statsProxy +export const DELETE = statsProxy +export const OPTIONS = statsProxy +export const PATCH = statsProxy diff --git a/packages/console/app/src/routes/stats/index.ts b/packages/console/app/src/routes/stats/index.ts new file mode 100644 index 0000000000..d1899215df --- /dev/null +++ b/packages/console/app/src/routes/stats/index.ts @@ -0,0 +1,8 @@ +import { statsProxy } from "~/lib/stats-proxy" + +export const GET = statsProxy +export const POST = statsProxy +export const PUT = statsProxy +export const DELETE = statsProxy +export const OPTIONS = statsProxy +export const PATCH = statsProxy diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index 37295402d7..ab12c59697 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -266,7 +266,6 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {
  • MiMo-V2.5
  • MiniMax M2.5
  • MiniMax M2.7
  • -
  • Qwen3.5 Plus
  • Qwen3.6 Plus
  • Qwen3.7 Max
  • DeepSeek V4 Pro
  • diff --git a/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx index cb273a422e..09c67f230b 100644 --- a/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx @@ -4,7 +4,6 @@ import { IconCopy, IconCheck } from "~/component/icon" import { Key } from "@opencode-ai/console-core/key.js" import { withActor } from "~/context/auth.withActor" import { createStore } from "solid-js/store" -import { formatDateUTC, formatDateForTable } from "../../common" import styles from "./key-section.module.css" import { Actor } from "@opencode-ai/console-core/actor.js" import { useI18n } from "~/context/i18n" @@ -124,7 +123,6 @@ export function KeySection() { {i18n.t("workspace.keys.table.name")} {i18n.t("workspace.keys.table.key")} {i18n.t("workspace.keys.table.createdBy")} - {i18n.t("workspace.keys.table.lastUsed")} @@ -156,9 +154,6 @@ export function KeySection() { {key.email} - - {key.timeUsed ? formatDateForTable(key.timeUsed) : "-"} -
    diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index dac287551f..ed76c16c1a 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -478,35 +478,29 @@ export async function handler( stickyId: string, trialProviders: string[] | undefined, retry: RetryOptions, - stickyProvider: string | undefined, + stickyProviderId: string | undefined, modelTpmLimits: Record | undefined, - modelTpsLimits: Record | undefined, + modelTpsLimits: Record | undefined, ) { const modelProvider = (() => { - const allProviders = modelInfo.providers.filter((provider) => !provider.disabled) - // Byok is top priority b/c if user set their own API key, we should use it // instead of using the sticky provider for the same session if (authInfo?.provider?.credentials) { - return allProviders.find((provider) => provider.id === modelInfo.byokProvider) - } - - // Always use the same provider for the same session - if (stickyProvider) { - const provider = allProviders.find((provider) => provider.id === stickyProvider) - if (provider) return provider + return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider) } + // Prioritize trial providers + let allProviders = modelInfo.providers.filter((provider) => !provider.disabled) if (trialProviders) { - const trialProvider = trialProviders[Math.floor(Math.random() * trialProviders.length)] - const provider = allProviders.find((provider) => provider.id === trialProvider) - if (provider) return provider + allProviders = allProviders.map((provider) => ({ + ...provider, + priority: trialProviders.includes(provider.id) ? 0 : provider.priority, + })) } if (retry.retryCount !== MAX_FAILOVER_RETRIES) { let topPriority = Infinity const providers = allProviders - .filter((provider) => !provider.disabled) .filter((provider) => provider.weight !== 0) .filter((provider) => !retry.excludeProviders.includes(provider.id)) .filter((provider) => { @@ -516,7 +510,11 @@ export async function handler( }) .filter((provider) => { if (!provider.tpsGoal) return true - const isLowTps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? false + const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? { + qualify: 0, + unqualify: 0, + } + const isLowTps = tps.qualify + tps.unqualify > 10 && tps.qualify < tps.unqualify return !isLowTps }) .map((provider) => { @@ -534,7 +532,23 @@ export async function handler( } const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1 const provider = providers[index || 0] - if (provider) return provider + + // sticky provider does not exist => use selected provider + if (!stickyProviderId) return provider + const stickProvider = allProviders.find((provider) => provider.id === stickyProviderId) + if (!stickProvider) return provider + + // stick provider exists + selected provider is API type => use sticky provider + if (!provider.tpsGoal) return stickProvider + + // stick provier exists + selected provider is GPU type + GPU not idle => use selected provider + const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? { + qualify: 0, + unqualify: 0, + } + if (tps.qualify <= tps.unqualify * 3) return stickProvider + + return provider } // fallback provider @@ -990,10 +1004,6 @@ export async function handler( return undefined })(), }), - db - .update(KeyTable) - .set({ timeUsed: sql`now()` }) - .where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))), ...(() => { if (billingSource === "subscription") { const plan = authInfo.billing.subscription!.plan diff --git a/packages/console/app/src/routes/zen/util/modelTpsLimiter.ts b/packages/console/app/src/routes/zen/util/modelTpsLimiter.ts index 477d08ce68..3ff63f7d49 100644 --- a/packages/console/app/src/routes/zen/util/modelTpsLimiter.ts +++ b/packages/console/app/src/routes/zen/util/modelTpsLimiter.ts @@ -37,7 +37,7 @@ export function createModelTpsLimiter(providers: { id: string; model: string; tp ) // convert to map of model to summed count across current and previous intervals - const result = data.reduce( + return data.reduce( (acc, curr) => { const existing = acc[curr.id] ?? { qualify: 0, unqualify: 0 } acc[curr.id] = { @@ -48,13 +48,6 @@ export function createModelTpsLimiter(providers: { id: string; model: string; tp }, {} as Record, ) - - return Object.fromEntries( - Object.entries(result).map(([id, { qualify, unqualify }]) => { - const isLowTps = qualify + unqualify > 10 && qualify < unqualify - return [id, isLowTps] - }), - ) }, track: async ( provider: string, diff --git a/packages/console/app/vite.config.ts b/packages/console/app/vite.config.ts index fb753b6be7..33455acc5e 100644 --- a/packages/console/app/vite.config.ts +++ b/packages/console/app/vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ ], server: { allowedHosts: true, + port: 3001, }, build: { rollupOptions: { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index dcc1b59a6b..4d5290f8d1 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.15.11", + "version": "1.15.12", "private": true, "type": "module", "license": "MIT", @@ -37,6 +37,7 @@ "update-limits": "script/update-limits.ts", "promote-limits-to-dev": "script/promote-limits.ts dev", "promote-limits-to-prod": "script/promote-limits.ts production", + "referral-backfill": "script/referral-backfill.ts", "typecheck": "tsgo --noEmit" }, "devDependencies": { diff --git a/packages/console/core/script/referral-backfill.ts b/packages/console/core/script/referral-backfill.ts new file mode 100644 index 0000000000..c3062ad78e --- /dev/null +++ b/packages/console/core/script/referral-backfill.ts @@ -0,0 +1,153 @@ +import { and, Database, eq, inArray, isNull } from "../src/drizzle/index.js" +import { Identifier } from "../src/identifier.js" +import { Referral } from "../src/referral.js" +import { LiteTable } from "../src/schema/billing.sql.js" +import { ReferralRewardTable, ReferralTable } from "../src/schema/referral.sql.js" +import { UserTable } from "../src/schema/user.sql.js" +import { WorkspaceTable } from "../src/schema/workspace.sql.js" + +const backfills = [ + { + inviterWorkspaceID: "wrk_00000000000000000000000000", + inviteeWorkspaceID: "wrk_00000000000000000000000000", + inviteeAccountID: "acc_00000000000000000000000000", + }, +] + +console.log(`Backfilling ${backfills.length} referrals`) + +for (const [index, backfill] of backfills.entries()) { + console.log(`[${index + 1}/${backfills.length}] ${backfill.inviterWorkspaceID} -> ${backfill.inviteeWorkspaceID}`) + console.log(` invitee account: ${backfill.inviteeAccountID}`) + + const result = await Database.transaction(async (tx) => { + if (backfill.inviterWorkspaceID === backfill.inviteeWorkspaceID) throw new Error("Self-referral workspace mismatch") + + const inviterWorkspace = await tx + .select({ id: WorkspaceTable.id }) + .from(WorkspaceTable) + .where(and(eq(WorkspaceTable.id, backfill.inviterWorkspaceID), isNull(WorkspaceTable.timeDeleted))) + .then((rows) => rows[0]) + if (!inviterWorkspace) throw new Error(`Inviter workspace not found: ${backfill.inviterWorkspaceID}`) + + const inviteeWorkspace = await tx + .select({ id: WorkspaceTable.id }) + .from(WorkspaceTable) + .where(and(eq(WorkspaceTable.id, backfill.inviteeWorkspaceID), isNull(WorkspaceTable.timeDeleted))) + .then((rows) => rows[0]) + if (!inviteeWorkspace) throw new Error(`Invitee workspace not found: ${backfill.inviteeWorkspaceID}`) + + const inviteeUser = await tx + .select({ id: UserTable.id }) + .from(UserTable) + .where( + and( + eq(UserTable.workspaceID, backfill.inviteeWorkspaceID), + eq(UserTable.accountID, backfill.inviteeAccountID), + eq(UserTable.role, "admin"), + isNull(UserTable.timeDeleted), + ), + ) + .then((rows) => rows[0]) + if (!inviteeUser) throw new Error(`Invitee workspace owner not found: ${backfill.inviteeAccountID}`) + + const inviterUser = await tx + .select({ id: UserTable.id }) + .from(UserTable) + .where( + and( + eq(UserTable.workspaceID, backfill.inviterWorkspaceID), + eq(UserTable.accountID, backfill.inviteeAccountID), + isNull(UserTable.timeDeleted), + ), + ) + .then((rows) => rows[0]) + if (inviterUser) throw new Error(`Self-referral is not allowed: ${backfill.inviteeAccountID}`) + + const lite = await tx + .select({ id: LiteTable.id }) + .from(LiteTable) + .where( + and( + eq(LiteTable.workspaceID, backfill.inviteeWorkspaceID), + eq(LiteTable.userID, inviteeUser.id), + isNull(LiteTable.timeDeleted), + ), + ) + .then((rows) => rows[0]) + if (!lite) throw new Error(`Invitee Lite subscription not found: ${backfill.inviteeWorkspaceID}`) + + const existingReferral = await tx + .select({ id: ReferralTable.id, workspaceID: ReferralTable.workspaceID }) + .from(ReferralTable) + .where(and(eq(ReferralTable.inviteeAccountID, backfill.inviteeAccountID), isNull(ReferralTable.timeDeleted))) + .then((rows) => rows[0]) + if (existingReferral && existingReferral.workspaceID !== backfill.inviterWorkspaceID) { + throw new Error(`Referral already belongs to ${existingReferral.workspaceID}: ${existingReferral.id}`) + } + + const referralID = existingReferral?.id ?? Identifier.create("referral") + if (!existingReferral) { + await tx.insert(ReferralTable).ignore().values({ + workspaceID: backfill.inviterWorkspaceID, + id: referralID, + inviteeAccountID: backfill.inviteeAccountID, + }) + + const referral = await tx + .select({ id: ReferralTable.id }) + .from(ReferralTable) + .where(and(eq(ReferralTable.inviteeAccountID, backfill.inviteeAccountID), isNull(ReferralTable.timeDeleted))) + .then((rows) => rows[0]) + if (!referral) throw new Error(`Referral not created: ${backfill.inviteeAccountID}`) + if (referral.id !== referralID) throw new Error(`Referral already redeemed: ${referral.id}`) + } + + const rewardInsert = await tx + .insert(ReferralRewardTable) + .ignore() + .values([ + { + workspaceID: backfill.inviterWorkspaceID, + referralID, + amount: Referral.REWARD_AMOUNT, + }, + { + workspaceID: backfill.inviteeWorkspaceID, + referralID, + amount: Referral.REWARD_AMOUNT, + }, + ]) + + const rewards = await tx + .select({ workspaceID: ReferralRewardTable.workspaceID, amount: ReferralRewardTable.amount }) + .from(ReferralRewardTable) + .where( + and( + eq(ReferralRewardTable.referralID, referralID), + inArray(ReferralRewardTable.workspaceID, [backfill.inviterWorkspaceID, backfill.inviteeWorkspaceID]), + isNull(ReferralRewardTable.timeDeleted), + ), + ) + if (rewards.length !== 2) throw new Error(`Referral rewards not created: ${referralID}`) + if (rewards.some((reward) => reward.amount !== Referral.REWARD_AMOUNT)) { + throw new Error(`Referral reward amount mismatch: ${referralID}`) + } + + return { + referralID, + createdReferral: !existingReferral, + createdRewards: rewardInsert.rowsAffected, + inviteeUserID: inviteeUser.id, + liteID: lite.id, + rewardWorkspaces: rewards.map((reward) => reward.workspaceID), + } + }) + + console.log(` invitee user: ${result.inviteeUserID}`) + console.log(` lite: ${result.liteID}`) + console.log(` referral: ${result.referralID} (${result.createdReferral ? "created" : "existing"})`) + console.log(` rewards: ${result.rewardWorkspaces.join(", ")} (${result.createdRewards} inserted)`) +} + +console.log("Referral backfill complete") diff --git a/packages/console/core/src/referral.ts b/packages/console/core/src/referral.ts index 66b14dbe39..96277ca3f1 100644 --- a/packages/console/core/src/referral.ts +++ b/packages/console/core/src/referral.ts @@ -1,8 +1,8 @@ import { z } from "zod" -import { and, asc, eq, isNull, sql, Database } from "./drizzle" +import { and, asc, eq, inArray, isNull, sql, Database } from "./drizzle" import { Actor } from "./actor" import { Identifier } from "./identifier" -import { LiteTable } from "./schema/billing.sql" +import { LiteTable, PaymentTable } from "./schema/billing.sql" import { ReferralCodeTable, ReferralRewardTable, ReferralTable } from "./schema/referral.sql" import { AuthTable } from "./schema/auth.sql" import { UserTable } from "./schema/user.sql" @@ -318,6 +318,26 @@ export namespace Referral { .then((rows) => rows[0]) if (selfReferral) throw new Error("Self-referral is not allowed") + const workspaceIDs = await tx + .select({ workspaceID: UserTable.workspaceID }) + .from(UserTable) + .where(and(eq(UserTable.accountID, input.accountID), isNull(UserTable.timeDeleted))) + .then((rows) => rows.map((row) => row.workspaceID)) + if (workspaceIDs.length === 0) return + + const litePayment = await tx + .select({ id: PaymentTable.id }) + .from(PaymentTable) + .where( + and( + inArray(PaymentTable.workspaceID, workspaceIDs), + isNull(PaymentTable.timeDeleted), + sql`JSON_UNQUOTE(JSON_EXTRACT(${PaymentTable.enrichment}, '$.type')) = 'lite'`, + ), + ) + .then((rows) => rows[0]) + if (litePayment) return + const referralID = Identifier.create("referral") await tx.insert(ReferralTable).ignore().values({ workspaceID: code.workspaceID, @@ -355,25 +375,32 @@ export namespace Referral { .from(ReferralTable) .where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted))) .then((rows) => rows[0]) - if (!referral) throw new Error("Referral not found") + if (!referral) return - const result = await tx - .insert(ReferralRewardTable) - .ignore() - .values([ - { - workspaceID: referral.workspaceID, - referralID: referral.id, - amount: REWARD_AMOUNT, - }, - { - workspaceID: input.workspaceID, - referralID: referral.id, - amount: REWARD_AMOUNT, - }, - ]) + await tx.insert(ReferralRewardTable).ignore().values({ + workspaceID: referral.workspaceID, + referralID: referral.id, + amount: REWARD_AMOUNT, + }) - if (result.rowsAffected === 0) throw new Error("Referral already completed") + const existingInviteeReward = await tx + .select({ workspaceID: ReferralRewardTable.workspaceID }) + .from(ReferralRewardTable) + .where( + and( + eq(ReferralRewardTable.referralID, referral.id), + sql`${ReferralRewardTable.workspaceID} <> ${referral.workspaceID}`, + isNull(ReferralRewardTable.timeDeleted), + ), + ) + .then((rows) => rows[0]) + if (existingInviteeReward) return + + await tx.insert(ReferralRewardTable).ignore().values({ + workspaceID: input.workspaceID, + referralID: referral.id, + amount: REWARD_AMOUNT, + }) }) } diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 14b8b47a72..5ad1beccec 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.15.11", + "version": "1.15.12", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index d7ee2204df..bbc43f4eac 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.15.11", + "version": "1.15.12", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index bb658ff911..f62a2e1084 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.15.11", + "version": "1.15.12", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 9c8a55e99a..c5d599f210 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.11", + "version": "1.15.12", "name": "@opencode-ai/core", "type": "module", "license": "MIT", @@ -59,7 +59,7 @@ "ai-gateway-provider": "3.1.2", "cross-spawn": "catalog:", "effect": "catalog:", - "gitlab-ai-provider": "6.7.0", + "gitlab-ai-provider": "6.8.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "immer": "11.1.4", diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 504e156ac2..c9269b9c26 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -47,6 +47,7 @@ export const Flag = { OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), + OPENCODE_EXPERIMENTAL_SESSION_SWITCHER: enabledByExperimental("OPENCODE_EXPERIMENTAL_SESSION_SWITCHER"), // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index ef36a702bf..ae1692b933 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -43,9 +43,9 @@ function authFetch(fetchWithRuntimeOptions?: unknown) { // do not, so inject a Google access token into their fetch path. return async (input: Parameters[0], init?: RequestInit) => { const { GoogleAuth } = await import("google-auth-library") - const auth = new GoogleAuth() - const client = await auth.getApplicationDefault() - const token = await client.credential.getAccessToken() + const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }) + const client = await auth.getClient() + const token = await client.getAccessToken() const headers = new Headers(init?.headers) headers.set("Authorization", `Bearer ${token.token}`) return typeof fetchWithRuntimeOptions === "function" diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index ecab998c3c..2abb342cc3 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -7,6 +7,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper" const vertexOptions: Record[] = [] +const googleAuthOptions: Record[] = [] void mock.module("@ai-sdk/google-vertex", () => ({ createVertex: (options: Record) => { @@ -19,12 +20,14 @@ void mock.module("@ai-sdk/google-vertex", () => ({ void mock.module("google-auth-library", () => ({ GoogleAuth: class { - async getApplicationDefault() { + constructor(options: Record) { + googleAuthOptions.push(options) + } + + async getClient() { return { - credential: { - async getAccessToken() { - return { token: "vertex-token" } - }, + async getAccessToken() { + return { token: "vertex-token" } }, } } @@ -247,6 +250,7 @@ describe("GoogleVertexPlugin", () => { it.effect("keeps Google auth fetch for OpenAI-compatible Vertex endpoints", () => Effect.gen(function* () { + googleAuthOptions.length = 0 const fetchCalls: { input: Parameters[0]; init?: RequestInit }[] = [] const plugin = yield* PluginV2.Service yield* plugin.add(GoogleVertexPlugin) @@ -292,6 +296,7 @@ describe("GoogleVertexPlugin", () => { }), ) expect(fetchCalls).toHaveLength(1) + expect(googleAuthOptions).toEqual([{ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }]) expect(fetchCalls[0].input).toBe("https://vertex.example") expect(new Headers(fetchCalls[0].init?.headers).get("authorization")).toBe("Bearer vertex-token") expect(new Headers(fetchCalls[0].init?.headers).get("x-test")).toBe("1") diff --git a/packages/desktop/package.json b/packages/desktop/package.json index bf952d88d3..cefc5f7830 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.15.11", + "version": "1.15.12", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", @@ -59,12 +59,12 @@ "zod-openapi": "5.4.6" }, "optionalDependencies": { - "@lydell/node-pty-darwin-arm64": "1.2.0-beta.10", - "@lydell/node-pty-darwin-x64": "1.2.0-beta.10", - "@lydell/node-pty-linux-arm64": "1.2.0-beta.10", - "@lydell/node-pty-linux-x64": "1.2.0-beta.10", - "@lydell/node-pty-win32-arm64": "1.2.0-beta.10", - "@lydell/node-pty-win32-x64": "1.2.0-beta.10", + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", + "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", + "@lydell/node-pty-linux-x64": "1.2.0-beta.12", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", + "@lydell/node-pty-win32-x64": "1.2.0-beta.12", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 140881d6d1..2338db7eb5 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": "1.15.11", + "version": "1.15.12", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index e17d2b59cf..390c63935f 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.15.11", + "version": "1.15.12", "private": true, "type": "module", "license": "MIT", diff --git a/packages/enterprise/vite.config.ts b/packages/enterprise/vite.config.ts index 531732c2be..90f4665c59 100644 --- a/packages/enterprise/vite.config.ts +++ b/packages/enterprise/vite.config.ts @@ -29,6 +29,7 @@ export default defineConfig({ server: { host: "0.0.0.0", allowedHosts: true, + port: 3002, }, worker: { format: "es", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index c596d83ebb..0fefca58d1 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.15.11" +version = "1.15.12" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 62ad31fe8e..d4994a00c1 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.15.11", + "version": "1.15.12", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index f90bf4a4dc..7e79f7f6dc 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": "1.15.11", + "version": "1.15.12", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index e4ce957be4..38a55c27c9 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.11", + "version": "1.15.12", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 991a6c4571..bc3086a9eb 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.11", + "version": "1.15.12", "name": "opencode", "type": "module", "license": "MIT", @@ -122,6 +122,7 @@ "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", + "@types/ws": "8.18.1", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", "ai-gateway-provider": "3.1.2", @@ -135,7 +136,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.7.0", + "gitlab-ai-provider": "6.8.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -163,6 +164,7 @@ "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "which": "6.0.1", + "ws": "8.21.0", "xdg-basedir": "5.1.0", "yargs": "18.0.0", "zod": "catalog:" diff --git a/packages/opencode/src/acp-next/agent.ts b/packages/opencode/src/acp-next/agent.ts deleted file mode 100644 index 040c1947e2..0000000000 --- a/packages/opencode/src/acp-next/agent.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - RequestError, - type Agent as ACPAgent, - type AgentSideConnection, - type AuthenticateRequest, - type CancelNotification, - type CloseSessionRequest, - type ForkSessionRequest, - type InitializeRequest, - type ListSessionsRequest, - type LoadSessionRequest, - type NewSessionRequest, - type PromptRequest, - type ResumeSessionRequest, - type SetSessionConfigOptionRequest, - type SetSessionModelRequest, - type SetSessionModeRequest, -} from "@agentclientprotocol/sdk" -import { Effect } from "effect" -import type { OpencodeClient } from "@opencode-ai/sdk/v2" -import * as ACPNextError from "./error" -import * as ACPNextService from "./service" - -export function init({ sdk: _sdk }: { sdk: OpencodeClient }) { - return { - create: (connection: AgentSideConnection) => { - return new Agent(ACPNextService.make({ sdk: _sdk, connection })) - }, - } -} - -export class Agent implements ACPAgent { - constructor(private readonly service: ACPNextService.Interface) {} - - initialize(params: InitializeRequest) { - return run(this.service.initialize(params)) - } - - authenticate(params: AuthenticateRequest) { - return run(this.service.authenticate(params)) - } - - newSession(params: NewSessionRequest) { - return run(this.service.newSession(params)) - } - - loadSession(params: LoadSessionRequest) { - return run(this.service.loadSession(params)) - } - - listSessions(params: ListSessionsRequest) { - return run(this.service.listSessions(params)) - } - - resumeSession(params: ResumeSessionRequest) { - return run(this.service.resumeSession(params)) - } - - closeSession(params: CloseSessionRequest) { - return run(this.service.closeSession(params)) - } - - unstable_forkSession(params: ForkSessionRequest) { - return run(this.service.forkSession(params)) - } - - setSessionConfigOption(params: SetSessionConfigOptionRequest) { - return run(this.service.setSessionConfigOption(params)) - } - - setSessionMode(params: SetSessionModeRequest) { - return run(this.service.setSessionMode(params)) - } - - unstable_setSessionModel(params: SetSessionModelRequest) { - return run(this.service.setSessionModel(params)) - } - - prompt(params: PromptRequest) { - return run(this.service.prompt(params)) - } - - cancel(params: CancelNotification) { - return run(this.service.cancel(params)) - } -} - -function run(effect: Effect.Effect) { - return Effect.runPromise(effect.pipe(Effect.mapError(ACPNextError.toRequestError))).catch((defect: unknown) => { - if (defect instanceof RequestError) throw defect - throw ACPNextError.toRequestError(ACPNextError.fromUnknownDefect(defect)) - }) -} - -export * as ACPNext from "./agent" diff --git a/packages/opencode/src/acp-next/session.ts b/packages/opencode/src/acp-next/session.ts deleted file mode 100644 index 6ab61f5f0a..0000000000 --- a/packages/opencode/src/acp-next/session.ts +++ /dev/null @@ -1,225 +0,0 @@ -import type { McpServer } from "@agentclientprotocol/sdk" -import type { Message, Part } from "@opencode-ai/sdk/v2" -import { Context, Effect, Layer, Ref } from "effect" -import type { ModelID, ProviderID } from "../provider/schema" -import * as ACPNextError from "./error" - -export type SelectedModel = { - providerID: ProviderID - modelID: ModelID -} - -export type KnownMessagePartMetadata = { - messageId: string - partId: string - partType?: Part["type"] - role?: Message["role"] - ignored?: boolean - toolCallId?: string - metadata?: unknown -} - -export type Info = { - id: string - cwd: string - mcpServers: readonly McpServer[] - createdAt: Date - model?: SelectedModel - variant?: string - modeId?: string - knownParts: ReadonlyMap -} - -export type StoreInput = { - id: string - cwd: string - mcpServers?: readonly McpServer[] - createdAt?: Date - model?: SelectedModel - variant?: string - modeId?: string -} - -export type RecordPartMetadataInput = { - sessionId: string - messageId: string - partId: string - partType?: Part["type"] - role?: Message["role"] - ignored?: boolean - toolCallId?: string - metadata?: unknown -} - -export type PartMetadataLookupInput = { - sessionId: string - messageId: string - partId: string -} - -export type Interface = { - readonly create: (input: StoreInput) => Effect.Effect - readonly load: (input: StoreInput) => Effect.Effect - readonly get: (sessionId: string) => Effect.Effect - readonly tryGet: (sessionId: string) => Effect.Effect - readonly remove: (sessionId: string) => Effect.Effect - readonly setModel: ( - sessionId: string, - model: SelectedModel | undefined, - ) => Effect.Effect - readonly getModel: (sessionId: string) => Effect.Effect - readonly setVariant: ( - sessionId: string, - variant: string | undefined, - ) => Effect.Effect - readonly getVariant: (sessionId: string) => Effect.Effect - readonly setMode: ( - sessionId: string, - modeId: string | undefined, - ) => Effect.Effect - readonly getMode: (sessionId: string) => Effect.Effect - readonly recordPartMetadata: ( - input: RecordPartMetadataInput, - ) => Effect.Effect - readonly getPartMetadata: ( - input: PartMetadataLookupInput, - ) => Effect.Effect - readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/ACPNext/Session") {} - -type State = Map - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const sessions = yield* Ref.make(new Map()) - - const store = Effect.fn("ACPNext.Session.store")(function* (input: StoreInput) { - const session = makeSession(input) - yield* Ref.update(sessions, (state) => new Map(state).set(session.id, session)) - return snapshot(session) - }) - - const tryGet = Effect.fn("ACPNext.Session.tryGet")(function* (sessionId: string) { - const session = (yield* Ref.get(sessions)).get(sessionId) - if (!session) return - return snapshot(session) - }) - - const get = Effect.fn("ACPNext.Session.get")(function* (sessionId: string) { - const session = yield* tryGet(sessionId) - if (session) return session - return yield* new ACPNextError.SessionNotFoundError({ sessionId }) - }) - - const update = Effect.fn("ACPNext.Session.update")(function* (sessionId: string, fn: (session: Info) => Info) { - const result = yield* Ref.modify(sessions, (state) => { - const session = state.get(sessionId) - if (!session) return [undefined, state] as const - const next = fn(session) - return [snapshot(next), new Map(state).set(sessionId, next)] as const - }) - if (result) return result - return yield* new ACPNextError.SessionNotFoundError({ sessionId }) - }) - - const remove = Effect.fn("ACPNext.Session.remove")(function* (sessionId: string) { - return yield* Ref.modify(sessions, (state) => { - const session = state.get(sessionId) - if (!session) return [undefined, state] as const - const next = new Map(state) - next.delete(sessionId) - return [snapshot(session), next] as const - }) - }) - - const setModel: Interface["setModel"] = Effect.fn("ACPNext.Session.setModel")((sessionId, model) => - update(sessionId, (session) => ({ ...session, model })), - ) - - const setVariant: Interface["setVariant"] = Effect.fn("ACPNext.Session.setVariant")((sessionId, variant) => - update(sessionId, (session) => ({ ...session, variant })), - ) - - const setMode: Interface["setMode"] = Effect.fn("ACPNext.Session.setMode")((sessionId, modeId) => - update(sessionId, (session) => ({ ...session, modeId })), - ) - - const recordPartMetadata: Interface["recordPartMetadata"] = Effect.fn("ACPNext.Session.recordPartMetadata")(( - input, - ) => { - const metadata = { - messageId: input.messageId, - partId: input.partId, - partType: input.partType, - role: input.role, - ignored: input.ignored, - toolCallId: input.toolCallId, - metadata: input.metadata, - } - return update(input.sessionId, (session) => ({ - ...session, - knownParts: new Map(session.knownParts).set(partMetadataKey(input), metadata), - })).pipe(Effect.as(metadata)) - }) - - return Service.of({ - create: store, - load: store, - get, - tryGet, - remove, - setModel, - getModel: Effect.fn("ACPNext.Session.getModel")(function* (sessionId) { - return (yield* get(sessionId)).model - }), - setVariant, - getVariant: Effect.fn("ACPNext.Session.getVariant")(function* (sessionId) { - return (yield* get(sessionId)).variant - }), - setMode, - getMode: Effect.fn("ACPNext.Session.getMode")(function* (sessionId) { - return (yield* get(sessionId)).modeId - }), - recordPartMetadata, - getPartMetadata: Effect.fn("ACPNext.Session.getPartMetadata")(function* (input) { - return (yield* get(input.sessionId)).knownParts.get(partMetadataKey(input)) - }), - tryGetPartMetadata: Effect.fn("ACPNext.Session.tryGetPartMetadata")(function* (input) { - return (yield* tryGet(input.sessionId))?.knownParts.get(partMetadataKey(input)) - }), - }) - }), -) - -export const defaultLayer = layer - -function makeSession(input: StoreInput): Info { - return { - id: input.id, - cwd: input.cwd, - mcpServers: [...(input.mcpServers ?? [])], - createdAt: input.createdAt ? new Date(input.createdAt) : new Date(), - model: input.model, - variant: input.variant, - modeId: input.modeId, - knownParts: new Map(), - } -} - -function snapshot(session: Info): Info { - return { - ...session, - mcpServers: [...session.mcpServers], - createdAt: new Date(session.createdAt), - knownParts: new Map(session.knownParts), - } -} - -function partMetadataKey(input: { messageId: string; partId: string }) { - return `${input.messageId}:${input.partId}` -} - -export * as ACPNextSession from "./session" diff --git a/packages/opencode/src/acp/README.md b/packages/opencode/src/acp/README.md deleted file mode 100644 index aab33259bb..0000000000 --- a/packages/opencode/src/acp/README.md +++ /dev/null @@ -1,174 +0,0 @@ -# ACP (Agent Client Protocol) Implementation - -This directory contains a clean, protocol-compliant implementation of the [Agent Client Protocol](https://agentclientprotocol.com/) for opencode. - -## Architecture - -The implementation follows a clean separation of concerns: - -### Core Components - -- **`agent.ts`** - Implements the `Agent` interface from `@agentclientprotocol/sdk` - - Handles initialization and capability negotiation - - Manages session lifecycle (`session/new`, `session/load`) - - Processes prompts and returns responses - - Properly implements ACP protocol v1 - -- **`client.ts`** - Implements the `Client` interface for client-side capabilities - - File operations (`readTextFile`, `writeTextFile`) - - Permission requests (auto-approves for now) - - Terminal support (stub implementation) - -- **`session.ts`** - Session state management - - Creates and tracks ACP sessions - - Maps ACP sessions to internal opencode sessions - - Maintains working directory context - - Handles MCP server configurations - -- **`server.ts`** - ACP server startup and lifecycle - - Sets up JSON-RPC over stdio using the official library - - Manages graceful shutdown on SIGTERM/SIGINT - - Provides Instance context for the agent - -- **`types.ts`** - Type definitions for internal use - -## Usage - -### Command Line - -```bash -# Start the ACP server in the current directory -opencode acp - -# Start in a specific directory -opencode acp --cwd /path/to/project -``` - -### Question Tool Opt-In - -ACP excludes `QuestionTool` by default. - -```bash -OPENCODE_ENABLE_QUESTION_TOOL=1 opencode acp -``` - -Enable this only for ACP clients that support interactive question prompts. - -### Programmatic - -```typescript -import { ACPServer } from "./acp/server" - -await ACPServer.start() -``` - -### Integration with Zed - -Add to your Zed configuration (`~/.config/zed/settings.json`): - -```json -{ - "agent_servers": { - "OpenCode": { - "command": "opencode", - "args": ["acp"] - } - } -} -``` - -## Protocol Compliance - -This implementation follows the ACP specification v1: - -✅ **Initialization** - -- Proper `initialize` request/response with protocol version negotiation -- Capability advertisement (`agentCapabilities`) -- Authentication support (stub) - -✅ **Session Management** - -- `session/new` - Create new conversation sessions -- `session/load` - Resume existing sessions (basic support) -- Working directory context (`cwd`) -- MCP server configuration support - -✅ **Prompting** - -- `session/prompt` - Process user messages -- Content block handling (text, resources) -- Response with stop reasons - -✅ **Client Capabilities** - -- File read/write operations -- Permission requests -- Terminal support (stub for future) - -## Current Limitations - -### Not Yet Implemented - -1. **Streaming Responses** - Currently returns complete responses instead of streaming via `session/update` notifications -2. **Tool Call Reporting** - Doesn't report tool execution progress -3. **Session Modes** - No mode switching support yet -4. **Authentication** - No actual auth implementation -5. **Terminal Support** - Placeholder only -6. **Session Persistence** - `session/load` doesn't restore actual conversation history - -### Future Enhancements - -- **Real-time Streaming**: Implement `session/update` notifications for progressive responses -- **Tool Call Visibility**: Report tool executions as they happen -- **Session Persistence**: Save and restore full conversation history -- **Mode Support**: Implement different operational modes (ask, code, etc.) -- **Enhanced Permissions**: More sophisticated permission handling -- **Terminal Integration**: Full terminal support via opencode's bash tool - -## Testing - -```bash -# Run ACP tests -bun test test/acp.test.ts - -# Test manually with stdio -echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}' | opencode acp -``` - -## Design Decisions - -### Why the Official Library? - -We use `@agentclientprotocol/sdk` instead of implementing JSON-RPC ourselves because: - -- Ensures protocol compliance -- Handles edge cases and future protocol versions -- Reduces maintenance burden -- Works with other ACP clients automatically - -### Clean Architecture - -Each component has a single responsibility: - -- **Agent** = Protocol interface -- **Client** = Client-side operations -- **Session** = State management -- **Server** = Lifecycle and I/O - -This makes the codebase maintainable and testable. - -### Mapping to OpenCode - -ACP sessions map cleanly to opencode's internal session model: - -- ACP `session/new` → creates internal Session -- ACP `session/prompt` → uses SessionPrompt.prompt() -- Working directory context preserved per-session -- Tool execution uses existing ToolRegistry - -## References - -- [ACP Specification](https://agentclientprotocol.com/) -- [TypeScript Library](https://github.com/agentclientprotocol/typescript-sdk) -- [Protocol Examples](https://github.com/agentclientprotocol/typescript-sdk/tree/main/src/examples) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 8b74b9c9ba..a7c59a2615 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -3,1964 +3,93 @@ import { type Agent as ACPAgent, type AgentSideConnection, type AuthenticateRequest, - type AuthMethod, type CancelNotification, type CloseSessionRequest, - type CloseSessionResponse, type ForkSessionRequest, - type ForkSessionResponse, type InitializeRequest, - type InitializeResponse, type ListSessionsRequest, - type ListSessionsResponse, type LoadSessionRequest, type NewSessionRequest, - type PermissionOption, - type PlanEntry, type PromptRequest, type ResumeSessionRequest, - type ResumeSessionResponse, - type Role, - type SessionInfo, - type SetSessionModelRequest, - type SessionConfigOption, type SetSessionConfigOptionRequest, - type SetSessionConfigOptionResponse, + type SetSessionModelRequest, type SetSessionModeRequest, - type SetSessionModeResponse, - type ToolCallContent, - type ToolKind, - type Usage, } from "@agentclientprotocol/sdk" - -import * as Log from "@opencode-ai/core/util/log" -import { pathToFileURL } from "url" -import { Filesystem } from "@/util/filesystem" -import { Hash } from "@opencode-ai/core/util/hash" -import { ACPSessionManager } from "./session" -import type { ACPConfig } from "./types" -import { ACPRuntime } from "./runtime" -import { Provider } from "@/provider/provider" -import { ModelID, ProviderID } from "../provider/schema" -import { MessageV2 } from "@/session/message-v2" -import { ConfigMCP } from "@/config/mcp" -import { Todo } from "@/session/todo" -import { Result, Schema } from "effect" -import { LoadAPIKeyError } from "ai" -import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" -import { applyPatch } from "diff" -import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { ShellID } from "@/tool/shell/id" - -type ModeOption = { id: string; name: string; description?: string } -type ModelOption = { modelId: string; name: string } -const decodeTodos = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Array(Todo.Info))) - -const DEFAULT_VARIANT_VALUE = "default" - -const log = Log.create({ service: "acp-agent" }) - -async function getContextLimit( - sdk: OpencodeClient, - providerID: ProviderID, - modelID: ModelID, - directory: string, -): Promise { - const providers = await sdk.config - .providers({ directory }) - .then((x) => x.data?.providers ?? []) - .catch((error) => { - log.error("failed to get providers for context limit", { error }) - return [] - }) - - const provider = providers.find((p) => p.id === providerID) - const model = provider?.models[modelID] - return model?.limit.context ?? null -} - -async function sendUsageUpdate( - connection: AgentSideConnection, - sdk: OpencodeClient, - sessionID: string, - directory: string, -): Promise { - const messages = await sdk.session - .messages({ sessionID, directory }, { throwOnError: true }) - .then((x) => x.data) - .catch((error) => { - log.error("failed to fetch messages for usage update", { error }) - return undefined - }) - - if (!messages) return - - const assistantMessages = messages.filter( - (m): m is { info: AssistantMessage; parts: SessionMessageResponse["parts"] } => m.info.role === "assistant", - ) - - const lastAssistant = assistantMessages[assistantMessages.length - 1] - if (!lastAssistant) return - - const msg = lastAssistant.info - if (!msg.providerID || !msg.modelID) return - const size = await getContextLimit(sdk, ProviderID.make(msg.providerID), ModelID.make(msg.modelID), directory) - - if (!size) { - // Cannot calculate usage without known context size - return - } - - const used = msg.tokens.input + (msg.tokens.cache?.read ?? 0) - const totalCost = assistantMessages.reduce((sum, m) => sum + m.info.cost, 0) - - await connection - .sessionUpdate({ - sessionId: sessionID, - update: { - sessionUpdate: "usage_update", - used, - size, - cost: { amount: totalCost, currency: "USD" }, - }, - }) - .catch((error) => { - log.error("failed to send usage update", { error }) - }) -} +import { Effect } from "effect" +import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import * as ACPError from "./error" +import * as ACPService from "./service" export function init({ sdk: _sdk }: { sdk: OpencodeClient }) { return { - create: (connection: AgentSideConnection, fullConfig: ACPConfig) => { - return new Agent(connection, fullConfig) + create: (connection: AgentSideConnection) => { + return new Agent(ACPService.make({ sdk: _sdk, connection })) }, } } export class Agent implements ACPAgent { - private connection: AgentSideConnection - private config: ACPConfig - private sdk: OpencodeClient - private sessionManager: ACPSessionManager - private eventAbort = new AbortController() - private eventStarted = false - private shellSnapshots = new Map() - private toolStarts = new Set() - private permissionQueues = new Map>() - private permissionOptions: PermissionOption[] = [ - { optionId: "once", kind: "allow_once", name: "Allow once" }, - { optionId: "always", kind: "allow_always", name: "Always allow" }, - { optionId: "reject", kind: "reject_once", name: "Reject" }, - ] + constructor(private readonly service: ACPService.Interface) {} - constructor(connection: AgentSideConnection, config: ACPConfig) { - this.connection = connection - this.config = config - this.sdk = config.sdk - this.sessionManager = new ACPSessionManager(this.sdk) - this.startEventSubscription() + initialize(params: InitializeRequest) { + return run(this.service.initialize(params)) } - private startEventSubscription() { - if (this.eventStarted) return - this.eventStarted = true - this.runEventSubscription().catch((error) => { - if (this.eventAbort.signal.aborted) return - log.error("event subscription failed", { error }) - }) + authenticate(params: AuthenticateRequest) { + return run(this.service.authenticate(params)) } - private async runEventSubscription() { - while (true) { - if (this.eventAbort.signal.aborted) return - const events = await this.sdk.global.event({ - signal: this.eventAbort.signal, - }) - for await (const event of events.stream) { - if (this.eventAbort.signal.aborted) return - const payload = event?.payload - if (!payload) continue - await this.handleEvent(payload as Event).catch((error) => { - log.error("failed to handle event", { error, type: payload.type }) - }) - } - } + newSession(params: NewSessionRequest) { + return run(this.service.newSession(params)) } - private async handleEvent(event: Event) { - switch (event.type) { - case "permission.asked": { - const permission = event.properties - const session = this.sessionManager.tryGet(permission.sessionID) - if (!session) return - - const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve() - const next = prev - .then(async () => { - const directory = session.cwd - - const res = await this.connection - .requestPermission({ - sessionId: permission.sessionID, - toolCall: { - toolCallId: permission.tool?.callID ?? permission.id, - status: "pending", - title: permission.permission, - rawInput: permission.metadata, - kind: toToolKind(permission.permission), - locations: toLocations(permission.permission, permission.metadata), - }, - options: this.permissionOptions, - }) - .catch(async (error) => { - log.error("failed to request permission from ACP", { - error, - permissionID: permission.id, - sessionID: permission.sessionID, - }) - await this.sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - directory, - }) - return undefined - }) - - if (!res) return - if (res.outcome.outcome !== "selected") { - await this.sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - directory, - }) - return - } - - if (res.outcome.optionId !== "reject" && permission.permission == "edit") { - const metadata = permission.metadata || {} - const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : "" - const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : "" - const content = (await Filesystem.exists(filepath)) ? await Filesystem.readText(filepath) : "" - const newContent = getNewContent(content, diff) - - if (newContent) { - void this.connection.writeTextFile({ - sessionId: session.id, - path: filepath, - content: newContent, - }) - } - } - - await this.sdk.permission.reply({ - requestID: permission.id, - reply: res.outcome.optionId as "once" | "always" | "reject", - directory, - }) - }) - .catch((error) => { - log.error("failed to handle permission", { error, permissionID: permission.id }) - }) - .finally(() => { - if (this.permissionQueues.get(permission.sessionID) === next) { - this.permissionQueues.delete(permission.sessionID) - } - }) - this.permissionQueues.set(permission.sessionID, next) - return - } - - case "message.part.updated": { - log.info("message part updated", { event: event.properties }) - const props = event.properties - const part = props.part - const session = this.sessionManager.tryGet(part.sessionID) - if (!session) return - const sessionId = session.id - - if (part.type === "tool") { - await this.toolStart(sessionId, part) - - switch (part.state.status) { - case "pending": - this.shellSnapshots.delete(part.callID) - return - - case "running": - const output = this.shellOutput(part) - const content: ToolCallContent[] = [] - if (output) { - const hash = Hash.fast(output) - if (part.tool === ShellID.ToolID) { - if (this.shellSnapshots.get(part.callID) === hash) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - }, - }) - .catch((error) => { - log.error("failed to send tool in_progress to ACP", { error }) - }) - return - } - this.shellSnapshots.set(part.callID, hash) - } - content.push({ - type: "content", - content: { - type: "text", - text: output, - }, - }) - } - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - ...(content.length > 0 && { content }), - }, - }) - .catch((error) => { - log.error("failed to send tool in_progress to ACP", { error }) - }) - return - - case "completed": { - this.toolStarts.delete(part.callID) - this.shellSnapshots.delete(part.callID) - const kind = toToolKind(part.tool) - const content = completedToolContent(part, kind) - - if (part.tool === "todowrite") { - const parsedTodos = decodeTodos(part.state.output) - if (Result.isSuccess(parsedTodos)) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "plan", - entries: parsedTodos.success.map((todo) => { - const status: PlanEntry["status"] = - todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"]) - return { - priority: "medium", - status, - content: todo.content, - } - }), - }, - }) - .catch((error) => { - log.error("failed to send session update for todo", { error }) - }) - } else { - log.error("failed to parse todo output", { error: parsedTodos.failure }) - } - } - - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "completed", - kind, - content, - title: part.state.title, - rawInput: part.state.input, - rawOutput: completedToolRawOutput(part), - }, - }) - .catch((error) => { - log.error("failed to send tool completed to ACP", { error }) - }) - return - } - case "error": - this.toolStarts.delete(part.callID) - this.shellSnapshots.delete(part.callID) - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "failed", - kind: toToolKind(part.tool), - title: part.tool, - rawInput: part.state.input, - content: [ - { - type: "content", - content: { - type: "text", - text: part.state.error, - }, - }, - ], - rawOutput: { - error: part.state.error, - metadata: part.state.metadata, - }, - }, - }) - .catch((error) => { - log.error("failed to send tool error to ACP", { error }) - }) - return - } - } - - // ACP clients already know the prompt they just submitted, so replaying - // live user parts duplicates the message. We still replay user history in - // loadSession() and forkSession() via processMessage(). - if (part.type !== "text" && part.type !== "file") return - - return - } - - case "message.part.delta": { - const props = event.properties - const session = this.sessionManager.tryGet(props.sessionID) - if (!session) return - const sessionId = session.id - - const message = await this.sdk.session - .message( - { - sessionID: props.sessionID, - messageID: props.messageID, - directory: session.cwd, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((error) => { - log.error("unexpected error when fetching message", { error }) - return undefined - }) - - if (!message || message.info.role !== "assistant") return - - const part = message.parts.find((p) => p.id === props.partID) - if (!part) return - - if (part.type === "text" && props.field === "text" && part.ignored !== true) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_message_chunk", - messageId: props.messageID, - content: { - type: "text", - text: props.delta, - }, - }, - }) - .catch((error) => { - log.error("failed to send text delta to ACP", { error }) - }) - return - } - - if (part.type === "reasoning" && props.field === "text") { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_thought_chunk", - messageId: props.messageID, - content: { - type: "text", - text: props.delta, - }, - }, - }) - .catch((error) => { - log.error("failed to send reasoning delta to ACP", { error }) - }) - } - return - } - } + loadSession(params: LoadSessionRequest) { + return run(this.service.loadSession(params)) } - async initialize(params: InitializeRequest): Promise { - log.info("initialize", { protocolVersion: params.protocolVersion }) - - const authMethod: AuthMethod = { - description: "Run `opencode auth login` in the terminal", - name: "Login with opencode", - id: "opencode-login", - } - - // If client supports terminal-auth capability, use that instead. - if (params.clientCapabilities?._meta?.["terminal-auth"] === true) { - authMethod._meta = { - "terminal-auth": { - command: "opencode", - args: ["auth", "login"], - label: "OpenCode Login", - }, - } - } - - return { - protocolVersion: 1, - agentCapabilities: { - loadSession: true, - mcpCapabilities: { - http: true, - sse: true, - }, - promptCapabilities: { - embeddedContext: true, - image: true, - }, - sessionCapabilities: { - close: {}, - fork: {}, - list: {}, - resume: {}, - }, - }, - authMethods: [authMethod], - agentInfo: { - name: "OpenCode", - version: InstallationVersion, - }, - } + listSessions(params: ListSessionsRequest) { + return run(this.service.listSessions(params)) } - async authenticate(_params: AuthenticateRequest) { - throw new Error("Authentication not implemented") + resumeSession(params: ResumeSessionRequest) { + return run(this.service.resumeSession(params)) } - async newSession(params: NewSessionRequest) { - const directory = params.cwd - try { - const model = await defaultModel(this.config, directory) - - // Store ACP session state - const state = await this.sessionManager.create(params.cwd, params.mcpServers, model) - const sessionId = state.id - - log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length }) - - const load = await this.loadSessionMode({ - cwd: directory, - mcpServers: params.mcpServers, - sessionId, - }) - - return { - sessionId, - configOptions: load.configOptions, - models: load.models, - modes: load.modes, - _meta: load._meta, - } - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } + closeSession(params: CloseSessionRequest) { + return run(this.service.closeSession(params)) } - async loadSession(params: LoadSessionRequest) { - const directory = params.cwd - const sessionId = params.sessionId - - try { - const model = await defaultModel(this.config, directory) - - // Store ACP session state - await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) - - const messages = await this.loadSessionMessages(directory, sessionId) - this.restoreSessionStateFromMessages(sessionId, messages) - - log.info("load_session", { sessionId, mcpServers: params.mcpServers.length }) - - const result = await this.loadSessionMode({ - cwd: directory, - mcpServers: params.mcpServers, - sessionId, - }) - - for (const msg of messages ?? []) { - log.debug("replay message", msg) - await this.processMessage(msg) - } - - await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) - - return result - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } + unstable_forkSession(params: ForkSessionRequest) { + return run(this.service.forkSession(params)) } - async listSessions(params: ListSessionsRequest): Promise { - try { - const cursor = params.cursor ? Number(params.cursor) : undefined - const limit = 100 - - const sessions = await this.sdk.session - .list( - { - directory: params.cwd ?? undefined, - roots: true, - }, - { throwOnError: true }, - ) - .then((x) => x.data ?? []) - - const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated) - const filtered = cursor ? sorted.filter((s) => s.time.updated < cursor) : sorted - const page = filtered.slice(0, limit) - - const entries: SessionInfo[] = page.map((session) => ({ - sessionId: session.id, - cwd: session.directory, - title: session.title, - updatedAt: new Date(session.time.updated).toISOString(), - })) - - const last = page[page.length - 1] - const next = filtered.length > limit && last ? String(last.time.updated) : undefined - - const response: ListSessionsResponse = { - sessions: entries, - } - if (next) response.nextCursor = next - return response - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } + setSessionConfigOption(params: SetSessionConfigOptionRequest) { + return run(this.service.setSessionConfigOption(params)) } - async unstable_forkSession(params: ForkSessionRequest): Promise { - const directory = params.cwd - const mcpServers = params.mcpServers ?? [] - - try { - const model = await defaultModel(this.config, directory) - - const forked = await this.sdk.session - .fork( - { - sessionID: params.sessionId, - directory, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - - if (!forked) { - throw new Error("Fork session returned no data") - } - - const sessionId = forked.id - await this.sessionManager.load(sessionId, directory, mcpServers, model) - - const messages = await this.loadSessionMessages(directory, sessionId) - this.restoreSessionStateFromMessages(sessionId, messages) - - log.info("fork_session", { sessionId, mcpServers: mcpServers.length }) - - const mode = await this.loadSessionMode({ - cwd: directory, - mcpServers, - sessionId, - }) - - for (const msg of messages ?? []) { - log.debug("replay message", msg) - await this.processMessage(msg) - } - - await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) - - return mode - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } + setSessionMode(params: SetSessionModeRequest) { + return run(this.service.setSessionMode(params)) } - async resumeSession(params: ResumeSessionRequest): Promise { - const directory = params.cwd - const sessionId = params.sessionId - const mcpServers = params.mcpServers ?? [] - - try { - const model = await defaultModel(this.config, directory) - await this.sessionManager.load(sessionId, directory, mcpServers, model) - - const messages = await this.loadSessionMessages(directory, sessionId, 20) - this.restoreSessionStateFromMessages(sessionId, messages) - - log.info("resume_session", { sessionId, mcpServers: mcpServers.length }) - - const result = await this.loadSessionMode({ - cwd: directory, - mcpServers, - sessionId, - }) - - await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) - - return result - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } + unstable_setSessionModel(params: SetSessionModelRequest) { + return run(this.service.setSessionModel(params)) } - async closeSession(params: CloseSessionRequest): Promise { - const session = this.sessionManager.remove(params.sessionId) - if (!session) return {} - - await this.sdk.session - .abort( - { - sessionID: params.sessionId, - directory: session.cwd, - }, - { throwOnError: true }, - ) - .catch((error) => { - log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId }) - }) - - this.permissionQueues.delete(params.sessionId) - log.info("close_session", { sessionId: params.sessionId }) - return {} + prompt(params: PromptRequest) { + return run(this.service.prompt(params)) } - private async processMessage(message: SessionMessageResponse) { - log.debug("process message", message) - if (message.info.role !== "assistant" && message.info.role !== "user") return - const sessionId = message.info.sessionID - - for (const part of message.parts) { - if (part.type === "tool") { - await this.toolStart(sessionId, part) - switch (part.state.status) { - case "pending": - this.shellSnapshots.delete(part.callID) - break - case "running": - const output = this.shellOutput(part) - const runningContent: ToolCallContent[] = [] - if (output) { - runningContent.push({ - type: "content", - content: { - type: "text", - text: output, - }, - }) - } - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - ...(runningContent.length > 0 && { content: runningContent }), - }, - }) - .catch((err) => { - log.error("failed to send tool in_progress to ACP", { error: err }) - }) - break - case "completed": - this.toolStarts.delete(part.callID) - this.shellSnapshots.delete(part.callID) - const kind = toToolKind(part.tool) - const content = completedToolContent(part, kind) - - if (part.tool === "todowrite") { - const parsedTodos = decodeTodos(part.state.output) - if (Result.isSuccess(parsedTodos)) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "plan", - entries: parsedTodos.success.map((todo) => { - const status: PlanEntry["status"] = - todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"]) - return { - priority: "medium", - status, - content: todo.content, - } - }), - }, - }) - .catch((err) => { - log.error("failed to send session update for todo", { error: err }) - }) - } else { - log.error("failed to parse todo output", { error: parsedTodos.failure }) - } - } - - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "completed", - kind, - content, - title: part.state.title, - rawInput: part.state.input, - rawOutput: completedToolRawOutput(part), - }, - }) - .catch((err) => { - log.error("failed to send tool completed to ACP", { error: err }) - }) - break - case "error": - this.toolStarts.delete(part.callID) - this.shellSnapshots.delete(part.callID) - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "failed", - kind: toToolKind(part.tool), - title: part.tool, - rawInput: part.state.input, - content: [ - { - type: "content", - content: { - type: "text", - text: part.state.error, - }, - }, - ], - rawOutput: { - error: part.state.error, - metadata: part.state.metadata, - }, - }, - }) - .catch((err) => { - log.error("failed to send tool error to ACP", { error: err }) - }) - break - } - } else if (part.type === "text") { - if (part.text) { - const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk", - messageId: message.info.id, - content: { - type: "text", - text: part.text, - ...(audience && { annotations: { audience } }), - }, - }, - }) - .catch((err) => { - log.error("failed to send text to ACP", { error: err }) - }) - } - } else if (part.type === "file") { - // Replay file attachments as appropriate ACP content blocks. - // OpenCode stores files internally as { type: "file", url, filename, mime }. - // We convert these back to ACP blocks based on the URL scheme and MIME type: - // - file:// URLs → resource_link - // - data: URLs with image/* → image block - // - data: URLs with text/* or application/json → resource with text - // - data: URLs with other types → resource with blob - const url = part.url - const filename = part.filename ?? "file" - const mime = part.mime || "application/octet-stream" - const messageChunk = message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk" - - if (url.startsWith("file://")) { - // Local file reference - send as resource_link - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: messageChunk, - messageId: message.info.id, - content: { type: "resource_link", uri: url, name: filename, mimeType: mime }, - }, - }) - .catch((err) => { - log.error("failed to send resource_link to ACP", { error: err }) - }) - } else if (url.startsWith("data:")) { - // Embedded content - parse data URL and send as appropriate block type - const base64Match = url.match(/^data:([^;]+);base64,(.*)$/) - const dataMime = base64Match?.[1] - const base64Data = base64Match?.[2] ?? "" - - const effectiveMime = dataMime || mime - - if (effectiveMime.startsWith("image/")) { - // Image - send as image block - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: messageChunk, - messageId: message.info.id, - content: { - type: "image", - mimeType: effectiveMime, - data: base64Data, - uri: pathToFileURL(filename).href, - }, - }, - }) - .catch((err) => { - log.error("failed to send image to ACP", { error: err }) - }) - } else { - // Non-image: text types get decoded, binary types stay as blob - const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json" - const fileUri = pathToFileURL(filename).href - const resource = isText - ? { - uri: fileUri, - mimeType: effectiveMime, - text: Buffer.from(base64Data, "base64").toString("utf-8"), - } - : { uri: fileUri, mimeType: effectiveMime, blob: base64Data } - - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: messageChunk, - messageId: message.info.id, - content: { type: "resource", resource }, - }, - }) - .catch((err) => { - log.error("failed to send resource to ACP", { error: err }) - }) - } - } - // URLs that don't match file:// or data: are skipped (unsupported) - } else if (part.type === "reasoning") { - if (part.text) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_thought_chunk", - messageId: message.info.id, - content: { - type: "text", - text: part.text, - }, - }, - }) - .catch((err) => { - log.error("failed to send reasoning to ACP", { error: err }) - }) - } - } - } - } - - private shellOutput(part: ToolPart) { - if (part.tool !== ShellID.ToolID) return - if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return - const output = part.state.metadata["output"] - if (typeof output !== "string") return - return output - } - - private async toolStart(sessionId: string, part: ToolPart) { - if (this.toolStarts.has(part.callID)) return - this.toolStarts.add(part.callID) - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call", - toolCallId: part.callID, - title: part.tool, - kind: toToolKind(part.tool), - status: "pending", - locations: [], - rawInput: {}, - }, - }) - .catch((error) => { - log.error("failed to send tool pending to ACP", { error }) - }) - } - - private async loadAvailableModes(directory: string): Promise { - const agents = await this.config.sdk.app - .agents( - { - directory, - }, - { throwOnError: true }, - ) - .then((resp) => resp.data!) - - return agents - .filter((agent) => agent.mode !== "subagent" && !agent.hidden) - .map((agent) => ({ - id: agent.name, - name: agent.name, - description: agent.description, - })) - } - - private async resolveModeState( - directory: string, - sessionId: string, - ): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> { - const availableModes = await this.loadAvailableModes(directory) - const storedModeId = this.sessionManager.get(sessionId).modeId - if (storedModeId && availableModes.some((mode) => mode.id === storedModeId)) { - return { availableModes, currentModeId: storedModeId } - } - - const currentModeId = await (async () => { - if (!availableModes.length) return undefined - const defaultAgent = await ACPRuntime.defaultAgentInfo(directory) - const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id - this.sessionManager.setMode(sessionId, resolvedModeId) - return resolvedModeId - })() - - return { availableModes, currentModeId } - } - - private async loadSessionMode(params: LoadSessionRequest) { - const directory = params.cwd - const sessionId = params.sessionId - const model = this.sessionManager.get(sessionId).model ?? (await defaultModel(this.config, directory)) - - const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers) - const entries = sortProvidersByName(providers) - const availableVariants = modelVariantsFromProviders(entries, model) - const currentVariant = this.sessionManager.getVariant(sessionId) - if (currentVariant && !availableVariants.includes(currentVariant)) { - this.sessionManager.setVariant(sessionId, undefined) - } - const availableModels = buildAvailableModels(entries) - const modeState = await this.resolveModeState(directory, sessionId) - const currentModeId = modeState.currentModeId - const modes = currentModeId - ? { - availableModes: modeState.availableModes, - currentModeId, - } - : undefined - - const commands = await this.config.sdk.command - .list( - { - directory, - }, - { throwOnError: true }, - ) - .then((resp) => resp.data!) - - const availableCommands = commands.map((command) => ({ - name: command.name, - description: command.description ?? "", - })) - const names = new Set(availableCommands.map((c) => c.name)) - if (!names.has("compact")) - availableCommands.push({ - name: "compact", - description: "compact the session", - }) - - const mcpServers: Record = {} - for (const server of params.mcpServers) { - if ("type" in server) { - mcpServers[server.name] = { - url: server.url, - headers: server.headers.reduce>((acc, { name, value }) => { - acc[name] = value - return acc - }, {}), - type: "remote", - } - } else { - mcpServers[server.name] = { - type: "local", - command: [server.command, ...server.args], - environment: server.env.reduce>((acc, { name, value }) => { - acc[name] = value - return acc - }, {}), - } - } - } - - await Promise.all( - Object.entries(mcpServers).map(async ([key, mcp]) => { - await this.sdk.mcp - .add( - { - directory, - name: key, - config: mcp, - }, - { throwOnError: true }, - ) - .catch((error) => { - log.error("failed to add mcp server", { name: key, error }) - }) - }), - ) - - setTimeout(() => { - void this.connection.sessionUpdate({ - sessionId, - update: { - sessionUpdate: "available_commands_update", - availableCommands, - }, - }) - }, 0) - - return { - sessionId, - models: { - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false), - availableModels, - }, - modes, - configOptions: buildConfigOptions({ - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false), - availableModels, - currentVariant, - availableVariants, - modes, - }), - _meta: buildVariantMeta({ - model, - variant: this.sessionManager.getVariant(sessionId), - availableVariants, - }), - } - } - - async unstable_setSessionModel(params: SetSessionModelRequest) { - const session = this.sessionManager.get(params.sessionId) - const providers = await this.sdk.config - .providers({ directory: session.cwd }, { throwOnError: true }) - .then((x) => x.data!.providers) - - const selection = parseModelSelection(params.modelId, providers) - this.sessionManager.setModel(session.id, selection.model) - this.sessionManager.setVariant(session.id, selection.variant) - - const entries = sortProvidersByName(providers) - const availableVariants = modelVariantsFromProviders(entries, selection.model) - const modeState = await this.resolveModeState(session.cwd, session.id) - const modes = modeState.currentModeId - ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId } - : undefined - - await this.connection.sessionUpdate({ - sessionId: session.id, - update: { - sessionUpdate: "config_option_update", - configOptions: buildConfigOptions({ - currentModelId: formatModelIdWithVariant(selection.model, selection.variant, availableVariants, false), - availableModels: buildAvailableModels(entries), - currentVariant: selection.variant, - availableVariants, - modes, - }), - }, - }) - - return { - _meta: buildVariantMeta({ - model: selection.model, - variant: selection.variant, - availableVariants, - }), - } - } - - async setSessionMode(params: SetSessionModeRequest): Promise { - const session = this.sessionManager.get(params.sessionId) - const availableModes = await this.loadAvailableModes(session.cwd) - if (!availableModes.some((mode) => mode.id === params.modeId)) { - throw new Error(`Agent not found: ${params.modeId}`) - } - this.sessionManager.setMode(params.sessionId, params.modeId) - } - - async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise { - const session = this.sessionManager.get(params.sessionId) - const providers = await this.sdk.config - .providers({ directory: session.cwd }, { throwOnError: true }) - .then((x) => x.data!.providers) - const entries = sortProvidersByName(providers) - - if (params.configId === "model") { - if (typeof params.value !== "string") throw RequestError.invalidParams("model value must be a string") - const selection = parseModelSelection(params.value, providers) - this.sessionManager.setModel(session.id, selection.model) - this.sessionManager.setVariant(session.id, selection.variant) - } else if (params.configId === "effort") { - if (typeof params.value !== "string") throw RequestError.invalidParams("effort value must be a string") - const current = session.model ?? (await defaultModel(this.config, session.cwd)) - const availableVariants = modelVariantsFromProviders(entries, current) - if (!availableVariants.includes(params.value)) { - throw RequestError.invalidParams(JSON.stringify({ error: `Effort not found: ${params.value}` })) - } - this.sessionManager.setVariant(session.id, params.value) - } else if (params.configId === "mode") { - if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string") - const availableModes = await this.loadAvailableModes(session.cwd) - if (!availableModes.some((mode) => mode.id === params.value)) { - throw RequestError.invalidParams(JSON.stringify({ error: `Mode not found: ${params.value}` })) - } - this.sessionManager.setMode(session.id, params.value) - } else { - throw RequestError.invalidParams(JSON.stringify({ error: `Unknown config option: ${params.configId}` })) - } - - const updatedSession = this.sessionManager.get(session.id) - const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd)) - const availableVariants = modelVariantsFromProviders(entries, model) - const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, false) - const availableModels = buildAvailableModels(entries) - const modeState = await this.resolveModeState(session.cwd, session.id) - const modes = modeState.currentModeId - ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId } - : undefined - - return { - configOptions: buildConfigOptions({ - currentModelId, - availableModels, - currentVariant: updatedSession.variant, - availableVariants, - modes, - }), - } - } - - async prompt(params: PromptRequest) { - const sessionID = params.sessionId - const session = this.sessionManager.get(sessionID) - const directory = session.cwd - - const current = session.model - const model = current ?? (await defaultModel(this.config, directory)) - if (!current) { - this.sessionManager.setModel(session.id, model) - } - const agent = session.modeId ?? (await ACPRuntime.defaultAgentInfo(directory)).name - - const parts: Array< - | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } - | { type: "file"; url: string; filename: string; mime: string } - > = [] - for (const part of params.prompt) { - switch (part.type) { - case "text": - const audience = part.annotations?.audience - const forAssistant = audience?.length === 1 && audience[0] === "assistant" - const forUser = audience?.length === 1 && audience[0] === "user" - parts.push({ - type: "text" as const, - text: part.text, - ...(forAssistant && { synthetic: true }), - ...(forUser && { ignored: true }), - }) - break - case "image": { - const parsed = parseUri(part.uri ?? "") - const filename = parsed.type === "file" ? parsed.filename : "image" - if (part.data) { - parts.push({ - type: "file", - url: `data:${part.mimeType};base64,${part.data}`, - filename, - mime: part.mimeType, - }) - } else if (part.uri && part.uri.startsWith("http:")) { - parts.push({ - type: "file", - url: part.uri, - filename, - mime: part.mimeType, - }) - } - break - } - - case "resource_link": - const parsed = parseUri(part.uri) - // Use the name from resource_link if available - if (part.name && parsed.type === "file") { - parsed.filename = part.name - } - parts.push(parsed) - - break - - case "resource": { - const resource = part.resource - if ("text" in resource && resource.text) { - parts.push({ - type: "text", - text: resource.text, - }) - } else if ("blob" in resource && resource.blob && resource.mimeType) { - // Binary resource (PDFs, etc.): store as file part with data URL - const parsed = parseUri(resource.uri ?? "") - const filename = parsed.type === "file" ? parsed.filename : "file" - parts.push({ - type: "file", - url: `data:${resource.mimeType};base64,${resource.blob}`, - filename, - mime: resource.mimeType, - }) - } - break - } - - default: - break - } - } - - log.info("parts", { parts }) - - const cmd = (() => { - const text = parts - .filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => p.text) - .join("") - .trim() - - if (!text.startsWith("/")) return - - const [name, ...rest] = text.slice(1).split(/\s+/) - return { name, args: rest.join(" ").trim() } - })() - - const buildUsage = (msg: AssistantMessage): Usage => ({ - totalTokens: - msg.tokens.input + - msg.tokens.output + - msg.tokens.reasoning + - (msg.tokens.cache?.read ?? 0) + - (msg.tokens.cache?.write ?? 0), - inputTokens: msg.tokens.input, - outputTokens: msg.tokens.output, - thoughtTokens: msg.tokens.reasoning || undefined, - cachedReadTokens: msg.tokens.cache?.read || undefined, - cachedWriteTokens: msg.tokens.cache?.write || undefined, - }) - - if (!cmd) { - const response = await this.sdk.session.prompt({ - sessionID, - model: { - providerID: model.providerID, - modelID: model.modelID, - }, - variant: this.sessionManager.getVariant(sessionID), - parts, - agent, - directory, - }) - const msg = response.data?.info - - await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) - - return { - stopReason: "end_turn" as const, - usage: msg ? buildUsage(msg) : undefined, - _meta: {}, - } - } - - const command = await this.config.sdk.command - .list({ directory }, { throwOnError: true }) - .then((x) => x.data!.find((c) => c.name === cmd.name)) - if (command) { - const response = await this.sdk.session.command({ - sessionID, - command: command.name, - arguments: cmd.args, - model: model.providerID + "/" + model.modelID, - agent, - directory, - }) - const msg = response.data?.info - - await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) - - return { - stopReason: "end_turn" as const, - usage: msg ? buildUsage(msg) : undefined, - _meta: {}, - } - } - - switch (cmd.name) { - case "compact": - await this.config.sdk.session.summarize( - { - sessionID, - directory, - providerID: model.providerID, - modelID: model.modelID, - }, - { throwOnError: true }, - ) - break - } - - await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) - - return { - stopReason: "end_turn" as const, - _meta: {}, - } - } - - async cancel(params: CancelNotification) { - const session = this.sessionManager.get(params.sessionId) - await this.config.sdk.session.abort( - { - sessionID: params.sessionId, - directory: session.cwd, - }, - { throwOnError: true }, - ) - } - - private async loadSessionMessages(directory: string, sessionId: string, limit?: number) { - return this.sdk.session - .messages( - { - sessionID: sessionId, - directory, - limit, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((error) => { - log.error("unexpected error when fetching message", { error }) - return undefined - }) - } - - private restoreSessionStateFromMessages(sessionId: string, messages: SessionMessageResponse[] | undefined) { - const lastUser = messages?.findLast((message) => message.info.role === "user")?.info - if (lastUser?.role !== "user") return - - this.sessionManager.setModel(sessionId, { - providerID: ProviderID.make(lastUser.model.providerID), - modelID: ModelID.make(lastUser.model.modelID), - }) - this.sessionManager.setVariant(sessionId, lastUser.model.variant) - if (lastUser.agent) { - this.sessionManager.setMode(sessionId, lastUser.agent) - } + cancel(params: CancelNotification) { + return run(this.service.cancel(params)) } } -function toToolKind(toolName: string): ToolKind { - const tool = toolName.toLocaleLowerCase() - - switch (tool) { - case ShellID.ToolID: - return "execute" - - case "webfetch": - return "fetch" - - case "edit": - case "patch": - case "write": - return "edit" - - case "grep": - case "glob": - case "repo_clone": - case "repo_overview": - case "context7_resolve_library_id": - case "context7_get_library_docs": - return "search" - - case "read": - return "read" - - default: - return "other" - } -} - -function toLocations(toolName: string, input: Record): { path: string }[] { - const tool = toolName.toLocaleLowerCase() - - switch (tool) { - case "read": - case "edit": - case "write": - return input["filePath"] ? [{ path: input["filePath"] }] : [] - case "glob": - case "grep": - return input["path"] ? [{ path: input["path"] }] : [] - case "repo_clone": - return input["path"] ? [{ path: input["path"] }] : [] - case "repo_overview": - return input["path"] ? [{ path: input["path"] }] : [] - case ShellID.ToolID: - return [] - default: - return [] - } -} - -function completedToolContent(part: ToolPart, kind: ToolKind): ToolCallContent[] { - if (part.state.status !== "completed") return [] - - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } - - content.push(...imageContents(part.state.attachments ?? [])) - return content -} - -function completedToolRawOutput(part: ToolPart) { - if (part.state.status !== "completed") return {} - return { - output: part.state.output, - metadata: part.state.metadata, - ...(part.state.attachments?.length ? { attachments: part.state.attachments } : {}), - } -} - -function imageContents(attachments: Array<{ mime: string; url: string }>): ToolCallContent[] { - return attachments.flatMap((attachment): ToolCallContent[] => { - const match = attachment.url.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/) - const mime = match?.[1] ?? attachment.mime - if (!mime.startsWith("image/")) return [] - const data = match?.[2] - if (data === undefined) return [] - return [ - { - type: "content" as const, - content: { - type: "image" as const, - mimeType: mime, - data, - }, - }, - ] +function run(effect: Effect.Effect) { + return Effect.runPromise(effect.pipe(Effect.mapError(ACPError.toRequestError))).catch((defect: unknown) => { + if (defect instanceof RequestError) throw defect + throw ACPError.toRequestError(ACPError.fromUnknownDefect(defect)) }) } -async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> { - const sdk = config.sdk - const configured = config.defaultModel - if (configured) return configured - - const directory = cwd ?? process.cwd() - - const specified = await sdk.config - .get({ directory }, { throwOnError: true }) - .then((resp) => { - const cfg = resp.data - if (!cfg || !cfg.model) return undefined - return Provider.parseModel(cfg.model) - }) - .catch((error) => { - log.error("failed to load user config for default model", { error }) - return undefined - }) - - const providers = await sdk.config - .providers({ directory }, { throwOnError: true }) - .then((x) => x.data?.providers ?? []) - .catch((error) => { - log.error("failed to list providers for default model", { error }) - return [] - }) - - if (specified && providers.length) { - const provider = providers.find((p) => p.id === specified.providerID) - if (provider && provider.models[specified.modelID]) return specified - } - - if (specified && !providers.length) return specified - - const lastUsed = await lastUsedModel(sdk, directory, providers) - if (lastUsed) return lastUsed - - const opencodeProvider = providers.find((p) => p.id === "opencode") - if (opencodeProvider) { - const [best] = Provider.sort(Object.values(opencodeProvider.models)) - if (best) { - return { - providerID: ProviderID.make(best.providerID), - modelID: ModelID.make(best.id), - } - } - } - - const models = providers.flatMap((p) => Object.values(p.models)) - const [best] = Provider.sort(models) - if (best) { - return { - providerID: ProviderID.make(best.providerID), - modelID: ModelID.make(best.id), - } - } - - if (specified) return specified - throw new Error("No models available") -} - -async function lastUsedModel( - sdk: OpencodeClient, - directory: string, - providers: Array<{ id: string; models: Record }>, -): Promise<{ providerID: ProviderID; modelID: ModelID } | undefined> { - const session = await sdk.session - .list({ directory, roots: true, limit: 1 }, { throwOnError: true }) - .then((x) => x.data?.[0]) - .catch((error) => { - log.error("failed to list sessions for default model", { error }) - return undefined - }) - if (!session) return - - const lastUser = await sdk.session - .messages({ sessionID: session.id, directory, limit: 20 }, { throwOnError: true }) - .then((x) => x.data?.findLast((message) => message.info.role === "user")?.info) - .catch((error) => { - log.error("failed to load session messages for default model", { error, sessionID: session.id }) - return undefined - }) - if (lastUser?.role !== "user") return - - const provider = providers.find((entry) => entry.id === lastUser.model.providerID) - if (!provider?.models[lastUser.model.modelID]) return - return { - providerID: ProviderID.make(lastUser.model.providerID), - modelID: ModelID.make(lastUser.model.modelID), - } -} - -function parseUri( - uri: string, -): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } { - try { - if (uri.startsWith("file://")) { - const path = uri.slice(7) - const name = path.split("/").pop() || path - return { - type: "file", - url: uri, - filename: name, - mime: "text/plain", - } - } - if (uri.startsWith("zed://")) { - const url = new URL(uri) - const path = url.searchParams.get("path") - if (path) { - const name = path.split("/").pop() || path - return { - type: "file", - url: pathToFileURL(path).href, - filename: name, - mime: "text/plain", - } - } - } - return { - type: "text", - text: uri, - } - } catch { - return { - type: "text", - text: uri, - } - } -} - -function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined { - const result = applyPatch(fileOriginal, unifiedDiff) - if (result === false) { - log.error("Failed to apply unified diff (context mismatch)") - return undefined - } - return result -} - -function sortProvidersByName(providers: T[]): T[] { - return [...providers].sort((a, b) => { - const nameA = a.name.toLowerCase() - const nameB = b.name.toLowerCase() - if (nameA < nameB) return -1 - if (nameA > nameB) return 1 - return 0 - }) -} - -function modelVariantsFromProviders( - providers: Array<{ id: string; models: Record }> }>, - model: { providerID: ProviderID; modelID: ModelID }, -): string[] { - const provider = providers.find((entry) => entry.id === model.providerID) - if (!provider) return [] - const modelInfo = provider.models[model.modelID] - if (!modelInfo?.variants) return [] - return Object.keys(modelInfo.variants) -} - -function buildAvailableModels( - providers: Array<{ id: string; name: string; models: Record }>, - options: { includeVariants?: boolean } = {}, -): ModelOption[] { - const includeVariants = options.includeVariants ?? false - return providers.flatMap((provider) => { - const unsorted: Array<{ id: string; name: string; variants?: Record }> = Object.values(provider.models) - const models = Provider.sort(unsorted) - return models.flatMap((model) => { - const base: ModelOption = { - modelId: `${provider.id}/${model.id}`, - name: `${provider.name}/${model.name}`, - } - if (!includeVariants || !model.variants) return [base] - const variants = Object.keys(model.variants).filter((variant) => variant !== DEFAULT_VARIANT_VALUE) - const variantOptions = variants.map((variant) => ({ - modelId: `${provider.id}/${model.id}/${variant}`, - name: `${provider.name}/${model.name} (${variant})`, - })) - return [base, ...variantOptions] - }) - }) -} - -function formatModelIdWithVariant( - model: { providerID: ProviderID; modelID: ModelID }, - variant: string | undefined, - availableVariants: string[], - includeVariant: boolean, -) { - const base = `${model.providerID}/${model.modelID}` - if (!includeVariant || availableVariants.length === 0) return base - const selectedVariant = - variant && availableVariants.includes(variant) - ? variant - : availableVariants.includes(DEFAULT_VARIANT_VALUE) - ? DEFAULT_VARIANT_VALUE - : availableVariants[0] - return `${base}/${selectedVariant}` -} - -function buildVariantMeta(input: { - model: { providerID: ProviderID; modelID: ModelID } - variant?: string - availableVariants: string[] -}) { - return { - opencode: { - modelId: `${input.model.providerID}/${input.model.modelID}`, - variant: input.variant ?? null, - availableVariants: input.availableVariants, - }, - } -} - -function parseModelSelection( - modelId: string, - providers: Array<{ id: string; models: Record }> }>, -): { model: { providerID: ProviderID; modelID: ModelID }; variant?: string } { - const parsed = Provider.parseModel(modelId) - const provider = providers.find((p) => p.id === parsed.providerID) - if (!provider) { - return { model: parsed, variant: undefined } - } - - // Check if modelID exists directly - if (provider.models[parsed.modelID]) { - return { model: parsed, variant: undefined } - } - - // Try to extract variant from end of modelID (e.g., "claude-sonnet-4/high" -> model: "claude-sonnet-4", variant: "high") - const segments = parsed.modelID.split("/") - if (segments.length > 1) { - const candidateVariant = segments[segments.length - 1] - const baseModelId = segments.slice(0, -1).join("/") - const baseModelInfo = provider.models[baseModelId] - if (baseModelInfo?.variants && candidateVariant in baseModelInfo.variants) { - return { - model: { providerID: parsed.providerID, modelID: ModelID.make(baseModelId) }, - variant: candidateVariant, - } - } - } - - return { model: parsed, variant: undefined } -} - -function buildConfigOptions(input: { - currentModelId: string - availableModels: ModelOption[] - currentVariant?: string - availableVariants?: string[] - modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined -}): SessionConfigOption[] { - const options: SessionConfigOption[] = [ - { - id: "model", - name: "Model", - category: "model", - type: "select", - currentValue: input.currentModelId, - options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })), - }, - ] - if (input.availableVariants?.length) { - options.push({ - id: "effort", - name: "Effort", - description: "Available effort levels for this model", - category: "thought_level", - type: "select", - currentValue: - input.currentVariant && input.availableVariants.includes(input.currentVariant) - ? input.currentVariant - : input.availableVariants.includes(DEFAULT_VARIANT_VALUE) - ? DEFAULT_VARIANT_VALUE - : input.availableVariants[0], - options: input.availableVariants.map((variant) => ({ value: variant, name: formatVariantName(variant) })), - }) - } - if (input.modes) { - options.push({ - id: "mode", - name: "Session Mode", - category: "mode", - type: "select", - currentValue: input.modes.currentModeId, - options: input.modes.availableModes.map((m) => ({ - value: m.id, - name: m.name, - ...(m.description ? { description: m.description } : {}), - })), - }) - } - return options -} - -function formatVariantName(variant: string) { - return variant - .split(/[_-]/) - .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) - .join(" ") -} - export * as ACP from "./agent" diff --git a/packages/opencode/src/acp-next/config-option.ts b/packages/opencode/src/acp/config-option.ts similarity index 100% rename from packages/opencode/src/acp-next/config-option.ts rename to packages/opencode/src/acp/config-option.ts diff --git a/packages/opencode/src/acp-next/content.ts b/packages/opencode/src/acp/content.ts similarity index 100% rename from packages/opencode/src/acp-next/content.ts rename to packages/opencode/src/acp/content.ts diff --git a/packages/opencode/src/acp-next/directory.ts b/packages/opencode/src/acp/directory.ts similarity index 93% rename from packages/opencode/src/acp-next/directory.ts rename to packages/opencode/src/acp/directory.ts index 90ffa36358..c49613dd06 100644 --- a/packages/opencode/src/acp-next/directory.ts +++ b/packages/opencode/src/acp/directory.ts @@ -5,7 +5,7 @@ import { InstanceStore } from "@/project/instance-store" import { ModelID, ProviderID } from "@/provider/schema" import { Provider } from "@/provider/provider" import { Context, Effect, Layer, SynchronizedRef } from "effect" -import type * as ACPNextError from "./error" +import type * as ACPError from "./error" export type ModelOption = { readonly providerID: ProviderID @@ -39,18 +39,18 @@ export type Snapshot = { } export interface LoaderInterface { - readonly load: (directory: string) => Effect.Effect + readonly load: (directory: string) => Effect.Effect } export interface Interface { - readonly get: (directory: string) => Effect.Effect - readonly refresh: (directory: string) => Effect.Effect + readonly get: (directory: string) => Effect.Effect + readonly refresh: (directory: string) => Effect.Effect readonly variants: (snapshot: Snapshot, model: DefaultModel) => ModelVariants | undefined } -export class Loader extends Context.Service()("@opencode/ACPNextDirectoryLoader") {} +export class Loader extends Context.Service()("@opencode/ACPDirectoryLoader") {} -export class Service extends Context.Service()("@opencode/ACPNextDirectory") {} +export class Service extends Context.Service()("@opencode/ACPDirectory") {} export const modelKey = (model: DefaultModel) => `${model.providerID}/${model.modelID}` @@ -110,7 +110,7 @@ export const loaderLayer = Layer.effect( const command = yield* Command.Service return Loader.of({ - load: Effect.fn("ACPNextDirectoryLoader.load")(function* (directory) { + load: Effect.fn("ACPDirectoryLoader.load")(function* (directory) { const ctx = yield* store.load({ directory }) return yield* Effect.gen(function* () { const providers = yield* provider.list() @@ -142,7 +142,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const loader = yield* Loader - const snapshots = yield* SynchronizedRef.make(new Map>()) + const snapshots = yield* SynchronizedRef.make(new Map>()) const cached = Effect.fnUntraced(function* (directory: string) { return yield* SynchronizedRef.modifyEffect( @@ -166,11 +166,11 @@ export const layer = Layer.effect( ) }) - const get = Effect.fn("ACPNextDirectory.get")(function* (directory: string) { + const get = Effect.fn("ACPDirectory.get")(function* (directory: string) { return yield* yield* cached(directory) }) - const refresh = Effect.fn("ACPNextDirectory.refresh")(function* (directory: string) { + const refresh = Effect.fn("ACPDirectory.refresh")(function* (directory: string) { return yield* SynchronizedRef.modifyEffect( snapshots, Effect.fnUntraced(function* (items) { diff --git a/packages/opencode/src/acp-next/error.ts b/packages/opencode/src/acp/error.ts similarity index 75% rename from packages/opencode/src/acp-next/error.ts rename to packages/opencode/src/acp/error.ts index 1d4af53b50..0c66a0e049 100644 --- a/packages/opencode/src/acp-next/error.ts +++ b/packages/opencode/src/acp/error.ts @@ -1,52 +1,49 @@ import { RequestError } from "@agentclientprotocol/sdk" import { Schema } from "effect" -export class SessionNotFoundError extends Schema.TaggedErrorClass()( - "ACPNextSessionNotFoundError", - { - sessionId: Schema.String, - }, -) {} +export class SessionNotFoundError extends Schema.TaggedErrorClass()("ACPSessionNotFoundError", { + sessionId: Schema.String, +}) {} export class InvalidConfigOptionError extends Schema.TaggedErrorClass()( - "ACPNextInvalidConfigOptionError", + "ACPInvalidConfigOptionError", { configId: Schema.String, }, ) {} -export class InvalidModelError extends Schema.TaggedErrorClass()("ACPNextInvalidModelError", { +export class InvalidModelError extends Schema.TaggedErrorClass()("ACPInvalidModelError", { modelId: Schema.String, providerId: Schema.optional(Schema.String), }) {} -export class InvalidEffortError extends Schema.TaggedErrorClass()("ACPNextInvalidEffortError", { +export class InvalidEffortError extends Schema.TaggedErrorClass()("ACPInvalidEffortError", { effort: Schema.String, }) {} -export class InvalidModeError extends Schema.TaggedErrorClass()("ACPNextInvalidModeError", { +export class InvalidModeError extends Schema.TaggedErrorClass()("ACPInvalidModeError", { mode: Schema.String, }) {} -export class AuthRequiredError extends Schema.TaggedErrorClass()("ACPNextAuthRequiredError", { +export class AuthRequiredError extends Schema.TaggedErrorClass()("ACPAuthRequiredError", { providerId: Schema.optional(Schema.String), }) {} export class UnknownAuthMethodError extends Schema.TaggedErrorClass()( - "ACPNextUnknownAuthMethodError", + "ACPUnknownAuthMethodError", { methodId: Schema.String, }, ) {} export class UnsupportedOperationError extends Schema.TaggedErrorClass()( - "ACPNextUnsupportedOperationError", + "ACPUnsupportedOperationError", { method: Schema.String, }, ) {} -export class ServiceFailureError extends Schema.TaggedErrorClass()("ACPNextServiceFailureError", { +export class ServiceFailureError extends Schema.TaggedErrorClass()("ACPServiceFailureError", { safeMessage: Schema.String, service: Schema.optional(Schema.String), }) {} @@ -64,26 +61,26 @@ export type Error = export function toRequestError(error: Error) { switch (error._tag) { - case "ACPNextSessionNotFoundError": + case "ACPSessionNotFoundError": return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`) - case "ACPNextInvalidConfigOptionError": + case "ACPInvalidConfigOptionError": return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`) - case "ACPNextInvalidModelError": + case "ACPInvalidModelError": return RequestError.invalidParams( { providerId: error.providerId, modelId: error.modelId }, `model not found: ${error.modelId}`, ) - case "ACPNextInvalidEffortError": + case "ACPInvalidEffortError": return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`) - case "ACPNextInvalidModeError": + case "ACPInvalidModeError": return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`) - case "ACPNextAuthRequiredError": + case "ACPAuthRequiredError": return RequestError.authRequired({ providerId: error.providerId }, "provider authentication required") - case "ACPNextUnknownAuthMethodError": + case "ACPUnknownAuthMethodError": return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`) - case "ACPNextUnsupportedOperationError": + case "ACPUnsupportedOperationError": return RequestError.methodNotFound(error.method) - case "ACPNextServiceFailureError": + case "ACPServiceFailureError": return RequestError.internalError({ service: error.service }, error.safeMessage) } } diff --git a/packages/opencode/src/acp-next/event.ts b/packages/opencode/src/acp/event.ts similarity index 93% rename from packages/opencode/src/acp-next/event.ts rename to packages/opencode/src/acp/event.ts index 25aa5c08ef..df105d6acf 100644 --- a/packages/opencode/src/acp-next/event.ts +++ b/packages/opencode/src/acp/event.ts @@ -10,7 +10,8 @@ import type { ToolPart, } from "@opencode-ai/sdk/v2" import { Effect } from "effect" -import { ACPNextSession } from "./session" +import { ACPSession } from "./session" +import { ACPPermission } from "./permission" import { duplicateRunningToolUpdate, errorToolUpdate, @@ -20,9 +21,10 @@ import { completedToolUpdate, } from "./tool" -const log = Log.create({ service: "acp-next-event" }) +const log = Log.create({ service: "acp-event" }) -type Connection = Pick +type Connection = Pick & + Partial> type GlobalEventEnvelope = { payload?: Event } @@ -30,7 +32,7 @@ type GlobalEventStream = { stream: AsyncIterable } -export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPNextSession.Interface }) { +export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPSession.Interface }) { const subscription = new Subscription(input) subscription.start() return subscription @@ -40,15 +42,18 @@ export class Subscription { private readonly abort = new AbortController() private readonly shellSnapshots = new Map() private readonly toolStarts = new Set() + private readonly permission: ACPPermission.Handler private started = false constructor( private readonly input: { sdk: OpencodeClient connection: Connection - session: ACPNextSession.Interface + session: ACPSession.Interface }, - ) {} + ) { + this.permission = new ACPPermission.Handler(input) + } start() { if (this.started) return @@ -65,6 +70,9 @@ export class Subscription { async handle(event: Event) { switch (event.type) { + case "permission.asked": + this.permission.handle(event) + return case "message.part.updated": return this.handlePartUpdated(event) case "message.part.delta": @@ -308,4 +316,4 @@ export class Subscription { } } -export * as ACPNextEvent from "./event" +export * as ACPEvent from "./event" diff --git a/packages/opencode/src/acp/permission.ts b/packages/opencode/src/acp/permission.ts new file mode 100644 index 0000000000..cefd2a34f3 --- /dev/null +++ b/packages/opencode/src/acp/permission.ts @@ -0,0 +1,145 @@ +import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk" +import * as Log from "@opencode-ai/core/util/log" +import type { Event, OpencodeClient } from "@opencode-ai/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 { Effect } from "effect" + +const log = Log.create({ service: "acp-permission" }) + +type PermissionEvent = Extract +type Reply = "once" | "always" | "reject" +type Connection = Partial> + +const permissionOptions: PermissionOption[] = [ + { optionId: "once", kind: "allow_once", name: "Allow once" }, + { optionId: "always", kind: "allow_always", name: "Always allow" }, + { optionId: "reject", kind: "reject_once", name: "Reject" }, +] + +export class Handler { + private readonly queues = new Map>() + + constructor( + private readonly input: { + sdk: OpencodeClient + connection: Connection + session: ACPSession.Interface + }, + ) {} + + handle(event: PermissionEvent) { + const permission = event.properties + const previous = this.queues.get(permission.sessionID) ?? Promise.resolve() + const next = previous + .then(() => this.process(event)) + .catch((error: unknown) => { + log.error("failed to handle permission", { error, permissionID: permission.id }) + }) + .finally(() => { + if (this.queues.get(permission.sessionID) === next) { + this.queues.delete(permission.sessionID) + } + }) + this.queues.set(permission.sessionID, next) + } + + private async process(event: PermissionEvent) { + const permission = event.properties + const session = await Effect.runPromise(this.input.session.tryGet(permission.sessionID)) + if (!session) return + + if (!this.input.connection.requestPermission) { + log.error("ACP connection cannot request permission", { + permissionID: permission.id, + sessionID: permission.sessionID, + }) + await this.reply(permission.id, "reject", session.cwd) + return + } + + const result = await this.input.connection + .requestPermission({ + sessionId: permission.sessionID, + toolCall: { + toolCallId: permission.tool?.callID ?? permission.id, + status: "pending", + title: permission.permission, + rawInput: permission.metadata, + kind: toToolKind(permission.permission), + locations: toLocations(permission.permission, permission.metadata), + }, + options: permissionOptions, + }) + .catch(async (error: unknown) => { + log.error("failed to request permission from ACP", { + error, + permissionID: permission.id, + sessionID: permission.sessionID, + }) + await this.reply(permission.id, "reject", session.cwd) + return undefined + }) + + if (!result) return + + const reply = selectedReply(result) + if (reply !== "once" && reply !== "always") { + await this.reply(permission.id, "reject", session.cwd) + return + } + + if (permission.permission === "edit") { + await this.writeProposedEdit(session.id, permission.metadata).catch((error: unknown) => { + log.error("failed to write proposed edit through ACP", { + error, + permissionID: permission.id, + sessionID: permission.sessionID, + }) + }) + } + + await this.reply(permission.id, reply, session.cwd) + } + + private async reply(requestID: string, reply: Reply, directory: string) { + await this.input.sdk.permission.reply({ + requestID, + reply, + directory, + }) + } + + private async writeProposedEdit(sessionId: string, metadata: ToolInput) { + const filepath = stringValue(metadata.filepath) + const diff = stringValue(metadata.diff) + if (!filepath || !diff || !this.input.connection.writeTextFile) return + + const content = (await exists(filepath)) ? await readText(filepath) : "" + const next = applyPatch(content, diff) + if (next === false) { + log.error("Failed to apply unified diff (context mismatch)") + return + } + + void this.input.connection.writeTextFile({ + sessionId, + path: filepath, + content: 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 + return "reject" +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value : undefined +} + +export * as ACPPermission from "./permission" diff --git a/packages/opencode/src/acp/profile.ts b/packages/opencode/src/acp/profile.ts new file mode 100644 index 0000000000..9e728b6a1a --- /dev/null +++ b/packages/opencode/src/acp/profile.ts @@ -0,0 +1,42 @@ +const enabled = process.env.OPENCODE_ACP_PROFILE === "1" +const started = performance.now() + +export function mark(name: string, fields?: Record) { + if (!enabled) return + write(`${name}.mark`, performance.now() - started, fields) +} + +export function duration( + name: string, + startedAt: number, + fields?: Record, +) { + if (!enabled) return + write(name, performance.now() - startedAt, fields) +} + +export async function measure( + name: string, + fn: () => Promise, + fields?: Record, +) { + if (!enabled) return fn() + const start = performance.now() + try { + return await fn() + } finally { + write(name, performance.now() - start, fields) + } +} + +function write(name: string, durationMs: number, fields?: Record) { + const extra = fields + ? Object.entries(fields) + .filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined) + .map(([key, value]) => `${key}=${value}`) + .join(" ") + : "" + console.error(`[acp-profile] ${name} ${Math.round(durationMs)}ms${extra ? ` ${extra}` : ""}`) +} + +export * as ACPProfile from "./profile" diff --git a/packages/opencode/src/acp/runtime.ts b/packages/opencode/src/acp/runtime.ts deleted file mode 100644 index b08c73cf2e..0000000000 --- a/packages/opencode/src/acp/runtime.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Agent } from "@/agent/agent" -import { AppRuntime, type AppServices } from "@/effect/app-runtime" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceRuntime } from "@/project/instance-runtime" -import { Effect } from "effect" - -// Global ACP Effect re-entry: no project InstanceRef is provided. -export const runGlobal = AppRuntime.runPromise - -// Directory-scoped ACP Effect re-entry: load the project instance and provide InstanceRef. -export async function runDirectory(input: { directory: string; effect: Effect.Effect }) { - const ctx = await InstanceRuntime.load({ directory: input.directory }) - return AppRuntime.runPromise(input.effect.pipe(Effect.provideService(InstanceRef, ctx))) -} - -export const defaultAgentInfo = (directory: string) => - runDirectory({ - directory, - effect: Agent.Service.use((svc) => svc.defaultInfo()), - }) - -export * as ACPRuntime from "./runtime" diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp/service.ts similarity index 59% rename from packages/opencode/src/acp-next/service.ts rename to packages/opencode/src/acp/service.ts index be368da623..7172907283 100644 --- a/packages/opencode/src/acp-next/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -33,19 +33,24 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Log from "@opencode-ai/core/util/log" import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" import { Context, Effect, Layer, ManagedRuntime } from "effect" -import * as ACPNextError from "./error" +import * as ACPError from "./error" import { buildConfigOptions, parseModelSelection } from "./config-option" +import { promptContentToParts } from "./content" import { Directory } from "./directory" -import { ACPNextEvent } from "./event" -import { ACPNextSession } from "./session" +import { ACPEvent } from "./event" +import { ACPSession } from "./session" +import { UsageService } from "./usage" +import { ACPProfile } from "./profile" import { ModelID, ProviderID } from "@/provider/schema" import { Provider } from "@/provider/provider" import type { Command } from "@/command" export const AuthMethodID = "opencode-login" -const log = Log.create({ service: "acp-next-service" }) +const log = Log.create({ service: "acp-service" }) -export type Error = ACPNextError.Error +export type Error = ACPError.Error +type ServiceConnection = Pick & + Partial> export type Interface = { readonly initialize: (input: InitializeRequest) => Effect.Effect @@ -65,24 +70,27 @@ export type Interface = { readonly cancel: (input: CancelNotification) => Effect.Effect } -export class Service extends Context.Service()("@opencode/ACPNext/Service") {} +export class Service extends Context.Service()("@opencode/ACP/Service") {} export function make(input: { sdk: OpencodeClient - connection?: Pick + connection?: ServiceConnection directory?: Directory.Interface - session?: ACPNextSession.Interface - eventSubscription?: (subscription: ACPNextEvent.Subscription) => void + session?: ACPSession.Interface + usage?: UsageService.Interface + eventSubscription?: (subscription: ACPEvent.Subscription) => void }): Interface { const session = input.session ?? makeSessionService() const directoryService = input.directory ?? makeDirectoryService(input.sdk) const registeredMcp = new Map>() + const sessionSnapshots = new Map() const events = input.connection - ? ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session }) + ? ACPEvent.start({ sdk: input.sdk, connection: input.connection, session }) : undefined if (events) input.eventSubscription?.(events) - const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) { + const initialize = Effect.fn("ACP.initialize")(function* (params: InitializeRequest) { + const started = performance.now() const authMethod: AuthMethod = { description: "Run `opencode auth login` in the terminal", name: "Login with opencode", @@ -99,7 +107,7 @@ export function make(input: { } } - return { + const response = { protocolVersion: 1, agentCapabilities: { loadSession: true, @@ -124,25 +132,40 @@ export function make(input: { version: InstallationVersion, }, } + ACPProfile.duration("acp.initialize", started) + return response }) - const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) { + const authenticate = Effect.fn("ACP.authenticate")(function* (params: AuthenticateRequest) { if (params.methodId !== AuthMethodID) { - return yield* new ACPNextError.UnknownAuthMethodError({ methodId: params.methodId }) + return yield* new ACPError.UnknownAuthMethodError({ methodId: params.methodId }) } return {} }) - const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (cwd: string) { - return yield* directoryService.get(cwd) + const directorySnapshot = Effect.fn("ACP.directorySnapshot")(function* (cwd: string) { + const started = performance.now() + const snapshot = yield* directoryService.get(cwd) + ACPProfile.duration("acp.directory.snapshot", started) + return snapshot }) - const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) { + const configSnapshot = Effect.fn("ACP.configSnapshot")(function* (state: ACPSession.Info) { + const snapshot = sessionSnapshots.get(state.id) + if (snapshot) return snapshot + const loaded = yield* directorySnapshot(state.cwd) + sessionSnapshots.set(state.id, loaded) + return loaded + }) + + const newSession = Effect.fn("ACP.newSession")(function* (params: NewSessionRequest) { + const started = performance.now() const snapshot = yield* directorySnapshot(params.cwd) const selected = selectDefaultModel(snapshot) const variant = selectVariant(snapshot, selected) const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined - const created = yield* request( + const created = yield* profiledRequest( + "acp.newSession.session.create", () => input.sdk.session.create( { @@ -166,11 +189,12 @@ export function make(input: { variant, modeId, }) + sessionSnapshots.set(state.id, snapshot) yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers) yield* sendAvailableCommands(input.connection, state.id, snapshot) - return { + const response = { sessionId: state.id, configOptions: configOptions(snapshot, { model: state.model ?? selected, @@ -178,9 +202,11 @@ export function make(input: { modeId: state.modeId, }), } + ACPProfile.duration("acp.newSession", started) + return response }) - const loadSession = Effect.fn("ACPNext.loadSession")(function* (params: LoadSessionRequest) { + const loadSession = Effect.fn("ACP.loadSession")(function* (params: LoadSessionRequest) { const snapshot = yield* directorySnapshot(params.cwd) yield* request( () => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }), @@ -204,6 +230,7 @@ export function make(input: { variant: restored.variant ?? selectVariant(snapshot, model), modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), }) + sessionSnapshots.set(state.id, snapshot) yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers) yield* sendAvailableCommands(input.connection, state.id, snapshot) @@ -218,7 +245,7 @@ export function make(input: { } }) - const listSessions = Effect.fn("ACPNext.listSessions")(function* (params: ListSessionsRequest) { + const listSessions = Effect.fn("ACP.listSessions")(function* (params: ListSessionsRequest) { const cursor = params.cursor ? Number(params.cursor) : undefined const limit = 100 const sessions = yield* request( @@ -232,25 +259,39 @@ export function make(input: { ), "session", ) - const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated) + const serverEntries = sessions.map( + (item): SessionInfo => ({ + sessionId: item.id, + cwd: item.directory, + title: item.title, + updatedAt: new Date(item.time.updated).toISOString(), + }), + ) + const liveEntries = (yield* session.list(params.cwd ?? undefined)) + .filter((item) => !serverEntries.some((entry) => entry.sessionId === item.id)) + .map( + (item): SessionInfo => ({ + sessionId: item.id, + cwd: item.cwd, + updatedAt: item.createdAt.toISOString(), + }), + ) + const sorted = [...liveEntries, ...serverEntries].toSorted( + (a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime(), + ) const filtered = - cursor === undefined || !Number.isFinite(cursor) ? sorted : sorted.filter((item) => item.time.updated < cursor) + cursor === undefined || !Number.isFinite(cursor) + ? sorted + : sorted.filter((item) => new Date(item.updatedAt ?? 0).getTime() < cursor) const page = filtered.slice(0, limit) const last = page.at(-1) return { - sessions: page.map( - (item): SessionInfo => ({ - sessionId: item.id, - cwd: item.directory, - title: item.title, - updatedAt: new Date(item.time.updated).toISOString(), - }), - ), - ...(filtered.length > limit && last ? { nextCursor: String(last.time.updated) } : {}), + sessions: page, + ...(filtered.length > limit && last ? { nextCursor: String(new Date(last.updatedAt ?? 0).getTime()) } : {}), } }) - const resumeSession = Effect.fn("ACPNext.resumeSession")(function* (params: ResumeSessionRequest) { + const resumeSession = Effect.fn("ACP.resumeSession")(function* (params: ResumeSessionRequest) { const snapshot = yield* directorySnapshot(params.cwd) yield* request( () => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }), @@ -274,6 +315,7 @@ export function make(input: { variant: restored.variant ?? selectVariant(snapshot, model), modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), }) + sessionSnapshots.set(state.id, snapshot) yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? []) yield* sendAvailableCommands(input.connection, state.id, snapshot) @@ -288,9 +330,10 @@ export function make(input: { } }) - const closeSession = Effect.fn("ACPNext.closeSession")(function* (params: CloseSessionRequest) { + const closeSession = Effect.fn("ACP.closeSession")(function* (params: CloseSessionRequest) { const removed = yield* session.remove(params.sessionId) registeredMcp.delete(params.sessionId) + sessionSnapshots.delete(params.sessionId) if (!removed) return {} yield* request( @@ -306,7 +349,7 @@ export function make(input: { return {} }) - const forkSession = Effect.fn("ACPNext.forkSession")(function* (params: ForkSessionRequest) { + const forkSession = Effect.fn("ACP.forkSession")(function* (params: ForkSessionRequest) { const snapshot = yield* directorySnapshot(params.cwd) const forked = yield* request( () => @@ -334,6 +377,7 @@ export function make(input: { variant: restored.variant ?? selectVariant(snapshot, model), modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), }) + sessionSnapshots.set(state.id, snapshot) yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? []) yield* sendAvailableCommands(input.connection, state.id, snapshot) @@ -349,13 +393,13 @@ export function make(input: { } }) - const setSessionConfigOption = Effect.fn("ACPNext.setSessionConfigOption")(function* ( + const setSessionConfigOption = Effect.fn("ACP.setSessionConfigOption")(function* ( params: SetSessionConfigOptionRequest, ) { const current = yield* session.get(params.sessionId) - const snapshot = yield* directorySnapshot(current.cwd) + const snapshot = yield* configSnapshot(current) if (typeof params.value !== "string") { - return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId }) + return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId }) } if (params.configId === "model") { @@ -377,7 +421,7 @@ export function make(input: { const model = current.model ?? selectDefaultModel(snapshot) const variants = Directory.variants(snapshot, model) if (!variants || !Object.keys(variants).includes(params.value)) { - return yield* new ACPNextError.InvalidEffortError({ effort: params.value }) + return yield* new ACPError.InvalidEffortError({ effort: params.value }) } const state = yield* session.setVariant(params.sessionId, params.value) return { @@ -391,7 +435,7 @@ export function make(input: { if (params.configId === "mode") { if (!snapshot.availableModes.some((mode) => mode.id === params.value)) { - return yield* new ACPNextError.InvalidModeError({ mode: params.value }) + return yield* new ACPError.InvalidModeError({ mode: params.value }) } const state = yield* session.setMode(params.sessionId, params.value) return { @@ -403,22 +447,22 @@ export function make(input: { } } - return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId }) + return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId }) }) - const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) { + const setSessionMode = Effect.fn("ACP.setSessionMode")(function* (params: SetSessionModeRequest) { const current = yield* session.get(params.sessionId) - const snapshot = yield* directorySnapshot(current.cwd) + const snapshot = yield* configSnapshot(current) if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) { - return yield* new ACPNextError.InvalidModeError({ mode: params.modeId }) + return yield* new ACPError.InvalidModeError({ mode: params.modeId }) } yield* session.setMode(params.sessionId, params.modeId) return {} }) - const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) { + const setSessionModel = Effect.fn("ACP.setSessionModel")(function* (params: SetSessionModelRequest) { const current = yield* session.get(params.sessionId) - const snapshot = yield* directorySnapshot(current.cwd) + const snapshot = yield* configSnapshot(current) const selected = yield* parseSelectedModel(snapshot, params.modelId) yield* session .setVariant( @@ -443,18 +487,91 @@ export function make(input: { setSessionConfigOption, setSessionMode, setSessionModel, - prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) { - return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" }) + prompt: Effect.fn("ACP.prompt")(function* (params: PromptRequest) { + const current = yield* session.get(params.sessionId) + const snapshot = yield* directorySnapshot(current.cwd) + const selected = current.model ?? selectDefaultModel(snapshot) + if (!current.model) { + yield* session.setModel(params.sessionId, selected) + } + const variant = current.variant ?? selectVariant(snapshot, selected) + const modeId = current.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined) + const parts = promptContentToParts(params.prompt) + const command = detectSlashCommand(parts) + + if (!command) { + const response = yield* request( + () => + input.sdk.session.prompt( + { + sessionID: current.id, + model: { + providerID: selected.providerID, + modelID: selected.modelID, + }, + ...(variant ? { variant } : {}), + parts, + ...(modeId ? { agent: modeId } : {}), + directory: current.cwd, + }, + { throwOnError: true }, + ), + "session", + ) + yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) + return promptResponse(response.info, params.messageId) + } + + const known = snapshot.availableCommands.find((item) => item.name === command.name) + if (known) { + const response = yield* request( + () => + input.sdk.session.command( + { + sessionID: current.id, + command: known.name, + arguments: command.args, + model: `${selected.providerID}/${selected.modelID}`, + ...(variant ? { variant } : {}), + ...(modeId ? { agent: modeId } : {}), + directory: current.cwd, + }, + { throwOnError: true }, + ), + "session", + ) + yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) + return promptResponse(response.info, params.messageId) + } + + if (command.name === "compact") { + yield* request( + () => + input.sdk.session.summarize( + { + sessionID: current.id, + directory: current.cwd, + providerID: selected.providerID, + modelID: selected.modelID, + }, + { throwOnError: true }, + ), + "session", + ) + } + + yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) + return promptResponse(undefined, params.messageId) }), - cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) { - return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" }) + cancel: Effect.fn("ACP.cancel")(function* (_input: CancelNotification) { + return yield* new ACPError.UnsupportedOperationError({ method: "session/cancel" }) }), } } function makeSessionService() { - return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync( - ACPNextSession.Service.use((service) => Effect.succeed(service)), + return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + ACPSession.Service.use((service) => Effect.succeed(service)), ) } @@ -473,7 +590,90 @@ function makeDirectoryService(sdk: OpencodeClient) { ).runSync(Directory.Service.use((service) => Effect.succeed(service))) } -function replayMessages(subscription: ACPNextEvent.Subscription | undefined, messages: SessionMessageResponse[]) { +function makeUsageService(sdk: OpencodeClient) { + const limits = new Map>() + const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACP.promptUsage.contextLimit")( + function* (params) { + const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}` + const current = limits.get(key) + if (current) return yield* Effect.promise(() => current) + + const next = sdk.config + .providers({ directory: params.directory }, { throwOnError: true }) + .then((response) => { + const providers = Object.fromEntries( + (response.data?.providers ?? []).map((provider) => [provider.id, provider]), + ) as Record + return UsageService.findContextLimit(providers, params.providerID, params.modelID) + }) + .catch((error: unknown) => { + log.error("failed to get providers for usage context limit", { error }) + return undefined + }) + limits.set(key, next) + return yield* Effect.promise(() => next) + }, + ) + + const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACP.promptUsage.sendUpdate")(function* (params) { + const messages = yield* request( + () => + sdk.session.messages( + { + sessionID: params.sessionID, + directory: params.directory, + }, + { throwOnError: true }, + ), + "session", + ).pipe( + Effect.map((messages) => messages as readonly UsageService.SessionMessage[]), + Effect.catch((error) => + Effect.sync(() => { + log.error("failed to fetch messages for usage update", { error }) + return undefined + }), + ), + ) + if (!messages) return + + const message = UsageService.latestAssistantMessage(messages) + if (!message?.providerID || !message.modelID) return + + const size = yield* contextLimit({ + directory: params.directory, + providerID: ProviderID.make(message.providerID), + modelID: ModelID.make(message.modelID), + }) + if (!size) return + + yield* Effect.promise(() => + params.connection + .sessionUpdate({ + sessionId: params.sessionID, + update: { + sessionUpdate: "usage_update", + used: message.tokens.input + message.tokens.cache.read, + size, + cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" }, + }, + }) + .catch((error) => { + log.error("failed to send usage update", { error }) + }), + ) + }) + + return UsageService.Service.of({ + buildUsage: UsageService.buildUsage, + latestAssistantMessage: UsageService.latestAssistantMessage, + totalSessionCost: UsageService.totalSessionCost, + contextLimit, + sendUpdate, + }) +} + +function replayMessages(subscription: ACPEvent.Subscription | undefined, messages: SessionMessageResponse[]) { if (!subscription) return Effect.void return Effect.promise(async () => { for (const message of messages) { @@ -505,6 +705,8 @@ type MessageInfo = { readonly agent?: Message["agent"] } +type AssistantInfo = UsageService.AssistantTokenCost | undefined + function request(fn: () => Promise>, service?: string) { return Effect.tryPromise({ try: async () => { @@ -519,66 +721,77 @@ function request(fn: () => Promise>, service?: string) { }) } -async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { - const [providersResponse, agentsResponse, commandsResponse, skillsResponse] = await Promise.all([ - sdk.config.providers({ directory }, { throwOnError: true }), - sdk.app.agents({ directory }, { throwOnError: true }), - sdk.command.list({ directory }, { throwOnError: true }), - sdk.app.skills({ directory }, { throwOnError: true }), - ]) - const providersData = providersResponse.data! - const agents = agentsResponse.data! - const commandsData = commandsResponse.data! - const skills = skillsResponse.data! - const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record< - ProviderID, - Provider.Info - > - const defaultModel = await defaultModelFromSdk(sdk, directory, providers) - const modes = agents - .filter((agent) => agent.mode !== "subagent" && agent.hidden !== true) - .map((agent) => ({ - id: agent.name, - name: agent.name, - ...(agent.description ? { description: agent.description } : {}), - })) - const commands = [ - ...commandsData, - ...skills - .filter((skill) => !commandsData.some((command) => command.name === skill.name)) - .map((skill) => ({ - name: skill.name, - description: skill.description, - source: "skill" as const, - template: skill.content, - hints: [], - })), - ] as Command.Info[] +function profiledRequest(name: string, fn: () => Promise>, service?: string) { + return request(() => ACPProfile.measure(name, fn), service) +} - return Directory.build({ - directory, - providers, - modes, - defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build", - commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)), - ...(defaultModel ? { defaultModel } : {}), +async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { + return ACPProfile.measure("acp.directory.load", async () => { + const [providersResponse, agentsResponse, commandsResponse, skillsResponse, configResponse] = await Promise.all([ + ACPProfile.measure("acp.directory.provider.list", () => + sdk.config.providers({ directory }, { throwOnError: true }), + ), + ACPProfile.measure("acp.directory.mode.defaultAgent.load", () => + sdk.app.agents({ directory }, { throwOnError: true }), + ), + ACPProfile.measure("acp.directory.command.list", () => sdk.command.list({ directory }, { throwOnError: true })), + ACPProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })), + ACPProfile.measure("acp.directory.defaultModel.config", () => + sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined), + ), + ]) + const providersData = providersResponse.data! + const agents = agentsResponse.data! + const commandsData = commandsResponse.data! + const skills = skillsResponse.data! + const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record< + ProviderID, + Provider.Info + > + const defaultModelStarted = performance.now() + const defaultModel = defaultModelFromConfig(configResponse?.data?.model, providers) + ACPProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel }) + const modes = agents + .filter((agent) => agent.mode !== "subagent" && agent.hidden !== true) + .map((agent) => ({ + id: agent.name, + name: agent.name, + ...(agent.description ? { description: agent.description } : {}), + })) + const commands = [ + ...commandsData, + ...skills + .filter((skill) => !commandsData.some((command) => command.name === skill.name)) + .map((skill) => ({ + name: skill.name, + description: skill.description, + source: "skill" as const, + template: skill.content, + hints: [], + })), + ] as Command.Info[] + + return Directory.build({ + directory, + providers, + modes, + defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build", + commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)), + ...(defaultModel ? { defaultModel } : {}), + }) }) } -async function defaultModelFromSdk( - sdk: OpencodeClient, - directory: string, +function defaultModelFromConfig( + configuredModel: string | undefined, providers: Record, -): Promise { - const configured = await sdk.config - .get({ directory }, { throwOnError: true }) - .then((response) => (response.data?.model ? Provider.parseModel(response.data.model) : undefined)) - .catch(() => undefined) +): Directory.DefaultModel | undefined { + const configured = configuredModel ? Provider.parseModel(configuredModel) : undefined if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured - const lastUsed = await lastUsedModel(sdk, directory, providers) - if (lastUsed) return lastUsed - + // First-session ACP startup must not scan historical sessions just to infer + // a default. Configured model, opencode provider, then sorted best model keep + // the protocol response deterministic without extra session/message reads. const opencodeProvider = providers[ProviderID.make("opencode")] const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id } @@ -588,30 +801,6 @@ async function defaultModelFromSdk( if (configured) return configured } -async function lastUsedModel( - sdk: OpencodeClient, - directory: string, - providers: Record, -): Promise { - const session = await sdk.session - .list({ directory, roots: true, limit: 1 }, { throwOnError: true }) - .then((response) => response.data?.[0]) - .catch(() => undefined) - if (!session) return - - const lastUser = await sdk.session - .messages({ directory, sessionID: session.id, limit: 20 }, { throwOnError: true }) - .then((response) => response.data?.findLast((message) => message.info.role === "user")?.info) - .catch(() => undefined) - if (lastUser?.role !== "user") return - if (!providers[ProviderID.make(lastUser.model.providerID)]?.models[ModelID.make(lastUser.model.modelID)]) return - - return { - providerID: ProviderID.make(lastUser.model.providerID), - modelID: ModelID.make(lastUser.model.modelID), - } -} - function selectDefaultModel(snapshot: Directory.Snapshot) { if (snapshot.defaultModel) return snapshot.defaultModel const model = snapshot.modelOptions[0] @@ -619,6 +808,43 @@ function selectDefaultModel(snapshot: Directory.Snapshot) { return { providerID: "unknown" as ProviderID, modelID: "unknown" as ModelID } } +function detectSlashCommand(parts: ReturnType) { + const text = parts + .filter((part): part is Extract<(typeof parts)[number], { type: "text" }> => part.type === "text") + .map((part) => part.text) + .join("") + .trim() + if (!text.startsWith("/")) return + + const [name, ...rest] = text.slice(1).split(/\s+/) + if (!name) return + return { name, args: rest.join(" ").trim() } +} + +function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse { + return { + stopReason: "end_turn", + ...(info ? { usage: UsageService.buildUsage(info) } : {}), + ...(messageId ? { userMessageId: messageId } : {}), + _meta: {}, + } +} + +function sendUsageUpdate( + usage: UsageService.Interface | undefined, + sdk: OpencodeClient, + connection: ServiceConnection | undefined, + sessionID: string, + directory: string, +) { + if (!connection) return Effect.void + return (usage ?? makeUsageService(sdk)).sendUpdate({ + connection, + sessionID, + directory, + }) +} + function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultModel) { const variants = Directory.variants(snapshot, model) if (!variants) return @@ -642,14 +868,14 @@ function parseSelectedModel(snapshot: Directory.Snapshot, modelId: string) { const model = provider?.models[ModelID.make(selected.model.modelID)] if (!model) { return Effect.fail( - new ACPNextError.InvalidModelError({ + new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId, }), ) } if (selected.variant && !model.variants?.[selected.variant]) { - return Effect.fail(new ACPNextError.InvalidEffortError({ effort: selected.variant })) + return Effect.fail(new ACPError.InvalidEffortError({ effort: selected.variant })) } return Effect.succeed({ model: { @@ -689,6 +915,7 @@ function registerMcpServers( sessionId: string, servers: readonly McpServer[], ) { + const started = performance.now() const current = registered.get(sessionId) ?? new Set() registered.set(sessionId, current) const pending = new Set() @@ -720,7 +947,16 @@ function registerMcpServers( ), ), { concurrency: "unbounded" }, - ).pipe(Effect.asVoid) + ).pipe( + Effect.tap(() => + Effect.sync(() => + ACPProfile.duration("acp.mcp.register", started, { + count: pending.size, + }), + ), + ), + Effect.asVoid, + ) } function mcpRegistrationKey(name: string, config: ReturnType) { @@ -780,20 +1016,20 @@ function isSdkResponse(value: T | SdkResponse): value is SdkResponse { } function fromUnknownError(error: unknown, service?: string): Error { - if (isACPNextError(error)) return error + if (isACPError(error)) return error if (isAuthRequired(error)) { - return new ACPNextError.AuthRequiredError({ providerId: findProviderID(error) }) + return new ACPError.AuthRequiredError({ providerId: findProviderID(error) }) } - return new ACPNextError.ServiceFailureError({ safeMessage: "OpenCode service failure", service }) + return new ACPError.ServiceFailureError({ safeMessage: "OpenCode service failure", service }) } -function isACPNextError(error: unknown): error is Error { +function isACPError(error: unknown): error is Error { return ( typeof error === "object" && error !== null && "_tag" in error && typeof error._tag === "string" && - error._tag.startsWith("ACPNext") + error._tag.startsWith("ACP") ) } diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index cc1ed0be30..82a9f61758 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -1,122 +1,230 @@ -import { RequestError, type McpServer } from "@agentclientprotocol/sdk" -import type { ACPSessionState } from "./types" -import * as Log from "@opencode-ai/core/util/log" -import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import type { McpServer } from "@agentclientprotocol/sdk" +import type { Message, Part } from "@opencode-ai/sdk/v2" +import { Context, Effect, Layer, Ref } from "effect" +import type { ModelID, ProviderID } from "../provider/schema" +import * as ACPError from "./error" -const log = Log.create({ service: "acp-session-manager" }) +export type SelectedModel = { + providerID: ProviderID + modelID: ModelID +} -export class ACPSessionManager { - private sessions = new Map() - private sdk: OpencodeClient +export type KnownMessagePartMetadata = { + messageId: string + partId: string + partType?: Part["type"] + role?: Message["role"] + ignored?: boolean + toolCallId?: string + metadata?: unknown +} - constructor(sdk: OpencodeClient) { - this.sdk = sdk - } +export type Info = { + id: string + cwd: string + mcpServers: readonly McpServer[] + createdAt: Date + model?: SelectedModel + variant?: string + modeId?: string + knownParts: ReadonlyMap +} - tryGet(sessionId: string): ACPSessionState | undefined { - return this.sessions.get(sessionId) - } +export type StoreInput = { + id: string + cwd: string + mcpServers?: readonly McpServer[] + createdAt?: Date + model?: SelectedModel + variant?: string + modeId?: string +} - async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise { - const session = await this.sdk.session - .create( - { - directory: cwd, - }, - { throwOnError: true }, - ) - .then((x) => x.data!) +export type RecordPartMetadataInput = { + sessionId: string + messageId: string + partId: string + partType?: Part["type"] + role?: Message["role"] + ignored?: boolean + toolCallId?: string + metadata?: unknown +} - const sessionId = session.id - const resolvedModel = model +export type PartMetadataLookupInput = { + sessionId: string + messageId: string + partId: string +} - const state: ACPSessionState = { - id: sessionId, - cwd, - mcpServers, - createdAt: new Date(), - model: resolvedModel, - } - log.info("creating_session", { state }) - - this.sessions.set(sessionId, state) - return state - } - - async load( +export type Interface = { + readonly create: (input: StoreInput) => Effect.Effect + readonly load: (input: StoreInput) => Effect.Effect + readonly list: (cwd?: string) => Effect.Effect + readonly get: (sessionId: string) => Effect.Effect + readonly tryGet: (sessionId: string) => Effect.Effect + readonly remove: (sessionId: string) => Effect.Effect + readonly setModel: ( sessionId: string, - cwd: string, - mcpServers: McpServer[], - model?: ACPSessionState["model"], - ): Promise { - const session = await this.sdk.session - .get( - { - sessionID: sessionId, - directory: cwd, - }, - { throwOnError: true }, - ) - .then((x) => x.data!) + model: SelectedModel | undefined, + ) => Effect.Effect + readonly getModel: (sessionId: string) => Effect.Effect + readonly setVariant: ( + sessionId: string, + variant: string | undefined, + ) => Effect.Effect + readonly getVariant: (sessionId: string) => Effect.Effect + readonly setMode: ( + sessionId: string, + modeId: string | undefined, + ) => Effect.Effect + readonly getMode: (sessionId: string) => Effect.Effect + readonly recordPartMetadata: ( + input: RecordPartMetadataInput, + ) => Effect.Effect + readonly getPartMetadata: ( + input: PartMetadataLookupInput, + ) => Effect.Effect + readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect +} - const resolvedModel = model +export class Service extends Context.Service()("@opencode/ACP/Session") {} - const state: ACPSessionState = { - id: sessionId, - cwd, - mcpServers, - createdAt: new Date(session.time.created), - model: resolvedModel, - } - log.info("loading_session", { state }) +type State = Map - this.sessions.set(sessionId, state) - return state - } +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const sessions = yield* Ref.make(new Map()) - get(sessionId: string): ACPSessionState { - const session = this.sessions.get(sessionId) - if (!session) { - log.error("session not found", { sessionId }) - throw RequestError.invalidParams(JSON.stringify({ error: `Session not found: ${sessionId}` })) - } - return session - } + const store = Effect.fn("ACP.Session.store")(function* (input: StoreInput) { + const session = makeSession(input) + yield* Ref.update(sessions, (state) => new Map(state).set(session.id, session)) + return snapshot(session) + }) - getModel(sessionId: string) { - const session = this.get(sessionId) - return session.model - } + const tryGet = Effect.fn("ACP.Session.tryGet")(function* (sessionId: string) { + const session = (yield* Ref.get(sessions)).get(sessionId) + if (!session) return + return snapshot(session) + }) - setModel(sessionId: string, model: ACPSessionState["model"]) { - const session = this.get(sessionId) - session.model = model - this.sessions.set(sessionId, session) - return session - } + const get = Effect.fn("ACP.Session.get")(function* (sessionId: string) { + const session = yield* tryGet(sessionId) + if (session) return session + return yield* new ACPError.SessionNotFoundError({ sessionId }) + }) - getVariant(sessionId: string) { - const session = this.get(sessionId) - return session.variant - } + const update = Effect.fn("ACP.Session.update")(function* (sessionId: string, fn: (session: Info) => Info) { + const result = yield* Ref.modify(sessions, (state) => { + const session = state.get(sessionId) + if (!session) return [undefined, state] as const + const next = fn(session) + return [snapshot(next), new Map(state).set(sessionId, next)] as const + }) + if (result) return result + return yield* new ACPError.SessionNotFoundError({ sessionId }) + }) - setVariant(sessionId: string, variant?: string) { - const session = this.get(sessionId) - session.variant = variant - this.sessions.set(sessionId, session) - return session - } + const remove = Effect.fn("ACP.Session.remove")(function* (sessionId: string) { + return yield* Ref.modify(sessions, (state) => { + const session = state.get(sessionId) + if (!session) return [undefined, state] as const + const next = new Map(state) + next.delete(sessionId) + return [snapshot(session), next] as const + }) + }) - setMode(sessionId: string, modeId: string) { - const session = this.get(sessionId) - session.modeId = modeId - this.sessions.set(sessionId, session) - return session - } + const setModel: Interface["setModel"] = Effect.fn("ACP.Session.setModel")((sessionId, model) => + update(sessionId, (session) => ({ ...session, model })), + ) - remove(sessionId: string): ACPSessionState | undefined { - const session = this.sessions.get(sessionId) - this.sessions.delete(sessionId) - return session + const setVariant: Interface["setVariant"] = Effect.fn("ACP.Session.setVariant")((sessionId, variant) => + update(sessionId, (session) => ({ ...session, variant })), + ) + + const setMode: Interface["setMode"] = Effect.fn("ACP.Session.setMode")((sessionId, modeId) => + update(sessionId, (session) => ({ ...session, modeId })), + ) + + const recordPartMetadata: Interface["recordPartMetadata"] = Effect.fn("ACP.Session.recordPartMetadata")((input) => { + const metadata = { + messageId: input.messageId, + partId: input.partId, + partType: input.partType, + role: input.role, + ignored: input.ignored, + toolCallId: input.toolCallId, + metadata: input.metadata, + } + return update(input.sessionId, (session) => ({ + ...session, + knownParts: new Map(session.knownParts).set(partMetadataKey(input), metadata), + })).pipe(Effect.as(metadata)) + }) + + return Service.of({ + create: store, + load: store, + list: Effect.fn("ACP.Session.list")(function* (cwd?: string) { + return [...(yield* Ref.get(sessions)).values()] + .filter((session) => !cwd || session.cwd === cwd) + .map(snapshot) + .toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + }), + get, + tryGet, + remove, + setModel, + getModel: Effect.fn("ACP.Session.getModel")(function* (sessionId) { + return (yield* get(sessionId)).model + }), + setVariant, + getVariant: Effect.fn("ACP.Session.getVariant")(function* (sessionId) { + return (yield* get(sessionId)).variant + }), + setMode, + getMode: Effect.fn("ACP.Session.getMode")(function* (sessionId) { + return (yield* get(sessionId)).modeId + }), + recordPartMetadata, + getPartMetadata: Effect.fn("ACP.Session.getPartMetadata")(function* (input) { + return (yield* get(input.sessionId)).knownParts.get(partMetadataKey(input)) + }), + tryGetPartMetadata: Effect.fn("ACP.Session.tryGetPartMetadata")(function* (input) { + return (yield* tryGet(input.sessionId))?.knownParts.get(partMetadataKey(input)) + }), + }) + }), +) + +export const defaultLayer = layer + +function makeSession(input: StoreInput): Info { + return { + id: input.id, + cwd: input.cwd, + mcpServers: [...(input.mcpServers ?? [])], + createdAt: input.createdAt ? new Date(input.createdAt) : new Date(), + model: input.model, + variant: input.variant, + modeId: input.modeId, + knownParts: new Map(), } } + +function snapshot(session: Info): Info { + return { + ...session, + mcpServers: [...session.mcpServers], + createdAt: new Date(session.createdAt), + knownParts: new Map(session.knownParts), + } +} + +function partMetadataKey(input: { messageId: string; partId: string }) { + return `${input.messageId}:${input.partId}` +} + +export * as ACPSession from "./session" diff --git a/packages/opencode/src/acp-next/tool.ts b/packages/opencode/src/acp/tool.ts similarity index 99% rename from packages/opencode/src/acp-next/tool.ts rename to packages/opencode/src/acp/tool.ts index 288d24e31e..08cf7ff845 100644 --- a/packages/opencode/src/acp-next/tool.ts +++ b/packages/opencode/src/acp/tool.ts @@ -74,7 +74,7 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio case "read": case "edit": case "write": - return locationFrom(input.filePath) + return locationFrom(input.filePath ?? input.filepath) case "grep": case "glob": diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts deleted file mode 100644 index 2c3e886bc1..0000000000 --- a/packages/opencode/src/acp/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { McpServer } from "@agentclientprotocol/sdk" -import type { OpencodeClient } from "@opencode-ai/sdk/v2" -import type { ProviderID, ModelID } from "../provider/schema" - -export interface ACPSessionState { - id: string - cwd: string - mcpServers: McpServer[] - createdAt: Date - model?: { - providerID: ProviderID - modelID: ModelID - } - variant?: string - modeId?: string -} - -export interface ACPConfig { - sdk: OpencodeClient - defaultModel?: { - providerID: ProviderID - modelID: ModelID - } -} diff --git a/packages/opencode/src/acp-next/usage.ts b/packages/opencode/src/acp/usage.ts similarity index 95% rename from packages/opencode/src/acp-next/usage.ts rename to packages/opencode/src/acp/usage.ts index eabbb02af2..aae3246696 100644 --- a/packages/opencode/src/acp-next/usage.ts +++ b/packages/opencode/src/acp/usage.ts @@ -7,7 +7,7 @@ import { ModelID, ProviderID } from "@/provider/schema" import { Provider } from "@/provider/provider" import { Context, Effect, Layer, SynchronizedRef } from "effect" -const log = Log.create({ service: "acp-next-usage" }) +const log = Log.create({ service: "acp-usage" }) export type AssistantTokenCost = Pick @@ -60,14 +60,14 @@ export interface Interface { } export class MessageLoader extends Context.Service()( - "@opencode/ACPNextUsageMessageLoader", + "@opencode/ACPUsageMessageLoader", ) {} export class ContextLimitLoader extends Context.Service()( - "@opencode/ACPNextUsageContextLimitLoader", + "@opencode/ACPUsageContextLimitLoader", ) {} -export class Service extends Context.Service()("@opencode/ACPNextUsage") {} +export class Service extends Context.Service()("@opencode/ACPUsage") {} export function messageLoaderFromSDK(sdk: SDK): MessageLoaderInterface { return MessageLoader.of({ @@ -124,7 +124,7 @@ export const contextLimitLoaderLayer = Layer.effect( const provider = yield* Provider.Service return ContextLimitLoader.of({ - providers: Effect.fn("ACPNextUsageContextLimitLoader.providers")(function* (directory) { + providers: Effect.fn("ACPUsageContextLimitLoader.providers")(function* (directory) { const ctx = yield* store.load({ directory }) return yield* Effect.gen(function* () { return yield* provider.list() @@ -168,7 +168,7 @@ export const layer = Layer.effect( ) }) - const contextLimit = Effect.fn("ACPNextUsage.contextLimit")(function* (input: { + const contextLimit = Effect.fn("ACPUsage.contextLimit")(function* (input: { readonly directory: string readonly providerID: ProviderID readonly modelID: ModelID @@ -176,7 +176,7 @@ export const layer = Layer.effect( return yield* yield* cachedLimit(input) }) - const sendUpdate = Effect.fn("ACPNextUsage.sendUpdate")(function* (input: { + const sendUpdate = Effect.fn("ACPUsage.sendUpdate")(function* (input: { readonly connection: UsageConnection readonly sessionID: string readonly directory: string diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index b113a278f9..99647e5e2a 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -3,12 +3,11 @@ import { Effect } from "effect" import { effectCmd } from "../effect-cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" -import { ACPNext } from "@/acp-next/agent" import { Server } from "@/server/server" import { ServerAuth } from "@/server/auth" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" -import { RuntimeFlags } from "@/effect/runtime-flags" +import { ACPProfile } from "@/acp/profile" const log = Log.create({ service: "acp-command" }) @@ -23,10 +22,10 @@ export const AcpCommand = effectCmd({ }) }, handler: Effect.fn("Cli.acp")(function* (args) { + ACPProfile.mark("cli.acp.handler") process.env.OPENCODE_CLIENT = "acp" - const flags = yield* RuntimeFlags.Service const opts = yield* resolveNetworkOptions(args) - const server = yield* Effect.promise(() => Server.listen(opts)) + const server = yield* Effect.promise(() => ACPProfile.measure("cli.acp.server.listen", () => Server.listen(opts))) const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, @@ -57,10 +56,11 @@ export const AcpCommand = effectCmd({ }) const stream = ndJsonStream(input, output) - const agent = flags.acpNext ? ACPNext.init({ sdk }) : ACP.init({ sdk }) + const agent = ACP.init({ sdk }) new AgentSideConnection((conn) => { - return agent.create(conn, { sdk }) + ACPProfile.mark("cli.acp.connection.create") + return agent.create(conn) }, stream) log.info("setup connection") diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 39182dac0e..68803d0d11 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -41,6 +41,7 @@ import { DialogThemeList } from "@tui/component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" +import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list" import { DialogConsoleOrg } from "@tui/component/dialog-console-org" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" @@ -114,6 +115,7 @@ const appBindingCommands = [ "theme.mode.lock", "help.show", "docs.open", + "workspace.list", "app.debug", "app.console", "app.heap_snapshot", @@ -606,6 +608,16 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.clear() }, }, + { + name: "workspace.list", + title: "Manage workspaces", + category: "Workspace", + hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, + slashName: "workspaces", + run: () => { + dialog.replace(() => ) + }, + }, ...Array.from({ length: 9 }, (_, i) => ({ name: `session.quick_switch.${i + 1}`, title: `Switch to session in quick slot ${i + 1}`, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 09c2d64b00..4b4484b798 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -73,6 +73,7 @@ export function DialogModel(props: { providerID?: string }) { map(([model, info]) => ({ value: { providerID: provider.id, modelID: model }, title: info.name ?? model, + releaseDate: info.release_date, description: favorites.some((item) => item.providerID === provider.id && item.modelID === model) ? "(Favorite)" : undefined, @@ -91,10 +92,7 @@ export function DialogModel(props: { providerID?: string }) { return false return true }), - sortBy( - (x) => x.footer !== "Free", - (x) => x.title, - ), + (options) => sortModelOptions(options, props.providerID !== undefined), ), ), ) @@ -173,3 +171,15 @@ export function DialogModel(props: { providerID?: string }) { /> ) } + +export function sortModelOptions( + options: T[], + newestFirst: boolean, +) { + if (newestFirst) return sortBy(options, [(option) => option.releaseDate, "desc"], (option) => option.title) + return sortBy( + options, + (option) => option.footer !== "Free", + (option) => option.title, + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 874afa05b4..7c1386ae76 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -380,7 +380,7 @@ function ApiMethod(props: ApiMethodProps) { with generous usage limits. - Go to https://opencode.ai/zen and enable OpenCode Go + Go to https://opencode.ai/go and enable OpenCode Go ), diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 17653af6b9..e16893353a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -53,13 +53,22 @@ export function DialogSessionList() { const workspaceID = await (async () => { if (selection.type === "none") return null if (selection.type === "existing") return selection.workspaceID - const result = await sdk.client.experimental.workspace - .create({ type: selection.workspaceType, branch: null }) - .catch(() => undefined) + let result + try { + result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null }) + } catch (err) { + toast.show({ + title: "Failed to create workspace", + message: errorMessage(err), + variant: "error", + }) + return + } const workspace = result?.data if (!workspace) { toast.show({ - message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`, + title: "Failed to create workspace", + message: errorMessage(result?.error ?? "no response"), variant: "error", }) return diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index b22930bc6c..29566c4610 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -61,15 +61,17 @@ async function loadWorkspaceAdapters(input: { const dir = input.sync.path.directory || input.sdk.directory const url = new URL("/experimental/workspace/adapter", input.sdk.url) if (dir) url.searchParams.set("directory", dir) - const res = await input.sdk - .fetch(url) - .then((x) => x.json() as Promise) - .catch(() => undefined) - if (res) return res - input.toast.show({ - message: "Failed to load workspace adapters", - variant: "error", - }) + try { + const response = await input.sdk.fetch(url) + return (await response.json()) as Adapter[] + } catch (err) { + input.toast.show({ + title: "Failed to load workspace adapters", + message: errorMessage(err), + variant: "error", + }) + return undefined + } } export async function openWorkspaceSelect(input: { @@ -100,13 +102,21 @@ export async function warpWorkspaceSession(input: { copyChanges: boolean done?: () => void }): Promise { - const result = await input.sdk.client.experimental.workspace - .warp({ + let result + try { + result = await input.sdk.client.experimental.workspace.warp({ id: input.workspaceID, sessionID: input.sessionID, copyChanges: input.copyChanges, }) - .catch(() => undefined) + } catch (err) { + input.toast.show({ + title: "Failed to warp session", + message: errorMessage(err), + variant: "error", + }) + return false + } if (!result?.data) { if (result?.error && "name" in result.error && result.error.name === "VcsApplyError") { await DialogAlert.show( @@ -118,7 +128,8 @@ export async function warpWorkspaceSession(input: { } input.toast.show({ - message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`, + title: "Failed to warp session", + message: errorMessage(result?.error ?? "no response"), variant: "error", }) return false diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx new file mode 100644 index 0000000000..0790d64796 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx @@ -0,0 +1,112 @@ +import type { Workspace } from "@opencode-ai/sdk/v2" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { useProject } from "@tui/context/project" +import { useRoute } from "@tui/context/route" +import { useSync } from "@tui/context/sync" +import { useTheme } from "@tui/context/theme" +import { createMemo, createSignal, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { errorMessage } from "@/util/error" +import { useSDK } from "../context/sdk" +import { useToast } from "../ui/toast" + +type WorkspaceOption = { workspace: Workspace } + +export function DialogWorkspaceList() { + const dialog = useDialog() + const route = useRoute() + const sync = useSync() + const sdk = useSDK() + const toast = useToast() + const project = useProject() + const { theme } = useTheme() + const [deleting, setDeleting] = createSignal() + const [removing, setRemoving] = createSignal() + const [expanded, setExpanded] = createStore>({}) + + const current = createMemo(() => { + if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID + return project.workspace.current() + }) + + const options = createMemo[]>(() => + project.workspace + .list() + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((workspace) => { + const status = project.workspace.status(workspace.id) + return { + title: + removing() === workspace.id + ? "Deleting..." + : deleting() === workspace.id + ? `Delete ${workspace.name}? Press delete again` + : workspace.name, + value: { workspace }, + footer: workspace.type, + details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined, + gutter: () => , + } + }), + ) + + function showDetails(workspace: Workspace) { + setExpanded(workspace.id, (open) => !open) + } + + async function remove(workspace: Workspace) { + if (removing()) return + if (deleting() !== workspace.id) { + setDeleting(workspace.id) + return + } + + setDeleting(undefined) + setRemoving(workspace.id) + const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({ + error: err, + })) + if (result?.error) { + setRemoving(undefined) + toast.show({ + variant: "error", + title: "Failed to delete workspace", + message: errorMessage(result.error), + }) + return + } + + if (current() === workspace.id) { + project.workspace.set(undefined) + route.navigate({ type: "home" }) + } + await project.workspace.sync() + await sync.bootstrap({ fatal: false }).catch(() => undefined) + setRemoving(undefined) + } + + onMount(() => { + dialog.setSize("large") + void sdk.client.experimental.workspace.syncList().catch(() => undefined) + void project.workspace.sync() + }) + + return ( + { + setDeleting(undefined) + }} + onSelect={(option) => showDetails(option.value.workspace)} + actions={[ + { + command: "session.delete", + title: "delete", + onTrigger: (option) => void remove(option.value.workspace), + }, + ]} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 28b1f62f69..966ba932d4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -40,6 +40,7 @@ import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v import { TuiEvent } from "../../event" import { iife } from "@/util/iife" import { Locale } from "@/util/locale" +import { errorMessage } from "@/util/error" import { formatDuration } from "@/util/format" import { createColors, createFrames } from "../../ui/spinner.ts" import { useDialog } from "@tui/ui/dialog" @@ -216,14 +217,25 @@ export function Prompt(props: PromptProps) { async function createWorkspace(selection: Extract) { setCreatingWorkspace(true) - const result = await sdk.client.experimental.workspace - .create({ type: selection.workspaceType, branch: null }) - .catch(() => undefined) - if (result == undefined || result.error || !result.data) { + let result + try { + result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null }) + } catch (err) { selectWorkspace(undefined) setCreatingWorkspace(false) toast.show({ - message: "Creating workspace failed", + title: "Creating workspace failed", + message: errorMessage(err), + variant: "error", + }) + return + } + if (result.error || !result.data) { + selectWorkspace(undefined) + setCreatingWorkspace(false) + toast.show({ + title: "Creating workspace failed", + message: errorMessage(result.error ?? "no response"), variant: "error", }) return diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/dialog.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/dialog.tsx new file mode 100644 index 0000000000..816546b868 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/dialog.tsx @@ -0,0 +1,335 @@ +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select" +import { useRoute } from "@tui/context/route" +import { useSync } from "@tui/context/sync" +import { useProject } from "@tui/context/project" +import { useTheme } from "@tui/context/theme" +import { useSDK } from "@tui/context/sdk" +import { useLocal } from "@tui/context/local" +import { useToast } from "@tui/ui/toast" +import { useCommandShortcut } from "@tui/keymap" +import { createEffect, createMemo, createResource, createSignal, on, onMount, untrack } from "solid-js" +import { Spinner } from "@tui/component/spinner" +import { DialogSessionRename } from "@tui/component/dialog-session-rename" +import { DialogSessionDeleteFailed } from "@tui/component/dialog-session-delete-failed" +import { + openWorkspaceSelect, + type WorkspaceSelection, + warpWorkspaceSession, +} from "@tui/component/dialog-workspace-create" +import { createDebouncedSignal } from "@tui/util/signal" +import { errorMessage } from "@/util/error" +import { SessionPreviewPane, createLeadingTrailingSignal } from "./preview-pane" +import { relativeTime } from "./util" + +export function SessionSwitcherDialog() { + const dialog = useDialog() + const route = useRoute() + const sync = useSync() + const project = useProject() + const { theme } = useTheme() + const sdk = useSDK() + const local = useLocal() + const toast = useToast() + const [toDelete, setToDelete] = createSignal() + const [search, setSearch] = createDebouncedSignal("", 150) + const deleteHint = useCommandShortcut("session.delete") + const quickSwitch1 = useCommandShortcut("session.quick_switch.1") + const quickSwitch9 = useCommandShortcut("session.quick_switch.9") + let select: DialogSelectRef | undefined + + const [searchResults, { refetch }] = createResource( + () => ({ query: search(), filter: sync.session.query() }), + async (input) => { + if (!input.query) return undefined + const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter }) + return result.data ?? [] + }, + ) + + const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) + const sessions = createMemo(() => searchResults() ?? sync.data.session) + const [focusedSession, setFocusedSession, scheduleFocused] = createLeadingTrailingSignal( + undefined, + 150, + ) + const focusedSessionInfo = createMemo(() => { + const id = focusedSession() + if (!id) return undefined + return sessions().find((session) => session.id === id) ?? sync.data.session.find((session) => session.id === id) + }) + + function recoverFailed(session: NonNullable[number]>) { + const workspace = project.workspace.get(session.workspaceID!) + const list = () => dialog.replace(() => ) + const warp = async (selection: WorkspaceSelection) => { + const workspaceID = await (async () => { + if (selection.type === "none") return null + if (selection.type === "existing") return selection.workspaceID + const result = await sdk.client.experimental.workspace + .create({ type: selection.workspaceType, branch: null }) + .catch(() => undefined) + const created = result?.data + if (!created) { + toast.show({ + message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`, + variant: "error", + }) + return + } + await project.workspace.sync() + return created.id + })() + if (workspaceID === undefined) return + await warpWorkspaceSession({ + dialog, + sdk, + sync, + project, + toast, + sourceWorkspaceID: session.workspaceID, + workspaceID, + sessionID: session.id, + copyChanges: false, + done: list, + }) + } + dialog.replace(() => ( + { + const current = currentSessionID() + const info = current ? sync.data.session.find((item) => item.id === current) : undefined + const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! }) + if (result.error) { + toast.show({ + variant: "error", + title: "Failed to delete workspace", + message: errorMessage(result.error), + }) + return false + } + await project.workspace.sync() + await sync.session.refresh() + if (search()) await refetch() + if (info?.workspaceID === session.workspaceID) { + route.navigate({ type: "home" }) + } + return true + }} + onRestore={() => { + void openWorkspaceSelect({ + dialog, + sdk, + sync, + project, + toast, + onSelect: (selection) => { + void warp(selection) + }, + }) + return false + }} + /> + )) + } + + function orderByRecency(sessionsList: NonNullable>) { + return sessionsList + .filter((x) => x.parentID === undefined) + .toSorted((a, b) => b.time.updated - a.time.updated) + .map((x) => x.id) + } + + const [browseOrder] = createSignal(orderByRecency(sync.data.session)) + + const quickSwitchHint = createMemo(() => { + const first = quickSwitch1() + const last = quickSwitch9() + if (!first || !last) return undefined + return quickSwitchRange(first, last) + }) + const quickSwitchFooterHints = createMemo(() => { + const hint = quickSwitchHint() + return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : [] + }) + + const options = createMemo[]>(() => { + const today = new Date().toDateString() + const sessionMap = new Map( + sessions() + .filter((x) => x.parentID === undefined) + .map((x) => [x.id, x]), + ) + + const searchResult = searchResults() + const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder() + + const pinned = local.session.pinned().filter((id) => sessionMap.has(id)) + const pinnedSet = new Set(pinned) + const slotByID = new Map(local.session.slots().map((id, i) => [id, i + 1])) + + function buildOption(id: string, category: string): DialogSelectOption | undefined { + const x = sessionMap.get(id) + if (!x) return undefined + const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined + + const footer = relativeTime(x.time.updated) + const isWorktree = workspace?.type === "worktree" + + const isDeleting = toDelete() === x.id + const status = sync.data.session_status?.[x.id] + const isWorking = status?.type === "busy" || status?.type === "retry" + const slot = slotByID.get(x.id) + const gutter = isWorking + ? () => + : slot !== undefined + ? () => {slot} + : undefined + const titleText = isDeleting ? `Press ${deleteHint()} again to confirm` : isWorktree ? `⎇ ${x.title}` : x.title + return { + title: titleText, + bg: isDeleting ? theme.error : undefined, + value: x.id, + category, + footer, + gutter, + } + } + + const remaining = displayOrder + .filter((id) => !pinnedSet.has(id)) + .map((id) => { + const x = sessionMap.get(id) + if (!x) return undefined + const label = new Date(x.time.updated).toDateString() + return buildOption(id, label === today ? "Today" : label) + }) + .filter((x): x is DialogSelectOption => x !== undefined) + + return [ + ...pinned.map((id) => buildOption(id, "Pinned")).filter((x): x is DialogSelectOption => x !== undefined), + ...remaining, + ] + }) + + createEffect( + on([options, currentSessionID], ([items, current]) => { + const selected = untrack(() => select?.selected) + const selectedID = selected && items.some((item) => item.value === selected.value) ? selected.value : undefined + const currentID = current && items.some((item) => item.value === current) ? current : undefined + setFocusedSession(selectedID ?? currentID ?? items[0]?.value) + }), + ) + + onMount(() => { + dialog.setSize("xlarge") + }) + + const list = ( + (select = value)} + title="Sessions" + options={options()} + skipFilter={true} + current={currentSessionID()} + onFilter={setSearch} + onMove={(option) => { + setToDelete(undefined) + scheduleFocused(option.value) + }} + onSelect={(option) => { + route.navigate({ + type: "session", + sessionID: option.value, + }) + dialog.clear() + }} + actions={[ + { + command: "session.pin.toggle", + title: "pin/unpin", + onTrigger: (option: { value: string }) => { + local.session.togglePin(option.value) + }, + }, + { + command: "session.delete", + title: "delete", + onTrigger: async (option) => { + if (toDelete() === option.value) { + const session = sessions().find((item) => item.id === option.value) + const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined + + try { + const result = await sdk.client.session.delete({ + sessionID: option.value, + }) + if (result.error) { + if (session?.workspaceID) { + recoverFailed(session) + } else { + toast.show({ + variant: "error", + title: "Failed to delete session", + message: errorMessage(result.error), + }) + } + setToDelete(undefined) + return + } + } catch (err) { + if (session?.workspaceID) { + recoverFailed(session) + } else { + toast.show({ + variant: "error", + title: "Failed to delete session", + message: errorMessage(err), + }) + } + setToDelete(undefined) + return + } + if (status && status !== "connected") { + await sync.session.refresh() + } + if (search()) await refetch() + setToDelete(undefined) + return + } + setToDelete(option.value) + }, + }, + { + command: "session.rename", + title: "rename", + onTrigger: async (option) => { + dialog.replace(() => ) + }, + }, + ]} + footerHints={quickSwitchFooterHints()} + /> + ) + + return ( + + + {list} + + + + + + + ) +} + +function quickSwitchRange(first: string, last: string) { + const prefix = first.slice(0, -1) + if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9` + return `${first} through ${last}` +} diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/index.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/index.tsx new file mode 100644 index 0000000000..50055d3bdb --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/index.tsx @@ -0,0 +1,32 @@ +import type { TuiPlugin } from "@opencode-ai/plugin/tui" +import type { InternalTuiPlugin } from "../../plugin/internal" +import { SessionSwitcherDialog } from "./dialog" + +const id = "internal:session-switcher" + +const tui: TuiPlugin = async (api) => { + api.keymap.registerLayer({ + priority: 1000, + commands: [ + { + name: "session.list", + title: "Switch session", + category: "Session", + namespace: "palette", + suggested: () => api.state.session.count() > 0, + slashName: "sessions", + slashAliases: ["resume", "continue"], + run() { + api.ui.dialog.replace(() => ) + }, + }, + ], + }) +} + +const plugin: InternalTuiPlugin = { + id, + tui, +} + +export default plugin diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/preview-pane.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/preview-pane.tsx new file mode 100644 index 0000000000..8e75745a6c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/preview-pane.tsx @@ -0,0 +1,382 @@ +import { createResource, Show, createMemo, createSignal, onMount, type Accessor, type JSX } from "solid-js" +import { TextAttributes, type RGBA } from "@opentui/core" +import { useTerminalDimensions } from "@opentui/solid" +import { debounce, leadingAndTrailing } from "@solid-primitives/scheduled" +import type { Message, Part, Session as SdkSession, SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import { useTheme } from "@tui/context/theme" +import { useSDK } from "@tui/context/sdk" +import { useSync } from "@tui/context/sync" +import { Locale } from "@/util/locale" +import { Spinner } from "@tui/component/spinner" +import { extractMessageMarkdown, extractMessageText, formatDiffSummary, relativeTime, shortModelLabel } from "./util" + +type WithParts = { info: Message; parts: Part[] } + +type Sdk = ReturnType +type Sync = ReturnType + +const messageCache = new Map>() +const diffCache = new Map>() + +function cacheKey(sessionID: string, version: number) { + return `${sessionID}:${version}` +} + +function hydrateFromSync(sync: Sync, sessionID: string): WithParts[] | undefined { + const infos = sync.data.message[sessionID] + if (!infos || infos.length === 0) return undefined + return infos.map((info) => ({ info, parts: sync.data.part[info.id] ?? [] })) +} + +function loadMessages(sdk: Sdk, sessionID: string, version: number): Promise { + const key = cacheKey(sessionID, version) + const cached = messageCache.get(key) + if (cached) return cached + + const promise = sdk.client.session + .messages({ sessionID, limit: 50 }) + .then((res) => { + if (res.error) messageCache.delete(key) + return (res.data as WithParts[] | undefined) ?? [] + }) + .catch(() => { + messageCache.delete(key) + return [] as WithParts[] + }) + messageCache.set(key, promise) + return promise +} + +function loadDiff(sdk: Sdk, sessionID: string, version: number): Promise { + const key = cacheKey(sessionID, version) + const cached = diffCache.get(key) + if (cached) return cached + + const promise = sdk.client.session + .diff({ sessionID }) + .then((res) => { + if (res.error) diffCache.delete(key) + return (res.data as SnapshotFileDiff[] | undefined) ?? [] + }) + .catch(() => { + diffCache.delete(key) + return [] as SnapshotFileDiff[] + }) + diffCache.set(key, promise) + return promise +} + +export function prefetchPreviews(sdk: Sdk, sync: Sync, sessionIDs: readonly string[]) { + for (const id of sessionIDs) { + const version = sync.data.session.find((session) => session.id === id)?.time.updated ?? 0 + if (!hydrateFromSync(sync, id)) loadMessages(sdk, id, version).catch(() => {}) + if (!sync.data.session_diff[id]?.length) loadDiff(sdk, id, version).catch(() => {}) + } +} + +export function createLeadingTrailingSignal(initial: T, ms: number): [Accessor, (v: T) => void, (v: T) => void] { + const [get, set] = createSignal(initial) + const setNow = (v: T) => set(() => v) + const schedule = leadingAndTrailing(debounce, setNow, ms) + return [get, setNow, schedule] +} + +export function SessionPreviewPane(props: { + sessionID: Accessor + session?: Accessor +}) { + const { theme } = useTheme() + const sdk = useSDK() + const sync = useSync() + const dimensions = useTerminalDimensions() + + const maxHeight = createMemo(() => Math.max(8, Math.floor(dimensions().height / 2) - 4)) + const session = createMemo(() => { + const provided = props.session?.() + if (provided) return provided + const id = props.sessionID() + if (!id) return undefined + return sync.data.session.find((s) => s.id === id) + }) + + const status = createMemo(() => { + const id = props.sessionID() + if (!id) return undefined + return sync.data.session_status?.[id]?.type + }) + + onMount(() => { + const top = sync.data.session + .filter((s) => s.parentID === undefined) + .slice() + .sort((a, b) => b.time.updated - a.time.updated) + .slice(0, 5) + .map((s) => s.id) + prefetchPreviews(sdk, sync, top) + }) + + const syncedMessages = createMemo(() => { + const id = props.sessionID() + if (!id) return undefined + return hydrateFromSync(sync, id) + }) + + const syncedDiff = createMemo(() => { + const id = props.sessionID() + if (!id) return undefined + const diff = sync.data.session_diff[id] + return diff && diff.length > 0 ? (diff as SnapshotFileDiff[]) : undefined + }) + + const [fetchedMessages] = createResource( + () => { + const id = props.sessionID() + if (!id || syncedMessages()) return undefined + return { sessionID: id, version: session()?.time.updated ?? 0 } + }, + async (input) => loadMessages(sdk, input.sessionID, input.version), + ) + + const [fetchedDiff] = createResource( + () => { + const id = props.sessionID() + if (!id || syncedDiff()) return undefined + return { sessionID: id, version: session()?.time.updated ?? 0 } + }, + async (input) => loadDiff(sdk, input.sessionID, input.version), + ) + + const messages = createMemo(() => syncedMessages() ?? fetchedMessages() ?? []) + const diff = createMemo(() => syncedDiff() ?? fetchedDiff() ?? []) + + const diffSummary = createMemo(() => { + const live = diff() + if (live && live.length > 0) { + let additions = 0 + let deletions = 0 + for (const file of live) { + additions += file.additions ?? 0 + deletions += file.deletions ?? 0 + } + return formatDiffSummary({ additions, deletions, files: live.length }) + } + return formatDiffSummary(session()?.summary) + }) + + const exchange = createMemo(() => { + const items = messages() + if (!items || items.length === 0) return undefined + const sorted = items.toSorted((a, b) => messageCreated(a) - messageCreated(b)) + const user = sorted.findLast((item) => messageRole(item) === "user") + const assistant = user + ? sorted.findLast((item) => messageRole(item) === "assistant" && messageParentID(item) === user.info.id) + : sorted.findLast((item) => messageRole(item) === "assistant") + return { user, assistant } + }) + + const loading = createMemo(() => (fetchedMessages.loading || fetchedDiff.loading) && !exchange()) + + const statusLabel = createMemo(() => { + const s = status() + if (s === "busy") return { text: "working", color: theme.warning } + if (s === "retry") return { text: "retrying", color: theme.warning } + return { text: "idle", color: theme.textMuted } + }) + + return ( + + + No session selected + + } + > + {(s) => ( + <> +
    + + loading preview... + + + + No messages yet + + + } + > + {(ex) => } + + + )} + + + ) +} + +function messageRole(item: WithParts) { + return (item.info as { role?: string }).role +} + +function messageCreated(item: WithParts) { + return (item.info.time as { created?: number }).created ?? 0 +} + +function messageParentID(item: WithParts) { + return (item.info as { parentID?: string }).parentID +} + +const ROW_WIDTH = 40 + +function Header(props: { + session: SdkSession + statusLabel: { text: string; color: RGBA } + diff: { additions: number; deletions: number; files: number } | undefined +}) { + const { theme } = useTheme() + const title = createMemo(() => Locale.truncate(props.session.title, ROW_WIDTH)) + const modelAgent = createMemo(() => { + const m = shortModelLabel(props.session.model) + const a = props.session.agent ?? "" + if (m && a) return Locale.truncate(`${m} · ${a}`, ROW_WIDTH) + if (m) return Locale.truncate(m, ROW_WIDTH) + if (a) return Locale.truncate(a, ROW_WIDTH) + return "" + }) + const statusRest = createMemo(() => { + const joined = ` · ${relativeTime(props.session.time.updated)}` + return Locale.truncate(joined, Math.max(0, ROW_WIDTH - props.statusLabel.text.length)) + }) + + return ( + + + + {title()} + + + + + + {modelAgent()} + + + + + + {props.statusLabel.text} + {statusRest()} + + + {(d) => } + + ) +} + +function Row(props: { height: number; children: JSX.Element }) { + return ( + + {props.children} + + ) +} + +function DiffRow(props: { diff: { additions: number; deletions: number; files: number } }) { + const { theme } = useTheme() + const showAdds = () => props.diff.additions > 0 + const showDels = () => props.diff.deletions > 0 + if (!showAdds() && !showDels()) return null + return ( + + + + +{props.diff.additions} + + + + + + −{props.diff.deletions} + + + + ) +} + +const PROMPT_MAX_CHARS = 240 +const REPLY_MAX_LINES = 12 +const REPLY_MAX_CHARS = 800 + +function Exchange(props: { exchange: { user?: WithParts; assistant?: WithParts } }) { + const { theme, syntax } = useTheme() + const userText = createMemo(() => + props.exchange.user ? extractMessageText(props.exchange.user.parts, PROMPT_MAX_CHARS) : undefined, + ) + const assistantMarkdown = createMemo(() => + props.exchange.assistant + ? extractMessageMarkdown(props.exchange.assistant.parts, REPLY_MAX_LINES, REPLY_MAX_CHARS) + : undefined, + ) + + return ( + + + + + {userText()!} + + + + + + + + + + ) +} + +function NonTextHint(props: { exchange: { user?: WithParts; assistant?: WithParts } }) { + const { theme } = useTheme() + const summary = createMemo(() => { + const counts: Record = {} + for (const item of [props.exchange.user, props.exchange.assistant]) { + if (!item) continue + for (const part of item.parts) { + counts[part.type] = (counts[part.type] ?? 0) + 1 + } + } + return Object.entries(counts) + .map(([k, n]) => `${n} ${k}`) + .join(", ") + }) + return ( + + + Latest exchange has no text content ({summary()}) + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/util.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/util.tsx new file mode 100644 index 0000000000..4323602ab7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/util.tsx @@ -0,0 +1,70 @@ +import type { Part } from "@opencode-ai/sdk/v2" +import { Locale } from "@/util/locale" + +export function relativeTime(timestamp: number): string { + const diff = Date.now() - timestamp + if (diff < 0) return "just now" + const seconds = Math.floor(diff / 1000) + if (seconds < 60) return "just now" + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + if (days < 7) return `${days}d ago` + const d = new Date(timestamp) + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) +} + +export function extractMessageText(parts: readonly Part[], maxLength: number): string { + const joined = collectTextParts(parts).join(" ").replace(/\s+/g, " ").trim() + return Locale.truncate(joined, maxLength) +} + +export function extractMessageMarkdown(parts: readonly Part[], maxLines: number, maxChars: number): string { + const joined = collectTextParts(parts).join("\n\n").trim() + if (!joined) return joined + + let truncated = joined + const lines = truncated.split("\n") + if (lines.length > maxLines) { + truncated = lines.slice(0, maxLines).join("\n") + } + if (truncated.length > maxChars) { + truncated = truncated.slice(0, maxChars).trimEnd() + } + if (truncated.length === joined.length) return joined + // Close any unterminated fenced code block so the renderer doesn't keep + // the rest of the panel in "code mode". + const fences = (truncated.match(/^```/gm) ?? []).length + if (fences % 2 === 1) truncated += "\n```" + return truncated + "\n\n…" +} + +function collectTextParts(parts: readonly Part[]): string[] { + const chunks: string[] = [] + for (const part of parts) { + if (part.type !== "text") continue + const p = part as Part & { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } + if (p.synthetic || p.ignored) continue + if (!p.text) continue + chunks.push(p.text) + } + return chunks +} + +export function formatDiffSummary( + summary: { additions: number; deletions: number; files: number } | undefined, +): { additions: number; deletions: number; files: number } | undefined { + if (!summary) return undefined + if (!summary.additions && !summary.deletions && !summary.files) return undefined + return summary +} + +export function shortModelLabel(model: { id: string; providerID?: string; variant?: string } | undefined): string { + if (!model) return "" + const id = model.id ?? "" + const stripped = + model.providerID && id.startsWith(`${model.providerID}/`) ? id.slice(model.providerID.length + 1) : id + return model.variant ? `${stripped} (${model.variant})` : stripped +} diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 8b9b088805..8b2b2ed37b 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -442,16 +442,25 @@ function ReasoningHeader(props: { toggleable: boolean; open: boolean; done: bool : theme.warning return ( - - - {props.open ? "- " : "+ "} - - {props.done ? "Thought" : "Thinking"} - - : - {props.title} - - + + + + {props.title ? "Thinking: " + props.title : "Thinking"} + + + + + + {props.open ? "- " : "+ "} + + Thought + + : + {props.title} + + + + ) } diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index d85b38c569..0180ea69d2 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -11,6 +11,8 @@ import Notifications from "../feature-plugins/system/notifications" import SessionV2Debug from "../feature-plugins/system/session-v2" import WhichKey from "../feature-plugins/system/which-key" import DiffViewer from "../feature-plugins/system/diff-viewer" +import SessionSwitcher from "../feature-plugins/session" +import { Flag } from "@opencode-ai/core/flag/flag" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" import type { RuntimeFlags } from "@/effect/runtime-flags" @@ -35,5 +37,6 @@ export function internalTuiPlugins(flags: Pick - - {props.open ? "- " : "+ "} - - {props.done ? "Thought" : "Thinking"} - - : - - - {props.title} - - - - {props.title ? " · " : ""} - {props.duration} - - - + + + + {props.title ? "Thinking: " + props.title : "Thinking"} + + + + + + {props.open ? "- " : "+ "} + + Thought + + : + + + {props.title} + + + + {props.title ? " · " : ""} + {props.duration} + + + + + ) } @@ -1780,12 +1791,12 @@ function InlineTool(props: { part: ToolPart onClick?: () => void }) { - const [margin, setMargin] = createSignal(0) const { theme } = useTheme() const ctx = use() const sync = useSync() const renderer = useRenderer() const [hover, setHover] = createSignal(false) + const [errorExpanded, setErrorExpanded] = createSignal(false) const permission = createMemo(() => { const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID @@ -1793,14 +1804,6 @@ function InlineTool(props: { return callID === props.part.callID }) - const fg = createMemo(() => { - if (props.color) return props.color - if (permission()) return theme.warning - if (hover() && props.onClick) return theme.text - if (props.complete) return theme.textMuted - return theme.text - }) - const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined)) const denied = createMemo( @@ -1811,53 +1814,134 @@ function InlineTool(props: { error()?.includes("user dismissed"), ) + const failed = createMemo(() => Boolean(error() && !denied())) + const clickable = createMemo(() => Boolean(props.onClick || failed())) + const fg = createMemo(() => { + if (props.color) return props.color + if (permission()) return theme.warning + if (failed()) return theme.error + if (hover() && props.onClick) return theme.text + if (props.complete) return theme.textMuted + return theme.text + }) + + return ( + + sync.data.message[ctx.sessionID]?.some((message) => message.role === "user" && message.id === id) ?? false + } + onMouseOver={() => clickable() && setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + if (failed()) { + setErrorExpanded((value) => !value) + return + } + props.onClick?.() + }} + > + {props.children} + + ) +} + +export function InlineToolRow(props: { + icon: string + iconColor?: RGBA + color?: RGBA + errorColor?: RGBA + failed?: boolean + denied?: boolean + error?: string + errorExpanded?: boolean + complete: any + pending: string + spinner?: boolean + children: JSX.Element + separateAfter?: (id: string | undefined) => boolean + onMouseOver?: () => void + onMouseOut?: () => void + onMouseUp?: () => void +}) { + const [margin, setMargin] = createSignal(0) + return ( props.onClick && setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={() => { - if (renderer.getSelection()?.getSelectedText()) return - props.onClick?.() - }} + onMouseOver={props.onMouseOver} + onMouseOut={props.onMouseOut} + onMouseUp={props.onMouseUp} renderBefore={function () { const el = this as BoxRenderable const parent = el.parent if (!parent) { return } - if (el.height > 1) { - setMargin(1) - return - } const children = parent.getChildren() const index = children.indexOf(el) const previous = children[index - 1] - if (!previous) { - setMargin(0) - return - } - if (previous.height > 1 || previous.id.startsWith("text-")) { - setMargin(1) - return - } + setMargin( + previous?.id.startsWith("text-") || + previous?.id.startsWith("tool-block-") || + props.separateAfter?.(previous?.id) + ? 1 + : 0, + ) }} > - + - - ~ {props.pending}} when={props.complete}> - {props.icon} {props.children} - - + + ~ {props.pending} + + } + when={props.complete} + > + + + {props.icon} + + + {props.children} + + + - - {error()} + + + {props.error} + ) @@ -1876,6 +1960,7 @@ function BlockTool(props: { const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined)) return ( { title: string value: T description?: string + details?: string[] footer?: JSX.Element | string category?: string categoryView?: JSX.Element @@ -64,6 +65,7 @@ export interface DialogSelectOption { export type DialogSelectRef = { filter: string filtered: DialogSelectOption[] + selected: DialogSelectOption | undefined } export function DialogSelect(props: DialogSelectProps) { @@ -167,7 +169,7 @@ export function DialogSelect(props: DialogSelectProps) { if (!category) return acc return acc + (i > 0 ? 2 : 1) }, 0) - return flat().length + headers + return flat().reduce((acc, option) => acc + 1 + (option.details?.length ?? 0), headers) }) const dimensions = useTerminalDimensions() @@ -336,6 +338,9 @@ export function DialogSelect(props: DialogSelectProps) { get filtered() { return filtered() }, + get selected() { + return selected() + }, } props.ref?.(ref) @@ -349,7 +354,7 @@ export function DialogSelect(props: DialogSelectProps) { const right = createMemo(() => visibleActions().filter((item) => item.side === "right")) return ( - + @@ -386,93 +391,108 @@ export function DialogSelect(props: DialogSelectProps) { - 0} - fallback={ - - No results found - - } - > - (scroll = r)} - maxHeight={height()} + + 0} + fallback={ + + No results found + + } > - - {([category, options], index) => ( - <> - - 0 ? 1 : 0} paddingLeft={3}> - - {category} - - } - > - {options[0]?.categoryView} - - - - - {(option) => { - const active = createMemo(() => isDeepEqual(option.value, selected()?.value)) - const current = createMemo(() => isDeepEqual(option.value, props.current)) - return ( - { - setStore("input", "mouse") - }} - onMouseUp={() => { - option.onSelect?.(dialog) - props.onSelect?.(option) - }} - onMouseOver={() => { - if (store.input !== "mouse") return - const index = flat().findIndex((x) => isDeepEqual(x.value, option.value)) - if (index === -1) return - moveTo(index) - }} - onMouseDown={() => { - const index = flat().findIndex((x) => isDeepEqual(x.value, option.value)) - if (index === -1) return - moveTo(index) - }} - backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)} - paddingLeft={current() || option.gutter ? 1 : 3} - paddingRight={3} - gap={1} + (scroll = r)} + maxHeight={height()} + > + + {([category, options], index) => ( + <> + + 0 ? 1 : 0} paddingLeft={3}> + + {category} + + } > - - - {option.margin} + {options[0]?.categoryView} + + + + + {(option) => { + const active = createMemo(() => isDeepEqual(option.value, selected()?.value)) + const current = createMemo(() => isDeepEqual(option.value, props.current)) + return ( + { + setStore("input", "mouse") + }} + onMouseUp={() => { + option.onSelect?.(dialog) + props.onSelect?.(option) + }} + onMouseOver={() => { + if (store.input !== "mouse") return + const index = flat().findIndex((x) => isDeepEqual(x.value, option.value)) + if (index === -1) return + moveTo(index) + }} + onMouseDown={() => { + const index = flat().findIndex((x) => isDeepEqual(x.value, option.value)) + if (index === -1) return + moveTo(index) + }} + > + + + + {option.margin} + + + - - - ) - }} - - - )} - - - + + {(detail) => ( + + + {Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))} + + + )} + + + ) + }} + + + )} + + + + }> ()("@opencode/Runtime experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"), - acpNext: bool("OPENCODE_ACP_NEXT"), outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"), + experimentalWebSockets: bool("OPENCODE_EXPERIMENTAL_WEBSOCKETS"), client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")), }) {} diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index adddea0b35..bf421473f7 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -3,6 +3,7 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Context, Option, Schema } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" export const Tokens = Schema.Struct({ accessToken: Schema.mutableKey(Schema.String), @@ -33,6 +34,7 @@ const decodeAuthData = Schema.decodeUnknownOption(Schema.Record(Schema.String, E type AuthData = Record const filepath = path.join(Global.Path.data, "mcp-auth.json") +const lockKey = `mcp-auth:${filepath}` export interface Interface { readonly all: () => Effect.Effect> @@ -58,14 +60,27 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service + const flock = yield* EffectFlock.Service - const all = Effect.fn("McpAuth.all")(function* () { + const read = Effect.fn("McpAuth.read")(function* () { return yield* fs.readJson(filepath).pipe( Effect.map((data): AuthData => Option.getOrElse(decodeAuthData(data), () => ({}) as AuthData) as AuthData), Effect.catch(() => Effect.succeed({} as AuthData)), ) }) + const all = Effect.fn("McpAuth.all")(function* () { + return yield* read().pipe(flock.withLock(lockKey), Effect.orDie) + }) + + const mutate = Effect.fn("McpAuth.mutate")(function* (update: (data: AuthData) => AuthData | undefined) { + yield* Effect.gen(function* () { + const next = update(yield* read()) + if (!next) return + yield* fs.writeJson(filepath, next, 0o600).pipe(Effect.orDie) + }).pipe(flock.withLock(lockKey), Effect.orDie) + }) + const get = Effect.fn("McpAuth.get")(function* (mcpName: string) { const data = yield* all() return data[mcpName] @@ -80,31 +95,38 @@ export const layer = Layer.effect( }) const set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) { - const data = yield* all() - if (serverUrl) entry.serverUrl = serverUrl - yield* fs.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600).pipe(Effect.orDie) + yield* mutate((data) => ({ + ...data, + [mcpName]: serverUrl ? { ...entry, serverUrl } : entry, + })) }) const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) { - const data = yield* all() - delete data[mcpName] - yield* fs.writeJson(filepath, data, 0o600).pipe(Effect.orDie) + yield* mutate((data) => { + const next = { ...data } + delete next[mcpName] + return next + }) }) const updateField = (field: K, spanName: string) => Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable, serverUrl?: string) { - const entry = (yield* get(mcpName)) ?? {} - entry[field] = value - yield* set(mcpName, entry, serverUrl) + yield* mutate((data) => { + const entry = data[mcpName] ?? {} + entry[field] = value + if (serverUrl) entry.serverUrl = serverUrl + return { ...data, [mcpName]: entry } + }) }) const clearField = (field: keyof Entry, spanName: string) => Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) { - const entry = yield* get(mcpName) - if (entry) { + yield* mutate((data) => { + const entry = data[mcpName] + if (!entry) return undefined delete entry[field] - yield* set(mcpName, entry) - } + return { ...data, [mcpName]: entry } + }) }) const updateTokens = updateField("tokens", "updateTokens") @@ -144,6 +166,9 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), +) export * as McpAuth from "./auth" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index c95394f338..2bac6cc84b 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -971,7 +971,7 @@ export type AuthStatus = "authenticated" | "expired" | "not_authenticated" // --- Per-service runtime --- export const defaultLayer = layer.pipe( - Layer.provide(McpAuth.layer), + Layer.provide(McpAuth.defaultLayer), Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 793a072b29..717dff8db2 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -10,7 +10,7 @@ import { Bus } from "../bus" import * as Log from "@opencode-ai/core/util/log" import { createOpencodeClient } from "@opencode-ai/sdk" import { ServerAuth } from "@/server/auth" -import { CodexAuthPlugin } from "./codex" +import { CodexAuthPlugin } from "./openai/codex" import { Session } from "@/session/session" import { NamedError } from "@opencode-ai/core/util/error" import { CopilotAuthPlugin } from "./github-copilot/copilot" @@ -29,6 +29,7 @@ import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } fro import { registerAdapter } from "@/control-plane/adapters" import type { WorkspaceAdapter } from "@/control-plane/types" import { RuntimeFlags } from "@/effect/runtime-flags" +import { InstallationChannel } from "@opencode-ai/core/installation/version" const log = Log.create({ service: "plugin" }) @@ -57,18 +58,28 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Plugin") {} +export function experimentalWebSocketsEnabled(input: { enabled: boolean; channel?: string }) { + return input.enabled || ["local", "dev", "beta"].includes(input.channel ?? InstallationChannel) +} + // Built-in plugins that are directly imported (not installed from npm) -const INTERNAL_PLUGINS: PluginInstance[] = [ - CodexAuthPlugin, - CopilotAuthPlugin, - GitlabAuthPlugin, - PoeAuthPlugin, - CloudflareWorkersAuthPlugin, - CloudflareAIGatewayAuthPlugin, - AzureAuthPlugin, - DigitalOceanAuthPlugin, - XaiAuthPlugin, -] +function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] { + return [ + // Temporary rollout: pre-release builds use WebSockets by default; releases require explicit opt-in. + (input) => + CodexAuthPlugin(input, { + experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }), + }), + CopilotAuthPlugin, + GitlabAuthPlugin, + PoeAuthPlugin, + CloudflareWorkersAuthPlugin, + CloudflareAIGatewayAuthPlugin, + AzureAuthPlugin, + DigitalOceanAuthPlugin, + XaiAuthPlugin, + ] +} function isServerPlugin(value: unknown): value is PluginInstance { return typeof value === "function" @@ -151,7 +162,7 @@ export const layer = Layer.effect( $: typeof Bun === "undefined" ? undefined : Bun.$, } - for (const plugin of flags.disableDefaultPlugins ? [] : INTERNAL_PLUGINS) { + for (const plugin of flags.disableDefaultPlugins ? [] : internalPlugins(flags)) { log.info("loading internal plugin", { name: plugin.name }) const init = yield* Effect.tryPromise({ try: () => plugin(input), diff --git a/packages/opencode/src/plugin/openai/README.md b/packages/opencode/src/plugin/openai/README.md new file mode 100644 index 0000000000..2286b3509d --- /dev/null +++ b/packages/opencode/src/plugin/openai/README.md @@ -0,0 +1,31 @@ +# OpenAI Responses WebSocket + +Enabled by default on `local`, `dev`, and `beta`. On `latest` and `prod`, set `OPENCODE_EXPERIMENTAL_WEBSOCKETS=true`. + +## Flow + +1. A streamed `POST /responses` request arrives. +2. If it has no `session-id` or `x-session-affinity` header, use HTTP. +3. Title requests use HTTP. +4. If that session's socket is busy or already in fallback mode, use HTTP. +5. Otherwise, reuse its open socket or open a new one. +6. Send `response.create` and return WebSocket events as SSE. + +## Lifetime + +- Connect timeout: 15 seconds. +- Idle timeout: 5 minutes. +- After a completed response, keep the socket for reuse. +- Reuse a socket for up to 55 minutes, then replace it on the next request. + +## Retries + +- Retry WebSocket stream/setup failures up to 5 times, then use HTTP for that session until the pool entry is idle-pruned. +- `websocket_connection_limit_reached` consumes the same retry budget and HTTP fallback. +- If a WebSocket fails after its first event, fail it as retryable rather than replaying partial output in transport. +- Abort or cancel closes the socket. + +## Next Steps + +- `previous_response_id` continuation. +- Optional second WebSocket for concurrent requests in one session. Currently these use HTTP. diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/openai/codex.ts similarity index 91% rename from packages/opencode/src/plugin/codex.ts rename to packages/opencode/src/plugin/openai/codex.ts index bbd65e0f00..7ed48d0879 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -1,10 +1,11 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import * as Log from "@opencode-ai/core/util/log" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { OAUTH_DUMMY_KEY } from "../auth" +import { OAUTH_DUMMY_KEY } from "../../auth" import os from "os" import { setTimeout as sleep } from "node:timers/promises" import { createServer } from "http" +import { OpenAIWebSocketPool } from "./ws-pool" const log = Log.create({ service: "plugin.codex" }) @@ -28,20 +29,12 @@ interface PkceCodes { } async function generatePKCE(): Promise { - const verifier = generateRandomString(43) - const encoder = new TextEncoder() - const data = encoder.encode(verifier) - const hash = await crypto.subtle.digest("SHA-256", data) - const challenge = base64UrlEncode(hash) - return { verifier, challenge } -} - -function generateRandomString(length: number): string { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" - const bytes = crypto.getRandomValues(new Uint8Array(length)) - return Array.from(bytes) + const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43))) .map((b) => chars[b % chars.length]) .join("") + const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))) + return { verifier, challenge } } function base64UrlEncode(buffer: ArrayBuffer): string { @@ -50,10 +43,6 @@ function base64UrlEncode(buffer: ArrayBuffer): string { return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") } -function generateState(): string { - return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) -} - export interface IdTokenClaims { chatgpt_account_id?: string organizations?: Array<{ id: string }> @@ -120,6 +109,7 @@ interface TokenResponse { interface CodexAuthPluginOptions { issuer?: string codexApiEndpoint?: string + experimentalWebSockets?: boolean } async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise { @@ -371,8 +361,14 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise { const issuer = options.issuer ?? ISSUER const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT + let websocketFetchInstalled = false + const websocketFetches: Array> = [] return { + async dispose() { + for (const websocketFetch of websocketFetches) websocketFetch.close() + websocketFetches.length = 0 + }, provider: { id: "openai", async models(provider, ctx) { @@ -410,7 +406,14 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug provider: "openai", async loader(getAuth) { const auth = await getAuth() - if (auth.type !== "oauth") return {} + const websocketFetch = options.experimentalWebSockets + ? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch }) + : undefined + if (websocketFetch) { + websocketFetches.push(websocketFetch) + websocketFetchInstalled = true + } + if (auth.type !== "oauth") return websocketFetch ? { fetch: websocketFetch } : {} let refreshPromise: | Promise<{ @@ -422,7 +425,6 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug return { apiKey: OAUTH_DUMMY_KEY, async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { - // Remove dummy API key authorization header if (init?.headers) { if (init.headers instanceof Headers) { init.headers.delete("authorization") @@ -436,12 +438,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug } const currentAuth = await getAuth() - if (currentAuth.type !== "oauth") return fetch(requestInput, init) + if (currentAuth.type !== "oauth") + return websocketFetch ? websocketFetch(requestInput, init) : fetch(requestInput, init) - // Cast to include accountId field const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string } - // Check if token needs refresh if (!currentAuth.access || currentAuth.expires < Date.now()) { if (!refreshPromise) { log.info("refreshing codex access token") @@ -473,7 +474,6 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug authWithAccount.accountId = refreshed.accountId } - // Build headers const headers = new Headers() if (init?.headers) { if (init.headers instanceof Headers) { @@ -488,16 +488,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug } } } - - // Set authorization header with access token headers.set("authorization", `Bearer ${currentAuth.access}`) - - // Set ChatGPT-Account-Id header for organization subscriptions if (authWithAccount.accountId) { headers.set("ChatGPT-Account-Id", authWithAccount.accountId) } - // Rewrite URL to Codex endpoint const parsed = requestInput instanceof URL ? requestInput @@ -507,10 +502,12 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug ? new URL(codexApiEndpoint) : parsed - return fetch(url, { + const requestInit = { ...init, headers, - }) + } + if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit) + return fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit)) }, } }, @@ -521,7 +518,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug authorize: async () => { const { redirectUri } = await startOAuthServer() const pkce = await generatePKCE() - const state = generateState() + const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) const authUrl = buildAuthorizeUrl(redirectUri, pkce, state) const callbackPromise = waitForOAuthCallback(pkce, state) @@ -639,6 +636,10 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug output.headers.originator = "opencode" output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})` output.headers["session-id"] = input.sessionID + // Temporary fetch-layer hack: title generation currently shares the conversation + // session ID, so the OpenAI plugin marks it for HTTP fallback until transport + // context can be passed directly instead of smuggled through headers. + if (websocketFetchInstalled && input.agent === "title") output.headers[OpenAIWebSocketPool.TITLE_HEADER] = "true" }, "chat.params": async (input, output) => { if (input.model.providerID !== "openai") return diff --git a/packages/opencode/src/plugin/openai/ws-pool.ts b/packages/opencode/src/plugin/openai/ws-pool.ts new file mode 100644 index 0000000000..3316570976 --- /dev/null +++ b/packages/opencode/src/plugin/openai/ws-pool.ts @@ -0,0 +1,272 @@ +import WebSocket from "ws" +import * as Log from "@opencode-ai/core/util/log" +import { ProviderError } from "@/provider/error" +import { isRecord } from "@/util/record" +import { OpenAIWebSocket } from "./ws" + +export const TITLE_HEADER = "x-opencode-title" + +const log = Log.create({ service: "plugin.openai.ws" }) + +export interface CreateWebSocketFetchOptions { + httpFetch?: typeof globalThis.fetch + url?: string + connectTimeout?: number + idleTimeout?: number + maxConnectionAge?: number + streamRetries?: number +} + +interface PoolEntry { + socket?: WebSocket + connectedAt?: number + lastUsedAt: number + busy: boolean + fallback: boolean + streamFailures: number +} + +const DEFAULT_CONNECT_TIMEOUT = 15_000 +const DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1000 +const DEFAULT_MAX_CONNECTION_AGE = 55 * 60 * 1000 +const CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached" + +export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) { + const httpFetch = options?.httpFetch ?? globalThis.fetch + const pool = new Map() + const connectTimeout = options?.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT + const idleTimeout = options?.idleTimeout ?? DEFAULT_IDLE_TIMEOUT + const maxConnectionAge = options?.maxConnectionAge ?? DEFAULT_MAX_CONNECTION_AGE + const streamRetries = options?.streamRetries ?? 5 + const pruneTimer = setInterval(() => prune(), Math.min(idleTimeout, 60_000)) + if (typeof pruneTimer === "object" && "unref" in pruneTimer && typeof pruneTimer.unref === "function") { + pruneTimer.unref() + } + + async function websocketFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = input instanceof URL ? input.toString() : typeof input === "string" ? input : input.url + const internalHeaders = OpenAIWebSocket.normalizeHeaders(init?.headers) + const httpInit = withoutInternalHeaders(init) + + if (init?.method !== "POST" || !new URL(url).pathname.endsWith("/responses")) { + return httpFetch(input, httpInit) + } + + const body = (() => { + try { + if (typeof init?.body !== "string") return undefined + const parsed = JSON.parse(init.body) + return typeof parsed === "object" && parsed !== null ? parsed : undefined + } catch { + return undefined + } + })() + if (!body?.stream) return httpFetch(input, httpInit) + if (internalHeaders[TITLE_HEADER] === "true") { + log.debug("http fallback", { reason: "title" }) + return httpFetch(input, httpInit) + } + + const sessionID = internalHeaders["x-session-affinity"] ?? internalHeaders["session-id"] + if (!sessionID) { + log.debug("http fallback", { reason: "missing_session" }) + return httpFetch(input, httpInit) + } + const key = `${sessionID}:conversation` + + const entry = pool.get(key) ?? { lastUsedAt: Date.now(), busy: false, fallback: false, streamFailures: 0 } + pool.set(key, entry) + + if (entry.fallback) { + log.debug("http fallback", { key, reason: "fallback_active" }) + return httpFetch(input, httpInit) + } + if (entry.busy) { + log.debug("http fallback", { key, reason: "busy" }) + return httpFetch(input, httpInit) + } + + entry.busy = true + entry.lastUsedAt = Date.now() + try { + entry.socket = await socket( + entry, + options?.url ?? url, + OpenAIWebSocket.normalizeHeaders(httpInit?.headers), + connectTimeout, + maxConnectionAge, + init?.signal, + ) + let resolveFirstEvent: (started: boolean) => void = () => {} + let rejectFirstEvent: (error: Error) => void = () => {} + const firstEvent = new Promise((resolve, reject) => { + resolveFirstEvent = resolve + rejectFirstEvent = reject + }) + const response = OpenAIWebSocket.streamResponsesWebSocket({ + socket: entry.socket, + body, + idleTimeout, + signal: init?.signal ?? undefined, + onFirstEvent: () => resolveFirstEvent(true), + onTerminal: (event) => { + entry.busy = false + entry.lastUsedAt = Date.now() + entry.streamFailures = 0 + if (event.type !== "response.completed" && event.type !== "response.done") { + log.warn("websocket terminal failure", { key, type: event.type }) + invalidate(entry) + } + }, + onConnectionInvalid: (error) => { + log.warn("websocket invalidated", { key, error: error.message }) + entry.busy = false + if (!entry.fallback) recordStreamFailure(entry) + invalidate(entry) + resolveFirstEvent(false) + }, + onAbort: (error) => { + log.debug("websocket aborted", { key }) + entry.busy = false + entry.lastUsedAt = Date.now() + entry.streamFailures = 0 + invalidate(entry) + rejectFirstEvent(error) + }, + onRetryableTerminal: async (event) => { + const error = connectionLimitError(event) + if (!error) return undefined + log.warn("websocket connection limit reached", { key }) + throw error + }, + }) + if (await firstEvent) return response + if (!entry.fallback) return response + log.debug("http fallback", { key, reason: "websocket_retries_exhausted" }) + return httpFetch(input, httpInit) + } catch (error) { + entry.busy = false + entry.lastUsedAt = Date.now() + if (OpenAIWebSocket.isAbortError(error)) { + entry.streamFailures = 0 + invalidate(entry) + throw error + } + + recordStreamFailure(entry) + log.warn("websocket setup failed", { + key, + error: error instanceof Error ? error.message : String(error), + fallback: entry.fallback ? "http" : undefined, + }) + invalidate(entry) + if (entry.fallback) return httpFetch(input, httpInit) + return failedResponse( + new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), { + cause: error, + }), + ) + } + } + + function recordStreamFailure(entry: PoolEntry) { + entry.streamFailures++ + // Codex counts retries after the initial failed WebSocket attempt. + if (entry.streamFailures > streamRetries) entry.fallback = true + } + + function prune() { + const now = Date.now() + for (const [key, entry] of pool) { + if (entry.busy) continue + if (now - entry.lastUsedAt < idleTimeout) continue + log.debug("websocket idle prune", { key }) + invalidate(entry) + pool.delete(key) + } + } + + function close() { + log.debug("websocket pool close", { count: pool.size }) + clearInterval(pruneTimer) + for (const entry of pool.values()) invalidate(entry) + pool.clear() + } + + return Object.assign(websocketFetch, { close }) +} + +function connectionLimitError(event: Record) { + if (event.type !== "error" || !isRecord(event.error) || event.error.code !== CONNECTION_LIMIT_REACHED_CODE) return + return new Error(typeof event.error.message === "string" ? event.error.message : CONNECTION_LIMIT_REACHED_CODE) +} + +function failedResponse(error: ProviderError.ResponseStreamError) { + return new Response( + new ReadableStream({ + start(controller) { + controller.error(error) + }, + }), + { + status: 200, + headers: { "content-type": "text/event-stream" }, + }, + ) +} + +async function socket( + entry: PoolEntry, + url: string, + headers: Record, + connectTimeout: number, + maxConnectionAge: number, + signal?: AbortSignal | null, +) { + if ( + entry.socket?.readyState === WebSocket.OPEN && + entry.connectedAt && + Date.now() - entry.connectedAt < maxConnectionAge + ) { + return entry.socket + } + + invalidate(entry) + const next = await OpenAIWebSocket.connectResponsesWebSocket({ + url: OpenAIWebSocket.toWebSocketUrl(url), + headers, + timeout: connectTimeout, + signal: signal ?? undefined, + }) + entry.connectedAt = Date.now() + return next +} + +function invalidate(entry: PoolEntry) { + if (entry.socket) { + entry.socket.on("error", () => {}) + entry.socket.terminate() + entry.socket = undefined + } + entry.connectedAt = undefined +} + +export function withoutInternalHeaders(init: T | undefined): T | undefined { + if (!init?.headers) return init + if (init.headers instanceof Headers) { + const headers = new Headers(init.headers) + headers.delete(TITLE_HEADER) + return { ...init, headers } + } + + if (Array.isArray(init.headers)) { + return { ...init, headers: init.headers.filter((item) => item[0].toLowerCase() !== TITLE_HEADER) } + } + + return { + ...init, + headers: Object.fromEntries(Object.entries(init.headers).filter(([key]) => key.toLowerCase() !== TITLE_HEADER)), + } +} + +export * as OpenAIWebSocketPool from "./ws-pool" diff --git a/packages/opencode/src/plugin/openai/ws.ts b/packages/opencode/src/plugin/openai/ws.ts new file mode 100644 index 0000000000..7ff8d7bb83 --- /dev/null +++ b/packages/opencode/src/plugin/openai/ws.ts @@ -0,0 +1,334 @@ +// Low-level OpenAI Responses WebSocket protocol helpers. Session pooling, +// fallback, and continuation state intentionally live above this file. + +import WebSocket from "ws" +import { ProviderError } from "@/provider/error" +import { errorMessage } from "@/util/error" +import { ProxyEnv } from "@/util/proxy-env" + +export const PROTOCOL_HEADER = "responses_websockets=2026-02-06" + +export interface ConnectResponsesWebSocketOptions { + url: string + headers: Record + timeout?: number + signal?: AbortSignal +} + +export interface StreamResponsesWebSocketOptions { + socket: WebSocket + body: Record + idleTimeout?: number + signal?: AbortSignal + onFirstEvent?: () => void + onComplete?: (event: Record) => void + onTerminal?: (event: Record) => void + onRetryableTerminal?: (event: Record) => Promise + onConnectionInvalid?: (error: ProviderError.ResponseStreamError) => void + onAbort?: (error: Error) => void +} + +export function toWebSocketUrl(url: string) { + return url.replace(/^http/, "ws") +} + +export function normalizeHeaders(headers: HeadersInit | undefined): Record { + const result: Record = {} + if (!headers) return result + + if (headers instanceof Headers) { + headers.forEach((value, key) => { + result[key.toLowerCase()] = value + }) + return result + } + + if (Array.isArray(headers)) { + for (const [key, value] of headers) { + result[key.toLowerCase()] = value + } + return result + } + + for (const [key, value] of Object.entries(headers)) { + if (value != null) result[key.toLowerCase()] = value + } + return result +} + +export function isAbortError(error: unknown): error is DOMException { + return error instanceof DOMException && error.name === "AbortError" +} + +export function connectResponsesWebSocket(options: ConnectResponsesWebSocketOptions) { + return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + reject(abortError(options.signal)) + return + } + + const headers: Record = { + ...options.headers, + "openai-beta": options.headers["openai-beta"] ?? PROTOCOL_HEADER, + } + delete headers["content-length"] + + // Bun does not apply HTTP(S)_PROXY to WebSockets unless the proxy is supplied explicitly. + const proxy = + typeof Bun === "undefined" + ? undefined + : ProxyEnv.getProxyForUrl(options.url.replace(/^wss:/, "https:").replace(/^ws:/, "http:")) + const connect = { headers, ...(proxy ? { proxy } : {}) } + const socket = new WebSocket(options.url, connect) + const timeout = options.timeout + ? setTimeout(() => { + cleanup() + socket.on("error", () => {}) + socket.terminate() + reject(new Error("WebSocket connect timed out")) + }, options.timeout) + : undefined + + function cleanup() { + if (timeout) clearTimeout(timeout) + socket.off("open", onOpen) + socket.off("error", onError) + socket.off("close", onClose) + options.signal?.removeEventListener("abort", onAbort) + } + + function onOpen() { + cleanup() + resolve(socket) + } + + function onError(error: unknown) { + socket.on("error", () => {}) + cleanup() + reject(error instanceof Error ? error : new Error(errorMessage(error), { cause: error })) + } + + function onClose(code: number, reason: Buffer) { + cleanup() + reject(new Error(closeMessage("WebSocket closed before open", code, reason))) + } + + function onAbort() { + cleanup() + socket.on("error", () => {}) + socket.terminate() + reject(abortError(options.signal)) + } + + socket.once("open", onOpen) + socket.once("error", onError) + socket.once("close", onClose) + options.signal?.addEventListener("abort", onAbort, { once: true }) + }) +} + +export function streamResponsesWebSocket(options: StreamResponsesWebSocketOptions) { + const encoder = new TextEncoder() + + let socket = options.socket + let controller: ReadableStreamDefaultController | undefined + let cleanupSocket = () => {} + let completed = false + let emitted = false + let idleTimer: ReturnType | undefined + + function cleanup() { + if (idleTimer) clearTimeout(idleTimer) + cleanupSocket() + options.signal?.removeEventListener("abort", onAbort) + } + + function terminateSocket(target = socket) { + target.on("error", () => {}) + target.terminate() + } + + function closeCompleted() { + cleanup() + controller?.enqueue(encoder.encode("data: [DONE]\n\n")) + controller?.close() + } + + function invalidate(error: ProviderError.ResponseStreamError) { + if (completed) return + completed = true + cleanup() + options.onConnectionInvalid?.(error) + controller?.error(error) + } + + function resetIdleTimeout(message: string) { + if (completed) return + if (!options.idleTimeout) return + if (idleTimer) clearTimeout(idleTimer) + idleTimer = setTimeout(() => invalidate(new ProviderError.ResponseStreamError(message)), options.idleTimeout) + } + + async function onMessage(data: WebSocket.RawData, isBinary: boolean) { + if (completed) return + if (isBinary) { + invalidate(new ProviderError.ResponseStreamError("Unexpected binary WebSocket frame")) + return + } + + const text = data.toString() + const event = (() => { + try { + const parsed = JSON.parse(text) + return typeof parsed === "object" && parsed !== null ? parsed : undefined + } catch { + return undefined + } + })() + + if (event?.type === "error" && !emitted && options.onRetryableTerminal) { + cleanupSocket() + if (idleTimer) clearTimeout(idleTimer) + idleTimer = undefined + try { + const next = await options.onRetryableTerminal(event) + if (completed) { + if (next) terminateSocket(next) + return + } + if (next) { + attach(next) + return + } + } catch (error) { + invalidate( + new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), { + cause: error, + }), + ) + return + } + } + + if (!emitted) options.onFirstEvent?.() + controller?.enqueue( + encoder.encode( + `${text + .split(/\r?\n/) + .map((line) => `data: ${line}`) + .join("\n")}\n\n`, + ), + ) + emitted = true + resetIdleTimeout("idle timeout waiting for websocket") + + if (!event) return + + if (event.type === "response.completed" || event.type === "response.done") { + completed = true + options.onComplete?.(event) + options.onTerminal?.(event) + closeCompleted() + return + } + + if (event.type === "response.failed" || event.type === "response.incomplete" || event.type === "error") { + completed = true + options.onTerminal?.(event) + closeCompleted() + } + } + + function onError(error: Error) { + invalidate(new ProviderError.ResponseStreamError(error.message, { cause: error })) + } + + function onClose(code: number, reason: Buffer) { + if (completed) return + invalidate( + new ProviderError.ResponseStreamError(closeMessage("WebSocket closed before response.completed", code, reason)), + ) + } + + function onAbort() { + const error = abortError(options.signal) + if (completed) return + completed = true + cleanup() + terminateSocket() + options.onAbort?.(error) + controller?.error(error) + } + + function onCancel(reason: unknown) { + if (completed) return + completed = true + cleanup() + terminateSocket() + options.onAbort?.(cancelError(reason)) + } + + function attach(next: WebSocket) { + cleanupSocket() + socket = next + socket.on("message", onMessage) + socket.once("error", onError) + socket.once("close", onClose) + cleanupSocket = () => { + socket.off("message", onMessage) + socket.off("error", onError) + socket.off("close", onClose) + } + const { stream: _stream, background: _background, ...payload } = options.body + resetIdleTimeout("idle timeout sending websocket request") + socket.send(JSON.stringify({ type: "response.create", ...payload }), (error) => { + if (completed) return + resetIdleTimeout("idle timeout waiting for websocket") + if (error) invalidate(new ProviderError.ResponseStreamError(error.message, { cause: error })) + }) + } + + return new Response( + new ReadableStream({ + start(next) { + controller = next + options.signal?.addEventListener("abort", onAbort, { once: true }) + + if (options.signal?.aborted) { + onAbort() + return + } + + attach(socket) + }, + cancel(reason) { + onCancel(reason) + }, + }), + { + status: 200, + headers: { "content-type": "text/event-stream" }, + }, + ) +} + +function cancelError(reason: unknown) { + if (isAbortError(reason)) return reason + if (reason instanceof Error) return reason + return new DOMException(typeof reason === "string" ? reason : "Aborted", "AbortError") +} + +function abortError(signal: AbortSignal | undefined) { + const reason = signal?.reason + if (isAbortError(reason)) return reason + return new DOMException(reason instanceof Error ? reason.message : "Aborted", "AbortError") +} + +function closeMessage(message: string, code: number, reason: Buffer) { + const details = [`code ${code}`] + if (code === 1009) details.push("message too big") + if (reason.length > 0) details.push(reason.toString()) + return `${message} (${details.join(": ")})` +} + +export * as OpenAIWebSocket from "./ws" diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 4233839171..06b388b48d 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -11,6 +11,14 @@ export class HeaderTimeoutError extends Error { } } +export class ResponseStreamError extends Error { + public override readonly name = "ProviderResponseStreamError" + + constructor(message: string, options?: ErrorOptions) { + super(message, options) + } +} + // Adapted from overflow detection patterns in: // https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts const OVERFLOW_PATTERNS = [ diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index adb45005b9..73d21d6dd1 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -51,7 +51,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) { async pull(ctrl) { const part = await new Promise>>((resolve, reject) => { const id = setTimeout(() => { - const err = new Error("SSE read timed out") + const err = new ProviderError.ResponseStreamError("SSE read timed out") ctl.abort(err) void reader.cancel(err) reject(err) @@ -505,9 +505,9 @@ function custom(dep: CustomDep): Record { location, fetch: async (input: RequestInfo | URL, init?: RequestInit) => { const { GoogleAuth } = await import("google-auth-library") - const auth = new GoogleAuth() - const client = await auth.getApplicationDefault() - const token = await client.credential.getAccessToken() + const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }) + const client = await auth.getClient() + const token = await client.getAccessToken() const headers = new Headers(init?.headers) headers.set("Authorization", `Bearer ${token.token}`) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 08b2f49221..688e7dc8c9 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -596,8 +596,16 @@ function openaiCompatibleReasoningEfforts(id: string) { return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS } +function anthropicOpus47OrLater(apiId: string) { + const version = /opus-(\d+)[.-](\d+)(?:[.@-]|$)/i.exec(apiId) + if (!version) return false + const major = Number(version[1]) + const minor = Number(version[2]) + return major > 4 || (major === 4 && minor >= 7) +} + function anthropicAdaptiveEfforts(apiId: string): string[] | null { - if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) { + if (anthropicOpus47OrLater(apiId)) { return ["low", "medium", "high", "xhigh", "max"] } if (["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => apiId.includes(v))) { @@ -625,6 +633,7 @@ export function variants(model: Provider.Model): Record= 1.2.0-beta.12) assigns the child pid + // asynchronously, so `proc.pid` is 0 at the synchronous spawn point and only + // resolves a tick later. `create` snapshots it immediately, so 0 is a valid + // "pid not yet assigned" value here. + pid: NonNegativeInt, }).annotate({ identifier: "Pty" }) export type Info = Types.DeepMutable> diff --git a/packages/opencode/src/pty/pty.node.ts b/packages/opencode/src/pty/pty.node.ts index b45c5bf509..76f415f4cd 100644 --- a/packages/opencode/src/pty/pty.node.ts +++ b/packages/opencode/src/pty/pty.node.ts @@ -1,4 +1,3 @@ -/** @ts-expect-error */ import * as pty from "@lydell/node-pty" import type { Opts, Proc } from "./pty" diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 6a5101dc42..09a6e67b6e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -27,6 +27,16 @@ export class ApiWorkspaceWarpError extends Schema.ErrorClass("WorkspaceCreateError")( + { + name: Schema.Literal("WorkspaceCreateError"), + data: Schema.Struct({ + message: Schema.String, + }), + }, + { httpApiStatus: 400 }, +) {} + export const WorkspacePaths = { adapters: `${root}/adapter`, list: root, @@ -64,7 +74,7 @@ export const WorkspaceApi = HttpApi.make("workspace") query: WorkspaceRoutingQuery, payload: CreatePayload, success: described(Workspace.Info, "Workspace created"), - error: HttpApiError.BadRequest, + error: [ApiWorkspaceCreateError, HttpApiError.BadRequest], }).annotateMerge( OpenApi.annotations({ identifier: "experimental.workspace.create", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index 2699c86590..7f5c437d9d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -2,12 +2,12 @@ import { listAdapters } from "@/control-plane/adapters" import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" import { Vcs } from "@/project/vcs" -import { Effect } from "effect" -import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { Cause, Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { notFound } from "../errors" import { ApiVcsApplyError } from "../groups/instance" -import { ApiWorkspaceWarpError, CreatePayload, WarpPayload } from "../groups/workspace" +import { ApiWorkspaceCreateError, ApiWorkspaceWarpError, CreatePayload, WarpPayload } from "../groups/workspace" export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) => Effect.gen(function* () { @@ -30,7 +30,22 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac extra: ctx.payload.extra ?? null, projectID: instance.project.id, }) - .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + .pipe( + Effect.catchCause((cause) => { + // Plugin throws surface as defects (because EffectBridge.fromPromise uses Effect.promise), + // bypassing Effect.mapError. Walk the cause to surface the real error to the client. + const die = cause.reasons.find(Cause.isDieReason) + const fail = cause.reasons.find(Cause.isFailReason) + const reason: unknown = die?.defect ?? fail?.error + const message = reason instanceof Error ? reason.message : "Workspace creation failed" + return Effect.fail( + new ApiWorkspaceCreateError({ + name: "WorkspaceCreateError", + data: { message }, + }), + ) + }), + ) }) const syncList = Effect.fn("WorkspaceHttpApi.syncList")(function* () { diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index c21de79a24..db6554590f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -63,11 +63,11 @@ function decodeCredential(input: string) { Effect.match({ onFailure: emptyCredential, onSuccess: (header) => { - const parts = header.split(":") - if (parts.length !== 2) return emptyCredential() + const separator = header.indexOf(":") + if (separator === -1) return emptyCredential() return { - username: parts[0], - password: Redacted.make(parts[1]), + username: header.slice(0, separator), + password: Redacted.make(header.slice(separator + 1)), } }, }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts index 230f5b105b..e5362f8cbe 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -4,17 +4,11 @@ import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerR import * as Socket from "effect/unstable/socket/Socket" import { WebSocketTracker } from "../websocket-tracker" -function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined { - return request.source instanceof Request ? request.source : undefined -} - function requestBody(request: HttpServerRequest.HttpServerRequest) { if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty + if (request.source instanceof Request && request.source.body === null) return HttpBody.empty const len = request.headers["content-length"] - return HttpBody.raw(webSource(request)?.body ?? null, { - contentType: request.headers["content-type"], - contentLength: len ? Number(len) : undefined, - }) + return HttpBody.stream(request.stream, request.headers["content-type"], len ? Number(len) : undefined) } export function websocket( diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 68ba95a7c4..66fe1e12e1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -159,14 +159,14 @@ function planWorkspaceRequest( function planRequest( request: HttpServerRequest.HttpServerRequest, - sessionWorkspaceID?: WorkspaceID, + session?: Session.Info, ): Effect.Effect { return Effect.gen(function* () { const url = requestURL(request) const envWorkspaceID = configuredWorkspaceID() const workspaceID = url.pathname.startsWith("/api/") - ? selectedV2WorkspaceID(url, sessionWorkspaceID) - : selectedWorkspaceID(url, sessionWorkspaceID) + ? selectedV2WorkspaceID(url, session?.workspaceID) + : selectedWorkspaceID(url, session?.workspaceID) if (workspaceID === InvalidWorkspaceID) return RequestPlan.InvalidWorkspace() const workspace = yield* resolveWorkspace(workspaceID, envWorkspaceID) @@ -178,7 +178,10 @@ function planRequest( return yield* planWorkspaceRequest(request, url, workspace) } - return RequestPlan.Local({ directory: defaultDirectory(request, url), workspaceID: envWorkspaceID ?? workspaceID }) + return RequestPlan.Local({ + directory: session?.directory || defaultDirectory(request, url), + workspaceID: envWorkspaceID ?? workspaceID, + }) }) } @@ -226,7 +229,7 @@ function routeHttpApiWorkspace( Effect.catchDefect(() => Effect.succeed(undefined)), ) : undefined - const plan = yield* planRequest(request, session?.workspaceID) + const plan = yield* planRequest(request, session) return yield* routeWorkspace(client, effect, plan) }) } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 29cc83a1eb..68157f5f4d 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1155,6 +1155,17 @@ export function fromError( }, { cause: e }, ).toObject() + case e instanceof ProviderError.ResponseStreamError: + return new APIError( + { + message: e.message, + isRetryable: true, + metadata: { + code: e.name, + }, + }, + { cause: e }, + ).toObject() case APICallError.isInstance(e): const parsed = ProviderError.parseAPICallError({ providerID: ctx.providerID, diff --git a/packages/opencode/src/util/proxy-env.ts b/packages/opencode/src/util/proxy-env.ts new file mode 100644 index 0000000000..6682b3ca13 --- /dev/null +++ b/packages/opencode/src/util/proxy-env.ts @@ -0,0 +1,72 @@ +/* + * Adapted from proxy-from-env: https://github.com/Rob--W/proxy-from-env + * + * The MIT License + * + * Copyright (C) 2016-2018 Rob Wu + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const DEFAULT_PORTS: Record = { + ftp: 21, + gopher: 70, + http: 80, + https: 443, + ws: 80, + wss: 443, +} + +export function getProxyForUrl(input: string | URL) { + const url = typeof input === "string" ? (URL.canParse(input) ? new URL(input) : undefined) : input + if (!url) return + + const protocol = url.protocol.split(":", 1)[0] + const hostname = url.host.replace(/:\d*$/, "") + const port = Number.parseInt(url.port) || DEFAULT_PORTS[protocol] || 0 + if (!shouldProxy(hostname, port)) return + + const proxy = env(`${protocol}_proxy`) || env("all_proxy") + if (!proxy) return + return proxy.includes("://") ? proxy : `${protocol}://${proxy}` +} + +function shouldProxy(hostname: string, port: number) { + const noProxy = env("no_proxy").toLowerCase() + if (!noProxy) return true + if (noProxy === "*") return false + + return noProxy.split(/[,\s]/).every((proxy) => { + if (!proxy) return true + + const parsed = proxy.match(/^(.+):(\d+)$/) + const proxyHostname = parsed ? parsed[1] : proxy + const proxyPort = parsed ? Number.parseInt(parsed[2]) : 0 + if (proxyPort && proxyPort !== port) return true + + if (!/^[.*]/.test(proxyHostname)) return hostname !== proxyHostname + return !hostname.endsWith(proxyHostname.startsWith("*") ? proxyHostname.slice(1) : proxyHostname) + }) +} + +function env(key: string) { + return process.env[key.toLowerCase()] || process.env[key.toUpperCase()] || "" +} + +export * as ProxyEnv from "./proxy-env" diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index a1d4f89c2a..d9743e563b 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -351,7 +351,7 @@ export const layer: Layer.Layer< return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" }) } - const primary = yield* canonical(ctx.worktree) + const primary = yield* canonical(ctx.project.worktree) const primaryName = pathSvc.basename(primary).toLowerCase() return yield* Effect.forEach(parseWorktreeList(result.text), (entry) => Effect.gen(function* () { diff --git a/packages/opencode/test/acp/agent-interface.test.ts b/packages/opencode/test/acp/agent-interface.test.ts deleted file mode 100644 index 7c4633d7d8..0000000000 --- a/packages/opencode/test/acp/agent-interface.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { ACP } from "../../src/acp/agent" -import type { Agent as ACPAgent } from "@agentclientprotocol/sdk" - -/** - * Type-level test: This line will fail to compile if ACP.Agent - * doesn't properly implement the ACPAgent interface. - * - * The SDK checks for methods like `agent.unstable_setSessionModel` at runtime - * and throws "Method not found" if they're missing. TypeScript allows optional - * interface methods to be omitted, but the SDK still expects them. - * - * @see https://github.com/agentclientprotocol/typescript-sdk/commit/7072d3f - */ -type _AssertAgentImplementsACPAgent = ACP.Agent extends ACPAgent ? true : never -const _typeCheck: _AssertAgentImplementsACPAgent = true - -/** - * Runtime verification that optional methods the SDK expects are actually implemented. - * The SDK's router checks `if (!agent.methodName)` and throws MethodNotFound if missing. - */ -describe("acp.agent interface compliance", () => { - // Extract method names from the ACPAgent interface type - type ACPAgentMethods = keyof ACPAgent - - // Methods that the SDK's router explicitly checks for at runtime - const sdkCheckedMethods: ACPAgentMethods[] = [ - // Required - "initialize", - "newSession", - "prompt", - "cancel", - // Optional but checked by SDK router - "loadSession", - "setSessionMode", - "authenticate", - // Capability-gated methods checked by the SDK router - "listSessions", - "resumeSession", - "closeSession", - "unstable_forkSession", - "unstable_setSessionModel", - ] - - test("Agent implements all SDK-checked methods", () => { - for (const method of sdkCheckedMethods) { - expect(typeof ACP.Agent.prototype[method as keyof typeof ACP.Agent.prototype], `Missing method: ${method}`).toBe( - "function", - ) - } - }) -}) diff --git a/packages/opencode/test/acp-next/config-option.test.ts b/packages/opencode/test/acp/config-option.test.ts similarity index 98% rename from packages/opencode/test/acp-next/config-option.test.ts rename to packages/opencode/test/acp/config-option.test.ts index d538ee81c0..846cfadad6 100644 --- a/packages/opencode/test/acp-next/config-option.test.ts +++ b/packages/opencode/test/acp/config-option.test.ts @@ -8,7 +8,7 @@ import { formatVariantName, parseModelSelection, type ConfigOptionProvider, -} from "@/acp-next/config-option" +} from "@/acp/config-option" const providers: ConfigOptionProvider[] = [ { @@ -46,7 +46,7 @@ const providers: ConfigOptionProvider[] = [ }, ] -describe("acp-next config options", () => { +describe("acp config options", () => { test("builds the model select option with ACP verifier category", () => { expect( buildModelSelectOption({ diff --git a/packages/opencode/test/acp-next/content.test.ts b/packages/opencode/test/acp/content.test.ts similarity index 97% rename from packages/opencode/test/acp-next/content.test.ts rename to packages/opencode/test/acp/content.test.ts index 88f8608345..90f62f9d18 100644 --- a/packages/opencode/test/acp-next/content.test.ts +++ b/packages/opencode/test/acp/content.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test" import type { ContentBlock } from "@agentclientprotocol/sdk" import { pathToFileURL } from "node:url" -import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp-next/content" +import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp/content" -describe("acp-next content conversion", () => { +describe("acp content conversion", () => { test("plain text block becomes a text part", () => { expect(contentBlockToParts({ type: "text", text: "hello" })).toEqual([{ type: "text", text: "hello" }]) }) @@ -158,7 +158,7 @@ describe("acp-next content conversion", () => { }) }) -describe("acp-next replay conversion", () => { +describe("acp replay conversion", () => { test("replays text audience annotations", () => { expect(partsToContentChunks([{ type: "text", text: "cached", synthetic: true }])).toEqual([ { diff --git a/packages/opencode/test/acp-next/directory.test.ts b/packages/opencode/test/acp/directory.test.ts similarity index 98% rename from packages/opencode/test/acp-next/directory.test.ts rename to packages/opencode/test/acp/directory.test.ts index 050ff06d45..aa5fc12df2 100644 --- a/packages/opencode/test/acp-next/directory.test.ts +++ b/packages/opencode/test/acp/directory.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Directory } from "@/acp-next/directory" +import { Directory } from "@/acp/directory" import { Command } from "@/command" import { ModelID, ProviderID } from "@/provider/schema" import { Provider } from "@/provider/provider" @@ -97,7 +97,7 @@ const fakeLayer = (calls: string[]) => ), ) -describe("ACP next directory snapshot", () => { +describe("ACP directory snapshot", () => { it.effect("two concurrent callers share one load", () => { const calls: string[] = [] return Effect.gen(function* () { diff --git a/packages/opencode/test/acp-next/error.test.ts b/packages/opencode/test/acp/error.test.ts similarity index 51% rename from packages/opencode/test/acp-next/error.test.ts rename to packages/opencode/test/acp/error.test.ts index a82c6c576e..649f7d806f 100644 --- a/packages/opencode/test/acp-next/error.test.ts +++ b/packages/opencode/test/acp/error.test.ts @@ -1,35 +1,33 @@ import { describe, expect, test } from "bun:test" import { RequestError } from "@agentclientprotocol/sdk" -import * as ACPNextError from "../../src/acp-next/error" +import * as ACPError from "../../src/acp/error" -describe("acp-next.error", () => { +describe("acp.error", () => { test("maps validation failures to invalid params", () => { - const cases: ACPNextError.Error[] = [ - new ACPNextError.SessionNotFoundError({ sessionId: "ses_missing" }), - new ACPNextError.InvalidConfigOptionError({ configId: "temperature" }), - new ACPNextError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }), - new ACPNextError.InvalidEffortError({ effort: "extreme" }), - new ACPNextError.InvalidModeError({ mode: "turbo" }), + const cases: ACPError.Error[] = [ + new ACPError.SessionNotFoundError({ sessionId: "ses_missing" }), + new ACPError.InvalidConfigOptionError({ configId: "temperature" }), + new ACPError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }), + new ACPError.InvalidEffortError({ effort: "extreme" }), + new ACPError.InvalidModeError({ mode: "turbo" }), ] - expect(cases.map((error) => ACPNextError.toRequestError(error).code)).toEqual([ - -32602, -32602, -32602, -32602, -32602, - ]) + expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([-32602, -32602, -32602, -32602, -32602]) }) test("includes safe validation details", () => { - expect(ACPNextError.toRequestError(new ACPNextError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({ + expect(ACPError.toRequestError(new ACPError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({ code: -32602, data: { sessionId: "ses_123" }, }) - expect(ACPNextError.toRequestError(new ACPNextError.InvalidModelError({ modelId: "gpt-missing" }))).toMatchObject({ + expect(ACPError.toRequestError(new ACPError.InvalidModelError({ modelId: "gpt-missing" }))).toMatchObject({ code: -32602, data: { modelId: "gpt-missing" }, }) }) test("maps auth required to the SDK auth error", () => { - const requestError = ACPNextError.toRequestError(new ACPNextError.AuthRequiredError({ providerId: "anthropic" })) + const requestError = ACPError.toRequestError(new ACPError.AuthRequiredError({ providerId: "anthropic" })) expect(requestError).toBeInstanceOf(RequestError) expect(requestError.code).toBe(-32000) @@ -38,17 +36,15 @@ describe("acp-next.error", () => { }) test("maps unsupported operations to method not found", () => { - const requestError = ACPNextError.toRequestError( - new ACPNextError.UnsupportedOperationError({ method: "session/new" }), - ) + const requestError = ACPError.toRequestError(new ACPError.UnsupportedOperationError({ method: "session/new" })) expect(requestError.code).toBe(-32601) expect(requestError.data).toEqual({ method: "session/new" }) }) test("maps service failures to safe internal errors", () => { - const requestError = ACPNextError.toRequestError( - new ACPNextError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }), + const requestError = ACPError.toRequestError( + new ACPError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }), ) expect(requestError.code).toBe(-32603) @@ -57,8 +53,8 @@ describe("acp-next.error", () => { }) test("wraps unknown defects without leaking raw details", () => { - const requestError = ACPNextError.toRequestError( - ACPNextError.fromUnknownDefect(new Error("stack has sk-ant-secret and oauth refresh token")), + const requestError = ACPError.toRequestError( + ACPError.fromUnknownDefect(new Error("stack has sk-ant-secret and oauth refresh token")), ) const serialized = JSON.stringify(requestError.toErrorResponse()) diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts deleted file mode 100644 index a01680e305..0000000000 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ /dev/null @@ -1,977 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { ACP } from "../../src/acp/agent" -import type { AgentSideConnection } from "@agentclientprotocol/sdk" -import type { - Event, - EventMessagePartUpdated, - ToolStateCompleted, - ToolStatePending, - ToolStateRunning, -} from "@opencode-ai/sdk/v2" -import { provideTestInstance, tmpdir } from "../fixture/fixture" - -const pollUntil = async ( - check: () => T | undefined | false | Promise, - message: string, - opts?: { timeoutMs?: number; intervalMs?: number }, -): Promise => { - const timeoutMs = opts?.timeoutMs ?? 2000 - const intervalMs = opts?.intervalMs ?? 5 - const started = Date.now() - while (true) { - const v = await check() - if (v !== undefined && v !== null && v !== false) return v as T - if (Date.now() - started > timeoutMs) throw new Error(message) - await new Promise((r) => setTimeout(r, intervalMs)) - } -} - -type SessionUpdateParams = Parameters[0] -type RequestPermissionParams = Parameters[0] -type RequestPermissionResult = Awaited> - -type GlobalEventEnvelope = { - directory?: string - payload?: Event -} - -type EventController = { - push: (event: GlobalEventEnvelope) => void - close: () => void -} - -function inProgressText(update: SessionUpdateParams["update"]) { - if (update.sessionUpdate !== "tool_call_update") return undefined - if (update.status !== "in_progress") return undefined - if (!update.content || !Array.isArray(update.content)) return undefined - const first = update.content[0] - if (!first || first.type !== "content") return undefined - if (first.content.type !== "text") return undefined - return first.content.text -} - -function isToolCallUpdate( - update: SessionUpdateParams["update"], -): update is Extract { - return update.sessionUpdate === "tool_call_update" -} - -function completedToolUpdate(sessionUpdates: SessionUpdateParams[], sessionId: string, callID: string) { - return sessionUpdates - .filter((u) => u.sessionId === sessionId) - .map((u) => u.update) - .filter(isToolCallUpdate) - .find((u) => u.toolCallId === callID && u.status === "completed") -} - -function toolEvent( - sessionId: string, - cwd: string, - opts: { - callID: string - tool: string - input: Record - } & ({ status: "running"; metadata?: Record } | { status: "pending"; raw: string }), -): GlobalEventEnvelope { - const state: ToolStatePending | ToolStateRunning = - opts.status === "running" - ? { - status: "running", - input: opts.input, - ...(opts.metadata && { metadata: opts.metadata }), - time: { start: Date.now() }, - } - : { - status: "pending", - input: opts.input, - raw: opts.raw, - } - const payload: EventMessagePartUpdated = { - id: `evt_${opts.callID}`, - type: "message.part.updated", - properties: { - sessionID: sessionId, - time: Date.now(), - part: { - id: `part_${opts.callID}`, - sessionID: sessionId, - messageID: `msg_${opts.callID}`, - type: "tool", - callID: opts.callID, - tool: opts.tool, - state, - }, - }, - } - return { directory: cwd, payload } -} - -function completedToolEvent( - sessionId: string, - cwd: string, - opts: { - callID: string - tool: string - input: Record - output: string - attachments?: ToolStateCompleted["attachments"] - }, -): GlobalEventEnvelope { - const state: ToolStateCompleted = { - status: "completed", - input: opts.input, - output: opts.output, - title: opts.tool, - metadata: {}, - time: { start: Date.now() - 1, end: Date.now() }, - ...(opts.attachments && { attachments: opts.attachments }), - } - const payload: EventMessagePartUpdated = { - id: `evt_${opts.callID}`, - type: "message.part.updated", - properties: { - sessionID: sessionId, - time: Date.now(), - part: { - id: `part_${opts.callID}`, - sessionID: sessionId, - messageID: `msg_${opts.callID}`, - type: "tool", - callID: opts.callID, - tool: opts.tool, - state, - }, - }, - } - return { directory: cwd, payload } -} - -function createEventStream() { - const queue: GlobalEventEnvelope[] = [] - const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = [] - const state = { closed: false } - - const push = (event: GlobalEventEnvelope) => { - const waiter = waiters.shift() - if (waiter) { - waiter(event) - return - } - queue.push(event) - } - - const close = () => { - state.closed = true - for (const waiter of waiters.splice(0)) { - waiter(undefined) - } - } - - const stream = async function* (signal?: AbortSignal) { - while (true) { - if (signal?.aborted) return - const next = queue.shift() - if (next) { - yield next - continue - } - if (state.closed) return - const value = await new Promise((resolve) => { - waiters.push(resolve) - if (!signal) return - signal.addEventListener("abort", () => resolve(undefined), { once: true }) - }) - if (!value) return - yield value - } - } - - return { controller: { push, close } satisfies EventController, stream } -} - -function createFakeAgent() { - const updates = new Map() - const chunks = new Map() - const sessionUpdates: SessionUpdateParams[] = [] - const record = (sessionId: string, type: string) => { - const list = updates.get(sessionId) ?? [] - list.push(type) - updates.set(sessionId, list) - } - - const connection = { - async sessionUpdate(params: SessionUpdateParams) { - sessionUpdates.push(params) - const update = params.update - const type = update?.sessionUpdate ?? "unknown" - record(params.sessionId, type) - if (update?.sessionUpdate === "agent_message_chunk") { - const content = update.content - if (content?.type !== "text") return - if (typeof content.text !== "string") return - chunks.set(params.sessionId, (chunks.get(params.sessionId) ?? "") + content.text) - } - }, - async requestPermission(_params: RequestPermissionParams): Promise { - return { outcome: { outcome: "selected", optionId: "once" } } as RequestPermissionResult - }, - } as unknown as AgentSideConnection - - const { controller, stream } = createEventStream() - const calls = { - eventSubscribe: 0, - sessionCreate: 0, - } - - const sdk = { - global: { - event: async (opts?: { signal?: AbortSignal }) => { - calls.eventSubscribe++ - return { stream: stream(opts?.signal) } - }, - }, - session: { - create: async (_params?: any) => { - calls.sessionCreate++ - return { - data: { - id: `ses_${calls.sessionCreate}`, - time: { created: new Date().toISOString() }, - }, - } - }, - get: async (_params?: any) => { - return { - data: { - id: "ses_1", - time: { created: new Date().toISOString() }, - }, - } - }, - messages: async () => { - return { data: [] } - }, - message: async (params?: any) => { - // Return a message with parts that can be looked up by partID - return { - data: { - info: { - role: "assistant", - }, - parts: [ - { - id: params?.messageID ? `${params.messageID}_part` : "part_1", - type: "text", - text: "", - }, - ], - }, - } - }, - }, - permission: { - respond: async () => { - return { data: true } - }, - }, - config: { - providers: async () => { - return { - data: { - providers: [ - { - id: "opencode", - name: "opencode", - models: { - "big-pickle": { id: "big-pickle", name: "big-pickle" }, - }, - }, - ], - }, - } - }, - }, - app: { - agents: async () => { - return { - data: [ - { - name: "build", - description: "build", - mode: "agent", - }, - ], - } - }, - }, - command: { - list: async () => { - return { data: [] } - }, - }, - mcp: { - add: async () => { - return { data: true } - }, - }, - } as any - - const agent = new ACP.Agent(connection, { - sdk, - defaultModel: { providerID: "opencode", modelID: "big-pickle" }, - } as any) - - const stop = () => { - controller.close() - ;(agent as any).eventAbort.abort() - } - - return { agent, controller, calls, updates, chunks, sessionUpdates, stop, sdk, connection } -} - -describe("acp.agent event subscription", () => { - test("routes message.part.delta by the event sessionID (no cross-session pollution)", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, controller, updates, stop } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - - const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - - controller.push({ - directory: cwd, - payload: { - type: "message.part.delta", - properties: { - sessionID: sessionB, - messageID: "msg_1", - partID: "msg_1_part", - field: "text", - delta: "hello", - }, - }, - } as any) - - await pollUntil( - () => (updates.get(sessionB) ?? []).includes("agent_message_chunk"), - "sessionB never received agent_message_chunk", - ) - - expect((updates.get(sessionA) ?? []).includes("agent_message_chunk")).toBe(false) - expect((updates.get(sessionB) ?? []).includes("agent_message_chunk")).toBe(true) - - stop() - }, - }) - }) - - test("does not emit user_message_chunk for live prompt parts", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, controller, sessionUpdates, stop } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - - controller.push({ - directory: cwd, - payload: { - type: "message.part.updated", - properties: { - sessionID: sessionId, - time: Date.now(), - part: { - id: "part_1", - sessionID: sessionId, - messageID: "msg_user", - type: "text", - text: "hello", - }, - }, - }, - } as any) - - controller.push({ - directory: cwd, - payload: { - type: "message.part.delta", - properties: { - sessionID: sessionId, - messageID: "msg_marker", - partID: "msg_marker_part", - field: "text", - delta: "marker", - }, - }, - } as any) - - await pollUntil( - () => - sessionUpdates.some((u) => u.sessionId === sessionId && u.update.sessionUpdate === "agent_message_chunk"), - "marker event was never processed", - ) - - expect( - sessionUpdates - .filter((u) => u.sessionId === sessionId) - .some((u) => u.update.sessionUpdate === "user_message_chunk"), - ).toBe(false) - - stop() - }, - }) - }) - - test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, controller, chunks, stop } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - - const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - - const tokenA = ["ALPHA_", "111", "_X"] - const tokenB = ["BETA_", "222", "_Y"] - - const push = (sessionId: string, messageID: string, delta: string) => { - controller.push({ - directory: cwd, - payload: { - type: "message.part.delta", - properties: { - sessionID: sessionId, - messageID, - partID: `${messageID}_part`, - field: "text", - delta, - }, - }, - } as any) - } - - push(sessionA, "msg_a", tokenA[0]) - push(sessionB, "msg_b", tokenB[0]) - push(sessionA, "msg_a", tokenA[1]) - push(sessionB, "msg_b", tokenB[1]) - push(sessionA, "msg_a", tokenA[2]) - push(sessionB, "msg_b", tokenB[2]) - - await pollUntil( - () => - (chunks.get(sessionA) ?? "").includes(tokenA.join("")) && - (chunks.get(sessionB) ?? "").includes(tokenB.join("")), - "interleaved chunks never fully arrived", - ) - - const a = chunks.get(sessionA) ?? "" - const b = chunks.get(sessionB) ?? "" - - expect(a).toContain(tokenA.join("")) - expect(b).toContain(tokenB.join("")) - for (const part of tokenB) expect(a).not.toContain(part) - for (const part of tokenA) expect(b).not.toContain(part) - - stop() - }, - }) - }) - - test("does not create additional event subscriptions on repeated loadSession()", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, calls, stop } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - - const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - - await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) - await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) - await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) - await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) - - expect(calls.eventSubscribe).toBe(1) - - stop() - }, - }) - }) - - test("permission.asked events are handled and replied", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const permissionReplies: string[] = [] - const { agent, controller, stop, sdk } = createFakeAgent() - sdk.permission.reply = async (params: any) => { - permissionReplies.push(params.requestID) - return { data: true } - } - const cwd = "/tmp/opencode-acp-test" - - const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - - controller.push({ - directory: cwd, - payload: { - type: "permission.asked", - properties: { - id: "perm_1", - sessionID: sessionA, - permission: "bash", - patterns: ["*"], - metadata: {}, - always: [], - }, - }, - } as any) - - await pollUntil(() => permissionReplies.includes("perm_1"), "perm_1 was never replied") - - expect(permissionReplies).toContain("perm_1") - - stop() - }, - }) - }) - - test("permission prompt on session A does not block message updates for session B", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const permissionReplies: string[] = [] - let resolvePermissionA: (() => void) | undefined - const permissionABlocking = new Promise((r) => { - resolvePermissionA = r - }) - - const { agent, controller, chunks, stop, sdk, connection } = createFakeAgent() - - // Make permission request for session A block until we release it - const originalRequestPermission = connection.requestPermission.bind(connection) - let _permissionCalls = 0 - connection.requestPermission = async (params: RequestPermissionParams) => { - _permissionCalls++ - if (params.sessionId.endsWith("1")) { - await permissionABlocking - } - return originalRequestPermission(params) - } - - sdk.permission.reply = async (params: any) => { - permissionReplies.push(params.requestID) - return { data: true } - } - - const cwd = "/tmp/opencode-acp-test" - - const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - - // Push permission.asked for session A (will block) - controller.push({ - directory: cwd, - payload: { - type: "permission.asked", - properties: { - id: "perm_a", - sessionID: sessionA, - permission: "bash", - patterns: ["*"], - metadata: {}, - always: [], - }, - }, - } as any) - - await pollUntil(() => _permissionCalls > 0, "permission handling for A never started") - - controller.push({ - directory: cwd, - payload: { - type: "message.part.delta", - properties: { - sessionID: sessionB, - messageID: "msg_b", - partID: "msg_b_part", - field: "text", - delta: "session_b_message", - }, - }, - } as any) - - await pollUntil( - () => (chunks.get(sessionB) ?? "").includes("session_b_message"), - "session B never received its message", - ) - - expect(chunks.get(sessionB) ?? "").toContain("session_b_message") - expect(permissionReplies).not.toContain("perm_a") - - resolvePermissionA!() - await pollUntil(() => permissionReplies.includes("perm_a"), "perm_a was never replied after release") - - expect(permissionReplies).toContain("perm_a") - - stop() - }, - }) - }) - - test("streams running bash output snapshots and de-dupes identical snapshots", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, controller, sessionUpdates, stop } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - const input = { command: "echo hello", description: "run command" } - - for (const output of ["a", "a", "ab"]) { - controller.push( - toolEvent(sessionId, cwd, { - callID: "call_1", - tool: "bash", - status: "running", - input, - metadata: { output }, - }), - ) - } - await pollUntil( - () => - sessionUpdates - .filter((u) => u.sessionId === sessionId) - .filter((u) => isToolCallUpdate(u.update)) - .map((u) => inProgressText(u.update)) - .filter((t) => t === "ab").length > 0, - "final bash snapshot 'ab' never arrived", - ) - - const snapshots = sessionUpdates - .filter((u) => u.sessionId === sessionId) - .filter((u) => isToolCallUpdate(u.update)) - .map((u) => inProgressText(u.update)) - - expect(snapshots).toEqual(["a", undefined, "ab"]) - stop() - }, - }) - }) - - test("emits synthetic pending before first running update for any tool", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, controller, sessionUpdates, stop } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - - controller.push( - toolEvent(sessionId, cwd, { - callID: "call_bash", - tool: "bash", - status: "running", - input: { command: "echo hi", description: "run command" }, - metadata: { output: "hi\n" }, - }), - ) - controller.push( - toolEvent(sessionId, cwd, { - callID: "call_read", - tool: "read", - status: "running", - input: { filePath: "/tmp/example.txt" }, - }), - ) - await pollUntil( - () => - sessionUpdates - .filter((u) => u.sessionId === sessionId) - .map((u) => u.update.sessionUpdate) - .filter((u) => u === "tool_call" || u === "tool_call_update").length >= 4, - "expected 4 tool_call/tool_call_update events", - ) - - const types = sessionUpdates - .filter((u) => u.sessionId === sessionId) - .map((u) => u.update.sessionUpdate) - .filter((u) => u === "tool_call" || u === "tool_call_update") - expect(types).toEqual(["tool_call", "tool_call_update", "tool_call", "tool_call_update"]) - - const pendings = sessionUpdates.filter( - (u) => u.sessionId === sessionId && u.update.sessionUpdate === "tool_call", - ) - expect(pendings.every((p) => p.update.sessionUpdate === "tool_call" && p.update.status === "pending")).toBe( - true, - ) - stop() - }, - }) - }) - - test("emits image attachments as ACP tool content blocks on live completed tool updates", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, controller, sessionUpdates, stop } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - const data = Buffer.from("image-data").toString("base64") - - controller.push( - completedToolEvent(sessionId, cwd, { - callID: "call_image", - tool: "read", - input: { filePath: "/tmp/image.png" }, - output: "Image read successfully", - attachments: [ - { - id: "part_image", - sessionID: sessionId, - messageID: "msg_image", - type: "file", - mime: "image/png", - filename: "image.png", - url: `data:image/png;base64,${data}`, - }, - { - id: "part_text", - sessionID: sessionId, - messageID: "msg_image", - type: "file", - mime: "text/plain", - filename: "note.txt", - url: "data:text/plain;base64,Zm9v", - }, - ], - }), - ) - await pollUntil( - () => completedToolUpdate(sessionUpdates, sessionId, "call_image"), - "completed tool update for call_image never arrived", - ) - - const update = completedToolUpdate(sessionUpdates, sessionId, "call_image") - expect(update?.content).toContainEqual({ - type: "content", - content: { type: "text", text: "Image read successfully" }, - }) - expect(update?.content).toContainEqual({ - type: "content", - content: { type: "image", mimeType: "image/png", data }, - }) - expect(update?.content?.some((item) => item.type === "content" && item.content.type === "resource")).toBe(false) - expect((update?.rawOutput as { attachments?: unknown[] } | undefined)?.attachments?.length).toBe(2) - - stop() - }, - }) - }) - - test("replays completed tool image attachments as ACP tool content blocks", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, sessionUpdates, stop, sdk } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - const data = Buffer.from("replay-image").toString("base64") - - sdk.session.messages = async () => ({ - data: [ - { - info: { - role: "assistant", - sessionID: sessionId, - }, - parts: [ - { - id: "part_replay", - sessionID: sessionId, - messageID: "msg_replay", - type: "tool", - callID: "call_replay_image", - tool: "webfetch", - state: { - status: "completed", - input: { url: "https://example.com/image.png" }, - output: "Image fetched successfully", - title: "webfetch", - metadata: {}, - time: { start: Date.now() - 1, end: Date.now() }, - attachments: [ - { - id: "part_replay_image", - sessionID: sessionId, - messageID: "msg_replay", - type: "file", - mime: "image/jpeg", - filename: "image.jpg", - url: `data:image/jpeg;base64,${data}`, - }, - ], - }, - }, - ], - }, - ], - }) - - await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) - - const update = completedToolUpdate(sessionUpdates, sessionId, "call_replay_image") - expect(update?.content).toContainEqual({ - type: "content", - content: { type: "text", text: "Image fetched successfully" }, - }) - expect(update?.content).toContainEqual({ - type: "content", - content: { type: "image", mimeType: "image/jpeg", data }, - }) - - stop() - }, - }) - }) - - test("does not emit duplicate synthetic pending after replayed running tool", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, controller, sessionUpdates, stop, sdk } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - const input = { command: "echo hi", description: "run command" } - - sdk.session.messages = async () => ({ - data: [ - { - info: { - role: "assistant", - sessionID: sessionId, - }, - parts: [ - { - type: "tool", - callID: "call_1", - tool: "bash", - state: { - status: "running", - input, - metadata: { output: "hi\n" }, - time: { start: Date.now() }, - }, - }, - ], - }, - ], - }) - - await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) - controller.push( - toolEvent(sessionId, cwd, { - callID: "call_1", - tool: "bash", - status: "running", - input, - metadata: { output: "hi\nthere\n" }, - }), - ) - await pollUntil( - () => - sessionUpdates - .filter((u) => u.sessionId === sessionId) - .map((u) => u.update) - .filter((u) => "toolCallId" in u && u.toolCallId === "call_1") - .map((u) => u.sessionUpdate) - .filter((u) => u === "tool_call" || u === "tool_call_update").length >= 3, - "expected 3 tool events for call_1", - ) - - const types = sessionUpdates - .filter((u) => u.sessionId === sessionId) - .map((u) => u.update) - .filter((u) => "toolCallId" in u && u.toolCallId === "call_1") - .map((u) => u.sessionUpdate) - .filter((u) => u === "tool_call" || u === "tool_call_update") - - expect(types).toEqual(["tool_call", "tool_call_update", "tool_call_update"]) - stop() - }, - }) - }) - - test("clears bash snapshot marker on pending state", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, controller, sessionUpdates, stop } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - const input = { command: "echo hello", description: "run command" } - - controller.push( - toolEvent(sessionId, cwd, { - callID: "call_1", - tool: "bash", - status: "running", - input, - metadata: { output: "a" }, - }), - ) - controller.push( - toolEvent(sessionId, cwd, { - callID: "call_1", - tool: "bash", - status: "pending", - input, - raw: '{"command":"echo hello"}', - }), - ) - controller.push( - toolEvent(sessionId, cwd, { - callID: "call_1", - tool: "bash", - status: "running", - input, - metadata: { output: "a" }, - }), - ) - await pollUntil( - () => - sessionUpdates - .filter((u) => u.sessionId === sessionId) - .filter((u) => isToolCallUpdate(u.update)) - .map((u) => inProgressText(u.update)) - .filter((t) => t === "a").length >= 2, - "expected two 'a' bash snapshots after pending reset", - ) - - const snapshots = sessionUpdates - .filter((u) => u.sessionId === sessionId) - .filter((u) => isToolCallUpdate(u.update)) - .map((u) => inProgressText(u.update)) - - expect(snapshots).toEqual(["a", "a"]) - stop() - }, - }) - }) -}) diff --git a/packages/opencode/test/acp-next/event.test.ts b/packages/opencode/test/acp/event.test.ts similarity index 96% rename from packages/opencode/test/acp-next/event.test.ts rename to packages/opencode/test/acp/event.test.ts index 92b65b8881..c79baf035e 100644 --- a/packages/opencode/test/acp-next/event.test.ts +++ b/packages/opencode/test/acp/event.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "bun:test" import type { AgentSideConnection } from "@agentclientprotocol/sdk" import type { Event, Message, OpencodeClient, Part, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import { Effect, ManagedRuntime } from "effect" -import { ACPNextEvent } from "@/acp-next/event" -import * as ACPNextService from "@/acp-next/service" -import { Directory } from "@/acp-next/directory" -import { ACPNextSession } from "@/acp-next/session" +import { ACPEvent } from "@/acp/event" +import * as ACPService from "@/acp/service" +import { Directory } from "@/acp/directory" +import { ACPSession } from "@/acp/session" type SessionUpdateParams = Parameters[0] type ToolSessionUpdateParams = SessionUpdateParams & { @@ -30,8 +30,8 @@ const pollUntil = async ( } function makeSessionService() { - return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync( - ACPNextSession.Service.use((service) => Effect.succeed(service)), + return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + ACPSession.Service.use((service) => Effect.succeed(service)), ) } @@ -107,7 +107,7 @@ function createHarness(messages: Record = {}) { }, } satisfies Pick const session = makeSessionService() - const subscription = new ACPNextEvent.Subscription({ sdk, connection, session }) + const subscription = new ACPEvent.Subscription({ sdk, connection, session }) return { calls, connection, events, sdk, session, subscription, updates } } @@ -296,7 +296,7 @@ function toolUpdates(updates: SessionUpdateParams[]) { } async function createKnownSession( - session: ACPNextSession.Interface, + session: ACPSession.Interface, sessionId: string, part: { messageId: string; partId: string; partType: Part["type"]; role?: Message["role"] }, ) { @@ -312,7 +312,7 @@ async function createKnownSession( ) } -describe("acp-next event routing", () => { +describe("acp event routing", () => { it("routes message.part.delta by sessionID without cross-session pollution", async () => { const harness = createHarness() await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" }) @@ -348,8 +348,8 @@ describe("acp-next event routing", () => { it("does not create extra subscriptions on repeated loadSession", async () => { const harness = createHarness() - let subscription: ACPNextEvent.Subscription | undefined - const service = ACPNextService.make({ + let subscription: ACPEvent.Subscription | undefined + const service = ACPService.make({ sdk: harness.sdk, connection: harness.connection, directory: { @@ -439,8 +439,8 @@ describe("acp-next event routing", () => { return Promise.resolve() }, } satisfies Pick - let subscription: ACPNextEvent.Subscription | undefined - const service = ACPNextService.make({ + let subscription: ACPEvent.Subscription | undefined + const service = ACPService.make({ sdk: { global: { event: (options?: { signal?: AbortSignal }) => Promise.resolve({ stream: events.stream(options?.signal) }), diff --git a/packages/opencode/test/acp/permission.test.ts b/packages/opencode/test/acp/permission.test.ts new file mode 100644 index 0000000000..fb86026af6 --- /dev/null +++ b/packages/opencode/test/acp/permission.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "bun:test" +import type { + AgentSideConnection, + RequestPermissionRequest, + RequestPermissionResponse, + SessionUpdate, +} from "@agentclientprotocol/sdk" +import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2" +import { Effect, ManagedRuntime } from "effect" +import { ACPEvent } from "@/acp/event" +import { ACPSession } from "@/acp/session" + +type PermissionEvent = Extract +type PermissionReplyParams = Parameters[0] +type SessionUpdateParams = Parameters[0] + +const pollUntil = async ( + check: () => boolean | Promise, + message: string, + opts?: { timeoutMs?: number; intervalMs?: number }, +) => { + const started = Date.now() + while (true) { + if (await check()) return + if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message) + await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5)) + } +} + +function makeSessionService() { + return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + ACPSession.Service.use((service) => Effect.succeed(service)), + ) +} + +function createHarness( + requestPermission: (params: RequestPermissionRequest) => Promise = () => + Promise.resolve({ outcome: { outcome: "selected", optionId: "once" } }), +) { + const replies: PermissionReplyParams[] = [] + const requests: RequestPermissionRequest[] = [] + const updates: SessionUpdateParams[] = [] + const session = makeSessionService() + const sdk = { + permission: { + reply: (params: PermissionReplyParams) => { + replies.push(params) + return Promise.resolve({ data: true }) + }, + }, + session: { + message: () => Promise.resolve({ data: undefined }), + }, + } as unknown as OpencodeClient + const connection = { + requestPermission: (params: RequestPermissionRequest) => { + requests.push(params) + return requestPermission(params) + }, + sessionUpdate: (params: SessionUpdateParams) => { + updates.push(params) + return Promise.resolve() + }, + } satisfies Pick + const subscription = new ACPEvent.Subscription({ sdk, connection, session }) + + return { connection, replies, requests, sdk, session, subscription, updates } +} + +async function createSession(session: ACPSession.Interface, sessionId: string, cwd = "/workspace") { + await Effect.runPromise(session.create({ id: sessionId, cwd })) +} + +async function createKnownTextPart( + session: ACPSession.Interface, + sessionId: string, + messageId: string, + partId: string, +) { + await Effect.runPromise( + session.recordPartMetadata({ + sessionId, + messageId, + partId, + partType: "text", + role: "assistant", + }), + ) +} + +function permissionAsked( + sessionID: string, + id: string, + input: { + permission?: string + metadata?: Record + tool?: { messageID: string; callID: string } + } = {}, +) { + return { + id: `evt_${id}`, + type: "permission.asked", + properties: { + id, + sessionID, + permission: input.permission ?? "bash", + patterns: ["*"], + metadata: input.metadata ?? { command: "printf hello" }, + always: [], + ...(input.tool ? { tool: input.tool } : {}), + }, + } as PermissionEvent +} + +function textDelta(sessionID: string, messageID: string, partID: string, delta: string) { + return { + id: `evt_${sessionID}_${messageID}_${partID}`, + type: "message.part.delta", + properties: { + sessionID, + messageID, + partID, + field: "text", + delta, + }, + } as Event +} + +function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) { + return updates + .filter((item) => item.sessionId === sessionId) + .map((item) => item.update) + .filter((update): update is Extract => { + return update.sessionUpdate === "agent_message_chunk" + }) + .map((update) => (update.content.type === "text" ? update.content.text : "")) + .join("") +} + +describe("acp permissions", () => { + it("sends requestPermission and replies with the selected outcome", async () => { + const harness = createHarness() + await createSession(harness.session, "ses_a") + + harness.subscription.handle(permissionAsked("ses_a", "perm_1", { tool: { messageID: "msg_1", callID: "call_1" } })) + + await pollUntil(() => harness.replies.length === 1, "permission was never replied") + + expect(harness.requests[0]).toMatchObject({ + sessionId: "ses_a", + toolCall: { + toolCallId: "call_1", + status: "pending", + title: "bash", + rawInput: { command: "printf hello" }, + kind: "execute", + locations: [], + }, + options: [ + { optionId: "once", kind: "allow_once", name: "Allow once" }, + { optionId: "always", kind: "allow_always", name: "Always allow" }, + { optionId: "reject", kind: "reject_once", name: "Reject" }, + ], + }) + expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }]) + }) + + it("rejects non-selected outcomes", async () => { + const harness = createHarness(() => Promise.resolve({ outcome: { outcome: "cancelled" } })) + await createSession(harness.session, "ses_a") + + harness.subscription.handle(permissionAsked("ses_a", "perm_cancelled")) + + await pollUntil(() => harness.replies.length === 1, "cancelled permission was never replied") + + expect(harness.replies[0]).toMatchObject({ requestID: "perm_cancelled", reply: "reject" }) + }) + + it("rejects when requestPermission fails", async () => { + const harness = createHarness(() => Promise.reject(new Error("client permission UI failed"))) + await createSession(harness.session, "ses_a") + + harness.subscription.handle(permissionAsked("ses_a", "perm_failed")) + + await pollUntil(() => harness.replies.length === 1, "failed permission was never rejected") + + expect(harness.replies[0]).toMatchObject({ requestID: "perm_failed", reply: "reject" }) + }) + + it("does not let a blocked session A permission block session B message updates", async () => { + let releasePermission: (() => void) | undefined + const blocked = new Promise((resolve) => { + releasePermission = () => resolve({ outcome: { outcome: "selected", optionId: "once" } }) + }) + const harness = createHarness(() => blocked) + await createSession(harness.session, "ses_a") + await createSession(harness.session, "ses_b") + await createKnownTextPart(harness.session, "ses_b", "msg_b", "part_b") + + harness.subscription.handle(permissionAsked("ses_a", "perm_blocked")) + await pollUntil(() => harness.requests.length === 1, "blocked permission was never requested") + + await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "session_b_message")) + + expect(textFromUpdates(harness.updates, "ses_b")).toBe("session_b_message") + expect(harness.replies).toHaveLength(0) + + releasePermission?.() + await pollUntil(() => harness.replies.length === 1, "blocked permission was never replied after release") + }) + + it("serializes permission requests per session", async () => { + let releaseFirst: (() => void) | undefined + const first = new Promise((resolve) => { + releaseFirst = () => resolve({ outcome: { outcome: "selected", optionId: "once" } }) + }) + const harness = createHarness(() => + harness.requests.length === 1 ? first : Promise.resolve({ outcome: { outcome: "selected", optionId: "always" } }), + ) + await createSession(harness.session, "ses_a") + + harness.subscription.handle(permissionAsked("ses_a", "perm_1")) + harness.subscription.handle(permissionAsked("ses_a", "perm_2")) + + await pollUntil(() => harness.requests.length === 1, "first permission was never requested") + expect(harness.requests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"]) + + releaseFirst?.() + await pollUntil(() => harness.requests.length === 2, "second permission was not requested after first resolved") + await pollUntil(() => harness.replies.length === 2, "serialized permissions were not both replied") + + expect(harness.replies.map((reply) => [reply.requestID, reply.reply])).toEqual([ + ["perm_1", "once"], + ["perm_2", "always"], + ]) + }) +}) diff --git a/packages/opencode/test/acp-next/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts similarity index 58% rename from packages/opencode/test/acp-next/service-session.test.ts rename to packages/opencode/test/acp/service-session.test.ts index 96d4066f7e..7a09fd1cc9 100644 --- a/packages/opencode/test/acp-next/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -4,6 +4,7 @@ import type { ForkSessionResponse, LoadSessionResponse, NewSessionResponse, + SessionNotification, ResumeSessionResponse, SessionConfigOption, SessionConfigSelectOption, @@ -11,9 +12,10 @@ import type { } from "@agentclientprotocol/sdk" import type { OpencodeClient } from "@opencode-ai/sdk/v2" import { Effect, ManagedRuntime } from "effect" -import * as ACPNextService from "@/acp-next/service" -import * as ACPNextError from "@/acp-next/error" -import { ACPNextSession } from "@/acp-next/session" +import * as ACPService from "@/acp/service" +import * as ACPError from "@/acp/error" +import { ACPSession } from "@/acp/session" +import { UsageService } from "@/acp/usage" import { ModelID, ProviderID } from "@/provider/schema" import type { Provider } from "@/provider/provider" @@ -139,12 +141,16 @@ const provider: Provider.Info = { }, } -describe("ACP next service sessions", () => { +describe("ACP service sessions", () => { const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => { - const updates: unknown[] = [] + const updates: SessionNotification[] = [] const mcpAdds: string[] = [] const aborts: string[] = [] const forks: string[] = [] + const prompts: unknown[] = [] + const commands: unknown[] = [] + const summarizes: unknown[] = [] + const usageUpdates: string[] = [] const sessions = Array.from({ length: 102 }, (_, index) => ({ id: `ses_${index + 1}`, directory: index % 2 === 0 ? "/workspace" : "/other", @@ -184,6 +190,36 @@ describe("ACP next 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 }, + }), + }, + }) + }, + command: (input: unknown) => { + commands.push(input) + return Promise.resolve({ + data: { + info: assistantInfo({ + input: 3, + output: 4, + reasoning: 0, + cache: { read: 0, write: 0 }, + }), + }, + }) + }, + summarize: (input: unknown) => { + summarizes.push(input) + return Promise.resolve({ data: true }) + }, abort: (input: { sessionID: string }) => { aborts.push(input.sessionID) return Promise.resolve({ data: true }) @@ -201,13 +237,33 @@ describe("ACP next service sessions", () => { }, } as unknown as OpencodeClient const connection = { - sessionUpdate: (update: unknown) => { + sessionUpdate: (update: SessionNotification) => { updates.push(update) return Promise.resolve() }, } as Pick + const usage = UsageService.Service.of({ + buildUsage: UsageService.buildUsage, + latestAssistantMessage: UsageService.latestAssistantMessage, + totalSessionCost: UsageService.totalSessionCost, + contextLimit: () => Effect.succeed(128000), + sendUpdate: (input) => + Effect.sync(() => { + usageUpdates.push(input.sessionID) + }), + }) - return { service: ACPNextService.make({ sdk, connection }), updates, mcpAdds, aborts, forks } + return { + service: ACPService.make({ sdk, connection, usage }), + updates, + mcpAdds, + aborts, + forks, + prompts, + commands, + summarizes, + usageUpdates, + } } it("creates a backed session with config options and command update", async () => { @@ -267,6 +323,15 @@ describe("ACP next service sessions", () => { expect(second.sessions).toEqual(first.sessions) }) + it("includes live ACP sessions before they appear in server-backed session list", async () => { + const { service } = makeService() + const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const listed = await Effect.runPromise(service.listSessions({ cwd: "/workspace" })) + + expect(listed.sessions[0]?.sessionId).toBe(created.sessionId) + expect(listed.sessions[0]?.cwd).toBe("/workspace") + }) + it("lists all sessions with next cursor when the first page is full", async () => { const { service } = makeService() const first = await Effect.runPromise(service.listSessions({})) @@ -309,7 +374,7 @@ describe("ACP next service sessions", () => { const missing = await Effect.runPromise( service .setSessionConfigOption({ sessionId: created.sessionId, configId: "effort", value: "high" }) - .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip), + .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip), ) expect(missing.code).toBe(-32602) expect(aborts).toEqual([created.sessionId]) @@ -317,8 +382,8 @@ describe("ACP next service sessions", () => { }) it("does not fail close when backing abort fails", async () => { - const sessionService = ManagedRuntime.make(ACPNextSession.defaultLayer).runSync( - ACPNextSession.Service.use((service) => Effect.succeed(service)), + const sessionService = ManagedRuntime.make(ACPSession.defaultLayer).runSync( + ACPSession.Service.use((service) => Effect.succeed(service)), ) const { service } = makeService() const sdk = { @@ -340,7 +405,7 @@ describe("ACP next service sessions", () => { add: () => Promise.resolve({ data: {} }), }, } as unknown as OpencodeClient - const closing = ACPNextService.make({ sdk, session: sessionService }) + const closing = ACPService.make({ sdk, session: sessionService }) await Effect.runPromise(sessionService.create({ id: "ses_close", cwd: "/workspace" })) expect(await Effect.runPromise(closing.closeSession({ sessionId: "ses_close" }))).toEqual({}) @@ -402,7 +467,7 @@ describe("ACP next service sessions", () => { }) it("maps provider auth failures to auth-required request errors", async () => { - const service = ACPNextService.make({ + const service = ACPService.make({ sdk: { config: { providers: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }), @@ -420,7 +485,7 @@ describe("ACP next service sessions", () => { const error = await Effect.runPromise( service .newSession({ cwd: "/workspace", mcpServers: [] }) - .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip), + .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip), ) expect(error.code).toBe(-32000) @@ -454,12 +519,12 @@ describe("ACP next service sessions", () => { add: () => Promise.resolve({ data: {} }), }, } as unknown as OpencodeClient - const service = ACPNextService.make({ sdk }) + const service = ACPService.make({ sdk }) const first = await Effect.runPromise( service .newSession({ cwd: "/workspace", mcpServers: [] }) - .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip), + .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip), ) const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) @@ -497,7 +562,7 @@ describe("ACP next service sessions", () => { }, }, } as unknown as OpencodeClient - const service = ACPNextService.make({ sdk }) + const service = ACPService.make({ sdk }) await Effect.runPromise( service.newSession({ @@ -538,7 +603,7 @@ describe("ACP next service sessions", () => { add: () => Promise.resolve({ data: {} }), }, } as unknown as OpencodeClient - const service = ACPNextService.make({ sdk }) + const service = ACPService.make({ sdk }) const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) @@ -546,6 +611,46 @@ describe("ACP next service sessions", () => { expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model") }) + it("does not scan last-used sessions when resolving the new session default", async () => { + const historyCalls: string[] = [] + const sdk = { + config: { + providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }), + get: () => Promise.resolve({ data: {} }), + }, + app: { + agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }), + skills: () => Promise.resolve({ data: [] }), + }, + command: { + list: () => Promise.resolve({ data: [] }), + }, + session: { + create: (input: { model?: { id?: string } }) => Promise.resolve({ data: { id: input.model?.id } }), + list: () => { + historyCalls.push("list") + return Promise.resolve({ data: [{ id: "ses_recent" }] }) + }, + messages: () => { + historyCalls.push("messages") + return Promise.resolve({ + data: [{ info: { role: "user", model: { providerID: "test", modelID: "second-model" } } }], + }) + }, + }, + mcp: { + add: () => Promise.resolve({ data: {} }), + }, + } as unknown as OpencodeClient + const service = ACPService.make({ sdk }) + + const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + expect(result.sessionId).toBe("test-model") + expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/test-model") + expect(historyCalls).toEqual([]) + }) + it("switches model and returns updated model and effort options", async () => { const { service } = makeService() const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) @@ -604,31 +709,42 @@ describe("ACP next service sessions", () => { Effect.runPromise( service .setSessionConfigOption({ sessionId: session.sessionId, ...input }) - .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip), + .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip), ), ), ) expect(results.map((error) => error.code)).toEqual([-32602, -32602, -32602, -32602]) }) - it("does not reload providers or commands when switching effort from a warm snapshot", async () => { - let providersCalls = 0 - let commandCalls = 0 + it("does not refetch providers modes or commands when switching effort from session snapshot", async () => { + const calls = { + providers: 0, + agents: 0, + commands: 0, + skills: 0, + mcpAdds: 0, + } const sdk = { config: { providers: () => { - providersCalls++ + calls.providers++ return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }) }, get: () => Promise.resolve({ data: {} }), }, app: { - agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }), - skills: () => Promise.resolve({ data: [] }), + agents: () => { + calls.agents++ + return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }) + }, + skills: () => { + calls.skills++ + return Promise.resolve({ data: [] }) + }, }, command: { list: () => { - commandCalls++ + calls.commands++ return Promise.resolve({ data: [] }) }, }, @@ -637,14 +753,16 @@ describe("ACP next service sessions", () => { list: () => Promise.resolve({ data: [] }), }, mcp: { - add: () => Promise.resolve({ data: {} }), + add: () => { + calls.mcpAdds++ + return Promise.resolve({ data: {} }) + }, }, } as unknown as OpencodeClient - const service = ACPNextService.make({ sdk }) + const service = ACPService.make({ sdk }) const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) - expect(providersCalls).toBe(1) - expect(commandCalls).toBe(1) + expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 }) await Effect.runPromise( service.setSessionConfigOption({ @@ -654,11 +772,350 @@ describe("ACP next service sessions", () => { }), ) - expect(providersCalls).toBe(1) - expect(commandCalls).toBe(1) + expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 }) + }) + + it("switches model against the warm provider snapshot without refetching", async () => { + const calls = { + providers: 0, + agents: 0, + commands: 0, + skills: 0, + } + const sdk = { + config: { + providers: () => { + calls.providers++ + return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }) + }, + get: () => Promise.resolve({ data: {} }), + }, + app: { + agents: () => { + calls.agents++ + return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }) + }, + skills: () => { + calls.skills++ + return Promise.resolve({ data: [] }) + }, + }, + command: { + list: () => { + calls.commands++ + return Promise.resolve({ data: [] }) + }, + }, + session: { + create: () => Promise.resolve({ data: { id: "ses_model_fast" } }), + list: () => Promise.resolve({ data: [] }), + }, + mcp: { + add: () => Promise.resolve({ data: {} }), + }, + } as unknown as OpencodeClient + const service = ACPService.make({ sdk }) + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const updated = await Effect.runPromise( + service.setSessionConfigOption({ + sessionId: session.sessionId, + configId: "model", + value: "test/second-model", + }), + ) + + expect(select(updated, "model")?.currentValue).toBe("test/second-model") + expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1 }) + }) + + it("reuses the warm directory snapshot for a second new session in the same cwd", async () => { + const calls = { + providers: 0, + config: 0, + agents: 0, + commands: 0, + skills: 0, + sessionList: 0, + messages: 0, + creates: 0, + } + const sdk = { + config: { + providers: () => { + calls.providers++ + return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }) + }, + get: () => { + calls.config++ + return Promise.resolve({ data: {} }) + }, + }, + app: { + agents: () => { + calls.agents++ + return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }) + }, + skills: () => { + calls.skills++ + return Promise.resolve({ data: [] }) + }, + }, + command: { + list: () => { + calls.commands++ + return Promise.resolve({ data: [] }) + }, + }, + session: { + create: () => { + calls.creates++ + return Promise.resolve({ data: { id: `ses_warm_${calls.creates}` } }) + }, + list: () => { + calls.sessionList++ + return Promise.resolve({ data: [] }) + }, + messages: () => { + calls.messages++ + return Promise.resolve({ data: [] }) + }, + }, + mcp: { + add: () => Promise.resolve({ data: {} }), + }, + } as unknown as OpencodeClient + const service = ACPService.make({ sdk }) + + const first = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + expect(first.sessionId).toBe("ses_warm_1") + expect(second.sessionId).toBe("ses_warm_2") + expect(calls).toEqual({ + providers: 1, + config: 1, + agents: 1, + commands: 1, + skills: 1, + sessionList: 0, + messages: 0, + creates: 2, + }) + }) + + it("normal text prompt sends model variant mode and converted parts", async () => { + const { service, prompts, usageUpdates } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + await Effect.runPromise( + service.setSessionConfigOption({ + sessionId: session.sessionId, + configId: "effort", + value: "high", + }), + ) + await Effect.runPromise( + service.setSessionConfigOption({ + sessionId: session.sessionId, + configId: "mode", + value: "plan", + }), + ) + + const result = await Effect.runPromise( + service.prompt({ + sessionId: session.sessionId, + messageId: "00000000-0000-4000-8000-000000000001", + prompt: [{ type: "text", text: "hello" }], + }), + ) + + expect(prompts).toEqual([ + { + sessionID: session.sessionId, + model: { providerID, modelID }, + variant: "high", + parts: [{ type: "text", text: "hello" }], + agent: "plan", + directory: "/workspace", + }, + ]) + expect(result).toEqual({ + stopReason: "end_turn", + usage: { + inputTokens: 100, + outputTokens: 40, + thoughtTokens: 7, + cachedReadTokens: 11, + cachedWriteTokens: 13, + totalTokens: 171, + }, + userMessageId: "00000000-0000-4000-8000-000000000001", + _meta: {}, + }) + expect(usageUpdates).toEqual([session.sessionId]) + }) + + it("prompt maps assistant and user audience annotations", async () => { + const { service, prompts } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + await Effect.runPromise( + service.prompt({ + sessionId: session.sessionId, + prompt: [ + { type: "text", text: "assistant context", annotations: { audience: ["assistant"] } }, + { type: "text", text: "user context", annotations: { audience: ["user"] } }, + ], + }), + ) + + expect(prompts).toContainEqual({ + sessionID: session.sessionId, + model: { providerID, modelID }, + variant: "default", + parts: [ + { type: "text", text: "assistant context", synthetic: true }, + { type: "text", text: "user context", ignored: true }, + ], + agent: "build", + directory: "/workspace", + }) + }) + + it("prompt sends image and resource parts", async () => { + const { service, prompts } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + await Effect.runPromise( + service.prompt({ + sessionId: session.sessionId, + prompt: [ + { type: "image", data: "AAAA", mimeType: "image/png", uri: "file:///tmp/screenshot.png" }, + { + type: "resource", + resource: { + uri: "file:///tmp/report.pdf", + mimeType: "application/pdf", + blob: "JVBERg==", + }, + }, + ], + }), + ) + + expect((prompts[0] as { parts?: unknown }).parts).toEqual([ + { + type: "file", + url: "data:image/png;base64,AAAA", + filename: "screenshot.png", + mime: "image/png", + }, + { + type: "file", + url: "data:application/pdf;base64,JVBERg==", + filename: "report.pdf", + mime: "application/pdf", + }, + ]) + }) + + it("slash command prompt calls session command", async () => { + const { service, prompts, commands } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + const result = await Effect.runPromise( + service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/init now" }] }), + ) + + expect(prompts).toEqual([]) + expect(commands).toEqual([ + { + sessionID: session.sessionId, + command: "init", + arguments: "now", + model: "test/test-model", + variant: "default", + agent: "build", + directory: "/workspace", + }, + ]) + expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 4, totalTokens: 7 }) + }) + + it("compact slash command calls summarize path", async () => { + const { service, prompts, commands, summarizes } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + await Effect.runPromise( + service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/compact" }] }), + ) + + expect(prompts).toEqual([]) + expect(commands).toEqual([]) + expect(summarizes).toEqual([ + { + sessionID: session.sessionId, + directory: "/workspace", + providerID, + modelID, + }, + ]) + }) + + it("maps prompt auth failures to auth-required request errors", async () => { + const { service } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const failing = ACPService.make({ + sdk: { + config: { + providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }), + get: () => Promise.resolve({ data: {} }), + }, + app: { + agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }), + skills: () => Promise.resolve({ data: [] }), + }, + command: { + list: () => Promise.resolve({ data: [] }), + }, + session: { + create: () => Promise.resolve({ data: { id: session.sessionId } }), + list: () => Promise.resolve({ data: [] }), + prompt: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }), + }, + mcp: { + add: () => Promise.resolve({ data: {} }), + }, + } as unknown as OpencodeClient, + usage: UsageService.Service.of({ + buildUsage: UsageService.buildUsage, + latestAssistantMessage: UsageService.latestAssistantMessage, + totalSessionCost: UsageService.totalSessionCost, + contextLimit: () => Effect.succeed(128000), + sendUpdate: () => Effect.void, + }), + }) + await Effect.runPromise(failing.newSession({ cwd: "/workspace", mcpServers: [] })) + const error = await Effect.runPromise( + failing + .prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }) + .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip), + ) + + expect(error.code).toBe(-32000) }) }) +function assistantInfo(tokens: UsageService.AssistantTokenCost["tokens"]): UsageService.AssistantMessage { + return { + role: "assistant", + providerID: "test", + modelID: "test-model", + cost: 0, + tokens, + } +} + function categories(result: NewSessionResponse | LoadSessionResponse) { return result.configOptions?.map((option) => option.category) ?? [] } diff --git a/packages/opencode/test/acp-next/session.test.ts b/packages/opencode/test/acp/session.test.ts similarity index 64% rename from packages/opencode/test/acp-next/session.test.ts rename to packages/opencode/test/acp/session.test.ts index 0c1cb16cc7..c033880375 100644 --- a/packages/opencode/test/acp-next/session.test.ts +++ b/packages/opencode/test/acp/session.test.ts @@ -1,14 +1,14 @@ import { describe, expect } from "bun:test" import type { McpServer } from "@agentclientprotocol/sdk" import { Effect } from "effect" -import * as ACPNextError from "@/acp-next/error" -import * as ACPNextSession from "@/acp-next/session" +import * as ACPError from "@/acp/error" +import * as ACPSession from "@/acp/session" import { ModelID, ProviderID } from "@/provider/schema" import { testEffect } from "../lib/effect" -const sessionTest = testEffect(ACPNextSession.defaultLayer) +const sessionTest = testEffect(ACPSession.defaultLayer) -const model = (providerID: string, modelID: string): ACPNextSession.SelectedModel => ({ +const model = (providerID: string, modelID: string): ACPSession.SelectedModel => ({ providerID: ProviderID.make(providerID), modelID: ModelID.make(modelID), }) @@ -20,11 +20,11 @@ const mcpServer: McpServer = { env: [], } -describe("acp-next session state", () => { +describe("acp session state", () => { sessionTest.effect("creates and retrieves session state", () => Effect.gen(function* () { const createdAt = new Date("2026-05-25T00:00:00.000Z") - const created = yield* ACPNextSession.Service.use((session) => + const created = yield* ACPSession.Service.use((session) => session.create({ id: "ses_1", cwd: "/workspace", @@ -35,7 +35,7 @@ describe("acp-next session state", () => { modeId: "build", }), ) - const loaded = yield* ACPNextSession.Service.use((session) => session.get("ses_1")) + const loaded = yield* ACPSession.Service.use((session) => session.get("ses_1")) expect(created).toMatchObject({ id: "ses_1", @@ -52,17 +52,17 @@ describe("acp-next session state", () => { sessionTest.effect("fails required lookups with typed SessionNotFound", () => Effect.gen(function* () { - const error = yield* ACPNextSession.Service.use((session) => session.get("ses_missing")).pipe(Effect.flip) + const error = yield* ACPSession.Service.use((session) => session.get("ses_missing")).pipe(Effect.flip) - expect(error).toBeInstanceOf(ACPNextError.SessionNotFoundError) + expect(error).toBeInstanceOf(ACPError.SessionNotFoundError) expect(error.sessionId).toBe("ses_missing") }), ) sessionTest.effect("tryGet lets event routing ignore unknown sessions", () => Effect.gen(function* () { - const missing = yield* ACPNextSession.Service.use((session) => session.tryGet("ses_missing")) - const missingPart = yield* ACPNextSession.Service.use((session) => + const missing = yield* ACPSession.Service.use((session) => session.tryGet("ses_missing")) + const missingPart = yield* ACPSession.Service.use((session) => session.tryGetPartMetadata({ sessionId: "ses_missing", messageId: "msg_1", partId: "part_1" }), ) @@ -73,7 +73,7 @@ describe("acp-next session state", () => { sessionTest.effect("updates selected model while preserving session identity and inputs", () => Effect.gen(function* () { - yield* ACPNextSession.Service.use((session) => + yield* ACPSession.Service.use((session) => session.create({ id: "ses_model", cwd: "/workspace", @@ -84,7 +84,7 @@ describe("acp-next session state", () => { }), ) - const updated = yield* ACPNextSession.Service.use((session) => + const updated = yield* ACPSession.Service.use((session) => session.setModel("ses_model", model("openai", "gpt-5")), ) @@ -99,7 +99,7 @@ describe("acp-next session state", () => { sessionTest.effect("updates selected variant and mode independently", () => Effect.gen(function* () { - yield* ACPNextSession.Service.use((session) => + yield* ACPSession.Service.use((session) => session.load({ id: "ses_config", cwd: "/workspace", @@ -109,21 +109,21 @@ describe("acp-next session state", () => { }), ) - yield* ACPNextSession.Service.use((session) => session.setVariant("ses_config", "high")) - expect(yield* ACPNextSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high") - expect(yield* ACPNextSession.Service.use((session) => session.getMode("ses_config"))).toBe("plan") + yield* ACPSession.Service.use((session) => session.setVariant("ses_config", "high")) + expect(yield* ACPSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high") + expect(yield* ACPSession.Service.use((session) => session.getMode("ses_config"))).toBe("plan") - yield* ACPNextSession.Service.use((session) => session.setMode("ses_config", "build")) - expect(yield* ACPNextSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high") - expect(yield* ACPNextSession.Service.use((session) => session.getMode("ses_config"))).toBe("build") + yield* ACPSession.Service.use((session) => session.setMode("ses_config", "build")) + expect(yield* ACPSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high") + expect(yield* ACPSession.Service.use((session) => session.getMode("ses_config"))).toBe("build") }), ) sessionTest.effect("records known message part metadata for delta routing", () => Effect.gen(function* () { - yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_parts", cwd: "/workspace" })) + yield* ACPSession.Service.use((session) => session.create({ id: "ses_parts", cwd: "/workspace" })) - const metadata = yield* ACPNextSession.Service.use((session) => + const metadata = yield* ACPSession.Service.use((session) => session.recordPartMetadata({ sessionId: "ses_parts", messageId: "msg_1", @@ -132,7 +132,7 @@ describe("acp-next session state", () => { metadata: { output: "first chunk" }, }), ) - const routed = yield* ACPNextSession.Service.use((session) => + const routed = yield* ACPSession.Service.use((session) => session.getPartMetadata({ sessionId: "ses_parts", messageId: "msg_1", partId: "part_1" }), ) @@ -148,8 +148,8 @@ describe("acp-next session state", () => { sessionTest.effect("keeps repeated part ids distinct across messages", () => Effect.gen(function* () { - yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_duplicate_parts", cwd: "/workspace" })) - yield* ACPNextSession.Service.use((session) => + yield* ACPSession.Service.use((session) => session.create({ id: "ses_duplicate_parts", cwd: "/workspace" })) + yield* ACPSession.Service.use((session) => session.recordPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_1", @@ -157,7 +157,7 @@ describe("acp-next session state", () => { metadata: { output: "from first message" }, }), ) - yield* ACPNextSession.Service.use((session) => + yield* ACPSession.Service.use((session) => session.recordPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_2", @@ -166,10 +166,10 @@ describe("acp-next session state", () => { }), ) - const first = yield* ACPNextSession.Service.use((session) => + const first = yield* ACPSession.Service.use((session) => session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_1", partId: "part_1" }), ) - const second = yield* ACPNextSession.Service.use((session) => + const second = yield* ACPSession.Service.use((session) => session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_2", partId: "part_1" }), ) @@ -180,14 +180,14 @@ describe("acp-next session state", () => { sessionTest.effect("removing a session clears its known part metadata", () => Effect.gen(function* () { - yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_remove", cwd: "/workspace" })) - yield* ACPNextSession.Service.use((session) => + yield* ACPSession.Service.use((session) => session.create({ id: "ses_remove", cwd: "/workspace" })) + yield* ACPSession.Service.use((session) => session.recordPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }), ) - const removed = yield* ACPNextSession.Service.use((session) => session.remove("ses_remove")) - const missing = yield* ACPNextSession.Service.use((session) => session.tryGet("ses_remove")) - const missingPart = yield* ACPNextSession.Service.use((session) => + const removed = yield* ACPSession.Service.use((session) => session.remove("ses_remove")) + const missing = yield* ACPSession.Service.use((session) => session.tryGet("ses_remove")) + const missingPart = yield* ACPSession.Service.use((session) => session.tryGetPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }), ) diff --git a/packages/opencode/test/acp-next/tool.test.ts b/packages/opencode/test/acp/tool.test.ts similarity index 98% rename from packages/opencode/test/acp-next/tool.test.ts rename to packages/opencode/test/acp/tool.test.ts index 0e0cc1e3ff..7587f7bbd9 100644 --- a/packages/opencode/test/acp-next/tool.test.ts +++ b/packages/opencode/test/acp/tool.test.ts @@ -7,9 +7,9 @@ import { shellOutputSnapshot, toLocations, toToolKind, -} from "../../src/acp-next/tool" +} from "../../src/acp/tool" -describe("acp-next tool conversion", () => { +describe("acp tool conversion", () => { test("maps OpenCode tool ids to ACP tool kinds", () => { expect(toToolKind("bash")).toBe("execute") expect(toToolKind("shell")).toBe("execute") diff --git a/packages/opencode/test/acp-next/usage.test.ts b/packages/opencode/test/acp/usage.test.ts similarity index 99% rename from packages/opencode/test/acp-next/usage.test.ts rename to packages/opencode/test/acp/usage.test.ts index 77c17d4f72..343a270138 100644 --- a/packages/opencode/test/acp-next/usage.test.ts +++ b/packages/opencode/test/acp/usage.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { SessionNotification } from "@agentclientprotocol/sdk" -import { UsageService } from "@/acp-next/usage" +import { UsageService } from "@/acp/usage" import { ModelID, ProviderID } from "@/provider/schema" import { Provider } from "@/provider/provider" import { Effect, Layer } from "effect" @@ -122,7 +122,7 @@ const connection = (updates: SessionNotification[]) => ({ }, }) -describe("acp-next usage", () => { +describe("acp usage", () => { test("builds ACP Usage from assistant token shape", () => { expect( UsageService.buildUsage({ diff --git a/packages/opencode/test/cli/acp-next/acp-next-process.test.ts b/packages/opencode/test/cli/acp-next/acp-next-process.test.ts deleted file mode 100644 index 95b905543b..0000000000 --- a/packages/opencode/test/cli/acp-next/acp-next-process.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { describe, expect } from "bun:test" -import type { - AuthenticateResponse, - CloseSessionResponse, - InitializeResponse, - LoadSessionResponse, - NewSessionResponse, - ResumeSessionResponse, - SessionNotification, - SetSessionConfigOptionResponse, -} from "@agentclientprotocol/sdk" -import { Effect } from "effect" -import { cliIt } from "../../lib/cli-process" -import { testProviderConfig } from "../../lib/test-provider" -import { createAcpClient, expectOk, firstAlternateValue, selectConfigOption } from "../acp/acp-test-client" - -describe("opencode acp-next (subprocess)", () => { - cliIt.live( - "responds to initialize behind OPENCODE_ACP_NEXT", - ({ opencode }) => - Effect.gen(function* () { - const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } })) - const initialized = expectOk( - yield* acp.request("initialize", { - protocolVersion: 1, - clientCapabilities: { _meta: { "terminal-auth": true } }, - }), - ) - - expect(initialized.protocolVersion).toBe(1) - expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true) - expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true) - expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true) - expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true) - expect(initialized.agentCapabilities?.loadSession).toBe(true) - expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) - expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({}) - expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({}) - expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) - expect(initialized.agentInfo?.name).toBe("OpenCode") - expect(initialized.authMethods?.[0]?.id).toBe("opencode-login") - expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined() - }), - 60_000, - ) - - cliIt.live( - "authenticate succeeds for the advertised auth method and rejects unknown methods safely", - ({ opencode }) => - Effect.gen(function* () { - const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } })) - const initialized = expectOk(yield* acp.request("initialize", { protocolVersion: 1 })) - const methodId = initialized.authMethods?.[0]?.id - expect(methodId).toBe("opencode-login") - - expectOk(yield* acp.request("authenticate", { methodId })) - - const rejected = yield* acp.request("authenticate", { methodId: "missing-auth-method" }) - expect(errorCode(rejected.error)).toBe(-32602) - }), - 60_000, - ) - - cliIt.live( - "creates and loads sessions behind OPENCODE_ACP_NEXT", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_ACP_NEXT: "1", - OPENCODE_CONFIG_CONTENT: JSON.stringify(testProviderConfig(llm.url)), - }, - }), - ) - yield* acp.request("initialize", { protocolVersion: 1 }) - - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - expect(typeof session.sessionId).toBe("string") - expect(selectConfigOption(session.configOptions, "model")?.category).toBe("model") - - const update = yield* acp.waitForNotification( - "session/update", - (params) => - params.sessionId === session.sessionId && params.update.sessionUpdate === "available_commands_update", - ) - expect(update.params?.sessionId).toBe(session.sessionId) - - const loaded = expectOk( - yield* acp.request("session/load", { - cwd: home, - sessionId: session.sessionId, - mcpServers: [], - }), - ) - expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model") - - const prompt = yield* acp.request("session/prompt", { - sessionId: "ses_missing", - prompt: [{ type: "text", text: "hello" }], - }) - expect(errorCode(prompt.error)).toBe(-32601) - }), - 60_000, - ) - - cliIt.live( - "switches model through config options behind OPENCODE_ACP_NEXT", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_ACP_NEXT: "1", - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), - }, - }), - ) - yield* acp.request("initialize", { protocolVersion: 1 }) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - - const updated = expectOk( - yield* acp.request("session/set_config_option", { - sessionId: session.sessionId, - configId: "model", - value: "test/second-model", - }), - ) - - expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe("test/second-model") - }), - 60_000, - ) - - cliIt.live( - "switches effort through config options behind OPENCODE_ACP_NEXT", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_ACP_NEXT: "1", - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), - }, - }), - ) - yield* acp.request("initialize", { protocolVersion: 1 }) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - const effort = selectConfigOption(session.configOptions, "effort") - expect(effort?.category).toBe("thought_level") - const nextEffort = effort ? firstAlternateValue(effort) : undefined - expect(nextEffort).toBe("high") - - const updated = expectOk( - yield* acp.request("session/set_config_option", { - sessionId: session.sessionId, - configId: "effort", - value: nextEffort, - }), - ) - - expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort) - }), - 60_000, - ) - - cliIt.live( - "advertises and supports close behind OPENCODE_ACP_NEXT", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_ACP_NEXT: "1", - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), - }, - }), - ) - const initialized = expectOk(yield* acp.request("initialize", { protocolVersion: 1 })) - expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - - expectOk(yield* acp.request("session/close", { sessionId: session.sessionId })) - }), - 60_000, - ) - - cliIt.live( - "advertises and supports resume behind OPENCODE_ACP_NEXT", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_ACP_NEXT: "1", - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), - }, - }), - ) - const initialized = expectOk(yield* acp.request("initialize", { protocolVersion: 1 })) - expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - const resumed = expectOk( - yield* acp.request("session/resume", { - cwd: home, - sessionId: session.sessionId, - mcpServers: [], - }), - ) - - expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model") - }), - 60_000, - ) - - cliIt.live( - "exits cleanly when flagged stdin is closed", - ({ opencode }) => - Effect.gen(function* () { - const exitedPromise = yield* Effect.scoped( - Effect.gen(function* () { - const acp = yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }) - return acp.exited - }), - ) - - const code = yield* Effect.promise(() => exitedPromise) - expect(typeof code === "number" || code === null).toBe(true) - }), - 60_000, - ) - - cliIt.live( - "default unflagged path still uses production ACP", - ({ opencode }) => - Effect.gen(function* () { - const acp = createAcpClient(yield* opencode.acp()) - const initialized = expectOk(yield* acp.request("initialize", { protocolVersion: 1 })) - - expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) - expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) - }), - 60_000, - ) -}) - -function errorCode(error: unknown) { - if (!error || typeof error !== "object") return undefined - if (!("code" in error)) return undefined - return typeof error.code === "number" ? error.code : undefined -} - -function verifierConfig(llmUrl: string) { - const config = testProviderConfig(llmUrl) - return { - ...config, - model: "test/test-model", - provider: { - test: { - ...config.provider.test, - models: { - "test-model": { - ...config.provider.test.models["test-model"], - variants: { - low: {}, - high: {}, - }, - }, - "second-model": { - ...config.provider.test.models["test-model"], - id: "second-model", - name: "Second Test Model", - variants: { - medium: {}, - max: {}, - }, - }, - }, - }, - }, - } -} diff --git a/packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts b/packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts deleted file mode 100644 index 5352039383..0000000000 --- a/packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { describe, expect } from "bun:test" -import type { - CloseSessionResponse, - InitializeResponse, - NewSessionResponse, - ResumeSessionResponse, - SessionNotification, - SetSessionConfigOptionResponse, -} from "@agentclientprotocol/sdk" -import { Effect } from "effect" -import { mkdir } from "node:fs/promises" -import path from "node:path" -import { cliIt } from "../../lib/cli-process" -import { testProviderConfig } from "../../lib/test-provider" -import { - createAcpClient, - expectOk, - firstAlternateValue, - flattenSelectOptions, - selectConfigOption, -} from "./acp-test-client" - -describe("opencode acp verifier compatibility baseline", () => { - cliIt.live( - "initialize advertises close and resume capabilities", - ({ opencode }) => - Effect.gen(function* () { - const acp = createAcpClient(yield* opencode.acp()) - const initialized = expectOk( - yield* acp.request("initialize", { - protocolVersion: 1, - }), - ) - - expect(initialized.protocolVersion).toBe(1) - expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) - expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) - }), - 60_000, - ) - - cliIt.live( - "first session timing diagnostic stays bounded and returns model options", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), - }, - }), - ) - const started = Date.now() - yield* acp.request("initialize", { - protocolVersion: 1, - clientCapabilities: {}, - clientInfo: { name: "opencode-local-acp-baseline", version: "0.1.0" }, - }) - const session = expectOk( - yield* acp.request("session/new", { - cwd: home, - mcpServers: [], - }), - ) - const durationMs = Date.now() - started - expect(durationMs).toBeLessThan(15_000) - - const model = selectConfigOption(session.configOptions, "model") - expect(model?.category).toBe("model") - expect(model?.currentValue).toBe("test/test-model") - expect(model ? flattenSelectOptions(model).length : 0).toBeGreaterThanOrEqual(2) - }), - 60_000, - ) - - cliIt.live( - "warm newSession timing diagnostic stays bounded", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), - }, - }), - ) - yield* acp.request("initialize", { protocolVersion: 1 }) - yield* acp.request("session/new", { cwd: home, mcpServers: [] }) - - const started = Date.now() - const session = expectOk( - yield* acp.request("session/new", { - cwd: home, - mcpServers: [], - }), - ) - const durationMs = Date.now() - started - expect(durationMs).toBeLessThan(15_000) - expect(session.sessionId).toBeTruthy() - }), - 60_000, - ) - - cliIt.live( - "model switch timing diagnostic updates currentValue", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), - }, - }), - ) - yield* acp.request("initialize", { protocolVersion: 1 }) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - const model = selectConfigOption(session.configOptions, "model") - expect(model).toBeDefined() - const nextModel = model - ? flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value - : undefined - expect(nextModel).toBe("test/second-model") - - const started = Date.now() - const updated = expectOk( - yield* acp.request("session/set_config_option", { - sessionId: session.sessionId, - configId: "model", - value: nextModel, - }), - ) - const durationMs = Date.now() - started - - expect(durationMs).toBeLessThan(15_000) - expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel) - }), - 60_000, - ) - - cliIt.live( - "effort option is listed for variant-capable models and can switch", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), - }, - }), - ) - yield* acp.request("initialize", { protocolVersion: 1 }) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - const effort = selectConfigOption(session.configOptions, "effort") - expect(effort?.category).toBe("thought_level") - const nextEffort = effort ? firstAlternateValue(effort) : undefined - expect(nextEffort).toBe("high") - - const updated = expectOk( - yield* acp.request("session/set_config_option", { - sessionId: session.sessionId, - configId: "effort", - value: nextEffort, - }), - ) - - expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort) - }), - 60_000, - ) - - cliIt.live( - "default test provider documents missing effort option when the model has no variants", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_CONFIG_CONTENT: JSON.stringify(noVariantConfig(llm.url)), - }, - }), - ) - yield* acp.request("initialize", { protocolVersion: 1 }) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - - expect(selectConfigOption(session.configOptions, "model")?.currentValue).toBe("test/test-model") - expect(selectConfigOption(session.configOptions, "effort")).toBeUndefined() - }), - 60_000, - ) - - cliIt.live( - "skill slash command timing diagnostic appears through available_commands_update", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const skills = path.join(home, "skills") - yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true })) - yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill)) - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)), - }, - }), - ) - yield* acp.request("initialize", { protocolVersion: 1 }) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - - const update = yield* acp.waitForNotification( - "session/update", - (params) => - params.sessionId === session.sessionId && - params.update.sessionUpdate === "available_commands_update" && - params.update.availableCommands.some((command) => command.name === "verifier-skill"), - ) - - expect(update.params?.sessionId).toBe(session.sessionId) - - const secondSession = expectOk( - yield* acp.request("session/new", { cwd: home, mcpServers: [] }), - ) - const started = Date.now() - yield* acp.waitForNotification( - "session/update", - (params) => - params.sessionId === secondSession.sessionId && - params.update.sessionUpdate === "available_commands_update" && - params.update.availableCommands.some((command) => command.name === "verifier-skill"), - ) - const durationMs = Date.now() - started - expect(durationMs).toBeLessThan(15_000) - }), - 60_000, - ) - - cliIt.live( - "close request succeeds for a live session", - ({ home, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient(yield* opencode.acp()) - yield* acp.request("initialize", { protocolVersion: 1 }) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - - expectOk(yield* acp.request("session/close", { sessionId: session.sessionId })) - }), - 60_000, - ) - - cliIt.live( - "resume request succeeds for a created session", - ({ home, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient(yield* opencode.acp()) - yield* acp.request("initialize", { protocolVersion: 1 }) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - - const resumed = expectOk( - yield* acp.request("session/resume", { - sessionId: session.sessionId, - cwd: home, - mcpServers: [], - }), - ) - expect(resumed.configOptions?.length).toBeGreaterThan(0) - }), - 60_000, - ) -}) - -function verifierConfig(llmUrl: string, skills?: string) { - const config = testProviderConfig(llmUrl) - return { - ...config, - model: "test/test-model", - ...(skills ? { skills: { paths: [skills] } } : {}), - provider: { - test: { - ...config.provider.test, - models: { - "test-model": { - ...config.provider.test.models["test-model"], - variants: { - low: {}, - high: {}, - }, - }, - "second-model": { - ...config.provider.test.models["test-model"], - id: "second-model", - name: "Second Test Model", - }, - }, - }, - }, - } -} - -function noVariantConfig(llmUrl: string) { - const config = verifierConfig(llmUrl) - return { - ...config, - provider: { - test: { - ...config.provider.test, - models: { - "test-model": { - ...config.provider.test.models["test-model"], - variants: undefined, - }, - "second-model": config.provider.test.models["second-model"], - }, - }, - }, - } -} - -const verifierSkill = `--- -name: verifier-skill -description: Verifier compatibility skill. ---- - -# Verifier Skill -` diff --git a/packages/opencode/test/cli/acp/acp-process.test.ts b/packages/opencode/test/cli/acp/acp-process.test.ts deleted file mode 100644 index a3c244c6c8..0000000000 --- a/packages/opencode/test/cli/acp/acp-process.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Subprocess integration tests for `opencode acp`. ACP is a JSON-RPC -// protocol spoken over stdin/stdout (not HTTP) — see src/acp/README.md. -// This is the only test tier that exercises the full pipe of bun startup → -// server boot → ACP agent init → stdio framing → graceful shutdown. -import { describe, expect } from "bun:test" -import { Duration, Effect } from "effect" -import { cliIt } from "../../lib/cli-process" - -describe("opencode acp (subprocess)", () => { - // Smoke test: send the `initialize` request from src/acp/README.md and - // assert the response advertises the same protocol version and a non-empty - // capabilities block. If this fails, every other ACP test will too — start - // debugging here. - cliIt.live( - "responds to initialize with protocolVersion 1 and capabilities", - ({ opencode }) => - Effect.gen(function* () { - const acp = yield* opencode.acp() - - yield* acp.send({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { protocolVersion: 1 }, - }) - - // Tight deadline — the response should arrive within a few seconds - // once startup completes. A hang means the agent never finished init, - // which is a real regression and not a tuning issue. - const response = (yield* acp.receive.pipe(Effect.timeout(Duration.seconds(10)))) as { - jsonrpc: string - id: number - result?: { protocolVersion: number; agentCapabilities: Record } - error?: unknown - } - - expect(response.jsonrpc).toBe("2.0") - expect(response.id).toBe(1) - expect(response.error).toBeUndefined() - expect(response.result?.protocolVersion).toBe(1) - expect(response.result?.agentCapabilities).toBeDefined() - }), - 60_000, - ) - - // Lock in the scope-close kill path. ACP's clean shutdown is "EOF on stdin" - // — if a future refactor breaks the stdin-end branch in the handler, the - // process would only exit on SIGTERM fallback (2s in the harness). This - // test passing within the inner-scope assertion proves the EOF path works. - cliIt.live( - "exits cleanly when stdin is closed (scope close)", - ({ opencode }) => - Effect.gen(function* () { - const exitedPromise = yield* Effect.scoped( - Effect.gen(function* () { - const acp = yield* opencode.acp() - // Capture the Promise — scope-close fires the finalizer which - // ends stdin, and ACP should exit gracefully. - return acp.exited - }), - ) - - const code = yield* Effect.promise(() => exitedPromise) - // Bun returns a number for normal exit. Anything goes for SIGTERM, - // but we still require resolution within the test timeout. - expect(typeof code === "number" || code === null).toBe(true) - }), - 60_000, - ) -}) diff --git a/packages/opencode/test/cli/acp/config-options.test.ts b/packages/opencode/test/cli/acp/config-options.test.ts new file mode 100644 index 0000000000..0c712f0f23 --- /dev/null +++ b/packages/opencode/test/cli/acp/config-options.test.ts @@ -0,0 +1,103 @@ +import { describe, expect } from "bun:test" +import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk" +import { Effect } from "effect" +import { cliIt } from "../../lib/cli-process" +import { expectOk, flattenSelectOptions, selectConfigOption } from "./acp-test-client" +import { + createAcpClient, + expectAlternateValue, + expectSelectOption, + initialize, + newSession, + verifierConfig, +} from "./helpers" + +describe("opencode acp config option subprocess", () => { + cliIt.live( + 'model option is listed with category "model"', + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + yield* initialize(acp) + const model = expectSelectOption((yield* newSession(acp, home)).configOptions, "model") + + expect(model.category).toBe("model") + expect(model.currentValue).toBe("test/test-model") + expect(flattenSelectOptions(model).length).toBeGreaterThanOrEqual(2) + }), + 60_000, + ) + + cliIt.live( + "model switch updates currentValue", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + yield* initialize(acp) + const session = yield* newSession(acp, home) + const model = expectSelectOption(session.configOptions, "model") + const nextModel = flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value + expect(nextModel).toBe("test/second-model") + + const updated = expectOk( + yield* acp.request("session/set_config_option", { + sessionId: session.sessionId, + configId: "model", + value: nextModel, + }), + ) + + expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel) + }), + 60_000, + ) + + cliIt.live( + 'effort option is listed with category "thought_level" when selected model supports variants', + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + yield* initialize(acp) + const effort = expectSelectOption((yield* newSession(acp, home)).configOptions, "effort") + + expect(effort.category).toBe("thought_level") + expect(effort.currentValue).toBe("low") + expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"]) + }), + 60_000, + ) + + cliIt.live( + "effort switch updates currentValue", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + yield* initialize(acp) + const session = yield* newSession(acp, home) + const nextEffort = expectAlternateValue(expectSelectOption(session.configOptions, "effort")) + + const updated = expectOk( + yield* acp.request("session/set_config_option", { + sessionId: session.sessionId, + configId: "effort", + value: nextEffort, + }), + ) + + expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort) + }), + 60_000, + ) +}) diff --git a/packages/opencode/test/cli/acp/helpers.ts b/packages/opencode/test/cli/acp/helpers.ts new file mode 100644 index 0000000000..92c560d941 --- /dev/null +++ b/packages/opencode/test/cli/acp/helpers.ts @@ -0,0 +1,96 @@ +import { expect } from "bun:test" +import type { InitializeResponse, NewSessionResponse, SessionConfigOption } from "@agentclientprotocol/sdk" +import { Effect } from "effect" +import type { CliFixture } from "../../lib/cli-process" +import { testProviderConfig } from "../../lib/test-provider" +import { + createAcpClient as createJsonRpcAcpClient, + expectOk, + flattenSelectOptions, + selectConfigOption, + type AcpClient, +} from "./acp-test-client" + +export function createAcpClient(input: Pick, env?: Record) { + return Effect.gen(function* () { + return createJsonRpcAcpClient(yield* input.opencode.acp(env ? { env } : undefined)) + }) +} + +export function initialize(acp: AcpClient) { + return Effect.gen(function* () { + return expectOk( + yield* acp.request("initialize", { + protocolVersion: 1, + clientCapabilities: { _meta: { "terminal-auth": true } }, + clientInfo: { name: "opencode-local-acp", version: "0.1.0" }, + }), + ) + }) +} + +export function newSession(acp: AcpClient, cwd: string) { + return Effect.gen(function* () { + return expectOk(yield* acp.request("session/new", { cwd, mcpServers: [] })) + }) +} + +export function verifierConfig(llmUrl: string, skills?: string) { + const config = testProviderConfig(llmUrl) + return { + ...config, + model: "test/test-model", + ...(skills ? { skills: { paths: [skills] } } : {}), + provider: { + test: { + ...config.provider.test, + models: { + "test-model": { + ...config.provider.test.models["test-model"], + variants: { + low: {}, + high: {}, + }, + }, + "second-model": { + ...config.provider.test.models["test-model"], + id: "second-model", + name: "Second Test Model", + variants: { + medium: {}, + max: {}, + }, + }, + }, + }, + }, + } +} + +export function expectErrorCode(error: unknown, code: number) { + if (!error || typeof error !== "object" || !("code" in error)) { + expect(error).toEqual({ code }) + return + } + expect(error.code).toBe(code) +} + +export function expectSelectOption(options: SessionConfigOption[] | null | undefined, id: string) { + const option = selectConfigOption(options, id) + expect(option).toBeDefined() + return option! +} + +export function expectAlternateValue(option: ReturnType) { + const value = flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value + expect(value).toBeDefined() + return value! +} + +export const verifierSkill = `--- +name: verifier-skill +description: Verifier compatibility skill. +--- + +# Verifier Skill +` diff --git a/packages/opencode/test/cli/acp/initialize-auth.test.ts b/packages/opencode/test/cli/acp/initialize-auth.test.ts new file mode 100644 index 0000000000..709c27f3a3 --- /dev/null +++ b/packages/opencode/test/cli/acp/initialize-auth.test.ts @@ -0,0 +1,61 @@ +import { describe, expect } from "bun:test" +import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk" +import { Effect } from "effect" +import { cliIt } from "../../lib/cli-process" +import { createAcpClient, expectErrorCode, initialize } from "./helpers" + +describe("opencode acp initialize/auth subprocess", () => { + cliIt.live( + "initialize responds with capabilities", + ({ opencode }) => + Effect.gen(function* () { + const initialized = yield* initialize(yield* createAcpClient({ opencode })) + + expect(initialized.protocolVersion).toBe(1) + expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true) + expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true) + expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true) + expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true) + expect(initialized.agentCapabilities?.loadSession).toBe(true) + expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) + expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({}) + expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({}) + expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) + expect(initialized.agentInfo?.name).toBe("OpenCode") + }), + 60_000, + ) + + cliIt.live( + "auth negotiation is explicit and safe", + ({ opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpClient({ opencode }) + const initialized = yield* initialize(acp) + + expect(initialized.authMethods?.[0]?.id).toBe("opencode-login") + expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined() + expect(yield* acp.request("authenticate", { methodId: "opencode-login" })).toMatchObject({ + result: {}, + }) + + const rejected = yield* acp.request("authenticate", { methodId: "missing-auth-method" }) + expectErrorCode(rejected.error, -32602) + expect(JSON.stringify(rejected.error)).not.toContain(process.env.OPENCODE_AUTH_CONTENT ?? "not-present") + }), + 60_000, + ) + + cliIt.live( + "initialize without terminal-auth metadata keeps auth command implicit", + ({ opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpClient({ opencode }) + const initialized = yield* acp.request("initialize", { protocolVersion: 1 }) + + expect(initialized.result?.authMethods?.[0]?.id).toBe("opencode-login") + expect(initialized.result?.authMethods?.[0]?._meta?.["terminal-auth"]).toBeUndefined() + }), + 60_000, + ) +}) diff --git a/packages/opencode/test/cli/acp/lifecycle.test.ts b/packages/opencode/test/cli/acp/lifecycle.test.ts new file mode 100644 index 0000000000..9f2558ea2f --- /dev/null +++ b/packages/opencode/test/cli/acp/lifecycle.test.ts @@ -0,0 +1,118 @@ +import { describe, expect } from "bun:test" +import type { + CloseSessionResponse, + ListSessionsResponse, + LoadSessionResponse, + ResumeSessionResponse, +} from "@agentclientprotocol/sdk" +import { Duration, Effect } from "effect" +import { cliIt } from "../../lib/cli-process" +import { expectOk, selectConfigOption } from "./acp-test-client" +import { createAcpClient, initialize, newSession, verifierConfig } from "./helpers" + +describe("opencode acp lifecycle subprocess", () => { + cliIt.live( + "stdin EOF exits cleanly", + ({ opencode }) => + Effect.gen(function* () { + const acp = yield* opencode.acp() + acp.close() + + const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(5))) + expect(code).toBe(0) + }), + 60_000, + ) + + cliIt.live( + "close capability and close request", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + const initialized = yield* initialize(acp) + expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) + + const session = yield* newSession(acp, home) + expectOk(yield* acp.request("session/close", { sessionId: session.sessionId })) + }), + 60_000, + ) + + cliIt.live( + "loadSession capability and load request return session config options", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + const initialized = yield* initialize(acp) + expect(initialized.agentCapabilities?.loadSession).toBe(true) + const session = yield* newSession(acp, home) + const loaded = expectOk( + yield* acp.request("session/load", { + cwd: home, + sessionId: session.sessionId, + mcpServers: [], + }), + ) + + expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model") + }), + 60_000, + ) + + cliIt.live( + "list request includes a live ACP-created session", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + yield* initialize(acp) + const session = yield* newSession(acp, home) + const listed = expectOk(yield* acp.request("session/list", { cwd: home })) + + expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true) + }), + 60_000, + ) + + cliIt.live( + "resume capability advertisement", + ({ opencode }) => + Effect.gen(function* () { + const initialized = yield* initialize(yield* createAcpClient({ opencode })) + + expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) + }), + 60_000, + ) + + cliIt.live( + "resume request returns session config options", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + yield* initialize(acp) + const session = yield* newSession(acp, home) + const resumed = expectOk( + yield* acp.request("session/resume", { + cwd: home, + sessionId: session.sessionId, + mcpServers: [], + }), + ) + + expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model") + }), + 60_000, + ) +}) diff --git a/packages/opencode/test/cli/acp/prompt-content.test.ts b/packages/opencode/test/cli/acp/prompt-content.test.ts new file mode 100644 index 0000000000..6b6f6ddeb6 --- /dev/null +++ b/packages/opencode/test/cli/acp/prompt-content.test.ts @@ -0,0 +1,97 @@ +import { describe, expect } from "bun:test" +import type { PromptResponse } from "@agentclientprotocol/sdk" +import { Effect } from "effect" +import { writeFile } from "node:fs/promises" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { cliIt } from "../../lib/cli-process" +import { expectOk } from "./acp-test-client" +import { createAcpClient, initialize, newSession, verifierConfig } from "./helpers" + +const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=" + +describe("opencode acp prompt content subprocess", () => { + cliIt.live( + "accepts embedded text resource image and file resource link prompt content", + ({ home, llm, opencode }) => + Effect.gen(function* () { + yield* Effect.promise(() => writeFile(path.join(home, "README.md"), "# ACP content smoke\n")) + const acp = yield* createAcpClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(promptContentConfig(llm.url)) }, + ) + yield* initialize(acp) + const session = yield* newSession(acp, home) + + yield* llm.text("embedded resource accepted") + expectOk( + yield* acp.request("session/prompt", { + sessionId: session.sessionId, + prompt: [ + { type: "text", text: "Use this embedded resource." }, + { + type: "resource", + resource: { uri: "file:///context.txt", mimeType: "text/plain", text: "embedded context" }, + }, + ], + }), + ) + + yield* llm.text("image accepted") + expectOk( + yield* acp.request("session/prompt", { + sessionId: session.sessionId, + prompt: [ + { type: "text", text: "Use this image." }, + { + type: "image", + mimeType: "image/png", + data: tinyPng, + }, + ], + }), + ) + + yield* llm.text("file link accepted") + const linked = expectOk( + yield* acp.request("session/prompt", { + sessionId: session.sessionId, + prompt: [ + { type: "text", text: "Use this linked file." }, + { + type: "resource_link", + uri: pathToFileURL(path.join(home, "README.md")).href, + name: "README.md", + mimeType: "text/markdown", + }, + ], + }), + ) + + expect(linked.stopReason).toBe("end_turn") + }), + 60_000, + ) +}) + +function promptContentConfig(llmUrl: string) { + const config = verifierConfig(llmUrl) + return { + ...config, + provider: { + test: { + ...config.provider.test, + models: Object.fromEntries( + Object.entries(config.provider.test.models).map(([id, model]) => [ + id, + { + ...model, + attachment: true, + reasoning: true, + }, + ]), + ), + }, + }, + } +} diff --git a/packages/opencode/test/cli/acp/skills.test.ts b/packages/opencode/test/cli/acp/skills.test.ts new file mode 100644 index 0000000000..b423493cfa --- /dev/null +++ b/packages/opencode/test/cli/acp/skills.test.ts @@ -0,0 +1,38 @@ +import { describe, expect } from "bun:test" +import type { SessionNotification } from "@agentclientprotocol/sdk" +import { Effect } from "effect" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { cliIt } from "../../lib/cli-process" +import { createAcpClient, initialize, newSession, verifierConfig, verifierSkill } from "./helpers" + +describe("opencode acp skills subprocess", () => { + cliIt.live( + "skill slash command appears through available_commands_update", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const skills = path.join(home, "skills") + yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true })) + yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill)) + const acp = yield* createAcpClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)) }, + ) + yield* initialize(acp) + const session = yield* newSession(acp, home) + + const update = yield* acp.waitForNotification( + "session/update", + (params) => + params.sessionId === session.sessionId && + params.update.sessionUpdate === "available_commands_update" && + params.update.availableCommands.some( + (command) => command.name === "verifier-skill" && command.description.length > 0, + ), + ) + + expect(update.params?.sessionId).toBe(session.sessionId) + }), + 60_000, + ) +}) diff --git a/packages/opencode/test/cli/cmd/tui/model-options.test.ts b/packages/opencode/test/cli/cmd/tui/model-options.test.ts new file mode 100644 index 0000000000..195078ebd6 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/model-options.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import { sortModelOptions } from "../../../../src/cli/cmd/tui/component/dialog-model" + +describe("sortModelOptions", () => { + test("orders provider-scoped model choices by newest release first", () => { + const sorted = sortModelOptions( + [ + { title: "GPT 5.2", releaseDate: "2025-12-11" }, + { title: "GPT 5.4", releaseDate: "2026-03-05" }, + { title: "GPT 5.1", releaseDate: "2025-11-13" }, + ], + true, + ) + + expect(sorted.map((model) => model.title)).toEqual(["GPT 5.4", "GPT 5.2", "GPT 5.1"]) + }) + + test("preserves free-first alphabetical ordering for the regular picker", () => { + const sorted = sortModelOptions( + [ + { title: "Beta", releaseDate: "2026-01-01" }, + { title: "Alpha", releaseDate: "2025-01-01", footer: "Free" }, + { title: "Gamma", releaseDate: "2024-01-01", footer: "Free" }, + ], + false, + ) + + expect(sorted.map((model) => model.title)).toEqual(["Alpha", "Gamma", "Beta"]) + }) +}) diff --git a/packages/opencode/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap b/packages/opencode/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap new file mode 100644 index 0000000000..9e317dae88 --- /dev/null +++ b/packages/opencode/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap @@ -0,0 +1,54 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read rows at a narrow width 1`] = ` +" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data. + *dir|xdg|APPDATA" in packages/opencode/src (151 matches) + ✱ Glob "**/*db*" in packages/opencode (6 matches) + → Read packages/opencode/src/storage/db.ts [offset=1, limit=130] + → Read packages/opencode/src/index.ts [offset=1, limit=100] + ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\. + Path\\.data|data =" in packages/opencode/src (115 matches)" +`; + +exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool text 1`] = ` +" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data. + *dir|xdg|APPDATA" in packages/opencode/src (151 matches) + ✱ Glob "**/*db*" in packages/opencode (6 matches) + → Read packages/opencode/src/storage/db.ts [offset=1, limit=130] + → Read packages/opencode/src/index.ts [offset=1, limit=100] + No LSP server available for this file type. + ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\. + Path\\.data|data =" in packages/opencode/src (115 matches)" +`; + +exports[`TUI inline tool wrapping keeps separation after a shell output block 1`] = ` +" + + # List files + + $ ls + + file.ts + + ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data. + *dir|xdg|APPDATA" in packages/opencode/src (151 matches) + ✱ Glob "**/*db*" in packages/opencode (6 matches) + → Read packages/opencode/src/storage/db.ts [offset=1, limit=130] + → Read packages/opencode/src/index.ts [offset=1, limit=100] + ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\. + Path\\.data|data =" in packages/opencode/src (115 matches)" +`; + +exports[`TUI inline tool wrapping keeps separation after a padded user message 1`] = ` +" + Check whether the next tool remains separated. + + + ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data. + *dir|xdg|APPDATA" in packages/opencode/src (151 matches) + ✱ Glob "**/*db*" in packages/opencode (6 matches) + → Read packages/opencode/src/storage/db.ts [offset=1, limit=130] + → Read packages/opencode/src/index.ts [offset=1, limit=100] + ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\. + Path\\.data|data =" in packages/opencode/src (115 matches)" +`; diff --git a/packages/opencode/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/opencode/test/cli/tui/inline-tool-wrap-snapshot.test.tsx new file mode 100644 index 0000000000..cab39bdc23 --- /dev/null +++ b/packages/opencode/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { For } from "solid-js" +import { testRender, type JSX } from "@opentui/solid" +import { InlineToolRow } from "../../../src/cli/cmd/tui/routes/session/index" + +let testSetup: Awaited> | undefined + +afterEach(() => { + testSetup?.renderer.destroy() + testSetup = undefined +}) + +type ToolFixture = { icon: string; label: string; error?: string } + +const tools: readonly ToolFixture[] = [ + { + icon: "✱", + label: + 'Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.*dir|xdg|APPDATA" in packages/opencode/src (151 matches)', + }, + { + icon: "✱", + label: 'Glob "**/*db*" in packages/opencode (6 matches)', + }, + { + icon: "→", + label: "Read packages/opencode/src/storage/db.ts [offset=1, limit=130]", + }, + { + icon: "→", + label: "Read packages/opencode/src/index.ts [offset=1, limit=100]", + error: "No LSP server available for this file type.", + }, + { + icon: "✱", + label: + 'Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.Path\\.data|data =" in packages/opencode/src (115 matches)', + }, +] as const + +function ShellOutput() { + return ( + + # List files + + $ ls + file.ts + + + ) +} + +function UserMessage() { + return ( + + + Check whether the next tool remains separated. + + + ) +} + +function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) { + return ( + + + {props.before === "shell" && } + {props.before === "user" && } + + {(item) => ( + id === "message-user"} + > + {item.label} + + )} + + + + ) +} + +async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) { + testSetup = await testRender(component, options) + await testSetup.renderOnce() + await Bun.sleep(25) + await testSetup.renderOnce() + + return testSetup + .captureCharFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trimEnd() +} + +describe("TUI inline tool wrapping", () => { + test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => { + expect(await renderFrame(() => , { width: 72, height: 12 })).toMatchSnapshot() + }) + + test("snapshots expanded tool errors under the tool text", async () => { + expect(await renderFrame(() => , { width: 72, height: 12 })).toMatchSnapshot() + }) + + test("keeps separation after a shell output block", async () => { + expect(await renderFrame(() => , { width: 72, height: 16 })).toMatchSnapshot() + }) + + test("keeps separation after a padded user message", async () => { + expect(await renderFrame(() => , { width: 72, height: 14 })).toMatchSnapshot() + }) +}) diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts index b044d07f23..36579f48ea 100644 --- a/packages/opencode/test/effect/runtime-flags.test.ts +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -63,6 +63,7 @@ describe("RuntimeFlags", () => { expect(flags.experimentalWorkspaces).toBe(true) expect(flags.experimentalIconDiscovery).toBe(true) expect(flags.experimentalNativeLlm).toBe(false) + expect(flags.experimentalWebSockets).toBe(false) expect(flags.client).toBe("desktop") }), ) @@ -91,6 +92,16 @@ describe("RuntimeFlags", () => { }), ) + it.effect("enables WebSockets via dedicated flag only", () => + Effect.gen(function* () { + const explicit = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_WEBSOCKETS: "true" }))) + const umbrella = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL: "true" }))) + + expect(explicit.experimentalWebSockets).toBe(true) + expect(umbrella.experimentalWebSockets).toBe(false) + }), + ) + it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () => Effect.gen(function* () { const flags = yield* readFlags.pipe( diff --git a/packages/opencode/test/mcp/auth.test.ts b/packages/opencode/test/mcp/auth.test.ts new file mode 100644 index 0000000000..efd7579e37 --- /dev/null +++ b/packages/opencode/test/mcp/auth.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test" +import { setTimeout as sleep } from "node:timers/promises" +import { Effect, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { McpAuth } from "../../src/mcp/auth" + +function authFile() { + let raw = "" + let activeWrites = 0 + let sawOverlap = false + + const layer = Layer.effect( + AppFileSystem.Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + + return AppFileSystem.Service.of({ + ...fs, + readJson: (file) => + file.endsWith("mcp-auth.json") + ? Effect.try({ + try: () => { + if (!raw) throw new Error("mcp-auth.json missing") + return JSON.parse(raw) + }, + catch: (cause) => new AppFileSystem.FileSystemError({ method: "readJson", cause }), + }) + : fs.readJson(file), + writeJson: (file, value, mode) => + file.endsWith("mcp-auth.json") + ? Effect.promise(async () => { + activeWrites++ + sawOverlap = sawOverlap || activeWrites > 1 + raw = "" + await sleep(10) + const next = JSON.stringify(value, null, 2) + raw = sawOverlap ? `${next}\n}` : next + activeWrites-- + }) + : fs.writeJson(file, value, mode), + }) + }), + ).pipe(Layer.provide(AppFileSystem.defaultLayer)) + + return { layer, raw: () => raw } +} + +function authService(layer: Layer.Layer) { + return McpAuth.Service.use((auth) => Effect.succeed(auth)).pipe( + Effect.provide(McpAuth.layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(layer))), + ) +} + +test("serializes concurrent auth file updates across service instances", async () => { + const file = authFile() + + await Effect.runPromise( + Effect.gen(function* () { + const first = yield* authService(file.layer) + const second = yield* authService(file.layer) + + yield* Effect.all( + [ + first.updateTokens("posthog", { accessToken: "access-token" }, "https://mcp.posthog.com/mcp"), + second.updateClientInfo("posthog", { clientId: "client-id" }, "https://mcp.posthog.com/mcp"), + ], + { concurrency: "unbounded" }, + ) + + const entry = yield* first.get("posthog") + expect(entry?.tokens?.accessToken).toBe("access-token") + expect(entry?.clientInfo?.clientId).toBe("client-id") + expect(entry?.serverUrl).toBe("https://mcp.posthog.com/mcp") + expect(() => JSON.parse(file.raw())).not.toThrow() + }), + ) +}) diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index 271bcde99b..a375fe4ee1 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -5,7 +5,7 @@ import { extractAccountIdFromClaims, extractAccountId, type IdTokenClaims, -} from "../../src/plugin/codex" +} from "../../src/plugin/openai/codex" function createTestJwt(payload: object): string { const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url") @@ -122,6 +122,24 @@ describe("plugin.codex", () => { }) }) + test("installs websocket transport only when experimental websockets are enabled", async () => { + const disabled = await CodexAuthPlugin({} as never) + const enabled = await CodexAuthPlugin({} as never, { experimentalWebSockets: true }) + + const disabledOptions = await disabled.auth!.loader!( + async () => ({ type: "api", key: "sk-test" }) as never, + {} as never, + ) + const enabledOptions = await enabled.auth!.loader!( + async () => ({ type: "api", key: "sk-test" }) as never, + {} as never, + ) + + expect(disabledOptions.fetch).toBeUndefined() + expect(enabledOptions.fetch).toBeFunction() + await enabled.dispose?.() + }) + test("deduplicates concurrent Codex token refreshes", async () => { let auth = { type: "oauth" as const, diff --git a/packages/opencode/test/plugin/openai-rollout.test.ts b/packages/opencode/test/plugin/openai-rollout.test.ts new file mode 100644 index 0000000000..1278e1cfbb --- /dev/null +++ b/packages/opencode/test/plugin/openai-rollout.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test" +import { experimentalWebSocketsEnabled } from "../../src/plugin" + +describe("plugin.openai.websocket rollout", () => { + test("enables websockets by default only on pre-release channels", () => { + expect(experimentalWebSocketsEnabled({ enabled: false, channel: "local" })).toBe(true) + expect(experimentalWebSocketsEnabled({ enabled: false, channel: "dev" })).toBe(true) + expect(experimentalWebSocketsEnabled({ enabled: false, channel: "beta" })).toBe(true) + expect(experimentalWebSocketsEnabled({ enabled: false, channel: "latest" })).toBe(false) + expect(experimentalWebSocketsEnabled({ enabled: false, channel: "prod" })).toBe(false) + }) + + test("allows releases to opt in through the experimental flag", () => { + expect(experimentalWebSocketsEnabled({ enabled: true, channel: "latest" })).toBe(true) + expect(experimentalWebSocketsEnabled({ enabled: true, channel: "prod" })).toBe(true) + }) +}) diff --git a/packages/opencode/test/plugin/openai-ws.test.ts b/packages/opencode/test/plugin/openai-ws.test.ts new file mode 100644 index 0000000000..dcb31199c4 --- /dev/null +++ b/packages/opencode/test/plugin/openai-ws.test.ts @@ -0,0 +1,711 @@ +import { describe, expect, test } from "bun:test" +import { EventEmitter } from "node:events" +import { createServer, type IncomingMessage, type Server as HttpServer } from "node:http" +import net, { type AddressInfo, type Socket } from "node:net" +import WebSocket, { WebSocketServer } from "ws" +import { ProviderError } from "../../src/provider/error" +import { OpenAIWebSocket } from "../../src/plugin/openai/ws" +import { OpenAIWebSocketPool, TITLE_HEADER } from "../../src/plugin/openai/ws-pool" + +describe("plugin.openai.ws", () => { + test("derives websocket URLs and sends auth plus protocol headers", async () => { + let headers: IncomingMessage["headers"] | undefined + await using server = await createWebSocketServer((_socket, request) => { + headers = request.headers + }) + + const socket = await OpenAIWebSocket.connectResponsesWebSocket({ + url: server.wsUrl, + headers: { authorization: "Bearer test", "content-length": "123" }, + }) + + expect(OpenAIWebSocket.toWebSocketUrl("http://example.com/v1/responses")).toBe("ws://example.com/v1/responses") + expect(OpenAIWebSocket.toWebSocketUrl("https://example.com/v1/responses")).toBe("wss://example.com/v1/responses") + expect(headers?.authorization).toBe("Bearer test") + expect(headers?.["openai-beta"]).toBe(OpenAIWebSocket.PROTOCOL_HEADER) + expect(headers?.["content-length"]).toBeUndefined() + socket.terminate() + }) + + test("enforces websocket connect timeout", async () => { + await using server = await createHangingTcpServer() + + await expect( + OpenAIWebSocket.connectResponsesWebSocket({ + url: server.wsUrl, + headers: {}, + timeout: 20, + }), + ).rejects.toThrow("WebSocket connect timed out") + }) + + test("surfaces websocket upgrade rejection messages", async () => { + await using server = await createRejectingWebSocketServer(() => {}) + + await expect( + OpenAIWebSocket.connectResponsesWebSocket({ + url: server.wsUrl, + headers: {}, + }), + ).rejects.toThrow("Expected 101 status code") + }) + + test("enforces websocket send idle timeout", async () => { + const socket = new (class extends EventEmitter { + send(_data: string, _callback: (error?: Error) => void) {} + })() as unknown as WebSocket + const invalid: string[] = [] + const response = OpenAIWebSocket.streamResponsesWebSocket({ + socket, + body: { stream: true, input: "hi" }, + idleTimeout: 20, + onConnectionInvalid: (error) => invalid.push(error.message), + }) + + expect((await readTextError(response.text())).message).toContain("idle timeout sending websocket request") + expect(invalid).toEqual(["idle timeout sending websocket request"]) + }) + + test("streams websocket events as SSE and handles response.done", async () => { + let requestBody: unknown + await using server = await createWebSocketServer((socket) => { + socket.once("message", (data) => { + requestBody = JSON.parse(data.toString()) + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "hello" })) + socket.send(JSON.stringify({ type: "response.done", response: { id: "resp_123" } })) + socket.close(1000, "done") + }) + }) + + const socket = await OpenAIWebSocket.connectResponsesWebSocket({ + url: server.wsUrl, + headers: { authorization: "Bearer test", "content-length": "123" }, + }) + const completed: Record[] = [] + const response = OpenAIWebSocket.streamResponsesWebSocket({ + socket, + body: { stream: true, background: true, input: "hi" }, + onComplete: (event) => completed.push(event), + }) + + expect(await response.text()).toBe( + 'data: {"type":"response.output_text.delta","delta":"hello"}\n\ndata: {"type":"response.done","response":{"id":"resp_123"}}\n\ndata: [DONE]\n\n', + ) + expect(requestBody).toEqual({ type: "response.create", input: "hi" }) + expect(completed).toHaveLength(1) + expect(completed[0]?.type).toBe("response.done") + }) + + test("errors the SSE stream when the server closes before a terminal event", async () => { + const invalid: Error[] = [] + await using server = await createWebSocketServer((socket) => { + socket.once("message", () => { + socket.close(1009, "payload too large") + }) + }) + + const socket = await OpenAIWebSocket.connectResponsesWebSocket({ url: server.wsUrl, headers: {} }) + const response = OpenAIWebSocket.streamResponsesWebSocket({ + socket, + body: { stream: true, input: "hi" }, + onConnectionInvalid: (error) => invalid.push(error), + }) + + expect((await readTextError(response.text())).message).toContain( + "WebSocket closed before response.completed (code 1009: message too big: payload too large)", + ) + expect(invalid[0]).toBeInstanceOf(ProviderError.ResponseStreamError) + expect(invalid.map((error) => error.message)).toEqual([ + "WebSocket closed before response.completed (code 1009: message too big: payload too large)", + ]) + }) + + test("rejects unexpected binary websocket frames", async () => { + const invalid: string[] = [] + await using server = await createWebSocketServer((socket) => { + socket.once("message", () => { + socket.send(Buffer.from("not json text")) + }) + }) + + const socket = await OpenAIWebSocket.connectResponsesWebSocket({ url: server.wsUrl, headers: {} }) + const response = OpenAIWebSocket.streamResponsesWebSocket({ + socket, + body: { stream: true, input: "hi" }, + onConnectionInvalid: (error) => invalid.push(error.message), + }) + + expect((await readTextError(response.text())).message).toContain("Unexpected binary WebSocket frame") + expect(invalid).toEqual(["Unexpected binary WebSocket frame"]) + }) +}) + +describe("plugin.openai.ws-pool", () => { + test("reuses one healthy websocket for sequential requests", async () => { + let connections = 0 + let messages = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.on("message", () => { + messages += 1 + socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${messages}` } })) + }) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + }) + + const first = await fetch(server.url, streamRequest()) + expect(await first.text()).toContain("data: [DONE]") + + const second = await fetch(server.url, streamRequest()) + expect(await second.text()).toContain("data: [DONE]") + expect(connections).toBe(1) + expect(messages).toBe(2) + fetch.close() + }) + + test("rotates a socket that exceeds max connection age", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.on("message", () => { + socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${connections}` } })) + }) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + maxConnectionAge: 0, + }) + + const first = await fetch(server.url, streamRequest()) + expect(await first.text()).toContain("data: [DONE]") + + const second = await fetch(server.url, streamRequest()) + expect(await second.text()).toContain("data: [DONE]") + expect(connections).toBe(2) + fetch.close() + }) + + test("falls back to HTTP after websocket setup retries are exhausted", async () => { + const attempts: string[] = [] + await using server = await createRejectingWebSocketServer(() => attempts.push("websocket")) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + connectTimeout: 100, + streamRetries: 1, + }) + + const first = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" })) + expect(await readTextError(first.text())).toBeInstanceOf(ProviderError.ResponseStreamError) + const second = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" })) + const third = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" })) + + expect(await second.text()).toBe("http") + expect(await third.text()).toBe("http") + expect(attempts).toEqual(["websocket", "websocket"]) + expect(server.httpRequests).toHaveLength(2) + expect(server.httpRequests[0]?.headers[TITLE_HEADER]).toBeUndefined() + expect(server.httpRequests[1]?.headers[TITLE_HEADER]).toBeUndefined() + fetch.close() + }) + + test("prunes HTTP fallback after its idle timeout", async () => { + let websocketAttempts = 0 + await using server = await createRejectingWebSocketServer(() => websocketAttempts++) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + connectTimeout: 100, + idleTimeout: 20, + streamRetries: 0, + }) + + const first = await fetch(server.url, streamRequest()) + expect(await first.text()).toBe("http") + await new Promise((resolve) => setTimeout(resolve, 50)) + const second = await fetch(server.url, streamRequest()) + + expect(await second.text()).toBe("http") + expect(websocketAttempts).toBe(2) + expect(server.httpRequests).toHaveLength(2) + fetch.close() + }) + + test("invalidates but does not reuse a socket after terminal failure frames", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => { + socket.send(JSON.stringify({ type: connections === 1 ? "response.failed" : "response.completed" })) + }) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + }) + + const first = await fetch(server.url, streamRequest()) + expect(await first.text()).toContain('data: {"type":"response.failed"}') + + const second = await fetch(server.url, streamRequest()) + expect(await second.text()).toContain('data: {"type":"response.completed"}') + expect(connections).toBe(2) + expect(server.httpRequests).toHaveLength(0) + fetch.close() + }) + + test("retries websocket connection limit errors on the next stream attempt", async () => { + let connections = 0 + let messages = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => { + messages += 1 + if (connections === 1) { + socket.send( + JSON.stringify({ + type: "error", + status: 400, + error: { + type: "invalid_request_error", + code: "websocket_connection_limit_reached", + message: "Responses websocket connection limit reached", + }, + }), + ) + return + } + socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_retry" } })) + }) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + }) + + const first = await fetch(server.url, streamRequest()) + expect((await readTextError(first.text())).message).toContain("Responses websocket connection limit reached") + const second = await fetch(server.url, streamRequest()) + const text = await second.text() + + expect(text).not.toContain("websocket_connection_limit_reached") + expect(text).toContain('data: {"type":"response.completed","response":{"id":"resp_retry"}}') + expect(text).toContain("data: [DONE]") + expect(connections).toBe(2) + expect(messages).toBe(2) + expect(server.httpRequests).toHaveLength(0) + fetch.close() + }) + + test("falls back to HTTP after websocket connection limit retries are exhausted", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => { + socket.send( + JSON.stringify({ + type: "error", + status: 400, + error: { + type: "invalid_request_error", + code: "websocket_connection_limit_reached", + message: "Responses websocket connection limit reached", + }, + }), + ) + }) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + streamRetries: 2, + }) + + const first = await fetch(server.url, streamRequest()) + expect((await readTextError(first.text())).message).toContain("Responses websocket connection limit reached") + const second = await fetch(server.url, streamRequest()) + expect((await readTextError(second.text())).message).toContain("Responses websocket connection limit reached") + const third = await fetch(server.url, streamRequest()) + const fourth = await fetch(server.url, streamRequest()) + + expect(await third.text()).toBe("http") + expect(await fourth.text()).toBe("http") + expect(connections).toBe(3) + expect(server.httpRequests).toHaveLength(2) + fetch.close() + }) + + test("shares the websocket retry budget across stream and connection limit failures", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => { + if (connections === 1) { + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" })) + socket.terminate() + return + } + socket.send( + JSON.stringify({ + type: "error", + error: { + code: "websocket_connection_limit_reached", + message: "Responses websocket connection limit reached", + }, + }), + ) + }) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + streamRetries: 1, + }) + + const first = await fetch(server.url, streamRequest()) + expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed") + const second = await fetch(server.url, streamRequest()) + + expect(await second.text()).toBe("http") + expect(connections).toBe(2) + expect(server.httpRequests).toHaveLength(1) + fetch.close() + }) + + test("retries websocket idle failures before first event then falls back to HTTP", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => {}) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + idleTimeout: 20, + streamRetries: 1, + }) + + const first = await fetch(server.url, streamRequest()) + expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket") + const second = await fetch(server.url, streamRequest()) + const third = await fetch(server.url, streamRequest()) + + expect(await second.text()).toBe("http") + expect(await third.text()).toBe("http") + expect(connections).toBe(2) + expect(server.httpRequests).toHaveLength(2) + fetch.close() + }) + + test("retries failed websocket streams before using HTTP fallback", async () => { + await using server = await createWebSocketServer((socket) => { + socket.once("message", () => { + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" })) + }) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + idleTimeout: 20, + streamRetries: 1, + }) + + const first = await fetch(server.url, streamRequest()) + expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket") + const second = await fetch(server.url, streamRequest()) + expect((await readTextError(second.text())).message).toContain("idle timeout waiting for websocket") + const third = await fetch(server.url, streamRequest()) + + expect(await third.text()).toBe("http") + expect(server.httpRequests).toHaveLength(1) + fetch.close() + }) + + test("resets websocket stream failures after a completed response", async () => { + let connections = 0 + let requests = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.on("message", () => { + requests += 1 + if (requests === 1 || requests === 3) { + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" })) + socket.terminate() + return + } + socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${requests}` } })) + }) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + streamRetries: 1, + }) + + const first = await fetch(server.url, streamRequest()) + expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed") + const second = await fetch(server.url, streamRequest()) + expect(await second.text()).toContain("data: [DONE]") + const third = await fetch(server.url, streamRequest()) + expect((await readTextError(third.text())).message).toContain("WebSocket closed before response.completed") + const fourth = await fetch(server.url, streamRequest()) + + expect(await fourth.text()).toContain("data: [DONE]") + expect(connections).toBe(3) + expect(requests).toBe(4) + expect(server.httpRequests).toHaveLength(0) + fetch.close() + }) + + test("falls back to HTTP for missing session and title requests", async () => { + await using server = await createWebSocketServer(() => {}) + const fetch = OpenAIWebSocketPool.createWebSocketFetch() + + const missingSession = await fetch(server.url, { + method: "POST", + headers: { [TITLE_HEADER]: "false" }, + body: JSON.stringify({ stream: true }), + }) + const title = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "true" })) + + expect(await missingSession.text()).toBe("http") + expect(await title.text()).toBe("http") + expect(server.httpRequests).toHaveLength(2) + expect(server.httpRequests[0]?.headers[TITLE_HEADER]).toBeUndefined() + expect(server.httpRequests[1]?.headers[TITLE_HEADER]).toBeUndefined() + fetch.close() + }) + + test("falls back to HTTP while a websocket lane is busy", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => { + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" })) + }) + }) + const abort = new AbortController() + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + }) + + const first = await fetch(server.url, streamRequest({}, abort.signal)) + const firstText = first.text() + await waitFor(() => connections === 1, "websocket did not connect") + const second = await fetch(server.url, streamRequest()) + + expect(await second.text()).toBe("http") + expect(server.httpRequests).toHaveLength(1) + expect(connections).toBe(1) + abort.abort(new Error("stop")) + expect((await readTextError(firstText)).message).toContain("stop") + fetch.close() + }) + + test("reserves a websocket lane while its socket is connecting", async () => { + await using server = await createHangingTcpServer() + await using fallback = await createHttpServer() + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + connectTimeout: 20, + streamRetries: 0, + }) + + const first = fetch(fallback.url, streamRequest()) + await waitFor(() => server.connections() === 1, "first websocket did not begin connecting") + const second = fetch(fallback.url, streamRequest()) + + expect(await (await second).text()).toBe("http") + expect(await (await first).text()).toBe("http") + expect(server.connections()).toBe(1) + expect(fallback.httpRequests).toHaveLength(2) + fetch.close() + }) + + test("retries unexpected closes before first event then falls back to HTTP", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => { + socket.close(1001, "server shutdown") + }) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + streamRetries: 1, + }) + + const first = await fetch(server.url, streamRequest()) + expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed") + const second = await fetch(server.url, streamRequest()) + const third = await fetch(server.url, streamRequest()) + + expect(await second.text()).toBe("http") + expect(await third.text()).toBe("http") + expect(connections).toBe(2) + expect(server.httpRequests).toHaveLength(2) + fetch.close() + }) + + test("does not keep HTTP fallback active after aborting a websocket response", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => { + if (connections === 1) { + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" })) + return + } + socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_456" } })) + }) + }) + const abort = new AbortController() + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + }) + + const first = await fetch(server.url, streamRequest({}, abort.signal)) + const firstText = first.text() + await waitFor(() => connections === 1, "first websocket did not connect") + abort.abort(new Error("stop")) + expect((await readTextError(firstText)).message).toContain("stop") + + const second = await fetch(server.url, streamRequest()) + + expect(await second.text()).toContain("data: [DONE]") + expect(connections).toBe(2) + expect(server.httpRequests).toHaveLength(0) + fetch.close() + }) + + test("releases the websocket lane when the response body is cancelled", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => { + if (connections === 1) { + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" })) + return + } + socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_after_cancel" } })) + }) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + }) + + const first = await fetch(server.url, streamRequest()) + await waitFor(() => connections === 1, "first websocket did not connect") + await first.body!.cancel("stop") + + const second = await fetch(server.url, streamRequest()) + + expect(await second.text()).toContain("data: [DONE]") + expect(connections).toBe(2) + expect(server.httpRequests).toHaveLength(0) + fetch.close() + }) +}) + +function streamRequest(headers?: Record, signal?: AbortSignal): RequestInit { + return { + method: "POST", + headers: { + "session-id": "session-1", + authorization: "Bearer test", + ...headers, + }, + body: JSON.stringify({ stream: true, input: "hi" }), + signal, + } +} + +async function readTextError(promise: Promise) { + // Bun 1.3.14 hangs on expect(response.text()).rejects for streams errored from ws callbacks. + return promise.then( + () => { + throw new Error("Expected response text to reject") + }, + (error) => { + expect(error).toBeInstanceOf(Error) + return error as Error + }, + ) +} + +async function createWebSocketServer(onConnection: (socket: WebSocket, request: IncomingMessage) => void) { + const http = await createHttpServer() + const server = new WebSocketServer({ server: http.server }) + server.on("connection", onConnection) + return websocketServerHandle(server, http) +} + +async function createHangingTcpServer() { + const sockets = new Set() + let connections = 0 + const server = net.createServer((socket) => { + connections += 1 + sockets.add(socket) + socket.on("close", () => sockets.delete(socket)) + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() as AddressInfo + return { + url: `http://127.0.0.1:${address.port}/v1/responses`, + wsUrl: `ws://127.0.0.1:${address.port}/v1/responses`, + connections: () => connections, + async [Symbol.asyncDispose]() { + for (const socket of sockets) socket.destroy() + server.close() + }, + } +} + +async function createRejectingWebSocketServer(onAttempt: () => void) { + const http = await createHttpServer() + const server = new WebSocketServer({ + server: http.server, + verifyClient(_info, callback) { + onAttempt() + callback(false, 401, "denied") + }, + }) + return websocketServerHandle(server, http) +} + +async function createHttpServer() { + const httpRequests: IncomingMessage[] = [] + const server = createServer((request, response) => { + httpRequests.push(request) + response.writeHead(200, { "content-type": "text/plain" }) + response.end("http") + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() as AddressInfo + return { + server, + httpRequests, + url: `http://127.0.0.1:${address.port}/v1/responses`, + async [Symbol.asyncDispose]() { + await closeHttpServer(server) + }, + } +} + +function websocketServerHandle(server: WebSocketServer, http: Awaited>) { + return { + url: http.url, + wsUrl: http.url.replace(/^http/, "ws"), + httpRequests: http.httpRequests, + async [Symbol.asyncDispose]() { + for (const socket of server.clients) socket.terminate() + server.close() + http.server.close() + }, + } +} + +function closeHttpServer(server: HttpServer) { + return new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) +} + +async function waitFor(predicate: () => boolean, message: string) { + const started = Date.now() + while (!predicate()) { + if (Date.now() - started > 1_000) throw new Error(message) + await new Promise((resolve) => setTimeout(resolve, 1)) + } +} diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 688b818bee..fedf98371e 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -214,6 +214,22 @@ describe("Worktree", () => { { git: true }, ) + it.instance( + "lists the active linked worktree but not the project checkout", + () => + withCreatedWorktree(undefined, ({ info }) => + Effect.gen(function* () { + const test = yield* TestInstance + const svc = yield* Worktree.Service + const list = yield* svc.list().pipe(provideInstance(info.directory)) + + expect(list.map((item) => item.name)).toContain(info.name) + expect(list.map((item) => item.name)).not.toContain(path.basename(test.directory).toLowerCase()) + }), + ), + { git: true }, + ) + it.instance( "create with custom name", () => diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts index 145a77280a..543bb5f30d 100644 --- a/packages/opencode/test/provider/header-timeout.test.ts +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -10,6 +10,7 @@ import { testProviderConfig } from "../lib/test-provider" import { Env } from "@/env" import { Plugin } from "@/plugin" import { Provider } from "@/provider/provider" +import { ProviderError } from "@/provider/error" import { ModelID, ProviderID } from "@/provider/schema" afterEach(async () => { @@ -58,6 +59,35 @@ it.live("headerTimeout does not abort delayed SSE body after headers arrive", () ), ) +it.live("chunkTimeout raises a response stream error when SSE body stalls", () => + provideTmpdirServer( + ({ llm }) => + Effect.gen(function* () { + yield* llm.push(reply().wait(Bun.sleep(250)).text("late").stop()) + + const provider = yield* Provider.Service + const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model")) + const result = streamText({ + model: yield* provider.getLanguage(model), + onError() {}, + messages: [{ role: "user", content: "hello" }], + }) + + const error = yield* Effect.promise(async () => { + try { + for await (const part of result.fullStream) { + if (part.type === "error") return part.error + } + } catch (error) { + return error + } + }) + expect(error).toBeInstanceOf(ProviderError.ResponseStreamError) + }), + { config: (url) => providerConfig(url, { chunkTimeout: 50 }) }, + ), +) + it.live("headerTimeout aborts when response headers do not arrive", () => Effect.gen(function* () { const server = yield* Effect.acquireRelease( diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 2bce158560..ae99d3f10e 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3223,6 +3223,12 @@ describe("ProviderTransform.variants", () => { efforts: ["low", "medium", "high", "xhigh", "max"], expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, }, + { + name: "opus 4.8", + apiIds: ["claude-opus-4-8", "claude-opus-4.8"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, ]) { for (const apiId of testCase.apiIds) { test(`${testCase.name} ${apiId} returns supported reasoning efforts`, () => { @@ -3292,6 +3298,30 @@ describe("ProviderTransform.variants", () => { }) }) + describe("@ai-sdk/google-vertex/anthropic", () => { + test("opus 4.8 uses adaptive reasoning for Vertex model IDs", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "google-vertex-anthropic/claude-opus-4-8@default", + providerID: "google-vertex-anthropic", + api: { + id: "claude-opus-4-8@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", () => { test("anthropic sonnet 4.6 returns adaptive reasoning options", () => { const model = createMockModel({ @@ -3341,6 +3371,28 @@ describe("ProviderTransform.variants", () => { }) }) + test("anthropic opus 4.8 returns adaptive reasoning options with xhigh", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "bedrock/anthropic-claude-opus-4.8", + providerID: "bedrock", + api: { + id: "anthropic.claude-opus-4.8", + 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", diff --git a/packages/opencode/test/pty/info-schema.test.ts b/packages/opencode/test/pty/info-schema.test.ts new file mode 100644 index 0000000000..429f29b00e --- /dev/null +++ b/packages/opencode/test/pty/info-schema.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { Pty } from "../../src/pty" + +// Windows ConPTY (via @lydell/node-pty >= 1.2.0-beta.12) assigns the child pid +// asynchronously: `proc.pid` reads back as 0 at the synchronous spawn point and +// only resolves to the real pid a tick later. `Pty.create` snapshots `proc.pid` +// while building `Info`, so `Info.pid` legitimately carries 0 right after spawn. +// `Pty.Info` must be able to represent that, otherwise every `pty.create` on +// Windows fails to encode/decode and the terminal feature is unusable. +const sample = (pid: number) => ({ + id: "pty_01J5Y5H0AH4Q4NXJ6P4C3P5V2K", + title: "demo", + command: "cmd.exe", + args: [], + cwd: "C:\\", + status: "running", + pid, +}) + +describe("Pty.Info", () => { + test("accepts pid 0 (Windows ConPTY assigns the pid asynchronously)", () => { + expect(Schema.decodeUnknownSync(Pty.Info)(sample(0)).pid).toBe(0) + }) + + test("accepts a positive pid", () => { + expect(Schema.decodeUnknownSync(Pty.Info)(sample(48012)).pid).toBe(48012) + }) + + test("rejects a negative pid", () => { + expect(() => Schema.decodeUnknownSync(Pty.Info)(sample(-1))).toThrow() + }) +}) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 5c8f3fb24e..db06a7a77c 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" import { Cause, Effect, Exit, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Flag } from "@opencode-ai/core/flag/flag" import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" @@ -27,7 +28,9 @@ import * as DateTime from "effect/DateTime" import * as Log from "@opencode-ai/core/util/log" import { eq } from "drizzle-orm" import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, TestInstance } from "../fixture/fixture" +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" void Log.init({ print: false }) @@ -368,6 +371,45 @@ describe("session HttpApi", () => { { git: true, config: { formatter: false, lsp: false } }, ) + it.live("uses the persisted session directory for prompt requests", () => + Effect.gen(function* () { + const llm = yield* TestLLMServer + yield* llm.text("ok", { usage: { input: 1, output: 1 } }) + + const config = testProviderConfig(llm.url) + const sessionDirectory = yield* tmpdirScoped({ git: true, config }) + const requestDirectory = yield* tmpdirScoped({ git: true, config }) + const session = yield* createSession({ title: "directory regression" }).pipe( + provideInstanceEffect(sessionDirectory), + ) + + const response = yield* request( + `${pathFor(SessionPaths.prompt, { sessionID: session.id })}?directory=${encodeURIComponent(requestDirectory)}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "which directory?" }], + }), + }, + ) + + expect(response.status).toBe(200) + yield* responseJson(response) + + const messages = yield* Session.use + .messages({ sessionID: session.id }) + .pipe(provideInstanceEffect(sessionDirectory), Effect.orDie) + const assistant = messages.find((message) => message.info.role === "assistant") + expect(assistant?.info.role === "assistant" ? assistant.info.path : undefined).toEqual({ + cwd: sessionDirectory, + root: sessionDirectory, + }) + }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + ) + it.instance( "returns v2 public request errors for cursor and workspace query failures", () => diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 1ffa0d2005..72e227d115 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -406,6 +406,20 @@ describe("HttpApi UI fallback", () => { }), ) + it.live("accepts basic auth passwords containing colons for the web UI", () => + Effect.gen(function* () { + const response = yield* uiApp({ + password: "sec:ret", + username: "opencode", + disableEmbeddedWebUi: true, + }).request("/", { + headers: { authorization: `Basic ${btoa("opencode:sec:ret")}` }, + }) + + expect(response.status).toBe(200) + }), + ) + // Regression for #25698 (Ope): the browser fetches the PWA manifest and // its icons via flows that don't carry app-managed credentials (the // `` request is not under page-auth control), so the diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index bc7074e23c..50a400674a 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -1,6 +1,6 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { describe, expect } from "bun:test" -import { Context, Effect, Layer, Queue, Ref, Schema } from "effect" +import { Context, Effect, Layer, Queue, Ref, Schema, Stream } from "effect" import { FetchHttpClient, HttpClient, @@ -64,6 +64,7 @@ type ProxiedRequest = { url: string method: string headers: Record + body: string } type TestHandler = ( @@ -181,7 +182,12 @@ const startRemoteWorkspaceHttpServer = ( // everything else is the request being proxied by the middleware. const sync = syncResponse(request) if (sync) return yield* sync - return yield* handler({ url: request.url, method: request.method, headers: request.headers }) + return yield* handler({ + url: request.url, + method: request.method, + headers: request.headers, + body: yield* request.text, + }) }), ) @@ -285,13 +291,18 @@ describe("HttpApi workspace routing middleware", () => { // should make the middleware call HttpApiProxy.http instead. yield* serveProbe + const body = '{"title":"Remote workspace request"}' const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe( HttpClientRequest.setHeaders({ - "content-type": "application/json", "x-opencode-directory": "/secret/path", "x-opencode-workspace": "internal", }), + HttpClientRequest.bodyStream( + Stream.make(new TextEncoder().encode('{"title":"Remote '), new TextEncoder().encode('workspace request"}')), + { contentType: "application/json" }, + ), HttpClient.execute, + Effect.timeout("2 seconds"), ) expect(response.status).toBe(201) @@ -304,6 +315,7 @@ describe("HttpApi workspace routing middleware", () => { expect(forwardedURL?.searchParams.get("keep")).toBe("yes") expect(forwardedURL?.searchParams.get("workspace")).toBeNull() expect(forwarded?.method).toBe("PATCH") + expect(forwarded?.body).toBe(body) expect(forwarded?.headers["content-type"]).toBe("application/json") expect(forwarded?.headers["x-target-auth"]).toBe("secret") expect(forwarded?.headers["x-opencode-directory"]).toBeUndefined() diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index e1c6e5701d..35d6cb313e 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -479,6 +479,16 @@ describe("workspace HttpApi", () => { method: "POST", }), ]) + + const aborted = yield* request(`http://localhost/session/${session.id}/abort`, dir, { method: "POST" }) + expect(aborted.status).toBe(200) + expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/abort`)).toEqual([ + expect.objectContaining({ + url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/abort`, + method: "POST", + body: "", + }), + ]) } finally { void remote.stop(true) yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) diff --git a/packages/opencode/test/server/workspace-proxy.test.ts b/packages/opencode/test/server/workspace-proxy.test.ts index 732f2560a2..869570f62d 100644 --- a/packages/opencode/test/server/workspace-proxy.test.ts +++ b/packages/opencode/test/server/workspace-proxy.test.ts @@ -112,6 +112,22 @@ describe("HttpApi workspace proxy", () => { }), ) + it.live("proxies bodyless Web mutation requests as an empty body", () => + Effect.gen(function* () { + const url = yield* listenServer( + Effect.fnUntraced(function* (req: HttpServerRequest.HttpServerRequest) { + return yield* HttpServerResponse.json({ method: req.method, body: yield* req.text }) + }), + ) + const request = HttpServerRequest.fromWeb(new Request("http://localhost/session/abc/abort", { method: "POST" })) + const httpClient = yield* HttpClient.HttpClient + const response = yield* HttpApiProxy.http(httpClient, `${url}/session/abc/abort`, undefined, request) + + expect(response.status).toBe(200) + expect(yield* HttpServerResponse.toClientResponse(response).json).toEqual({ method: "POST", body: "" }) + }), + ) + it.live("strips opencode-internal headers and merges extra headers", () => Effect.gen(function* () { let forwarded: Record = {} diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 26c55bde71..e1cbec0364 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -172,6 +172,17 @@ describe("session.retry.retryable", () => { }) }) + test("retries websocket stream transport errors", () => { + const request = MessageV2.fromError( + new ProviderError.ResponseStreamError("WebSocket closed before response.completed (code 1006: Connection ended)"), + { providerID }, + ) + expect(MessageV2.APIError.isInstance(request)).toBe(true) + expect(SessionRetry.retryable(request, retryProvider)).toEqual({ + message: "WebSocket closed before response.completed (code 1006: Connection ended)", + }) + }) + test("does not retry context overflow errors", () => { const error = new MessageV2.ContextOverflowError({ message: "Input exceeds context window of this model", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d002e0fcd9..3b3a8973d1 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.15.11", + "version": "1.15.12", "type": "module", "license": "MIT", "scripts": { @@ -22,9 +22,9 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.2.15", - "@opentui/keymap": ">=0.2.15", - "@opentui/solid": ">=0.2.15" + "@opentui/core": ">=0.2.16", + "@opentui/keymap": ">=0.2.16", + "@opentui/solid": ">=0.2.16" }, "peerDependenciesMeta": { "@opentui/core": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index f9ee8870e5..96cc096823 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.15.11", + "version": "1.15.12", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index ebcb4271c4..f393b94849 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1960,6 +1960,13 @@ export type Workspace = { timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } +export type WorkspaceCreateError = { + name: "WorkspaceCreateError" + data: { + message: string + } +} + export type WorkspaceWarpError = { name: "WorkspaceWarpError" data: { @@ -8042,9 +8049,9 @@ export type ExperimentalWorkspaceCreateData = { export type ExperimentalWorkspaceCreateErrors = { /** - * BadRequest | InvalidRequestError + * WorkspaceCreateError | BadRequest | InvalidRequestError */ - 400: EffectHttpApiErrorBadRequest | InvalidRequestError + 400: WorkspaceCreateError | EffectHttpApiErrorBadRequest | InvalidRequestError } export type ExperimentalWorkspaceCreateError = diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index bbd8b9dd5d..53b8966fa6 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10038,11 +10038,14 @@ } }, "400": { - "description": "BadRequest | InvalidRequestError", + "description": "WorkspaceCreateError | BadRequest | InvalidRequestError", "content": { "application/json": { "schema": { "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceCreateError" + }, { "$ref": "#/components/schemas/effect_HttpApiError_BadRequest" }, @@ -11592,7 +11595,7 @@ }, "pid": { "type": "integer", - "exclusiveMinimum": 0 + "minimum": 0 } }, "required": ["id", "title", "command", "args", "cwd", "status", "pid"], @@ -16237,6 +16240,27 @@ "required": ["id", "type", "name", "projectID", "timeUsed"], "additionalProperties": false }, + "WorkspaceCreateError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["WorkspaceCreateError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, "WorkspaceWarpError": { "type": "object", "properties": { diff --git a/packages/slack/package.json b/packages/slack/package.json index ce0082af90..282c9209c5 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.15.11", + "version": "1.15.12", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/AGENTS.md b/packages/stats/AGENTS.md new file mode 100644 index 0000000000..409271cf09 --- /dev/null +++ b/packages/stats/AGENTS.md @@ -0,0 +1 @@ +To start the stats site locally, run `bun dev:stats` from the repo root. diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index 4e0ee95692..14e5264d13 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.15.11", + "version": "1.15.12", "private": true, "type": "module", "license": "MIT", @@ -12,6 +12,7 @@ "start": "vite start" }, "dependencies": { + "@ibm/plex": "6.4.1", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -20,6 +21,7 @@ "d3-scale": "4.0.2", "effect": "catalog:", "nitro": "3.0.1-alpha.1", + "sst": "catalog:", "solid-js": "catalog:", "vite": "catalog:" }, diff --git a/packages/stats/app/src/app.tsx b/packages/stats/app/src/app.tsx index 3a356119b0..1d46cb782f 100644 --- a/packages/stats/app/src/app.tsx +++ b/packages/stats/app/src/app.tsx @@ -7,8 +7,8 @@ import "./app.css" function AppMeta() { return ( <> - opencode stats - + OpenCode Stats + ) } @@ -16,6 +16,7 @@ function AppMeta() { export default function App() { return ( ( diff --git a/packages/stats/app/src/asset/unfurl-rankings.png b/packages/stats/app/src/asset/unfurl-rankings.png new file mode 100644 index 0000000000..872cb6073d Binary files /dev/null and b/packages/stats/app/src/asset/unfurl-rankings.png differ diff --git a/packages/stats/app/src/entry-server.tsx b/packages/stats/app/src/entry-server.tsx index 34fb5af265..f1e054cfa0 100644 --- a/packages/stats/app/src/entry-server.tsx +++ b/packages/stats/app/src/entry-server.tsx @@ -1,6 +1,17 @@ // @refresh reload import { createHandler, StartServer } from "@solidjs/start/server" +const statsThemePreloadScript = `;(function () { + var preference = "system" + try { + var stored = localStorage.getItem("opencode:stats-theme") + if (stored === "dark" || stored === "light" || stored === "system") preference = stored + } catch (_) {} + document.documentElement.dataset.statsTheme = preference + if (preference === "system") document.documentElement.style.removeProperty("color-scheme") + else document.documentElement.style.setProperty("color-scheme", preference) +})()` + export default createHandler( () => ( + {assets} diff --git a/packages/stats/app/src/resource.d.ts b/packages/stats/app/src/resource.d.ts new file mode 100644 index 0000000000..098ea852ad --- /dev/null +++ b/packages/stats/app/src/resource.d.ts @@ -0,0 +1,10 @@ +import "sst/resource" + +declare module "sst/resource" { + export interface Resource { + EMAILOCTOPUS_API_KEY: { + type: "sst.sst.Secret" + value: string + } + } +} diff --git a/packages/stats/app/src/routes/api/newsletter.ts b/packages/stats/app/src/routes/api/newsletter.ts new file mode 100644 index 0000000000..50485fb485 --- /dev/null +++ b/packages/stats/app/src/routes/api/newsletter.ts @@ -0,0 +1,29 @@ +import { Resource } from "sst/resource" + +const listId = "8b9bb82c-9d5f-11f0-975f-0df6fd1e4945" + +export async function POST(event: { request: Request }) { + const contentType = event.request.headers.get("content-type") ?? "" + if (!contentType.includes("multipart/form-data") && !contentType.includes("application/x-www-form-urlencoded")) { + return Response.json({ error: "Email address is required" }, { status: 400 }) + } + + const form = await event.request.formData() + const emailAddress = form.get("email") + if (typeof emailAddress !== "string" || emailAddress.trim().length === 0) { + return Response.json({ error: "Email address is required" }, { status: 400 }) + } + + const response = await fetch(`https://api.emailoctopus.com/lists/${listId}/contacts`, { + method: "PUT", + headers: { + Authorization: `Bearer ${Resource.EMAILOCTOPUS_API_KEY.value}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email_address: emailAddress.trim(), + }), + }) + if (!response.ok) return Response.json({ error: "Failed to subscribe" }, { status: 502 }) + return Response.json({ success: true }) +} diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index 2e292346d8..d8aee2d6ac 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -1,4 +1,49 @@ +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Regular-Latin1.woff2") format("woff2"); + unicode-range: + U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E, + U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2") format("woff2"); + unicode-range: + U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E, + U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-SemiBold-Latin1.woff2") format("woff2"); + unicode-range: + U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E, + U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Bold-Latin1.woff2") format("woff2"); + unicode-range: + U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E, + U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02; +} + [data-page="stats"] { + color-scheme: light; --color-background: #ffffff; --color-background-weak: #fafafa; --color-background-weak-hover: #eeeeee; @@ -17,17 +62,24 @@ --stats-text: #161616; --stats-muted: #5c5c5c; --stats-faint: #808080; + --stats-theme-icon-active: #3a3a3a; --stats-accent: #3b5cf6; --stats-accent-text: #6c7dff; --stats-bar-idle: #d4d4d4; --stats-dot: #d4d4d4; + --stats-hero-muted: #5c5c5c; + --stats-hero-pattern: #eeeeee; + --stats-logo-bg: #161616; + --stats-logo-fill: #454545; + --stats-logo-stroke: #e2e2e2; --stats-page-padding: 5rem; --stats-section-padding: 6rem; min-height: 100vh; display: flex; flex-direction: column; - gap: 4rem; - padding-bottom: 5rem; + font-synthesis: none; + overflow-x: clip; + padding-bottom: 0; background: var(--stats-bg); } @@ -39,139 +91,643 @@ } [data-page="stats"] [data-component="container"] { - max-width: 67.5rem; + box-sizing: border-box; + width: 100%; + max-width: 80rem; margin: 0 auto; - border-left: 1px solid var(--stats-line); - border-right: 1px solid var(--stats-line); } [data-page="stats"] [data-component="top"] { position: sticky; top: 0; z-index: 10; - display: flex; - align-items: center; - justify-content: space-between; - height: 80px; - min-height: 80px; - padding: 24px var(--stats-page-padding); - color: var(--stats-muted); + box-sizing: border-box; + width: 100%; + color: var(--stats-text); background: var(--stats-bg); - border-bottom: 1px solid var(--stats-line); font-family: "IBM Plex Mono", var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); } +[data-page="stats"] [data-component="top"] * { + box-sizing: border-box; +} + +[data-page="stats"] [data-slot="header-bar"] { + display: flex; + align-items: center; + gap: 16px; + min-height: 72px; + padding: 20px 20px 20px 24px; + overflow: hidden; +} + [data-page="stats"] [data-component="top"] a { - color: var(--stats-text); - font-size: 14px; - line-height: 18px; text-decoration: none; } [data-page="stats"] [data-component="top"] a:hover { - text-decoration: underline; - text-underline-offset: 4px; + text-decoration: none; } [data-page="stats"] [data-slot="brand"] { + flex: 0 0 auto; + min-width: 0; display: flex; align-items: center; + margin-right: auto; + color: var(--stats-text); } -[data-page="stats"] [data-slot="brand"] img { - width: auto; - height: 34px; +[data-page="stats"] [data-slot="stats-wordmark"] { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 12px; } -[data-page="stats"] [data-slot="logo dark"] { +[data-page="stats"] [data-slot="brand-mark"] { + display: block; + width: 19px; + height: 24px; +} + +[data-page="stats"] [data-slot="brand-label"] { + display: block; + width: 50.851px; + height: 14px; +} + +[data-page="stats"] [data-component="section-nav"] { display: none; + align-items: center; + justify-content: center; + min-width: 0; } -[data-page="stats"] [data-component="nav-desktop"] ul { +[data-page="stats"] [data-component="section-nav"] ul { display: flex; align-items: center; - gap: 32px; margin: 0; padding: 0; list-style: none; } -[data-page="stats"] [data-component="nav-desktop"] a span { - color: var(--stats-faint); -} - -[data-page="stats"] [data-component="nav-desktop"] [data-slot="cta-button"] { +[data-page="stats"] [data-component="section-nav"] a { display: flex; align-items: center; + justify-content: center; + height: 32px; + padding: 0 15px; + color: var(--stats-muted); + font-size: 13px; + line-height: 1; + white-space: nowrap; +} + +[data-page="stats"] [data-component="section-nav"] a:hover { + color: var(--stats-text); +} + +[data-page="stats"] [data-slot="header-actions"] { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: flex-end; gap: 8px; - padding: 8px 16px 8px 10px; - border-radius: 4px; - background: var(--color-background-strong); - color: var(--color-text-inverted); +} + +[data-page="stats"] [data-slot="header-button"], +[data-page="stats"] [data-slot="menu-button"] { + position: relative; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + overflow: hidden; + margin: 0; + border: 0; + border-radius: 0; + appearance: none; + font: inherit; + font-size: 13px; + line-height: 1.1; + cursor: pointer; +} + +[data-page="stats"] [data-slot="header-button"]::before, +[data-page="stats"] [data-slot="menu-button"]::before { + position: absolute; + top: 0; + right: 0; + left: 0; + height: 16px; + pointer-events: none; + content: ""; + background: linear-gradient(180deg, #ffffff00 0%, #ffffff12 100%); +} + +[data-page="stats"] [data-slot="header-button"] { + padding: 0 12px; +} + +[data-page="stats"] [data-slot="header-button"] strong, +[data-page="stats"] [data-slot="header-button"] span, +[data-page="stats"] [data-slot="menu-button"] svg { + position: relative; + z-index: 1; +} + +[data-page="stats"] [data-slot="header-button"] strong { font-weight: 500; white-space: nowrap; } -[data-page="stats"] [data-component="nav-desktop"] [data-slot="cta-button"]:hover { - background: var(--color-background-strong-hover); - text-decoration: none; +[data-page="stats"] [data-slot="header-button"][data-variant="neutral"] { + display: none; + gap: 6px; + color: #161616; + background: #ffffff; + box-shadow: + 0 0 0 0 #00000024, + 0 0 0 0.5px #00000024, + 0 1px 1.5px 0 #0000001a; +} + +[data-page="stats"] [data-slot="header-button"][data-variant="neutral"] span { + color: #5c5c5c; + font-weight: 400; + font-variant-numeric: tabular-nums; +} + +[data-page="stats"] [data-slot="header-button"][data-variant="contrast"] { + gap: 8px; + color: #ffffff; + background: #242424; + box-shadow: + 0 0 0 0 #00000000, + 0 0 0 0.5px #3a3a3a, + 0 1px 1.5px 0 #00000033, + inset 0 -1px 2px 0 #0000000f, + inset 0 1px 2px 0 #ffffff24; +} + +[data-page="stats"] [data-slot="menu-button"] { + display: inline-flex; + width: 32px; + padding: 0; + color: #3a3a3a; + background: #ffffff; + box-shadow: + 0 0 0 0 #00000024, + 0 0 0 0.5px #00000024, + 0 1px 1.5px 0 #0000001a; +} + +[data-page="stats"] [data-slot="header-button"]:focus-visible, +[data-page="stats"] [data-slot="menu-button"]:focus-visible, +[data-page="stats"] [data-component="section-nav"] a:focus-visible, +[data-page="stats"] [data-slot="mobile-menu-item"]:focus-visible, +[data-page="stats"] [data-slot="brand"]:focus-visible { + outline: 2px solid var(--stats-accent); + outline-offset: 2px; +} + +[data-page="stats"] [data-slot="mobile-menu"] { + position: fixed; + top: 72px; + right: 0; + bottom: 0; + left: 0; + z-index: 9; + display: none; + overflow: auto; + background: var(--stats-bg); + border-top: 1px solid var(--stats-line); +} + +[data-page="stats"] [data-menu-open="true"] [data-slot="mobile-menu"]:not([hidden]) { + display: block; +} + +[data-page="stats"] [data-slot="mobile-menu-item"] { + display: flex; + align-items: center; + gap: 12px; + min-height: 65px; + padding: 24px; + color: var(--stats-text); + border-bottom: 1px solid var(--stats-line); + font-size: 13px; + line-height: 16px; + white-space: nowrap; +} + +[data-page="stats"] [data-slot="mobile-menu-item"]:hover { + color: var(--stats-text); + background: var(--stats-layer); +} + +[data-page="stats"] [data-slot="mobile-menu-item"] strong { + font-weight: 400; +} + +[data-page="stats"] [data-slot="mobile-menu-item"] span { + min-width: 0; + overflow: hidden; + color: var(--stats-muted); + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; } [data-page="stats"] [data-component="footer"] { - display: flex; - border-top: 1px solid var(--stats-line); + position: relative; + display: grid; + gap: 56px; + box-sizing: border-box; + min-height: 0; + padding: 112px clamp(32px, 4vw, 48px) 24px; color: var(--stats-text); font-family: "IBM Plex Mono", var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); - font-size: 14px; + font-size: 11px; } -[data-page="stats"] [data-component="footer"] [data-slot="cell"] { - flex: 1; - text-align: center; +[data-page="stats"] [data-slot="footer-grid"] { + display: grid; + grid-template-columns: 120px repeat(4, minmax(0, 1fr)); + gap: 72px; + align-items: start; } -[data-page="stats"] [data-component="footer"] [data-slot="cell"] + [data-slot="cell"] { - border-left: 1px solid var(--stats-line); -} - -[data-page="stats"] [data-component="footer"] a { +[data-page="stats"] [data-slot="footer-mark"] { display: block; - width: 100%; - padding: 2rem 0; + width: fit-content; + color: var(--stats-text); +} + +[data-page="stats"] [data-slot="footer-mark"] [data-slot="opencode-mark"] { + display: block; + width: 40px; + height: 40px; +} + +[data-page="stats"] [data-slot="footer-column"] { + display: grid; + align-content: start; + gap: 18px; + min-width: 0; +} + +[data-page="stats"] [data-slot="footer-column"] h2 { + color: var(--stats-text); + font-size: 11px; + font-weight: 500; + line-height: 1; + letter-spacing: 0; +} + +[data-page="stats"] [data-slot="footer-column"] nav { + display: grid; + gap: 12px; +} + +[data-page="stats"] [data-slot="footer-column"] a, +[data-page="stats"] [data-slot="footer-column"] p { + color: var(--stats-text); + font-size: 11px; + font-weight: 400; + line-height: 1.5; + text-decoration: none; +} + +[data-page="stats"] [data-slot="footer-column"] a, +[data-page="stats"] [data-slot="footer-column"] p, +[data-page="stats"] [data-slot="footer-bottom"] { + color: var(--stats-muted); +} + +[data-page="stats"] [data-slot="footer-column"] a:hover { color: var(--stats-text); text-decoration: none; } -[data-page="stats"] [data-component="footer"] a:hover { - background: var(--stats-layer); - text-decoration: underline; - text-underline-offset: 4px; +[data-page="stats"] [data-slot="subscribe-button"] { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + height: 28px; + margin: 0; + padding: 0 12px; + border: 1px solid var(--stats-line); + border-radius: 0; + appearance: none; + background: var(--stats-bg); + color: var(--stats-text) !important; + font: inherit; + font-size: 11px; + font-weight: 500; + line-height: 1; + text-decoration: none; + cursor: pointer; } -[data-page="stats"] [data-component="legal"] { - display: flex; - justify-content: center; - gap: 32px; - color: var(--stats-faint); - text-align: center; +[data-page="stats"] [data-slot="subscribe-button"]:hover { + border-color: var(--stats-line-strong); + background: var(--stats-layer); +} + +[data-page="stats"] [data-slot="subscribe-button"]:focus-visible, +[data-page="stats"] [data-slot="theme-option"]:focus-visible, +[data-page="stats"] [data-component="subscribe-modal"] button:focus-visible, +[data-page="stats"] [data-component="subscribe-modal"] input:focus-visible { + outline: 2px solid var(--stats-accent); + outline-offset: 2px; +} + +[data-page="stats"] [data-component="subscribe-modal"] { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + box-sizing: border-box; + padding: 8px; + color: var(--stats-text); font-family: "IBM Plex Mono", var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); - font-size: 14px; } -[data-page="stats"] [data-component="legal"] a { +[data-page="stats"] [data-slot="modal-scrim"] { + position: absolute; + inset: 0; + background: #00000066; + backdrop-filter: blur(2px); +} + +[data-page="stats"] [data-slot="modal-panel"] { + position: relative; + z-index: 1; + width: min(374px, calc(100vw - 16px)); + max-height: calc(100dvh - 16px); + overflow: auto; + background: #fafafa; + box-shadow: + 0 0 0 0.5px #0000001f, + 0 4px 8px #00000014, + 0 8px 16px #0000000a; +} + +[data-page="stats"] [data-slot="modal-brand"] { + position: relative; + display: flex; + align-items: center; + justify-content: center; + height: 122px; + background: #242424; + color: #ffffff; +} + +[data-page="stats"] [data-slot="modal-logo"] { + display: block; + width: 234px; + height: 42px; +} + +[data-page="stats"] [data-slot="modal-close"] { + position: absolute; + top: 0; + right: 0; + display: grid; + place-items: center; + width: 32px; + height: 32px; + margin: 0; + padding: 0; + border: 0; + border-radius: 0; + appearance: none; + background: transparent; + color: #808080; + cursor: pointer; +} + +[data-page="stats"] [data-slot="modal-close"]:hover { + background: #ffffff1a; +} + +[data-page="stats"] [data-slot="modal-body"] { + display: grid; + gap: 24px; + padding: 24px 12px 12px; + background: #fafafa; +} + +[data-page="stats"] [data-slot="modal-intro"] { + display: grid; + gap: 4px; + text-align: center; +} + +[data-page="stats"] [data-slot="modal-intro"] h2 { + color: #161616; + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0; +} + +[data-page="stats"] [data-slot="modal-intro"] p { + color: #5c5c5c; + font-size: 16px; + font-weight: 400; + line-height: 24px; +} + +[data-page="stats"] [data-slot="subscribe-form"] { + display: grid; + gap: 12px; +} + +[data-page="stats"] [data-slot="subscribe-form"] input, +[data-page="stats"] [data-slot="subscribe-form"] button { + box-sizing: border-box; + width: 100%; + height: 40px; + margin: 0; + border-radius: 0; + appearance: none; + font: inherit; + font-size: 13px; + line-height: 1; +} + +[data-page="stats"] [data-slot="subscribe-form"] input { + padding: 0 12px; + border: 0; + background: #ffffff; + color: #161616; + box-shadow: + 0 0 0 0.5px #00000024, + 0 1px 1.5px #0000001a; +} + +[data-page="stats"] [data-slot="subscribe-form"] input::placeholder { + color: #808080; +} + +[data-page="stats"] [data-slot="subscribe-form"] button { + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + border: 0; + background: #242424; + color: #ffffff; + font-weight: 500; + cursor: pointer; + box-shadow: + 0 0 0 0.5px #3a3a3a, + 0 1px 1.5px #00000033, + inset 0 -1px 2px #0000000f, + inset 0 1px 2px #ffffff24; +} + +[data-page="stats"] [data-slot="subscribe-form"] button::before { + position: absolute; + top: 0.25px; + right: 0; + left: 0; + height: 20px; + pointer-events: none; + content: ""; + background: linear-gradient(180deg, #ffffff00 0%, #ffffff12 100%); +} + +[data-page="stats"] [data-slot="subscribe-form"] button span { + position: relative; +} + +[data-page="stats"] [data-slot="subscribe-form"] button:disabled { + cursor: progress; + opacity: 0.7; +} + +[data-page="stats"] [data-slot="subscribe-feedback"]:empty { + display: none; +} + +[data-page="stats"] [data-slot="subscribe-feedback"] p { + text-align: center; + font-size: 11px; + font-weight: 500; + line-height: 16px; +} + +[data-page="stats"] [data-slot="subscribe-feedback"] p[data-state="success"] { + color: #198b43; +} + +[data-page="stats"] [data-slot="subscribe-feedback"] p[data-state="error"] { + color: #b82d35; +} + +[data-page="stats"] [data-slot="footer-pattern"] { + height: 16px; + overflow: hidden; + background: var(--stats-hero-pattern); + mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E"); + mask-position: center top; + mask-repeat: repeat; + mask-size: 6px 6px; + -webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E"); + -webkit-mask-position: center top; + -webkit-mask-repeat: repeat; + -webkit-mask-size: 6px 6px; +} + +[data-page="stats"] [data-slot="footer-bottom"], +[data-page="stats"] [data-slot="footer-bottom"] > div { + display: flex; + align-items: center; +} + +[data-page="stats"] [data-slot="footer-bottom"] { + justify-content: space-between; + gap: 24px; + font-size: 11px; + line-height: 1; +} + +[data-page="stats"] [data-slot="footer-bottom"] > div:first-child { + gap: 24px; +} + +[data-page="stats"] [data-slot="theme-toggle"] { + display: flex; + align-items: center; + gap: 1px; + padding: 1px; + background: var(--stats-layer-2); +} + +[data-page="stats"] [data-slot="theme-option"] { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 20px; + margin: 0; + padding: 0; + border: 0; + border-radius: 0; + appearance: none; + background: transparent; color: var(--stats-faint); - text-decoration: none; + font: inherit; + font-size: 11px; + line-height: 1; + cursor: pointer; } -[data-page="stats"] [data-component="legal"] a:hover { - color: var(--stats-muted); - text-decoration: underline; +[data-page="stats"] [data-slot="theme-icon"] { + display: block; + width: 16px; + height: 16px; + pointer-events: none; +} + +[data-page="stats"] [data-slot="theme-option"]:hover { + color: var(--stats-text); +} + +[data-page="stats"] [data-slot="theme-option"][aria-pressed="true"] { + background: var(--stats-bg); + color: var(--stats-theme-icon-active); + box-shadow: + 0 0 0 0 #0000, + 0 0 0 0.5px #0000001f, + 0 1px 2px -1px #00000014, + 0 2px 4px 0 #0000000a; +} + +[data-page="stats"] [data-slot="status"] { + display: flex; + align-items: center; + gap: 10px; +} + +[data-page="stats"] [data-slot="status"]::before { + content: ""; + width: 6px; + height: 6px; + background: #198b43; } [data-page="stats"] [data-component="content"] a { @@ -184,19 +740,26 @@ text-underline-offset: 4px; } -[data-page="stats"] [data-section="hero"], -[data-page="stats"] [data-section="chart"], -[data-page="stats"] [data-section="newsletter"] { +[data-page="stats"] [data-section="chart"] { border-bottom: 1px solid var(--stats-line); + box-shadow: + inset 1px 0 var(--stats-line), + inset -1px 0 var(--stats-line); padding: var(--stats-section-padding) var(--stats-page-padding); } [data-page="stats"] [data-section="hero"] { - min-height: 270px; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 64px; - align-items: start; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-end; + gap: 24px; + min-height: 0; + overflow: hidden; + padding: 128px 24px 48px; + border-bottom: 0; + box-shadow: none; } [data-page="stats"] h1, @@ -210,6 +773,7 @@ line-height: 1; letter-spacing: normal; font-weight: 600; + max-width: none; } [data-page="stats"] h2 { @@ -219,58 +783,503 @@ font-weight: 500; } -[data-page="stats"] [data-section="hero"] > p, [data-page="stats"] [data-slot="section-header"] p { color: var(--stats-muted); font-size: 16px; line-height: 1.5; } -[data-page="stats"] [data-section="hero"] > div { +[data-page="stats"] [data-slot="hero-canvas"] { display: flex; flex-direction: column; - gap: 16px; + gap: 24px; + width: 100%; min-width: 0; } -[data-page="stats"] [data-slot="meta"] { +[data-page="stats"] [data-section="hero"] h1 { + order: 1; + color: var(--stats-text); + font-size: 64px; + font-weight: 500; + line-height: 1; + letter-spacing: 0; +} + +[data-page="stats"] [data-slot="hero-copy"] { + order: 3; + color: var(--stats-hero-muted); + font-size: 16px; + font-weight: 400; + line-height: 1.5; +} + +[data-page="stats"] [data-slot="hero-copy-break"] { + display: none; +} + +[data-page="stats"] [data-slot="hero-pattern"] { + order: 2; + flex: 0 0 auto; + width: 100%; + height: 16px; + overflow: hidden; + background: var(--stats-hero-pattern); + mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E"); + mask-repeat: repeat; + mask-size: 6px 6px; + -webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E"); + -webkit-mask-repeat: repeat; + -webkit-mask-size: 6px 6px; +} + +[data-page="stats"] [data-slot="hero-meta"] { display: flex; align-items: center; gap: 4px; width: fit-content; + max-width: 100%; height: 24px; padding: 0 8px 0 4px; background: var(--stats-layer-2); - color: var(--stats-faint); + color: var(--stats-hero-muted); font-size: 13px; - font-weight: 600; + font-weight: 500; line-height: 1.1; overflow: hidden; white-space: nowrap; } -[data-page="stats"] [data-slot="meta"] svg { +[data-page="stats"] [data-slot="hero-meta"] svg { width: 16px; height: 16px; - color: var(--stats-faint); flex: 0 0 auto; } -[data-page="stats"] [data-slot="meta"] span { - color: var(--stats-muted); +[data-page="stats"] [data-slot="hero-meta-label"], +[data-page="stats"] [data-slot="hero-meta-empty"] { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } -[data-page="stats"] [data-slot="meta"] b, -[data-page="stats"] [data-slot="meta"] em { - color: var(--stats-faint); - font-style: normal; - font-weight: 600; +[data-page="stats"] [data-slot="hero-meta-time"] { + display: inline-flex; + align-items: center; + min-width: 0; + overflow: hidden; +} + +[data-page="stats"] [data-slot="hero-meta-separator"] { + flex: 0 0 auto; + margin-right: 0.35em; +} + +[data-page="stats"] [data-slot="hero-meta-ticker"] { + position: relative; + flex: 0 1 auto; + min-width: 0; + height: 1.1em; + overflow: hidden; + line-height: 1.1; +} + +[data-page="stats"] [data-slot="hero-meta-ticker-track"] { + display: flex; + min-width: 0; + flex-direction: column; + transform: translateY(0); + will-change: transform; +} + +[data-page="stats"] [data-slot="hero-meta-ticker"][data-ticking="true"] [data-slot="hero-meta-ticker-track"] { + animation: stats-hero-meta-ticker 680ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +[data-page="stats"] [data-slot="hero-meta-ticker-item"] { + display: block; + min-width: 0; + overflow: hidden; + line-height: 1.1; + text-overflow: ellipsis; +} + +@keyframes stats-hero-meta-ticker { + 0%, + 18% { + transform: translateY(0); + } + + 82%, + 100% { + transform: translateY(-50%); + } +} + +@media (prefers-reduced-motion: reduce) { + [data-page="stats"] [data-slot="hero-meta-ticker"][data-ticking="true"] [data-slot="hero-meta-ticker-track"] { + animation: none; + transform: translateY(-50%); + } +} + +@media (min-width: 48rem) { + [data-page="stats"] [data-section="hero"] { + gap: 16px; + padding: 128px 32px 32px; + } + + [data-page="stats"] [data-slot="hero-canvas"] { + position: relative; + display: block; + height: 232px; + overflow: hidden; + } + + [data-page="stats"] [data-slot="hero-pattern"] { + position: absolute; + top: 50%; + left: 50%; + width: 1280px; + height: 351px; + transform: translate(-50%, -50%); + } + + [data-page="stats"] [data-section="hero"] h1 { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: max-content; + max-width: 100%; + padding: 0 12px 12px 0; + background: var(--stats-bg); + white-space: nowrap; + } + + [data-page="stats"] [data-slot="hero-copy"] { + position: absolute; + right: 0; + bottom: 0; + z-index: 1; + width: min(563px, 100%); + padding: 12px 0 0 16px; + background: var(--stats-bg); + text-align: right; + } + + [data-page="stats"] [data-slot="hero-copy-break"] { + display: block; + } +} + +@media (min-width: 75rem) { + [data-page="stats"] [data-section="hero"] { + padding: 128px 40px 24px; + } +} + +@media (min-width: 90rem) { + [data-page="stats"] [data-section="hero"] { + padding-right: 0; + padding-left: 0; + } } [data-page="stats"] [data-section="chart"] { min-height: 0; } +[data-page="stats"] [data-section="top-models"] { + box-sizing: border-box; + margin-top: 1px; + box-shadow: + 0 -1px var(--stats-line), + inset 0 -1px var(--stats-line), + inset 1px 0 var(--stats-line), + inset -1px 0 var(--stats-line); + padding: 80px 40px; + color: var(--stats-text); +} + +[data-page="stats"] [data-slot="top-models-title"] { + max-width: 1200px; + margin-bottom: 40px; + color: var(--stats-muted); + font-size: 28px; + font-weight: 400; + line-height: 1.5; + letter-spacing: 0; +} + +[data-page="stats"] [data-slot="top-models-title"] strong { + color: var(--stats-text); + font-weight: 500; +} + +[data-page="stats"] [data-slot="top-models-title"] span { + color: var(--stats-muted); + font-weight: 400; +} + +[data-page="stats"] [data-slot="top-models-mobile-controls"] { + display: none; +} + +[data-page="stats"] [data-component="top-models-chart"] { + --top-models-bar-gap: 12px; + position: relative; + display: grid; + grid-template-rows: 34px minmax(0, 1fr); + gap: 12px; + width: 100%; + height: clamp(320px, 44vh, 400px); +} + +[data-page="stats"] [data-slot="top-models-axis"], +[data-page="stats"] [data-slot="top-models-bars"] { + display: flex; + min-width: 0; +} + +[data-page="stats"] [data-slot="top-models-axis"] { + gap: var(--top-models-bar-gap); +} + +[data-page="stats"] [data-slot="top-models-axis"] > div { + flex: 1 1 0; + min-width: 0; + color: var(--stats-faint); + font-size: 11px; + font-weight: 500; + line-height: 1.5; +} + +[data-page="stats"] [data-slot="top-models-axis"] > div[data-active="true"] { + color: var(--stats-text); + font-weight: 500; +} + +[data-page="stats"] [data-slot="axis-label"] { + display: flex; + flex-direction: column; +} + +[data-page="stats"] + [data-slot="top-models-axis"] + > div[data-label-hidden="true"]:not([data-active="true"]) + [data-slot="axis-label"], +[data-page="stats"] + [data-slot="market-labels"] + button[data-label-hidden="true"]:not([data-active="true"]) + [data-slot="market-axis-label"] { + visibility: hidden; +} + +[data-page="stats"] [data-slot="axis-date-mobile"] { + display: none; +} + +[data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] { + --top-models-bar-gap: 8px; + grid-template-rows: 40px minmax(0, 1fr); +} + +[data-page="stats"] [data-component="top-models-chart"][data-range="2M"] { + --top-models-bar-gap: 6px; +} + +[data-page="stats"] [data-component="market-share"][data-dense-labels="true"] { + --market-gap: 8px; + grid-template-rows: 40px minmax(0, 1fr); +} + +[data-page="stats"] [data-component="market-share"][data-range="2M"] { + --market-gap: 6px; +} + +[data-page="stats"] [data-component="market-share"][data-range="2M"] [data-slot="market-labels"], +[data-page="stats"] [data-component="market-share"][data-range="2M"] [data-slot="market-bars"] { + gap: var(--market-gap); +} + +[data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] [data-slot="top-models-axis"] > div { + position: relative; + display: flex; + align-items: center; + justify-content: center; + height: 40px; + font-weight: 600; +} + +[data-page="stats"] [data-component="market-share"][data-dense-labels="true"] [data-slot="market-labels"] button { + position: relative; + align-items: center; + justify-content: center; + height: 40px; + line-height: 1.2; +} + +[data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] [data-slot="axis-label"], +[data-page="stats"] [data-component="market-share"][data-dense-labels="true"] [data-slot="market-axis-label"] { + position: absolute; + left: 50%; + width: max-content; + max-width: 72px; + transform: rotate(-90deg) translateX(-50%); + transform-origin: left center; +} + +[data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] [data-slot="axis-total"], +[data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] [data-slot="axis-date-full"], +[data-page="stats"] [data-component="market-share"][data-dense-labels="true"] [data-slot="market-total"], +[data-page="stats"] [data-component="market-share"][data-dense-labels="true"] [data-slot="market-date-full"] { + display: none; +} + +[data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] [data-slot="axis-date-mobile"], +[data-page="stats"] [data-component="market-share"][data-dense-labels="true"] [data-slot="market-date-mobile"] { + display: block; +} + +[data-page="stats"] [data-slot="top-models-bars"] { + width: calc(100% + var(--top-models-bar-gap)); + margin-inline: calc(var(--top-models-bar-gap) / -2); + min-height: 0; +} + +[data-page="stats"] [data-slot="top-models-bar"] { + position: relative; + box-sizing: border-box; + flex: 1 1 0; + min-width: 0; + height: 100%; + padding-inline: calc(var(--top-models-bar-gap) / 2); + outline: none; + cursor: pointer; +} + +[data-page="stats"] [data-slot="top-models-bar"]::before { + position: absolute; + inset: 0 calc(var(--top-models-bar-gap) / 2); + content: ""; + background: var(--stats-dot); + mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E"); + mask-position: center top; + mask-repeat: repeat; + mask-size: 6px 6px; + -webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E"); + -webkit-mask-position: center top; + -webkit-mask-repeat: repeat; + -webkit-mask-size: 6px 6px; +} + +[data-page="stats"] [data-slot="top-models-stack"] { + position: absolute; + right: calc(var(--top-models-bar-gap) / 2); + bottom: 0; + left: calc(var(--top-models-bar-gap) / 2); + z-index: 1; + display: grid; + height: var(--top-models-bar-height); + min-height: 0; + overflow: hidden; + background: var(--stats-bg); + box-shadow: 0 -4px 0 var(--stats-bg); +} + +[data-page="stats"] [data-slot="top-models-stack"] i { + box-sizing: border-box; + display: block; + min-height: 0; + transition: background 120ms ease; +} + +[data-page="stats"] [data-slot="top-models-stack"] i + i { + border-top: 2px solid var(--stats-bg); +} + +[data-page="stats"] [data-slot="mobile-filter-button"] { + display: inline-flex; + flex: 1 1 0; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + min-width: 0; + overflow: hidden; + padding: 0 12px; + color: var(--stats-text); + background: var(--stats-bg); + border: 0; + border-radius: 0; + box-shadow: + 0 0 0 0 #00000024, + 0 0 0 0.5px #00000024, + 0 1px 1.5px 0 #0000001a; + font-size: 13px; + font-weight: 600; + line-height: 1.1; +} + +[data-page="stats"] [data-slot="mobile-filter-button"] svg { + flex: 0 0 auto; + color: var(--stats-muted); +} + +[data-page="stats"] [data-component="mobile-filter-sheet"] { + position: fixed; + inset: 0; + z-index: 30; + display: flex; + align-items: flex-end; + padding: 0 8px 8px; + background: #00000066; + backdrop-filter: blur(3px); +} + +[data-page="stats"] [data-slot="filter-sheet-panel"] { + width: 100%; + background: var(--stats-bg); + box-shadow: 0 8px 24px #00000026; +} + +[data-page="stats"] [data-slot="filter-sheet-panel"] button { + position: relative; + display: flex; + align-items: center; + width: 100%; + min-height: 44px; + padding: 0 42px; + color: var(--stats-faint); + background: transparent; + border: 0; + border-radius: 0; + border-bottom: 1px solid var(--stats-line); + font-size: 13px; + font-weight: 600; + line-height: 1.1; + text-align: left; +} + +[data-page="stats"] [data-slot="filter-sheet-panel"] button:last-child { + border-bottom: 0; +} + +[data-page="stats"] [data-slot="filter-sheet-panel"] button[data-active="true"] { + color: var(--stats-text); + background: transparent; +} + +[data-page="stats"] [data-slot="filter-sheet-panel"] button[data-active="true"]::before { + position: absolute; + left: 20px; + width: 6px; + height: 6px; + content: ""; + background: currentColor; +} + [data-page="stats"] [data-slot="section-header"] { display: flex; justify-content: space-between; @@ -317,8 +1326,26 @@ background: var(--stats-text); } -[data-page="stats"] [data-component="usage-chart"], -[data-page="stats"] [data-component="country-map"] { +[data-page="stats"] button[data-slot="mobile-filter-button"] { + padding: 0 12px; + color: var(--stats-text); + background: var(--stats-bg); + border-radius: 0; +} + +[data-page="stats"] [data-slot="filter-sheet-panel"] button { + padding: 0 42px; + color: var(--stats-faint); + background: transparent; + border-radius: 0; +} + +[data-page="stats"] [data-slot="filter-sheet-panel"] button[data-active="true"] { + color: var(--stats-text); + background: transparent; +} + +[data-page="stats"] [data-component="usage-chart"] { position: relative; } @@ -350,8 +1377,7 @@ line-height: 1.5; } -[data-page="stats"] [data-component="usage-chart"] svg, -[data-page="stats"] [data-component="country-map"] svg { +[data-page="stats"] [data-component="usage-chart"] svg { display: block; width: 100%; overflow: visible; @@ -391,6 +1417,10 @@ margin-top: 32px; } +[data-page="stats"] [data-section="top-models"] [data-slot="chart-footer"] { + margin-top: 32px; +} + [data-page="stats"] [data-component="usage-filter"] { display: flex; align-items: center; @@ -401,48 +1431,54 @@ } [data-page="stats"] [data-component="usage-filter"][data-variant="range"] { - gap: 4px; + gap: 6px; } [data-page="stats"] [data-component="usage-filter"] button { color: var(--stats-faint); background: transparent; - border: 1px solid transparent; + border: 0; border-radius: 0; padding: 0; font-size: 13px; + font-weight: 500; line-height: 17px; } +[data-page="stats"] [data-component="usage-filter"] button:hover { + color: var(--stats-text); +} + [data-page="stats"] [data-component="usage-filter"] button[data-active="true"] { color: var(--stats-text); background: transparent; + font-weight: 500; } [data-page="stats"] [data-component="usage-filter"][data-variant="product"] button[data-active="true"] { display: flex; align-items: center; - gap: 8px; + gap: 6px; } [data-page="stats"] [data-component="usage-filter"][data-variant="product"] button[data-active="true"]::before { content: ""; - width: 8px; - height: 8px; - background: var(--stats-text); + width: 16px; + height: 16px; + background: linear-gradient(var(--stats-text), var(--stats-text)) center / 6px 6px no-repeat; } [data-page="stats"] [data-component="usage-filter"][data-variant="range"] button { - min-width: 34px; - padding: 3px 7px; + height: 24px; + width: 36px; + padding: 0; } [data-page="stats"] [data-component="usage-filter"][data-variant="range"] button[data-active="true"] { - border-color: var(--stats-line-strong); + background: var(--stats-layer-2); } -[data-page="stats"] [data-component="chart-tooltip"], -[data-page="stats"] [data-component="map-tooltip"] { +[data-page="stats"] [data-component="chart-tooltip"] { position: absolute; right: auto; top: 96px; @@ -502,30 +1538,222 @@ color: var(--stats-text); } +[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] { + top: 110px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 0; + width: 192px; + min-width: 192px; + padding: 0; + background: #fffffff2; + border: 0; + box-shadow: + 0 0 0 0.5px #00000024, + 0 8px 16px #0000000f, + 0 4px 8px #00000014; + color: var(--stats-text); +} + +[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"][data-placement="right"] { + right: auto; + left: calc(100% + 8px); +} + +[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"][data-placement="left"] { + right: calc(100% + 8px); + left: auto; +} + +[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] strong, +[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] > span { + display: block; + font-size: 11px; + line-height: 12px; + white-space: nowrap; +} + +[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] strong { + padding: 8px 8px 0; + font-weight: 500; +} + +[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] > span { + padding: 4px 8px 8px; + color: var(--stats-muted); +} + +[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] [data-slot="tooltip-divider"] { + height: 0.5px; + margin: 0; +} + +[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] p { + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px; + height: 16px; + margin: 4px 0 0; + padding: 0 8px; + font-size: 11px; + font-weight: 500; + line-height: 12px; +} + +[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] p[data-muted="true"] { + opacity: 0.46; +} + +[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] [data-slot="tooltip-divider"] + p { + margin-top: 8px; +} + +[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] p:last-child { + margin-bottom: 8px; +} + +[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] [data-slot="tooltip-label"] { + grid-template-columns: 16px minmax(0, 1fr); + gap: 4px; +} + +[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] i { + width: 6px; + height: 6px; + justify-self: center; +} + +[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] b { + font-weight: 500; +} + +[data-page="stats"] + :is( + [data-section="leaderboard"], + [data-section="market-share"], + [data-section="token-cost"], + [data-section="session-cost"] + ) { + position: relative; + box-sizing: border-box; + box-shadow: + inset 0 -1px var(--stats-line), + inset 1px 0 var(--stats-line), + inset -1px 0 var(--stats-line); + padding: 80px 40px; + color: var(--stats-text); +} + +[data-page="stats"] [data-component="section-bridge"] { + position: absolute; + top: 0; + left: 50%; + z-index: 2; + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 10px; + border: 1px solid var(--stats-line); + background: var(--stats-bg); + color: var(--stats-faint); + font-size: 10px; + font-weight: 500; + line-height: 1; + text-decoration: none; + transform: translate(-50%, -50%); + white-space: nowrap; +} + +[data-page="stats"] [data-component="section-bridge"]:hover { + color: var(--stats-text); + text-decoration: none; +} + +[data-page="stats"] [data-component="section-bridge"] i { + width: 1px; + height: 10px; + background: var(--stats-line-strong); +} + +[data-page="stats"] [data-component="section-bridge"] strong, +[data-page="stats"] [data-component="section-bridge"] b { + color: var(--stats-muted); + font-weight: 600; +} + +[data-page="stats"] [data-component="section-bridge"] { + display: none; +} + +[data-page="stats"] [data-slot="section-title"] { + max-width: 1200px; + margin-bottom: 40px; + color: var(--stats-muted); + font-size: 28px; + font-weight: 400; + line-height: 1.5; + letter-spacing: 0; +} + +[data-page="stats"] [data-slot="section-title"] strong { + color: var(--stats-text); + font-weight: 500; +} + +[data-page="stats"] [data-slot="section-title"] span { + color: var(--stats-muted); + font-weight: 400; +} + [data-page="stats"] [data-component="leaderboard"] { display: block; + width: 100%; } -[data-page="stats"] [data-slot="leaderboard-grid"] { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); +[data-page="stats"] [data-slot="leaderboard-pattern"] { + scroll-margin-top: 88px; + height: 16px; + margin: 32px 0 16px; + overflow: hidden; + background: var(--stats-hero-pattern); + mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E"); + mask-position: center top; + mask-repeat: repeat; + mask-size: 6px 6px; + -webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E"); + -webkit-mask-position: center top; + -webkit-mask-repeat: repeat; + -webkit-mask-size: 6px 6px; +} + +[data-page="stats"] [data-slot="leaderboard-scroll"] { + box-sizing: border-box; + display: flex; gap: 8px; - min-height: 447px; + width: calc(100% + 80px); + margin-inline: -40px; + padding: 4px 40px 8px; + overflow-x: auto; + overscroll-behavior-x: contain; + scroll-padding-inline: 40px; + scroll-snap-type: x proximity; + scrollbar-width: none; } -[data-page="stats"] [data-slot="leaderboard-featured"], -[data-page="stats"] [data-slot="leaderboard-compact"] { - display: grid; - gap: 8px; +[data-page="stats"] [data-slot="leaderboard-scroll"]::-webkit-scrollbar { + display: none; } -[data-page="stats"] [data-slot="leaderboard-compact"] { - grid-template-columns: repeat(2, minmax(0, 1fr)); +[data-page="stats"] [data-slot="leaderboard-scroll"] [data-component="leader-card"] { + flex: 0 0 clamp(240px, 31vw, 360px); + width: clamp(240px, 31vw, 360px); + scroll-snap-align: start; } [data-page="stats"] [data-component="leader-card"] { - container-type: inline-size; position: relative; + box-sizing: border-box; display: flex; flex-direction: column; justify-content: space-between; @@ -536,6 +1764,12 @@ background: linear-gradient(180deg, #ffffff0a 0%, #ffffff00 100%), var(--stats-layer); font-size: 13px; line-height: 1.1; + outline: none; + transition: + background 140ms ease, + border-color 140ms ease, + box-shadow 140ms ease, + transform 140ms ease; } [data-page="stats"] [data-slot="rank"], @@ -544,11 +1778,13 @@ } [data-page="stats"] [data-component="leader-card"][data-size="featured"] { - min-height: 143px; + justify-content: flex-start; + gap: 17px; + min-height: 154px; } [data-page="stats"] [data-component="leader-card"][data-size="compact"] { - min-height: 82px; + min-height: 88px; box-shadow: 0 0 0 0.5px #0000001f, 0 1px 2px -1px #00000014, @@ -557,27 +1793,28 @@ [data-page="stats"] [data-slot="leader-watermark"] { position: absolute; - right: -44px; + right: -20px; top: 50%; display: none; - width: 210px; - height: 210px; + width: 168px; + height: 168px; transform: translateY(-50%); color: var(--stats-line); - opacity: 0.5; + opacity: 0.58; pointer-events: none; + transition: + color 140ms ease, + opacity 140ms ease; } -@container (min-width: 280px) { - [data-page="stats"] [data-slot="leader-watermark"] { - display: block; - } +[data-page="stats"] [data-component="leader-card"][data-size="featured"] [data-slot="leader-watermark"] { + display: block; } [data-page="stats"] [data-slot="leader-body"] { position: relative; display: flex; - align-items: flex-end; + align-items: center; gap: 12px; min-width: 0; z-index: 1; @@ -586,7 +1823,7 @@ [data-page="stats"] [data-component="leader-card"][data-size="featured"] [data-slot="leader-body"] { flex-direction: column; align-items: flex-start; - gap: 16px; + gap: 12px; width: 100%; } @@ -607,14 +1844,6 @@ font-weight: 600; } -[data-page="stats"] [data-component="leader-card"][data-size="featured"] [data-slot="leader-avatar"] { - width: 28px; - height: 28px; - padding: 4px; - border-radius: 6px; - font-size: 13px; -} - [data-page="stats"] [data-slot="leader-copy"] { display: grid; gap: 4px; @@ -645,13 +1874,14 @@ [data-page="stats"] [data-slot="leader-copy"] strong { color: var(--stats-text); font-weight: 600; + line-height: 1.3; } [data-page="stats"] [data-slot="leader-copy"] span { color: var(--stats-muted); } -[data-page="stats"] [data-slot="delta"] { +[data-page="stats"] [data-slot="leader-copy"] [data-slot="delta"] { color: #198b43; } @@ -659,18 +1889,64 @@ color: #b82d35; } +[data-page="stats"] [data-component="leader-card"][data-active="true"] { + border-color: var(--stats-line-strong); + background: #ffffff; + box-shadow: + 0 0 0 0.5px #00000024, + 0 6px 16px #0000000d, + 0 2px 6px #0000000f; + transform: translateY(-1px); +} + +[data-page="stats"] [data-component="leader-card"][data-active="true"] [data-slot="leader-watermark"] { + color: var(--stats-layer-2); + opacity: 0.86; +} + +[data-page="stats"] [data-component="leader-card"][data-active="true"] [data-slot="leader-avatar"] { + border-color: var(--stats-line-strong); + color: var(--stats-text); +} + +@media (hover: hover) { + [data-page="stats"] [data-component="leader-card"]:hover, + [data-page="stats"] [data-component="leader-card"]:focus-visible { + border-color: var(--stats-line-strong); + background: #ffffff; + box-shadow: + 0 0 0 0.5px #00000024, + 0 6px 16px #0000000d, + 0 2px 6px #0000000f; + transform: translateY(-1px); + } + + [data-page="stats"] [data-component="leader-card"]:hover [data-slot="leader-watermark"], + [data-page="stats"] [data-component="leader-card"]:focus-visible [data-slot="leader-watermark"] { + color: var(--stats-layer-2); + opacity: 0.86; + } + + [data-page="stats"] [data-component="leader-card"]:hover [data-slot="leader-avatar"], + [data-page="stats"] [data-component="leader-card"]:focus-visible [data-slot="leader-avatar"] { + border-color: var(--stats-line-strong); + color: var(--stats-text); + } +} + [data-page="stats"] [data-component="market-share"] { + --market-gap: 12px; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 12px; - height: 280px; + height: 348px; } [data-page="stats"] [data-slot="market-labels"], [data-page="stats"] [data-slot="market-bars"] { display: grid; - grid-template-columns: repeat(7, minmax(0, 1fr)); - gap: 12px; + grid-template-columns: repeat(var(--market-count), minmax(0, 1fr)); + gap: var(--market-gap); } [data-page="stats"] [data-slot="market-labels"] button, @@ -688,14 +1964,23 @@ flex-direction: column; align-items: flex-start; color: var(--stats-faint); - font-size: 11px; - font-weight: 600; - line-height: 1.5; + font-size: 10px; + font-weight: 500; + line-height: 14px; } [data-page="stats"] [data-slot="market-labels"] button[data-active="true"] { color: var(--stats-text); background: transparent; + font-weight: 600; +} + +[data-page="stats"] [data-slot="market-axis-label"] { + display: grid; +} + +[data-page="stats"] [data-slot="market-date-mobile"] { + display: none; } [data-page="stats"] [data-slot="market-bars"] { @@ -707,6 +1992,7 @@ flex-direction: column; gap: 2px; height: 100%; + overflow: hidden; } [data-page="stats"] [data-slot="market-bars"] button[data-active="true"] { @@ -717,6 +2003,10 @@ width: 100%; min-height: 1px; background: var(--stats-layer-2); + cursor: pointer; + transition: + background-color 140ms ease, + opacity 140ms ease; } [data-page="stats"] [data-component="market-share-list"] { @@ -724,8 +2014,8 @@ grid-auto-flow: column; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(3, auto); - gap: 8px; - margin: 20px 0 0; + gap: 8px 10px; + margin: 36px 0 0; padding: 0; list-style: none; } @@ -733,20 +2023,38 @@ [data-page="stats"] [data-component="market-share-list"] li { display: flex; align-items: center; - gap: 6px; + gap: 8px; min-width: 0; - padding: 8px 12px 8px 8px; + height: 28px; + box-sizing: border-box; + padding: 0 8px; background: var(--stats-layer); border: 1px solid var(--stats-line); - font-size: 13px; - line-height: 1.1; + font-size: 11px; + line-height: 1; + cursor: pointer; + outline: none; + transition: + border-color 120ms ease, + box-shadow 120ms ease, + background 120ms ease; +} + +[data-page="stats"] [data-component="market-share-list"] li[data-active="true"], +[data-page="stats"] [data-component="market-share-list"] li:focus-visible { + border-color: var(--stats-text); + background: var(--stats-layer); + box-shadow: + 0 0 0 0.5px color-mix(in srgb, var(--stats-text) 70%, transparent), + 0 1px 2px -1px #00000014, + 0 2px 4px #0000000a; } [data-page="stats"] [data-component="market-share-list"] span { - width: 20px; + width: 22px; flex: 0 0 auto; color: var(--stats-muted); - font-weight: 600; + font-weight: 500; text-align: center; } @@ -775,7 +2083,7 @@ [data-page="stats"] [data-component="market-share-list"] b { color: var(--stats-muted); - font-weight: 600; + font-weight: 500; } [data-page="stats"] [data-slot="market-footer"] { @@ -783,15 +2091,17 @@ align-items: center; justify-content: space-between; gap: 64px; - margin-top: 24px; + margin-top: 40px; } [data-page="stats"] [data-slot="market-footer"] p { display: flex; align-items: center; gap: 8px; - width: 166px; - font-size: 13px; + width: 240px; + color: var(--stats-text); + font-size: 11px; + font-weight: 500; line-height: 1.1; white-space: nowrap; } @@ -801,6 +2111,7 @@ display: grid; gap: 8px; width: 100%; + margin-top: 4px; } [data-page="stats"] button[data-component="token-row"] { @@ -814,8 +2125,8 @@ border-radius: 0; background: transparent; color: var(--stats-text); - font-size: 13px; - line-height: 1.5; + font-size: 11px; + line-height: 16px; text-align: left; } @@ -825,8 +2136,9 @@ } [data-page="stats"] [data-component="token-row"] strong { - flex: 0 0 48px; - font-weight: 600; + flex: 0 0 56px; + color: var(--stats-text); + font-weight: 500; white-space: nowrap; } @@ -835,10 +2147,11 @@ } [data-page="stats"] [data-component="token-row"] > span { - flex: 0 0 133px; + flex: 0 0 132px; overflow: hidden; color: var(--stats-muted); font-weight: 400; + line-height: 16px; text-overflow: ellipsis; white-space: nowrap; } @@ -848,23 +2161,21 @@ } [data-page="stats"] [data-component="metric-bar"] { - display: flex; - align-items: center; - gap: 4px; + position: relative; + display: block; flex: 1; min-width: 0; - height: 6px; + height: 5px; + background: var(--stats-layer-2); font-style: normal; } -[data-page="stats"] [data-component="metric-bar"] b, -[data-page="stats"] [data-component="metric-bar"] em { - display: block; - height: 6px; -} - [data-page="stats"] [data-component="metric-bar"] b { - flex-basis: 0; + position: absolute; + inset: 0 auto 0 0; + display: block; + width: var(--metric-bar-fill); + height: 5px; background: var(--stats-text); } @@ -873,12 +2184,10 @@ } [data-page="stats"] [data-component="metric-bar"] em { - flex: 1; - min-width: 12px; - background: var(--stats-layer-2); + display: none; } -[data-page="stats"] [data-component="metric-bar"][data-active="true"] em { +[data-page="stats"] [data-component="metric-bar"][data-active="true"] { background: var(--stats-line-strong); } @@ -887,28 +2196,34 @@ align-items: flex-end; gap: 12px; width: 100%; + height: 18px; +} + +[data-page="stats"] [data-slot="session-heading"] strong { + flex: 0 0 56px; } [data-page="stats"] [data-slot="session-heading"] span { - flex: 0 0 210px; + flex: 0 0 150px; } [data-page="stats"] [data-slot="session-heading"] p { flex: 1; min-width: 0; color: var(--stats-faint); - font-size: 11px; + font-size: 9px; font-weight: 600; - line-height: 1.5; + line-height: 1; } [data-page="stats"] [data-component="token-tooltip"] { position: absolute; - left: 32%; + left: 44%; z-index: 2; display: grid; - gap: 8px; - width: 192px; + gap: 6px; + width: 150px; + box-sizing: border-box; padding: 8px; background: var(--stats-layer); box-shadow: @@ -921,8 +2236,8 @@ [data-page="stats"] [data-component="token-tooltip"] p { display: flex; justify-content: space-between; - gap: 12px; - font-size: 11px; + gap: 10px; + font-size: 9px; font-weight: 600; line-height: 1.1; } @@ -933,27 +2248,23 @@ [data-page="stats"] [data-slot="token-footer"] { display: flex; + align-items: center; justify-content: space-between; gap: 24px; - margin-top: 32px; + margin-top: 40px; } -[data-page="stats"] button[data-component="live-filter"] { - display: flex; +[data-page="stats"] [data-component="live-filter"] { + display: none; align-items: center; gap: 6px; padding: 0; color: var(--stats-muted); - font-size: 13px; - font-weight: 600; + font-size: 11px; + font-weight: 500; line-height: 1.1; } -[data-page="stats"] button[data-component="live-filter"][data-active="true"] { - color: var(--stats-muted); - background: transparent; -} - [data-page="stats"] [data-component="live-filter"]::before { content: ""; width: 6px; @@ -966,174 +2277,155 @@ display: grid; gap: 8px; width: 100%; + margin-top: 4px; } [data-page="stats"] [data-component="toggle"] { width: fit-content; } -[data-page="stats"] [data-component="country-map"] circle { - fill: var(--stats-accent); - fill-opacity: 0.14; - stroke: var(--stats-accent); - stroke-width: 1.5; - transition: - fill-opacity 120ms ease, - stroke-width 120ms ease; +[data-page="stats"][data-theme="dark"], +:root[data-stats-theme="dark"] [data-page="stats"]:not([data-theme="light"]) { + color-scheme: dark; + --color-background: #161616; + --color-background-weak: #242424; + --color-background-weak-hover: #303030; + --color-background-strong: #ffffff; + --color-background-strong-hover: #eeeeee; + --color-text: #d4d4d4; + --color-text-weak: #808080; + --color-text-strong: #ffffff; + --color-text-inverted: #161616; + --color-border-weak: #ffffff1a; + --stats-bg: #161616; + --stats-layer: #242424; + --stats-layer-2: #303030; + --stats-line: #ffffff1a; + --stats-line-strong: #ffffff33; + --stats-text: #ffffff; + --stats-muted: #d4d4d4; + --stats-faint: #808080; + --stats-theme-icon-active: #fafafa; + --stats-bar-idle: #303030; + --stats-dot: #303030; + --stats-hero-muted: #808080; + --stats-hero-pattern: #303030; + --stats-logo-bg: #f1ecec; + --stats-logo-fill: #b7b1b1; + --stats-logo-stroke: #211e1e; } -[data-page="stats"] [data-component="country-map"] g[role="button"] { - cursor: pointer; - outline: none; +[data-page="stats"][data-theme="dark"] [data-component="chart-tooltip"], +:root[data-stats-theme="dark"] [data-page="stats"]:not([data-theme="light"]) [data-component="chart-tooltip"] { + background: #242424f2; } -[data-page="stats"] [data-component="country-map"] g[role="button"][data-active="true"] circle { - fill-opacity: 0.28; - stroke-width: 3; +[data-page="stats"][data-theme="dark"] [data-section="top-models"] [data-component="chart-tooltip"], +:root[data-stats-theme="dark"] + [data-page="stats"]:not([data-theme="light"]) + [data-section="top-models"] + [data-component="chart-tooltip"] { + background: #242424f2; + box-shadow: + 0 0 0 0.5px #ffffff24, + 0 8px 16px #0000003d, + 0 4px 8px #00000052; } -[data-page="stats"] [data-component="country-map"] text { - fill: var(--stats-text); - font-size: 12px; - font-weight: 600; - pointer-events: none; -} - -[data-page="stats"] [data-component="map-tooltip"] { - top: 24px; - left: 24px; - right: auto; - min-width: 168px; -} - -[data-page="stats"] [data-component="map-tooltip"] span, -[data-page="stats"] [data-component="map-tooltip"] em { - color: var(--stats-muted); - font-size: 12px; - font-style: normal; -} - -[data-page="stats"] [data-component="map-tooltip"] p { - display: flex; - justify-content: space-between; - gap: 16px; - color: var(--stats-text); - font-size: 12px; -} - -[data-page="stats"] [data-component="country-list"] { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 8px; - margin: 20px 0 0; - padding: 0; - list-style: none; -} - -[data-page="stats"] [data-component="country-list"] button { - display: grid; - grid-template-columns: 24px minmax(0, 1fr) auto auto; - align-items: center; - gap: 8px; - width: 100%; - padding: 8px; - border: 1px solid var(--stats-line); - border-radius: 0; - background: var(--stats-layer); - color: var(--stats-muted); - font-size: 13px; - line-height: 1.1; - text-align: left; -} - -[data-page="stats"] [data-component="country-list"] button[data-active="true"] { - border-color: var(--stats-line-strong); - background: var(--stats-layer-2); - color: var(--stats-muted); -} - -[data-page="stats"] [data-component="country-list"] span, -[data-page="stats"] [data-component="country-list"] em { - color: var(--stats-faint); - font-style: normal; -} - -[data-page="stats"] [data-component="country-list"] strong { - min-width: 0; - overflow: hidden; - color: var(--stats-text); - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; -} - -[data-page="stats"] [data-component="country-list"] b { - color: var(--stats-muted); - font-weight: 600; -} - -[data-page="stats"] [data-slot="country-footer"] { - display: flex; - align-items: center; - justify-content: space-between; - gap: 24px; - margin-top: 32px; -} - -[data-page="stats"] [data-slot="country-footer"] p { - display: flex; - align-items: center; - gap: 8px; - color: var(--stats-muted); - font-size: 13px; -} - -[data-page="stats"] [data-slot="country-footer"] span { +[data-page="stats"][data-theme="dark"] [data-section="top-models"] [data-component="chart-tooltip"] > span, +:root[data-stats-theme="dark"] + [data-page="stats"]:not([data-theme="light"]) + [data-section="top-models"] + [data-component="chart-tooltip"] + > span { color: var(--stats-faint); } -[data-page="stats"] [data-section="newsletter"] { - min-height: 272px; - display: grid; - grid-template-columns: minmax(0, 1fr) 420px; - align-items: center; - gap: 48px; +[data-page="stats"][data-theme="dark"] [data-component="leader-card"][data-size="compact"], +:root[data-stats-theme="dark"] + [data-page="stats"]:not([data-theme="light"]) + [data-component="leader-card"][data-size="compact"] { + box-shadow: + 0 0 0 0.5px #ffffff1f, + 0 1px 2px -1px #00000052, + 0 2px 4px #0000003d; } -[data-page="stats"] [data-section="newsletter"] div { - display: grid; - gap: 16px; +[data-page="stats"][data-theme="dark"] [data-component="leader-card"][data-active="true"], +[data-page="stats"][data-theme="dark"] [data-component="leader-card"]:hover, +[data-page="stats"][data-theme="dark"] [data-component="leader-card"]:focus-visible, +:root[data-stats-theme="dark"] + [data-page="stats"]:not([data-theme="light"]) + [data-component="leader-card"][data-active="true"], +:root[data-stats-theme="dark"] [data-page="stats"]:not([data-theme="light"]) [data-component="leader-card"]:hover, +:root[data-stats-theme="dark"] + [data-page="stats"]:not([data-theme="light"]) + [data-component="leader-card"]:focus-visible { + background: #303030; + box-shadow: + 0 0 0 0.5px #ffffff24, + 0 6px 16px #0000003d, + 0 2px 6px #00000052; } -[data-page="stats"] [data-section="newsletter"] p { - color: var(--stats-muted); +[data-page="stats"][data-theme="dark"] + [data-component="leader-card"][data-active="true"] + [data-slot="leader-watermark"], +[data-page="stats"][data-theme="dark"] [data-component="leader-card"]:hover [data-slot="leader-watermark"], +[data-page="stats"][data-theme="dark"] [data-component="leader-card"]:focus-visible [data-slot="leader-watermark"], +:root[data-stats-theme="dark"] + [data-page="stats"]:not([data-theme="light"]) + [data-component="leader-card"][data-active="true"] + [data-slot="leader-watermark"], +:root[data-stats-theme="dark"] + [data-page="stats"]:not([data-theme="light"]) + [data-component="leader-card"]:hover + [data-slot="leader-watermark"], +:root[data-stats-theme="dark"] + [data-page="stats"]:not([data-theme="light"]) + [data-component="leader-card"]:focus-visible + [data-slot="leader-watermark"] { + color: var(--stats-line-strong); + opacity: 0.78; } -[data-page="stats"] form { - display: flex; - border: 1px solid var(--stats-line); - background: var(--stats-layer); - padding: 4px; +[data-page="stats"][data-theme="dark"] [data-slot="header-button"][data-variant="neutral"], +[data-page="stats"][data-theme="dark"] [data-slot="menu-button"], +:root[data-stats-theme="dark"] + [data-page="stats"]:not([data-theme="light"]) + [data-slot="header-button"][data-variant="neutral"], +:root[data-stats-theme="dark"] [data-page="stats"]:not([data-theme="light"]) [data-slot="menu-button"] { + color: #fafafa; + background: #ffffff0f; + box-shadow: + 0 -0.5px 0 0 #ffffff33, + 0 0 0 0.5px #ffffff33, + 0 1px 2px 0 #00000066; } -[data-page="stats"] input { - min-width: 0; - flex: 1; - border: 0; - background: transparent; - color: var(--stats-text); - font: inherit; - padding: 12px; +[data-page="stats"][data-theme="dark"] [data-slot="header-button"][data-variant="neutral"] span, +:root[data-stats-theme="dark"] + [data-page="stats"]:not([data-theme="light"]) + [data-slot="header-button"][data-variant="neutral"] + span { + color: #aeaeae; } -[data-page="stats"] form button { - background: var(--stats-text); - color: #fff; - padding: 12px 16px; +[data-page="stats"][data-theme="dark"] [data-slot="header-button"][data-variant="contrast"], +:root[data-stats-theme="dark"] + [data-page="stats"]:not([data-theme="light"]) + [data-slot="header-button"][data-variant="contrast"] { + color: #ffffff; + background: #5c5c5c; + box-shadow: + 0 -0.5px 0 0 #ffffff4d, + 0 0 0 0.5px #ffffff66, + 0 1px 2px 0 #00000066; } @media (prefers-color-scheme: dark) { - [data-page="stats"] { + :root:not([data-stats-theme="light"]) [data-page="stats"]:not([data-theme="light"]) { + color-scheme: dark; --color-background: #161616; --color-background-weak: #242424; --color-background-weak-hover: #303030; @@ -1152,34 +2444,167 @@ --stats-text: #ffffff; --stats-muted: #d4d4d4; --stats-faint: #808080; + --stats-theme-icon-active: #fafafa; --stats-bar-idle: #303030; --stats-dot: #303030; + --stats-hero-muted: #808080; + --stats-hero-pattern: #303030; + --stats-logo-bg: #f1ecec; + --stats-logo-fill: #b7b1b1; + --stats-logo-stroke: #211e1e; } - [data-page="stats"] [data-component="chart-tooltip"], - [data-page="stats"] [data-component="map-tooltip"] { + :root:not([data-stats-theme="light"]) [data-page="stats"]:not([data-theme="light"]) [data-component="chart-tooltip"] { background: #242424f2; } - [data-page="stats"] [data-component="leader-card"][data-size="compact"] { + :root:not([data-stats-theme="light"]) + [data-page="stats"]:not([data-theme="light"]) + [data-section="top-models"] + [data-component="chart-tooltip"] { + background: #242424f2; + box-shadow: + 0 0 0 0.5px #ffffff24, + 0 8px 16px #0000003d, + 0 4px 8px #00000052; + } + + :root:not([data-stats-theme="light"]) + [data-page="stats"]:not([data-theme="light"]) + [data-section="top-models"] + [data-component="chart-tooltip"] + > span { + color: var(--stats-faint); + } + + :root:not([data-stats-theme="light"]) + [data-page="stats"]:not([data-theme="light"]) + [data-component="leader-card"][data-size="compact"] { box-shadow: 0 0 0 0.5px #ffffff1f, 0 1px 2px -1px #00000052, 0 2px 4px #0000003d; } - [data-page="stats"] [data-slot="logo light"] { + :root:not([data-stats-theme="light"]) + [data-page="stats"]:not([data-theme="light"]) + [data-component="leader-card"][data-active="true"], + :root:not([data-stats-theme="light"]) + [data-page="stats"]:not([data-theme="light"]) + [data-component="leader-card"]:hover, + :root:not([data-stats-theme="light"]) + [data-page="stats"]:not([data-theme="light"]) + [data-component="leader-card"]:focus-visible { + background: #303030; + box-shadow: + 0 0 0 0.5px #ffffff24, + 0 6px 16px #0000003d, + 0 2px 6px #00000052; + } + + :root:not([data-stats-theme="light"]) + [data-page="stats"]:not([data-theme="light"]) + [data-component="leader-card"][data-active="true"] + [data-slot="leader-watermark"], + :root:not([data-stats-theme="light"]) + [data-page="stats"]:not([data-theme="light"]) + [data-component="leader-card"]:hover + [data-slot="leader-watermark"], + :root:not([data-stats-theme="light"]) + [data-page="stats"]:not([data-theme="light"]) + [data-component="leader-card"]:focus-visible + [data-slot="leader-watermark"] { + color: var(--stats-line-strong); + opacity: 0.78; + } + + :root:not([data-stats-theme="light"]) + [data-page="stats"]:not([data-theme="light"]) + [data-slot="header-button"][data-variant="neutral"], + :root:not([data-stats-theme="light"]) [data-page="stats"]:not([data-theme="light"]) [data-slot="menu-button"] { + color: #fafafa; + background: #ffffff0f; + box-shadow: + 0 -0.5px 0 0 #ffffff33, + 0 0 0 0.5px #ffffff33, + 0 1px 2px 0 #00000066; + } + + :root:not([data-stats-theme="light"]) + [data-page="stats"]:not([data-theme="light"]) + [data-slot="header-button"][data-variant="neutral"] + span { + color: #aeaeae; + } + + :root:not([data-stats-theme="light"]) + [data-page="stats"]:not([data-theme="light"]) + [data-slot="header-button"][data-variant="contrast"] { + color: #ffffff; + background: #5c5c5c; + box-shadow: + 0 -0.5px 0 0 #ffffff4d, + 0 0 0 0.5px #ffffff66, + 0 1px 2px 0 #00000066; + } +} + +@media (min-width: 48rem) { + [data-page="stats"] [data-slot="header-button"][data-variant="neutral"] { + display: inline-flex; + } +} + +@media (min-width: 75rem) { + [data-page="stats"] [data-slot="header-bar"] { + gap: 32px; + } + + [data-page="stats"] [data-slot="brand"], + [data-page="stats"] [data-component="section-nav"], + [data-page="stats"] [data-slot="header-actions"] { + flex: 1 1 0; + } + + [data-page="stats"] [data-slot="brand"] { + margin-right: 0; + } + + [data-page="stats"] [data-component="section-nav"] { + display: flex; + } + + [data-page="stats"] [data-slot="menu-button"] { display: none; } - [data-page="stats"] [data-slot="logo dark"] { - display: block; + [data-page="stats"] [data-slot="mobile-menu"] { + display: none !important; + } +} + +@media (min-width: 90rem) { + [data-page="stats"] [data-component="footer"] { + padding-right: 0; + padding-left: 0; } } @media (max-width: 74rem) { - [data-page="stats"] [data-component="container"] { - border: 0; + [data-page="stats"] [data-section="top-models"], + [data-page="stats"] [data-section="leaderboard"], + [data-page="stats"] [data-section="market-share"], + [data-page="stats"] [data-section="token-cost"], + [data-page="stats"] [data-section="session-cost"] { + padding: 64px 32px; + } + + [data-page="stats"] [data-slot="leaderboard-scroll"] { + width: calc(100% + 64px); + margin-inline: -32px; + padding-right: 32px; + padding-left: 32px; + scroll-padding-inline: 32px; } } @@ -1189,27 +2614,11 @@ --stats-section-padding: 4rem; } - [data-page="stats"] [data-component="top"] { + [data-page="stats"] [data-section="chart"] { padding-left: 24px; padding-right: 24px; } - [data-page="stats"] [data-component="nav-desktop"] ul { - gap: 18px; - } - - [data-page="stats"] [data-section="hero"], - [data-page="stats"] [data-section="chart"], - [data-page="stats"] [data-section="newsletter"] { - padding-left: 24px; - padding-right: 24px; - } - - [data-page="stats"] [data-section="hero"], - [data-page="stats"] [data-section="newsletter"] { - grid-template-columns: 1fr; - } - [data-page="stats"] [data-slot="section-header"] { flex-direction: column; } @@ -1231,10 +2640,18 @@ flex-wrap: wrap; } - [data-page="stats"] [data-slot="leaderboard-grid"], - [data-page="stats"] [data-slot="leaderboard-compact"] { - grid-template-columns: 1fr; - min-height: 0; + [data-page="stats"] [data-section="top-models"] [data-slot="chart-footer"] { + align-items: center; + flex-direction: row; + } + + [data-page="stats"] [data-section="top-models"] [data-component="usage-filter"] { + flex-wrap: nowrap; + } + + [data-page="stats"] [data-slot="leaderboard-scroll"] [data-component="leader-card"] { + flex-basis: 280px; + width: 280px; } [data-page="stats"] [data-component="leader-card"][data-size="featured"], @@ -1244,18 +2661,10 @@ [data-page="stats"] [data-component="leader-card"][data-size="featured"] [data-slot="leader-body"] { flex-direction: row; - align-items: flex-end; + align-items: center; gap: 12px; } - [data-page="stats"] [data-slot="leader-avatar"] { - width: 28px; - height: 28px; - padding: 4px; - border-radius: 6px; - font-size: 13px; - } - [data-page="stats"] [data-slot="leader-watermark"] { right: -64px; width: 180px; @@ -1263,6 +2672,10 @@ font-size: 96px; } + [data-page="stats"] [data-slot="session-heading"] { + display: none; + } + [data-page="stats"] [data-slot="market-labels"], [data-page="stats"] [data-slot="market-bars"] { gap: 8px; @@ -1274,8 +2687,11 @@ grid-template-rows: none; } - [data-page="stats"] [data-component="country-list"] { - grid-template-columns: 1fr; + [data-page="stats"] [data-component="token-tooltip"] { + position: static; + order: -1; + width: 100%; + margin-bottom: 8px; } [data-page="stats"] [data-slot="market-footer"] { @@ -1284,33 +2700,272 @@ gap: 24px; } - [data-page="stats"] [data-slot="country-footer"] { - align-items: flex-start; - flex-direction: column; - } - - [data-page="stats"] [data-component="chart-tooltip"], - [data-page="stats"] [data-component="map-tooltip"] { + [data-page="stats"] [data-component="chart-tooltip"] { position: static; margin-top: 16px; } - [data-page="stats"] [data-section="newsletter"] form { + [data-page="stats"] [data-component="footer"] { + padding: 88px 24px 24px; + } + + [data-page="stats"] [data-slot="footer-grid"] { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 40px 32px; + } + + [data-page="stats"] [data-slot="footer-mark"] { + grid-column: 1 / -1; + } + + [data-page="stats"] [data-slot="footer-bottom"], + [data-page="stats"] [data-slot="footer-bottom"] > div:first-child { + align-items: flex-start; + flex-direction: column; + } + + [data-page="stats"] [data-slot="footer-bottom"] { + line-height: 1.5; + } +} + +@media (max-width: 47.999rem) { + [data-page="stats"] [data-section="top-models"], + [data-page="stats"] [data-section="leaderboard"], + [data-page="stats"] [data-section="market-share"], + [data-page="stats"] [data-section="token-cost"], + [data-page="stats"] [data-section="session-cost"] { + padding: 48px 24px; + } + + [data-page="stats"] [data-slot="top-models-title"], + [data-page="stats"] [data-slot="section-title"] { + margin-bottom: 32px; + font-size: 16px; + } + + [data-page="stats"] [data-slot="top-models-mobile-controls"] { + display: flex; + gap: 8px; width: 100%; } + + [data-page="stats"] [data-section="top-models"] [data-slot="chart-footer"] { + display: flex; + margin-top: 24px; + } + + [data-page="stats"] [data-section="top-models"] [data-slot="chart-footer"] [data-component="usage-filter"] { + display: none; + } + + [data-page="stats"] [data-slot="leaderboard-featured"], + [data-page="stats"] [data-slot="leaderboard-compact"] { + display: none; + } + + [data-page="stats"] [data-slot="leaderboard-pattern"] { + margin: 24px 0 12px; + } + + [data-page="stats"] [data-slot="leaderboard-scroll"] { + gap: 12px; + width: calc(100% + 48px); + margin-inline: -24px; + padding-right: 24px; + padding-left: 24px; + scroll-padding-inline: 24px; + } + + [data-page="stats"] [data-slot="leaderboard-scroll"] [data-component="leader-card"] { + flex: 0 0 240px; + width: 240px; + min-height: 156px; + gap: 32px; + } + + [data-page="stats"] [data-slot="leaderboard-scroll"] [data-slot="leader-body"] { + flex-direction: column; + align-items: flex-start; + gap: 16px; + } + + [data-page="stats"] [data-slot="leaderboard-scroll"] [data-slot="leader-avatar"] { + width: 28px; + height: 28px; + padding: 4px; + border-radius: 6px; + } + + [data-page="stats"] [data-slot="leaderboard-scroll"] [data-slot="leader-watermark"] { + right: -68px; + width: 240px; + height: 240px; + } + + [data-page="stats"] [data-slot="leaderboard-mobile"] { + box-sizing: border-box; + display: flex; + gap: 12px; + width: calc(100% + 48px); + margin-inline: -24px; + padding-inline: 24px; + overflow-x: auto; + overscroll-behavior-x: contain; + scroll-padding-inline: 24px; + scroll-snap-type: x proximity; + scrollbar-width: none; + } + + [data-page="stats"] [data-slot="leaderboard-mobile"]::-webkit-scrollbar { + display: none; + } + + [data-page="stats"] [data-slot="leaderboard-mobile"] [data-component="leader-card"] { + flex: 0 0 240px; + width: 240px; + min-height: 156px; + gap: 32px; + scroll-snap-align: start; + } + + [data-page="stats"] [data-slot="leaderboard-mobile"] [data-slot="leader-body"] { + flex-direction: column; + align-items: flex-start; + gap: 16px; + } + + [data-page="stats"] [data-slot="leaderboard-mobile"] [data-slot="leader-avatar"] { + width: 28px; + height: 28px; + padding: 4px; + border-radius: 6px; + } + + [data-page="stats"] [data-slot="leaderboard-mobile"] [data-slot="leader-watermark"] { + right: -68px; + width: 240px; + height: 240px; + } + + [data-page="stats"] [data-component="top-models-chart"] { + grid-template-rows: 40px minmax(0, 1fr); + height: 360px; + } + + [data-page="stats"] [data-component="market-share"] { + grid-template-rows: 40px minmax(0, 1fr); + height: 400px; + } + + [data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"], + [data-page="stats"] [data-component="market-share"][data-dense-labels="true"] { + overflow-x: auto; + overflow-y: visible; + overscroll-behavior-x: contain; + scrollbar-width: none; + } + + [data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"]::-webkit-scrollbar, + [data-page="stats"] [data-component="market-share"][data-dense-labels="true"]::-webkit-scrollbar { + display: none; + } + + [data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] { + --top-models-mobile-bar-width: 12px; + } + + [data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] [data-slot="top-models-axis"], + [data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] [data-slot="top-models-bars"] { + min-width: calc(var(--top-models-count) * (var(--top-models-mobile-bar-width) + var(--top-models-bar-gap))); + } + + [data-page="stats"] [data-component="market-share"][data-dense-labels="true"] { + --market-mobile-bar-width: 12px; + } + + [data-page="stats"] [data-component="market-share"][data-dense-labels="true"] [data-slot="market-labels"], + [data-page="stats"] [data-component="market-share"][data-dense-labels="true"] [data-slot="market-bars"] { + min-width: calc(var(--market-count) * (var(--market-mobile-bar-width) + var(--market-gap))); + } + + [data-page="stats"] [data-slot="top-models-axis"] > div { + position: relative; + display: flex; + align-items: center; + justify-content: center; + height: 40px; + font-weight: 600; + } + + [data-page="stats"] [data-slot="market-labels"] button { + position: relative; + align-items: center; + justify-content: center; + height: 40px; + line-height: 1.2; + } + + [data-page="stats"] [data-slot="axis-label"] { + position: absolute; + left: 50%; + width: max-content; + max-width: 72px; + transform: rotate(-90deg) translateX(-50%); + transform-origin: left center; + } + + [data-page="stats"] [data-slot="market-axis-label"] { + position: absolute; + left: 50%; + width: max-content; + max-width: 72px; + transform: rotate(-90deg) translateX(-50%); + transform-origin: left center; + } + + [data-page="stats"] [data-slot="top-models-axis"] > div[data-mobile-hidden="true"] [data-slot="axis-label"], + [data-page="stats"] [data-slot="axis-total"], + [data-page="stats"] [data-slot="axis-date-full"] { + display: none; + } + + [data-page="stats"] [data-slot="market-labels"] button[data-mobile-hidden="true"] [data-slot="market-axis-label"], + [data-page="stats"] [data-slot="market-total"], + [data-page="stats"] [data-slot="market-date-full"] { + display: none; + } + + [data-page="stats"] [data-slot="axis-date-mobile"] { + display: block; + } + + [data-page="stats"] [data-slot="market-date-mobile"] { + display: block; + } + + [data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] { + position: fixed; + top: auto; + right: 12px; + bottom: 12px; + left: 12px; + z-index: 40; + width: auto; + min-width: 0; + max-height: min(320px, 48vh); + overflow: auto; + transform: none; + } + + [data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"][data-placement] { + right: 12px; + left: 12px; + } } @media (max-width: 40rem) { - [data-page="stats"] [data-component="nav-desktop"] li:not(:last-child) { - display: none; - } - - [data-page="stats"] [data-component="footer"], - [data-page="stats"] [data-component="legal"] { - flex-wrap: wrap; - } - - [data-page="stats"] [data-component="footer"] [data-slot="cell"] { - flex-basis: 50%; + [data-page="stats"] [data-slot="footer-grid"] { + grid-template-columns: 1fr; } } diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 01c02d4717..a770a9f046 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -1,8 +1,13 @@ import "./index.css" -import { Meta, Title } from "@solidjs/meta" +import { Link, Meta, Title } from "@solidjs/meta" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import ibmPlexMonoRegularLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Regular-Latin1.woff2?url" +import ibmPlexMonoMediumLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2?url" +import ibmPlexMonoSemiBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-SemiBold-Latin1.woff2?url" +import ibmPlexMonoBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Bold-Latin1.woff2?url" +import opencodeWordmarkDark from "../asset/logo-ornate-dark.svg" +import statsUnfurlRankings from "../asset/unfurl-rankings.png?url" import { - type CountryEntry, getStatsHomeData, type LeaderboardEntry, type MarketDay, @@ -13,97 +18,272 @@ import { } from "@opencode-ai/stats-core/domain/home" import { runtime } from "@opencode-ai/stats-core/runtime" import { createAsync, query } from "@solidjs/router" -import { scaleBand, scaleLinear } from "d3-scale" -import { createMemo, createSignal, For, Show, type JSX } from "solid-js" +import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js" import { getRequestEvent } from "solid-js/web" -import logoDark from "../asset/logo-ornate-dark.svg" -import logoLight from "../asset/logo-ornate-light.svg" -const products = ["All Users", "Zen", "Go", "Enterprise"] as const -const tokenProducts = ["Zen", "Go", "Enterprise"] as const -const ranges = ["1D", "1W", "1M", "3M", "YTD", "ALL"] as const -const usageColors = ["#ff5d64", "#ff8a00", "#8bef00", "#12c8b3", "#18c7dc", "#6c7dff", "#9d73f7"] -const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"] -const countryPositions = [ - { x: 112, y: 96 }, - { x: 284, y: 144 }, - { x: 472, y: 92 }, - { x: 642, y: 154 }, - { x: 800, y: 96 }, - { x: 172, y: 234 }, - { x: 362, y: 250 }, - { x: 552, y: 236 }, - { x: 744, y: 252 }, - { x: 48, y: 184 }, - { x: 892, y: 198 }, - { x: 456, y: 176 }, +const products = ["All Users", "Zen", "Go"] as const +const tokenProducts = ["Zen", "Go"] as const +const ranges = ["1D", "1W", "2W", "1M", "2M"] as const +const rangeLabels: Record = { + "1D": "1 Day", + "1W": "1 Week", + "2W": "2 Weeks", + "1M": "1 Month", + "2M": "2 Months", +} +const statsHomeTitle = "OpenCode Stats" +const statsHomeDescription = "OpenCode usage, market share, token cost, and session cost stats." +const statsHomeFallbackUrl = "https://stats.opencode.ai" +const statsUnfurlAlt = "OpenCode Stats wordmark on a dark patterned background" +const headerLinks = [ + { href: "#top-models", label: "Top Models" }, + { href: "#leaderboard", label: "Leaderboard" }, + { href: "#token-cost", label: "Token Cost" }, + { href: "#session-cost", label: "Session Cost" }, + { href: "#market-share", label: "Market Share" }, ] as const +const githubLink = { + href: "https://github.com/anomalyco/opencode", + apiHref: "https://api.github.com/repos/anomalyco/opencode", + label: "GitHub", + fallbackStars: "150K", + ariaLabel: "Star OpenCode on GitHub", +} +const compactNumberFormatter = new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, +}) +const usageColors = [ + "#ed6aff", + "#a684ff", + "#7c86ff", + "#51a2ff", + "#00d3f2", + "#00d5be", + "#00bc7d", + "#9ae600", + "#ffb900", + "#ff8904", + "#ff6467", +] +const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"] +const themePreferences = ["dark", "light", "system"] as const +const themePreferenceLabels = { + dark: "Dark", + light: "Light", + system: "System", +} as const +const themeStorageKey = "opencode:stats-theme" type UsageProduct = (typeof products)[number] type TokenProduct = (typeof tokenProducts)[number] type UsageRange = (typeof ranges)[number] +type ThemePreference = (typeof themePreferences)[number] const getData = query(async () => { "use server" return runtime.runPromise(getStatsHomeData()) }, "getStatsHomeData") +const getGitHubStars = query(async () => { + "use server" + return fetch(githubLink.apiHref, { + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }) + .then((response) => (response.ok ? response.json() : undefined)) + .then((body: unknown) => + body && typeof body === "object" && "stargazers_count" in body && typeof body.stargazers_count === "number" + ? compactNumberFormatter.format(body.stargazers_count) + : githubLink.fallbackStars, + ) + .catch(() => githubLink.fallbackStars) +}, "getGitHubStars") + export default function StatsHome() { - getRequestEvent()?.response.headers.set( - "Cache-Control", - "public, max-age=60, s-maxage=300, stale-while-revalidate=86400", - ) + const event = getRequestEvent() + event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400") + const statsHomeUrl = new URL( + import.meta.env.BASE_URL, + event?.request.url ?? (typeof window === "undefined" ? statsHomeFallbackUrl : window.location.href), + ).toString() + const statsUnfurlUrl = new URL(statsUnfurlRankings, statsHomeUrl).toString() const data = createAsync(() => getData()) + const githubStars = createAsync(() => getGitHubStars()) + const [themePreference, setThemePreference] = createSignal("system") + const updateThemePreference = (preference: ThemePreference) => { + applyThemePreference(preference) + setThemePreference(preference) + if (typeof window === "undefined") return + window.localStorage.setItem(themeStorageKey, preference) + } + + onMount(() => { + if (typeof window === "undefined") return + const preference = window.localStorage.getItem(themeStorageKey) + const nextPreference = isThemePreference(preference) ? preference : "system" + applyThemePreference(nextPreference) + setThemePreference(nextPreference) + }) return ( -
    - OpenCode Stats - +
    + {statsHomeTitle} + + + + + + + + + + + + + + + + + + + + + +
    -
    }> {(stats) => ( <> - - - + - - + )}
    -
    +
    -
    ) } +function isThemePreference(value: string | null): value is ThemePreference { + return value === "dark" || value === "light" || value === "system" +} + +function applyThemePreference(preference: ThemePreference) { + if (typeof document === "undefined") return + document.documentElement.dataset.statsTheme = preference + if (preference === "system") { + document.documentElement.style.removeProperty("color-scheme") + return + } + document.documentElement.style.setProperty("color-scheme", preference) +} + function Hero(props: { updatedAt: string | null }) { + const [timeZone, setTimeZone] = createSignal("UTC") + const [previousTimeZone, setPreviousTimeZone] = createSignal("UTC") + const [isTicking, setIsTicking] = createSignal(false) + const updatedAtParts = (timeZone: string) => + props.updatedAt ? formatUpdatedAtParts(props.updatedAt, timeZone) : { date: "No rows yet", time: "" } + const previousUpdatedAt = createMemo(() => updatedAtParts(previousTimeZone())) + const currentUpdatedAt = createMemo(() => updatedAtParts(timeZone())) + const currentUpdatedLabel = createMemo(() => + props.updatedAt ? `Updated ${formatUpdatedAtLabel(currentUpdatedAt())}` : "No rows yet", + ) + const isDateTicking = createMemo(() => isTicking() && previousUpdatedAt().date !== currentUpdatedAt().date) + const isTimeTicking = createMemo(() => isTicking() && previousUpdatedAt().time !== currentUpdatedAt().time) + + onMount(() => { + if (!props.updatedAt) return + const nextTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC" + if (nextTimeZone === "UTC") return + if ( + formatUpdatedAtLabel(formatUpdatedAtParts(props.updatedAt, nextTimeZone)) === + formatUpdatedAtLabel(updatedAtParts("UTC")) + ) + return + const timeouts: number[] = [] + timeouts.push( + window.setTimeout(() => { + setPreviousTimeZone(timeZone()) + setTimeZone(nextTimeZone) + setIsTicking(true) + timeouts.push( + window.setTimeout(() => { + setPreviousTimeZone(nextTimeZone) + setIsTicking(false) + }, 720), + ) + }, 480), + ) + onCleanup(() => timeouts.forEach((timeout) => window.clearTimeout(timeout))) + }) + return (
    -
    -

    OpenCode Stats

    -

    - - OpenCode data ·{" "} - {props.updatedAt ? `Updated ${formatUpdatedAt(props.updatedAt)}` : "No rows yet"} +

    + + {props.updatedAt ? ( + <> + + + + ) : ( + No rows yet + )} +

    +
    + -

    See how model usage, provider share, cost, and geography move across OpenCode traffic.

    ) } +function HeroMetaTickerPart(props: { previous: string; current: string; ticking: boolean }) { + return ( + + + {props.previous} + {props.current} + + + ) +} + function StatsLoading() { return ( <> @@ -115,9 +295,15 @@ function StatsLoading() { ) } -function ChartSection(props: { title: string; description?: string; controls?: JSX.Element; children: JSX.Element }) { +function ChartSection(props: { + id?: string + title: string + description?: string + controls?: JSX.Element + children: JSX.Element +}) { return ( -
    +

    {props.title}

    @@ -130,6 +316,25 @@ function ChartSection(props: { title: string; description?: string; controls?: J ) } +function SectionTitle(props: { title: string; description: string }) { + return ( +

    + {props.title}. {props.description} +

    + ) +} + +function SectionBridge(props: { label: string; href: string }) { + return ( +
    + LEAN MORE + + {props.label} + + + ) +} + function EmptyState(props: { title: string; description: string }) { return (
    @@ -139,36 +344,197 @@ function EmptyState(props: { title: string; description: string }) { ) } -function formatUpdatedAt(value: string) { +function formatUpdatedAtParts(value: string, timeZone: string) { const date = new Date(value) - if (Number.isNaN(date.getTime())) return "just now" - return new Intl.DateTimeFormat("en", { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - timeZone: "UTC", - timeZoneName: "short", - }).format(date) + if (Number.isNaN(date.getTime())) return { date: "just now", time: "" } + return { + date: new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + timeZone, + }).format(date), + time: new Intl.DateTimeFormat("en", { + hour: "numeric", + minute: "2-digit", + timeZone, + timeZoneName: "short", + }).format(date), + } } -function UsageSection(props: { data: StatsHomeData["usage"] }) { +function formatUpdatedAtLabel(value: { date: string; time: string }) { + if (!value.time) return value.date + return `${value.date}, ${value.time}` +} + +function TopModelsSection(props: { data: StatsHomeData["usage"]; leaderboard: StatsHomeData["leaderboard"] }) { const [product, setProduct] = createSignal("All Users") - const [range, setRange] = createSignal("1W") + const [range, setRange] = createSignal("2M") + const [sheet, setSheet] = createSignal<"product" | "range">() + const [activeModel, setActiveModel] = createSignal() const data = createMemo(() => props.data[product()][range()]) + const leaderboard = createMemo(() => props.leaderboard[product()][range()]) + + createEffect(() => { + if (!sheet()) return + if (typeof document === "undefined") return + const htmlOverflow = document.documentElement.style.overflow + const bodyOverflow = document.body.style.overflow + document.documentElement.style.overflow = "hidden" + document.body.style.overflow = "hidden" + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setSheet(undefined) + } + document.addEventListener("keydown", onKeyDown) + onCleanup(() => { + document.documentElement.style.overflow = htmlOverflow + document.body.style.overflow = bodyOverflow + document.removeEventListener("keydown", onKeyDown) + }) + }) return ( - +
    +

    + Top models. Usage of models across OpenCode. +

    usageTotal(item) > 0)} fallback={} > - + + +
    + ) +} + +function MobileFilterButton(props: { label: string; value: string; expanded: boolean; onClick: () => void }) { + return ( + + ) +} + +function MobileFilterSheet(props: { + kind: "product" | "range" + product: UsageProduct + range: UsageRange + onProductSelect: (product: UsageProduct) => void + onRangeSelect: (range: UsageRange) => void + onClose: () => void +}) { + return ( +
    +
    + + {(item) => ( + + )} + + } + > + + {(item) => ( + + )} + + +
    +
    + ) +} + +function ChevronDown() { + return ( + ) } @@ -224,158 +590,248 @@ function FilterPills(props: { ) } -function UsageChart(props: { data: UsagePoint[] }) { +function TopModelsChart(props: { + data: UsagePoint[] + range: UsageRange + activeModel: string | undefined + onActiveModelChange: (model: string | undefined) => void +}) { + let chartRef: HTMLDivElement | undefined const [activeIndex, setActiveIndex] = createSignal() - const [activeSegment, setActiveSegment] = createSignal() - const height = 434 - const width = 920 - const headerOffset = 46 - const segmentGap = 2 - const maxTotal = createMemo(() => Math.max(1, Math.max(...props.data.map((item) => usageTotal(item))) * 1.02)) + const maxTotal = createMemo(() => getTopModelsMaxTotal(props.data)) + const segmentOrder = createMemo(() => getTopModelsSegmentOrder(props.data)) const activePoint = createMemo(() => props.data[activeIndex() ?? -1]) - const y = createMemo(() => scaleLinear([0, maxTotal()], [height, 0])) - const x = createMemo(() => - scaleBand( - props.data.map((_, index) => String(index)), - [0, width], - ).paddingInner(0.08), - ) - const activeBar = createMemo(() => { - const index = activeIndex() - const point = activePoint() - if (index === undefined) return - if (!point) return - return { - point, - x: x()(String(index)) ?? 0, - width: x().bandwidth(), - } - }) + + createEffect(() => scrollDenseChartToEnd(chartRef, props.range, props.data.length)) return ( -
    - - - - - - +
    +