Compare commits

..
126 changed files with 1720 additions and 2775 deletions
+1 -1
View File
@@ -184,7 +184,7 @@ const table = sqliteTable("session", {
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
- Keep provider-specific native compaction mechanisms in `@opencode/ai` behind `LLMClient.compact`. `SessionCompaction` chooses a summary or native compaction from the model's `compaction` setting and owns route provenance, request shrinking, the retry policy, interruption, usage accounting, and checkpoint persistence.
- Keep native compaction mechanisms out of `SessionCompaction`. Plugins register `native` strategies through the `SessionCompaction` editor that turn a prepared request into a replacement window (the built-in `NativeCompactionPlugin` handles `@opencode/ai` compaction operations); later registrations win. Core owns the provider-mode decision, route provenance, the retry policy, overflow recovery, interruption, usage accounting, and checkpoint persistence.
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep event replay ownership separate from clustered Session execution ownership.
+57 -69
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -112,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.18",
"version": "2.0.16",
"bin": {
"opencode": "./bin/opencode.cjs",
"opencode2": "./bin/opencode2.cjs",
@@ -135,6 +135,7 @@
"effect": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"open": "10.1.2",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
@@ -176,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -202,7 +203,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -215,7 +216,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -251,7 +252,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -278,7 +279,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -295,7 +296,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -319,7 +320,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -339,7 +340,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -407,7 +408,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
@@ -456,7 +457,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -493,7 +494,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -509,7 +510,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -528,7 +529,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -541,7 +542,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -555,7 +556,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -570,7 +571,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -609,7 +610,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -639,7 +640,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -654,7 +655,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -678,7 +679,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -699,7 +700,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -721,7 +722,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -756,7 +757,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -776,7 +777,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -810,7 +811,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -829,7 +830,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -875,7 +876,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -889,7 +890,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -908,6 +909,7 @@
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
"open": "10.1.2",
"opentui-spinner": "catalog:",
"remeda": "catalog:",
"solid-js": "catalog:",
@@ -923,7 +925,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -958,7 +960,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -980,7 +982,6 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"open": "11.0.4",
"pacote": "21.5.1",
},
"devDependencies": {
@@ -996,7 +997,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1037,7 +1038,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
@@ -1097,7 +1098,6 @@
"@types/node": "catalog:",
"bun-types": "1.4.2",
"effect": "catalog:",
"open": "11.0.4",
"solid-js": "catalog:",
},
"catalog": {
@@ -4322,7 +4322,7 @@
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="],
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"is-document.all": ["is-document.all@1.0.0", "", { "dependencies": { "call-bound": "^1.0.4" } }, "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g=="],
@@ -4340,8 +4340,6 @@
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="],
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
"is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
@@ -4388,7 +4386,7 @@
"is-whitespace": ["is-whitespace@0.3.0", "", {}, "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg=="],
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
@@ -4852,7 +4850,7 @@
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
"open": ["open@11.0.4", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg=="],
"open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="],
"openai": ["openai@6.49.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg=="],
@@ -4996,8 +4994,6 @@
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
"powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="],
"preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="],
"preact-render-to-string": ["preact-render-to-string@6.6.5", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA=="],
@@ -5818,7 +5814,7 @@
"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@1.0.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA=="],
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
@@ -5914,6 +5910,8 @@
"@astrojs/telemetry/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"@astrojs/telemetry/is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="],
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="],
"@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="],
@@ -6396,6 +6394,8 @@
"builder-util/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
"chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -6500,10 +6500,6 @@
"import-in-the-middle/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
@@ -6514,6 +6510,8 @@
"lighthouse/devtools-protocol": ["devtools-protocol@0.0.1663043", "", {}, "sha512-33aOY3ZnBP1dgZsshgaL+/XlsQleiFZgyUaDtdZkEa1nbZhVY1MoDeWjk+wxg25fU924l1ZJfoGNmjjeA/5s1w=="],
"lighthouse/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"lighthouse/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
@@ -6616,6 +6614,8 @@
"sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
"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-solidjs-vite/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"storybook-solidjs-vite/vite": ["vite@7.1.11", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg=="],
@@ -6702,10 +6702,6 @@
"write-file-atomic/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"wsl-utils/powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
"yaml-language-server/prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
"yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="],
@@ -7340,6 +7336,8 @@
"builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"chrome-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -7400,6 +7398,12 @@
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"lighthouse/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
"lighthouse/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"lighthouse/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
@@ -7670,10 +7674,6 @@
"@astrojs/starlight/@astrojs/mdx/@astrojs/markdown-remark/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
"@astrojs/starlight/astro/@astrojs/telemetry/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"@astrojs/starlight/astro/@astrojs/telemetry/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"@astrojs/starlight/astro/p-queue/p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
"@astrojs/starlight/astro/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
@@ -8076,10 +8076,6 @@
"@opencode/web/@astrojs/cloudflare/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
"@opencode/web/astro/@astrojs/telemetry/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"@opencode/web/astro/@astrojs/telemetry/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"@opencode/web/astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"@opencode/web/astro/p-queue/p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
@@ -8220,10 +8216,6 @@
"archiver-utils/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"astro-expressive-code/astro/@astrojs/telemetry/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"astro-expressive-code/astro/@astrojs/telemetry/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"astro-expressive-code/astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"astro-expressive-code/astro/p-queue/p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
@@ -8326,10 +8318,6 @@
"temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"toolbeam-docs-theme/astro/@astrojs/telemetry/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"toolbeam-docs-theme/astro/@astrojs/telemetry/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"toolbeam-docs-theme/astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"toolbeam-docs-theme/astro/p-queue/p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-9gJjhes2ueYckAgdeGlPwZcaIDdwB3ZnqK/XHHXhWNs=",
"aarch64-linux": "sha256-Sy5YXYM9tKevIITdV++bP35SJNaFCQVKwlNJRbWsD1Q=",
"aarch64-darwin": "sha256-wiXHjKXm2VIFvalwITpSiRHaFZEWc8UJIqjQyc/0f0s=",
"x86_64-darwin": "sha256-r/mnhdNbnPIJOY3qvtuY6GQ7ed1Nauq65X+8uERhdP8="
"x86_64-linux": "sha256-aQQQhaUlAhpfqzH0vNi0IJ1cg7FQHIKYzxeq5d8PZoU=",
"aarch64-linux": "sha256-r9aDFu3UYmudmmYPhzCrpFvQlaejXc8V1IzLtG3jZPc=",
"aarch64-darwin": "sha256-B0m41LelD7d61vPHGIZZSO/cU7gbjHDJHt6oxNRRM8Q=",
"x86_64-darwin": "sha256-9TWJsyI3Y6BMomtGSgqA1th9LpxpqP4F5Tl/GyexVYw="
}
}
+1 -2
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.18",
"version": "2.0.16",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
@@ -162,7 +162,6 @@
"@types/node": "catalog:",
"bun-types": "1.4.2",
"effect": "catalog:",
"open": "11.0.4",
"solid-js": "catalog:"
},
"patchedDependencies": {
+4 -4
View File
@@ -12,9 +12,9 @@
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Message.media(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. `LLM.generate`/`LLM.stream` and Promise `ai.llm.generate`/`ai.llm.stream` accept ergonomic input or an `LLMRequest`; both paths use the same canonical request. Core still builds, logs, replays, and updates that durable `LLMRequest` boundary. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path.
Modality namespaces mirror `LLM` exactly: `Image.request`, `Image.generate`, `Image.stream`, and the same for `Video`, `Speech`, and `Transcription`. Common request fields (`images`, `mask`, `n`, `size`, `aspectRatio`, `seed`, `format`) lower natively or fail with a typed `AIError`; provider-native controls always live under `providerOptions`, never under a modality-specific `options` key.
Modality namespaces mirror `LLM` exactly: `Image.request`, `Image.generate`, `Image.stream` (later `Video`, `Speech`, `Transcription`). Common request fields (`images`, `mask`, `n`, `size`, `aspectRatio`, `seed`, `format`) lower natively or fail with a typed `AIError`; provider-native controls always live under `providerOptions`, never under a modality-specific `options` key.
Media payloads are always `Media.Asset` (`src/media.ts`). Construct them with `Media.bytes`, `Media.base64`, `Media.url`, `Media.ref`, `Media.fromDataUrl`, or `Media.file`; never introduce a parallel `data: string | Uint8Array` shape. `MediaPart.media`, `ImageRequest.images`/`mask`, `ImageResponse.images`, and the `media` `LLMEvent` all share it. Protocols branch on `asset.source.type` and `asset.kind` and use `ProviderShared.requireInlineMedia` / `inlineRequired` / `mediaUrl` / `mediaReference` and `MediaInput.inlineBytes` / `refID` rather than re-deriving base64 or URL handling.
Media payloads are always `Media.Asset` (`src/media.ts`). Construct them with `Media.bytes`, `Media.base64`, `Media.url`, `Media.ref`, `Media.fromDataUrl`, or `Media.file`; never introduce a parallel `data: string | Uint8Array` shape. `MediaPart.media`, `ImageRequest.images`/`mask`, `ImageResponse.images`, and the `media` `LLMEvent` all share it. Protocols branch on `asset.source.type` and `asset.kind` and use `ProviderShared.inlineMedia` / `requireInlineMedia` / `mediaUrl` / `MediaInput.refID` rather than re-deriving base64 or URL handling.
`schema/messages.ts → media.ts → route/executor-service.ts` is an accepted runtime dependency from the schema layer on the executor service tag: `Media.Asset.bytes()` must be able to download `url` sources, and the tag lives in that leaf module precisely so the schema barrel never imports the executor implementation (which imports the schema barrel back). Do not move the tag into `route/executor.ts` or import `route/executor.ts` from `src/schema/*` or `src/media.ts`.
@@ -98,7 +98,7 @@ When a provider supports multiple physical transports, selection remains executi
Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.inline(...)` / `queued(...)` / `stream(...)` (`src/route/media.ts`) compose a `MediaProtocol` kind with `Endpoint` and `Auth` and own the transport plumbing: `http` option merging, URL/query rendering, auth headers, JSON vs multipart encoding, and handing the response back to the protocol. `MediaProtocol.inline` (`src/route/media-protocol.ts`) is `body.from(request)` plus `response.decode(response, context)`; each protocol declares `const route = MediaProtocol.identity({ id, name, provider })` once and decodes through `route.decodeJson` / `route.text` / `route.decodeStarted` so decode failures retain the raw body and HTTP context, raising `route.unsupported(operation, message)` for requests it cannot lower, and passes `route` as the first argument to `MediaProtocol.inline` / `queued` / `stream`. `Generation` (`src/generation.ts`) is the provider-neutral handle for a queued generation over a `GenerationRoute` (`status`, `result`, `cancel`). Image protocol files follow the same section order as LLM protocols and declare unsupported common fields once through the protocol's `unsupported` list.
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel` (with `activeOnly` when the provider's cancel endpoint deletes finished work, as Runway's does: the route refreshes status first and skips terminal generations), each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added or changed so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel`, each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added or changed so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
`MediaProtocol.stream` is the incremental kind every speech route uses, with the same discipline as LLM protocols. `MediaRoute.stream` submits the caller's request as `MediaProtocol.Addressed<Request>` (`{ ...request, mode }`, `mode: "generate" | "stream"`), so one provider stays one protocol: `body.from`, the endpoint path, and `frames` read `request.mode` to pick the body, path, and framing. `frames(bytes, context)` returns frames — `Framing.sse`, `Framing.lines`, `Framing.document` (a single-document response shaped like a streamed record), or the raw `bytes` for chunked audio. `initial()` is fresh per-response parser state; `step` folds each frame into it and emits modality events; `finish(state, context)` runs once after the last frame with the request, body, and observed `http` (header-only usage lives there) and emits exactly one terminal event or fails with `route.incomplete()`. Keep parser state to real accumulators and derive anything the request or body determines in `finish`. `generate` runs the same stream and folds it with the modality's `collect`. Request-derived URL parameters go on the body's `query` (array values repeat the parameter), applied before route and caller `http.query`. Decode frames with `route.decodeFrame` and raise stream-time failures with `route.frameError` (the frame stays on `reason.body`); protocols never thread HTTP context, because the route fills `reason.http` on stream errors that lack it. Speech protocols share `protocols/utils/speech-stream.ts` for deltas, timestamps, voice ids, PCM and container descriptions, and the terminal asset.
@@ -112,7 +112,7 @@ For providers where the URL is derived from typed inputs (Azure resource name, B
### Provider Facades
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id. Media models use per-modality selectors on the same facade (`openai.image(id)`, `.speech(id)`, `.transcription(id)`, `google.video(id)`) that mirror `openai.responses(id)`; the one-word overlap with the request namespace is accepted over a second construction path:
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id. Media models use per-modality selectors on the same facade (`openai.image(id)`, later `.video` / `.speech` / `.transcription`) that mirror `openai.responses(id)`; the one-word overlap with the request namespace is accepted over a second construction path:
```ts
const openai = OpenAI.configure({ apiKey, baseURL })
+18 -19
View File
@@ -475,18 +475,18 @@ const program = Effect.gen(function* () {
Common fields are portable in shape, not in support. Unsupported fields fail with a typed `AIError` before any network
call rather than being dropped, so check this table before swapping only the `model`:
| Provider | `n` | `size` | `aspectRatio` | `seed` | `format` | `images` | `mask` |
| --------------------- | --- | --------- | ------------- | ------ | -------- | -------------------------------- | ------------------- |
| OpenAI | ✓¹ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ |
| Google (Gemini) | 1 | ✗ | ✓ | ✓ | ✗ | ✓ (no public URLs) | ✗ |
| xAI | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ |
| Z.ai | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| Meta | ✓ | ✓ (hint) | ✗ | ✗ | ✓ | ✓ | ✗ |
| Black Forest Labs | 1 | per model | per model | ✓ | ✓ | per model (1–8) | `flux-pro-1.0-fill` |
| fal | ✓ | per model | per model | ✓ | ✓ | 1 (several on `/edit`, `/multi`) | ✓ |
| Replicate | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ (use `providerOptions`) | ✗ |
| Stability `image` | 1 | ✗ | ✓ | ✓ | ✓ | 1 (not on `core`) | ✗ |
| Stability `upscale()` | ✗ | ✗ | ✗ | ✓ | ✓ | exactly 1 (required) | ✗ |
| Provider | `n` | `size` | `aspectRatio` | `seed` | `format` | `images` | `mask` |
| --------------------- | --- | --------- | ------------- | ------ | -------- | ------------------------- | ------------------- |
| OpenAI | ✓¹ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ |
| Google (Gemini) | 1 | ✗ | ✓ | ✓ | ✗ | ✓ (no public URLs) | ✗ |
| xAI | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ |
| Z.ai | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| Meta | ✓ | ✓ (hint) | ✗ | ✗ | ✓ | ✓ | ✗ |
| Black Forest Labs | 1 | per model | per model | ✓ | ✓ | per model (1–8) | `flux-pro-1.0-fill` |
| fal | ✓ | per model | per model | ✓ | ✓ | 1 (several on `/edit`) | ✓ |
| Replicate | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ (use `providerOptions`) | ✗ |
| Stability `image` | 1 | ✗ | ✓ | ✓ | ✓ | 1 (not on `core`) | ✗ |
| Stability `upscale()` | ✗ | ✗ | ✗ | ✓ | ✓ | exactly 1 (required) | ✗ |
✓ lowers natively; ✗ fails whenever the field is set (including `n: 1`); `1` means `n > 1` fails. ¹ `Image.stream` on OpenAI generates one image. fal
rejects `size` and `aspectRatio` together; which one a fal or BFL model takes depends on the model.
@@ -621,7 +621,8 @@ persist the bytes promptly if they must remain available.
### Partial images
OpenAI's GPT image models stream previews. `Image.stream` sends `stream: true` with `partialImages` (0–3, default 2)
and emits `image-partial` events before each final `image`; `Image.generate` keeps the plain JSON request:
and emits `image-partial` events before each final `image`; `Image.generate` keeps the plain JSON request.
`dall-e-*` models do not stream and fail typed:
```ts
import { Stream } from "effect"
@@ -698,7 +699,7 @@ const program = Effect.gen(function* () {
})
```
The hosted result is represented as a provider-executed tool call and a tool result whose content carries the generated image as a file. Gemini image-capable models instead emit a first-class `media` `LLMEvent` for inline image output (`response.message` then carries a `media` part). Retaining `response.message` preserves the generated image for continuation on both routes.
The hosted result is represented as a provider-executed tool call and tool result, and the generated image is also emitted as a first-class `media` `LLMEvent` (`response.message` then carries a `media` part). Gemini image-capable models emit the same `media` event for inline image output. Retaining `response.message` preserves the generated image for continuation on both routes.
## Video generation
@@ -839,10 +840,9 @@ Provider notes:
- **OpenAI** streams over SSE (`stream_format: "sse"`), which is also the only place it reports token usage; `tts-1`
and `tts-1-hd` do not support SSE and stream the raw audio body instead. `pcm` is 24 kHz 16-bit mono. `language`
and `timestamps` are not supported.
- **Gemini TTS** returns the provider's default output: WAV for Gemini 3.8 TTS `generate`, raw 16-bit PCM
(`audio/L16;codec=pcm;rate=24000`) otherwise. `pcm` is the only explicit `format` it accepts, and it fails typed on
Gemini 3.8 `generate`; the route never wraps PCM as WAV. Style is directed in the text, so `instructions` and
`speed` fail typed. Only `gemini-3.1-flash-tts-preview` and later support streaming. Two-speaker audio goes through
- **Gemini TTS** returns raw 16-bit PCM only (`audio/L16;codec=pcm;rate=24000`), so any `format` other than `pcm`
fails typed; wrap the samples yourself. Style is directed in the text, so `instructions` and `speed` fail typed.
Only `gemini-3.1-flash-tts-preview` and later support streaming. Two-speaker audio goes through
`providerOptions.speechConfig.multiSpeakerVoiceConfig`.
- **ElevenLabs** requires `voice` (the path voice id) and authenticates with `xi-api-key`. `format` maps to the
`output_format` query parameter (`mp3_44100_128`, `pcm_24000`, `wav_24000`, `opus_48000_64`);
@@ -944,7 +944,6 @@ const transcript = await generation.await({ poll: { interval: 3_000 } })
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
- **`Media`** — the shared asset type (`Media.Asset`, `Media.Source`) and constructors used by messages, tool results, and media requests.
- **`Generation`** — provider-neutral handle for an in-flight media generation (`await`, `refresh`, `cancel`, `events`) used by queued media routes.
- **`Video.request` / `generate` / `stream` / `start` / `resume`** — queued video generation through a provider-neutral request; `VideoClient` is its Effect service and layer.
- **`Speech.request` / `Speech.generate` / `Speech.stream`** — text-to-speech through a provider-neutral request; `SpeechClient` is its Effect service and layer.
- **`Transcription.request` / `generate` / `stream` / `start` / `resume`** — speech-to-text over inline, streaming, and queued routes; `TranscriptionClient` is its Effect service and layer.
- **`AIClient.layer` / `AIClient.layerWith(executor)`** — every modality client plus the request executor in one layer.
+29 -36
View File
@@ -1,7 +1,6 @@
# Media generation in `@opencode/ai` — public API direction
Status: phases 1–4 implemented (through Image queued routes and partial images; ElevenLabs Scribe transcription
pending); phase 5 proposal.
Status: phases 1–4 implemented (through Image queued routes and partial images); phase 5 proposal.
## Goal
@@ -41,7 +40,7 @@ The design below is derived from a survey of the raw provider APIs (OpenAI, Gemi
### Model selection
A model value is built as `OpenAI.configure({ apiKey }).responses("gpt-5")` or `.image("gpt-image-2")`: `configure` fixes credentials, endpoint, and defaults; the selector fixes which of the provider's APIs to hit and binds the typed `providerOptions` generic. Media follows the same shape with one selector per modality — `.image(id)`, `.video(id)`, `.speech(id)`, `.transcription(id)` on the facades that offer each — mirroring `openai.responses(id)`. `Image.request` accepts `ImageModel` only, exactly as `LLM.request` accepts `LanguageModel`.
A model value is built as `OpenAI.configure({ apiKey }).responses("gpt-5")` or `.image("gpt-image-2")`: `configure` fixes credentials, endpoint, and defaults; the selector fixes which of the provider's APIs to hit and binds the typed `providerOptions` generic. Media follows the same shape with one selector per modality — `openai.image(id)` today, `.video(id)` / `.speech(id)` / `.transcription(id)` as those modalities land — mirroring `openai.responses(id)`. `Image.request` accepts `ImageModel` only, exactly as `LLM.request` accepts `LanguageModel`.
```ts
import { OpenAI, Google } from "@opencode/ai/providers"
@@ -136,8 +135,8 @@ Effect.gen(function* () {
})
```
`size` and `aspectRatio` are not interchangeable; each route rejects fields it cannot lower — see the portability table
in the README's Image generation section.
`size` and `aspectRatio` are not interchangeable; each route rejects fields it cannot lower — see the README's Image
portability matrix.
Editing is not a separate function; `images`/`mask` on the request select the edit path in the route (OpenAI `/images/edits`, Gemini multimodal parts, xAI `/images/edits`). Routes that cannot honor `mask` fail with `Unsupported`.
@@ -167,8 +166,8 @@ Effect.gen(function* () {
// Simple: wait for it.
const response = yield* Video.generate(request, { poll: { interval: "10 seconds", timeout: "10 minutes" } })
response.video // Media.Asset: url (expiresAt on Veo and Runway; transient `headers` for Veo downloads)
response.usage // credits on Runway; the other three report none (xAI's usage.cost_in_usd_ticks is not decoded)
response.video // Media.Asset: url with expiresAt (+ transient `headers` for Veo downloads)
response.usage // credits on Runway; the other three report none
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
yield* response.video.materialize() // pull bytes before the URL expires
@@ -176,7 +175,7 @@ Effect.gen(function* () {
const generation = yield* Video.start(request) // Generation<VideoResponse>
generation.id; generation.status; generation.progress; generation.position; generation.token
yield* generation.await({ poll }) // VideoResponse
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; Veo and xAI succeed without a request
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; no-op for Veo and xAI
// Resume from another process. The token is validated against the route's codec and refreshed once. It carries no
// route identity, so persist the provider and model ID alongside it: `resume` needs the model.
@@ -189,11 +188,10 @@ Effect.gen(function* () {
Tokens are route-owned JSON: Veo `{ operation }`, xAI `{ requestID }`, Runway `{ taskID }`, fal
`{ requestID, statusURL, responseURL, cancelURL }` (fal's follow-up URLs are authoritative and absolute). Common-field
lowering per provider: Veo takes inline media only, rejects `audio: false` and `n > 1`, and requires `frames.first`
when `frames.last` is set; xAI rejects `n`, `seed`, and `negativePrompt` and routes a `video` input to edits or
(`providerOptions.mode: "extend"`) extensions; fal rejects `n`, plus `durationSeconds`, `references`, and `frames.last`
because the field names and enums differ per model; Runway passes `aspectRatio` through as its pixel `ratio` and
rejects `n`.
lowering per provider: Veo takes inline media only and rejects `audio: false` and `n > 1`; xAI rejects `seed` and
`negativePrompt` and routes a `video` input to edits or (`providerOptions.mode: "extend"`) extensions; fal rejects
`durationSeconds`, `references`, and `frames.last` because the field names and enums differ per model; Runway passes
`aspectRatio` through as its pixel `ratio` and rejects `n`.
Deferred: `Video.complete(model, token, webhook)` (finish from a webhook payload without polling) and provider poll
hints (none of the four providers emit one). Later providers: Luma, Kling, MiniMax, Replicate.
@@ -243,12 +241,10 @@ name→id resolution. Multi-speaker (Gemini `speechConfig.multiSpeakerVoiceConfi
`opus_48000_64`, Cartesia `{ container, encoding, sample_rate }`, Deepgram `encoding`+`container`) and declares the
asset's media type rather than sniffing, because headerless PCM can look like an MPEG frame sync. Headerless PCM
always carries `info.encoding`, `info.sampleRate`, and `info.channels`; its media type is the provider's declaration
(Gemini `audio/L16;codec=pcm;rate=24000`, Deepgram's `content-type`) or `audio/pcm`. Gemini's asset follows the
provider's declared type: WAV for Gemini 3.8 TTS `generate`, headerless PCM otherwise. The route never wraps PCM as WAV,
so `pcm` is the only explicit `format` it accepts, and not on Gemini 3.8 `generate`. Every `format` value a route cannot
produce (unknown to it, a container on Cartesia SSE, WAV on an ElevenLabs stream, anything but `pcm` on Gemini, `pcm` on
Gemini 3.8 `generate`) fails the same way as an unsupported field: `UnsupportedOperation` with
`operation: "media.format"`.
(Gemini `audio/L16;codec=pcm;rate=24000`, Deepgram's `content-type`) or `audio/pcm`. Gemini returns PCM only, so any
other `format` is rejected rather than wrapped as WAV by the route. Every `format` value a route cannot produce (unknown
to it, a container on Cartesia SSE, WAV on an ElevenLabs stream, anything but PCM on Gemini) fails the same way as an
unsupported field: `UnsupportedOperation` with `operation: "media.format"`.
**Timestamps.** `timestamps: true` on the request asks for alignment. ElevenLabs selects the `with-timestamps`
endpoints (character-level, NDJSON when streaming); Cartesia sets `add_timestamps` on `/tts/sse` (word-level; a
@@ -281,7 +277,7 @@ const request = Transcription.request({
language: "en", // provider-native passthrough
timestamps: "segment", // none | segment | word
diarize: true,
speakers: 2, // exact speaker count (AssemblyAI only)
speakers: 2, // expected count, hint only (AssemblyAI)
providerOptions: { known_speaker_names: ["agent"] },
})
@@ -295,11 +291,10 @@ yield* Transcription.resume(model, token)
Transcription is the first modality whose providers span all three protocol kinds, and it needed no fourth kind.
Every `MediaRoute` now carries its `kind`; `TranscriptionRoute` is the union of the inline, stream, and queued routes;
`TranscriptionModel.fromRoute` is overloaded per protocol kind (arity picks the overload: `<Options>`,
`<Options, Frame, State>`, `<Options, Token>`) and composes through the shared `composeRoute` (`src/media-model.ts`),
which picks `MediaRoute.inline` / `stream` / `queued`; and `TranscriptionClient`, like every modality client, is
`MediaClient.make` (`src/media-client.ts`), which dispatches on `route.kind`. `generate` on a queued route is `start`
then `await`; `stream` on an inline route is the response as a single `finish`, and on a queued route it is the status
observations followed by `finish`. `start` / `resume` on a non-queued route fail with `UnsupportedOperation` (`transcription.start`). The
`<Options, Frame, State>`, `<Options, Token>`) and composes through `MediaRoute.inline` / `stream` / `queued`; and
`TranscriptionClient` dispatches on `route.kind`. `generate` on a queued route is `start` then `await`; `stream` on an
inline route is the response as a single `finish`, and on a queued route it is the status observations followed by
`finish`. `start` / `resume` on a non-queued route fail with `UnsupportedOperation` (`transcription.start`). The
`finish` event carries the whole transcript (text, segments, words, language, duration, usage), so the stream route's
`collect` is just "take `finish`".
@@ -314,12 +309,11 @@ Settled rules:
word offsets, so segment timestamps and diarization also request word offsets there.
- **Diarization.** `diarize` means segments (and words, where the provider labels them) carry `speaker`. Labels are
provider-native strings — OpenAI `A` or a known speaker name, Deepgram `0`, Gemini `spk:0`, AssemblyAI `A` — with no
cross-provider speaker model. `speakers` is the exact number of speakers to label, which AssemblyAI (`speakers_expected`, the only route that
accepts it) treats as a constraint rather than a hint.
cross-provider speaker model. `speakers` is a hint; only AssemblyAI (`speakers_expected`) accepts it.
- **Language** is passed through (`language`, OpenAI `gpt-transcribe` `languages[]`, Gemini `languageCodes`,
AssemblyAI `language_code`). `response.language` is the provider's own value, lowercased but not normalized: an
ISO code on most routes (AssemblyAI's detection returns `en`), `english` from whisper-1. Deepgram and AssemblyAI
assume English unless asked to detect, so a missing `language` enables their detection.
ISO code on most routes, `english` from whisper-1, `en_us` from AssemblyAI. Deepgram and AssemblyAI assume English
unless asked to detect, so a missing `language` enables their detection.
- **Gemini** requires a transcribe model; other model ids fail with `UnsupportedOperation` before the call, because
general models ignore `audioTranscriptionConfig` and answer conversationally. Streamed chunks carry whole speaker
turns (one part per turn), which join with a space.
@@ -329,7 +323,7 @@ Settled rules:
| Provider | Kind | Audio input | `timestamps` | `diarize` | Unsupported | Usage |
|---|---|---|---|---|---|---|
| OpenAI | stream (`stream: true` in `stream` mode; `whisper-1` ignores `stream`, so it emits only `finish`) | multipart `file` (inline only) | `whisper-1` (`verbose_json`); diarize model: `segment` | `gpt-4o-transcribe-diarize` (`diarized_json`) | `speakers`; `prompt` on the diarize model | `tokens` or `seconds` |
| OpenAI | stream (`stream: true` in `stream` mode) | multipart `file` (inline only) | `whisper-1` (`verbose_json`); diarize model: `segment` | `gpt-4o-transcribe-diarize` (`diarized_json`) | `speakers`; `prompt` on the diarize model; streaming on `whisper-1` | `tokens` or `seconds` |
| Gemini | stream (`generateContent` / `streamGenerateContent`) | `inlineData` or Gemini Files `fileData` | `audioTranscriptionConfig.wordTimestamp` | `audioTranscriptionConfig.diarization` | `prompt`, `speakers` | `tokens` |
| Deepgram | inline | raw body, or JSON `{ url }` | words always; `segment` → `utterances` | `diarize_model=latest` + `utterances` | `prompt`, `speakers` | `seconds` (`metadata.duration`) |
| AssemblyAI | queued (upload → submit → poll) | `/v2/upload` then `audio_url`, or a URL | words always; `segment` → `speaker_labels` | `speaker_labels` | — | `seconds` (`audio_duration`) |
@@ -359,7 +353,7 @@ GenerationAwaitOptions = { poll?: Poll }
Poll = { interval?: Duration; timeout?: Duration }
```
`Generation` is not video-specific. Image routes on BFL, fal, Replicate, and Stability `upscale()` are queued; `Image.start` exists for them. A route declares itself `inline` or `queued`; `generate` on a queued route is `start` then `await`.
`Generation` is not video-specific. Image routes on BFL, fal, and Replicate are queued; `Image.start` exists for them. A route declares itself `inline` or `queued`; `generate` on a queued route is `start` then `await`.
### Usage
@@ -406,15 +400,14 @@ Streams become `AsyncIterable` via `Stream.toAsyncIterable`. `AIError` is thrown
### Providers
Existing facades gain per-modality selectors; the modality routes each facade provides (*italics* are not
implemented):
Existing facades gain per-modality selectors; the modality routes each facade provides:
| Facade | llm | image | video | speech | transcription | other |
|---|---|---|---|---|---|---|
| `OpenAI` | responses (default), chat | Images API (stream) | *Sora skipped (decision 8)* | ✓ | ✓ | |
| `OpenAI` | responses (default), chat | Images API (stream) | Sora (deprecated 2026-09-24) | ✓ | ✓ | |
| `Google` | Gemini | Gemini-native | Veo | Gemini TTS | `gemini-3.5-transcribe` | |
| `XAI` | ✓ | ✓ | ✓ | | | |
| `ElevenLabs` | | | | ✓ | *Scribe (pending)* | *soundEffect, music (phase 5)* |
| `ElevenLabs` | | | | ✓ | Scribe | soundEffect, music |
| `Cartesia` | | | | ✓ | | |
| `Deepgram` | | | | Aura | ✓ | |
| `Fal` | | ✓ (queued) | ✓ | | | |
@@ -423,7 +416,7 @@ implemented):
| `Replicate` | | ✓ (queued) | | | | |
| `Stability` | | `image` (inline), `upscale()` (queued) | | | | |
| `Runway` | | | ✓ | | | |
| `Luma`, `Kling`, `MiniMax` | | *deferred* | *deferred* | | | |
| `Luma`, `Kling`, `MiniMax` | | per provider | | | | |
New facades follow the existing one-file-per-provider rule. The facade selector is the public path for media models; modality-specific package entrypoints (for example `@opencode/ai/providers/openai/images`) are deferred until Core has a modality-aware model resolver.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.18",
"version": "2.0.16",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+11 -23
View File
@@ -53,8 +53,6 @@ export type Event = Observation | { readonly type: "generation-finished"; readon
const TERMINAL: ReadonlySet<Status> = new Set(["completed", "failed", "cancelled", "expired"])
export const isTerminal = (status: Status) => TERMINAL.has(status)
export class Generation<Response> {
readonly id: string
readonly status: Status
@@ -83,7 +81,7 @@ export class Generation<Response> {
}
get terminal() {
return isTerminal(this.status)
return TERMINAL.has(this.status)
}
refresh(): Effect.Effect<Generation<Response>, AIError> {
@@ -111,10 +109,9 @@ export class Generation<Response> {
}
/**
* Status observations as a stream, ending after the first terminal observation. Each poll and each sleep between polls
* is bounded by the time remaining until `poll.timeout`, so a hung status request or a long interval fails the stream at
* the deadline instead of stalling it. (`Stream.interruptWhen` would express this directly but deadlocks under
* `TestClock` when the source completes while the timer sleeps.)
* Status observations as a stream, ending after the first terminal observation. Each poll is bounded by the time
* remaining until `poll.timeout`, so a hung status request fails the stream instead of stalling it. (`Stream.interruptWhen`
* would express this directly but deadlocks under `TestClock` when the source completes while the timer sleeps.)
*/
events(options?: AwaitOptions): Stream.Stream<Event, AIError> {
if (this.terminal) return Stream.make(this.event())
@@ -123,26 +120,17 @@ export class Generation<Response> {
Clock.currentTimeMillis.pipe(
Effect.map((start) => {
const deadline = start + Duration.toMillis(timeout)
// Fail before polling once the deadline has passed: a fast status request could otherwise win the zero-budget
// race and schedule another zero-delay poll.
const refresh = Clock.currentTimeMillis.pipe(
Effect.flatMap((now) =>
now >= deadline
? this.timeoutError(timeout)
: this.refresh().pipe(
Effect.timeoutOrElse({
duration: Duration.millis(deadline - now),
orElse: () => this.timeoutError(timeout),
}),
),
this.refresh().pipe(
Effect.timeoutOrElse({
duration: Duration.millis(Math.max(0, deadline - now)),
orElse: () => this.timeoutError(timeout),
}),
),
),
)
const schedule = this.schedule(options?.poll).pipe(
Schedule.modifyDelay((meta) =>
Effect.succeed(Duration.min(meta.duration, Duration.millis(Math.max(0, deadline - meta.now)))),
),
)
return Stream.fromEffectSchedule(refresh, schedule).pipe(
return Stream.fromEffectSchedule(refresh, this.schedule(options?.poll)).pipe(
Stream.takeUntil((generation) => generation.terminal),
Stream.map((generation) => generation.event()),
)
@@ -110,11 +110,8 @@ const fromRequest = Effect.fn("AssemblyAITranscription.fromRequest")(function* (
language_code: request.language,
language_detection: request.language === undefined ? true : undefined,
prompt: request.prompt,
// Turn-level `utterances`, the only segments AssemblyAI returns, and `speakers_expected` require speaker labels.
speaker_labels:
request.diarize === true || request.timestamps === "segment" || request.speakers !== undefined
? true
: undefined,
// Turn-level `utterances`, the only segments AssemblyAI returns, require speaker labels.
speaker_labels: request.diarize === true || request.timestamps === "segment" ? true : undefined,
speakers_expected: request.speakers,
},
request.providerOptions,
@@ -158,7 +155,8 @@ const decodeResult = Effect.fn("AssemblyAITranscription.decodeResult")(function*
const error = transcript.error ?? undefined
if (status === "failed")
return yield* output.ended("failed", `${route.name} transcription failed${error === undefined ? "" : `: ${error}`}`)
if (status !== "completed") return yield* output.pending(context.token.transcriptID)
if (status !== "completed")
return yield* output.invalid(`${route.name} transcript ${context.token.transcriptID} has not finished`)
const duration = transcript.audio_duration ?? undefined
return new TranscriptionResponse({
text: transcript.text ?? "",
+6 -20
View File
@@ -31,21 +31,13 @@ export type Request = ImageRequestFor<BlackForestLabsImageOptions>
// 2. Token and response schemas
// ---------------------------------------------------------------------------
/**
* Regional clusters answer on different hosts, so the returned `polling_url` is followed verbatim. BFL reports the
* credit cost on submit, so it rides on the token; it is optional so tokens persisted before it existed still decode.
*/
export const Token = Schema.Struct({
id: Schema.String,
pollingURL: Schema.String,
cost: Schema.optionalKey(Schema.Number),
})
/** Regional clusters answer on different hosts, so the returned `polling_url` is followed verbatim. */
export const Token = Schema.Struct({ id: Schema.String, pollingURL: Schema.String })
export type Token = Schema.Schema.Type<typeof Token>
const StartResponse = Schema.Struct({
id: Schema.String,
polling_url: Schema.String,
cost: optionalNull(Schema.Number),
})
const Result = Schema.Struct({
@@ -153,11 +145,7 @@ const fromRequest = Effect.fn("BlackForestLabsImages.fromRequest")(function* (re
// ---------------------------------------------------------------------------
const decodeStart = route.decodeStarted(StartResponse, (value) => ({
token: {
id: value.id,
pollingURL: value.polling_url,
...(value.cost === undefined || value.cost === null ? {} : { cost: value.cost }),
},
token: { id: value.id, pollingURL: value.polling_url },
snapshot: { id: value.id, status: "queued" },
}))
@@ -181,16 +169,14 @@ const decodeResult = Effect.fn("BlackForestLabsImages.decodeResult")(function* (
if (isModerated(document.status)) return yield* output.contentPolicy(`${route.name} moderated the generation`)
if (status === "failed" || status === "expired")
return yield* output.ended(status, `${route.name} generation ${context.token.id} ended with ${document.status}`)
if (status !== "completed") return yield* output.pending(context.token.id)
if (document.result === undefined || document.result === null)
if (status !== "completed" || document.result === undefined || document.result === null)
return yield* output.invalid(`${route.name} generation ${context.token.id} has no result`)
const { sample, seed, prompt, ...rest } = document.result
// A settled `cost` on the result supersedes the submit-time cost carried on the token.
const cost = document.cost ?? context.token.cost
return new ImageResponse({
// `sample` is a signed URL that expires 10 minutes after the result is ready, so it is downloaded now.
images: [yield* context.materialize(Media.url(sample))],
usage: cost === undefined ? undefined : { type: "credits", credits: cost },
usage:
document.cost === undefined || document.cost === null ? undefined : { type: "credits", credits: document.cost },
providerMetadata: {
bfl: { id: context.token.id, seed: seed ?? undefined, prompt: prompt ?? undefined, ...rest },
},
+1 -4
View File
@@ -67,9 +67,6 @@ const queryParameters = (request: Request) => {
}
const fromRequest = Effect.fn("DeepgramSpeech.fromRequest")(function* (request: Request) {
// Not in `unsupported`: that list would also reject `timestamps: false`, which asks for nothing.
if (request.timestamps === true)
return yield* route.unsupported("media.timestamps", `${route.name} does not return timestamps`)
if (
request.format !== undefined &&
FORMATS[request.format] === undefined &&
@@ -120,7 +117,7 @@ const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) =
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.stream<Request, SpeechEvent, Uint8Array, State>(route, {
unsupported: ["voice", "language", "instructions"],
unsupported: ["voice", "language", "instructions", "timestamps"],
body: { from: fromRequest },
frames: (bytes) => bytes,
initial: () => ({ chunks: [] }),
+14 -20
View File
@@ -49,7 +49,7 @@ const QueueResult = Schema.StructWithRest(
// ---------------------------------------------------------------------------
const sizing = (model: string) => {
if (/^fal-ai\/(nano-banana|flux-pro\/(v1\.1-ultra|kontext))/.test(model)) return "aspect_ratio"
if (/^fal-ai\/(nano-banana|flux-pro\/v1\.1-ultra)/.test(model)) return "aspect_ratio"
if (model.startsWith("fal-ai/flux")) return "image_size"
return undefined
}
@@ -63,24 +63,20 @@ const validate = (request: Request) => {
return Effect.fail(route.unsupported("media.size", `${id} sizes by aspectRatio`))
if (request.aspectRatio !== undefined && field === "image_size")
return Effect.fail(route.unsupported("media.aspectRatio", `${id} sizes by size (image_size)`))
if ((request.images?.length ?? 0) > 1 && !takesImageList(id))
if ((request.images?.length ?? 0) > 1 && !isEdit(id))
return Effect.fail(
route.unsupported(
"media.images",
`${id} takes one image_url; use an /edit or /multi endpoint for several images`,
),
route.unsupported("media.images", `${id} takes one image_url; use an /edit endpoint for several images`),
)
return Effect.void
}
// `/edit` and `/multi` (Kontext) endpoints take an `image_urls` list; image-to-image, fill, and Ultra take one
// `image_url` (beside `mask_url`).
const takesImageList = (model: string) => model.endsWith("/edit") || model.endsWith("/multi")
// `/edit` endpoints take an `image_urls` list; image-to-image, fill, and Ultra take one `image_url` (beside `mask_url`).
const isEdit = (model: string) => model.endsWith("/edit")
const fromRequest = Effect.fn("FalImages.fromRequest")(function* (request: Request) {
yield* validate(request)
const images = yield* Effect.forEach(request.images ?? [], (image) => FalQueue.mediaUrl(image, route.name))
const list = takesImageList(request.model.id)
const edit = isEdit(request.model.id)
return MediaProtocol.json(
mergeJsonRecords(
{
@@ -90,8 +86,8 @@ const fromRequest = Effect.fn("FalImages.fromRequest")(function* (request: Reque
image_size: request.size === undefined ? undefined : MediaInput.dimensions(request.size),
aspect_ratio: request.aspectRatio,
output_format: request.format,
image_urls: list && images.length > 0 ? images : undefined,
image_url: list ? undefined : images[0],
image_urls: edit && images.length > 0 ? images : undefined,
image_url: edit ? undefined : images[0],
mask_url: request.mask === undefined ? undefined : yield* FalQueue.mediaUrl(request.mask, route.name),
},
request.providerOptions,
@@ -116,14 +112,12 @@ const decodeResult = Effect.fn("FalImages.decodeResult")(function* (
// With the safety checker on, flagged images come back blacked out rather than omitted.
const flagged = (has_nsfw_concepts ?? []).flatMap((value, index) => (value ? [index] : []))
return new ImageResponse({
images: images.map((image) => {
const info = { width: image.width ?? undefined, height: image.height ?? undefined }
// `sync_mode: true` returns data URIs instead of hosted URLs.
return (
Media.parseDataUrl(image.url, { info }) ??
Media.url(image.url, { mediaType: image.content_type ?? undefined, info })
)
}),
images: images.map((image) =>
Media.url(image.url, {
mediaType: image.content_type ?? undefined,
info: { width: image.width ?? undefined, height: image.height ?? undefined },
}),
),
notices:
flagged.length === 0
? undefined
+1 -1
View File
@@ -101,7 +101,7 @@ const generationConfig = (request: Request) => {
const fromRequest = Effect.fn("GoogleImages.fromRequest")(function* (request: Request) {
if (request.n !== undefined && request.n > 1)
return yield* route.unsupported(
"media.n",
"image.n",
`${route.name} generates one image per request; call it once per image instead of n=${request.n}`,
)
const parts = yield* Effect.forEach(request.images ?? [], (image) =>
+1 -4
View File
@@ -56,9 +56,6 @@ interface State extends SpeechStream.Audio, GeminiGenerateContent.Metadata {
// ---------------------------------------------------------------------------
const fromRequest = Effect.fn("GoogleSpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
// Not in `unsupported`: that list would also reject `timestamps: false`, which asks for nothing.
if (request.timestamps === true)
return yield* route.unsupported("media.timestamps", `${route.name} does not return timestamps`)
if (request.format === "pcm" && request.mode === "generate" && /^gemini-3\.8-.*-tts(?:-|$)/.test(request.model.id))
return yield* route.unsupported(
"media.format",
@@ -128,7 +125,7 @@ const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) =
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string, State>(route, {
unsupported: ["instructions", "speed"],
unsupported: ["instructions", "speed", "timestamps"],
body: { from: fromRequest },
frames: (bytes, context) => GeminiGenerateContent.frames(bytes, context.request.mode),
initial: () => ({ chunks: [] }),
+2 -1
View File
@@ -149,7 +149,8 @@ const decodeResult = Effect.fn("GoogleVideo.decodeResult")(function* (
const output = yield* decodeOperation(response)
const operation = output.value
const status = statusOf(operation)
if (status === "running") return yield* output.pending(context.token.operation)
if (status === "running")
return yield* output.invalid(`${route.name} operation ${context.token.operation} has not finished`)
if (status === "failed")
return yield* output.ended(
"failed",
+24 -54
View File
@@ -48,17 +48,15 @@ const Usage = Schema.Struct({
output_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
})
/** What the provider actually rendered; it can differ from the request when `auto` or a default applied. */
const Settings = {
output_format: Schema.optional(Schema.String),
size: Schema.optional(Schema.String),
quality: Schema.optional(Schema.String),
background: Schema.optional(Schema.String),
}
const OpenAIImageResponse = Schema.Struct({
data: Schema.Array(Schema.Struct({ b64_json: Schema.String })),
...Settings,
data: Schema.Array(
Schema.Struct({
b64_json: Schema.optional(Schema.String),
url: Schema.optional(Schema.String),
revised_prompt: Schema.optional(Schema.String),
}),
),
output_format: Schema.optional(Schema.String),
usage: Schema.optional(Usage),
})
@@ -71,13 +69,11 @@ const StreamEvent = Schema.Union([
type: Schema.Literals(["image_generation.partial_image", "image_edit.partial_image"]),
b64_json: Schema.String,
partial_image_index: Schema.Number,
...Settings,
output_format: Schema.String,
}),
Schema.Struct({
type: Schema.Literals(["image_generation.completed", "image_edit.completed"]),
b64_json: Schema.String,
...Settings,
output_format: Schema.String,
usage: Schema.optional(Usage),
}),
@@ -96,9 +92,6 @@ type Frame = string | { readonly document: string; readonly requested: string |
interface State {
readonly completed: number
readonly format?: string
readonly size?: string
readonly quality?: string
readonly background?: string
readonly usage?: MediaUsage
}
@@ -117,6 +110,10 @@ const nativeOptions = (options: OpenAIImageOptions | undefined) => {
const streamOptions = (request: MediaProtocol.Addressed<Request>) => {
if (request.mode !== "stream") return Effect.succeed(undefined)
if (request.model.id.startsWith("dall-e"))
return Effect.fail(
route.unsupported("media.stream", `${request.model.id} does not stream; use Image.generate or a GPT image model`),
)
if (request.n !== undefined && request.n > 1)
return Effect.fail(
route.unsupported("media.n", `${route.name} streams one image; use Image.generate for n=${request.n}`),
@@ -197,34 +194,21 @@ const usage = (value: Schema.Schema.Type<typeof Usage> | undefined): MediaUsage
details: { openai: value },
}
/** `size` echoes the rendered `WIDTHxHEIGHT`; `auto` or any other value leaves the dimensions unknown. */
const info = (format: string, size: string | undefined): Media.Info => {
const match = size?.match(/^(\d+)x(\d+)$/)
return match ? { format, width: Number(match[1]), height: Number(match[2]) } : { format }
}
const eventImage = (frame: string, label: string, data: string, format: string, size: string | undefined) =>
const eventImage = (frame: string, label: string, data: string, format: string) =>
MediaInput.decodedAsset((message, cause) => route.frameError(message, frame, cause), label, data, `image/${format}`, {
info: info(format, size),
info: { format },
})
const onEvent = Effect.fn("OpenAIImages.onEvent")(function* (state: State, frame: string) {
const event = yield* decodeEvent(frame)
const format = event.output_format
if ("partial_image_index" in event) {
const image = yield* eventImage(frame, `${route.name} partial image`, event.b64_json, format, event.size)
const image = yield* eventImage(frame, `${route.name} partial image`, event.b64_json, format)
return [state, [ImagePartialEvent.make({ index: event.partial_image_index, image })]] as const
}
const image = yield* eventImage(frame, `${route.name} result ${state.completed}`, event.b64_json, format, event.size)
const image = yield* eventImage(frame, `${route.name} result ${state.completed}`, event.b64_json, format)
return [
{
completed: state.completed + 1,
format,
size: event.size,
quality: event.quality,
background: event.background,
usage: usage(event.usage),
},
{ ...state, completed: state.completed + 1, format, usage: usage(event.usage) },
[ImageOutputEvent.make({ index: state.completed, image })],
] as const
})
@@ -235,20 +219,16 @@ const onDocument = Effect.fn("OpenAIImages.onDocument")(function* (frame: Exclud
Effect.mapError((cause) => invalid(`${route.name} returned an invalid response`, cause)),
)
const format = decoded.output_format ?? frame.requested ?? "png"
const mediaType = `image/${format}`
const images = yield* Effect.forEach(decoded.data, (item, index) =>
MediaInput.decodedAsset(invalid, `${route.name} result ${index}`, item.b64_json, `image/${format}`, {
info: info(format, decoded.size),
MediaInput.imageOutput(invalid, `${route.name} result ${index}`, item, mediaType, {
info: { format },
providerMetadata:
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
}),
)
if (images.length === 0) return yield* invalid(`${route.name} returned no images`)
const state: State = {
completed: images.length,
format,
size: decoded.size,
quality: decoded.quality,
background: decoded.background,
usage: usage(decoded.usage),
}
const state: State = { completed: images.length, format, usage: usage(decoded.usage) }
return [state, images.map((image, index) => ImageOutputEvent.make({ index, image }))] as const
})
@@ -257,17 +237,7 @@ const step = (state: State, frame: Frame) => (typeof frame === "string" ? onEven
const finish = (state: State) => {
if (state.completed === 0) return Effect.fail(route.incomplete())
return Effect.succeed([
ImageFinishEvent.make({
usage: state.usage,
providerMetadata: {
openai: {
outputFormat: state.format,
size: state.size,
quality: state.quality,
background: state.background,
},
},
}),
ImageFinishEvent.make({ usage: state.usage, providerMetadata: { openai: { outputFormat: state.format } } }),
])
}
@@ -143,14 +143,12 @@ const adapter = {
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
// GPT-6 Astra, Sol, and Luna accept `configuration_update` only in standard mode (not `reasoning.mode: "pro"` or
// `-pro` slugs), and never alongside automatic `context_management` compaction.
// Only GPT-6 Astra accepts `configuration_update`, and never alongside automatic `context_management` compaction.
const supportsEffortUpdates = (request: LLMRequest) => {
if (request.providerOptions?.contextManagement !== undefined) return false
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(request.http?.body?.reasoning)) return false
const override = request.model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
return /(?:^|\/)gpt-6-(?:astra|sol|luna)$/i.test(request.model.id)
return /(?:^|\/)gpt-6-astra$/i.test(request.model.id)
}
const nativeImageToolInput = (tool: ToolDefinition) => {
+1 -4
View File
@@ -61,9 +61,6 @@ interface State extends SpeechStream.Audio {
const supportsSse = (model: string) => !/^tts-1(-hd)?(-|$)/.test(model)
const fromRequest = Effect.fn("OpenAISpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
// Not in `unsupported`: that list would also reject `timestamps: false`, which asks for nothing.
if (request.timestamps === true)
return yield* route.unsupported("media.timestamps", `${route.name} does not return timestamps`)
return MediaProtocol.json(
mergeJsonRecords(
{
@@ -124,7 +121,7 @@ const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) =
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string | Uint8Array, State>(route, {
unsupported: ["language"],
unsupported: ["language", "timestamps"],
body: { from: fromRequest },
frames: (bytes, context) => (isSse(context.body) ? Framing.sse.frame(bytes) : bytes),
initial: () => ({ chunks: [], done: false }),
@@ -1,9 +1,8 @@
import { Effect, Schema, Stream } from "effect"
import { classifyProviderFailure } from "../provider-error.js"
import { Framing } from "../route/framing.js"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { AIError, mergeJsonRecords, type MediaUsage } from "../schema/index.js"
import { mergeJsonRecords, type MediaUsage } from "../schema/index.js"
import {
TranscriptionFinishEvent,
TranscriptionModel,
@@ -60,9 +59,6 @@ const Usage = Schema.Union([
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
total_tokens: Schema.optional(Schema.Number),
input_token_details: Schema.optional(
Schema.Struct({ audio_tokens: Schema.optional(Schema.Number), text_tokens: Schema.optional(Schema.Number) }),
),
}),
Schema.Struct({ type: Schema.Literal("duration"), seconds: Schema.Number }),
])
@@ -79,23 +75,14 @@ const transcriptFields = {
usage: Schema.optional(Usage),
}
/** OpenAI may add stream event types; frames outside `EVENT_TYPES` are ignored. */
const EventType = Schema.Struct({ type: Schema.String })
const Event = Schema.Union([
Schema.Struct({ type: Schema.Literal("transcript.text.delta"), delta: Schema.String }),
Schema.Struct({ type: Schema.Literal("transcript.text.segment"), ...Segment.fields }),
Schema.Struct({ type: Schema.Literal("transcript.text.done"), ...transcriptFields }),
Schema.Struct({
type: Schema.Literal("error"),
message: Schema.optional(Schema.String),
error: Schema.optional(Schema.Struct({ message: Schema.optional(Schema.String) })),
}),
])
const EVENT_TYPES = new Set(["transcript.text.delta", "transcript.text.segment", "transcript.text.done", "error"])
const Transcript = Schema.Struct(transcriptFields)
type Transcript = Schema.Schema.Type<typeof Transcript>
const decodeEventType = route.decodeFrame(EventType)
const decodeEvent = route.decodeFrame(Event)
const decodeTranscript = route.decodeFrame(Transcript)
@@ -131,12 +118,10 @@ const capabilities = (model: string): Capabilities => {
return TRANSCRIBE
}
/** whisper-1 ignores `stream`, so its `stream` mode sends a plain request and emits only `finish`. */
const streamsEvents = (request: MediaProtocol.Addressed<Request>) =>
request.mode === "stream" && capabilities(request.model.id).stream
const validate = (request: MediaProtocol.Addressed<Request>, model: Capabilities) => {
const id = request.model.id
if (request.mode === "stream" && !model.stream)
return Effect.fail(route.unsupported("media.stream", `${id} does not stream; use Transcription.generate`))
if (request.diarize === true && !model.diarize)
return Effect.fail(route.unsupported("media.diarize", `${id} does not diarize; use gpt-4o-transcribe-diarize`))
if (request.prompt !== undefined && model.diarize)
@@ -188,7 +173,7 @@ const fromRequest = Effect.fn("OpenAITranscription.fromRequest")(function* (requ
timestamp_granularities: responseFormat === "verbose_json" ? [request.timestamps] : undefined,
// Diarizing audio longer than 30 seconds requires a chunking strategy.
chunking_strategy: model.diarize ? "auto" : undefined,
stream: streamsEvents(request) ? true : undefined,
stream: request.mode === "stream" ? true : undefined,
},
{
overlay: mergeJsonRecords(request.providerOptions, request.http?.body),
@@ -211,15 +196,7 @@ const segment = (value: Schema.Schema.Type<typeof Segment>): TranscriptionSegmen
})
const onEvent = Effect.fn("OpenAITranscription.onEvent")(function* (state: State, frame: string) {
if (!EVENT_TYPES.has((yield* decodeEventType(frame)).type)) return [state, []] as const
const event = yield* decodeEvent(frame)
if (event.type === "error")
return yield* new AIError({
reason: classifyProviderFailure({
message: `${route.name} stream failed: ${event.message ?? event.error?.message ?? "unknown error"}`,
rawBody: frame,
}),
})
if (event.type === "transcript.text.done") return [{ ...state, transcript: event }, []] as const
if (event.type === "transcript.text.delta")
return [state, event.delta.length === 0 ? [] : [TranscriptionTextDeltaEvent.make({ delta: event.delta })]] as const
@@ -269,7 +246,7 @@ export const protocol = MediaProtocol.stream<Request, TranscriptionEvent, Frame,
unsupported: ["speakers"],
body: { from: fromRequest },
frames: (bytes, context) =>
streamsEvents(context.request)
context.request.mode === "stream"
? Framing.sse.frame(bytes)
: Framing.document.frame(bytes).pipe(Stream.map((document) => ({ document }))),
initial: () => ({ segments: [] }),
@@ -132,7 +132,8 @@ const decodeResult = Effect.fn("ReplicateImages.decodeResult")(function* (
status,
`${route.name} prediction ${context.token.id} ${prediction.status}${typeof prediction.error === "string" ? `: ${prediction.error}` : ""}`,
)
if (status !== "completed") return yield* output.pending(context.token.id)
if (status !== "completed")
return yield* output.invalid(`${route.name} prediction ${context.token.id} has not finished`)
if (prediction.data_removed === true)
return yield* output.ended("expired", `${route.name} removed the output of prediction ${context.token.id}`)
if (!isOutput(prediction.output))
+3 -2
View File
@@ -141,7 +141,8 @@ const decodeResult = Effect.fn("RunwayVideo.decodeResult")(function* (
}
if (status === "cancelled")
return yield* output.ended("cancelled", `${route.name} task ${context.token.taskID} was cancelled`)
if (status !== "completed") return yield* output.pending(context.token.taskID)
if (status !== "completed")
return yield* output.invalid(`${route.name} task ${context.token.taskID} has not finished`)
const urls = task.output ?? []
if (urls.length === 0) return yield* output.invalid(`${route.name} task succeeded without any output`)
return new VideoResponse({
@@ -170,7 +171,7 @@ export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>(rout
start: { body: { from: fromRequest }, decode: decodeStart },
status: { path: taskPath, decode: decodeStatus },
result: { path: taskPath, decode: decodeResult },
cancel: { method: "DELETE", path: taskPath, activeOnly: true },
cancel: { method: "DELETE", path: taskPath },
})
const startPath = (request: Request) => {
@@ -175,7 +175,7 @@ const decodeUpscaleResult = Effect.fn("StabilityImages.decodeUpscaleResult")(fun
) {
if (response.status === 202) {
const output = yield* upscaleRoute.text(response)
return yield* output.pending(context.token.id)
return yield* output.invalid(`${upscaleRoute.name} upscale ${context.token.id} has not finished`)
}
return yield* decodeUpscaleImage(response)
})
+1 -2
View File
@@ -101,8 +101,7 @@ const decodeResponse = Effect.fn("XAIImages.decodeResponse")(function* (
)
if (images.length === 0) return yield* output.invalid(`${route.name} returned no images`)
const usage = ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
// xAI reports a USD cost (`cost_in_usd_ticks`) rather than tokens, seconds, or credits; the raw record stays in
// provider metadata.
// xAI reports image counts rather than tokens, seconds, or credits; the raw record stays in provider metadata.
return new ImageResponse({
images,
providerMetadata: usage === undefined ? undefined : { xai: { usage } },
+2 -1
View File
@@ -136,7 +136,8 @@ const decodeResult = Effect.fn("XAIVideo.decodeResult")(function* (
const output = yield* decodeVideoStatus(response)
const decoded = output.value
const status = yield* MediaProtocol.status(STATUS, decoded.status, output)
if (status === "running") return yield* output.pending(context.token.requestID)
if (status === "running")
return yield* output.invalid(`${route.name} request ${context.token.requestID} has not finished`)
if (status === "failed") {
const code = decoded.error?.code ?? undefined
const message = decoded.error?.message ?? undefined
+3 -3
View File
@@ -1,6 +1,7 @@
import { Duration, Effect, Schema } from "effect"
import { Effect, Schema } from "effect"
import type { HttpClientResponse } from "effect/unstable/http"
import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js"
import { Media } from "../media.js"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { mergeJsonRecords, type OpenString } from "../schema/index.js"
@@ -8,7 +9,6 @@ import { mergeJsonRecords, type OpenString } from "../schema/index.js"
const route = MediaProtocol.identity({ id: "zai-images", name: "Z.ai Images", provider: "zai" })
export const DEFAULT_BASE_URL = "https://api.z.ai/api/paas/v4"
export const PATH = "/images/generations"
const OUTPUT_RETENTION = Duration.days(30)
// ---------------------------------------------------------------------------
// 1. Public model input
@@ -76,7 +76,7 @@ const decodeResponse = Effect.fn("ZAIImages.decodeResponse")(function* (
const filters = decoded.content_filter ?? []
return new ImageResponse({
// Z.ai returns only URLs and no content type; the media type resolves when the asset is materialized.
images: yield* Effect.forEach(decoded.data, (item) => MediaProtocol.expiringUrl(item.url, OUTPUT_RETENTION)),
images: decoded.data.map((item) => Media.url(item.url)),
// Z.ai reports applied content filters alongside a successful result; surface them instead of dropping them.
notices:
filters.length === 0
+4 -17
View File
@@ -137,11 +137,6 @@ export interface Queued<Request, Response, Token> {
readonly cancel?: {
readonly method: AuthInput["method"]
readonly path: (token: Token) => string
/**
* Fetch a fresh status first and skip the call for terminal generations, for providers whose cancel endpoint
* destroys finished work (Runway's `DELETE /v1/tasks/{id}` deletes completed tasks and their outputs).
*/
readonly activeOnly?: boolean
}
}
@@ -200,8 +195,7 @@ export const identity = (input: { readonly id: string; readonly name: string; re
/**
* Read a text body while retaining the original payload and HTTP context on every downstream error. `invalid` is a
* malformed provider document; `ended` is a generation that reached a terminal status without output (`failed` is
* provider-side, `cancelled`/`expired` mean the result will never exist); `pending` is a `result()` read before the
* generation finished, which is caller misuse; `contentPolicy` is a moderated result.
* provider-side, `cancelled`/`expired` mean the result will never exist); `contentPolicy` is a moderated result.
*/
const text = Effect.fn("MediaProtocol.text")(function* (response: HttpClientResponse.HttpClientResponse) {
const http = context(response)
@@ -230,14 +224,6 @@ export const identity = (input: { readonly id: string; readonly name: string; re
? new ProviderInternalError({ message, body, http })
: new InvalidRequestError({ message, body, http }),
}),
pending: (id: string) =>
new AIError({
reason: new InvalidRequestError({
message: `${input.name} generation ${id} has not finished; await it before reading the result`,
body,
http,
}),
}),
contentPolicy: (message: string) => new AIError({ reason: new ContentPolicyError({ message, body, http }) }),
}
})
@@ -299,8 +285,9 @@ export const status = <Table extends Record<string, Status>>(
raw: string,
output: Output,
): Effect.Effect<Status, AIError> => {
if (!Object.hasOwn(table, raw)) return Effect.fail(output.invalid(`Unknown generation status "${raw}"`))
return Effect.succeed(table[raw])
const normalized: Status | undefined = table[raw]
if (normalized === undefined) return Effect.fail(output.invalid(`Unknown generation status "${raw}"`))
return Effect.succeed(normalized)
}
/** A `url` asset whose provider-declared retention window starts now. */
+6 -15
View File
@@ -5,7 +5,7 @@ import { Endpoint } from "./endpoint.js"
import { RequestExecutorService, type Interface } from "./executor-service.js"
import { RequestExecutor } from "./executor.js"
import { MediaProtocol } from "./media-protocol.js"
import { Generation, isTerminal } from "../generation.js"
import { Generation } from "../generation.js"
import type { Media } from "../media.js"
import {
AIError,
@@ -164,19 +164,14 @@ export const queued = <Request extends MediaRequest, Response, Token>(
transport
.call("GET", operation.path(token), http, execute)
.pipe(Effect.flatMap((sent) => operation.decode(sent.response, { token, auth: sent.auth, materialize })))
const status = poll(protocol.status)
const cancel = protocol.cancel
const send =
cancel === undefined
? undefined
: transport.call(cancel.method, cancel.path(token), http, execute).pipe(Effect.asVoid)
return {
status,
status: poll(protocol.status),
result: poll(protocol.result),
cancel:
send !== undefined && cancel?.activeOnly
? status.pipe(Effect.flatMap((snapshot) => (isTerminal(snapshot.status) ? Effect.void : send)))
: send,
cancel === undefined
? undefined
: transport.call(cancel.method, cancel.path(token), http, execute).pipe(Effect.asVoid),
}
}
@@ -385,11 +380,7 @@ const encode = (body: MediaProtocol.Body | undefined, headers: Headers.Headers)
}
}
/**
* Common fields are never silently dropped: a present field the protocol declared unsupported fails typed. `false`
* counts as present because some booleans mean something when false (video `audio`); protocols reject opt-in
* booleans such as speech `timestamps` with `=== true` in `body.from` instead of listing them.
*/
/** Common fields are never silently dropped: a present field the protocol declared unsupported fails typed. */
const rejectUnsupported = <Request extends object>(
route: string,
provider: ProviderID,
-24
View File
@@ -351,34 +351,10 @@ describe("OpenAI Responses effort updates", () => {
}),
)
it.effect("strips markers when the body overlay selects pro reasoning mode", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ apiKey: "fixture", http: { body: { reasoning: { mode: "pro" } } } }).responses(
"gpt-6-sol",
),
messages: conversation,
providerOptions: { reasoningEffort: "low" },
}),
)
expect(updates(prepared.body)).toEqual([])
expect(prepared.body.reasoning).toEqual({ effort: "low" })
}),
)
for (const [id, supported] of [
["gpt-6-astra", true],
["openai/gpt-6-astra", true],
["gpt-6-sol", true],
["openai/gpt-6-sol", true],
["gpt-6-luna", true],
["openai/gpt-6-luna", true],
["gpt-6-astra-2026-09-01", false],
["gpt-6-sol-pro", false],
["gpt-6-luna-pro", false],
["gpt-6-sol-fast", false],
["gpt-5.6-sol", false],
] as const) {
it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () =>
-31
View File
@@ -23,7 +23,6 @@ import { Provider as ProviderSubpath } from "@opencode/ai/provider"
import {
AssemblyAI,
Baseten,
BlackForestLabs,
Cartesia,
CloudflareAIGateway,
CloudflareWorkersAI,
@@ -33,18 +32,14 @@ import {
Fal,
Fireworks,
Google,
Meta,
OpenCodeZen,
OpenAI,
OpenAICompatible,
OpenRouter,
Replicate,
Runway,
Stability,
TypeSafeAI,
VercelAIGateway,
XAI,
ZAI,
} from "@opencode/ai/providers"
import {
OpenAIChat,
@@ -156,34 +151,8 @@ describe("public exports", () => {
expect(XAI.provider.chat).toBe(XAI.chat)
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("openai-compatible-chat")
expect(OpenAI.configure({ apiKey: "fixture" }).image("gpt-image-2").route.id).toBe("openai-images")
expect(OpenAI.provider.image).toBe(OpenAI.image)
expect(Google.configure({ apiKey: "fixture" }).image("imagen-4.0-generate-001").route.id).toBe("google-images")
expect(Google.provider.image).toBe(Google.image)
expect(XAI.configure({ apiKey: "fixture" }).image("grok-imagine-image").route.id).toBe("xai-images")
expect(XAI.provider.image).toBe(XAI.image)
expect(Fal.configure({ apiKey: "fixture" }).image("fal-ai/flux/dev").route.id).toBe("fal-images")
expect(Fal.provider.image).toBe(Fal.image)
expect(BlackForestLabs.configure({ apiKey: "fixture" }).image("flux-2-pro").route.id).toBe("bfl-images")
expect(BlackForestLabs.provider.image).toBe(BlackForestLabs.image)
expect(Replicate.configure({ apiKey: "fixture" }).image("black-forest-labs/flux-schnell").route.id).toBe(
"replicate-images",
)
expect(Replicate.provider.image).toBe(Replicate.image)
expect(Stability.configure({ apiKey: "fixture" }).image("sd3.5-large").route.id).toBe("stability-images")
expect(Stability.provider.image).toBe(Stability.image)
expect(Stability.configure({ apiKey: "fixture" }).upscale().route.id).toBe("stability-upscale")
expect(Stability.provider.upscale).toBe(Stability.upscale)
expect(Meta.configure({ apiKey: "fixture" }).image("muse-image").route.id).toBe("meta-images")
expect(Meta.provider.image).toBe(Meta.image)
expect(ZAI.configure({ apiKey: "fixture" }).image("glm-image").route.id).toBe("zai-images")
expect(ZAI.provider.image).toBe(ZAI.image)
expect(XAI.configure({ apiKey: "fixture" }).video("grok-imagine-video-1.5").route.id).toBe("xai-video")
expect(XAI.provider.video).toBe(XAI.video)
expect(Google.configure({ apiKey: "fixture" }).video("veo-3.1-generate-preview").route.id).toBe("google-video")
expect(Google.provider.video).toBe(Google.video)
expect(Fal.configure({ apiKey: "fixture" }).video("fal-ai/veo3.1").route.id).toBe("fal-video")
expect(Fal.provider.video).toBe(Fal.video)
expect(Runway.configure({ apiKey: "fixture" }).video("gen4.5").route.id).toBe("runway-video")
expect(Runway.provider.video).toBe(Runway.video)
expect(OpenAI.configure({ apiKey: "fixture" }).speech("gpt-4o-mini-tts").route.id).toBe("openai-speech")
-20
View File
@@ -97,26 +97,6 @@ describe("Generation", () => {
}),
)
it.effect("fails an event stream at the deadline when the poll interval is longer than the timeout", () =>
Effect.gen(function* () {
const scripted = yield* scriptedRoute(["running"], "never")
const generation = new Generation(scripted.route, "t", { id: "gen_1", status: "queued" })
const fiber = yield* Effect.forkChild(
generation
.events({ poll: { interval: "30 seconds", timeout: "10 seconds" } })
.pipe(Stream.runCollect, Effect.flip),
)
yield* TestClock.adjust("9 seconds")
expect(fiber.pollUnsafe()).toBeUndefined()
yield* TestClock.adjust("1 second")
const error = yield* Fiber.join(fiber)
expect(error.reason._tag).toBe("Timeout")
expect(yield* Ref.get(scripted.polls)).toBe(1)
}),
)
it.effect("surfaces the route failure body for failed generations", () =>
Effect.gen(function* () {
const scripted = yield* scriptedRoute(["running", "failed"], "unused")
+4 -135
View File
@@ -78,11 +78,8 @@ describe("Image", () => {
mediaType: "image/webp",
})
expect(yield* response.image.bytes()).toEqual(Uint8Array.from([1, 2, 3]))
expect(response.image.info).toEqual({ format: "webp", width: 2048, height: 2048 })
expect(response.image.providerMetadata).toEqual({ openai: { revisedPrompt: "A precise robot" } })
expect(response.usage).toMatchObject({ type: "tokens", total: 12 })
expect(response.providerMetadata).toEqual({
openai: { outputFormat: "webp", size: "2048x2048", quality: "high", background: "opaque" },
})
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
@@ -110,11 +107,8 @@ describe("Image", () => {
})
return input.respond(
JSON.stringify({
data: [{ b64_json: "AQID" }, { b64_json: "BAUG" }],
data: [{ b64_json: "AQID", revised_prompt: "A precise robot" }, { b64_json: "BAUG" }],
output_format: "webp",
size: "2048x2048",
quality: "high",
background: "opaque",
usage: { input_tokens: 4, output_tokens: 8, total_tokens: 12 },
}),
{ headers: { "content-type": "application/json" } },
@@ -150,7 +144,6 @@ describe("Image", () => {
),
)
expect(response.image.source).toEqual({ type: "bytes", data: Uint8Array.from([1, 2, 3]), mediaType: "image/png" })
expect(response.image.info).toEqual({ format: "png" })
}),
)
@@ -732,7 +725,6 @@ describe("Image", () => {
const errors = yield* Effect.all(
[
Image.start({ model: Google.configure({ apiKey: "test" }).image("gemini-3.1-flash-image"), prompt }),
Image.generate({ model: Google.configure({ apiKey: "test" }).image("gemini-3.1-flash-image"), prompt, n: 2 }),
Image.start({
model: BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro"),
prompt,
@@ -743,6 +735,7 @@ describe("Image", () => {
prompt,
size: "512x512",
}),
Stream.runCollect(Image.stream({ model: openai.image("dall-e-3"), prompt })),
Stream.runCollect(Image.stream({ model: openai.image("gpt-image-2"), prompt, n: 2 })),
Image.start({ model: replicate, prompt, seed: 7 }),
Image.start({
@@ -756,9 +749,9 @@ describe("Image", () => {
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
[
["UnsupportedOperation", "image.start"],
["UnsupportedOperation", "media.n"],
["UnsupportedOperation", "media.aspectRatio"],
["UnsupportedOperation", "media.size"],
["UnsupportedOperation", "media.stream"],
["UnsupportedOperation", "media.n"],
["UnsupportedOperation", "media.seed"],
["InvalidRequest", false],
@@ -768,80 +761,6 @@ describe("Image", () => {
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
)
const falToken = {
requestID: "r1",
statusURL: "https://queue.fal.test/fal-ai/flux/requests/r1/status",
responseURL: "https://queue.fal.test/fal-ai/flux/requests/r1",
cancelURL: "https://queue.fal.test/fal-ai/flux/requests/r1/cancel",
}
const falSubmitted = {
request_id: falToken.requestID,
status_url: falToken.statusURL,
response_url: falToken.responseURL,
cancel_url: falToken.cancelURL,
}
const bodies: Array<unknown> = []
it.effect("sizes fal Kontext by aspect ratio and sends several images to /multi", () =>
Effect.gen(function* () {
const fal = Fal.configure({ apiKey: "test", baseURL: "https://queue.fal.test" })
const images = [Media.url("https://example.test/a.png"), Media.url("https://example.test/b.png")]
const rejected = yield* Image.start({
model: fal.image("fal-ai/flux-pro/kontext"),
prompt: "A lighthouse",
size: "512x512",
}).pipe(Effect.flip)
yield* Image.start({
model: fal.image("fal-ai/flux-pro/kontext"),
prompt: "A lighthouse",
images: images.slice(0, 1),
aspectRatio: "16:9",
})
yield* Image.start({ model: fal.image("fal-ai/flux-pro/kontext/max/multi"), prompt: "A lighthouse", images })
expect(rejected.reason).toMatchObject({ _tag: "UnsupportedOperation", operation: "media.size" })
expect(bodies).toEqual([
{ prompt: "A lighthouse", aspect_ratio: "16:9", image_url: "https://example.test/a.png" },
{ prompt: "A lighthouse", image_urls: ["https://example.test/a.png", "https://example.test/b.png"] },
])
}).pipe(
Effect.provide(
layer((input) => {
bodies.push(JSON.parse(input.text))
return Effect.succeed(json(input, falSubmitted))
}),
),
),
)
it.effect("decodes fal sync_mode data URIs as inline images", () =>
Effect.gen(function* () {
const generation = yield* Image.resume(Fal.configure({ apiKey: "test" }).image("fal-ai/flux/schnell"), falToken)
const response = yield* generation.await()
expect(response.images.map((image) => image.source)).toEqual([
{ type: "base64", data: "AQID", mediaType: "image/png" },
{ type: "url", url: "https://v3.fal.media/out.jpg", mediaType: "image/jpeg" },
])
expect(response.image.info).toEqual({ width: 512, height: 512 })
expect(yield* response.image.bytes()).toEqual(Uint8Array.from([1, 2, 3]))
}).pipe(
Effect.provide(
layer((input) =>
Effect.succeed(
input.request.url === falToken.statusURL
? json(input, { status: "COMPLETED" })
: json(input, {
images: [
{ url: "data:image/png;base64,AQID", width: 512, height: 512, content_type: "image/png" },
{ url: "https://v3.fal.media/out.jpg", width: 512, height: 512, content_type: "image/jpeg" },
],
}),
),
),
),
),
)
const moderated = { id: "req_1", status: "Content Moderated" }
const prediction = {
id: "p_1",
@@ -849,56 +768,6 @@ describe("Image", () => {
output: { text: "not an image" },
urls: { get: "https://replicate.test/p_1", cancel: "https://replicate.test/p_1/cancel" },
}
for (const pending of [
{
model: BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro"),
token: { id: "req_1", pollingURL: "https://bfl.test/v1/get_result?id=req_1" },
status: 200,
body: { id: "req_1", status: "Pending" },
message: "Black Forest Labs generation req_1",
},
{
model: Replicate.configure({ apiKey: "test" }).image("owner/model"),
token: { id: "p_1", getURL: "https://replicate.test/p_1", cancelURL: "https://replicate.test/p_1/cancel" },
status: 200,
body: {
id: "p_1",
status: "processing",
urls: { get: "https://replicate.test/p_1", cancel: "https://replicate.test/p_1/cancel" },
},
message: "Replicate generation p_1",
},
{
model: Stability.configure({ apiKey: "test", baseURL: "https://stability.test" }).upscale(),
token: { id: "up_1" },
status: 202,
body: { id: "up_1", status: "in-progress" },
message: "Stability AI generation up_1",
},
]) {
it.effect(`rejects reading a ${pending.model.provider} result before the generation finishes`, () =>
Effect.gen(function* () {
const generation = yield* Image.resume(pending.model, pending.token)
const error = yield* generation.result().pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toBe(`${pending.message} has not finished; await it before reading the result`)
expect(error.reason.body).toBe(JSON.stringify(pending.body))
expect(error.reason.http?.status).toBe(pending.status)
}).pipe(
Effect.provide(
layer((input) =>
Effect.succeed(
input.respond(JSON.stringify(pending.body), {
status: pending.status,
headers: { "content-type": "application/json" },
}),
),
),
),
),
)
}
it.effect("classifies terminal outcomes the recordings never saw", () =>
Effect.gen(function* () {
const bfl = yield* Image.resume(BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro"), {
+1 -51
View File
@@ -3,7 +3,7 @@ import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Ref, Schema } from "effect"
import { FileSystem } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { AIError, Media, Message } from "../src/index.js"
import { Media, Message } from "../src/index.js"
import { it } from "./lib/effect.js"
import { dynamicResponse, scriptedResponses } from "./lib/http.js"
@@ -161,56 +161,6 @@ describe("Media", () => {
}),
)
it.effect("keeps transient url download headers out of toJSON and AssetSchema encoding", () =>
Effect.sync(() => {
const asset = Media.url("https://cdn.example.test/video.mp4", {
mediaType: "video/mp4",
expiresAt: 42,
headers: { "x-goog-api-key": "secret" },
})
expect(asset.headers).toEqual({ "x-goog-api-key": "secret" })
const source = { type: "url", url: "https://cdn.example.test/video.mp4", mediaType: "video/mp4", expiresAt: 42 }
expect(asset.toJSON()).not.toHaveProperty("headers")
expect(JSON.stringify(asset)).not.toContain("secret")
expect(asset.toJSON().source).toEqual(source)
const encoded = Schema.encodeSync(Media.AssetSchema)(asset)
expect(encoded).not.toHaveProperty("headers")
expect(encoded.source).toEqual(source)
const codec = Schema.fromJsonString(Media.AssetSchema)
const json = Schema.encodeSync(codec)(asset)
expect(json).not.toContain("secret")
const restored = Schema.decodeSync(codec)(json)
expect(restored).toBeInstanceOf(Media.Asset)
expect(restored.source).toEqual(source)
expect(restored.expiresAt).toBe(42)
expect(restored.headers).toBeUndefined()
}),
)
it.effect("fails url downloads with non-2xx status as a typed AIError keeping http and body", () =>
Effect.gen(function* () {
const body = JSON.stringify({ error: { message: "file expired" } })
const error = yield* Media.url("https://cdn.example.test/expired.png")
.bytes()
.pipe(
Effect.flip,
Effect.provide(
dynamicResponse((input) =>
Effect.succeed(input.respond(body, { status: 404, headers: { "content-type": "application/json" } })),
),
),
)
expect(error).toBeInstanceOf(AIError)
expect(error.message).toContain("file expired")
expect(error.reason.http?.status).toBe(404)
expect(error.reason.http?.url).toBe("https://cdn.example.test/expired.png")
expect(error.reason.body).toBe(body)
}),
)
it.effect("reads files with sniffed media types and writes materialized assets", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
@@ -33,8 +33,6 @@ describe("Black Forest Labs Images recorded", () => {
expect(response.image.source.type).toBe("bytes")
expect(dimensions(yield* response.image.bytes())).toEqual({ width: 512, height: 512 })
// BFL reports cost on submit only; the Ready result omits it.
expect(response.usage).toEqual({ type: "credits", credits: 1.4000000000000001 })
}),
{ timeout: 15 * 60 * 1000 },
)
@@ -29,11 +29,7 @@ describe("OpenAI Images recorded", () => {
expect(response.images).toHaveLength(1)
expect(response.image.mediaType).toBe("image/jpeg")
expect(response.image.info).toEqual({ format: "jpeg", width: 1024, height: 1024 })
expect((yield* response.image.bytes()).length).toBeGreaterThan(0)
expect(response.providerMetadata).toEqual({
openai: { outputFormat: "jpeg", size: "1024x1024", quality: "low", background: "opaque" },
})
}),
)
@@ -80,13 +76,8 @@ describe("OpenAI Images recorded", () => {
expect(events.map((event) => event.type)).toEqual(["image-partial", "image", "finish"])
const image = events.find(ImageEvent.is.image)
expect(image?.image.mediaType).toBe("image/jpeg")
expect(image?.image.info).toEqual({ format: "jpeg", width: 1024, height: 1024 })
expect(dimensions(yield* image!.image.bytes())).toEqual({ width: 1024, height: 1024 })
const finish = events.find(ImageEvent.is.finish)
expect(finish?.usage).toMatchObject({ type: "tokens" })
expect(finish?.providerMetadata).toEqual({
openai: { outputFormat: "jpeg", size: "1024x1024", quality: "low", background: "opaque" },
})
expect(events.find(ImageEvent.is.finish)?.usage).toMatchObject({ type: "tokens" })
}),
)
})
@@ -44,12 +44,7 @@ describe("OpenAI Transcription recorded", () => {
expect(deltas.length).toBeGreaterThan(1)
expect(deltas.join("")).toBe(finish.text)
expect(finish.text).toMatch(TRANSCRIPT)
expect(finish.usage).toMatchObject({
type: "tokens",
input: expect.any(Number),
output: expect.any(Number),
details: { openai: { input_token_details: { audio_tokens: expect.any(Number) } } },
})
expect(finish.usage).toMatchObject({ type: "tokens", input: expect.any(Number), output: expect.any(Number) })
}),
)
+1 -6
View File
@@ -32,12 +32,7 @@ describe("Z.ai Images", () => {
expect(response.images).toHaveLength(1)
expect(response.image.mediaType).toBe("application/octet-stream")
// Z.ai documents that output URLs expire 30 days after generation; the test clock starts at 0.
expect(response.image.source).toEqual({
type: "url",
url: "https://cdn.z.ai/generated.png",
expiresAt: 30 * 24 * 60 * 60 * 1000,
})
expect(response.image.source).toEqual({ type: "url", url: "https://cdn.z.ai/generated.png" })
expect(response.notices).toEqual([
{
type: "moderated",
-36
View File
@@ -97,42 +97,6 @@ describe("Speech", () => {
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
)
it.effect("treats timestamps: false as not asking for timestamps on routes that cannot return them", () =>
Effect.gen(function* () {
const bytes = Uint8Array.from([1, 2, 3])
const gemini = JSON.stringify({
candidates: [
{ content: { parts: [{ inlineData: { mimeType: "audio/L16;codec=pcm;rate=24000", data: "AQID" } }] } },
],
})
const responses = yield* Effect.all([
Speech.generate({ model: openai, text: "Hi", timestamps: false }).pipe(
Effect.provide(respond(new Blob([bytes]).stream(), "audio/mpeg")),
),
Speech.generate({ model: google, text: "Hi", timestamps: false }).pipe(
Effect.provide(respond(gemini, "application/json")),
),
Speech.generate({ model: deepgram, text: "Hi", timestamps: false }).pipe(
Effect.provide(respond(new Blob([bytes]).stream(), "audio/mpeg")),
),
])
for (const response of responses) expect(yield* response.audio.bytes()).toEqual(bytes)
const errors = yield* Effect.all(
[openai, google, deepgram].map((model) =>
Speech.generate({ model, text: "Hi", timestamps: true }).pipe(Effect.flip),
),
).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network"))))
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
[
["UnsupportedOperation", "media.timestamps"],
["UnsupportedOperation", "media.timestamps"],
["UnsupportedOperation", "media.timestamps"],
],
)
}),
)
it.effect("classifies stream failures and keeps the provider payload and HTTP context", () =>
Effect.gen(function* () {
const badFrame = JSON.stringify({ type: "speech.audio.delta", audio: "not base64!" })
+3 -103
View File
@@ -5,7 +5,7 @@ import { HttpClientRequest } from "effect/unstable/http"
import { Media, Transcription, TranscriptionClient } from "../src/index.js"
import { AssemblyAI, Deepgram, Google, OpenAI } from "../src/providers.js"
import { it } from "./lib/effect.js"
import { dynamicResponse, json, observe, type Call } from "./lib/http.js"
import { dynamicResponse } from "./lib/http.js"
const layer = (handler: Parameters<typeof dynamicResponse>[0]) =>
TranscriptionClient.layer.pipe(Layer.provideMerge(dynamicResponse(handler)))
@@ -25,6 +25,7 @@ describe("Transcription", () => {
Effect.gen(function* () {
const errors = yield* Effect.all(
[
Stream.runCollect(Transcription.stream({ model: openai.transcription("whisper-1"), audio })),
Transcription.generate({ model: openai.transcription("gpt-4o-mini-transcribe"), audio, diarize: true }),
Transcription.generate({ model: openai.transcription("gpt-4o-mini-transcribe"), audio, timestamps: "word" }),
Transcription.generate({ model: openai.transcription("gpt-4o-transcribe-diarize"), audio, prompt: "Names" }),
@@ -52,6 +53,7 @@ describe("Transcription", () => {
)
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
[
["UnsupportedOperation", "media.stream"],
["UnsupportedOperation", "media.diarize"],
["UnsupportedOperation", "media.timestamps"],
["UnsupportedOperation", "media.prompt"],
@@ -68,67 +70,6 @@ describe("Transcription", () => {
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
)
it.effect("ignores unknown OpenAI stream events and fails on an error event with the frame", () =>
Effect.gen(function* () {
const sse = (...frames: ReadonlyArray<string>) => frames.map((frame) => `data: ${frame}\n\n`).join("")
const failure = `{"type":"error","error":{"type":"server_error","code":"server_error","message":"The server had an error"}}`
const bodies = [
sse(
`{"type":"transcript.text.delta","delta":"Hi"}`,
`{"type":"transcript.text.future","payload":1}`,
`{"type":"transcript.text.done","text":"Hi"}`,
"[DONE]",
),
sse(`{"type":"transcript.text.delta","delta":"Hi"}`, failure),
]
const model = openai.transcription("gpt-4o-mini-transcribe")
const program = Effect.gen(function* () {
const events = Array.from(yield* Stream.runCollect(Transcription.stream({ model, audio })))
const error = yield* Stream.runCollect(Transcription.stream({ model, audio })).pipe(Effect.flip)
return { events, error }
})
const { events, error } = yield* program.pipe(
Effect.provide(
layer((input) =>
Effect.sync(() =>
input.respond(bodies.shift() ?? "", { headers: { "content-type": "text/event-stream" } }),
),
),
),
)
expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"])
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", body: failure })
expect(error.message).toContain("The server had an error")
}),
)
it.effect("streams whisper-1 as a single finish from a plain request", () =>
Effect.gen(function* () {
const bodies: Array<string> = []
const events = Array.from(
yield* Stream.runCollect(Transcription.stream({ model: openai.transcription("whisper-1"), audio })).pipe(
Effect.provide(
layer((input) =>
Effect.sync(() => {
bodies.push(input.text)
return input.respond(
JSON.stringify({ text: "Hello there.", usage: { type: "duration", seconds: 2 } }),
{ headers: { "content-type": "application/json" } },
)
}),
),
),
),
)
expect(bodies[0]).not.toContain('name="stream"')
expect(events).toEqual([
expect.objectContaining({ type: "finish", text: "Hello there.", usage: { type: "seconds", seconds: 2 } }),
])
}),
)
it.effect(
"uploads inline audio to AssemblyAI, resumes polling from a persisted token, and surfaces failed transcripts",
() =>
@@ -230,45 +171,4 @@ describe("Transcription", () => {
expect(failure.reason).toMatchObject({ _tag: "ProviderInternal", body: failed })
}),
)
it.effect("enables AssemblyAI speaker labels when only an expected speaker count is given", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
yield* Transcription.start({ model: assemblyai, audio: Media.url("https://a.test/call.mp3"), speakers: 2 }).pipe(
Effect.provide(
layer((input) => observe(calls, input).pipe(Effect.as(json(input, { id: "tr_1", status: "queued" })))),
),
)
expect(calls.map((call) => JSON.parse(call.body))).toEqual([
{
audio_url: "https://a.test/call.mp3",
speech_models: ["universal-3-5-pro"],
language_detection: true,
speaker_labels: true,
speakers_expected: 2,
},
])
}),
)
it.effect("rejects reading an AssemblyAI result before the transcript finishes", () =>
Effect.gen(function* () {
const generation = yield* Transcription.resume(assemblyai, { transcriptID: "tr_1" })
const error = yield* generation.result().pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toBe("AssemblyAI generation tr_1 has not finished; await it before reading the result")
expect(error.reason.body).toBe(JSON.stringify({ id: "tr_1", status: "processing" }))
expect(error.reason.http?.status).toBe(200)
}).pipe(
Effect.provide(
layer((input) =>
Effect.succeed(
input.respond(JSON.stringify({ id: "tr_1", status: "processing" }), {
headers: { "content-type": "application/json" },
}),
),
),
),
),
)
})
+2 -80
View File
@@ -576,7 +576,7 @@ describe("Video / Runway", () => {
const model = runway.video("gen4.5")
const taskUrl = "https://runway.test/v1/tasks/task_1"
it.effect("submits image_to_video, polls the task, reports credits, and keeps the finished task on cancel", () =>
it.effect("submits image_to_video with the API version header, polls the task, and reports credits", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const program = Effect.gen(function* () {
@@ -624,7 +624,7 @@ describe("Video / Runway", () => {
return json(input, { id: "task_1", estimatedCost: { credits: 25 } })
}
expect(call.url).toBe(taskUrl)
if (call.method === "DELETE") return yield* Effect.die("cancel deleted a finished Runway task")
if (call.method === "DELETE") return input.respond(null, { status: 204 })
if (nth === 1) return json(input, { id: "task_1", status: "PENDING", estimatedCost: { credits: 25 } })
if (nth === 2) return json(input, { id: "task_1", status: "THROTTLED", estimatedCost: { credits: 25 } })
if (nth === 3) return json(input, { id: "task_1", status: "RUNNING", progress: 0.5 })
@@ -653,32 +653,6 @@ describe("Video / Runway", () => {
`GET ${taskUrl}`,
`GET ${taskUrl}`,
`GET ${taskUrl}`,
`GET ${taskUrl}`,
])
}),
)
it.effect("cancels a task that is still running", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
yield* Effect.gen(function* () {
const generation = yield* Video.start({ model, prompt: "x" })
yield* generation.cancel()
}).pipe(
Effect.provide(
layer((input) =>
Effect.gen(function* () {
const { call } = yield* observe(calls, input)
if (call.method === "POST") return json(input, { id: "task_1" })
if (call.method === "DELETE") return input.respond(null, { status: 204 })
return json(input, { id: "task_1", status: "RUNNING", progress: 0.2 })
}),
),
),
)
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
"POST https://runway.test/v1/text_to_video",
`GET ${taskUrl}`,
`DELETE ${taskUrl}`,
])
}),
@@ -838,55 +812,3 @@ describe("Video / Runway", () => {
}),
)
})
// ---------------------------------------------------------------------------
// Shared queued behavior
// ---------------------------------------------------------------------------
describe("Video / queued result", () => {
for (const pending of [
{
model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).video("veo-3.1"),
token: { operation: "models/veo-3.1/operations/op_1" },
body: { name: "models/veo-3.1/operations/op_1", done: false },
name: "Google Veo",
},
{
model: XAI.configure({ apiKey: "test", baseURL: "https://xai.test/v1" }).video("grok-imagine-video-1.5"),
token: { requestID: "req_1" },
body: { status: "pending", progress: 40 },
name: "xAI Video",
},
{
model: Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5"),
token: { taskID: "task_1" },
body: { status: "RUNNING", progress: 0.5 },
name: "Runway",
},
]) {
it.effect(`rejects reading a ${pending.model.provider} result before the generation finishes`, () =>
Effect.gen(function* () {
const generation = yield* Video.resume(pending.model, pending.token)
const error = yield* generation.result().pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toBe(
`${pending.name} generation ${generation.id} has not finished; await it before reading the result`,
)
expect(error.reason.body).toBe(JSON.stringify(pending.body))
expect(error.reason.http?.status).toBe(200)
}).pipe(Effect.provide(layer((input) => Effect.succeed(json(input, pending.body))))),
)
}
it.effect("rejects a status that only matches an inherited property", () =>
Effect.gen(function* () {
const error = yield* Video.resume(
XAI.configure({ apiKey: "test", baseURL: "https://xai.test/v1" }).video("grok-imagine-video-1.5"),
{ requestID: "req_1" },
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toBe('Unknown generation status "constructor"')
expect(error.reason.body).toBe(JSON.stringify({ status: "constructor" }))
}).pipe(Effect.provide(layer((input) => Effect.succeed(json(input, { status: "constructor" }))))),
)
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.18",
"version": "2.0.16",
"description": "",
"type": "module",
"exports": {
+2 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"bin": {
@@ -41,6 +41,7 @@
"effect": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"open": "10.1.2",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
+2 -2
View File
@@ -16,8 +16,8 @@ export function requireInteractive(message: string) {
}
export const openUrl = Effect.fn("cli.prompt.open-url")(function* (url: string) {
const browser = yield* Effect.promise(() => import("@opencode/util/open"))
yield* Effect.promise(() => browser.openUrl(url)).pipe(Effect.ignore)
const { default: open } = yield* Effect.promise(() => import("open"))
yield* Effect.promise(() => open(url)).pipe(Effect.ignore)
})
export function handlePromptErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"repository": {
+21 -10
View File
@@ -55,7 +55,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Tagged templates: a tag applied to a template literal is called as `tag(strings, ...values)`, with the tag read
like a callee so a member tag keeps its receiver. `strings` is an array of the cooked text with a read-only `raw`
array of the source text; an invalid escape such as `\unicode` cooks to `undefined`. One template object per
site, as in JS, but it is not frozen: `strings[0] = "x"` succeeds here where JS throws.
site and both arrays are frozen, as in JS: `strings[0] = "x"` throws a `TypeError`.
- [x] Regular-expression literals.
- [x] `NaN` and `Infinity` globals.
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
@@ -101,9 +101,11 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `for`, `while`, and `do...while`.
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, Uint8Arrays, built-in iterators, custom
synchronous iterators, and confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references. `null`, `undefined`, and other
non-objects iterate nothing. An un-awaited promise throws rather than iterating.
- [ ] `for...in` over inherited enumerable keys (`Object.create(proto)`), and skipping keys deleted during the loop.
- [x] `for...in` over the enumerable keys of plain objects, arrays, strings, and tool references, following the
prototype chain like JS (`for (k in Object.create({ a: 1 }))` visits `a`; built-in prototype methods are
non-enumerable so `for (k in [])` visits nothing). A key deleted before its turn is skipped and keys added during
the loop are not visited. `null`, `undefined`, and other non-objects iterate nothing. An un-awaited promise
throws rather than iterating.
- [x] Unlabeled `break` and `continue`.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
- [x] `throw` with arbitrary values.
@@ -170,8 +172,11 @@ Math.floor)` is `"3"`). A detached method loses its receiver, as in JS: `values.
- [x] Redeclaring a function in the same scope, or alongside a `var`, is allowed: the last declaration wins.
- [x] Generator functions have their own `prototype` (inheriting the shared generator prototype), so
`g() instanceof g` holds. Plain functions have none, since they cannot construct.
- [ ] `GeneratorFunction.prototype`: every function, generator or not, inherits directly from `Function.prototype`,
and a generator whose `prototype` was replaced by a non-object still creates from the shared generator prototype.
- [x] Generator functions inherit from `GeneratorFunction.prototype` (async ones from
`AsyncGeneratorFunction.prototype`), an ordinary non-callable object under `Function.prototype` whose `prototype`
is the shared generator prototype and vice versa (`Object.getPrototypeOf(g).prototype.constructor`). Neither is
a global; async non-generator functions still inherit from `Function.prototype` directly. A generator whose
`prototype` was replaced by a non-object creates from the shared generator prototype.
- [x] Generator and async generator functions bind parameters (defaults, destructuring) at the call and defer only the
body to the first `next()`, so a bad argument throws synchronously from the call site, as in JS.
- [x] Synchronous and async generator declarations/expressions, `yield`, and `yield*`, including lazy bodies,
@@ -247,9 +252,13 @@ Math.floor)` is `"3"`). A detached method loses its receiver, as in JS: `values.
hint (`"abc".indexOf({ toString() { return "b" } })` is `1`, `(255).toString({ valueOf() { return 16 } })` is
`"ff"`, `String.prototype.trim.call({ toString() { return " a " } })` is `"a"`). Only consumed positions
convert; a RegExp pattern is used as is, and `includes`/`startsWith`/`endsWith` reject one before converting.
- [ ] ToPrimitive elsewhere: `Error.prototype.toString` on an object `message` and numeric arguments of the Array and
Uint8Array methods (`at`, `indexOf` start, `slice`) still use the built-in form (`NaN`, `"[object Object]"`) and
ignore own methods.
- [x] `Error.prototype.toString` converts an object `name` or `message` through its own `toString` (`String(e)` with
`e.message = { toString() { return "m" } }` is `"Error: m"`); an uncaught error's report at the result boundary
still uses the built-in form.
- [x] `Array.prototype.toString` calls `this.join`, so `arr.join = () => "j"` makes `arr + ""`, `String(arr)`, and
`${arr}` all `"j"`; a non-callable `join` gives `"[object Array]"`.
- [ ] ToPrimitive elsewhere: numeric arguments of the Array and Uint8Array methods (`at`, `indexOf` start, `slice`)
still use the built-in form (`NaN`) and ignore own methods.
- [x] Property keys follow ToPropertyKey: `x[null]` and `x[true]` become string keys, and a data object key
converts through its own `toString`/`valueOf` (string hint) exactly once per access, in reads, writes,
compound assignment, `++`, `delete`, `in`, object literals, and destructuring:
@@ -259,6 +268,8 @@ Math.floor)` is `"3"`). A detached method loses its receiver, as in JS: `values.
## Promises and tools
- [x] Tool calls start eagerly and return supervised, run-once CodeMode promises.
- [x] Tool references have identity: `tools.x === tools.x` and `tools.ns === tools.ns`, so they work as `Map`/`Set`
members and `switch` cases like any other object.
- [x] Direct `await`, repeated awaits, and recursive thenable assimilation when a promise or thenable is returned from
a function/program.
- [x] `Promise.resolve` and `Promise.reject`.
@@ -335,7 +346,7 @@ reject }` object.
`TypeError`.
- [x] `Object.create(proto)` with an object or `null` prototype; any other prototype is a `TypeError`
(`Object prototype may only be an Object or null`). Inherited reads, `in`, `hasOwnProperty`, and own-only
`Object.keys` follow the chain as in JS, but `for...in` still enumerates own keys only. A second `properties`
`Object.keys` follow the chain as in JS, and `for...in` enumerates inherited keys too. A second `properties`
argument other than `undefined` throws a `TypeError`: property descriptors are not supported (there is no
`Object.defineProperty` either).
- [x] `Object.freeze`, `Object.seal`, and `Object.preventExtensions`, with `isFrozen`, `isSealed`, and `isExtensible`.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.18",
"version": "2.0.16",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+15 -6
View File
@@ -20,7 +20,7 @@ import {
type Value,
} from "./objects.js"
import type { Interpreter } from "./interpreter.js"
import { toPrimitiveString } from "./callback.js"
import { toPrimitiveString, withPrimitives } from "./callback.js"
import { formatValue } from "../stdlib/console.js"
export const normalizeError = (error: unknown): Diagnostic => {
@@ -48,7 +48,9 @@ export const normalizeError = (error: unknown): Diagnostic => {
if (error instanceof Throw) {
const value = error.value
if (value instanceof ErrorObj) {
return value.host ? normalizeError(value.host) : { kind: "ExecutionFailure", message: errorToString(value) }
return value.host
? normalizeError(value.host)
: { kind: "ExecutionFailure", message: errorToString(get(value, "name"), get(value, "message")) }
}
let message: string
if (containsRuntimeReference(value)) {
@@ -113,9 +115,7 @@ export const materialize = <R>(ctx: Interpreter<R>, thrown: unknown): Value => {
}
/** Error.prototype.toString: `name: message`, omitting whichever side is empty. */
const errorToString = (self: Obj): string => {
const name = get(self, "name")
const message = get(self, "message")
const errorToString = (name: Value, message: Value): string => {
const shownName = name === undefined ? "Error" : coerceToString(name)
const shownMessage = message === undefined ? "" : coerceToString(message)
if (shownMessage === "") return shownName
@@ -179,7 +179,16 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
})
if (type === "Error") {
methods(builtins, prototype, [
["toString", 0, (thisValue) => errorToString(receiver(Obj, thisValue, "Error.prototype.toString"))],
[
"toString",
0,
(thisValue) => {
const self = receiver(Obj, thisValue, "Error.prototype.toString")
return withPrimitives(ctx, "string", [get(self, "name"), get(self, "message")], ([name, message]) =>
errorToString(name, message),
)
},
],
])
methods(builtins, ctor, [["isError", 1, (_, args) => args[0] instanceof ErrorObj]])
return ctor
@@ -79,7 +79,8 @@ import {
has,
hidden,
hasPrototype,
keys,
ownKeys,
enumerable,
Native,
parseArrayIndex,
Arguments,
@@ -100,7 +101,7 @@ import { Pending, resolvePromise, resolvePromiseValue } from "./promises.js"
import { describeValue, isOpaque, rejectCircularInsertion, typeofValue } from "./references.js"
import { ScopeStack } from "./scope.js"
import { constructRegExp } from "../stdlib/regexp.js"
import { enumerableSource } from "../stdlib/object.js"
import { enumerableSource, restrict } from "../stdlib/object.js"
import { compoundOperators } from "../stdlib/value.js"
/** The binary operators that convert object operands through ToPrimitive before acting on primitives. */
@@ -506,7 +507,7 @@ class Frame<R> {
): Fn {
const builtins = this.ctx.builtins
const fn = new Fn(
builtins.Function,
node.generator ? (node.async ? builtins.AsyncGeneratorFunction : builtins.GeneratorFunction) : builtins.Function,
name,
node.params,
node.body,
@@ -955,10 +956,24 @@ class Frame<R> {
}
// for...in over null/undefined iterates nothing, like JS.
// EnumerateObjectProperties: own keys, then each prototype's, visiting a shadowed key once.
private enumerableKeys(value: Value, node: AstNode): Array<string> {
if (value instanceof ToolReference) return [...this.ctx.tools.keys(value.path)]
if (value === null || value === undefined) return []
return keys(enumerableSource(this.ctx, "for...in", value, node))
const seen = new Set<string>()
const result: Array<string> = []
for (
let current: Obj | null = enumerableSource(this.ctx, "for...in", value, node);
current !== null;
current = current.proto
) {
for (const key of ownKeys(current)) {
if (typeof key !== "string" || seen.has(key)) continue
seen.add(key)
if (enumerable(current, key)) result.push(key)
}
}
return result
}
private evaluateForInStatement(
@@ -982,6 +997,8 @@ class Frame<R> {
const assignment = left.type === "VariableDeclaration" ? undefined : left
for (const key of keys) {
// A key deleted before its turn is skipped, as in JS.
if (right instanceof Obj && !has(right, key)) continue
const result = yield* Effect.gen(function* () {
if (declared?.lexical) {
self.scopes.push()
@@ -1710,12 +1727,15 @@ class Frame<R> {
})
}
// Built-ins throw without a location, synchronously or inside their Effect; the call site supplies it.
// Built-ins throw without a location, synchronously or inside their Effect; the call site supplies it. A built-in
// reached through `ctx.call` runs on the root frame, so the deeper of the frame and the enclosing site counts.
private native(body: () => Effect.Effect<Value, unknown, R>, node?: AstNode): Effect.Effect<Value, unknown, R> {
return Effect.provideService(
Effect.catchDefect(Effect.suspend(body), (defect) => Effect.die(locate(defect, node))),
CallSite,
{ node, depth: this.depth },
return Effect.flatMap(CallSite, (site) =>
Effect.provideService(
Effect.catchDefect(Effect.suspend(body), (defect) => Effect.die(locate(defect, node))),
CallSite,
{ node, depth: Math.max(this.depth, site.depth) },
),
)
}
@@ -2155,12 +2175,16 @@ class Frame<R> {
define(
strings,
"raw",
new Arr(
array,
node.quasi.quasis.map((quasi) => quasi.value.raw),
restrict(
"freeze",
new Arr(
array,
node.quasi.quasis.map((quasi) => quasi.value.raw),
),
),
frozen,
)
restrict("freeze", strings)
this.ctx.templates.set(node, strings)
return strings
}
@@ -2208,7 +2232,7 @@ class Frame<R> {
if (typeof key !== "string") {
throw typeError("Tool paths must use string property names.", propertyNode)
}
return new ToolReference([...objectValue.path, key])
return objectValue.child(key)
}
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define, hidden, Native, Arr, ErrorObj, Obj, type Value } from "./objects.js"
import { Arr, define, ErrorObj, hidden, Native, Obj, readOnly, type Value } from "./objects.js"
export const errorTypes = [
"Error",
@@ -41,6 +41,8 @@ const builtins = [
"AsyncIterator",
"Generator",
"AsyncGenerator",
"GeneratorFunction",
"AsyncGeneratorFunction",
] as const
/**
@@ -79,6 +81,15 @@ export const createBuiltins = (): Builtins => {
}
const iterator = plain()
const asyncIterator = plain()
// %GeneratorFunction.prototype% is an ordinary object linked both ways with %GeneratorPrototype%.
const generatorFunction = (generator: Obj) => {
const proto = new Obj(fn)
define(proto, "prototype", generator, readOnly)
define(generator, "constructor", proto, readOnly)
return proto
}
const generator = new Obj(iterator)
const asyncGenerator = new Obj(asyncIterator)
return {
Object: object,
Function: fn,
@@ -102,8 +113,10 @@ export const createBuiltins = (): Builtins => {
Iterator: iterator,
IteratorHelper: new Obj(iterator),
AsyncIterator: asyncIterator,
Generator: new Obj(iterator),
AsyncGenerator: new Obj(asyncIterator),
Generator: generator,
AsyncGenerator: asyncGenerator,
GeneratorFunction: generatorFunction(generator),
AsyncGeneratorFunction: generatorFunction(asyncGenerator),
Error: error,
TypeError: derived("TypeError"),
RangeError: derived("RangeError"),
+2 -1
View File
@@ -36,6 +36,7 @@ export const hidden: Attributes = { writable: true, enumerable: false, configura
export const readonly: Attributes = { writable: false, enumerable: false, configurable: true }
/** Constants such as `Math.PI` and a constructor's `prototype`. */
export const frozen: Attributes = { writable: false, enumerable: false, configurable: false }
export const readOnly: Attributes = { writable: false, enumerable: false, configurable: true }
/**
* An object owned by the program: own properties plus a prototype link. Subclasses answer, in one place, how a
@@ -645,7 +646,7 @@ export const ownKeys = (target: Obj): Array<string | symbol> => {
]
}
const enumerable = (target: Obj, key: string | symbol): boolean => own(target, key)?.enumerable === true
export const enumerable = (target: Obj, key: string | symbol): boolean => own(target, key)?.enumerable === true
/** Own enumerable keys, including the iterator symbols; what spread and `Object.assign` copy. */
export const enumerableKeys = (target: Obj): Array<string | symbol> =>
+7 -7
View File
@@ -5,6 +5,7 @@ import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpret
import {
define,
get,
Callable,
hidden,
Arr,
GeneratorObj,
@@ -165,13 +166,12 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
[
"toString",
0,
(thisValue) =>
withPrimitives(
ctx,
"string",
Array.from(self(thisValue, "toString").items, (item) => item ?? ""),
(items) => items.map(coerceToString).join(","),
),
(thisValue) => {
// Spec: delegate to this.join, so an overridden join shows up in `arr + ""` and String(arr).
const target = self(thisValue, "toString")
const join = get(target, "join")
return join instanceof Callable ? ctx.call(join, target, []) : `[object ${target.tag}]`
},
],
[
"includes",
+1 -1
View File
@@ -104,7 +104,7 @@ const propertyKey = (value: Value): PropertyKey =>
// SetIntegrityLevel: primitives pass through. A typed array's bytes cannot carry attributes, so JS throws after
// already making it non-extensible.
const restrict = (level: "freeze" | "seal" | "preventExtensions", value: Value): Value => {
export const restrict = (level: "freeze" | "seal" | "preventExtensions", value: Value): Value => {
if (!(value instanceof Obj)) return value
value.extensible = false
if (level === "preventExtensions") return value
+9
View File
@@ -113,7 +113,16 @@ export const toolExpression = (path: string) =>
.join("")
export class ToolReference {
private readonly children = new Map<string, ToolReference>()
constructor(readonly path: ReadonlyArray<string>) {}
/** One reference per path, so `tools.a === tools.a` holds like any other member read. */
child(key: string): ToolReference {
const existing = this.children.get(key)
if (existing !== undefined) return existing
const created = new ToolReference([...this.path, key])
this.children.set(key, created)
return created
}
}
// Dots in tool names are namespace separators; the last tool for a canonical path wins.
+19
View File
@@ -178,6 +178,25 @@ describe("call depth", () => {
expect(Date.now() - started).toBeLessThan(2000)
})
test("recursion routed through nested built-ins keeps counting depth", async () => {
const started = Date.now()
expect(
await value(`
const a = [1]
a.join = () => a + ""
const o = { toString() { return [o].map(String)[0] } }
const e = new Error()
e.message = { toString() { return String(e) } }
const names = []
for (const run of [() => String(a), () => String(o), () => String(e)]) {
try { run() } catch (error) { names.push(error.name) }
}
return names
`),
).toEqual(["RangeError", "RangeError", "RangeError"])
expect(Date.now() - started).toBeLessThan(2000)
})
test("uncaught overflow reports the call that overflowed", async () => {
const failure = await error(`const f = (n) => f(n + 1); return f(0)`)
expect(failure.kind).toBe("ExecutionFailure")
+78 -2
View File
@@ -1382,7 +1382,7 @@ describe("Object.getPrototypeOf and Object.create", () => {
expect((await error(`Object.getPrototypeOf(Symbol.iterator)`)).message).toContain("cannot convert a symbol")
})
test("Object.create links the prototype: inherited reads, in, and own-only keys", async () => {
test("Object.create links the prototype: inherited reads, in, own-only keys, and for...in", async () => {
expect(
await value(`
const p = { greet(name) { return "hi " + name }, a: 1 }
@@ -1392,7 +1392,7 @@ describe("Object.getPrototypeOf and Object.create", () => {
for (const key in c) seen.push(key)
return ["greet" in c, Object.keys(c), c.hasOwnProperty("a"), c.a, c.greet(c.name), Object.getPrototypeOf(c) === p, Object.getPrototypeOf(Object.create(null)), seen]
`),
).toEqual([true, ["name"], false, 1, "hi x", true, null, ["name"]])
).toEqual([true, ["name"], false, 1, "hi x", true, null, ["name", "greet", "a"]])
})
test("Object.create rejects non-object prototypes and property descriptors", async () => {
@@ -1984,3 +1984,79 @@ describe("WeakMap and WeakSet", () => {
expect((await error(`structuredClone(new WeakSet())`)).message).toContain("DataCloneError")
})
})
describe("small language leftovers", () => {
test("for...in walks the prototype chain and skips keys deleted before their turn", async () => {
expect(
await value(`
const o = Object.create({ a: 1, shadowed: 1 })
o.b = 2
o.shadowed = 3
const keys = []
for (const k in o) keys.push(k)
const live = { a: 1, b: 2, c: 3 }
const seen = []
for (const k in live) { seen.push(k); delete live.b; live.z = 1 }
const none = []
for (const k in []) none.push(k)
for (const k in new TypeError("x")) none.push(k)
return [keys, seen, none]
`),
).toEqual([["b", "shadowed", "a"], ["a", "c"], []])
})
test("tagged template objects are frozen", async () => {
expect(
await value(`
const tag = (s) => s
const f = () => tag\`a\${1}b\`
return [f() === f(), Object.isFrozen(f()), Object.isFrozen(f().raw)]
`),
).toEqual([true, true, true])
expect((await error("const tag = (s) => s; tag`a`[0] = 'x'")).message).toContain("read only")
})
test("Array.prototype.toString delegates to join", async () => {
expect(
await value(`
const a = [1, 2]
a.join = () => "j"
const b = [1]
b.join = 5
return [a + "", String(a), \`\${a}\`, b.toString(), Array.prototype.toString.call([3, [4]])]
`),
).toEqual(["j", "j", "j", "[object Array]", "3,4"])
})
test("Error.prototype.toString converts object name and message", async () => {
expect(
await value(`
const e = new Error("m")
e.message = { toString() { return "obj" } }
e.name = { valueOf() { return "N" }, toString() { return "T" } }
return [String(e), Error.prototype.toString.call({ name: "", message: "m" }), Error.prototype.toString.call({})]
`),
).toEqual(["T: obj", "m", "Error"])
expect(
(await error(`const e = new Error(); e.message = { toString() { throw new RangeError("r") } }; String(e)`))
.message,
).toContain("r")
})
test("generator functions inherit from GeneratorFunction.prototype", async () => {
expect(
await value(`
function* g() {}
async function* ag() {}
const GFP = Object.getPrototypeOf(g)
g.prototype = null
return [
typeof GFP, GFP === Function.prototype, Object.getPrototypeOf(GFP) === Function.prototype,
GFP.prototype.constructor === GFP, Object.getPrototypeOf(ag) === GFP, typeof g.bind,
Object.getPrototypeOf(g()) === GFP.prototype,
]
`),
).toEqual(["object", false, true, true, false, "function", true])
expect((await error(`function* g() {} Object.getPrototypeOf(g)()`)).message).toContain("not a function")
})
})
@@ -884,7 +884,6 @@ built-ins/AsyncGeneratorFunction/invoked-as-constructor-no-arguments.js # TypeE
built-ins/AsyncGeneratorFunction/invoked-as-function-multiple-arguments.js # TypeError: The Function constructor is not supported; write the function inline.
built-ins/AsyncGeneratorFunction/invoked-as-function-no-arguments.js # TypeError: The Function constructor is not supported; write the function inline.
built-ins/AsyncGeneratorFunction/invoked-as-function-single-argument.js # TypeError: The Function constructor is not supported; write the function inline.
built-ins/AsyncGeneratorFunction/prototype/not-callable.js # Expected SameValue(«"function"», «"object"») to be true
built-ins/AsyncGeneratorPrototype/next/this-val-not-async-generator.js # TypeError: Cannot read properties of undefined (reading '…').
built-ins/AsyncGeneratorPrototype/return/this-val-not-async-generator.js # TypeError: Cannot read properties of undefined (reading '…').
built-ins/AsyncGeneratorPrototype/throw/this-val-not-async-generator.js # TypeError: Cannot read properties of undefined (reading '…').
@@ -900,7 +899,6 @@ built-ins/Date/prototype/toJSON/invoke-result.js # TypeError: Date.prototype.to
built-ins/Date/prototype/toJSON/to-primitive-value-of.js # TypeError: Date.prototype.toJSON called on incompatible receiver a data object.
built-ins/Date/prototype/toString/format.js # Expected SameValue(«null», «null») to be false
built-ins/Date/prototype/toString/negative-year.js # Date.prototype.toString serializes year -1 to "-0001" Expected SameValue(«undefined», «"-0001"») to
built-ins/Error/prototype/toString/tostring-message-throws-toprimitive.js # ToPrimitive(msg) called by ToString(msg) throws TypeError Expected a TypeError to be thrown but no e
built-ins/Function/15.3.5-1gs.js # Expected a TypeError to be thrown but no exception was thrown at all
built-ins/Function/15.3.5-2gs.js # Expected a TypeError to be thrown but no exception was thrown at all
built-ins/Function/15.3.5.4_2-1gs.js # Expected a TypeError to be thrown but no exception was thrown at all
@@ -931,7 +929,6 @@ built-ins/GeneratorFunction/invoked-as-constructor-no-arguments.js # TypeError:
built-ins/GeneratorFunction/invoked-as-function-multiple-arguments.js # TypeError: The Function constructor is not supported; write the function inline.
built-ins/GeneratorFunction/invoked-as-function-no-arguments.js # TypeError: The Function constructor is not supported; write the function inline.
built-ins/GeneratorFunction/invoked-as-function-single-argument.js # TypeError: The Function constructor is not supported; write the function inline.
built-ins/GeneratorFunction/prototype/not-callable.js # Expected SameValue(«"function"», «"object"») to be true
built-ins/GeneratorPrototype/throw/from-state-completed.js # Expected a E but got a TypeError
built-ins/GeneratorPrototype/throw/from-state-suspended-start.js # Expected a E but got a TypeError
built-ins/Iterator/from/return-method-returns-iterator-result.js # Iterator next must be a function.
@@ -1468,12 +1465,10 @@ language/expressions/function/dstr/ary-init-iter-get-err-array-prototype.js # E
language/expressions/function/dstr/ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/expressions/function/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/expressions/function/dstr/dflt-ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/expressions/generators/default-proto.js # Expected SameValue(«object», «undefined») to be true
language/expressions/generators/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/expressions/generators/dstr/ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/expressions/generators/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/expressions/generators/dstr/dflt-ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/expressions/generators/prototype-relation-to-function.js # Expected SameValue(«object», «») to be true
language/expressions/greater-than-or-equal/S11.8.4_A3.2_T1.2.js # TypeError: Binary operators require data values.
language/expressions/greater-than/S11.8.2_A3.2_T1.2.js # TypeError: Binary operators require data values.
language/expressions/in/S8.12.6_A2_T2.js # TypeError: Robin cannot be constructed: user-defined constructors and classes are not supported. Cal
@@ -1559,7 +1554,6 @@ language/expressions/super/prop-expr-obj-key-err.js # unsupported syntax Super
language/expressions/super/prop-expr-obj-ref-strict.js # unsupported syntax Super
language/expressions/super/prop-expr-obj-unresolvable.js # Expected SameValue(«SyntaxError», «ReferenceError») to be true
language/expressions/tagged-template/constructor-invocation.js # The called value cannot be constructed: user-defined constructors and classes are not supported.
language/expressions/tagged-template/template-object-frozen-strict.js # Expected a TypeError to be thrown but no exception was thrown at all
language/expressions/this/11.1.1-1.js # Expected SameValue(«undefined», «undefined») to be false
language/expressions/unary-minus/S11.4.7_A3_T5.js # TypeError: Unary operators require data values.
language/expressions/unary-plus/S11.4.6_A3_T5.js # TypeError: Unary operators require data values.
@@ -1588,7 +1582,6 @@ language/statements/async-generator/dstr/dflt-ary-ptrn-elem-id-iter-val-array-pr
language/statements/async-generator/return-undefined-implicit-and-explicit.js # Actual ["tick 1", "tick 2", "g1 ret", "g2 ret", "g3 ret", "g4 ret"] and expected ["tick 1", "g1 ret"
language/statements/const/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/const/dstr/ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/statements/for-in/S12.6.4_A7_T2.js # for...in visits keys deleted during the loop
language/statements/for-in/order-enumerable-shadowed.js # Object.create property descriptors are not supported; assign the fields after creating the object.
language/statements/for-in/S12.6.4_A6.1.js # TypeError: FACTORY cannot be constructed: user-defined constructors and classes are not supported. C
language/statements/for-in/S12.6.4_A6.js # TypeError: FACTORY cannot be constructed: user-defined constructors and classes are not supported. C
@@ -1660,12 +1653,10 @@ language/statements/function/dstr/ary-init-iter-get-err-array-prototype.js # Ex
language/statements/function/dstr/ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/statements/function/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/function/dstr/dflt-ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/statements/generators/default-proto.js # generator functions have no GeneratorFunction.prototype
language/statements/generators/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/generators/dstr/ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/statements/generators/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/generators/dstr/dflt-ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/statements/generators/prototype-relation-to-function.js # generator functions have no GeneratorFunction.prototype
language/statements/generators/restricted-properties.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/labeled/value-await-non-module.js # Failed to parse TypeScript: Expression expected.
language/statements/let/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
+13
View File
@@ -368,3 +368,16 @@ describe("tool references under ==", () => {
).toEqual([false, false, false, 0])
})
})
describe("tool reference identity", () => {
test("repeated member reads yield the same reference", async () => {
const runtime = CodeMode.make({ tools: { probe: echo("Probe", "ok"), "ns.inner": echo("Inner", "in") } })
expect(
await value(
runtime,
`return [tools.probe === tools.probe, tools.ns.inner === tools.ns.inner, tools.ns === tools.ns, tools["probe"] === tools.probe,
tools.probe === tools.ns.inner, new Set([tools.probe, tools.probe]).size, await tools.ns.inner({})]`,
),
).toEqual([true, true, true, true, false, 1, "in"])
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.18",
"version": "2.0.16",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.18",
"version": "2.0.16",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.18",
"version": "2.0.16",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
@@ -18,7 +18,7 @@ export const Plugin = define({
editor.configure({
...(entry.info.compaction.auto === undefined ? {} : { auto: entry.info.compaction.auto }),
...(entry.info.compaction.buffer === undefined ? {} : { buffer: entry.info.compaction.buffer }),
...(entry.info.compaction.keep?.tokens === undefined ? {} : { keep: entry.info.compaction.keep.tokens }),
...(entry.info.compaction.keep?.tokens === undefined ? {} : { tokens: entry.info.compaction.keep.tokens }),
})
}
})
-2
View File
@@ -197,8 +197,6 @@ export const provider = (options: Options): OAuthClientProvider => {
saveTokens: (tokens) => options.store.saveTokens(tokens),
redirectToAuthorization: (url) => {
if (!redirect) throw refuse("user authorization")
if (url.protocol !== "http:" && url.protocol !== "https:")
throw new Error(`MCP server "${options.config.url}" returned a ${url.protocol} authorization URL; only http and https are supported`)
return redirect.open(url)
},
...(options.invalidate ? { invalidateCredentials: options.invalidate } : {}),
+29
View File
@@ -0,0 +1,29 @@
export * as NativeCompactionPlugin from "./compaction.js"
import { LLMClient, Message } from "@opencode/ai"
import { define } from "@opencode/plugin/effect/plugin"
import { Effect } from "effect"
import { SessionCompaction } from "../session/compaction.js"
import type { PluginInternal } from "./internal.js"
export const Plugin = define({
id: "opencode.compaction.native",
effect: Effect.fn("NativeCompactionPlugin")(function* () {
const llm = yield* LLMClient.Service
const compaction = yield* SessionCompaction.Service
yield* compaction.transform((editor) => {
editor.native((input) => {
const request = input.request
if (LLMClient.canCompact(request, { mechanism: "trigger" }))
return Effect.gen(function* () {
const retained = yield* input.retained
const result = yield* llm.compact(request, { ...input.options, mechanism: "trigger" })
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
})
if (LLMClient.canCompact(request))
return llm.compact(request, { mechanism: "endpoint", http: input.options.http })
return undefined
})
})
}),
} satisfies PluginInternal.InternalPlugin)
+2
View File
@@ -88,6 +88,7 @@ import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "@opencode/plugin-browser"
import { CommandPlugin } from "./command.js"
import { NativeCompactionPlugin } from "./compaction.js"
import { IdentityPlugin } from "./identity.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
@@ -224,6 +225,7 @@ const pre = [
SkillPlugin.Plugin,
VcsHgPlugin.Plugin,
ModelsDevPlugin,
NativeCompactionPlugin.Plugin,
...ProviderPlugins,
...WebSearchPlugins,
PatchTool.Plugin,
@@ -1,5 +1,5 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
import type { SessionRequestKind, SessionTitle } from "@opencode/plugin/effect/session"
import type { SessionRequestKind } from "@opencode/plugin/effect/session"
import { Effect, Option, Schema, Semaphore, Stream } from "effect"
import { IntegrationConnection } from "../../integration/connection.js"
import { Credential } from "../../credential.js"
@@ -8,7 +8,6 @@ import { CopilotModels } from "../../github-copilot/models.js"
import { App } from "../../app.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { Agent } from "../../agent.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
@@ -163,7 +162,6 @@ export const GithubCopilotPlugin = define({
const loading = Semaphore.makeUnsafe(1)
const loaded: {
baseURL?: string
token?: string
models?: CopilotModels.Snapshot
connection?: Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
} = {}
@@ -175,7 +173,6 @@ export const GithubCopilotPlugin = define({
: undefined
if (credential?.type !== "oauth") {
loaded.baseURL = undefined
loaded.token = undefined
loaded.models = undefined
loaded.connection = undefined
return
@@ -203,7 +200,6 @@ export const GithubCopilotPlugin = define({
)
return
loaded.baseURL = url
loaded.token = credential.refresh
loaded.models = remote
loaded.connection = connection
})
@@ -277,29 +273,6 @@ export const GithubCopilotPlugin = define({
}),
{ providerID: Provider.ID.githubCopilot },
)
// GitHub's integration guide designates session naming as a utility scenario served by
// free, rate-limited utility models. Any failure leaves the result unset so the normal
// billable title path still runs.
yield* ctx.session.hook(
"title",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
if (!loaded.baseURL || !loaded.token || !loaded.models) return
const agent = yield* ctx.agent.get({ agentID: Agent.ID.make("title") }).pipe(Effect.orElseSucceed(() => undefined))
if (agent?.data.model) return
const model = utilityTitleModels.find((id) => loaded.models?.has(id))
if (!model) return
evt.result = yield* utilityTitle(
{ baseURL: loaded.baseURL, token: loaded.token, model, app: ctx.app },
evt,
).pipe(
Effect.tapError((cause) => Effect.logDebug("Copilot utility title failed", { model, cause })),
Effect.orElseSucceed(() => undefined),
)
}),
{ providerID: Provider.ID.githubCopilot },
)
yield* ctx.session.hook(
"http.request",
(evt) =>
@@ -396,70 +369,6 @@ function request(url: string, init: RequestInit) {
type Fetch = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>
// Matches the Copilot client: gpt-4o-mini is the "small utility" model; when an account
// lacks it the request falls through to the regular title path instead of another guess.
export const utilityTitleModels = ["gpt-4o-mini"]
const UtilityCompletion = Schema.Struct({
choices: Schema.Array(Schema.Struct({ message: Schema.Struct({ content: Schema.NullOr(Schema.String) }) })),
})
const decodeUtilityCompletion = Schema.decodeUnknownEffect(Schema.fromJsonString(UtilityCompletion))
// This issues its own /chat/completions call instead of steering core's title request.
// The `session.title` hook exposes `model` read-only, so pointing core at another model
// would mean widening that contract and re-resolving inside SessionModelRequest; rewriting
// the body in `http.request` is worse still, since the endpoint (/responses for GPT-5,
// /v1/messages for Claude) is fixed before that hook runs and gpt-4o-mini only serves
// /chat/completions. Utility titles are Copilot-specific, stateless, non-streaming, and
// free, so a small request here costs less than a core seam. Trade-offs: no session
// usage record for the title step (the call bills nothing) and no `http.*` hook visibility.
export function utilityTitle(
input: { baseURL: string; token: string; model: string; app: App.Info; fetch?: Fetch },
request: Pick<SessionTitle, "sessionID" | "system" | "messages" | "options">,
) {
const send = input.fetch ?? fetch
const text = (parts: ReadonlyArray<{ type: string; text?: string | null }>) =>
parts.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : [])).join("\n")
const messages = [
...(request.system.length ? [{ role: "system", content: text(request.system) }] : []),
...request.messages.map((message) => ({ role: message.role, content: text(message.content) })),
]
return Effect.tryPromise({
try: async (signal) => {
const response = await send(`${input.baseURL}/chat/completions`, {
method: "POST",
signal,
headers: {
Authorization: `Bearer ${input.token}`,
"Content-Type": "application/json",
"User-Agent": App.useragent(input.app),
"X-GitHub-Api-Version": apiVersion,
"Openai-Intent": "conversation-edits",
"X-Interaction-Type": "agent-session-name-generation",
"X-Interaction-Id": request.sessionID,
"x-initiator": "agent",
},
body: JSON.stringify({
model: input.model,
messages,
stream: false,
...(typeof request.options.maxTokens === "number" ? { max_tokens: request.options.maxTokens } : {}),
}),
})
if (!response.ok) throw new Error(`Utility title request failed: ${response.status}`)
return response.text()
},
catch: (cause) => cause,
}).pipe(
Effect.timeout("20 seconds"),
Effect.flatMap(decodeUtilityCompletion),
Effect.flatMap((completion) => {
const title = completion.choices[0]?.message.content?.trim()
return title ? Effect.succeed(title) : Effect.fail(new Error("Utility title response was empty"))
}),
)
}
export function copilotFetch(token: string | undefined, upstream: Fetch | undefined, app: App.Info): Fetch {
const send = upstream ?? fetch
return async (input, init) => {
@@ -1,6 +1,5 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
import { define } from "@opencode/plugin/effect/plugin"
import type { SessionRequest } from "@opencode/plugin/effect/session"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
import type { Server } from "node:http"
import { App } from "../../app.js"
@@ -308,13 +307,6 @@ export const OpenAIPlugin = define({
}),
{ providerID: Provider.ID.openai },
)
// The ChatGPT backend rejects a requested output limit, and OpenAI counts one against rate limits.
const omitOutputLimit = (evt: SessionRequest) =>
Effect.sync(() => {
delete evt.options.maxTokens
})
for (const name of ["context", "compaction"] as const)
yield* ctx.session.hook(name, omitOutputLimit, { providerID: Provider.ID.openai })
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.provider.reload())))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
File diff suppressed because it is too large Load Diff
+1 -27
View File
@@ -44,14 +44,6 @@ const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
const IMAGE_REMOVED =
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
const GENERATION_KEYS = new Set(Object.keys(GenerationOptions.fields))
// Default output limit caps per request kind. Titles and generate have none and keep the provider default, because
// their reasoning is hard to budget.
const OUTPUT_TOKEN_CAPS: Partial<Record<SessionRequestKind, number>> = { primary: 256_000, compaction: 32_000 }
// Used when the catalog has no output limit for the model.
const OUTPUT_TOKEN_FALLBACK = 32_000
// Prompt text is estimated at about 4 characters per token, which can run low on dense text such as code.
const ESTIMATE_ERROR = 0.15
const OUTPUT_TOKEN_MIN = 1_024
/** Tool errors, plus the user declining a permission or dismissing a question. */
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
@@ -77,16 +69,6 @@ export interface Input {
readonly toolChoice?: LLM.RequestInput["toolChoice"]
/** Only the durable runner may use a stateful WebSocket. */
readonly webSocket?: "session"
/** Prompt size, measured by the provider or estimated. The default output limit leaves room for it. */
readonly inputTokens?: { readonly measured: number; readonly estimated: number }
}
/** The default output limit: the catalog limit, capped, and fitted to the room the prompt leaves in the context window. */
export const outputLimit = (limit: Model.Info["limit"], cap: number, inputTokens?: Input["inputTokens"]) => {
const requested = Math.min(limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK, cap)
if (inputTokens === undefined || limit.context <= 0) return requested
const room = limit.context - inputTokens.measured - Math.ceil(inputTokens.estimated * (1 + ESTIMATE_ERROR))
return Math.min(requested, Math.max(OUTPUT_TOKEN_MIN, room))
}
export const baseTranscript = (input: {
@@ -236,16 +218,8 @@ export const layer = Layer.effect(
const given = new Map(
tools.definitions.map((t) => [{ description: t.description, input: { ...t.inputSchema } }, t] as const),
)
// Hooks see the default output limit and may change or remove it.
const cap = OUTPUT_TOKEN_CAPS[kind]
const shaped = yield* shape(
{
sessionID: session.id,
model: model.ref,
system: input.system,
messages: input.messages,
options: cap === undefined ? {} : { maxTokens: outputLimit(model.limit, cap, input.inputTokens) },
},
{ sessionID: session.id, model: model.ref, system: input.system, messages: input.messages, options: {} },
Object.fromEntries(Array.from(given, ([d, t]) => [t.name, d])),
)
// Match by identity first, then by key. Entries matching neither were invented by a
+3 -29
View File
@@ -2,7 +2,7 @@ export * as SessionProviderContext from "./provider-context.js"
import { Message } from "@opencode/ai"
import { SessionProviderContext } from "@opencode/schema/session-provider-context"
import { Predicate, Schema } from "effect"
import { Schema } from "effect"
import { isDeepStrictEqual } from "node:util"
import { Hash } from "@opencode/util/hash"
import type { SessionMessage } from "./message.js"
@@ -54,31 +54,5 @@ export const encode = (provenance: Provenance, replacement: ReadonlyArray<Messag
messages: Schema.decodeSync(Schema.fromJsonString(Schema.Json))(JSON.stringify(replacement)),
})
export const decode = (context: Info) => Schema.decodeUnknownSync(messages)(upgradeLegacyMedia(context.messages))
export const validate = (context: Info) => Schema.decodeUnknownEffect(messages)(upgradeLegacyMedia(context.messages))
/**
* Before 2.0.15, version-1 checkpoints stored mediaType/data directly on media parts. Core only built
* those parts from prompt attachments, whose data the Prompt schema guarantees is base64.
*/
function upgradeLegacyMedia(input: Info["messages"]) {
if (!Array.isArray(input)) return input
return input.map((message: unknown) => {
if (!Predicate.isObject(message) || !Array.isArray(message.content)) return message
return {
...message,
content: message.content.map((part: unknown) => {
if (
!Predicate.isObject(part) ||
part.type !== "media" ||
part.media !== undefined ||
typeof part.mediaType !== "string" ||
typeof part.data !== "string"
)
return part
const { mediaType, data, ...rest } = part
return { ...rest, media: { source: { type: "base64", data, mediaType } } }
}),
}
})
}
export const decode = (context: Info) => Schema.decodeUnknownSync(messages)(context.messages)
export const validate = (context: Info) => Schema.decodeUnknownEffect(messages)(context.messages)
+39 -36
View File
@@ -20,7 +20,6 @@ import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { SessionMessageTable } from "../sql.js"
import { SessionTitle } from "../title.js"
import { toSessionError } from "../to-session-error.js"
import { DrainResult, Service, type Interface } from "./index.js"
import { Snapshot } from "../../snapshot.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
@@ -110,39 +109,39 @@ const layer = Layer.effect(
if (pending?.type === "move")
return DrainResult.Moved({ continuation: continuing ? { step } : undefined })
if (pending?.type === "compaction") {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const compacted = yield* restore(
Effect.gen(function* () {
const selected = yield* context.select(sessionID)
const model = yield* context.resolveModel(selected.session)
// Preview updates without admitting them after the already-delivered compaction marker.
const history = yield* SessionHistory.preview(
db,
sessionID,
selected.instructions,
SessionProviderContext.provenance(model) ?? "local",
)
return yield* compaction.compact({
reason: "manual",
return yield* compaction.compactManual({
session,
resolveContext: (session) =>
Effect.gen(function* () {
const selected = yield* context.select(session.id)
const model = yield* context.resolveModel(selected.session)
// Preview updates without admitting them after the already-delivered compaction marker.
const history = yield* SessionHistory.preview(
db,
session.id,
selected.instructions,
SessionProviderContext.provenance(model) ?? "local",
)
return {
session: selected.session,
agent: selected.agent,
tools: selected.tools,
model,
initial: history.initial,
messages: history.messages,
instructionUpdate: history.instructionUpdate,
}
}),
prepare: context.request.compaction,
messages: yield* store.context(sessionID),
inputID: pending.id,
context: {
session: selected.session,
agent: selected.agent,
tools: selected.tools,
model,
initial: history.initial,
messages: history.messages,
},
started: true,
})
}).pipe(
Effect.catch((error) =>
bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
inputID: pending.id,
error: toSessionError(error),
}),
),
),
}),
).pipe(Effect.exit)
if (Exit.isFailure(compacted)) {
yield* bus.publish(SessionEvent.Compaction.Failed, {
@@ -214,9 +213,14 @@ const layer = Layer.effect(
// Reuse boundary preparation once; retries refresh context without delivering more input.
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
initial = undefined
const compacted = yield* compaction.compact({ reason: "auto", context: loaded })
if (compacted.status === "failed") return yield* new StepFailedError({ error: compacted.error })
if (compacted.status === "completed") {
const compactionInput = {
context: loaded,
prepare: context.request.compaction,
}
if (compaction.required({ messages: loaded.messages, resolved: loaded.model, context: loaded })) {
const result = yield* compaction.compact(compactionInput)
if (result.status !== "completed") return yield* new StepFailedError({ error: result.error })
if (result.recoveredOverflow) recoverOverflow = false
assistantMessageID = SessionMessage.ID.create()
continue
}
@@ -240,7 +244,6 @@ const layer = Layer.effect(
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
inputTokens: SessionCompaction.estimatePrompt(loaded),
})
const outcome = yield* steps.attempt({
isLocationClosed: lifecycle.isClosed,
@@ -260,9 +263,9 @@ const layer = Layer.effect(
}),
recoverContinuation,
recoverOverflow: Effect.suspend(() =>
recoverOverflow
recoverOverflow && compaction.enabled()
? compaction
.compact({ reason: "overflow", context: loaded })
.compact({ ...compactionInput, overflow: true })
.pipe(Effect.map((result) => result.status === "completed"))
: Effect.succeed(false),
),
@@ -309,14 +309,17 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
Message.make({
id: message.id,
role: "user",
content: [
"<conversation-checkpoint>",
"The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.",
"",
`<summary>\n${message.summary}\n</summary>`,
...(message.recent ? ["", `<recent-context>\n${message.recent}\n</recent-context>`] : []),
"</conversation-checkpoint>",
].join("\n"),
content: `<conversation-checkpoint>
The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.
<summary>
${message.summary}
</summary>
<recent-context>
${message.recent}
</recent-context>
</conversation-checkpoint>`,
metadata: message.metadata,
}),
]
+11 -2
View File
@@ -54,7 +54,8 @@ const modelID = (model: Model.Info) => model.modelID ?? model.id
function claudeInfo(model: Model.Info) {
const id = modelID(model)
const familyFirst = /(?:claude-)?(opus|sonnet|haiku|fable|mythos)-(\d+)(?:[.-](\d+))?/i.exec(id)
// Versions are at most two digits so snapshot dates such as `-20260901` are not read as versions.
const familyFirst = /(?:claude-)?(opus|sonnet|haiku|fable|mythos)-(\d{1,2})(?:[.-](\d{1,2}))?(?!\d)/i.exec(id)
const versionFirst = /claude-(\d+)(?:[.-](\d+))?-(opus|sonnet|haiku|fable|mythos)/i.exec(id)
const family = (familyFirst?.[1] ?? versionFirst?.[3])?.toLowerCase()
const major = Number(familyFirst?.[2] ?? versionFirst?.[1])
@@ -64,7 +65,12 @@ function claudeInfo(model: Model.Info) {
major,
minor,
manual: (major === 3 && minor === 7) || (major === 4 && minor < 6),
always: family === "fable" || family === "mythos" || id.toLowerCase().includes("mythos-preview"),
// Opus 5.5 and later reject disabled thinking.
always:
family === "fable" ||
family === "mythos" ||
id.toLowerCase().includes("mythos-preview") ||
(family === "opus" && (major > 5 || (major === 5 && minor >= 5))),
}
}
@@ -390,6 +396,7 @@ const bedrockConverse: Protocol = (model, support) => {
return fields({ reasoningConfig: { type: "enabled", maxReasoningEffort: effort } })
})
case "toggle":
if (claude && claudeInfo(model).always) return []
return claude
? toggle(fields({ thinking: { type: "disabled" } }), fields({ thinking: ADAPTIVE_THINKING }))
: toggle(fields({ reasoningConfig: { type: "disabled" } }), fields({ reasoningConfig: { type: "enabled" } }))
@@ -449,6 +456,7 @@ const bedrockAISDK: Protocol = (model, support) => {
: { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } },
}))
case "toggle":
if (claude && claudeInfo(model).always) return []
return claude
? toggle(
{ settings: { additionalModelRequestFields: { thinking: { type: "disabled" } } } },
@@ -507,6 +515,7 @@ const sapAICore: Protocol = (model, support) => {
sap({ additionalModelRequestFields: { thinking: { type: "disabled" } } }),
sap({ additionalModelRequestFields: { thinking: { type: "enabled" } } }),
)
if (id.includes("anthropic") && claudeInfo(model).always) return []
if (id.includes("anthropic"))
return toggle(
sap({ additionalModelRequestFields: { thinking: { type: "disabled" } } }),
+13 -15
View File
@@ -53,11 +53,7 @@ describe("ConfigCompactionPlugin.Plugin", () => {
it.live("merges settings and reloads changed config", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
// An automatic compaction that is not due is skipped.
const due = (input: typeof nearInput) =>
compaction
.compact({ reason: "auto", context: input.context })
.pipe(Effect.map((outcome) => outcome.status !== "skipped"))
const modelRequests = yield* SessionModelRequest.Service
const config = yield* Config.Test
const bus = yield* Bus.Service
yield* config.setEntries([
@@ -77,9 +73,9 @@ describe("ConfigCompactionPlugin.Plugin", () => {
])
yield* ConfigCompactionPlugin.Plugin.effect(host({ event: { subscribe: () => bus.subscribe(Event.Updated) } }))
expect(yield* due(nearInput)).toBe(false)
const ended = yield* bus
.subscribe(SessionEvent.Compaction.Ended)
expect(compaction.required(nearInput)).toBe(false)
const started = yield* bus
.subscribe(SessionEvent.Compaction.Started)
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
const messages = [
SessionMessage.User.make({
@@ -96,13 +92,15 @@ describe("ConfigCompactionPlugin.Plugin", () => {
}),
]
expect(
yield* compaction.compact({
reason: "manual",
context: { ...nearInput.context, messages },
yield* compaction.compactManual({
session,
resolveContext: () => Effect.succeed({ ...nearInput.context, messages, instructionUpdate: "" }),
prepare: modelRequests.compaction,
messages,
inputID: SessionMessage.ID.make("msg_compaction_manual"),
}),
).toEqual({ status: "completed" })
expect(Option.getOrThrow(yield* Fiber.join(ended)).data.recent).toContain("Recent context")
expect(Option.getOrThrow(yield* Fiber.join(started)).data.recent).toContain("Recent context")
yield* config.setEntries([
new Document({
@@ -117,12 +115,12 @@ describe("ConfigCompactionPlugin.Plugin", () => {
yield* bus.publish(Event.Updated, {})
yield* Effect.gen(function* () {
for (let attempt = 0; attempt < 200; attempt++) {
if (yield* due(nearInput)) return
if (compaction.required(nearInput)) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for compaction config reload"))
})
expect(yield* due(bufferedInput)).toBe(false)
expect(compaction.required(bufferedInput)).toBe(false)
yield* config.setEntries([
new Document({
@@ -132,7 +130,7 @@ describe("ConfigCompactionPlugin.Plugin", () => {
])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
if (yield* due(bufferedInput)) return
if (compaction.required(bufferedInput)) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for compaction config reload"))
@@ -1,22 +0,0 @@
[
{
"role": "user",
"content": [
{ "type": "text", "text": "retained attachments" },
{
"type": "media",
"mediaType": "image/png",
"data": "AQID",
"filename": "attachment.png",
"cache": { "type": "ephemeral" },
"metadata": { "attachment": "original" },
"providerMetadata": { "openai": { "detail": "high" } }
},
{ "type": "media", "mediaType": "image/jpeg", "data": "BAUG" }
]
},
{
"role": "assistant",
"content": [{ "type": "compaction", "provider": "openai", "encrypted": "opaque-checkpoint", "id": "cp_legacy" }]
}
]
-8
View File
@@ -291,14 +291,6 @@ describe("MCP OAuth", () => {
await expect(authorize("not a URL")).rejects.toThrow(TypeError)
})
test("rejects an authorization endpoint that is not http or https", async () => {
const { server } = authorizationServer({ authorization_endpoint: "file:///tmp/authorize" })
await expect(Effect.runPromise(Effect.scoped(start(server))).finally(() => server.stop(true))).rejects.toThrow(
"returned a file: authorization URL",
)
})
test("sends the configured URL as the resource when the server publishes no metadata", async () => {
const { server, tokenRequests } = authorizationServer({})
const url = `${server.url.origin}/mcp`
@@ -15,9 +15,7 @@ import {
copilotEntitlementError,
copilotFetch,
GithubCopilotPlugin,
utilityTitle,
} from "@opencode/core/plugin/provider/github-copilot"
import { Message, SystemPart } from "@opencode/ai"
import { Provider } from "@opencode/core/provider"
import { Integration } from "@opencode/core/integration"
import type { SessionRequestKind } from "@opencode/plugin/effect/session"
@@ -239,73 +237,6 @@ describe("GithubCopilotPlugin", () => {
}),
)
const titleRequest = {
sessionID: Session.ID.make("ses_title"),
system: [SystemPart.make("You are a title generator.")],
messages: [Message.user("how do I make my python script faster")],
options: { maxTokens: 32 },
}
const utilityInput = (send: (init?: RequestInit) => Response | Promise<Response>) => ({
baseURL: "https://api.individual.githubcopilot.com",
token: "token",
model: "gpt-4o-mini",
app: App.make({ name: "test", version: "1.2.3", channel: "beta" }),
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => send(init),
})
it.live("names sessions with the utility model as a free background request", () =>
Effect.gen(function* () {
const seen: Array<{ url?: string; init?: RequestInit }> = []
const title = yield* utilityTitle(
{
...utilityInput(() => Response.json({ choices: [{ message: { content: " Speed Up Python Script\n" } }] })),
fetch: async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
seen.push({ url: String(input), init })
return Response.json({ choices: [{ message: { content: " Speed Up Python Script\n" } }] })
},
},
titleRequest,
)
expect(title).toBe("Speed Up Python Script")
expect(seen[0]?.url).toBe("https://api.individual.githubcopilot.com/chat/completions")
const headers = new Headers(seen[0]?.init?.headers)
expect(headers.get("authorization")).toBe("Bearer token")
expect(headers.get("x-interaction-type")).toBe("agent-session-name-generation")
expect(headers.get("x-interaction-id")).toBe("ses_title")
expect(headers.get("x-initiator")).toBe("agent")
expect(headers.get("x-github-api-version")).toBe("2026-08-01")
expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({
model: "gpt-4o-mini",
stream: false,
max_tokens: 32,
messages: [
{ role: "system", content: "You are a title generator." },
{ role: "user", content: "how do I make my python script faster" },
],
})
}),
)
it.live("fails the utility title on rate limits so the billable path can run", () =>
Effect.gen(function* () {
const error = yield* utilityTitle(
utilityInput(() => new Response("slow down", { status: 429 })),
titleRequest,
).pipe(Effect.flip)
expect(String(error)).toContain("429")
}),
)
it.live("fails the utility title on an empty completion", () =>
Effect.gen(function* () {
const error = yield* utilityTitle(
utilityInput(() => Response.json({ choices: [{ message: { content: null } }] })),
titleRequest,
).pipe(Effect.flip)
expect(String(error)).toContain("empty")
}),
)
it.live("keeps a declared agent initiator when the body looks user-initiated", () =>
Effect.gen(function* () {
const requests: Headers[] = []
@@ -216,31 +216,6 @@ describe("OpenAIPlugin", () => {
}),
)
it.effect("omits the default output limit from OpenAI steps and compaction", () =>
Effect.gen(function* () {
yield* addPlugin()
const hooks = yield* PluginHooks.Service
const maxTokens = (providerID: Provider.ID) =>
Effect.gen(function* () {
const draft = {
sessionID: Session.ID.make("ses_test"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
system: [],
messages: [],
options: { maxTokens: 128_000 },
}
const events = [
yield* hooks.trigger("session", "context", { ...draft, agent: Agent.ID.make("build"), tools: {} }),
yield* hooks.trigger("session", "compaction", { ...draft, agent: Agent.ID.make("build"), tools: {} }),
]
return events.map((event) => event.options.maxTokens)
})
expect(yield* maxTokens(Provider.ID.openai)).toEqual([undefined, undefined])
expect(yield* maxTokens(Provider.ID.azure)).toEqual([128_000, 128_000])
}),
)
it.effect("selects WebSocket only from explicit policy", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
+155 -166
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { GenerationOptions, LLMClient, LLMEvent, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode/ai"
import { LLMClient, LLMEvent, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
@@ -8,7 +8,6 @@ import { LayerNode } from "@opencode/util/effect/layer-node"
import { Bus } from "@opencode/core/bus"
import { EventTable } from "@opencode/core/event/sql"
import { SessionCompaction } from "@opencode/core/session/compaction"
import type { SessionContext } from "@opencode/core/session/context"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionMessage } from "@opencode/core/session/message"
import { SessionModelRequest } from "@opencode/core/session/model-request"
@@ -22,7 +21,9 @@ import { Project } from "@opencode/core/project"
import { ProjectTable } from "@opencode/core/project/sql"
import { App } from "@opencode/core/app"
import { Agent } from "@opencode/core/agent"
import { Model } from "@opencode/core/model"
import { Provider } from "@opencode/core/provider"
import { Location } from "@opencode/core/location"
import { AbsolutePath } from "@opencode/core/schema"
import { Money } from "@opencode/schema/money"
import { Skill } from "@opencode/schema/skill"
@@ -101,38 +102,28 @@ test("compaction prompt preserves detailed work state and relevant files", () =>
expect(prompt).toContain("## Relevant Files")
})
it.effect("compaction describes tool media without embedding base64", () =>
Effect.gen(function* () {
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const recent = yield* recentWithToolOutput(Session.ID.make("ses_tool_media"), [
{ type: "text", text: "Image read successfully" },
{
type: "file",
uri: `data:image/png;base64,${base64}`,
mime: "image/png",
name: "pixel.png",
},
])
test("compaction describes tool media without embedding base64", () => {
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const serialized = SessionCompaction.serializeToolContent([
{ type: "text", text: "Image read successfully" },
{
type: "file",
uri: `data:image/png;base64,${base64}`,
mime: "image/png",
name: "pixel.png",
},
])
expect(recent).toContain("[Tool result]: Image read successfully\n[Attached image/png: pixel.png]")
expect(recent).not.toContain(base64)
}),
)
expect(serialized).toBe("Image read successfully\n[Attached image/png: pixel.png]")
expect(serialized).not.toContain(base64)
})
it.effect("compaction truncation does not split surrogate pairs", () =>
Effect.gen(function* () {
const prefix = "a".repeat(1_249)
const split = yield* recentWithToolOutput(Session.ID.make("ses_truncate_split"), [
{ type: "text", text: `${prefix}😀suffix` },
])
const whole = yield* recentWithToolOutput(Session.ID.make("ses_truncate_whole"), [
{ type: "text", text: "😀".repeat(1_250) },
])
test("compaction truncation does not split surrogate pairs", () => {
const prefix = "a".repeat(1_999)
expect(split).toEndWith(`[Tool result]: ${prefix}😀\n[truncated]`)
expect(whole).toEndWith(`[Tool result]: ${"😀".repeat(1_250)}`)
}),
)
expect(SessionCompaction.truncateToolOutput(`${prefix}😀suffix`)).toBe(`${prefix}😀\n[truncated]`)
expect(SessionCompaction.truncateToolOutput("😀".repeat(2_000))).toBe("😀".repeat(2_000))
})
test("compaction prompt requires the checkpoint headings in order", () => {
const prompt = SessionCompaction.buildPrompt(false)
@@ -165,63 +156,74 @@ test("compaction prompts prohibit task execution", () => {
it.effect("auto compaction estimates current content against the buffered prompt ceiling", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const session = yield* insertSession(Session.ID.make("ses_input_limit"))
const input = (tokens: number, limit: { context: number; input?: number; output: number }) => ({
session,
model: SessionRunnerModel.resolved(model, {
const session = Session.Info.make({
id: Session.ID.make("ses_input_limit"),
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
})
const input = (tokens: number, limit: { context: number; input?: number; output: number }) => {
const resolved = SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image", "pdf"], output: ["text"] },
cost: [],
limit,
}),
messages: [
})
const messages = [
Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.make("msg_assistant"),
type: "assistant",
agent: Agent.defaultID,
model: { id: "summary-model", providerID: "test" },
model: { id: "test-model", providerID: "test-provider" },
content: [{ type: "text", text: "Done" }],
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, completed: 0 },
}),
],
agent: {
id: Agent.defaultID,
info: { ...Agent.Info.default(Agent.defaultID), system: "You are a helpful assistant." },
},
initial: "Project instructions.",
tools: {
definitions: [
ToolDefinition.make({ name: "read", description: "Read files", inputSchema: { type: "object" } }),
],
execute: () => Effect.die("unused"),
},
})
// An automatic compaction that is not due is skipped.
const due = (context: SessionContext.Loaded) =>
compaction.compact({ reason: "auto", context }).pipe(Effect.map((outcome) => outcome.status !== "skipped"))
]
return {
session,
resolved,
messages,
context: {
session,
model: resolved,
messages,
agent: {
id: Agent.defaultID,
info: { ...Agent.Info.default(Agent.defaultID), system: "You are a helpful assistant." },
},
initial: "Project instructions.",
tools: {
definitions: [
ToolDefinition.make({ name: "read", description: "Read files", inputSchema: { type: "object" } }),
],
execute: () => Effect.die("unused"),
},
},
}
}
// 90% of the input limit, which takes precedence over the context window.
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
expect(yield* due(input(244_799, inputLimited))).toBe(false)
expect(yield* due(input(244_800, inputLimited))).toBe(true)
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
const native = (tokens: number, limit: { context: number; input?: number; output: number } = inputLimited) => {
const selected = input(tokens, limit)
return { ...selected, model: { ...selected.model, compaction: { type: "native" as const } } }
return { ...selected, resolved: { ...selected.resolved, compaction: { type: "native" as const } } }
}
expect(yield* due(native(244_799))).toBe(false)
expect(yield* due(native(244_800))).toBe(true)
expect(yield* due(native(1_000_000, { context: 0, input: undefined, output: 0 }))).toBe(false)
expect(compaction.required(native(251_999))).toBe(false)
expect(compaction.required(native(252_000))).toBe(true)
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }))).toBe(false)
const contextLimited = { context: 100_000, output: 10_000 }
expect(yield* due(input(89_999, contextLimited))).toBe(false)
expect(yield* due(input(90_000, contextLimited))).toBe(true)
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
expect(compaction.required(input(80_000, contextLimited))).toBe(true)
// The reply limit does not lower the ceiling.
const outputLimited = { context: 100_000, output: 30_000 }
expect(yield* due(input(89_999, outputLimited))).toBe(false)
expect(yield* due(input(90_000, outputLimited))).toBe(true)
expect(compaction.required(input(69_999, outputLimited))).toBe(false)
expect(compaction.required(input(70_000, outputLimited))).toBe(true)
const assistant = input(89_000, contextLimited).messages[0]
const assistant = input(79_000, contextLimited).messages[0]
const tool = SessionMessage.AssistantTool.make({
type: "tool",
id: "call_read",
@@ -229,19 +231,16 @@ it.effect("auto compaction estimates current content against the buffered prompt
state: { status: "completed", input: {}, content: [{ type: "text", text: "x".repeat(4_000) }] },
time: { created: DateTime.makeUnsafe(0) },
})
const grown = { ...input(89_000, contextLimited), messages: [{ ...assistant, content: [tool] }] }
expect(SessionCompaction.estimateContext(grown)).toBe(90_000)
expect(yield* due(grown)).toBe(true)
const grown = { ...input(79_000, contextLimited), messages: [{ ...assistant, content: [tool] }] }
expect(SessionCompaction.estimateTokens(grown)).toBe(80_000)
expect(compaction.required(grown)).toBe(true)
const interrupted = { ...assistant, id: SessionMessage.ID.create(), tokens: undefined }
expect(SessionCompaction.estimateContext({ ...grown, messages: [...grown.messages, interrupted] })).toBe(90_001)
expect(SessionCompaction.estimateTokens({ ...grown, messages: [...grown.messages, interrupted] })).toBe(80_001)
// Without provider usage, include 20 tokens for the system prompt, instructions, and tool definition.
expect(SessionCompaction.estimateContext({ ...grown, messages: [interrupted] })).toBe(21)
// Another provider's usage is not trusted either.
const foreign = { ...assistant, model: { ...assistant.model, providerID: Provider.ID.make("other") } }
expect(SessionCompaction.estimateContext({ ...grown, messages: [foreign] })).toBe(21)
expect(SessionCompaction.estimateTokens({ ...grown, messages: [interrupted] })).toBe(21)
expect(
SessionCompaction.estimateContext({
SessionCompaction.estimateTokens({
...grown,
messages: [{ ...interrupted, tokens: input(0, contextLimited).messages[0].tokens }],
}),
@@ -254,7 +253,7 @@ it.effect("auto compaction estimates current content against the buffered prompt
const messages = [
{ ...assistant, content: [{ ...tool, state: { status: "completed" as const, input: {}, content: media } }] },
]
expect(SessionCompaction.estimateContext({ ...grown, messages })).toBe(92_500)
expect(SessionCompaction.estimateTokens({ ...grown, messages })).toBe(82_500)
const user = Schema.decodeUnknownSync(SessionMessage.User)({
id: SessionMessage.ID.create(),
type: "user",
@@ -262,18 +261,18 @@ it.effect("auto compaction estimates current content against the buffered prompt
files: media.map((file) => ({ mime: file.mime, data: "a".repeat(100_000), source: { type: "inline" } })),
time: { created: 0 },
})
expect(SessionCompaction.estimateContext({ ...grown, messages: [...messages, user] })).toBe(96_000)
expect(SessionCompaction.estimateTokens({ ...grown, messages: [...messages, user] })).toBe(86_000)
for (const [modalities, tokens, fallback] of [
[["text", "image"], 92_040, 1_520],
[["text", "pdf"], 93_042, 2_021],
[["text"], 89_082, 41],
[["text", "image"], 82_040, 1_520],
[["text", "pdf"], 83_042, 2_021],
[["text"], 79_082, 41],
] as const) {
const selected = {
...grown,
model: { ...grown.model, capabilities: { ...grown.model.capabilities, input: modalities } },
resolved: { ...grown.resolved, capabilities: { ...grown.resolved.capabilities, input: modalities } },
}
expect(SessionCompaction.estimateContext({ ...selected, messages: [...messages, user] })).toBe(tokens)
expect(SessionCompaction.estimateContext({ ...selected, messages: [user] })).toBe(fallback + 20)
expect(SessionCompaction.estimateTokens({ ...selected, messages: [...messages, user] })).toBe(tokens)
expect(SessionCompaction.estimateTokens({ ...selected, messages: [user] })).toBe(fallback + 20)
}
const checkpoint = Schema.decodeUnknownSync(SessionMessage.CompactionCompleted)({
@@ -285,7 +284,7 @@ it.effect("auto compaction estimates current content against the buffered prompt
recent: "",
time: { created: 0, completed: 0 },
})
expect(yield* due({ ...grown, messages: [checkpoint] })).toBe(false)
expect(compaction.required({ ...grown, messages: [checkpoint] })).toBe(false)
}),
)
@@ -324,66 +323,15 @@ const loaded = (session: Session.Info, messages: readonly SessionMessage.Info[])
model: resolved,
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
initial: "Session instructions",
instructionUpdate: "",
tools: { definitions: [], execute: () => Effect.die("Compaction must not execute tools") },
})
/** Opens the compaction's message as the runner does when it delivers `/compact`, then compacts. */
const compactManually = (
session: Session.Info,
messages: readonly SessionMessage.Info[],
inputID = SessionMessage.ID.create(),
) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const compaction = yield* SessionCompaction.Service
yield* bus.publish(SessionEvent.Compaction.Started, {
sessionID: session.id,
reason: "manual",
recent: "",
inputID,
})
return yield* compaction.compact({ reason: "manual", context: loaded(session, messages), inputID })
})
/** The recent text a manual compaction keeps when the latest exchange is one tool call with this output. */
const recentWithToolOutput = (id: Session.ID, content: SessionMessage.ToolStateCompleted["content"]) =>
Effect.gen(function* () {
const session = yield* insertSession(id)
const user = (text: string) =>
SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text,
time: { created: DateTime.makeUnsafe(0) },
})
const assistant = Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.create(),
type: "assistant",
agent: Agent.defaultID,
model: { id: "summary-model", providerID: "test" },
content: [
{
type: "tool",
id: "call_read",
name: "read",
state: { status: "completed", input: {}, content },
time: { created: 0 },
},
],
time: { created: 0, completed: 0 },
})
expect(yield* compactManually(session, [user("Earlier question"), user("Read it"), assistant])).toEqual({
status: "completed",
})
const store = yield* SessionStore.Service
const stored = (yield* store.context(id))[0]
return stored?.type === "compaction" && stored.status === "completed" ? stored.recent : ""
})
it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () {
requests = []
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_manual_compaction")
@@ -402,6 +350,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
time: { created: DateTime.makeUnsafe(0) },
}
const session = yield* insertSession(sessionID, { parent_id: parentID })
const modelRequests = yield* SessionModelRequest.Service
const hooks = yield* PluginHooks.Service
let hooked = 0
yield* hooks.register("session", "compaction", (event) =>
@@ -434,9 +383,15 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
.subscribe(SessionEvent.Compaction.Delta)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
expect(yield* compactManually(session, messages, SessionMessage.ID.make("msg_manual_compaction"))).toEqual({
status: "completed",
})
expect(
yield* compaction.compactManual({
session,
resolveContext: () => Effect.succeed(loaded(session, messages)),
prepare: modelRequests.compaction,
messages,
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
).toEqual({ status: "completed" })
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual([
"## Objective\n- manual summary",
])
@@ -452,7 +407,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
"x-opencode-session": sessionID,
"x-opencode-client": "opencode",
})
expect(requests[0]?.generation).toEqual(GenerationOptions.make({ maxTokens: 32_000 }))
expect(requests[0]?.generation).toBeUndefined()
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
expect(JSON.stringify(requests[0]?.messages)).toContain("User shell pwd completed: /project")
@@ -494,10 +449,12 @@ it.effect("compaction hooks can supply the summary instead of the model", () =>
Effect.gen(function* () {
requests = []
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const hooks = yield* PluginHooks.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_hooked_compaction")
const session = yield* insertSession(sessionID)
const modelRequests = yield* SessionModelRequest.Service
const messages = [
{
id: SessionMessage.ID.create(),
@@ -517,9 +474,15 @@ it.effect("compaction hooks can supply the summary instead of the model", () =>
}),
)
expect(yield* compactManually(session, messages, SessionMessage.ID.make("msg_hooked_compaction"))).toEqual({
status: "completed",
})
expect(
yield* compaction.compactManual({
session,
resolveContext: () => Effect.succeed(loaded(session, messages)),
prepare: modelRequests.compaction,
messages,
inputID: SessionMessage.ID.make("msg_hooked_compaction"),
}),
).toEqual({ status: "completed" })
expect(contexts).toBe(0)
expect(requests).toEqual([])
@@ -541,45 +504,65 @@ it.effect("compaction hooks can supply the summary instead of the model", () =>
}),
)
it.effect("native compaction fails without a model call on a route that cannot compact", () =>
it.effect("manual compaction records model resolution failures without calling the model", () =>
Effect.gen(function* () {
requests = []
const compaction = yield* SessionCompaction.Service
const session = yield* insertSession(Session.ID.make("ses_native_unsupported"))
const messages = [
SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Compact this natively.",
time: { created: DateTime.makeUnsafe(0) },
}),
]
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_manual_resolution_failure")
const session = yield* insertSession(sessionID)
const modelRequests = yield* SessionModelRequest.Service
const inputID = SessionMessage.ID.make("msg_manual_resolution_failure")
expect(
yield* compaction.compact({
reason: "manual",
context: { ...loaded(session, messages), model: { ...resolved, compaction: { type: "native" } } },
inputID: SessionMessage.ID.create(),
yield* compaction.compactManual({
session,
resolveContext: () =>
Effect.fail(
new SessionRunnerModel.ModelUnavailableError({
providerID: Provider.ID.make("test"),
modelID: Model.ID.make("missing"),
}),
),
prepare: modelRequests.compaction,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize this conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
],
inputID,
}),
).toEqual({
status: "failed",
error: {
type: "provider.unsupported-operation",
message: "Native compaction is not supported for test/openai-chat",
},
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
})
expect(requests).toHaveLength(0)
expect(yield* store.context(sessionID)).toMatchObject([
{
id: inputID,
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
},
])
}),
)
it.effect("forked session compaction reuses the fork root prompt cache key", () =>
Effect.gen(function* () {
requests = []
const compaction = yield* SessionCompaction.Service
const sessionID = Session.ID.make("ses_fork_compaction")
const rootID = Session.ID.make("ses_fork_compaction_root")
const session = yield* insertSession(sessionID, {
fork_session_id: rootID,
fork_boundary: { type: "before", messageID: SessionMessage.ID.create() },
})
const modelRequests = yield* SessionModelRequest.Service
const messages = [
SessionMessage.User.make({
id: SessionMessage.ID.create(),
@@ -588,9 +571,15 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
time: { created: DateTime.makeUnsafe(0) },
}),
]
expect(yield* compactManually(session, messages, SessionMessage.ID.make("msg_fork_compaction"))).toEqual({
status: "completed",
})
expect(
yield* compaction.compactManual({
session,
resolveContext: () => Effect.succeed(loaded(session, messages)),
prepare: modelRequests.compaction,
messages,
inputID: SessionMessage.ID.make("msg_fork_compaction"),
}),
).toEqual({ status: "completed" })
expect(requests).toHaveLength(1)
expect(requests[0]?.promptCacheKey).toBe(rootID)
@@ -154,61 +154,3 @@ describe("SessionModelRequest HTTP hooks", () => {
}),
)
})
describe("SessionModelRequest output limit", () => {
const input = { session, agent: Agent.ID.make("build"), model, system: [], messages: [] }
it.effect("caps the default output limit per request kind", () =>
Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
const large = {
...input,
model: SessionRunnerModel.resolved(OpenAIChat.route.model({ id: "large-output", provider: "test" }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 1_000_000, output: 384_000 },
}),
}
const maxTokens = (prepared: SessionModelRequest.Prepared<unknown>) => prepared.request.generation?.maxTokens
expect(maxTokens(yield* requests.primary(large))).toBe(256_000)
expect(maxTokens(yield* requests.compaction(large))).toBe(32_000)
const inputTokens = { measured: 170_000, estimated: 8_000 }
expect(maxTokens(yield* requests.primary({ ...input, inputTokens }))).toBe(20_800)
expect(maxTokens(yield* requests.compaction({ ...input, inputTokens }))).toBe(20_800)
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
)
// Provider plugins that remove the default limit only hook `context` and `compaction`. If titles or generate get a
// default, also hook `title` and `generate` in: the OpenAI plugin (`omitOutputLimit`), whose ChatGPT backend
// rejects any requested limit.
it.effect("sends no output limit for titles and generate by default", () =>
Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
expect((yield* requests.title(input)).request.generation?.maxTokens).toBeUndefined()
expect((yield* requests.generate(input)).request.generation?.maxTokens).toBeUndefined()
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
)
it.effect("lets hooks change or remove the default output limit", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const seen: Array<number | undefined> = []
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
seen.push(event.options.maxTokens)
delete event.options.maxTokens
}),
)
yield* hooks.register("session", "title", (event) =>
Effect.sync(() => {
event.options.maxTokens = 100
}),
)
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
expect((yield* requests.primary(input)).request.generation).toBeUndefined()
expect((yield* requests.title(input)).request.generation?.maxTokens).toBe(100)
expect(seen).toEqual([32_000])
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
)
})
@@ -1,40 +1,9 @@
import { describe, expect, test } from "bun:test"
import { Message, ToolResultPart, Media } from "@opencode/ai"
import { boundImages, outputLimit, unsupportedParts } from "@opencode/core/session/model-request"
import { boundImages, unsupportedParts } from "@opencode/core/session/model-request"
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
describe("SessionModelRequest.outputLimit", () => {
test("requests the catalog output limit up to the cap", () => {
expect(outputLimit({ context: 1_000_000, output: 128_000 }, 256_000)).toBe(128_000)
expect(outputLimit({ context: 200_000, output: 64_000 }, 256_000)).toBe(64_000)
expect(outputLimit({ context: 1_048_576, output: 1_048_576 }, 256_000)).toBe(256_000)
expect(outputLimit({ context: 200_000, output: 64_000 }, 32_000)).toBe(32_000)
})
test("falls back to 32k when the catalog has no output limit", () => {
expect(outputLimit({ context: 200_000, output: 0 }, 256_000)).toBe(32_000)
})
test("fits the limit to the room the prompt leaves in the context window", () => {
const limit = { context: 1_000_000, output: 128_000 }
expect(outputLimit(limit, 256_000, { measured: 50_000, estimated: 0 })).toBe(128_000)
expect(outputLimit(limit, 256_000, { measured: 900_000, estimated: 0 })).toBe(100_000)
// Estimated text counts 15% extra, so 40k estimated takes 46k of the room.
expect(outputLimit(limit, 256_000, { measured: 900_000, estimated: 40_000 })).toBe(54_000)
})
test("keeps a minimum limit when the prompt nearly fills the context window", () => {
const prompt = { measured: 199_000, estimated: 0 }
expect(outputLimit({ context: 200_000, output: 64_000 }, 256_000, prompt)).toBe(1_024)
expect(outputLimit({ context: 200_000, output: 512 }, 256_000, prompt)).toBe(512)
})
test("ignores the prompt size when the context window is unknown", () => {
expect(outputLimit({ context: 0, output: 32_000 }, 256_000, { measured: 500_000, estimated: 0 })).toBe(32_000)
})
})
describe("SessionModelRequest.unsupportedParts", () => {
test("replaces unsupported user media with a visible error", () => {
const messages = unsupportedParts(
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { LLMClient, LanguageModel, Message, ToolDefinition } from "@opencode/ai"
import { LLMClient, LanguageModel, Message, ToolDefinition, Usage } from "@opencode/ai"
import { OpenAI } from "@opencode/ai/providers"
import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
@@ -7,6 +7,7 @@ import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { llmClient } from "@opencode/core/effect/app-node-platform"
import { Instructions } from "@opencode/core/instructions/index"
import { NativeCompactionPlugin } from "@opencode/core/plugin/compaction"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { Project } from "@opencode/core/project"
import { ProjectTable } from "@opencode/core/project/sql"
@@ -26,6 +27,7 @@ import { SessionStore } from "@opencode/core/session/store"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
import { testEffect } from "./lib/effect"
import { host } from "./plugin/host"
const it = testEffect(
AppNodeBuilder.build(
@@ -44,7 +46,7 @@ const it = testEffect(
),
)
const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean } = {}) {
const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin?: boolean } = {}) {
const endpoint = options.endpoint ?? false
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
@@ -55,7 +57,7 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean } = {})
const hooks = yield* PluginHooks.Service
const blocked = Deferred.makeUnsafe<void>()
const hanging = Promise.withResolvers<Response>()
const state = { failure: false, flaky: false, hang: false, overflow: false, calls: 0 }
const state = { failure: false, flaky: false, hang: false, overflow: false, localFailure: false, calls: 0 }
const bodies: Record<string, unknown>[] = []
const headers: Headers[] = []
const server = yield* Effect.acquireRelease(
@@ -85,7 +87,7 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean } = {})
)
}
const trigger = JSON.stringify(bodies.at(-1)).includes("compaction_trigger")
if (state.overflow && trigger)
if (state.overflow && (trigger || state.localFailure))
return Response.json(
{
error: {
@@ -112,8 +114,26 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean } = {})
usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 },
})
const output = trigger ? [checkpoint] : []
const summary = state.overflow
? [
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "summary", role: "assistant", content: [] },
},
{
type: "response.output_text.delta",
item_id: "summary",
output_index: 0,
content_index: 0,
delta: "## Objective\n- Recovered locally",
},
]
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
.join("")
: ""
return new Response(
`data: ${JSON.stringify({
`${summary}data: ${JSON.stringify({
type: "response.completed",
response: {
id: `resp_${state.calls}`,
@@ -168,6 +188,7 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean } = {})
render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" },
})
yield* InstructionState.prepare(db, bus, instructions, sessionID)
if (options.plugin !== false) yield* NativeCompactionPlugin.Plugin.effect(host())
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
event.headers["x-test-hook"] = event.kind
@@ -197,6 +218,7 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean } = {})
model,
initial: history.initial,
messages: history.messages,
instructionUpdate: history.instructionUpdate,
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
tools: {
definitions: [
@@ -206,11 +228,14 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean } = {})
},
}
})
// Opens the compaction's message as the runner does when it delivers `/compact`.
const compact = Effect.gen(function* () {
const inputID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "", inputID })
return yield* compaction.compact({ reason: "manual", context: yield* load, inputID })
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
inputID: SessionMessage.ID.create(),
resolveContext: () => load,
prepare: requests.compaction,
})
})
const checkpoint = Effect.gen(function* () {
const messages = (yield* load).messages
@@ -225,9 +250,8 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean } = {})
})
return {
compact,
// An automatic compaction that skips the "is it due" check, which a context this small never passes.
overflow: Effect.gen(function* () {
return yield* compaction.compact({ reason: "overflow", context: yield* load })
automatic: Effect.gen(function* () {
return yield* compaction.compact({ context: yield* load, prepare: requests.compaction })
}),
checkpoint,
prompt,
@@ -332,8 +356,7 @@ it.live("manual and automatic endpoint compaction keep the provider replacement
const fixture = yield* setup({ endpoint: true })
yield* fixture.prompt("Original user")
expect(yield* fixture.compact).toEqual({ status: "completed" })
yield* fixture.prompt("Later user")
expect(yield* fixture.overflow).toEqual({ status: "completed" })
expect(yield* fixture.automatic).toEqual({ status: "completed" })
const replacement = SessionProviderContext.decode(yield* fixture.checkpoint)
expect(replacement[0]?.content).toEqual([Message.text("endpoint retained")])
expect(JSON.stringify(replacement)).not.toContain("Original user")
@@ -344,7 +367,7 @@ it.live("manual and automatic endpoint compaction keep the provider replacement
}),
)
it.live("automatic native failures, interruptions, and overflows retain the checkpoint", () =>
it.live("only known automatic native overflow falls back locally and failed recovery retains the checkpoint", () =>
Effect.gen(function* () {
const fixture = yield* setup()
yield* fixture.prompt("Original durable request")
@@ -352,12 +375,12 @@ it.live("automatic native failures, interruptions, and overflows retain the chec
const installed = yield* fixture.checkpoint
yield* fixture.prompt("Recent request")
fixture.state.failure = true
expect(yield* fixture.overflow).toMatchObject({ status: "failed", error: { type: "provider.rate-limit" } })
expect(yield* fixture.automatic).toMatchObject({ status: "failed", error: { type: "provider.rate-limit" } })
expect(fixture.state.calls).toBe(2)
expect(yield* fixture.checkpoint).toEqual(installed)
fixture.state.failure = false
fixture.state.hang = true
const pending = yield* fixture.overflow.pipe(Effect.forkScoped)
const pending = yield* fixture.automatic.pipe(Effect.forkScoped)
yield* Deferred.await(fixture.blocked)
yield* Fiber.interrupt(pending)
expect((yield* fixture.load).messages.at(-1)).toMatchObject({
@@ -367,12 +390,19 @@ it.live("automatic native failures, interruptions, and overflows retain the chec
})
expect(yield* fixture.checkpoint).toEqual(installed)
fixture.state.hang = false
// Overflow retries natively, with the provider's window, and gives up once nothing is left to shrink.
fixture.state.overflow = true
expect(yield* fixture.overflow).toMatchObject({ status: "failed", error: { type: "compaction.failed" } })
expect(fixture.state.calls).toBe(4)
expect(JSON.stringify(fixture.bodies[3])).toContain("encrypted_1")
fixture.state.localFailure = true
expect(yield* fixture.automatic).toMatchObject({ status: "failed" })
expect(fixture.state.calls).toBe(5)
expect(yield* fixture.checkpoint).toEqual(installed)
expect(JSON.stringify(fixture.bodies[4])).toContain("Original durable request")
expect(JSON.stringify(fixture.bodies[4])).not.toContain("encrypted_1")
fixture.state.localFailure = false
expect(yield* fixture.automatic).toEqual({ status: "completed", recoveredOverflow: true })
expect(fixture.state.calls).toBe(7)
expect((yield* fixture.load).messages).toContainEqual(
expect.objectContaining({ type: "compaction", summary: "## Objective\n- Recovered locally" }),
)
}),
)
@@ -421,6 +451,31 @@ it.live("rejects request-hook route rewrites before provider compaction", () =>
}),
)
it.live("provider compaction fails without a native strategy and persists a registered strategy's window", () =>
Effect.gen(function* () {
const fixture = yield* setup({ plugin: false })
yield* fixture.prompt("Original user")
expect(yield* fixture.compact).toMatchObject({
status: "failed",
error: { type: "provider.unsupported-operation", message: expect.stringContaining("openai/openai-responses") },
})
yield* fixture.compaction.transform((editor) => {
editor.native(() =>
Effect.succeed({
replacement: [Message.assistant("plugin window")],
usage: new Usage({ nonCachedInputTokens: 20, outputTokens: 4 }),
}),
)
})
expect(yield* fixture.compact).toEqual({ status: "completed" })
expect(fixture.state.calls).toBe(0)
const installed = yield* fixture.checkpoint
expect(installed.provenance).toEqual(SessionProviderContext.provenance(fixture.model)!)
expect(SessionProviderContext.decode(installed)).toEqual([Message.assistant("plugin window")])
expect(yield* fixture.store.get(fixture.sessionID)).toMatchObject({ tokens: { input: 20, output: 4 } })
}),
)
test("retained user budget counts attachments and drops whole oldest messages", () => {
const model = SessionRunnerModel.resolved(OpenAI.responses("gpt-5.4-mini"), {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
@@ -438,8 +493,8 @@ test("retained user budget counts attachments and drops whole oldest messages",
...user("x".repeat(63_000 * 4)),
files: [{ mime: "image/png", data: "aGVsbG8=", source: { type: "inline" as const } }],
}
expect(SessionCompaction.recentUserMessages([user("old"), newest], model, 64_000)).toEqual([])
expect(SessionCompaction.retainUsers([user("old"), newest], model, 64_000)).toEqual([])
expect(
SessionCompaction.recentUserMessages([user("x".repeat(63_000 * 4)), { ...newest, text: "new" }], model, 64_000),
SessionCompaction.retainUsers([user("x".repeat(63_000 * 4)), { ...newest, text: "new" }], model, 64_000),
).toHaveLength(1)
})
@@ -26,7 +26,6 @@ import { Model } from "@opencode/schema/model"
import { asc, eq } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { testEffect } from "./lib/effect"
import legacyMedia from "./fixture/provider-context/2.0.14-media.json"
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "deployment", provider: "openai", route: OpenAIResponses.route }),
@@ -47,51 +46,6 @@ const replacement = [
const providerContext = SessionProviderContext.encode(target, replacement)
const sessionID = SessionSchema.ID.make("ses_provider_context")
// Frozen serialized data, deliberately not constructed using today's Message API.
const legacyContext = Schema.decodeUnknownSync(SessionProviderContext.Info)({
...providerContext,
messages: legacyMedia,
})
test("reads pre-2.0.15 checkpoint media without rewriting the stored payload", async () => {
const before = JSON.stringify(legacyContext)
const decoded = SessionProviderContext.decode(legacyContext)
expect(await Effect.runPromise(SessionProviderContext.validate(legacyContext))).toEqual(decoded)
expect(decoded[0]?.content).toMatchObject([
{ type: "text", text: "retained attachments" },
{
type: "media",
media: { source: { type: "base64", data: "AQID", mediaType: "image/png" } },
filename: "attachment.png",
cache: { type: "ephemeral" },
metadata: { attachment: "original" },
providerMetadata: { openai: { detail: "high" } },
},
{ type: "media", media: { source: { type: "base64", data: "BAUG", mediaType: "image/jpeg" } } },
])
expect(decoded[1]?.content).toMatchObject(legacyMedia[1]?.content ?? [])
expect(JSON.stringify(legacyContext)).toBe(before)
})
test("reads mixed legacy and current media without overriding an existing asset", async () => {
const current = {
type: "media",
media: { source: { type: "base64", data: "BAUG", mediaType: "image/jpeg" } },
mediaType: "image/png",
data: "AQID",
}
const context = Schema.decodeUnknownSync(SessionProviderContext.Info)({
...providerContext,
messages: [{ role: "user", content: [...(legacyMedia[0]?.content ?? []), current] }],
})
const decoded = SessionProviderContext.decode(context)
expect(await Effect.runPromise(SessionProviderContext.validate(context))).toEqual(decoded)
expect(decoded[0]?.content.at(-1)).toMatchObject({ media: current.media })
const invalid = { ...context, messages: [{ role: "user", content: [{ ...current, media: null }] }] }
expect(() => SessionProviderContext.decode(invalid)).toThrow()
expect(await Effect.runPromise(Effect.isFailure(SessionProviderContext.validate(invalid)))).toBe(true)
})
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.node]),
@@ -141,26 +95,6 @@ const setup = Effect.gen(function* () {
return { db: database.db, bus, state, instructions, prepare, prompt, compact, load }
})
it.effect("loads and replays a persisted pre-2.0.15 media checkpoint", () =>
Effect.gen(function* () {
const s = yield* setup
yield* s.prepare
yield* s.prompt("original request")
yield* s.bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" })
yield* s.compact(legacyContext)
yield* s.prompt("continue")
const history = yield* SessionHistory.load(s.db, sessionID, target)
expect(toLLMMessages(history, model.ref).slice(0, 2)).toEqual([...SessionProviderContext.decode(legacyContext)])
expect(history.some(SessionProviderContext.isCheckpoint)).toBe(true)
const local = yield* SessionHistory.load(s.db, sessionID, "local")
expect(local.some(SessionProviderContext.isCheckpoint)).toBe(false)
expect(toLLMMessages(local, model.ref).map((message) => message.content)).toEqual([
[Message.text("original request")],
[Message.text("continue")],
])
}),
)
test("canonical provider context round-trips tools, opaque checkpoints and binary media through JSON", () => {
const messages = [
...replacement,
@@ -202,36 +202,6 @@ Recent work
])
})
test("leaves out the recent context of a checkpoint that kept none", () => {
const [checkpoint] = toLLMMessages(
[
SessionMessage.Compaction.make({
id: id("compaction"),
type: "compaction",
status: "completed",
reason: "auto",
summary: "Earlier work",
recent: "",
time: { created },
}),
],
model,
)
expect(checkpoint?.content).toEqual([
{
type: "text",
text: `<conversation-checkpoint>
The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.
<summary>
Earlier work
</summary>
</conversation-checkpoint>`,
},
])
})
describe("model-switched", () => {
const ref = (variant?: string) =>
Model.Ref.make({
+35 -268
View File
@@ -56,6 +56,7 @@ import { Plugin } from "@opencode/core/plugin"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { OptimizePlugin } from "@opencode/core/plugin/optimize"
import { IdentityPlugin } from "@opencode/core/plugin/identity"
import { NativeCompactionPlugin } from "@opencode/core/plugin/compaction"
import { QuestionTool } from "@opencode/core/tool/plugin/question"
import { Agent } from "@opencode/core/agent"
import { Config } from "@opencode/core/config"
@@ -126,7 +127,6 @@ const fullOutputModel = testModel("full-output", { context: 262_144, output: 262
const unknownContextModel = testModel("unknown-context", { context: 0, output: 32_000 })
const undersizedContextModel = testModel("undersized-context", { context: 1, output: 1_000 })
const recoveryModel = testModel("recovery", { context: 200_000, output: 1_000 })
const fittedOutputModel = testModel("fitted-output", { context: 100_000, output: 64_000 })
test("calculates step cost using the matching context tier", () => {
expect(
@@ -197,8 +197,6 @@ test("does not apply an ineligible tier without base pricing", () => {
).toBe(Money.USD.zero)
})
const resolvesModel: Effect.Effect<void, SessionRunnerModel.Error> = Effect.void
const makeRunnerState = (compaction?: SessionRunnerModel.Resolved["compaction"]) => {
let toolBarrier: ToolBarrier | undefined
const releaseTools = (barrier: ToolBarrier) =>
@@ -208,7 +206,7 @@ const makeRunnerState = (compaction?: SessionRunnerModel.Resolved["compaction"])
return {
currentModel: model,
compaction,
modelResolveHook: resolvesModel,
modelResolveHook: Effect.void,
systemBaseline: "Initial context",
systemRemoved: false,
systemUnavailable: false,
@@ -533,6 +531,7 @@ const setup = Effect.gen(function* () {
discard: true,
})
yield* IdentityPlugin.Plugin.effect(pluginHost)
yield* NativeCompactionPlugin.Plugin.effect(pluginHost)
yield* agents.transform((editor) => {
editor.update(Agent.ID.make("build"), (agent) => {
agent.mode = "primary"
@@ -672,11 +671,6 @@ const invalidRequest = () =>
reason: new InvalidRequestError({ message: "Invalid request" }),
})
const payloadTooLarge = () =>
new AIError({
reason: new InvalidRequestError({ message: "Too large", classification: "payload-too-large" }),
})
const rateLimited = (retryAfterMs?: number) =>
new AIError({
reason: new RateLimitError({ message: "Rate limited", retryAfterMs }),
@@ -1302,7 +1296,7 @@ describe("SessionRunnerLLM", () => {
},
)
scenario("settles compaction and delivers a move while initial instructions are unavailable", function* (s) {
scenario("delivers controls without preflighting unavailable initial instructions", function* (s) {
const runner = yield* SessionRunner.Service
s.systemUnavailable = true
let reads = 0
@@ -1329,15 +1323,14 @@ describe("SessionRunnerLLM", () => {
expect(yield* runner.drain({ sessionID, force: false })).toEqual(SessionRunner.DrainResult.Moved({}))
// Compaction needs the model and instructions, so it reads them and fails; the move does not.
expect(reads).toBe(1)
expect(reads).toBe(0)
expect(s.requests).toHaveLength(0)
expect(yield* s.inbox).toEqual([])
expect((yield* s.session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved"))
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
error: { message: "Instruction initialization blocked by unavailable sources: test/context" },
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
})
})
@@ -2103,7 +2096,7 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(TestLLM.text("## Objective\n- summary", "epoch-summary"))
yield* s.session.compact({ sessionID })
yield* s.resume
expect(systemTexts(s.requests[1])).toEqual([])
expect(systemTexts(s.requests[1])).toEqual(["Changed before compaction"])
expect((yield* s.context).some((message) => message.type === "system")).toBe(false)
s.systemBaseline = "Replacement context"
yield* s.runPrompt("Second")
@@ -2380,6 +2373,7 @@ describe("SessionRunnerLLM", () => {
scenario("explains when manual compaction has no history", function* (s) {
const compaction = yield* s.session.compact({ sessionID })
s.modelResolveHook = Effect.die("model resolution should not run")
yield* s.resume
@@ -2755,16 +2749,22 @@ describe("SessionRunnerLLM", () => {
})
}
for (const response of ["length", "content-filter"] as const) {
for (const response of ["length", "content-filter", "context overflow"] as const) {
scenario(`rejects compaction ${response} without retrying or committing its draft`, function* (s) {
yield* s.llm.push(TestLLM.text("Earlier answer", "history"))
yield* s.runPrompt("Earlier question")
s.requests.length = 0
yield* s.llm.push(
TestLLM.complete(
{ reason: { normalized: response } },
LLMEvent.textDelta({ id: "truncated", text: "## Objective\n- Incomplete summary" }),
),
response === "context overflow"
? Stream.fail(
new AIError({
reason: new InvalidRequestError({ message: "Too long", classification: "context-overflow" }),
}),
)
: TestLLM.complete(
{ reason: { normalized: response } },
LLMEvent.textDelta({ id: "truncated", text: "## Objective\n- Incomplete summary" }),
),
)
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
@@ -2778,205 +2778,6 @@ describe("SessionRunnerLLM", () => {
})
}
scenario("stops after three smaller compaction inputs overflow", function* (s) {
// Large enough that the conversation, not the system prompt, is most of the request.
const filler = "context ".repeat(1_000)
yield* s.llm.push(...Array.from({ length: 8 }, (_, index) => TestLLM.text(`Answer ${index}`, `answer-${index}`)))
yield* Effect.forEach(
Array.from({ length: 8 }, (_, index) => index),
(index) => s.runPrompt(`Request ${index}: ${filler}`),
)
s.currentModel = unknownContextModel
s.requests.length = 0
const overflow = () =>
Stream.fail(
new AIError({ reason: new InvalidRequestError({ message: "Too long", classification: "context-overflow" }) }),
)
yield* s.llm.push(overflow(), overflow(), overflow(), overflow())
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
expect(s.requests).toHaveLength(4)
expect(userTexts(s.requests[2])[0].length).toBeLessThan(userTexts(s.requests[1])[0].length)
expect(userTexts(s.requests[3])[0].length).toBeLessThan(userTexts(s.requests[2])[0].length)
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({ status: "failed" })
yield* s.llm.push(TestLLM.text("Continued", "continued"))
yield* s.runPrompt("Continue")
expect(userTexts(s.requests[4])).toContain(`Request 0: ${filler}`)
})
scenario("resends compaction as text after payload too large, then shrinks later rejections", function* (s) {
const image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
const filler = "context ".repeat(1_000)
yield* s.session.prompt({
sessionID,
text: "Request 0 with an image",
files: [{ uri: `data:image/png;base64,${image}` }],
resume: false,
})
yield* s.llm.push(...Array.from({ length: 7 }, (_, index) => TestLLM.text(`Answer ${index}`, `answer-${index}`)))
yield* s.resume
yield* Effect.forEach(
Array.from({ length: 6 }, (_, index) => index + 1),
(index) => s.runPrompt(`Request ${index}: ${filler}`),
)
s.currentModel = unknownContextModel
s.requests.length = 0
yield* s.llm.push(...Array.from({ length: 5 }, () => Stream.fail(payloadTooLarge())))
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
// The first rejection resends everything as text, which carries no media; later ones shrink like "too long".
expect(s.requests).toHaveLength(5)
expect(s.requests[0]?.messages.some((message) => message.content.some((part) => part.type === "media"))).toBeTrue()
expect(s.requests[1]?.messages.every((message) => message.role === "user")).toBeTrue()
expect(userTexts(s.requests[1])[0]).toContain("[image/png omitted]")
expect(userTexts(s.requests[1])[0]).not.toMatch(/older exchanges? omitted/)
expect(userTexts(s.requests[2])[0].length).toBeLessThan(userTexts(s.requests[1])[0].length)
expect(userTexts(s.requests[3])[0].length).toBeLessThan(userTexts(s.requests[2])[0].length)
expect(userTexts(s.requests[4])[0].length).toBeLessThan(userTexts(s.requests[3])[0].length)
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({ status: "failed" })
})
scenario("resends whole history as text after payload too large when it cannot be shortened", function* (s) {
const image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
yield* s.session.prompt({
sessionID,
text: `Request 0: ${"x".repeat(40_000)}`,
files: [{ uri: `data:image/png;base64,${image}` }],
resume: false,
})
yield* s.llm.push(TestLLM.text("Answer 0", "answer-0"))
yield* s.resume
s.currentModel = testModel("smaller-history", { context: 7_000, output: 1_000 })
s.requests.length = 0
yield* s.llm.push(Stream.fail(payloadTooLarge()), TestLLM.text("## Objective\n- Recovered", "summary"))
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
// Even the latest exchange is over the estimated limit, so the first send is unchanged and the second keeps it all.
expect(s.requests).toHaveLength(2)
expect(s.requests[0]?.messages.some((message) => message.content.some((part) => part.type === "media"))).toBeTrue()
expect(s.requests[1]?.messages.every((message) => message.role === "user")).toBeTrue()
expect(userTexts(s.requests[1])[0]).toContain("[image/png omitted]")
expect(userTexts(s.requests[1])[0]).toContain(`Request 0: ${"x".repeat(40_000)}`)
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({ status: "completed" })
})
scenario("shrinks after payload too large on a compaction already sent as text", function* (s) {
const service = yield* SessionCompaction.Service
yield* service.transform((editor) => editor.configure({ buffer: 3_000 }))
s.currentModel = testModel("large-history", { context: 1_000_000, output: 32_000 })
yield* s.llm.push(...Array.from({ length: 6 }, (_, index) => TestLLM.text(`Answer ${index}`, `answer-${index}`)))
yield* Effect.forEach(
Array.from({ length: 6 }, (_, index) => index),
(index) => s.runPrompt(`Request ${index}: ${"x".repeat(8_000)}`),
)
s.currentModel = testModel("smaller-history", { context: 12_000, output: 1_000 })
s.requests.length = 0
yield* s.llm.push(Stream.fail(payloadTooLarge()), TestLLM.text("## Objective\n- Recovered", "summary"))
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
// The first send was already text, so there is no media left to drop; the rejection counts like "too long".
expect(s.requests).toHaveLength(2)
expect(s.requests[0]?.messages.every((message) => message.role === "user")).toBeTrue()
expect(userTexts(s.requests[1])[0].length).toBeLessThan(userTexts(s.requests[0])[0].length)
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({ status: "completed" })
})
scenario("aims the first overflow compaction below the rejected context", function* (s) {
const filler = "context ".repeat(1_000)
yield* s.llm.push(...Array.from({ length: 4 }, (_, index) => TestLLM.text(`Answer ${index}`, `answer-${index}`)))
yield* Effect.forEach(
Array.from({ length: 4 }, (_, index) => index),
(index) => s.runPrompt(`Request ${index}: ${filler}`),
)
s.currentModel = recoveryModel
s.requests.length = 0
yield* s.llm.push(
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
TestLLM.text("## Objective\n- Recovered", "summary"),
TestLLM.text("Recovered", "recovered"),
)
yield* s.runPrompt("Continue")
// The history fits the model's window by estimate, but the provider just rejected it, so older exchanges go.
expect(s.requests).toHaveLength(3)
expect(userTexts(s.requests[1])[0]).toContain("older exchanges omitted")
expect(userTexts(s.requests[1])[0]).not.toContain("Request 0:")
expect(userTexts(s.requests[1])[0]).toContain("Request 3:")
})
scenario("serializes history and omits media after a summary input overflow", function* (s) {
const image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
yield* s.session.prompt({
sessionID,
text: "Earlier question",
files: [{ uri: `data:image/png;base64,${image}` }],
resume: false,
})
yield* s.llm.push(
TestLLM.stop(
LLMEvent.toolCall({ id: "hosted", name: "web_search", input: { query: "earlier" }, providerExecuted: true }),
LLMEvent.toolResult({
id: "hosted",
name: "web_search",
result: { type: "text", value: "x".repeat(5_000) },
providerExecuted: true,
}),
LLMEvent.textStart({ id: "history" }),
LLMEvent.textDelta({ id: "history", text: "Earlier answer" }),
LLMEvent.textEnd({ id: "history" }),
),
)
yield* s.resume
s.requests.length = 0
yield* s.llm.push(
[LLMEvent.providerError({ message: "Too long", classification: "context-overflow" })],
TestLLM.text("## Objective\n- Recovered", "summary"),
)
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
expect(s.requests).toHaveLength(2)
expect(s.requests[0]?.messages.some((message) => message.content.some((part) => part.type === "media"))).toBeTrue()
expect(s.requests[1]?.messages.every((message) => message.role === "user")).toBeTrue()
expect(userTexts(s.requests[1])[0]).toContain("[image/png omitted]")
expect(userTexts(s.requests[1])[0]).toContain("[Tool result]:")
expect(userTexts(s.requests[1])[0]).toContain(`${"x".repeat(1_250)}\n[truncated]`)
expect(userTexts(s.requests[1])[0]).not.toContain("x".repeat(1_251))
expect(s.requests[1]?.system).toEqual(s.requests[0]?.system)
expect(s.requests[1]?.tools).toEqual(s.requests[0]?.tools)
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({ status: "completed" })
})
scenario("fits serialized compaction history by omitting oldest exchanges", function* (s) {
const service = yield* SessionCompaction.Service
yield* service.transform((editor) => editor.configure({ buffer: 3_000 }))
s.currentModel = testModel("large-history", { context: 1_000_000, output: 32_000 })
yield* s.llm.push(...Array.from({ length: 4 }, (_, index) => TestLLM.text(`Answer ${index}`, `answer-${index}`)))
yield* Effect.forEach(
Array.from({ length: 4 }, (_, index) => index),
(index) => s.runPrompt(`Request ${index}: ${"x".repeat(8_000)}`),
)
s.currentModel = testModel("smaller-history", { context: 7_000, output: 1_000 })
s.requests.length = 0
yield* s.llm.push(TestLLM.text("## Objective\n- Recovered", "summary"))
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
expect(s.requests).toHaveLength(1)
expect(userTexts(s.requests[0])[0]).toContain("older exchanges omitted")
expect(userTexts(s.requests[0])[0]).not.toContain("Request 0:")
expect(userTexts(s.requests[0])[0]).not.toContain("Request 1:")
expect(userTexts(s.requests[0])[0]).toContain("Request 2:")
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
status: "completed",
summary: "## Objective\n- Recovered",
})
})
scenario("records cancelled manual compaction without surfacing an internal failure", function* (s) {
yield* s.llm.push(TestLLM.text("Earlier answer", "text-manual-interrupt-history"))
yield* s.runPrompt("Earlier question")
@@ -3026,30 +2827,6 @@ describe("SessionRunnerLLM", () => {
).toHaveLength(1)
})
scenario("records manual compaction model resolution failures without calling the model", function* (s) {
yield* s.llm.push(TestLLM.text("Earlier answer", "text-manual-unavailable-history"))
yield* s.runPrompt("Earlier question")
s.requests.length = 0
const compaction = yield* s.session.compact({ sessionID })
s.modelResolveHook = Effect.fail(
new SessionRunnerModel.ModelUnavailableError({
providerID: Provider.ID.make("test"),
modelID: Model.ID.make("missing"),
}),
)
yield* s.resume
expect(s.requests).toHaveLength(0)
expect(yield* SessionInbox.find(s.db, compaction.id)).toBeUndefined()
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
})
})
scenario("automatically compacts into a completed summary and retained recent turn", function* (s) {
const store = yield* SessionStore.Service
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "text-first", 3_950))
@@ -3098,7 +2875,7 @@ describe("SessionRunnerLLM", () => {
scenario("automatically persists native windows, retains earlier users, and waits for fresh usage", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
modelLimits.set("native", { context: 11_000, output: 1_000 })
modelLimits.set("native", { context: 42_000, output: 32_000 })
s.compaction = { type: "native" }
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
@@ -3148,32 +2925,34 @@ describe("SessionRunnerLLM", () => {
expect(JSON.stringify(s.requests[7].messages)).toContain('"encrypted":"second"')
})
scenario("recovers an overflowing native window with another native compaction", function* (s) {
scenario("recovers an overflowing native window locally from original durable history", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
modelLimits.set("native", { context: 11_000, output: 1_000 })
modelLimits.set("native", { context: 42_000, output: 32_000 })
s.compaction = { type: "native" }
const checkpoint = (encrypted: string) =>
CompactionCheckpointResponse.make({
responseID: `resp_${encrypted}`,
checkpoint: { type: "compaction", provider: s.currentModel.provider, encrypted },
})
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 10_000))
yield* s.runPrompt("Original durable request")
yield* s.llm.push(checkpoint("native-window"), TestLLM.text("After native", "after-native"))
yield* s.llm.push(
CompactionCheckpointResponse.make({
responseID: "resp_native",
checkpoint: { type: "compaction", provider: s.currentModel.provider, encrypted: "native-window" },
}),
TestLLM.text("After native", "after-native"),
)
yield* s.runPrompt("Before native checkpoint")
s.requests.length = 0
yield* s.llm.push(
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
checkpoint("recovered-window"),
TestLLM.text("## Objective\n- Recovered original history", "local-recovery"),
TestLLM.text("Recovered", "recovered"),
)
yield* s.runPrompt("Overflow request")
expect(s.requests).toHaveLength(3)
expect(JSON.stringify(s.requests[0].messages)).toContain("native-window")
expect(JSON.stringify(s.requests[2].messages)).toContain("recovered-window")
expect(JSON.stringify(s.requests[2].messages)).not.toContain('"encrypted":"native-window"')
expect(JSON.stringify(s.requests[1].messages)).not.toContain("native-window")
expect(userTexts(s.requests[1])).toContain("Original durable request")
expect(userTexts(s.requests[1]).at(-1)).toBe(SessionCompaction.buildPrompt(false))
expect(yield* s.context).toMatchObject([
{ type: "compaction", status: "completed", providerContext: { version: 1 } },
{ type: "compaction", summary: "## Objective\n- Recovered original history" },
{ type: "assistant" },
])
})
@@ -3206,7 +2985,7 @@ describe("SessionRunnerLLM", () => {
expect(yield* Effect.exit(s.resume)).toMatchObject({ _tag: "Failure" })
expect(s.requests).toHaveLength(1)
expect(s.requests[0]?.generation?.maxTokens).toBe(50)
expect(s.requests[0]?.generation).toBeUndefined()
expect(yield* s.context).toContainEqual(
expect.objectContaining({
type: "compaction",
@@ -3411,18 +3190,6 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("fits the output limit to the prompt size", function* (s) {
s.currentModel = fittedOutputModel
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "text-fitted-first", 50_000))
yield* s.runPrompt("Earlier question")
yield* s.llm.push(TestLLM.text("Continued", "text-fitted-final"))
yield* s.runPrompt("Continue")
expect(s.requests[0]?.generation?.maxTokens).toBe(64_000)
expect(s.requests[1]?.generation?.maxTokens).toBeLessThan(100_000 - 50_000)
expect(s.requests[1]?.generation?.maxTokens).toBeGreaterThan(100_000 - 50_000 - 100)
})
scenario("publishes the original overflow when recovery summarization fails", function* (s) {
yield* setupOverflowRecovery(s)
yield* s.llm.push(
+24
View File
@@ -104,6 +104,30 @@ test("recognizes Claude version spellings and future models", () => {
])
})
test("keeps thinking on for Claude Opus 5.5 and later", () => {
const supports: Variant.Support[] = [{ type: "toggle" }, { type: "effort", values: ["low", "high"] }]
const adaptive = ["low", "high"].map((effort) => ({
id: effort,
settings: { effort, thinking: { type: "adaptive", display: "summarized" } },
}))
for (const input of [
model("@opencode/ai/providers/cloudflare-ai-gateway", "anthropic/claude-opus-5.5"),
model("@opencode/ai/providers/anthropic", "claude-opus-5-5"),
model("@opencode/ai/providers/anthropic", "claude-opus-6"),
])
expect(resolve(input, supports)).toEqual(adaptive)
expect(
resolve(model("@opencode/ai/providers/amazon-bedrock", "us.anthropic.claude-opus-5-5-v1:0"), [{ type: "toggle" }]),
).toEqual([])
for (const id of ["claude-opus-5", "claude-opus-5-20260901"])
expect(resolve(model("@opencode/ai/providers/anthropic", id), supports)).toEqual([
{ id: "none", settings: { thinking: { type: "disabled" } } },
...adaptive,
])
})
test("spells Cloudflare AI Gateway variants for their upstream routes", () => {
const pkg = "@opencode/ai/providers/cloudflare-ai-gateway"
expect(resolve(model(pkg, "openai/gpt-5.4"), [{ type: "effort", values: ["low", "xhigh"] }])).toEqual([
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.18",
"version": "2.0.16",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.18",
"version": "2.0.16",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.18",
"version": "2.0.16",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.18",
"version": "2.0.16",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.18",
"version": "2.0.16",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.18",
"version": "2.0.16",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.18",
"version": "2.0.16",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.18",
"version": "2.0.16",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",

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