Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 48ea84109e refactor(ai): move credential lowering into providers 2026-08-19 17:34:13 -04:00
264 changed files with 13733 additions and 11328 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Prompt and synthetic inbox ID reuse is now idempotent: reusing an ID within the same Session succeeds and returns the first admission, ignoring the retried payload, metadata, and delivery mode. Previously reuse with a differing payload failed with a conflict. Cross-Session and cross-type reuse still fail, and control items keep their operation-specific conflict behavior.
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Apply shared Session model-request preparation to transient generation.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Simplify interrupt continuation: the steer-scoped resume decision now lives in SessionExecution as a post-cleanup inbox check, and the run coordinator drops its continuation state machine. Wakes arriving during cancellation cleanup now restart a normal full drain, and interrupting an idle session with continue now resumes pending steering input. Recovery-applied moves now end with the same full wake as inbox-admitted moves, retrying any stranded inbox work at the new location. Interrupting with continue now also resumes a next-in-line control item: between-turn manual compaction and moves run under any drain scope, while queued prompts remain parked.
+1 -1
View File
@@ -176,7 +176,7 @@ const table = sqliteTable("session", {
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. Reusing a user or synthetic inbox item ID is idempotent when Session and type match: the first admission wins and the retried payload, metadata, and delivery mode are ignored, whether the item is still pending or already delivered (reconciled from the projected message without retained enqueue history). Cross-Session or cross-type reuse fails. Control items keep their operation-specific conflict behavior.
- Reusing a Session ID adopts the existing Session. While a user or synthetic inbox item is pending, reusing its ID reconciles only when Session, type, complete payload, metadata, and delivery match; conflicting reuse fails. Once delivered, retry reconciliation for those message-producing items uses the projected message and does not require retained enqueue history or the original delivery mode. Control items keep their operation-specific conflict behavior.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown 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.
+70 -107
View File
@@ -471,9 +471,7 @@
"version": "1.18.15",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -482,7 +480,6 @@
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"aws4fetch": "^1.0.20",
"effect": "catalog:",
"hono": "catalog:",
"hono-openapi": "catalog:",
"js-base64": "3.7.7",
@@ -698,7 +695,6 @@
"@solidjs/meta": "catalog:",
"diff": "catalog:",
"dompurify": "3.3.1",
"effect": "catalog:",
"fuzzysort": "catalog:",
"luxon": "catalog:",
"marked": "catalog:",
@@ -837,16 +833,14 @@
"packages/storybook": {
"name": "@opencode-ai/storybook",
"devDependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
"@storybook/addon-a11y": "10.4.4",
"@storybook/addon-docs": "10.4.4",
"@storybook/addon-links": "10.4.4",
"@storybook/addon-onboarding": "10.4.4",
"@storybook/addon-vitest": "10.4.4",
"@storybook/builder-vite": "10.4.4",
"@storybook/addon-a11y": "^10.2.13",
"@storybook/addon-docs": "^10.2.13",
"@storybook/addon-links": "^10.2.13",
"@storybook/addon-onboarding": "^10.2.13",
"@storybook/addon-vitest": "^10.2.13",
"@tailwindcss/vite": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
@@ -854,11 +848,10 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"solid-js": "catalog:",
"storybook": "10.4.4",
"storybook-solidjs-vite": "10.5.2",
"storybook": "^10.2.13",
"storybook-solidjs-vite": "^10.0.9",
"typescript": "catalog:",
"vite": "7.1.11",
"vite-plugin-solid": "2.11.12",
"vite": "catalog:",
},
},
"packages/theme": {
@@ -2863,25 +2856,25 @@
"@stoplight/yaml-ast-parser": ["@stoplight/yaml-ast-parser@0.0.50", "", {}, "sha512-Pb6M8TDO9DtSVla9yXSTAxmo9GVEouq5P40DWXdOie69bXogZTkgvopCq+yEvTMA0F6PEvdJmbtTV3ccIp11VQ=="],
"@storybook/addon-a11y": ["@storybook/addon-a11y@10.4.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.4.4" } }, "sha512-/eUCx/6Ozq5grauwm/NqKtlW0oJ26b6GNesXrMuFID8WLg/qLEKf79Awfz9XrmyWxe7loD40K952r7AA5Oc23A=="],
"@storybook/addon-a11y": ["@storybook/addon-a11y@10.5.7", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.5.7" } }, "sha512-I30rsNz6aA3xg3811MEry40uJDHP3l5SOkfqtmNkp7y4NdqTDdKhmbhhDAZQX1WWEk6GeMBGr3AJ3TCz7r7JmQ=="],
"@storybook/addon-docs": ["@storybook/addon-docs@10.4.4", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.4.4", "@storybook/icons": "^2.0.2", "@storybook/react-dom-shim": "10.4.4", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.4.4" }, "optionalPeers": ["@types/react"] }, "sha512-yPshCvtmQTq52T2sXuXgjy7B/QbhA/WIZxLYggptNjBL8BJMvbOfp9bAfCKh7+KpRWGqDZ6Y6tWL1Q48Wj3vtw=="],
"@storybook/addon-docs": ["@storybook/addon-docs@10.5.7", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.5.7", "@storybook/icons": "^2.0.2", "@storybook/react-dom-shim": "10.5.7", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.7" }, "optionalPeers": ["@types/react"] }, "sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA=="],
"@storybook/addon-links": ["@storybook/addon-links@10.4.4", "", { "dependencies": { "@storybook/global": "^5.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.4.4" }, "optionalPeers": ["@types/react", "react"] }, "sha512-sWydPWLgduT24p/NJ/hXHcHsPlAyzQP+cOtCGliSI989K9yBP/TOL3A8sz7LIDfukI9DVAsylPhJ1jDSiAEI1w=="],
"@storybook/addon-links": ["@storybook/addon-links@10.5.7", "", { "dependencies": { "@storybook/global": "^5.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.7" }, "optionalPeers": ["@types/react", "react"] }, "sha512-17PxEOocLhAEaPeQ4q+8yul/LF9YEIePS1arknCAS7U1pQXTe0uj+R0pB6uPLVflM5gECQMiP4WzIj4tEiL6+A=="],
"@storybook/addon-onboarding": ["@storybook/addon-onboarding@10.4.4", "", { "peerDependencies": { "storybook": "^10.4.4" } }, "sha512-ZTWGm8VXQUTepV4aEmIgHxdY7JMtn57H3uYnM3HD+qR8fmAcpLPoJ9ffXaMWUwsjK6SeferQyDTRN3q3Jd5+mg=="],
"@storybook/addon-onboarding": ["@storybook/addon-onboarding@10.5.7", "", { "peerDependencies": { "storybook": "^10.5.7" } }, "sha512-GDssSoWmmGnz6OnUvAofMcrUuTMD53Y2rnbshjXvhtPnjKw4dufXFjo6C7yZQ6vyoNzTS9HyQwCHlxlhyCKJjQ=="],
"@storybook/addon-vitest": ["@storybook/addon-vitest@10.4.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2" }, "peerDependencies": { "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", "storybook": "^10.4.4", "vitest": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@vitest/browser", "@vitest/browser-playwright", "@vitest/runner", "vitest"] }, "sha512-VPpBwf1Elr+0g33am8ZE6aHhLB+r1TPxUsnDuCVNhxGjRxMFyQkAE8+jPJFPvS/YIUGMbVXarzaV7PcI/sJuVQ=="],
"@storybook/addon-vitest": ["@storybook/addon-vitest@10.5.7", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2" }, "peerDependencies": { "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", "storybook": "^10.5.7", "vitest": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@vitest/browser", "@vitest/browser-playwright", "@vitest/runner", "vitest"] }, "sha512-7NK7Kzazc2vb2h8nGlH15QlUn5J6/LV0xF70VziAK0bxu3r1JupLCoBvckX39vHzFXiAZljXZtt1Wq/OidBxow=="],
"@storybook/builder-vite": ["@storybook/builder-vite@10.4.4", "", { "dependencies": { "@storybook/csf-plugin": "10.4.4", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.4.4", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-VyuZ4mEvhhVXjJa1qXMWKH8ohnas0rgEuJDf6u4aJ54XeENFebPUEAHde1Qo2PflJ4rUdVdXieOZzKbYwP5RAQ=="],
"@storybook/builder-vite": ["@storybook/builder-vite@10.5.7", "", { "dependencies": { "@storybook/csf-plugin": "10.5.7", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.5.7", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-fShF/aQaITqcJuMCLr42BGNUAbhDi4IboqvlbZqXAwgrrTslnZEUnY8GcEcvpZmjl11VwlmazhMJdH50fIgBPg=="],
"@storybook/csf-plugin": ["@storybook/csf-plugin@10.4.4", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.4.4", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-1mzZyAwVUmAcw4WEUsJDVdSupkJf+Kf/f5uNAs4RzlBXA75P8YRkDKAb2EoMwsB5URiXFi9XoeAN/vWke0G6+w=="],
"@storybook/csf-plugin": ["@storybook/csf-plugin@10.5.7", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.5.7", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag=="],
"@storybook/global": ["@storybook/global@5.0.0", "", {}, "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ=="],
"@storybook/icons": ["@storybook/icons@2.1.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg=="],
"@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.4.4", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.4.4" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-y6SObmoW78AydE6VfKQSUmCkuqiaMPy9LgMpMdMEyWfJ/pSxBDMIKycr9dlRMJP1cvNgByaJgrusWtA46ndSQw=="],
"@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.5.7", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.7" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA=="],
"@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="],
@@ -5525,9 +5518,9 @@
"stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="],
"storybook": ["storybook@10.4.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15" }, "optionalPeers": ["@types/react", "prettier", "vite-plus"], "bin": "./dist/bin/dispatcher.js" }, "sha512-Nn0qFRxU5fyABa6dGRftfL3lz0Y+HkKOaAkfytF8S4Q2K6Szwwq7TwPAEs3Wsj8hBQbYhsobrKADcPsyXQpJaA=="],
"storybook": ["storybook@10.5.7", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", "jsonc-parser": "^3.3.1", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.21.1" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15 || ^0.2.0" }, "optionalPeers": ["@types/react", "prettier", "vite-plus"], "bin": "./dist/bin/dispatcher.js" }, "sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg=="],
"storybook-solidjs-vite": ["storybook-solidjs-vite@10.5.2", "", { "dependencies": { "@storybook/builder-vite": "10.4.4", "@storybook/global": "5.0.0", "@volar/language-core": "2.4.28", "@volar/typescript": "2.4.28", "semver": "7.8.1" }, "peerDependencies": { "@solidjs/web": "^2.0.0-0", "solid-js": "^1.8.0-0 || ^2.0.0-0", "storybook": "^0.0.0-0 || ^10.0.0", "typescript": "^4.0.0 || ^5.0.0 || ^6.0.0", "vite": "7.1.11", "vite-plugin-solid": "2.11.12" }, "optionalPeers": ["@solidjs/web", "typescript"] }, "sha512-u+XSoTxE8JoVZ4IdI+gJRuNMQL00PCLA1BinBPdwHRKSSSVczf+JQhcfBJ2xxODs430RJp+JjAajKZeaSIHj9Q=="],
"storybook-solidjs-vite": ["storybook-solidjs-vite@10.6.0", "", { "dependencies": { "@storybook/builder-vite": "^10.4.4", "@storybook/global": "^5.0.0", "@volar/language-core": "^2.4.28", "@volar/typescript": "^2.4.28", "semver": "^7.8.1" }, "peerDependencies": { "@solidjs/web": "^2.0.0-0", "solid-js": "^1.8.0-0 || ^2.0.0-0", "storybook": "^0.0.0-0 || ^10.0.0", "typescript": "^4.0.0 || ^5.0.0 || ^6.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vite-plugin-solid": "^2.0.0-0 || ^3.0.0-0" }, "optionalPeers": ["@solidjs/web", "typescript"] }, "sha512-/nNRk0D8Uwvqny/DKVNBsCzajjmC//cATifF66ebnpjpRBCwBrflg2ymsGEE0D9LmDsdcMBn1rsoe6Z4C2Jzbw=="],
"stream-replace-string": ["stream-replace-string@2.0.0", "", {}, "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w=="],
@@ -6379,10 +6372,6 @@
"@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
"@opencode-ai/storybook/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=="],
"@opencode-ai/storybook/vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/updates/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
@@ -6885,14 +6874,12 @@
"sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
"storybook/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"storybook/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"storybook-solidjs-vite/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"storybook-solidjs-vite/vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.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", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -7853,9 +7840,57 @@
"rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"storybook-solidjs-vite/vite/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"storybook/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"storybook-solidjs-vite/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"storybook/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"storybook/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"storybook/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"storybook/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"storybook/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"storybook/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"storybook/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"storybook/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"storybook/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"storybook/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"storybook/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"storybook/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"storybook/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"storybook/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"storybook/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"storybook/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"storybook/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"storybook/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"storybook/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"storybook/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"storybook/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"storybook/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"storybook/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"storybook/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"storybook/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -8761,78 +8796,6 @@
"rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"storybook-solidjs-vite/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"storybook-solidjs-vite/vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"storybook-solidjs-vite/vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"storybook-solidjs-vite/vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"storybook-solidjs-vite/vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"storybook-solidjs-vite/vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"storybook-solidjs-vite/vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"storybook-solidjs-vite/vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"storybook-solidjs-vite/vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"storybook-solidjs-vite/vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"storybook-solidjs-vite/vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"tw-to-css/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-9IJxoe/MdL6nmoeKvxZop+77HJ/b3HbmpytNUvLqYkc=",
"aarch64-linux": "sha256-KR1J102RDTRDZIkAD3jQfXeP1G2DN9Es7KkdKo+daec=",
"aarch64-darwin": "sha256-HCgSoq1W6XU6m73Ck3wV8gjB687caUTM3w/EO+V7wsE=",
"x86_64-darwin": "sha256-GrKTDvjg0XeDIWbOvayWQjj0SdJd8WPm2+q3IVS17Jg="
"x86_64-linux": "sha256-IxkSw0gK/qkMHZGVHqjwgM9BKhzbQX6hyF9SWUNtpzg=",
"aarch64-linux": "sha256-YVjpbil0QswVwi6NtVYFq3xCqpsfveG1chlNVCVI0MU=",
"aarch64-darwin": "sha256-CdL2mI84pawH2H5i9qu8A6IWbkmKOYHlJS+DI/Mafdw=",
"x86_64-darwin": "sha256-NtswwfU5WYv99bEmI4XeLwjhBGcS9ZMYLRo4MQRNtLo="
}
}
+10 -5
View File
@@ -71,7 +71,7 @@ export const route = Route.make({
})
```
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry model identity and the configured route; low-level callers may also attach model-specific defaults and compatibility metadata. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
@@ -88,7 +88,7 @@ For providers where the URL is derived from typed inputs (Azure resource name, B
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:
```ts
const openai = OpenAI.configure({ apiKey, baseURL })
const openai = OpenAI.configure({ apiKey, baseURL, store: false })
const model = openai.responses("gpt-4o-mini")
const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" })
@@ -108,17 +108,22 @@ Keep provider facades small and explicit:
- Resolve `apiKey``Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash).
- Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`.
Provider facades and model-derived `LLMRequest.providerOptions` are provider-specific, so expose typed native options flat at those boundaries. Provider package settings keep deployment configuration separate from their typed `providerOptions` field, except facades such as OpenAI whose settings are already unambiguous when flat. The selected `LanguageModel<Options>` carries request-option typing; the route decodes the flat runtime record. Keep provider metadata namespaced because replay may contain metadata from multiple layers.
`Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
### Provider Package Entrypoints
Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model({ id, settings, credential, defaults })`. Core selects and refreshes the optional `key | oauth` credential; the provider package interprets it as route authentication. Serializable provider settings remain separate from common `headers`, `body`, and `limits` defaults.
```ts
import { model } from "@opencode-ai/ai/providers/openai/responses"
const selected = model("gpt-5", {
apiKey,
const selected = model({
id: "gpt-5",
settings: {},
credential: { type: "key", value: apiKey },
defaults: {},
})
```
+56 -12
View File
@@ -305,19 +305,26 @@ const gateway = CloudflareAIGateway.configure({
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
```
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint. GitHub Copilot remains a Core-owned AI SDK integration rather than an AI-package provider.
### Package-like entrypoints
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model({ id, settings, credential, defaults })` contract. Core selects and refreshes the optional `key | oauth` credential, while the provider package interprets it as route authentication. Serializable provider settings remain separate from common `headers`, `body`, and `limits` defaults.
```ts
import { model } from "@opencode-ai/ai/providers/openai/responses"
const selected = model("gpt-5", {
apiKey: process.env.OPENAI_API_KEY,
headers: { "x-application": "opencode" },
limits: { context: 200_000, output: 64_000 },
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) throw new Error("OPENAI_API_KEY is required")
const selected = model({
id: "gpt-5",
settings: {},
credential: { type: "key", value: apiKey },
defaults: {
headers: { "x-application": "opencode" },
limits: { context: 200_000, output: 64_000 },
},
})
```
@@ -341,30 +348,57 @@ Tuned Vertex Gemini deployments use model ids shaped like `endpoints/1234567890`
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/gemini"
model("gemini-3.5-flash", { project: "my-project", location: "global" })
model({
id: "gemini-3.5-flash",
settings: { project: "my-project", location: "global" },
defaults: {},
})
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/chat"
model("deepseek-ai/deepseek-v3.2-maas", { project: "my-project", location: "global" })
model({
id: "deepseek-ai/deepseek-v3.2-maas",
settings: { project: "my-project", location: "global" },
defaults: {},
})
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/responses"
model("xai/grok-4.20-reasoning", { project: "my-project", location: "global" })
model({
id: "xai/grok-4.20-reasoning",
settings: { project: "my-project", location: "global" },
defaults: {},
})
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
model("claude-sonnet-4-6", { project: "my-project", location: "global" })
model({
id: "claude-sonnet-4-6",
settings: { project: "my-project", location: "global" },
defaults: {},
})
```
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path.
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path. The entrypoints listed above implement that contract and are covered by `test/provider-package.test.ts`.
Other provider exports listed above remain direct facades until they explicitly implement the package-like contract. Exporting a provider facade does not implicitly make it a catalog-loadable provider package.
## How OpenCode uses this package
OpenCode does not call provider facades directly from the CLI or server. Core owns the integration:
1. `packages/core/src/model-resolver.ts` resolves catalog metadata and an active integration credential into a `LanguageModel`. Native package entrypoints expose `model({ id, settings, credential, defaults })`; catalog packages without a native mapping fall back through Core's AI SDK adapter.
2. `packages/core/src/session/model-request.ts` lowers Session state, instructions, tools, and plugin hooks into one canonical `LLMRequest`.
3. `packages/core/src/session/runner/llm.ts` calls the yielded `LLMClient.Service` once per physical attempt and persists provider-neutral `LLMEvent`s.
4. Core owns retries, continuation, compaction, permissions, durable tool execution, and Session history. None of that orchestration belongs in this package.
Title generation, compaction, standalone generation, and transient Session generation also build `LLMRequest`s and use the same `LLMClient.Service`. Core's `AISDK` adapter wraps remaining Vercel AI SDK models in executable routes so native and fallback providers present the same request and event model to callers.
This separation is intentional: `@opencode-ai/ai` owns one model call, provider protocols, and transport; Core owns the durable agent runtime.
## Provider options & HTTP overlays
@@ -377,6 +411,16 @@ Request options in order of stability:
Route/provider defaults are overridden by request-level values for each axis.
Provider-specific facades accept their own options directly because the provider is already known:
```ts
const model = OpenAI.configure({
apiKey,
store: false,
reasoningEffort: "high",
}).responses("gpt-5")
```
The selected model supplies the provider-specific option type, so per-request overrides stay flat while the canonical runtime request remains provider-neutral:
```ts
+6 -7
View File
@@ -17,13 +17,12 @@ import { OpenAI } from "@opencode-ai/ai/providers"
const apiKey = Config.redacted("OPENAI_API_KEY")
// 1. Pick a model. The provider helper records provider identity, protocol
// choice, capabilities, deployment options, authentication, and defaults.
// choice, deployment options, authentication, and defaults. Catalog capabilities
// remain application-owned and are not part of LanguageModel.
const model = OpenAI.configure({
apiKey,
generation: { maxTokens: 160 },
providerOptions: {
store: false,
},
store: false,
}).model("gpt-4o-mini")
// 2. Build a provider-neutral request. This is useful when reusing one request
@@ -74,8 +73,8 @@ const streamText = LLM.stream(request).pipe(
Stream.runDrain,
)
// 5. Tools are typed with Effect Schema. Provider turns remain explicit:
// advertise definitions on the request, stream one turn, dispatch local calls,
// 5. Tools are typed with Effect Schema. Model calls remain explicit:
// advertise definitions on the request, stream one call, dispatch local calls,
// then persist/build follow-up history in the enclosing product flow.
const tools = {
get_weather: Tool.make({
@@ -102,7 +101,7 @@ const streamWithTools = Effect.gen(function* () {
console.log("tool result", event.name, dispatched.result)
// A durable agent would persist these messages before starting another
// raw model turn. This tutorial keeps the boundary visible instead.
// model call. This tutorial keeps the boundary visible instead.
const followUp = LLMRequest.update(request, {
messages: [
...request.messages,
+3
View File
@@ -37,6 +37,9 @@ export type {
LanguageModelOptions as ProviderLanguageModelOptions,
} from "./provider.js"
export type {
Credential as ProviderPackageCredential,
Defaults as ProviderPackageDefaults,
Definition as ProviderPackageDefinition,
ModelInput as ProviderPackageModelInput,
Settings as ProviderPackageSettings,
} from "./provider-package.js"
+44 -3
View File
@@ -1,7 +1,21 @@
import { Auth } from "./route/auth.js"
import type { AuthOverride, RequiredApiKeyAuth } from "./route/auth-options.js"
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
export interface Settings {}
export type Credential =
| {
readonly type: "key"
readonly value: string
readonly configuration?: Readonly<Record<string, unknown>>
}
| {
readonly type: "oauth"
readonly accessToken: string
}
export interface Defaults {
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: {
@@ -11,11 +25,38 @@ export interface Settings extends Readonly<Record<string, unknown>> {
}
}
export interface ModelInput<ProviderSettings extends Settings = Settings> {
readonly id: string
readonly settings: ProviderSettings
readonly credential?: Credential
readonly defaults: Defaults
}
export const routeDefaults = (input: Defaults) => ({
headers: input.headers,
http: input.body === undefined ? undefined : { body: input.body },
limits: input.limits,
})
export const bearerCredentialValue = (input: Credential) => (input.type === "key" ? input.value : input.accessToken)
export const bearerAuthOption = (input: Credential): AuthOverride => ({
auth: Auth.bearer(bearerCredentialValue(input)),
})
export const apiKeyOrBearerAuthOption = (
input: Credential,
competingKeyHeader: string,
): RequiredApiKeyAuth | AuthOverride =>
input.type === "key"
? { apiKey: input.value }
: { auth: Auth.remove(competingKeyHeader).andThen(Auth.bearer(input.accessToken)) }
export interface Definition<
ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions,
> {
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
readonly model: (input: ModelInput<ProviderSettings>) => LanguageModel<Options>
}
export * as ProviderPackage from "./provider-package.js"
@@ -1,6 +1,6 @@
import { Auth } from "../route/auth.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenAIResponses } from "../protocols/openai-responses.js"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
@@ -79,29 +79,27 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
const config = (settings: Settings): Config => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined)
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
if (!input.credential && input.settings.auth === "sigv4" && input.settings.apiKey !== undefined)
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
return {
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
baseURL: settings.baseURL,
credentials: settings.credentials,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
region: settings.region,
...ProviderPackage.routeDefaults(input.defaults),
apiKey: input.credential
? ProviderPackage.bearerCredentialValue(input.credential)
: input.settings.auth === "sigv4"
? undefined
: input.settings.apiKey,
baseURL: input.settings.baseURL,
credentials: input.settings.credentials,
providerOptions: input.settings.providerOptions,
region: input.settings.region,
}
}
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure(config(input)).chat(input.id)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure(config(input)).responses(input.id)
export const model = chatModel
+15 -13
View File
@@ -1,6 +1,6 @@
import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as BedrockConverse from "../protocols/bedrock-converse.js"
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
@@ -50,19 +50,21 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
export const model: ProviderPackage.Definition<Settings>["model"] = (input) => {
if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined)
throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
if (!input.credential && input.settings.auth === "sigv4" && input.settings.apiKey !== undefined)
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
return configure({
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
baseURL: settings.baseURL,
credentials: settings.credentials,
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
region: settings.region,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
apiKey: input.credential
? ProviderPackage.bearerCredentialValue(input.credential)
: input.settings.auth === "sigv4"
? undefined
: input.settings.apiKey,
baseURL: input.settings.baseURL,
credentials: input.settings.credentials,
generation: input.settings.topP === undefined ? undefined : { topP: input.settings.topP },
region: input.settings.region,
}).model(input.id)
}
@@ -1,4 +1,4 @@
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
@@ -32,7 +32,9 @@ export const routes = [AnthropicMessages.route]
const auth = (input: ProviderAuthOption<"optional">) => {
if ("auth" in input && input.auth) return input.auth
return Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key"))
return Auth.remove("authorization").andThen(
Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key")),
)
}
export const configure = (input: Config) => {
@@ -57,21 +59,20 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined)
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-api-key")
: input.settings.authToken === undefined
? { apiKey: input.settings.apiKey }
: { auth: Auth.bearer(input.settings.authToken) }),
baseURL: input.settings.baseURL,
provider: input.settings.provider,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
export * as AnthropicCompatible from "./anthropic-compatible.js"
+17 -16
View File
@@ -1,7 +1,7 @@
import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { AnthropicCompatible } from "./anthropic-compatible.js"
@@ -31,9 +31,11 @@ export type Settings = ProviderPackage.Settings &
const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
.orElse(Auth.config("ANTHROPIC_API_KEY"))
.pipe(Auth.header("x-api-key"))
return Auth.remove("authorization").andThen(
Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
.orElse(Auth.config("ANTHROPIC_API_KEY"))
.pipe(Auth.header("x-api-key")),
)
}
export const configure = (input: Config = {}) => {
@@ -52,18 +54,17 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined)
throw new Error("Anthropic apiKey cannot be combined with authToken")
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-api-key")
: input.settings.authToken === undefined
? { apiKey: input.settings.apiKey }
: { auth: Auth.bearer(input.settings.authToken) }),
baseURL: input.settings.baseURL,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
+17 -16
View File
@@ -1,7 +1,7 @@
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
@@ -120,28 +120,29 @@ export const provider = {
configure,
}
const config = (settings: Settings): Config => {
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
const settings = input.settings
const configuration = input.credential?.type === "key" ? input.credential.configuration : undefined
const baseURL = settings.baseURL ?? (typeof configuration?.baseURL === "string" ? configuration.baseURL : undefined)
const resourceName =
settings.resourceName ?? (typeof configuration?.resourceName === "string" ? configuration.resourceName : undefined)
const common = {
apiKey: settings.apiKey,
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "api-key")
: { apiKey: settings.apiKey }),
apiVersion: settings.apiVersion,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
}
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
if (baseURL !== undefined) return { ...common, baseURL }
if (resourceName !== undefined) return { ...common, resourceName }
throw new Error("Azure requires resourceName or baseURL")
}
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure(config(input)).responses(input.id)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure(config(input)).chat(input.id)
export const model = responsesModel
+11 -12
View File
@@ -1,4 +1,4 @@
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
@@ -68,16 +68,15 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => {
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined))
throw new Error("Google Vertex Chat does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken,
baseURL: input.settings.baseURL,
location: input.settings.location,
project: input.settings.project,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
@@ -1,5 +1,5 @@
import { Effect, Schema, Struct } from "effect"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
@@ -100,19 +100,15 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined))
throw new Error("Google Vertex Messages does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken,
baseURL: input.settings.baseURL,
location: input.settings.location,
project: input.settings.project,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
@@ -1,4 +1,4 @@
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
@@ -70,19 +70,15 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (input) => {
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined))
throw new Error("Google Vertex Responses does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken,
baseURL: input.settings.baseURL,
location: input.settings.location,
project: input.settings.project,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
@@ -69,9 +69,8 @@ const adc = (project?: string) => {
export const oauth = (input: OAuthOptions, project?: string) => {
if (input.accessToken !== undefined && input.auth !== undefined)
throw new Error("Google Vertex accessToken cannot be combined with auth")
if (input.auth) return input.auth
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
return adc(project)
const auth = input.auth ?? (input.accessToken !== undefined ? Auth.bearer(input.accessToken) : adc(project))
return Auth.remove("x-goog-api-key").andThen(auth)
}
export * as GoogleVertexShared from "./google-vertex-shared.js"
+20 -13
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { Gemini } from "../protocols/gemini.js"
import { ProviderShared } from "../protocols/shared.js"
import { Auth } from "../route/auth.js"
@@ -94,7 +94,10 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
return route.with({
...rest,
endpoint: { baseURL: endpoint },
auth: apiKey === undefined ? GoogleVertexShared.oauth(input, project) : Auth.header("x-goog-api-key", apiKey),
auth:
apiKey === undefined
? GoogleVertexShared.oauth(input, project)
: Auth.remove("authorization").andThen(Auth.header("x-goog-api-key", apiKey)),
})
}
@@ -111,17 +114,21 @@ export const provider = {
id,
configure,
}
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (input) => {
if (!input.credential && input.settings.apiKey !== undefined && input.settings.accessToken !== undefined)
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
return configure({
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? input.credential.type === "key"
? { apiKey: input.credential.value }
: { accessToken: input.credential.accessToken }
: input.settings.apiKey === undefined
? { accessToken: input.settings.accessToken }
: { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
location: input.settings.location,
project: input.settings.project,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
+14 -12
View File
@@ -1,7 +1,7 @@
import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema/index.js"
import { Gemini } from "../protocols/gemini.js"
import { GoogleImages } from "../protocols/google-images.js"
@@ -28,9 +28,11 @@ export interface Settings extends ProviderPackage.Settings {
const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
.orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY"))
.pipe(Auth.header("x-goog-api-key"))
return Auth.remove("authorization").andThen(
Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
.orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY"))
.pipe(Auth.header("x-goog-api-key")),
)
}
const configuredRoute = (input: Config) => {
@@ -57,14 +59,14 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (input) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-goog-api-key")
: { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
providerOptions: input.settings.providerOptions,
}).model(input.id)
export const image = provider.image
@@ -1,4 +1,4 @@
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
@@ -46,16 +46,11 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
) =>
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (input) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
provider: input.settings.provider,
providerOptions: input.settings.providerOptions,
}).model(input.id)
+8 -10
View File
@@ -2,7 +2,7 @@ import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
@@ -68,16 +68,14 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
provider: input.settings.provider,
providerOptions: input.settings.providerOptions,
}).model(input.id)
export const baseten = define(profiles.baseten)
export const cerebras = define(profiles.cerebras)
+5 -22
View File
@@ -1,37 +1,21 @@
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
import type { OpenResponsesOptionsInput } from "./open-responses-options.js"
import type { Options } from "../protocols/utils/open-responses-options.js"
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
export type OpenAIOptionsInput = OpenResponsesOptionsInput
export type OpenAIConfigOptions = Options
export type OpenAIProviderOptionsInput = OpenAIOptionsInput
const definedEntries = (input: Record<string, unknown>) =>
Object.entries(input).filter((entry) => entry[1] !== undefined)
const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): ProviderOptions | undefined => {
const result = Object.fromEntries(
definedEntries({
store: options?.store,
reasoningEffort: options?.reasoningEffort,
reasoningSummary: options?.reasoningSummary,
include: options?.include,
textVerbosity: options?.textVerbosity,
serviceTier: options?.serviceTier,
}),
)
if (Object.keys(result).length === 0) return undefined
return result
}
export const gpt5DefaultOptions = (
modelID: string,
options: { readonly textVerbosity?: boolean } = {},
): ProviderOptions | undefined => {
const id = modelID.toLowerCase()
if (!id.includes("gpt-5") || id.includes("gpt-5-chat") || id.includes("gpt-5-pro")) return undefined
return openAIProviderOptions({
return {
reasoningEffort: "medium",
reasoningSummary: "auto",
// GPT-5 reasoning models are configured stateless (`store: false`) by
@@ -44,14 +28,13 @@ export const gpt5DefaultOptions = (
options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
? "low"
: undefined,
})
}
}
export const openAIDefaultOptions = (
modelID: string,
options: { readonly textVerbosity?: boolean } = {},
): ProviderOptions | undefined =>
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID, options))
): ProviderOptions | undefined => mergeProviderOptions({ store: false }, gpt5DefaultOptions(modelID, options))
export const withOpenAIOptions = <Options extends { readonly providerOptions?: OpenAIProviderOptionsInput }>(
modelID: string,
+51 -19
View File
@@ -1,10 +1,10 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
import { withOpenAIOptions, type OpenAIConfigOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images.js"
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options.js"
@@ -17,11 +17,11 @@ export const routes = [OpenAIResponses.route, OpenAIChat.route]
// This provider facade wraps the lower-level Responses and Chat model factories
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
// and default option normalization.
export type Config = RouteDefaultsInput &
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
OpenAIConfigOptions &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly queryParams?: Record<string, string>
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface ImageGenerationOptions {
@@ -57,13 +57,12 @@ export const imageGeneration = (options: ImageGenerationOptions = {}) =>
},
})
export interface Settings extends ProviderPackage.Settings {
export interface Settings extends ProviderPackage.Settings, OpenAIConfigOptions {
readonly apiKey?: string
readonly baseURL?: string
readonly organization?: string
readonly project?: string
readonly queryParams?: Readonly<Record<string, string>>
readonly providerOptions?: OpenAIProviderOptionsInput
}
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
@@ -73,6 +72,39 @@ const defaults = (input: Config) => {
return rest
}
const splitConfigOptions = <Input extends OpenAIConfigOptions>(input: Input) => {
const {
instructions,
store,
reasoningEffort,
reasoningSummary,
include,
textVerbosity,
serviceTier,
truncation,
allowedTools,
maxToolCalls,
parallelToolCalls,
...rest
} = input
return {
options: {
instructions,
store,
reasoningEffort,
reasoningSummary,
include,
textVerbosity,
serviceTier,
truncation,
allowedTools,
maxToolCalls,
parallelToolCalls,
},
rest,
}
}
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
route.with({
auth: auth(input),
@@ -82,7 +114,8 @@ const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Co
export const configure = (input: Config = {}) => {
const responsesRoute = configuredRoute(OpenAIResponses.route, input)
const chatRoute = configuredRoute(OpenAIChat.route, input)
const modelDefaults = defaults(input)
const split = splitConfigOptions(defaults(input))
const modelDefaults = { ...split.rest, providerOptions: split.options }
const responses = (id: string | ModelID) =>
responsesRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
@@ -113,31 +146,30 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
const config = (settings: Settings): Config => {
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
const settings = input.settings
const options = splitConfigOptions(settings).options
const headers = {
...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }),
...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }),
...settings.headers,
...input.defaults.headers,
}
return {
apiKey: settings.apiKey,
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: settings.apiKey }),
baseURL: settings.baseURL,
headers: Object.keys(headers).length === 0 ? undefined : headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
...options,
}
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
return configure(config(settings)).responses(modelID)
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => {
return configure(config(input)).responses(input.id)
}
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure(config(input)).chat(input.id)
export const responses = provider.responses
export const chat = provider.chat
export const image = provider.image
+7 -12
View File
@@ -5,7 +5,7 @@ import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
@@ -191,15 +191,10 @@ export const configure = (input: LanguageModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
modelID,
settings,
) =>
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (input) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
providerOptions: input.settings.providerOptions,
}).model(input.id)
+7 -9
View File
@@ -8,7 +8,7 @@ import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
import { XAIImages } from "../protocols/xai-images.js"
import type { OpenAIOptionsInput } from "./openai-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
export const id = ProviderID.make("xai")
@@ -95,15 +95,13 @@ export const configure = (input: LanguageModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (input) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
providerOptions: input.settings.providerOptions,
}).model(input.id)
export const responses = provider.responses
export const chat = provider.chat
export const image = provider.image
+55 -17
View File
@@ -81,8 +81,22 @@ OpenAI.configure({
}).responses("gpt-4.1-mini")
OpenAI.configure({
generation: { maxTokens: 100 },
providerOptions: { store: false },
store: false,
}).responses("gpt-4.1-mini")
OpenAI.model({
id: "gpt-5",
settings: {},
credential: { type: "key", value: "sk-test" },
defaults: { headers: { "x-test": "value" } },
})
OpenAI.model({
id: "gpt-5",
settings: {
// @ts-expect-error Common request defaults belong under input.defaults.
headers: { "x-test": "value" },
},
defaults: {},
})
// @ts-expect-error OpenAI model selectors only accept model ids.
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini", {})
@@ -97,7 +111,7 @@ OpenAI.configure({ bogus: true })
OpenAI.configure({ generation: { maxTokens: "many" } })
// @ts-expect-error provider-native options remain typed.
OpenAI.configure({ providerOptions: { store: "false" } })
OpenAI.configure({ store: "false" })
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
@@ -145,8 +159,12 @@ Anthropic.configure({
}).model("claude-haiku")
// @ts-expect-error Anthropic model selectors only accept model ids.
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {})
// @ts-expect-error Anthropic package settings accept only one auth source.
Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" })
Anthropic.model({
id: "claude-sonnet-4-6",
// @ts-expect-error Anthropic package settings accept only one auth source.
settings: { apiKey: "anthropic-key", authToken: "anthropic-token" },
defaults: {},
})
// @ts-expect-error Enabled Anthropic thinking requires a token budget.
Anthropic.configure({ providerOptions: { thinking: { type: "enabled" } } })
// @ts-expect-error Anthropic thinking budgets must be numbers.
@@ -162,11 +180,15 @@ AnthropicCompatible.configure({
AnthropicCompatible.configure({ apiKey: "messages-key" })
// @ts-expect-error Anthropic-compatible model selectors only accept model ids.
AnthropicCompatible.configure({ baseURL: "https://messages.example.com/v1" }).model("compatible-model", {})
// @ts-expect-error Anthropic-compatible package settings accept only one auth source.
AnthropicCompatible.model("compatible-model", {
apiKey: "messages-key",
authToken: "messages-token",
baseURL: "https://messages.example.com/v1",
AnthropicCompatible.model({
id: "compatible-model",
// @ts-expect-error Anthropic-compatible package settings accept only one auth source.
settings: {
apiKey: "messages-key",
authToken: "messages-token",
baseURL: "https://messages.example.com/v1",
},
defaults: {},
})
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash")
@@ -189,15 +211,23 @@ GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {})
// @ts-expect-error Vertex Gemini config accepts only one auth source.
GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
// @ts-expect-error Vertex Gemini package settings accept only one auth source.
GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
GoogleVertex.model({
id: "gemini-3.5-flash",
// @ts-expect-error Vertex Gemini package settings accept only one auth source.
settings: { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" },
defaults: {},
})
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas")
GoogleVertexChat.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
"deepseek-ai/deepseek-v3.2-maas",
)
// @ts-expect-error Vertex Chat package settings do not accept API keys.
GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", { apiKey: "vertex-key", project: "project" })
GoogleVertexChat.model({
id: "deepseek-ai/deepseek-v3.2-maas",
// @ts-expect-error Vertex Chat package settings do not accept API keys.
settings: { apiKey: "vertex-key", project: "project" },
defaults: {},
})
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model(
"deepseek-ai/deepseek-v3.2-maas",
// @ts-expect-error Vertex Chat model selectors only accept model ids.
@@ -214,8 +244,12 @@ GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project
GoogleVertexResponses.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
"xai/grok-4.20-reasoning",
)
// @ts-expect-error Vertex Responses package settings do not accept API keys.
GoogleVertexResponses.model("xai/grok-4.20-reasoning", { apiKey: "vertex-key", project: "project" })
GoogleVertexResponses.model({
id: "xai/grok-4.20-reasoning",
// @ts-expect-error Vertex Responses package settings do not accept API keys.
settings: { apiKey: "vertex-key", project: "project" },
defaults: {},
})
GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model(
"xai/grok-4.20-reasoning",
// @ts-expect-error Vertex Responses model selectors only accept model ids.
@@ -233,8 +267,12 @@ GoogleVertexMessages.configure({
project: "project",
providerOptions: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" },
}).model("claude-sonnet-4-6")
// @ts-expect-error Vertex Messages package settings do not accept API keys.
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
GoogleVertexMessages.model({
id: "claude-sonnet-4-6",
// @ts-expect-error Vertex Messages package settings do not accept API keys.
settings: { apiKey: "vertex-key", project: "project" },
defaults: {},
})
GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6")
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model(
"claude-sonnet-4-6",
+315 -103
View File
@@ -1,6 +1,72 @@
import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { LLM, ProviderPackage } from "@opencode-ai/ai"
import { model } from "@opencode-ai/ai/providers/openai"
const packageInput = <Input extends Record<string, unknown>>(id: string, input: Input) => {
const { headers, body, limits, ...settings } = input
return { id, settings, defaults: { headers, body, limits } }
}
const authHeaders = (
selected: ReturnType<typeof model>,
headers: Record<string, string> = {},
env: Record<string, string> = {},
) =>
Effect.runPromise(
selected.route.auth
.apply({
request: LLM.request({ model: selected, prompt: "hello" }),
method: "POST",
url: "https://example.test/v1",
body: "{}",
headers: Headers.fromInput(headers),
})
.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))),
)
const applyAuth = (
option: ReturnType<typeof ProviderPackage.bearerAuthOption>,
headers: Record<string, string> = {},
) => {
const selected = model(packageInput("gpt-5", { apiKey: "fixture" }))
return Effect.runPromise(
option.auth.apply({
request: LLM.request({ model: selected, prompt: "hello" }),
method: "POST",
url: "https://example.test/v1",
body: "{}",
headers: Headers.fromInput(headers),
}),
)
}
describe("provider package credential lowering", () => {
test("intentionally renders keys and OAuth credentials as bearer auth", async () => {
const key = await applyAuth(ProviderPackage.bearerAuthOption({ type: "key", value: "provider-key" }))
const oauth = await applyAuth(ProviderPackage.bearerAuthOption({ type: "oauth", accessToken: "provider-token" }))
expect(key.authorization).toBe("Bearer provider-key")
expect(oauth.authorization).toBe("Bearer provider-token")
})
test("keeps key-header credentials configurable and removes stale keys for OAuth", async () => {
expect(ProviderPackage.apiKeyOrBearerAuthOption({ type: "key", value: "provider-key" }, "x-api-key")).toEqual({
apiKey: "provider-key",
})
const oauth = ProviderPackage.apiKeyOrBearerAuthOption(
{ type: "oauth", accessToken: "provider-token" },
"x-api-key",
)
if (!("auth" in oauth)) throw new Error("Expected OAuth credential to lower to auth")
const headers = await applyAuth(oauth, { "x-api-key": "stale" })
expect(headers.authorization).toBe("Bearer provider-token")
expect(headers["x-api-key"]).toBeUndefined()
})
})
describe("provider package entrypoints", () => {
test("semantic API aliases expose the same contract", async () => {
const modules = await Promise.all([
@@ -45,14 +111,18 @@ describe("provider package entrypoints", () => {
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
}
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
...settings,
providerOptions: { usage: true },
})
const xai = XAI.model("grok-4", {
...settings,
providerOptions: { reasoningEffort: "high" },
})
const openrouter = OpenRouter.model(
packageInput("anthropic/claude-sonnet-4", {
...settings,
providerOptions: { usage: true },
}),
)
const xai = XAI.model(
packageInput("grok-4", {
...settings,
providerOptions: { reasoningEffort: "high" },
}),
)
for (const selected of [openrouter, xai]) {
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
@@ -65,32 +135,151 @@ describe("provider package entrypoints", () => {
})
test("maps package settings onto the executable model", () => {
const selected = model("gpt-5", {
apiKey: "fixture",
baseURL: "https://api.openai.test/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
unrelatedInheritedSetting: true,
})
const selected = model(
packageInput("gpt-5", {
apiKey: "fixture",
baseURL: "https://api.openai.test/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
reasoningEffort: "high",
unrelatedInheritedSetting: true,
}),
)
expect(selected.route.id).toBe("openai-responses")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(selected.route.defaults.providerOptions).toEqual({
store: false,
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
})
})
test("lets provider packages interpret resolved credentials", async () => {
const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
const Azure = await import("@opencode-ai/ai/providers/azure")
const Google = await import("@opencode-ai/ai/providers/google")
const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex")
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
const openai = model({
id: "gpt-5",
settings: {},
credential: { type: "oauth", accessToken: "openai-token" },
defaults: {},
})
const anthropicKey = Anthropic.model({
id: "claude-sonnet-4-6",
settings: {},
credential: { type: "key", value: "anthropic-key" },
defaults: {},
})
const anthropicOAuth = Anthropic.model({
id: "claude-sonnet-4-6",
settings: {},
credential: { type: "oauth", accessToken: "anthropic-token" },
defaults: {},
})
const anthropicEmptyKey = Anthropic.model({
id: "claude-sonnet-4-6",
settings: {},
credential: { type: "key", value: "" },
defaults: {},
})
const azureKey = Azure.model({
id: "deployment",
settings: { resourceName: "opencode-test" },
credential: { type: "key", value: "azure-key" },
defaults: {},
})
const azureOAuth = Azure.model({
id: "deployment",
settings: { resourceName: "opencode-test" },
credential: { type: "oauth", accessToken: "azure-token" },
defaults: {},
})
const googleKey = Google.model({
id: "gemini-2.5-flash",
settings: {},
credential: { type: "key", value: "google-key" },
defaults: {},
})
const googleOAuth = Google.model({
id: "gemini-2.5-flash",
settings: {},
credential: { type: "oauth", accessToken: "google-token" },
defaults: {},
})
const vertexKey = GoogleVertex.model({
id: "gemini-3.5-flash",
settings: {},
credential: { type: "key", value: "vertex-key" },
defaults: {},
})
const vertexOAuth = GoogleVertex.model({
id: "gemini-3.5-flash",
settings: { project: "vertex-project" },
credential: { type: "oauth", accessToken: "vertex-token" },
defaults: {},
})
const vertexChatOAuth = GoogleVertexChat.model({
id: "deepseek-ai/deepseek-v3.2-maas",
settings: { apiKey: "configured-key", project: "vertex-project" },
credential: { type: "oauth", accessToken: "vertex-chat-token" },
defaults: {},
})
expect((await authHeaders(openai)).authorization).toBe("Bearer openai-token")
const anthropicKeyHeaders = await authHeaders(anthropicKey, { authorization: "Bearer stale" })
const anthropicOAuthHeaders = await authHeaders(anthropicOAuth, { "x-api-key": "stale" })
const anthropicEmptyKeyHeaders = await authHeaders(
anthropicEmptyKey,
{ authorization: "Bearer stale" },
{ ANTHROPIC_API_KEY: "environment-key" },
)
const azureKeyHeaders = await authHeaders(azureKey, { authorization: "Bearer stale" })
const azureOAuthHeaders = await authHeaders(azureOAuth, { "api-key": "stale" })
const googleKeyHeaders = await authHeaders(googleKey, { authorization: "Bearer stale" })
const googleOAuthHeaders = await authHeaders(googleOAuth, { "x-goog-api-key": "stale" })
const vertexKeyHeaders = await authHeaders(vertexKey, { authorization: "Bearer stale" })
const vertexOAuthHeaders = await authHeaders(vertexOAuth, { "x-goog-api-key": "stale" })
expect(anthropicKeyHeaders["x-api-key"]).toBe("anthropic-key")
expect(anthropicKeyHeaders.authorization).toBeUndefined()
expect(anthropicOAuthHeaders.authorization).toBe("Bearer anthropic-token")
expect(anthropicOAuthHeaders["x-api-key"]).toBeUndefined()
expect(anthropicEmptyKeyHeaders["x-api-key"]).toBe("environment-key")
expect(anthropicEmptyKeyHeaders.authorization).toBeUndefined()
expect(azureKeyHeaders["api-key"]).toBe("azure-key")
expect(azureKeyHeaders.authorization).toBeUndefined()
expect(azureOAuthHeaders.authorization).toBe("Bearer azure-token")
expect(azureOAuthHeaders["api-key"]).toBeUndefined()
expect(googleKeyHeaders["x-goog-api-key"]).toBe("google-key")
expect(googleKeyHeaders.authorization).toBeUndefined()
expect(googleOAuthHeaders.authorization).toBe("Bearer google-token")
expect(googleOAuthHeaders["x-goog-api-key"]).toBeUndefined()
expect(vertexKeyHeaders["x-goog-api-key"]).toBe("vertex-key")
expect(vertexKeyHeaders.authorization).toBeUndefined()
expect(vertexOAuthHeaders.authorization).toBe("Bearer vertex-token")
expect(vertexOAuthHeaders["x-goog-api-key"]).toBeUndefined()
expect((await authHeaders(vertexChatOAuth)).authorization).toBe("Bearer vertex-chat-token")
})
test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
const selected = OpenAICompatibleResponses.model("custom-model", {
apiKey: "fixture",
baseURL: "https://responses.example.test/v1",
provider: "example",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
providerOptions: { reasoningEffort: "low", store: true },
})
const selected = OpenAICompatibleResponses.model(
packageInput("custom-model", {
apiKey: "fixture",
baseURL: "https://responses.example.test/v1",
provider: "example",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
providerOptions: { reasoningEffort: "low", store: true },
}),
)
expect(String(selected.provider)).toBe("example")
expect(selected.route.id).toBe("openai-compatible-responses")
@@ -106,15 +295,17 @@ describe("provider package entrypoints", () => {
test("maps Anthropic-compatible settings onto the executable model", async () => {
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
const selected = AnthropicCompatible.model("compatible-model", {
apiKey: "fixture",
baseURL: "https://messages.example.test/v1",
provider: "example",
headers: { "x-application": "opencode" },
body: { metadata: { user_id: "user_1" } },
limits: { context: 200_000, output: 64_000 },
providerOptions: { effort: "low" },
})
const selected = AnthropicCompatible.model(
packageInput("compatible-model", {
apiKey: "fixture",
baseURL: "https://messages.example.test/v1",
provider: "example",
headers: { "x-application": "opencode" },
body: { metadata: { user_id: "user_1" } },
limits: { context: 200_000, output: 64_000 },
providerOptions: { effort: "low" },
}),
)
expect(String(selected.provider)).toBe("example")
expect(selected.route.id).toBe("anthropic-messages")
@@ -130,10 +321,12 @@ describe("provider package entrypoints", () => {
test("maps Anthropic provider options onto the executable model", async () => {
const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
const selected = Anthropic.model("claude-sonnet-4-6", {
apiKey: "fixture",
providerOptions: { thinking: { type: "adaptive" } },
})
const selected = Anthropic.model(
packageInput("claude-sonnet-4-6", {
apiKey: "fixture",
providerOptions: { thinking: { type: "adaptive" } },
}),
)
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
})
@@ -141,7 +334,7 @@ describe("provider package entrypoints", () => {
test("requires an Anthropic-compatible base URL at runtime", async () => {
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
expect(() =>
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
Reflect.apply(AnthropicCompatible.model, undefined, [packageInput("compatible-model", { apiKey: "fixture" })]),
).toThrow("Anthropic-compatible providers require a baseURL")
})
@@ -150,25 +343,28 @@ describe("provider package entrypoints", () => {
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
expect(() =>
Reflect.apply(AnthropicCompatible.model, undefined, [
"compatible-model",
{
packageInput("compatible-model", {
apiKey: "fixture",
authToken: "token",
baseURL: "https://messages.example.test/v1",
},
}),
]),
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
expect(() =>
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
Reflect.apply(Anthropic.model, undefined, [
packageInput("claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }),
]),
).toThrow("Anthropic apiKey cannot be combined with authToken")
})
test("maps legacy OpenAI organization and project settings to headers", () => {
const selected = model("gpt-5", {
apiKey: "fixture",
organization: "org_123",
project: "proj_123",
})
const selected = model(
packageInput("gpt-5", {
apiKey: "fixture",
organization: "org_123",
project: "proj_123",
}),
)
expect(selected.route.defaults.headers).toMatchObject({
"OpenAI-Organization": "org_123",
@@ -188,10 +384,10 @@ describe("provider package entrypoints", () => {
limits: { context: 200_000, output: 64_000 },
}
const responses = AzureResponses.model("deployment", settings)
const chat = AzureChat.model("deployment", settings)
const responses = AzureResponses.model(packageInput("deployment", settings))
const chat = AzureChat.model(packageInput("deployment", settings))
expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses")
expect(Azure.model(packageInput("deployment", settings)).route.id).toBe("azure-openai-responses")
expect(responses.route.id).toBe("azure-openai-responses")
expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
@@ -202,16 +398,20 @@ describe("provider package entrypoints", () => {
test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => {
const Azure = await import("@opencode-ai/ai/providers/azure")
const deployment = Azure.model("custom-deployment", {
apiKey: "fixture",
resourceName: "opencode-test",
apiVersion: "2025-01-01-preview",
useDeploymentBasedUrls: true,
})
const gateway = Azure.model("gateway-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/azure/",
})
const deployment = Azure.model(
packageInput("custom-deployment", {
apiKey: "fixture",
resourceName: "opencode-test",
apiVersion: "2025-01-01-preview",
useDeploymentBasedUrls: true,
}),
)
const gateway = Azure.model(
packageInput("gateway-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/azure/",
}),
)
expect(deployment.route.endpoint).toMatchObject({
baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment",
@@ -223,14 +423,16 @@ describe("provider package entrypoints", () => {
test("maps Google package settings onto the Gemini model", async () => {
const Google = await import("@opencode-ai/ai/providers/google")
const selected = Google.model("gemini-2.5-flash", {
apiKey: "fixture",
baseURL: "https://generativelanguage.test/v1beta",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
})
const selected = Google.model(
packageInput("gemini-2.5-flash", {
apiKey: "fixture",
baseURL: "https://generativelanguage.test/v1beta",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
}),
)
expect(selected.route.id).toBe("gemini")
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
@@ -246,27 +448,35 @@ describe("provider package entrypoints", () => {
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses")
const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages")
const gemini = GoogleVertex.model("gemini-3.5-flash", {
apiKey: "fixture",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
})
const messages = GoogleVertexMessages.model("claude-sonnet-4-6", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
})
const chat = GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
})
const responses = GoogleVertexResponses.model("xai/grok-4.20-reasoning", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
})
const gemini = GoogleVertex.model(
packageInput("gemini-3.5-flash", {
apiKey: "fixture",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
}),
)
const messages = GoogleVertexMessages.model(
packageInput("claude-sonnet-4-6", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
}),
)
const chat = GoogleVertexChat.model(
packageInput("deepseek-ai/deepseek-v3.2-maas", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
}),
)
const responses = GoogleVertexResponses.model(
packageInput("xai/grok-4.20-reasoning", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
}),
)
expect(GoogleVertexGemini.model).toBe(GoogleVertex.model)
expect(gemini.route.id).toBe("google-vertex-gemini")
@@ -276,11 +486,13 @@ describe("provider package entrypoints", () => {
expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] })
expect(gemini.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
expect(
GoogleVertex.model("gemini-3.5-flash", {
accessToken: "fixture",
location: "eu",
project: "vertex-project",
}).route.endpoint.baseURL,
GoogleVertex.model(
packageInput("gemini-3.5-flash", {
accessToken: "fixture",
location: "eu",
project: "vertex-project",
}),
).route.endpoint.baseURL,
).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google")
expect(messages.route.id).toBe("google-vertex-messages")
expect(messages.route.protocol).toBe("anthropic-messages")
@@ -310,8 +522,11 @@ describe("provider package entrypoints", () => {
const Providers = await import("@opencode-ai/ai/providers")
expect(() =>
Reflect.apply(GoogleVertex.model, undefined, [
"gemini-3.5-flash",
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
packageInput("gemini-3.5-flash", {
accessToken: "token",
apiKey: "fixture",
project: "vertex-project",
}),
]),
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
@@ -320,8 +535,7 @@ describe("provider package entrypoints", () => {
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
expect(() =>
Reflect.apply(GoogleVertexMessages.model, undefined, [
"claude-sonnet-4-6",
{ apiKey: "fixture", project: "vertex-project" },
packageInput("claude-sonnet-4-6", { apiKey: "fixture", project: "vertex-project" }),
]),
).toThrow("Google Vertex Messages does not support API keys")
expect(() =>
@@ -331,8 +545,7 @@ describe("provider package entrypoints", () => {
).toThrow("Google Vertex Messages does not support API keys")
expect(() =>
Reflect.apply(GoogleVertexChat.model, undefined, [
"deepseek-ai/deepseek-v3.2-maas",
{ apiKey: "fixture", project: "vertex-project" },
packageInput("deepseek-ai/deepseek-v3.2-maas", { apiKey: "fixture", project: "vertex-project" }),
]),
).toThrow("Google Vertex Chat does not support API keys")
expect(() =>
@@ -342,8 +555,7 @@ describe("provider package entrypoints", () => {
).toThrow("Google Vertex Chat does not support API keys")
expect(() =>
Reflect.apply(GoogleVertexResponses.model, undefined, [
"xai/grok-4.20-reasoning",
{ apiKey: "fixture", project: "vertex-project" },
packageInput("xai/grok-4.20-reasoning", { apiKey: "fixture", project: "vertex-project" }),
]),
).toThrow("Google Vertex Responses does not support API keys")
expect(() =>
@@ -179,7 +179,7 @@ test.describe("timeline adverse visual stability", () => {
userMessage(),
assistantMessage([
shell(shellID, "completed", wideLines(15)),
toolPart(contextIDs[0]!, "read", "completed", { path: "src/a.ts" }),
toolPart(contextIDs[0]!, "read", "completed", { filePath: "src/a.ts" }),
toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
textPart(followingID, "Following responsive timeline content that wraps on narrow screens."),
]),
@@ -1,5 +1,4 @@
import { test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import {
defineVisualRegions,
reportVisualStability,
@@ -19,20 +18,16 @@ import {
} from "./fixture"
const profiles = [
{
name: "edit",
tool: "edit",
input: { path: "src/edit.ts", oldString: "export const value = 1", newString: "export const value = 2" },
},
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
{
name: "multi patch",
tool: "patch",
input: { patchText: "Update generated files" },
tool: "apply_patch",
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
},
] as const
for (const profile of profiles) {
test(`stabilizes ${profile.name} streaming to completed`, async ({ page }, testInfo) => {
test(`stabilizes ${profile.name} pending to completed`, async ({ page }, testInfo) => {
const partID = `prt_file_matrix_${profiles.indexOf(profile)}`
const followingID = `prt_file_matrix_following_${profiles.indexOf(profile)}`
const timeline = await setupTimeline(page, {
@@ -40,7 +35,7 @@ for (const profile of profiles) {
userMessage(),
assistantMessage(
[
toolPart(partID, profile.tool, "streaming", profile.input),
toolPart(partID, profile.tool, "pending", profile.input),
textPart(followingID, `Following ${profile.name}`),
],
{ completed: false },
@@ -94,27 +89,34 @@ function completedPart(partID: string, profile: (typeof profiles)[number]) {
if (profile.tool === "edit") {
return toolPart(partID, profile.tool, "completed", profile.input, {
metadata: {
files: [patchFile("src/edit.ts", "modified", 50)],
filediff: {
file: "src/edit.ts",
additions: 50,
deletions: 50,
before: source(50, false),
after: source(50, true),
},
},
})
}
const files = [
patchFile("src/a.ts", "modified", 20),
patchFile("src/b.ts", "added", 20),
patchFile("src/old.ts", "deleted", 20),
patchFile("src/a.ts", "update"),
patchFile("src/b.ts", "add"),
patchFile("src/old.ts", "delete"),
{ ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
]
return toolPart(partID, profile.tool, "completed", profile.input, { metadata: { files } })
}
function patchFile(file: string, status: "added" | "modified" | "deleted", lines: number) {
const before = status === "added" ? "" : source(lines, false)
const after = status === "deleted" ? "" : source(lines, true)
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
return {
file,
status,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
additions: status === "deleted" ? 0 : lines,
deletions: status === "added" ? 0 : lines,
filePath,
relativePath: filePath,
type,
additions: type === "delete" ? 0 : 20,
deletions: type === "add" ? 0 : 20,
before: type === "add" ? undefined : source(20, false),
after: type === "delete" ? undefined : source(20, true),
}
}
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import {
defineVisualRegions,
reportVisualStability,
@@ -21,13 +20,13 @@ import {
test("adds patch files incrementally without resetting outer expansion", async ({ page }, testInfo) => {
const patchID = "prt_incremental_01_patch"
const followingID = "prt_incremental_02_following"
const first = patchFile("src/a.ts", "modified")
const first = patchFile("src/a.ts", "update")
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
toolPart(patchID, "patch", "running", { patchText: "Update files" }, { metadata: { files: [first] } }),
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
textPart(followingID, "Following incremental patch"),
],
{ completed: false },
@@ -56,15 +55,15 @@ test("adds patch files incrementally without resetting outer expansion", async (
},
})
await startVisualProbe(page, regions)
const second = patchFile("src/b.ts", "added")
const third = patchFile("src/old.ts", "deleted")
const second = patchFile("src/b.ts", "add")
const third = patchFile("src/old.ts", "delete")
await timeline.send(
partUpdated(
toolPart(
patchID,
"patch",
"apply_patch",
"running",
{ patchText: "Update files" },
{ files: [first.filePath, second.filePath] },
{ metadata: { files: [first, second] } },
),
),
@@ -74,9 +73,9 @@ test("adds patch files incrementally without resetting outer expansion", async (
partUpdated(
toolPart(
patchID,
"patch",
"apply_patch",
"completed",
{ patchText: "Update files" },
{ files: [first.filePath, second.filePath, third.filePath] },
{ metadata: { files: [first, second, third] } },
),
),
@@ -107,15 +106,15 @@ test("adds patch files incrementally without resetting outer expansion", async (
await expect(page.locator('[data-scope="apply-patch"] [data-type="delete"]')).toBeVisible()
})
function patchFile(file: string, status: "added" | "modified" | "deleted") {
const before = status === "added" ? "" : source(false)
const after = status === "deleted" ? "" : source(true)
function patchFile(filePath: string, type: "add" | "update" | "delete") {
return {
file,
status,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
additions: status === "deleted" ? 0 : 4,
deletions: status === "added" ? 0 : 3,
filePath,
relativePath: filePath,
type,
additions: type === "delete" ? 0 : 4,
deletions: type === "add" ? 0 : 3,
before: type === "add" ? undefined : source(false),
after: type === "delete" ? undefined : source(true),
}
}
@@ -35,7 +35,7 @@ describe("timeline fixture validation", () => {
userMessage(),
{
...assistantMessage(),
content: [{ type: "tool", id: "call_invalid", name: "shell", state: { status: "completed" } }],
content: [{ type: "tool", id: "call_invalid", name: "bash", state: { status: "completed" } }],
} as never,
]),
).toThrow()
@@ -60,11 +60,12 @@ if (false) {
const userSeed = { id: "prt_type_user", type: "text", text: "typed" } satisfies PartSeed<"user">
userMessage([userSeed])
// @ts-expect-error Tool completion fields are not valid while streaming.
toolPart("prt_invalid_streaming", "shell", "streaming", {}, { output: "impossible" })
toolPart("prt_valid_running", "shell", "running", {}, { output: "progressive output" })
// @ts-expect-error Tool completion fields are not valid while pending.
toolPart("prt_invalid_pending", "bash", "pending", {}, { output: "impossible" })
// @ts-expect-error Tool completion fields are not valid while running.
toolPart("prt_invalid_running", "bash", "running", {}, { output: "impossible" })
// @ts-expect-error Tool error fields are not valid after completion.
toolPart("prt_invalid_completed", "shell", "completed", {}, { error: "impossible" })
toolPart("prt_invalid_completed", "bash", "completed", {}, { error: "impossible" })
assistantMessage([
// @ts-expect-error Agent references belong to user messages, not assistant messages.
@@ -60,17 +60,17 @@ type ReasoningSeed = {
type ToolSeed = {
id: string
type: "tool"
name: string
callID: string
tool: string
messageID?: string
executed?: boolean
providerState?: Record<string, unknown>
providerResultState?: Record<string, unknown>
state:
| { status: "streaming"; input: Record<string, unknown>; raw: string }
| { status: "pending"; input: Record<string, unknown>; raw: string }
| {
status: "running"
input: Record<string, unknown>
output?: string
title?: string
metadata: Record<string, unknown>
time: { start: number }
@@ -100,10 +100,10 @@ export type PartSeed<Owner extends "user" | "assistant"> = Owner extends "user"
? TextSeed | FileSeed | AgentSeed
: TextSeed | ReasoningSeed | ToolSeed
type ToolOptions<State extends ToolStatus> = State extends "streaming"
type ToolOptions<State extends ToolStatus> = State extends "pending"
? { output?: never; title?: never; metadata?: never; error?: never }
: State extends "running"
? { title?: string; metadata?: Record<string, unknown>; output?: string; error?: never }
? { title?: string; metadata?: Record<string, unknown>; output?: never; error?: never }
: State extends "error"
? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
: { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
@@ -371,15 +371,6 @@ export function partUpdated(part: PartSeed<"assistant">): readonly OpenCodeEvent
}
if (part.type === "reasoning") {
startedParts.add(part.id)
if (!started && !part.text)
return [
makeEvent("session.reasoning.started", {
sessionID,
assistantMessageID: messageID,
ordinal: ref.ordinal!,
state: jsonRecord(part.metadata),
}),
]
return [
...(started
? []
@@ -551,9 +542,9 @@ export function reasoningPart(id: string, text: string): ReasoningSeed {
export function toolPart(
id: string,
tool: string,
state: "streaming",
state: "pending",
input: Record<string, unknown>,
options?: ToolOptions<"streaming">,
options?: ToolOptions<"pending">,
): ToolSeed
export function toolPart(
id: string,
@@ -583,15 +574,14 @@ export function toolPart(
input: Record<string, unknown>,
options: ToolOptions<ToolStatus> = {},
): ToolSeed {
const base = { id, type: "tool" as const, name: tool }
if (state === "streaming") return { ...base, state: { status: state, input, raw: "" } }
const base = { id, type: "tool" as const, callID: id, tool }
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
if (state === "running")
return {
...base,
state: {
status: state,
input,
...(options.output === undefined ? {} : { output: options.output }),
title: options.title,
metadata: options.metadata ?? {},
time: { start: 1700000001000 },
@@ -622,10 +612,12 @@ export function toolPart(
}
export function shell(id: string, state: ToolStatus, output = "", command = `echo ${id}`): ToolSeed {
if (state === "streaming") return toolPart(id, "shell", state, { command })
if (state === "running") return toolPart(id, "shell", state, { command }, { title: command, output })
if (state === "error") return toolPart(id, "shell", state, { command }, { error: output || undefined })
return toolPart(id, "shell", state, { command }, { title: command, output })
if (state === "pending") return toolPart(id, "bash", state, { command })
if (state === "running")
return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
if (state === "error")
return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } })
return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
}
export function completedAssistantInfo(info: SessionMessageAssistant): SessionMessageAssistant {
@@ -663,7 +655,7 @@ function messageContent(
): SessionMessageAssistant["content"][number] {
if (part.type === "tool") {
partRefs.set(part.id, { messageID, type: part.type })
toolStates.set(part.id, part.state.status)
toolStates.set(part.callID, part.state.status)
} else {
partRefs.set(part.id, { messageID, type: part.type, ordinal: ordinals[part.type]++ })
startedParts.add(part.id)
@@ -683,8 +675,8 @@ function messageContent(
const completed = state.status === "completed" || state.status === "error" ? state.time.end : undefined
const base = {
type: "tool" as const,
id: part.id,
name: part.name,
id: part.callID,
name: part.tool,
time: {
created: time?.start ?? 1700000001000,
...(time?.start === undefined ? {} : { ran: time.start }),
@@ -694,18 +686,11 @@ function messageContent(
...(part.providerState ? { providerState: jsonRecord(part.providerState) } : {}),
...(part.providerResultState ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
}
if (state.status === "streaming") return { ...base, state: { status: "streaming", input: state.raw } }
if (state.status === "pending") return { ...base, state: { status: "streaming", input: state.raw } }
if (state.status === "running")
return {
...base,
state: {
status: "running",
input: jsonRecord(state.input),
metadata: jsonRecord({
...state.metadata,
...(state.output === undefined ? {} : { output: state.output }),
}),
},
state: { status: "running", input: jsonRecord(state.input), metadata: jsonRecord(state.metadata) },
}
if (state.status === "error")
return {
@@ -729,7 +714,7 @@ function messageContent(
}
function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[] {
const previous = toolStates.get(part.id)
const previous = toolStates.get(part.callID)
if (previous === "completed" || previous === "error") return []
const events: OpenCodeEvent[] = []
@@ -738,27 +723,27 @@ function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[]
makeEvent("session.tool.input.started", {
sessionID,
assistantMessageID: messageID,
id: part.id,
name: part.name,
id: part.callID,
name: part.tool,
}),
)
}
if (part.state.status === "streaming") {
toolStates.set(part.id, part.state.status)
if (part.state.status === "pending") {
toolStates.set(part.callID, part.state.status)
return events
}
if (!previous || previous === "streaming") {
if (!previous || previous === "pending") {
events.push(
makeEvent("session.tool.input.ended", {
sessionID,
assistantMessageID: messageID,
id: part.id,
id: part.callID,
text: JSON.stringify(part.state.input),
}),
makeEvent("session.tool.called", {
sessionID,
assistantMessageID: messageID,
id: part.id,
id: part.callID,
input: part.state.input,
executed: part.executed ?? true,
state: jsonRecord(part.providerState),
@@ -766,20 +751,16 @@ function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[]
)
}
if (part.state.status === "running") {
const metadata = {
...part.state.metadata,
...(part.state.output === undefined ? {} : { output: part.state.output }),
}
if (previous === "running" || Object.keys(metadata).length)
if (previous === "running" || Object.keys(part.state.metadata).length)
events.push(
makeEvent("session.tool.progress", {
sessionID,
assistantMessageID: messageID,
id: part.id,
metadata: jsonRecord(metadata),
id: part.callID,
metadata: jsonRecord(part.state.metadata),
}),
)
toolStates.set(part.id, part.state.status)
toolStates.set(part.callID, part.state.status)
return events
}
if (part.state.status === "error") {
@@ -787,28 +768,28 @@ function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[]
makeEvent("session.tool.failed", {
sessionID,
assistantMessageID: messageID,
id: part.id,
id: part.callID,
error: { type: "ToolError", message: part.state.error },
metadata: jsonRecord(part.state.metadata),
executed: part.executed ?? true,
resultState: jsonRecord(part.providerResultState),
}),
)
toolStates.set(part.id, part.state.status)
toolStates.set(part.callID, part.state.status)
return events
}
events.push(
makeEvent("session.tool.success", {
sessionID,
assistantMessageID: messageID,
id: part.id,
id: part.callID,
content: [{ type: "text", text: part.state.output }],
metadata: jsonRecord(part.state.metadata),
executed: part.executed ?? true,
resultState: jsonRecord(part.providerResultState),
}),
)
toolStates.set(part.id, part.state.status)
toolStates.set(part.callID, part.state.status)
return events
}
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import {
defineVisualRegions,
reportVisualStability,
@@ -59,14 +58,14 @@ test("expands and collapses a long completed shell without overlap", async ({ pa
await startVisualProbe(page, regions)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await waitForVisualSettle(page, [regions.shell.selector, regions.following.selector])
await page.waitForTimeout(500)
const expanded = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, "shell-expand", expanded, plan)
await startVisualProbe(page, regions)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await waitForVisualSettle(page, [regions.shell.selector, regions.following.selector])
await page.waitForTimeout(500)
const collapsed = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, "shell-collapse", collapsed, plan)
})
@@ -84,7 +83,7 @@ test("expands and collapses a completed context group without overlap", async ({
messages: [
userMessage(),
assistantMessage([
toolPart(ids[0]!, "read", "completed", { path: "src/a.ts" }),
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart(ids[2]!, "grep", "completed", { path: ".", pattern: "stable" }),
toolPart(ids[3]!, "list", "completed", { path: "src" }),
@@ -111,7 +110,7 @@ test("expands and collapses a completed context group without overlap", async ({
await startVisualProbe(page, regions)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await waitForVisualSettle(page, [regions.context.selector, regions.following.selector])
await page.waitForTimeout(500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
@@ -143,23 +142,16 @@ test("expands and collapses an edit diff without moving twice", async ({ page },
editID,
"edit",
"completed",
{ path: "src/edit.ts", oldString: "export const value = 1", newString: "export const value = 2" },
{ filePath: "src/edit.ts" },
{
metadata: {
files: [
{
file: "src/edit.ts",
patch: createTwoFilesPatch(
"a/src/edit.ts",
"b/src/edit.ts",
source(40, false),
source(40, true),
),
additions: 40,
deletions: 40,
status: "modified",
},
],
filediff: {
file: "src/edit.ts",
additions: 40,
deletions: 40,
before: source(40, false),
after: source(40, true),
},
},
},
),
@@ -190,7 +182,7 @@ test("expands and collapses an edit diff without moving twice", async ({ page },
await startVisualProbe(page, regions)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await waitForVisualSettle(page, [regions.edit.selector, regions.following.selector])
await page.waitForTimeout(900)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
@@ -17,32 +17,32 @@ import {
userMessage,
} from "./fixture"
test("adds a subagent child-session link without replacing the row", async ({ page }, testInfo) => {
const taskID = "prt_subagent_link"
const childID = "ses_subagent_child"
const input = { description: "Inspect child", agent: "explore", prompt: "Inspect the child Session." }
test("adds a task child-session link without replacing the task row", async ({ page }, testInfo) => {
const taskID = "prt_task_link"
const childID = "ses_task_child"
const input = { description: "Inspect child", subagent_type: "explore" }
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([toolPart(taskID, "subagent", "running", input)], { completed: false })],
messages: [userMessage(), assistantMessage([toolPart(taskID, "task", "running", input)], { completed: false })],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect child" })],
cpuRate: 4,
})
const regions = defineVisualRegions({
subagent: { selector: `[data-timeline-part-id="${renderedPartID(taskID)}"] [data-slot="collapsible-trigger"]` },
task: { selector: `[data-timeline-part-id="${renderedPartID(taskID)}"] [data-slot="collapsible-trigger"]` },
})
await startVisualProbe(page, regions)
await timeline.send(
partUpdated(toolPart(taskID, "subagent", "completed", input, { metadata: { sessionID: childID } })),
partUpdated(toolPart(taskID, "task", "completed", input, { metadata: { sessionId: childID } })),
500,
)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"subagent-link",
"task-link",
trace,
visualPlan(regions, [
{ type: "required", regions: ["subagent"] },
{ type: "unique", regions: ["subagent"] },
{ type: "stable", regions: ["subagent"] },
{ type: "required", regions: ["task"] },
{ type: "unique", regions: ["task"] },
{ type: "stable", regions: ["task"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
@@ -21,30 +21,24 @@ import {
} from "./fixture"
test.describe("timeline tool state stability", () => {
test("moves lightweight tools through streaming, running, and completed without replacing rows", async ({
test("moves lightweight tools through pending, running, and completed without replacing rows", async ({
page,
}, testInfo) => {
const ids = ["webfetch", "websearch", "subagent", "skill", "custom"] as const
const ids = ["webfetch", "websearch", "task", "skill", "custom"] as const
const inputs = {
webfetch: { url: "https://example.com/docs" },
websearch: { query: "timeline stability" },
subagent: { description: "Inspect timeline", agent: "explore", prompt: "Inspect the timeline." },
task: { description: "Inspect timeline", subagent_type: "explore" },
skill: { name: "stability" },
custom: { target: "timeline", depth: 2 },
}
const names = {
webfetch: "webfetch",
websearch: "websearch",
subagent: "subagent",
skill: "skill",
custom: "mcp_probe",
}
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
const questionID = "prt_state_question"
const todoID = "prt_state_todo"
const initial = [
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "streaming", inputs[id])),
toolPart(questionID, "question", "streaming", questionInput()),
toolPart(todoID, "todowrite", "streaming", { todos: [{ content: "Hidden", status: "pending" }] }),
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
toolPart(questionID, "question", "pending", questionInput()),
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
textPart("prt_state_following", "Following lightweight tools"),
]
const childID = "ses_timeline_child"
@@ -61,14 +55,14 @@ test.describe("timeline tool state stability", () => {
const regionIDs = [
"prt_state_webfetch",
"prt_state_websearch",
"prt_state_subagent",
"prt_state_task",
"prt_state_skill",
"prt_state_custom",
] as const
const regions = defineVisualRegions({
prt_state_webfetch: toolRegion(regionIDs[0]),
prt_state_websearch: toolRegion(regionIDs[1]),
prt_state_subagent: toolRegion(regionIDs[2]),
prt_state_task: toolRegion(regionIDs[2]),
prt_state_skill: toolRegion(regionIDs[3]),
prt_state_custom: toolRegion(regionIDs[4]),
})
@@ -79,9 +73,9 @@ test.describe("timeline tool state stability", () => {
[80, 240, 100, 360, 140][index],
)
}
for (const [index, id] of ["skill", "webfetch", "custom", "subagent", "websearch"].entries()) {
for (const [index, id] of ["skill", "webfetch", "custom", "task", "websearch"].entries()) {
const key = id as (typeof ids)[number]
const metadata = key === "subagent" ? { sessionID: childID } : key === "websearch" ? { provider: "exa" } : {}
const metadata = key === "task" ? { sessionId: childID } : key === "websearch" ? { provider: "exa" } : {}
const output = key === "websearch" ? "Result https://example.com/result" : "Completed"
await timeline.send(
partUpdated(toolPart(`prt_state_${key}`, names[key], "completed", inputs[key], { metadata, output })),
@@ -127,12 +121,12 @@ test.describe("timeline tool state stability", () => {
const ids = ["prt_ctx_01_read", "prt_ctx_02_glob", "prt_ctx_03_grep", "prt_ctx_04_list"]
const tools = ["read", "glob", "grep", "list"]
const inputs = [
{ path: "src/a.ts", offset: 0, limit: 120 },
{ filePath: "src/a.ts", offset: 0, limit: 120 },
{ path: directory, pattern: "**/*.ts" },
{ path: directory, pattern: "stability", include: "*.ts" },
{ path: "src" },
]
const context = ids.map((id, index) => toolPart(id, tools[index]!, "streaming", inputs[index]!))
const context = ids.map((id, index) => toolPart(id, tools[index]!, "pending", inputs[index]!))
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
@@ -4,7 +4,6 @@ import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
import { expect } from "../benchmark"
import { createTwoFilesPatch } from "diff"
const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression"
@@ -27,29 +26,28 @@ const userMessage = {
const editPart: ToolSeed = {
id: editPartID,
sessionID,
messageID: assistantMessageID,
type: "tool",
name: "edit",
callID: "call_edit_regression",
tool: "edit",
state: {
status: "completed",
input: {
path: "src/regression.ts",
oldString: "export const value = 'before'",
newString: "export const value = 'after'",
},
content: [{ type: "text", text: "Edited src/regression.ts" }],
input: { filePath: "src/regression.ts" },
output: "Edited src/regression.ts",
title: "src/regression.ts",
metadata: {
files: [
currentFile(
"src/regression.ts",
"export const value = 'before'\n",
"export const value = 'after'\n",
1,
1,
),
],
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: "export const value = 'before'\n",
after: "export const value = 'after'\n",
},
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
},
time: { start: 1700000001000, end: 1700000002000 },
},
time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 },
}
const assistantMessage = {
@@ -206,20 +204,20 @@ function performanceTurn(index: number) {
? [
{
id: `prt_0000_${suffix}_edit`,
sessionID,
messageID: assistantID,
type: "tool",
name: "edit",
callID: `call_0000_${suffix}_edit`,
tool: "edit",
state: {
status: "completed",
input: { path: `src/history-${index}.ts`, oldString: before, newString: after },
content: [{ type: "text", text: `Edited src/history-${index}.ts` }],
input: { filePath: `src/history-${index}.ts` },
output: `Edited src/history-${index}.ts`,
title: `src/history-${index}.ts`,
metadata: {
files: [currentFile(`src/history-${index}.ts`, before, after, 48, 48)],
filediff: { file: `src/history-${index}.ts`, additions: 48, deletions: 48, before, after },
},
},
time: {
created: 1690000001200 + index * 2_000,
ran: 1690000001200 + index * 2_000,
completed: 1690000001400 + index * 2_000,
time: { start: 1690000001200 + index * 2_000, end: 1690000001400 + index * 2_000 },
},
},
]
@@ -228,18 +226,20 @@ function performanceTurn(index: number) {
? [
{
id: `prt_0000_${suffix}_write`,
sessionID,
messageID: assistantID,
type: "tool",
name: "write",
callID: `call_0000_${suffix}_write`,
tool: "write",
state: {
status: "completed",
input: { path: `src/generated-${index}.tsx`, content: after },
content: [{ type: "text", text: `Wrote src/generated-${index}.tsx` }],
metadata: { files: [currentFile(`src/generated-${index}.tsx`, "", after, 32, 0)] },
},
time: {
created: 1690000001400 + index * 2_000,
ran: 1690000001400 + index * 2_000,
completed: 1690000001500 + index * 2_000,
input: { filePath: `src/generated-${index}.tsx`, content: after },
output: `Wrote src/generated-${index}.tsx`,
title: `src/generated-${index}.tsx`,
metadata: {
filediff: { file: `src/generated-${index}.tsx`, additions: 32, deletions: 0, before: "", after },
},
time: { start: 1690000001400 + index * 2_000, end: 1690000001500 + index * 2_000 },
},
},
]
@@ -248,24 +248,31 @@ function performanceTurn(index: number) {
? [
{
id: `prt_0000_${suffix}_patch`,
sessionID,
messageID: assistantID,
type: "tool",
name: "patch",
callID: `call_0000_${suffix}_patch`,
tool: "apply_patch",
state: {
status: "completed",
input: { patchText: realisticPatch(index) },
content: [{ type: "text", text: "Success. Updated src/components/SessionCard.tsx" }],
output: "Success. Updated src/components/SessionCard.tsx",
title: "src/components/SessionCard.tsx",
metadata: {
files: [
{
...currentFile("src/components/SessionCard.tsx", before, after, 8, 3),
filePath: "src/components/SessionCard.tsx",
relativePath: "src/components/SessionCard.tsx",
type: "update",
additions: 8,
deletions: 3,
patch: realisticPatch(index),
before,
after,
},
],
},
},
time: {
created: 1690000001500 + index * 2_000,
ran: 1690000001500 + index * 2_000,
completed: 1690000001700 + index * 2_000,
time: { start: 1690000001500 + index * 2_000, end: 1690000001700 + index * 2_000 },
},
},
]
@@ -302,16 +309,20 @@ function performanceTurn(index: number) {
}
type ToolSeed = {
id: string
id?: string
sessionID?: string
messageID?: string
type: "tool"
name: string
callID: string
tool: string
state: {
status: "completed"
status: string
input: Record<string, unknown>
content: [{ type: "text"; text: string }]
output: string
title?: string
metadata: Record<string, unknown>
time: { start: number; end: number }
}
time: { created: number; ran: number; completed: number }
}
type ContentSeedBase = { id?: string; sessionID?: string; messageID?: string }
@@ -324,13 +335,13 @@ type ContentSeed =
function toolContent(part: ToolSeed): SessionMessageAssistant["content"][number] {
return {
type: "tool",
id: part.id,
name: part.name,
time: part.time,
id: part.callID,
name: part.tool,
time: { created: part.state.time.start, ran: part.state.time.start, completed: part.state.time.end },
state: {
status: "completed",
input: part.state.input as Record<string, JsonValue>,
content: part.state.content,
content: [{ type: "text", text: part.state.output }],
metadata: part.state.metadata as Record<string, JsonValue>,
},
}
@@ -411,16 +422,6 @@ export function MessageSummary(props: { messages: Message[]; locale: string }) {
`
}
function currentFile(file: string, before: string, after: string, additions: number, deletions: number) {
return {
file,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
additions,
deletions,
status: before ? (after ? "modified" : "deleted") : "added",
}
}
function realisticPatch(index: number) {
return `*** Begin Patch
*** Update File: src/components/SessionCard.tsx
@@ -1,6 +1,4 @@
import type { CDPSession, Page } from "@playwright/test"
import path from "node:path"
import { mkdir, writeFile } from "node:fs/promises"
export async function startTimelineProfile(page: Page, options: { cpuThrottle: number; profileCPU: boolean }) {
const cdp = await page.context().newCDPSession(page)
@@ -14,13 +12,6 @@ export async function startTimelineProfile(page: Page, options: { cpuThrottle: n
async stop() {
if (!options.profileCPU) return
const result = await cdp.send("Profiler.stop")
const directory = process.env.TIMELINE_CPU_PROFILE_DIR
if (directory) {
await mkdir(directory, { recursive: true })
const file = path.join(directory, `${process.env.OPENCODE_PERFORMANCE_RUN_ID ?? "manual"}-timeline.cpuprofile`)
await writeFile(file, JSON.stringify(result.profile))
console.log("timeline cpu profile file", file)
}
const self = new Map<number, number>()
result.profile.samples?.forEach((id, index) => {
const duration = (result.profile.timeDeltas?.[index] ?? 0) / 1_000
@@ -1,5 +1,3 @@
import { createTwoFilesPatch } from "diff"
const words = [
"alpha",
"bravo",
@@ -30,21 +28,22 @@ const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type MessagePart =
| { id: string; type: "text"; text: string }
| { id: string; type: "reasoning"; text: string; time?: { start: number; end?: number } }
| {
id: string
type: "tool"
name: string
state: {
status: "completed"
input: Record<string, unknown>
content: [{ type: "text"; text: string }]
metadata: Record<string, unknown>
}
time: { created: number; ran: number; completed: number }
}
type MessagePart = {
id: string
type: "text" | "reasoning" | "tool"
text?: string
time?: { start: number; end?: number }
callID?: string
tool?: string
state?: {
status: "completed"
input: Record<string, unknown>
output: string
title: unknown
metadata: Record<string, unknown>
time: { start: number; end: number }
}
}
function lorem(seed: number, length: number) {
let out = ""
@@ -103,16 +102,17 @@ function messageContent(part: MessagePart): SessionMessageAssistant["content"][n
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
: undefined,
}
const state = part.state!
return {
type: "tool",
id: part.id,
name: part.name,
time: part.time,
id: part.callID ?? part.id,
name: part.tool!,
time: { created: state.time.start, ran: state.time.start, completed: state.time.end },
state: {
status: "completed",
input: part.state.input as Record<string, JsonValue>,
content: part.state.content,
metadata: part.state.metadata as Record<string, JsonValue>,
input: state.input as Record<string, JsonValue>,
content: [{ type: "text", text: state.output }],
metadata: state.metadata as Record<string, JsonValue>,
},
}
}
@@ -149,46 +149,43 @@ function toolPart(
): MessagePart {
const metadata =
metadataOverride ??
(tool === "patch"
? {
files: [
patchFile(index, "modified"),
patchFile(index + 1, index % 2 === 0 ? "added" : "deleted"),
],
}
(tool === "apply_patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write"
? { files: [fileDiff(String(input.path ?? `src/generated/file-${index}.ts`), index)] }
? {
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
diff: patch(index, outputLength),
preview: patch(index + 1, 420),
}
: tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] }
: {})
return {
id: id(`call_${tool}_${partIndex}`, index),
id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool",
name: tool,
callID: id("call", index * 10 + partIndex),
tool,
state: {
status: "completed",
input,
content: [{ type: "text", text: lorem(index * 23 + partIndex, outputLength) }],
output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
metadata,
},
time: {
created: 1700000000000 + index * 10_000,
ran: 1700000000000 + index * 10_000,
completed: 1700000000000 + index * 10_000 + 400,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
},
}
}
function patchFile(seed: number, status: "added" | "modified" | "deleted") {
const file = `src/generated/patch-${seed}.ts`
const before = status === "added" ? "" : code(seed, 18)
const after = status === "deleted" ? "" : code(seed + 1, 24)
function patchFile(seed: number, type: "add" | "update" | "delete") {
return {
file,
status,
additions: status === "deleted" ? 0 : (seed % 7) + 1,
deletions: status === "added" ? 0 : seed % 4,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
filePath: `src/generated/patch-${seed}.ts`,
relativePath: `src/generated/patch-${seed}.ts`,
type,
additions: (seed % 7) + 1,
deletions: type === "add" ? 0 : seed % 4,
patch: patch(seed, 520),
before: type === "add" ? undefined : code(seed, 18),
after: type === "delete" ? undefined : code(seed + 1, 24),
}
}
@@ -203,13 +200,17 @@ function fileDiff(file: string, seed: number) {
: before.replace("value4", "updatedValue4").replace("value20", "updatedValue20")
return {
file,
status: "modified" as const,
additions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
deletions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
before,
after,
}
}
function patch(seed: number, length: number) {
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
}
function code(seed: number, lines: number, width = 32) {
return Array.from(
{ length: lines },
@@ -224,24 +225,22 @@ function turn(index: number): SessionMessageInfo[] {
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
...(index % 3 === 0
? [
toolPart(index, 0, "read", { path: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
]
: []),
textPart(index, 2, 160 + (index % 6) * 90),
...(index % 4 === 0
? [toolPart(index, 3, "edit", { path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" }, 700)]
: []),
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
...(index % 6 === 0
? [toolPart(index, 7, "write", { path: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)]
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []),
...(index % 7 === 0
? [toolPart(index, 4, "shell", { command: "bun typecheck", description: "Verify generated output" }, 620)]
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
@@ -257,15 +256,7 @@ function turn(index: number): SessionMessageInfo[] {
]
: []),
...(index % 17 === 0
? [
toolPart(
index,
12,
"subagent",
{ description: "Inspect generated fixture", agent: "explore", prompt: "Inspect the fixture." },
160,
),
]
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
: []),
]
return [user, assistantMessage(targetID, index, user.id, parts)]
@@ -281,10 +272,10 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
toolPart(
index + 1000,
1,
"subagent",
{ description: "Inspect child navigation", agent: "explore", prompt: "Inspect child navigation." },
"task",
{ description: "Inspect child navigation", subagent_type: "explore" },
160,
{ sessionID: childID },
{ sessionId: childID },
),
]
: []),
@@ -89,7 +89,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
data: {
id: "permission-background-a",
sessionID: sessionA.id,
action: "shell",
action: "bash",
resources: ["git status"],
metadata: {},
save: [],
@@ -116,7 +116,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
data: {
id: "permission-background-a-child",
sessionID: childSessionA.id,
action: "shell",
action: "bash",
resources: ["git diff"],
metadata: {},
save: [],
@@ -85,7 +85,7 @@ test("shows a pending permission dock", async ({ page }) => {
{
id: "permission-request",
sessionID,
permission: "shell",
permission: "bash",
patterns: ["git status", "git diff"],
metadata: {},
always: [],
@@ -2,7 +2,6 @@ import { expect, test, type Locator, type Page } from "@playwright/test"
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
import { createTwoFilesPatch } from "diff"
const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression"
@@ -41,28 +40,18 @@ const editPart = {
tool: "edit",
state: {
status: "completed",
input: {
path: "src/regression.ts",
oldString: "export const value = 'before'",
newString: "export const value = 'after'",
},
input: { filePath: "src/regression.ts" },
output: "Edited src/regression.ts",
title: "src/regression.ts",
metadata: {
files: [
{
file: "src/regression.ts",
patch: createTwoFilesPatch(
"a/src/regression.ts",
"b/src/regression.ts",
"export const value = 'before'\n",
"export const value = 'after'\n",
),
additions: 1,
deletions: 1,
status: "modified",
},
],
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: "export const value = 'before'\n",
after: "export const value = 'after'\n",
},
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
},
time: { start: 1700000001000, end: 1700000002000 },
},
@@ -160,15 +149,13 @@ test.describe("regression: session timeline local row state", () => {
...editPart.state,
metadata: {
...editPart.state.metadata,
files: [
{
file: "src/regression.ts",
patch: createTwoFilesPatch("a/src/regression.ts", "b/src/regression.ts", lines, after),
additions: 5,
deletions: 5,
status: "modified",
},
],
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: lines,
after,
},
},
},
}
@@ -78,7 +78,7 @@ test.describe("regression: session timeline context group resize", () => {
id("msg_assistant", 10),
["read", "glob", "grep", "list"][index]!,
[
{ path: "src/recent-a.ts" },
{ filePath: "src/recent-a.ts" },
{ path: directory, pattern: "**/*.ts" },
{ path: directory, pattern: "Explored" },
{ path: "src" },
@@ -213,7 +213,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
contextIDs[0]!,
assistantID,
"read",
{ path: "src/recent-a.ts", offset: 0, limit: 120 },
{ filePath: "src/recent-a.ts", offset: 0, limit: 120 },
status,
),
),
@@ -270,7 +270,7 @@ function contextTool(
status,
input,
output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
title: input.path || input.pattern || "completed",
title: input.filePath || input.path || input.pattern || "completed",
metadata: {},
time: { start: 1700000000000, end: 1700000000100 },
},
@@ -10,7 +10,7 @@ import {
test("preserves a collapsed context group through count and status updates", async ({ page }) => {
const ids = ["prt_closed_01_read", "prt_closed_02_glob"]
const inputs = {
read: { path: "src/a.ts", offset: 0, limit: 120 },
read: { filePath: "src/a.ts", offset: 0, limit: 120 },
glob: { path: ".", pattern: "**/*.ts" },
}
const timeline = await setupTimeline(page, {
@@ -7,7 +7,7 @@ test("renders completed write content", async ({ page }) => {
messages: [
userMessage(),
assistantMessage([
toolPart(id, "write", "completed", { path: "src/write.ts", content: "export const written = true\n" }),
toolPart(id, "write", "completed", { filePath: "src/write.ts", content: "export const written = true\n" }),
]),
],
settings: { editToolPartsExpanded: true },
@@ -24,19 +24,20 @@ test("renders a completed single-file patch", async ({ page }) => {
assistantMessage([
toolPart(
id,
"patch",
"apply_patch",
"completed",
{ patchText: "Update src/a.ts" },
{ files: ["src/a.ts"] },
{
metadata: {
files: [
{
file: "src/a.ts",
status: "modified",
patch:
"diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-export const value = 1\n+export const value = 2\n",
filePath: "src/a.ts",
relativePath: "src/a.ts",
type: "update",
additions: 1,
deletions: 1,
before: "export const value = 1\n",
after: "export const value = 2\n",
},
],
},
@@ -1,19 +1,18 @@
import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
import { createTwoFilesPatch } from "diff"
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
const patchID = "prt_nested_patch"
const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")]
const files = [patchFile("src/a.ts", "update"), patchFile("src/b.ts", "add"), patchFile("src/old.ts", "delete")]
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
patchID,
"patch",
"apply_patch",
"completed",
{ patchText: "Update three files" },
{ files: files.map((file) => file.filePath) },
{ metadata: { files } },
),
]),
@@ -32,15 +31,15 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
})
function patchFile(file: string, status: "added" | "modified" | "deleted") {
const before = status === "added" ? "" : source(false)
const after = status === "deleted" ? "" : source(true)
function patchFile(filePath: string, type: "add" | "update" | "delete") {
return {
file,
status,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
additions: status === "deleted" ? 0 : 4,
deletions: status === "added" ? 0 : 3,
filePath,
relativePath: filePath,
type,
additions: type === "delete" ? 0 : 4,
deletions: type === "add" ? 0 : 3,
before: type === "add" ? undefined : source(false),
after: type === "delete" ? undefined : source(true),
}
}
@@ -11,7 +11,7 @@ for (const profile of [
messages: [
userMessage(),
assistantMessage([
toolPart(ids[0]!, "read", "completed", { path: "src/a.ts" }),
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
]),
],
@@ -12,7 +12,7 @@ import {
test.describe("session timeline projection", () => {
test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => {
const parts = [
toolPart("prt_01_read", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_01_read", "read", "completed", { filePath: "src/a.ts" }),
toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }),
toolPart("prt_04_list", "list", "completed", { path: "src" }),
@@ -24,20 +24,16 @@ test.describe("session timeline projection", () => {
{ query: "timeline stability" },
{ output: "https://example.com/result" },
),
toolPart("prt_task", "subagent", "completed", {
description: "Inspect timeline",
agent: "explore",
prompt: "Inspect the timeline implementation.",
}),
toolPart("prt_task", "task", "completed", { description: "Inspect timeline", subagent_type: "explore" }),
toolPart(
"prt_bash",
"shell",
"bash",
"completed",
{ command: "printf stable" },
{ output: "stable", title: "printf stable" },
),
editPart("prt_edit"),
toolPart("prt_write", "write", "completed", { path: "src/new.ts", content: "export const stable = true\n" }),
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
patchPart("prt_patch"),
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
toolPart(
@@ -179,10 +175,16 @@ function editPart(id: string) {
id,
"edit",
"completed",
{ path: "src/a.ts", oldString: "export const value = 1", newString: "export const value = 2" },
{ filePath: "src/a.ts" },
{
metadata: {
files: [patchFile("src/a.ts", "modified")],
filediff: {
file: "src/a.ts",
additions: 1,
deletions: 1,
before: "export const value = 1\n",
after: "export const value = 2\n",
},
},
},
)
@@ -191,33 +193,31 @@ function editPart(id: string) {
function patchPart(id: string) {
return toolPart(
id,
"patch",
"apply_patch",
"completed",
{ patchText: "Update the projected files" },
{ files: ["src/a.ts", "src/b.ts"] },
{
metadata: {
files: [
patchFile("src/a.ts", "modified"),
patchFile("src/b.ts", "added"),
patchFile("src/old.ts", "deleted"),
patchFile("src/a.ts", "update"),
patchFile("src/b.ts", "add"),
patchFile("src/old.ts", "delete"),
{ ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
],
},
},
)
}
function patchFile(file: string, status: "added" | "modified" | "deleted") {
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
return {
file,
status,
patch:
status === "added"
? "@@ -0,0 +1 @@\n+export const after = true"
: status === "deleted"
? "@@ -1 +0,0 @@\n-export const before = true"
: "@@ -1 +1 @@\n-export const before = true\n+export const after = true",
additions: status === "deleted" ? 0 : 1,
deletions: status === "added" ? 0 : 1,
filePath,
relativePath: filePath,
type,
additions: type === "delete" ? 0 : 1,
deletions: type === "add" ? 0 : 1,
before: type === "add" ? undefined : "export const before = true\n",
after: type === "delete" ? undefined : "export const after = true\n",
}
}
@@ -15,7 +15,7 @@ import {
test("groups singleton and separated context operations at correct boundaries", async ({ page }) => {
const parts = [
toolPart("prt_boundary_01_read", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_boundary_01_read", "read", "completed", { filePath: "src/a.ts" }),
textPart("prt_boundary_02_text", "Boundary text"),
toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }),
@@ -67,18 +67,19 @@ for (const deviceScaleFactor of [1.25, 1.5]) {
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
const patchID = "prt_patch_outline"
const file = {
file: "src/outline.ts",
status: "modified",
patch:
"diff --git a/src/outline.ts b/src/outline.ts\n--- a/src/outline.ts\n+++ b/src/outline.ts\n@@ -1 +1 @@\n-const outline = false\n+const outline = true\n",
filePath: "src/outline.ts",
relativePath: "src/outline.ts",
type: "update",
additions: 1,
deletions: 1,
before: "const outline = false\n",
after: "const outline = true\n",
}
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(patchID, "patch", "completed", { patchText: "Update src/outline.ts" }, { metadata: { files: [file] } }),
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
]),
],
settings: { editToolPartsExpanded: true },
@@ -8,7 +8,7 @@ import {
} from "../performance/timeline-stability/fixture"
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
const ordinary = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
const parts = ordinary.map((tool, index) =>
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
)
@@ -37,8 +37,8 @@ test("transitions shell and question through running error outcomes", async ({ p
userMessage(),
assistantMessage(
[
toolPart(shellID, "shell", "streaming", { command: "exit 1" }),
toolPart(questionID, "question", "streaming", questionInput()),
toolPart(shellID, "bash", "pending", { command: "exit 1" }),
toolPart(questionID, "question", "pending", questionInput()),
],
{ completed: false },
),
@@ -46,11 +46,11 @@ test("transitions shell and question through running error outcomes", async ({ p
})
await timeline.waitForPart(shellID)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120)
await timeline.send(partUpdated(toolPart(shellID, "bash", "running", { command: "exit 1" })), 120)
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(
partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })),
partUpdated(toolPart(shellID, "bash", "error", { command: "exit 1" }, { error: "Command exited 1" })),
180,
)
await timeline.send(
@@ -147,13 +147,12 @@ function questionInput() {
}
function errorInput(tool: string) {
if (tool === "shell") return { command: "exit 1" }
if (["edit", "write"].includes(tool)) return { path: "src/error.ts", content: "" }
if (tool === "patch") return { patchText: "Update src/error.ts" }
if (tool === "bash") return { command: "exit 1" }
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
if (tool === "apply_patch") return { files: ["src/error.ts"] }
if (tool === "webfetch") return { url: "https://example.com" }
if (tool === "websearch") return { query: "failure" }
if (tool === "subagent")
return { description: "Fail subagent", agent: "explore", prompt: "Inspect the failure." }
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
if (tool === "skill") return { name: "failure" }
return { target: "failure" }
}
@@ -167,14 +167,14 @@ function parentMessages(): SessionMessageInfo[] {
content: [
{
type: "tool",
id: "call_subagent_0001",
name: "subagent",
id: "call_task_0001",
name: "task",
time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 },
state: {
status: "completed",
input: { description: taskDescription, agent: "explore", prompt: "Inspect the delegated work." },
input: { description: taskDescription, subagent_type: "explore" },
content: [{ type: "text", text: "Subagent finished" }],
metadata: { sessionID: childID },
metadata: { sessionId: childID },
},
},
],
@@ -1,5 +1,3 @@
import { createTwoFilesPatch } from "diff"
const words = [
"alpha",
"bravo",
@@ -30,21 +28,22 @@ const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type MessagePart =
| { id: string; type: "text"; text: string }
| { id: string; type: "reasoning"; text: string; time?: { start: number; end?: number } }
| {
id: string
type: "tool"
name: string
state: {
status: "completed"
input: Record<string, unknown>
content: [{ type: "text"; text: string }]
metadata: Record<string, unknown>
}
time: { created: number; ran: number; completed: number }
}
type MessagePart = {
id: string
type: "text" | "reasoning" | "tool"
text?: string
time?: { start: number; end?: number }
callID?: string
tool?: string
state?: {
status: "completed"
input: Record<string, unknown>
output: string
title: unknown
metadata: Record<string, unknown>
time: { start: number; end: number }
}
}
function lorem(seed: number, length: number) {
let out = ""
@@ -103,16 +102,17 @@ function messageContent(part: MessagePart): SessionMessageAssistant["content"][n
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
: undefined,
}
const state = part.state!
return {
type: "tool",
id: part.id,
name: part.name,
time: part.time,
id: part.callID ?? part.id,
name: part.tool!,
time: { created: state.time.start, ran: state.time.start, completed: state.time.end },
state: {
status: "completed",
input: part.state.input as Record<string, JsonValue>,
content: part.state.content,
metadata: part.state.metadata as Record<string, JsonValue>,
input: state.input as Record<string, JsonValue>,
content: [{ type: "text", text: state.output }],
metadata: state.metadata as Record<string, JsonValue>,
},
}
}
@@ -138,61 +138,60 @@ function toolPart(
outputLength = 160,
): MessagePart {
const metadata =
tool === "patch"
? {
files: [
patchFile(index, "modified"),
patchFile(index + 1, index % 2 === 0 ? "added" : "deleted"),
],
}
tool === "apply_patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write"
? { files: [fileDiff(String(input.path ?? `src/generated/file-${index}.ts`), index)] }
? {
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
diff: patch(index, outputLength),
preview: patch(index + 1, 420),
}
: tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] }
: {}
return {
id: id(`call_${tool}_${partIndex}`, index),
id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool",
name: tool,
callID: id("call", index * 100 + partIndex),
tool,
state: {
status: "completed",
input,
content: [{ type: "text", text: lorem(index * 23 + partIndex, outputLength) }],
output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
metadata,
},
time: {
created: 1700000000000 + index * 10_000,
ran: 1700000000000 + index * 10_000,
completed: 1700000000000 + index * 10_000 + 400,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
},
}
}
function patchFile(seed: number, status: "added" | "modified" | "deleted") {
const file = `src/generated/patch-${seed}.ts`
const before = status === "added" ? "" : code(seed, 18)
const after = status === "deleted" ? "" : code(seed + 1, 24)
function patchFile(seed: number, type: "add" | "update" | "delete") {
return {
file,
status,
additions: status === "deleted" ? 0 : (seed % 7) + 1,
deletions: status === "added" ? 0 : seed % 4,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
filePath: `src/generated/patch-${seed}.ts`,
relativePath: `src/generated/patch-${seed}.ts`,
type,
additions: (seed % 7) + 1,
deletions: type === "add" ? 0 : seed % 4,
patch: patch(seed, 520),
before: type === "add" ? undefined : code(seed, 18),
after: type === "delete" ? undefined : code(seed + 1, 24),
}
}
function fileDiff(file: string, seed: number) {
const before = code(seed, 32)
const after = code(seed + 1, 38)
return {
file,
status: "modified" as const,
additions: (seed % 9) + 1,
deletions: seed % 4,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
before: code(seed, 32),
after: code(seed + 1, 38),
}
}
function patch(seed: number, length: number) {
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
}
function code(seed: number, lines: number) {
return Array.from({ length: lines }, (_, index) => `export const value${index} = "${lorem(seed + index, 32)}"`).join(
"\n",
@@ -206,23 +205,21 @@ function turn(index: number): SessionMessageInfo[] {
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
...(index % 3 === 0
? [
toolPart(index, 0, "read", { path: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
]
: []),
textPart(index, 2, 160 + (index % 6) * 90),
...(index % 4 === 0
? [toolPart(index, 3, "edit", { path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" }, 700)]
: []),
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
...(index % 6 === 0
? [toolPart(index, 7, "write", { path: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)]
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []),
...(index % 7 === 0 ? [toolPart(index, 4, "shell", { command: "bun typecheck" }, 620)] : []),
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
...(index % 13 === 0
@@ -237,15 +234,7 @@ function turn(index: number): SessionMessageInfo[] {
]
: []),
...(index % 17 === 0
? [
toolPart(
index,
12,
"subagent",
{ description: "Inspect generated fixture", agent: "explore", prompt: "Inspect the fixture." },
160,
),
]
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
: []),
]
return [user, assistantMessage(targetID, index, user.id, parts)]
@@ -322,7 +311,7 @@ export const fixture = {
targetPartIDs: targetMessages.flatMap(currentPartIDs),
expandedShellPartID: targetMessages
.flatMap((message) => (message.type === "assistant" ? message.content : []))
.flatMap((part) => (part.type === "tool" && part.name === "shell" ? [part.id] : []))[0],
.flatMap((part) => (part.type === "tool" && part.name === "bash" ? [part.id] : []))[0],
},
}
@@ -518,7 +518,7 @@ async function expectCanScrollToStart(
let current = await timelineState(page)
let unchangedAtTop = 0
for (let attempt = 0; attempt < 800; attempt++) {
for (let attempt = 0; attempt < 600; attempt++) {
collectSeen(current, seenParts, seenMessages)
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText)
+20 -1
View File
@@ -1,5 +1,7 @@
import "@/index.css"
import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context"
import type { UiI18n } from "@opencode-ai/ui/context/i18n"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { File } from "@opencode-ai/session-ui/file"
@@ -24,7 +26,7 @@ import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, UiI18nBridge, type Locale, useLanguage } from "@/context/language"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { usePlatform } from "@/context/platform"
@@ -99,6 +101,23 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
)
}
function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return (
<I18nProvider
value={{
locale: language.intl,
layoutLocale: language.layoutLocale,
t: language.t as UiI18n["t"],
plural: language.plural,
pluralForm: language.pluralForm,
}}
>
{props.children}
</I18nProvider>
)
}
declare global {
interface Window {
__OPENCODE__?: {
@@ -0,0 +1,125 @@
// @ts-nocheck
import { createStore } from "solid-js/store"
import { createPromptState } from "@/context/prompt"
import { createPromptInputHistory, PromptInput } from "./prompt-input"
function createPromptInputStoryRuntime() {
const state = createPromptState()
return {
state,
history: createPromptInputHistory(),
submission: {
abort() {},
handleSubmit(event: Event) {
event.preventDefault()
state.reset()
},
},
}
}
function PromptInputExample() {
const input = createPromptInputStoryRuntime()
const [controls, setControls] = createStore({
agent: "build",
variant: undefined as string | undefined,
comments: 0,
tabs: [] as string[],
activeTab: undefined as string | undefined,
reviewOpen: false,
})
const storyModel = {
id: "claude-3-7-sonnet",
name: "Claude 3.7 Sonnet",
provider: { id: "anthropic", name: "Anthropic" },
}
const model = {
current: () => storyModel,
list: () => [storyModel],
visible: () => true,
set: () => {},
variant: {
list: () => ["fast", "thinking"],
current: () => controls.variant,
set: (variant?: string) => setControls("variant", variant),
},
}
const inputControls = {
agents: {
available: [{ name: "review", hidden: false, mode: "subagent" }],
options: ["build", "review", "plan"],
get current() {
return controls.agent
},
loading: false,
visible: true,
select: (agent?: string) => setControls("agent", agent ?? "build"),
},
model: {
selection: model,
paid: true,
loading: false,
},
session: {
id: "story-session",
tabs: {
active: () => controls.activeTab,
all: () => controls.tabs,
open: (tab: string) => setControls("tabs", (tabs) => (tabs.includes(tab) ? tabs : [...tabs, tab])),
setActive: (tab: string) => setControls("activeTab", tab),
},
reviewPanel: {
opened: () => controls.reviewOpen,
open: () => setControls("reviewOpen", true),
},
},
}
const addReviewComment = () => {
const comment = controls.comments + 1
setControls("comments", comment)
input.state.context.add({
type: "file",
path: "src/components/prompt-input.tsx",
selection: {
startLine: 84 + comment,
startChar: 0,
endLine: 84 + comment,
endChar: 0,
},
comment: `Review comment ${comment}`,
commentID: `review-comment-${comment}`,
commentOrigin: "review",
preview: "export const PromptInput = ...",
})
}
return (
<div class="flex flex-col gap-3">
<PromptInput controls={inputControls} {...input} />
<div>
<button
type="button"
class="rounded-md border border-border-weak-base bg-background-base px-2.5 py-1.5 text-12-medium text-text-base hover:bg-background-stronger"
onClick={addReviewComment}
>
Add review comment
</button>
</div>
</div>
)
}
export default {
title: "App/PromptInput",
id: "app-prompt-input",
component: PromptInput,
}
export const Basic = {
render: () => (
<div class="pt-10">
<h1 class="mb-4">Prompt Input</h1>
<PromptInputExample />
</div>
),
}
@@ -235,9 +235,6 @@ beforeAll(async () => {
session: {
remember: () => undefined,
setStatus: () => undefined,
// Delegates straight to the API client; optimistic admission and
// rollback are covered by the data-layer tests in packages/tui.
prompt: (input: unknown) => rootClient.api.session.prompt(input as never),
},
location: {
info: () => ({ project: { id: "project", directory: "/repo/main" } }),
@@ -417,9 +414,7 @@ describe("prompt submit worktree selection", () => {
model: { providerID: "provider", modelID: "model", variant: "high" },
},
})
// ID minting is delegated to the data layer, which mints a client ID when
// none is supplied (covered by the data-layer tests in packages/tui).
expect((promptInputs[0] as { id?: string }).id).toBeUndefined()
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
})
test("restores the prompt when sending fails", async () => {
@@ -11,7 +11,7 @@ import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Identifier } from "@/utils/id"
import { getDirectory } from "@opencode-ai/util/path"
import { buildPromptRequest } from "./build-prompt-request"
import { setCursorPosition } from "./editor-dom"
@@ -40,6 +40,7 @@ type FollowupSendInput = {
data: Data
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
draft: FollowupDraft
messageID?: string
optimisticBusy?: boolean
}
@@ -68,9 +69,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
) {
setBusy()
try {
const messageID = Identifier.ascending("message")
await input.api.command({
sessionID: input.draft.sessionID,
id: SessionMessage.ID.create(),
id: messageID,
command: cmd,
arguments: tail.join(" "),
agent: input.draft.agent,
@@ -93,6 +95,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}
}
const messageID = input.messageID ?? Identifier.ascending("message")
const encodedImages = await Promise.all(
images.map(async (attachment) => ({
...attachment,
@@ -129,10 +132,9 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
})
}
// The data layer admits optimistically under a client-minted ID: the
// prompt renders immediately and rolls back if the server rejects it.
await input.data.session.prompt({
await input.api.prompt({
sessionID: input.draft.sessionID,
id: messageID,
text: request.text,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
@@ -444,11 +446,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
?.find((command) => command.name === commandName)
if (customCommand) {
clearInput()
const messageID = Identifier.ascending("message")
submissionData.session.setStatus(session.id, "running")
void submissionServerSDK.api.session
.command({
sessionID: session.id,
id: SessionMessage.ID.create(),
id: messageID,
command: commandName,
arguments: args.join(" "),
agent,
@@ -473,6 +476,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")
for (const item of commentItems) submission.target().context.remove(item.key)
clearInput()
@@ -482,6 +486,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
data: submissionData,
session: () => session,
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
}).catch((err) => {
if (sessionDirectory === projectDirectory) {
@@ -1,55 +0,0 @@
import { Show, type JSX } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Tooltip } from "@opencode-ai/ui/tooltip"
export type SessionHeaderV2ActionsState = {
status?: { label: string; content: () => JSX.Element }
reviewLabel: string
reviewKeybind: string[]
reviewVisible: boolean
reviewOpened: boolean
onReviewToggle: () => void
}
export function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
return (
<div class="flex items-center gap-2">
<Show when={props.state.status}>
{(status) => (
<Tooltip appearance="standard" placement="bottom" value={status().label}>
{status().content()}
</Tooltip>
)}
</Show>
<Show when={props.state.reviewVisible}>
<Tooltip
class="shrink-0"
placement="bottom"
value={
<>
{props.state.reviewLabel}
<Show when={props.state.reviewKeybind.length > 0}>
<Keybind keys={props.state.reviewKeybind} variant="neutral" />
</Show>
</>
}
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
state={props.state.reviewOpened ? "pressed" : undefined}
onClick={props.state.onReviewToggle}
aria-label={props.state.reviewLabel}
aria-expanded={props.state.reviewOpened}
aria-controls="review-panel"
icon={<Icon name="sidebar-right" />}
/>
</Tooltip>
</Show>
</div>
)
}
@@ -6,9 +6,12 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout"
import { StatusPopoverV2 } from "../status-popover"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
import { useTitlebarRightMount } from "../titlebar"
import { SessionHeaderV2Actions, type SessionHeaderV2ActionsState } from "./session-header-actions"
export function SessionHeader() {
const command = useCommand()
@@ -20,7 +23,8 @@ export function SessionHeader() {
const isDesktop = createMediaQuery("(min-width: 768px)")
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
status: status() ? { label: language.t("status.popover.trigger"), content: () => <StatusPopoverV2 /> } : undefined,
statusVisible: status(),
statusLabel: language.t("status.popover.trigger"),
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: reviewTooltipKeybind(command),
reviewVisible: isDesktop(),
@@ -40,3 +44,52 @@ export function SessionHeader() {
</Show>
)
}
type SessionHeaderV2ActionsState = {
statusVisible: boolean
statusLabel: string
reviewLabel: string
reviewKeybind: string[]
reviewVisible: boolean
reviewOpened: boolean
onReviewToggle: () => void
}
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
return (
<div class="flex items-center gap-2">
<Show when={props.state.statusVisible}>
<Tooltip appearance="standard" placement="bottom" value={props.state.statusLabel}>
<StatusPopoverV2 />
</Tooltip>
</Show>
<Show when={props.state.reviewVisible}>
<Tooltip
class="shrink-0"
placement="bottom"
value={
<>
{props.state.reviewLabel}
<Show when={props.state.reviewKeybind.length > 0}>
<Keybind keys={props.state.reviewKeybind} variant="neutral" />
</Show>
</>
}
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
state={props.state.reviewOpened ? "pressed" : undefined}
onClick={props.state.onReviewToggle}
aria-label={props.state.reviewLabel}
aria-expanded={props.state.reviewOpened}
aria-controls="review-panel"
icon={<Icon name="sidebar-right" />}
/>
</Tooltip>
</Show>
</div>
)
}
+1 -20
View File
@@ -1,10 +1,8 @@
import * as i18n from "@solid-primitives/i18n"
import { createEffect, createMemo, createResource, type JSX } from "solid-js"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import {
I18nProvider,
type UiI18n,
pluralCategory,
type UiI18nPluralLookupKey,
type UiI18nPluralKey,
@@ -262,20 +260,3 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
}
},
})
export function UiI18nBridge(props: { children?: JSX.Element }) {
const language = useLanguage()
return (
<I18nProvider
value={{
locale: language.intl,
layoutLocale: language.layoutLocale,
t: language.t as UiI18n["t"],
plural: language.plural,
pluralForm: language.pluralForm,
}}
>
{props.children}
</I18nProvider>
)
}
-10
View File
@@ -1,10 +0,0 @@
export const popularProviders = [
"opencode",
"opencode-go",
"anthropic",
"github-copilot",
"openai",
"google",
"openrouter",
"vercel",
]
+10 -2
View File
@@ -5,9 +5,17 @@ import { Iterable, pipe } from "effect"
import { createEffect, createMemo, type Accessor } from "solid-js"
import { emptyProviderCatalog } from "./provider-catalog"
import { useIntegrations } from "./use-integrations"
import { popularProviders } from "./provider-order"
export { popularProviders } from "./provider-order"
export const popularProviders = [
"opencode",
"opencode-go",
"anthropic",
"github-copilot",
"openai",
"google",
"openrouter",
"vercel",
]
const popularProviderSet = new Set(popularProviders)
export function useProviders(directory: Accessor<string | undefined>) {
+29 -10
View File
@@ -1,5 +1,5 @@
import type { FilePart } from "@/types"
import type { FileDiffInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import type { SessionUserActions } from "@opencode-ai/session-ui/message"
import { getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
@@ -81,7 +81,6 @@ import {
} from "@/pages/session/session-panel-width"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
import { SessionPanelFrame, SessionRouteFrame } from "@/pages/session/session-frame"
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
@@ -91,7 +90,7 @@ import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Identifier } from "@/utils/id"
import { Persist, persisted } from "@/utils/persist"
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { requireServerKey, sessionHref } from "@/utils/session-route"
@@ -265,6 +264,27 @@ function MarkSessionNotificationsViewed(props: { sessionID?: () => string | unde
return null
}
function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
return (
<div class="relative size-full overflow-hidden flex flex-col" classList={{ "p-2": props.padded }}>
{props.children}
</div>
)
}
function SessionPanelFrame(props: ParentProps<{ raised?: boolean }>) {
return (
<div
class="flex-1 min-h-0 flex flex-col bg-v2-background-bg-base rounded-[10px] overflow-hidden"
classList={{
"shadow-[var(--v2-elevation-raised)]": props.raised,
}}
>
{props.children}
</div>
)
}
export default function Page() {
const data = useData()
const layout = useLayout()
@@ -1544,7 +1564,7 @@ export default function Page() {
const queueFollowup = (draft: FollowupDraft) => {
setFollowup("items", draft.sessionID, (items) => [
...(items ?? []),
{ id: SessionMessage.ID.create(), ...draft },
{ id: Identifier.ascending("message"), ...draft },
])
setFollowup("failed", draft.sessionID, undefined)
setFollowup("paused", draft.sessionID, undefined)
@@ -1593,15 +1613,14 @@ export default function Page() {
// attachment bytes are embedded as a data URL, so downloading always works;
// revealing requires the on-disk path captured by the client that attached the file
const openAttachment: NonNullable<SessionUserActions["openAttachment"]> = (file) => {
const url = file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`
const openAttachment = (file: FilePart) => {
const download = () => {
const anchor = document.createElement("a")
anchor.href = url
anchor.download = getFilename(file.name) || "attachment"
anchor.href = file.url
anchor.download = getFilename(file.filename) || "attachment"
anchor.click()
}
const path = file.name ?? ""
const path = file.filename ?? ""
const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path)
if (platform.revealPath && absolute) {
void platform.revealPath(path).then(
@@ -1615,7 +1634,7 @@ export default function Page() {
download()
}
const actions = { revert, openAttachment } satisfies SessionUserActions
const actions = { revert, openAttachment }
createEffect(() => {
const sessionID = controller.identity.params.id
@@ -7,32 +7,8 @@ import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import { SessionBackgroundDock } from "@/pages/session/composer/session-background-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller"
type SessionComposerRegionState = Pick<
SessionComposerRegionController["state"],
"questionRequest" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
> & {
background: Pick<SessionComposerRegionController["state"]["background"], "blocking" | "tasks" | "move">
}
export type SessionComposerRegionViewController = Pick<
SessionComposerRegionController,
| "centered"
| "followup"
| "revert"
| "onResponseSubmit"
| "openParent"
| "setPromptRef"
| "setDockRef"
| "parentID"
| "child"
| "showComposer"
| "handoffPrompt"
| "promptReady"
| "lift"
> & { state: SessionComposerRegionState }
export function SessionComposerRegion(props: {
controller: SessionComposerRegionViewController
controller: SessionComposerRegionController
promptInput: JSX.Element
}) {
const language = useLanguage()
@@ -1,22 +0,0 @@
import type { ParentProps } from "solid-js"
export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
return (
<div class="relative flex size-full flex-col overflow-hidden" classList={{ "p-2": props.padded }}>
{props.children}
</div>
)
}
export function SessionPanelFrame(props: ParentProps<{ raised?: boolean }>) {
return (
<div
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] bg-v2-background-bg-base"
classList={{
"shadow-[var(--v2-elevation-raised)]": props.raised,
}}
>
{props.children}
</div>
)
}
@@ -1,157 +0,0 @@
import {
activePermissionRequest,
activeQuestionRequest,
attachmentsAndCommentsDocument,
editThenTestDocument,
emptySessionDocument,
largeCompletedDocument,
pendingAndQueuedDocument,
permissionPendingDocument,
questionPendingDocument,
queuedPrompts,
recoveryDocument,
thinkingDocument,
} from "@opencode-ai/session-ui/storybook"
import { SessionPreview } from "./session-preview"
const description = "opencode · modular-session-ui"
const implementAndVerify = () => (
<SessionPreview
title="Update active Session status"
description={description}
document={editThenTestDocument}
draft="Add a browser assertion for the updated status"
/>
)
export default {
title: "OpenCode/Session/Complete workspace",
id: "app-current-session-surface",
component: SessionPreview,
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"A server-free Session workbench for product and design review. It composes the production current timeline, titlebar actions, composer region, prompt input, request docks, queue, and review components.",
},
},
},
}
export const StartACodingTask = {
render: () => (
<SessionPreview
title="New Session"
description={description}
document={emptySessionDocument}
draft="Find why the Session header shifts after the first streamed response"
/>
),
}
export const AgentIsThinking = {
render: () => (
<SessionPreview title="Fix Session header shift" description={description} document={thinkingDocument} />
),
}
export const ImplementAndVerifyLight = {
globals: { theme: "light" },
render: implementAndVerify,
}
export const ImplementAndVerifyDark = {
globals: { theme: "dark" },
render: implementAndVerify,
}
export const QueueAFollowUp = {
render: () => (
<SessionPreview
title="Add deterministic Session stories"
description={description}
document={pendingAndQueuedDocument}
followups={queuedPrompts}
backgroundTasks={[{ id: "task_storybook", type: "subagent", label: "Review the current Storybook scenarios" }]}
/>
),
}
export const PermissionRequired = {
render: () => (
<SessionPreview
title="Publish canary preview"
description={description}
document={permissionPendingDocument}
request={{ type: "permission", value: activePermissionRequest }}
/>
),
}
export const AnswerAProductQuestion = {
render: () => (
<SessionPreview
title="Add the Session review panel"
description={description}
document={questionPendingDocument}
request={{ type: "question", value: activeQuestionRequest }}
/>
),
}
export const ReviewChanges = {
render: () => (
<SessionPreview
title="Update active Session status"
description={description}
document={editThenTestDocument}
reviewOpened
/>
),
}
export const RecoverFromAFailedTest = {
render: () => (
<SessionPreview
title="Keep tool disclosure stable"
description={description}
document={recoveryDocument}
draft="Also run the App browser test"
/>
),
}
export const WorkFromAttachments = {
render: () => (
<SessionPreview
title="Fix narrow Session spacing"
description={description}
document={attachmentsAndCommentsDocument}
draft="Verify the same layout at 360 px"
/>
),
}
export const LongRunningSession = {
render: () => (
<SessionPreview
title="Modularize Session rendering"
description={description}
document={largeCompletedDocument}
draft="Summarize the remaining verification"
/>
),
}
export const MixedDirectionRtl = {
globals: { theme: "dark", direction: "rtl" },
render: () => (
<SessionPreview
title="مراجعة واجهة Session"
description="opencode · packages/app/src/session.tsx"
document={attachmentsAndCommentsDocument}
draft="راجع المسار packages/app/src/session.tsx ثم شغّل bun test"
/>
),
}
@@ -1,352 +0,0 @@
import type { ModelSelection } from "@/context/local"
import { PromptInputV2Composer, type PromptInputV2ComposerController } from "@/components/prompt-input-v2"
import { SessionHeaderV2Actions } from "@/components/session/session-header-actions"
import {
SessionComposerRegion,
type SessionComposerRegionViewController,
} from "@/pages/session/composer/session-composer-region"
import { SessionPanelFrame, SessionRouteFrame } from "@/pages/session/session-frame"
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { SessionDocument } from "@opencode-ai/session-ui/document"
import { CurrentSessionProviders, STORY_MODEL } from "@opencode-ai/session-ui/storybook"
import { SessionTimeline } from "@opencode-ai/session-ui/timeline"
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
import { createPromptInputV2Controller } from "@opencode-ai/session-ui/v2/prompt-input/interaction"
import type { PromptInputV2PersistedState } from "@opencode-ai/session-ui/v2/prompt-input/types"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { ReviewPanelV2View } from "@/pages/session/v2/review-panel-v2"
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
const modelReady = Object.assign(() => true, { promise: undefined }) satisfies ModelSelection["ready"]
const storyComposerModel = {
id: STORY_MODEL.id,
providerID: STORY_MODEL.providerID,
api: { id: STORY_MODEL.id, url: "https://api.anthropic.com", npm: "@ai-sdk/anthropic" },
name: "Claude Sonnet 4",
family: "claude-sonnet",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: true,
},
cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } },
limit: { context: 200_000, output: 64_000 },
status: "active",
options: {},
headers: {},
release_date: "2025-05-22",
variants: { balanced: {} },
provider: {
id: STORY_MODEL.providerID,
name: "Anthropic",
source: "custom",
env: [],
options: {},
models: {},
},
latest: true,
} satisfies NonNullable<ReturnType<ModelSelection["current"]>>
const modelSelection = {
ready: modelReady,
current: () => storyComposerModel,
recent: () => [storyComposerModel],
list: () => [storyComposerModel],
cycle() {},
set() {},
visible: () => true,
setVisibility() {},
variant: {
configured: () => STORY_MODEL.variant,
selected: () => STORY_MODEL.variant,
current: () => STORY_MODEL.variant,
list: () => [STORY_MODEL.variant],
set() {},
cycle() {},
},
} satisfies ModelSelection
export type SessionPreviewProps = {
title: string
description: string
document: SessionDocument
draft?: string
followups?: { id: string; text: string }[]
request?: { type: "permission"; value: PermissionRequest } | { type: "question"; value: FormInfo }
reviewOpened?: boolean
backgroundTasks?: { id: string; type: "shell" | "subagent"; label: string }[]
}
export function SessionPreview(props: SessionPreviewProps) {
const [state, setState] = createStore({ revision: 1 })
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
return (
<QueryClientProvider client={queryClient}>
<Show when={state.revision} keyed>
{(revision) => (
<div data-story-revision={revision}>
<SessionSurfaceState
{...props}
request={
props.request?.type === "question"
? {
type: "question",
value: { ...props.request.value, id: `${props.request.value.id}:${revision}` },
}
: props.request
}
onReset={() => setState("revision", (value) => value + 1)}
/>
</div>
)}
</Show>
</QueryClientProvider>
)
}
function createPromptController(input: {
initial: string
placeholder: string
status: () => SessionStatus
onActivity: (activity: string) => void
onSubmit: (text: string) => void
onStop: () => void
}) {
const draft = createStore<PromptInputV2PersistedState>({
prompt: [{ type: "text", content: input.initial, start: 0, end: input.initial.length }],
cursor: input.initial.length,
model: { providerID: STORY_MODEL.providerID, modelID: STORY_MODEL.id, variant: STORY_MODEL.variant },
context: { items: [] },
})
const interaction = createPromptInputV2Controller({
store: draft,
commands: () => [],
context: () => [],
searchContextFiles: () => [],
view: {
placeholder: () => input.placeholder,
add: { onAttach: () => input.onActivity("Opened the local attachment picker") },
submit: {
stopping: () => false,
working: () => input.status().type !== "idle",
onSubmit: () => {
const value = interaction.value().trim()
if (!value) return
input.onSubmit(value)
draft[1]("prompt", [{ type: "text", content: "", start: 0, end: 0 }])
draft[1]("cursor", 0)
},
onStop: input.onStop,
},
shell: {
onOpen: () => input.onActivity("Changed the composer to shell mode"),
onClose: () => input.onActivity("Changed the composer to prompt mode"),
},
},
})
return {
controller: {
...interaction,
model: { selection: modelSelection, paid: true, loading: false },
} satisfies PromptInputV2ComposerController,
setValue(value: string) {
draft[1]("prompt", [{ type: "text", content: value, start: 0, end: value.length }])
draft[1]("cursor", value.length)
},
}
}
function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void }) {
const language = useLanguage()
const [state, setState] = createStore<{
activity: string
reviewOpened: boolean
followups: { id: string; text: string }[]
request: SessionPreviewProps["request"]
}>({
activity: "Ready",
reviewOpened: props.reviewOpened ?? false,
followups: props.followups?.map((item) => ({ ...item })) ?? [],
request: props.request,
})
const prompt = createPromptController({
initial: props.draft ?? "",
placeholder: language.t("prompt.placeholder.normal"),
status: () => props.document.status,
onActivity: (activity) => setState("activity", activity),
onSubmit: (text) => setState("activity", `Submitted locally: ${text}`),
onStop: () => setState("activity", "Requested a local stop"),
})
const removeFollowup = (id: string) => setState("followups", (items) => items.filter((item) => item.id !== id))
const region = {
state: {
questionRequest: () => (state.request?.type === "question" ? state.request.value : undefined),
permissionRequest: () => (state.request?.type === "permission" ? state.request.value : undefined),
permissionResponding: () => false,
decide: (response) => {
setState("request", undefined)
setState("activity", `Permission response: ${response}`)
},
background: {
blocking: () => [],
tasks: () => props.backgroundTasks ?? [],
move: async () => {
setState("activity", "Requested background execution")
},
},
blocked: () => state.request !== undefined,
},
centered: () => true,
followup: () =>
state.followups.length
? {
items: state.followups,
onSend: (id: string) => {
removeFollowup(id)
setState("activity", "Requested immediate delivery for the queued message")
},
onEdit: (id: string) => {
const item = state.followups.find((value) => value.id === id)
if (item) prompt.setValue(item.text)
removeFollowup(id)
setState("activity", "Moved the queued message into the composer")
},
}
: undefined,
revert: () => undefined,
onResponseSubmit: () => {
setState("request", undefined)
setState("activity", "Submitted the answer locally")
},
openParent: () => setState("activity", "Opened the parent Session locally"),
setPromptRef() {},
setDockRef() {},
parentID: () => undefined,
child: () => false,
showComposer: () => true,
handoffPrompt: () => undefined,
promptReady: () => true,
lift: () => 0,
} satisfies SessionComposerRegionViewController
return (
<div class="mx-auto h-screen min-h-[640px] w-full max-w-[1440px]">
<SessionRouteFrame padded>
<SessionPanelFrame raised>
<main class="flex min-h-0 flex-1 flex-col">
<SessionSurfaceHeader
title={props.title}
description={props.description}
reviewVisible
reviewOpened={state.reviewOpened}
onReviewToggle={() => setState("reviewOpened", (value) => !value)}
onReset={props.onReset}
/>
<CurrentSessionProviders document={props.document}>
<div class="flex min-h-0 flex-1">
<section
classList={{
"min-w-0 flex-1 flex-col bg-background-base": true,
flex: !state.reviewOpened,
"hidden md:flex": state.reviewOpened,
}}
>
<div class="min-h-0 flex-1 overflow-y-auto py-6">
<SessionTimeline
document={props.document}
editToolDefaultOpen
shellToolDefaultOpen
class="mx-auto w-full max-w-[840px]"
/>
</div>
<SessionComposerRegion
controller={region}
promptInput={<PromptInputV2Composer controller={prompt.controller} borderUnderlay />}
/>
</section>
<Show when={state.reviewOpened}>
<aside id="review-panel" class="min-w-0 flex-1 border-l border-border-weak-base md:max-w-[52%]">
<SessionReviewPane diffs={props.document.diffs} />
</aside>
</Show>
</div>
</CurrentSessionProviders>
<output class="sr-only" aria-live="polite">
{state.activity}
</output>
</main>
</SessionPanelFrame>
</SessionRouteFrame>
</div>
)
}
function SessionSurfaceHeader(props: {
title: string
description: string
reviewVisible: boolean
reviewOpened: boolean
onReviewToggle: () => void
onReset: () => void
}) {
const language = useLanguage()
return (
<header class="flex min-h-14 shrink-0 items-center justify-between gap-4 border-b border-border-weak-base px-4 py-2">
<div class="flex min-w-0 items-center gap-3">
<span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-background-stronger text-icon-base">
<Icon name="folder" />
</span>
<div class="min-w-0">
<h1 class="truncate text-14-medium text-text-strong">{props.title}</h1>
<p class="truncate text-12-regular text-text-weak">{props.description}</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<SessionHeaderV2Actions
state={{
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: [],
reviewVisible: props.reviewVisible,
reviewOpened: props.reviewOpened,
onReviewToggle: props.onReviewToggle,
}}
/>
<Button size="small" variant="neutral" onClick={props.onReset}>
Reset
</Button>
</div>
</header>
)
}
function SessionReviewPane(props: { diffs: SessionDocument["diffs"] }) {
const language = useLanguage()
const review = createReviewPanelV2State()
const [state, setState] = createStore({
active: props.diffs[0]?.file,
diffStyle: "unified" as "unified" | "split",
})
return (
<ReviewPanelV2View
title={language.t("ui.sessionReview.title.lastTurn")}
empty={<SessionReviewEmptyChangesV2 />}
diffs={props.diffs}
diffsReady
activeFile={state.active}
onSelectFile={(file) => setState("active", file)}
diffStyle={state.diffStyle}
onDiffStyleChange={(value) => setState("diffStyle", value)}
state={review}
fileList="flat"
/>
)
}
@@ -0,0 +1,115 @@
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import {
ContextToolGroup,
Message,
Part as MessagePart,
partDefaultOpen,
type UserActions,
} from "@opencode-ai/session-ui/message-part"
import type { ToolPart } from "@/types"
import {
presentAssistantMessage,
presentAssistantContent,
presentUserMessage,
presentUserParts,
} from "@/utils/session-message"
import { createMemo, Show } from "solid-js"
export function CurrentUserMessage(props: {
sessionID: string
message: SessionMessageUser
agent: string
model: { id: string; providerID: string; variant?: string }
actions?: UserActions
useV2Actions?: boolean
comments?: { path: string; comment: string; selection?: { startLine: number; endLine: number } }[]
}) {
const message = createMemo(() => presentUserMessage(props.sessionID, props.message, props.agent, props.model))
const parts = createMemo(() => presentUserParts(props.sessionID, props.message))
return (
<Message
message={message()}
parts={parts()}
actions={props.actions}
useV2Actions={props.useV2Actions}
comments={props.comments}
/>
)
}
export function CurrentAssistantContent(props: {
sessionID: string
parentID: string
message: SessionMessageAssistant
content: SessionMessageAssistant["content"][number]
contentID: string
showAssistantCopyPartID?: string | null
turnDurationMs?: number
useV2Actions?: boolean
defaultOpen?: boolean
toolOpen?: boolean
onToolOpenChange?: (open: boolean) => void
onContentRendered?: () => void
}) {
const message = createMemo(() => presentAssistantMessage(props.sessionID, props.parentID, props.message))
const part = createMemo(() => presentAssistantContent(props.sessionID, props.message, props.contentID, props.content))
return (
<Show when={part()}>
{(part) => (
<MessagePart
part={part()}
message={message()}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
useV2Actions={props.useV2Actions}
defaultOpen={props.defaultOpen}
toolOpen={props.toolOpen}
onToolOpenChange={props.onToolOpenChange}
deferToolContent
virtualizeDiff={false}
onContentRendered={props.onContentRendered}
/>
)}
</Show>
)
}
export function CurrentContextToolGroup(props: {
sessionID: string
tools: { message: SessionMessageAssistant; content: SessionMessageAssistantTool; contentID: string }[]
open: boolean
busy: boolean
onOpenChange: (open: boolean) => void
onSizeChange?: () => void
}) {
const parts = createMemo(() =>
props.tools.flatMap(({ message, content, contentID }): ToolPart[] => {
const part = presentAssistantContent(props.sessionID, message, contentID, content)
return part?.type === "tool" ? [part] : []
}),
)
return (
<ContextToolGroup
parts={parts()}
open={props.open}
onOpenChange={props.onOpenChange}
busy={props.busy}
onSizeChange={props.onSizeChange}
/>
)
}
export function currentPartDefaultOpen(
sessionID: string,
message: SessionMessageAssistant,
content: SessionMessageAssistant["content"][number],
contentID: string,
shellExpanded: boolean,
editExpanded: boolean,
) {
return partDefaultOpen(presentAssistantContent(sessionID, message, contentID, content), shellExpanded, editExpanded)
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { ModelRef, SessionMessageInfo } from "@opencode-ai/client/promise"
import { createTimelineProjection, reuseTimelineRows, TimelineRow, type PartGroup } from "./projection"
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { reuseTimelineRows } from "./row-reconciliation"
import { TimelineRow } from "./timeline-row"
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
new TimelineRow.AssistantPart({
@@ -93,83 +94,3 @@ describe("reuseTimelineRows", () => {
reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex]))
})
})
describe("createTimelineProjection", () => {
test("builds current message, parent, context, and row indexes", () => {
const selectedModel = { id: "selected", providerID: "provider" } satisfies ModelRef
const assistantModel = { id: "assistant", providerID: "provider", variant: "fast" } satisfies ModelRef
const messages = [
{ id: "agent", type: "agent-switched", agent: "explore", time: { created: 1 } },
{ id: "model", type: "model-switched", model: selectedModel, time: { created: 2 } },
{ id: "user-1", type: "user", text: "first", time: { created: 3 } },
{
id: "assistant-1",
type: "assistant",
agent: "build",
model: assistantModel,
content: [{ type: "text", text: "answer" }],
time: { created: 4, completed: 5 },
},
{
id: "user-2",
type: "user",
text: "second",
metadata: {
agent: "review",
model: { modelID: "override", providerID: "custom", variant: "precise" },
},
time: { created: 6 },
},
] satisfies SessionMessageInfo[]
const result = createTimelineProjection({
sessionMessages: messages,
status: { type: "busy" },
showReasoningSummaries: true,
})
expect(result.activeMessageID).toBe("user-2")
expect(result.messageByID).toBe(result.sessionMessageByID)
expect(result.sessionMessageByID.get("assistant-1")).toBe(messages[3])
expect(result.assistantMessagesByParent.get("user-1")?.map((message) => message.id)).toEqual(["assistant-1"])
expect(result.assistantMessagesByParent.has("user-2")).toBe(false)
expect(result.userContextByID.get("user-1")).toEqual({ agent: "build", model: assistantModel })
expect(result.userContextByID.get("user-2")).toEqual({
agent: "review",
model: { id: "override", providerID: "custom", variant: "precise" },
})
expect(result.messageRowIndex.get("user-1")).toBe(0)
expect(result.messageLastRowIndex.get("user-1")).toBe(3)
expect(result.lastAssistantGroupKey.get("user-1")).toBe("part:assistant-1:assistant-1:text:0")
expect(result.rowByKey.get("user-message:user-1")).toBe(result.rows[2])
})
test("reuses a stable projected row array", () => {
const messages = [
{ id: "user-1", type: "user", text: "first", time: { created: 1 } },
{
id: "assistant-1",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "answer" }],
time: { created: 2, completed: 3 },
},
] satisfies SessionMessageInfo[]
const first = createTimelineProjection({
sessionMessages: messages,
status: { type: "idle" },
showReasoningSummaries: true,
})
const second = createTimelineProjection({
sessionMessages: messages,
status: { type: "idle" },
showReasoningSummaries: true,
previousRows: first.rows,
})
expect(second.rows).toBe(first.rows)
expect(second.rows[0]).toBe(first.rows[0])
expect(second.rows[1]).toBe(first.rows[1])
})
})
@@ -1,8 +1,9 @@
import type { ModelRef, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
import { reuseTimelineRows, Timeline, TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { createMemo, type Accessor } from "solid-js"
import { reuseTimelineRows } from "./row-reconciliation"
import { Timeline, TimelineRow } from "./rows"
export { reuseTimelineRows } from "@opencode-ai/session-ui/timeline/projection"
export { reuseTimelineRows } from "./row-reconciliation"
export function createTimelineProjection(input: {
sessionMessages: Accessor<SessionMessageInfo[]>
@@ -74,7 +75,7 @@ export function createTimelineProjection(input: {
return result
})
const projection = createMemo(() =>
Timeline.constructSessionMessageRows(input.sessionMessages(), input.showReasoningSummaries(), input.status()),
Timeline.constructSessionMessageRows(input.sessionMessages(), input.showReasoningSummaries(), input.status().type),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
@@ -0,0 +1,56 @@
import { TimelineRow } from "./timeline-row"
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
type PriorContext = { index: number; row: ContextRow }
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
if (!previous?.length) return rows
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
const contextByPart = new Map<string, PriorContext>()
previous.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
row.group.refs.forEach((ref) => contextByPart.set(`${row.userMessageID}:${ref.partID}`, { index, row }))
})
const reserved = new Map<string, number>()
rows.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
const key = TimelineRow.key(row)
if (byKey.has(key) && !reserved.has(key)) reserved.set(key, index)
})
const claimed = new Set<string>()
const next = rows.map((input, index) => {
const row = stabilizeContextKey(contextByPart, reserved, input, index, claimed)
const existing = byKey.get(TimelineRow.key(row))
if (!existing) return row
return TimelineRow.equals(existing, row) ? existing : row
})
if (previous.length === next.length && previous.every((row, index) => row === next[index])) return previous
return next
}
function stabilizeContextKey(
contextByPart: Map<string, PriorContext>,
reserved: Map<string, number>,
row: TimelineRow.TimelineRow,
rowIndex: number,
claimed: Set<string>,
) {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return row
const existing = row.group.refs.reduce<PriorContext | undefined>((result, ref) => {
const candidate = contextByPart.get(`${row.userMessageID}:${ref.partID}`)
if (!candidate) return result
const key = TimelineRow.key(candidate.row)
if (claimed.has(key)) return result
const owner = reserved.get(key)
if (owner !== undefined && owner !== rowIndex) return result
return !result || candidate.index < result.index ? candidate : result
}, undefined)
if (!existing) return row
const key = TimelineRow.key(existing.row)
claimed.add(key)
if (row.group.key === existing.row.group.key) return row
return new TimelineRow.AssistantPart({
...row,
group: { ...row.group, key: existing.row.group.key },
})
}
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { Timeline, TimelineRow } from "./projection"
const { Timeline, TimelineRow } = await import("./rows")
describe("current session timeline rows", () => {
test("derives turns and tagged rows from chronological current messages", () => {
@@ -24,7 +25,7 @@ describe("current session timeline rows", () => {
time: { created: 5 },
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" })
const result = Timeline.constructSessionMessageRows(source, true, "busy")
expect(result.activeMessageID).toBe("msg_3")
expect(result.rows.map(TimelineRow.key)).toEqual([
@@ -49,7 +50,7 @@ describe("current session timeline rows", () => {
time: { created: 1, completed: 2 },
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, { type: "idle" })
const result = Timeline.constructSessionMessageRows(source, true, "idle")
expect(result.activeMessageID).toBe("msg_shell")
expect(result.rows.map(TimelineRow.key)).toEqual(["shell:msg_shell"])
@@ -74,7 +75,7 @@ describe("current session timeline rows", () => {
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, { type: "idle" })
const result = Timeline.constructSessionMessageRows(source, true, "idle")
expect(result.activeMessageID).toBe("msg_assistant")
expect(result.rows.map(TimelineRow.key)).toEqual([
@@ -135,7 +136,7 @@ describe("current session timeline rows", () => {
time: { created: 11 },
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, { type: "idle" })
const result = Timeline.constructSessionMessageRows(source, true, "idle")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_user",
@@ -155,7 +156,7 @@ describe("current session timeline rows", () => {
{ id: "msg_z", type: "user", text: "existing", time: { created: 1 } },
{ id: "msg_a", type: "user", text: "pending", time: { created: 2 } },
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" })
const result = Timeline.constructSessionMessageRows(source, true, "busy")
expect(result.activeMessageID).toBe("msg_a")
expect(result.rows.map(TimelineRow.key)).toEqual([
@@ -180,7 +181,7 @@ describe("current session timeline rows", () => {
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" })
const result = Timeline.constructSessionMessageRows(source, true, "busy")
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Retry"])
})
@@ -206,122 +207,8 @@ describe("current session timeline rows", () => {
time: { created: 4 },
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" })
const result = Timeline.constructSessionMessageRows(source, true, "busy")
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"])
})
test("keeps content IDs and groups adjacent context tools", () => {
const source = [
{ id: "msg_user", type: "user", text: "inspect", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{ type: "text", text: " " },
{ type: "reasoning", text: "hidden" },
{
type: "tool",
id: "tool_read",
name: "read",
state: { status: "running", input: {}, metadata: {} },
time: { created: 2 },
},
{
type: "tool",
id: "tool_grep",
name: "grep",
state: { status: "running", input: {}, metadata: {} },
time: { created: 3 },
},
{
type: "tool",
id: "tool_todo",
name: "todowrite",
state: { status: "running", input: {}, metadata: {} },
time: { created: 4 },
},
{
type: "tool",
id: "tool_question",
name: "question",
state: { status: "streaming", input: "" },
time: { created: 5 },
},
{ type: "text", text: "visible" },
],
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, false, { type: "idle" })
const groups = result.rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group] : []))
expect(groups).toEqual([
{
type: "context",
key: "context:tool_read",
refs: [
{ messageID: "msg_assistant", partID: "tool_read" },
{ messageID: "msg_assistant", partID: "tool_grep" },
],
},
{
type: "part",
key: "part:msg_assistant:msg_assistant:text:1",
ref: { messageID: "msg_assistant", partID: "msg_assistant:text:1" },
},
])
})
test("places a divider after interrupted output unless the turn compacts", () => {
const messages = [
{ id: "msg_user", type: "user", text: "continue", time: { created: 1 } },
{
id: "msg_interrupted",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "before" }],
error: { type: "ExecutionInterrupted", message: "stopped" },
time: { created: 2, completed: 3 },
},
{
id: "msg_continued",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "after" }],
time: { created: 4, completed: 5 },
},
] satisfies SessionMessageInfo[]
expect(Timeline.constructSessionMessageRows(messages, true, { type: "idle" }).rows.map((row) => row._tag)).toEqual([
"UserMessage",
"AssistantPart",
"TurnDivider",
"AssistantPart",
])
const compacted = [
messages[0],
messages[1],
{
id: "msg_compaction",
type: "compaction",
status: "completed",
reason: "auto",
summary: "summary",
recent: "recent",
time: { created: 4 },
},
messages[2],
] satisfies SessionMessageInfo[]
expect(Timeline.constructSessionMessageRows(compacted, true, { type: "idle" }).rows.map((row) => row._tag)).toEqual(
["UserMessage", "AssistantPart", "Notice", "AssistantPart"],
)
})
})
@@ -0,0 +1,385 @@
import { parseCommentNote, readPromptPresentation } from "@/utils/comment-note"
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
SessionMessageShell,
SessionMessageUser,
SessionStatus,
} from "@opencode-ai/client/promise"
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow } from "./timeline-row"
export { TimelineRow } from "./timeline-row"
export type TimelineRowMap = {
TurnGap: { userMessageID: string }
UserMessage: {
userMessageID: string
}
Shell: { userMessageID: string; messageID: string }
Notice: { userMessageID: string; messageID: string }
TurnDivider: {
userMessageID: string
}
AssistantPart: {
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
}
Thinking: { userMessageID: string; reasoningHeading?: string }
Retry: { userMessageID: string }
Error: { userMessageID: string; text: string }
}
type Assistant = SessionMessageAssistant
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Entry = { type: "assistant"; message: Assistant } | { type: "notice"; message: Notice }
type Content = Assistant["content"][number]
type ContentRef = { messageID: string; partID: string }
const contextTools = new Set(["read", "glob", "grep", "list"])
export namespace Timeline {
export function constructSessionMessageRows(
messages: SessionMessageInfo[],
showReasoning: boolean,
status: SessionStatus["type"],
) {
type Turn = {
id: string
time: { created: number }
user?: SessionMessageUser
shell?: SessionMessageShell
entries: Entry[]
}
const turns: Turn[] = []
const turnByUserID = new Map<string, (typeof turns)[number]>()
const leading: Notice[] = []
let current: (typeof turns)[number] | undefined
messages.forEach((message) => {
if (isNotice(message)) {
if (current) current.entries.push({ type: "notice", message })
if (!current) leading.push(message)
return
}
if (message.type === "shell") {
const turn: Turn = { id: message.id, time: message.time, shell: message, entries: [] }
turns.push(turn)
current = turn
return
}
if (message.type === "user") {
if (turnByUserID.has(message.id)) return
const turn: Turn = { id: message.id, time: message.time, user: message, entries: [] }
turns.push(turn)
turnByUserID.set(message.id, turn)
current = turn
return
}
if (message.type !== "assistant") return
const existing = current?.user ? current : undefined
if (existing?.user) {
existing.entries.push({ type: "assistant", message })
current = existing
return
}
if (current && !current.user && !current.shell) {
current.entries.push({ type: "assistant", message })
return
}
const turn: Turn = { id: message.id, time: message.time, entries: [{ type: "assistant", message }] }
turns.push(turn)
current = turn
})
const activeMessageID = turns.at(-1)?.id
return {
activeMessageID,
rows: [
...leading.map(
(message) => new TimelineRow.Notice({ userMessageID: turns[0]?.id ?? message.id, messageID: message.id }),
),
...turns.flatMap((turn, index) => {
if (turn.shell)
return [
...(index > 0 ? [new TimelineRow.TurnGap({ userMessageID: turn.id })] : []),
new TimelineRow.Shell({ userMessageID: turn.id, messageID: turn.shell.id }),
...turn.entries.flatMap((entry) =>
entry.type === "notice"
? [new TimelineRow.Notice({ userMessageID: turn.id, messageID: entry.message.id })]
: [],
),
]
return constructMessageRows(
turn.user,
turn.id,
turn.entries,
index,
showReasoning,
status,
turn.id === activeMessageID,
)
}),
],
}
}
export function constructMessageRows(
userMessage: SessionMessageUser | undefined,
turnID: string,
entries: Entry[],
index: number,
showReasoning: boolean,
status: SessionStatus["type"],
isActive: boolean,
) {
const rows: TimelineRow.TimelineRow[] = []
const assistantMessages = entries.flatMap((entry) => (entry.type === "assistant" ? [entry.message] : []))
const previousUserMessage = index > 0
const compaction = entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction")
const error = assistantMessages.at(-1)?.error
const retry = assistantMessages.at(-1)?.retry
const interrupted = error?.type.toLowerCase().includes("abort") || error?.type.toLowerCase().includes("interrupt")
const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) =>
contentEntries(message)
.filter((entry) => renderable(entry.content, showReasoning))
.map((entry) => ({ messageID: message.id, messageIndex, partID: entry.id, content: entry.content })),
)
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: turnID }))
if (userMessage) rows.push(new TimelineRow.UserMessage({ userMessageID: turnID }))
let assistantGroupIndex = 0
const appendAssistants = (messages: Assistant[]) => {
const ids = new Set(messages.map((message) => message.id))
const refs = assistantPartRefs.filter((ref) => ids.has(ref.messageID))
const interruptedAt = messages.findIndex(
(message) =>
message.error?.type.toLowerCase().includes("abort") ||
message.error?.type.toLowerCase().includes("interrupt"),
)
const interruptedID = messages[interruptedAt]?.id
const interruptedIndex = assistantMessages.findIndex((message) => message.id === interruptedID)
const before = interruptedID ? refs.filter((ref) => ref.messageIndex <= interruptedIndex) : refs
const after = interruptedID ? refs.filter((ref) => ref.messageIndex > interruptedIndex) : []
const appendGroups = (items: typeof refs) =>
groupContent(items).forEach((group) => {
rows.push(
new TimelineRow.AssistantPart({
userMessageID: turnID,
group,
previousAssistantPart: assistantGroupIndex > 0,
}),
)
assistantGroupIndex += 1
})
appendGroups(before)
if (interruptedAt >= 0 && !compaction) rows.push(new TimelineRow.TurnDivider({ userMessageID: turnID }))
appendGroups(after)
}
let assistantSegment: Assistant[] = []
entries.forEach((entry) => {
if (entry.type === "assistant") {
assistantSegment.push(entry.message)
return
}
appendAssistants(assistantSegment)
assistantSegment = []
rows.push(new TimelineRow.Notice({ userMessageID: turnID, messageID: entry.message.id }))
})
appendAssistants(assistantSegment)
if (isActive && status === "busy" && !error && !retry && (showReasoning ? assistantPartRefs.length === 0 : true)) {
const heading = assistantMessages
.flatMap((message) => message.content)
.map((content) => (content.type === "reasoning" && content.text ? reasoningHeading(content.text) : undefined))
.find((value): value is string => !!value)
rows.push(
new TimelineRow.Thinking({
userMessageID: turnID,
reasoningHeading: heading,
}),
)
}
if (isActive && retry) rows.push(new TimelineRow.Retry({ userMessageID: turnID }))
if (error && !interrupted) {
rows.push(
new TimelineRow.Error({
userMessageID: turnID,
text: unwrapErrorMessage(error.message),
}),
)
}
return rows
}
export function resolveContent(message: SessionMessageInfo | undefined, partID: string) {
if (message?.type !== "assistant") return
return contentEntries(message).find((entry) => entry.id === partID)?.content
}
export function contentEntries(message: Assistant) {
const ordinals = { text: 0, reasoning: 0 }
return message.content.map((content) => ({
id: content.type === "tool" ? content.id : `${message.id}:${content.type}:${ordinals[content.type]++}`,
content,
}))
}
function renderable(content: Content, showReasoning: boolean) {
if (content.type === "text") return !!content.text.trim()
if (content.type === "reasoning") return showReasoning && !!content.text.trim()
if (content.name === "todowrite") return false
if (content.name === "question") return content.state.status !== "streaming" && content.state.status !== "running"
return true
}
function groupContent(items: { messageID: string; partID: string; content: Content }[]): PartGroup[] {
const groups: PartGroup[] = []
let context: ContentRef[] = []
const flush = () => {
const first = context[0]
if (!first) return
groups.push({ type: "context", key: `context:${first.partID}`, refs: context })
context = []
}
items.forEach((item) => {
if (item.content.type === "tool" && contextTools.has(item.content.name)) {
context.push({ messageID: item.messageID, partID: item.partID })
return
}
flush()
groups.push({
type: "part",
key: `part:${item.messageID}:${item.partID}`,
ref: { messageID: item.messageID, partID: item.partID },
})
})
flush()
return groups
}
function reasoningHeading(text: string) {
const markdown = text.replace(/\r\n?/g, "\n")
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
if (html?.[1]) {
const value = cleanHeading(html[1].replace(/<[^>]+>/g, " "))
if (value) return value
}
const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m)
if (atx?.[1]) {
const value = cleanHeading(atx[1])
if (value) return value
}
const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m)
if (setext?.[1]) {
const value = cleanHeading(setext[1])
if (value) return value
}
const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m)
if (strong?.[1]) {
const value = cleanHeading(strong[1])
if (value) return value
}
}
function cleanHeading(value: string) {
return value
.replace(/`([^`]+)`/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[*_~]+/g, "")
.trim()
}
function unwrapErrorMessage(message: string) {
const text = message.replace(/^Error:\s*/, "").trim()
const parse = (value: string) => {
try {
return JSON.parse(value) as unknown
} catch {
return undefined
}
}
const read = (value: string) => {
const first = parse(value)
if (typeof first !== "string") return first
return parse(first.trim())
}
let json = read(text)
if (json === undefined) {
const start = text.indexOf("{")
const end = text.lastIndexOf("}")
if (start !== -1 && end > start) json = read(text.slice(start, end + 1))
}
if (!record(json)) return message
const err = record(json.error) ? json.error : undefined
if (err) {
const type = typeof err.type === "string" ? err.type : undefined
const msg = typeof err.message === "string" ? err.message : undefined
if (type && msg) return `${type}: ${msg}`
if (msg) return msg
if (type) return type
const code = typeof err.code === "string" ? err.code : undefined
if (code) return code
}
const msg = typeof json.message === "string" ? json.message : undefined
if (msg) return msg
const reason = typeof json.error === "string" ? json.error : undefined
if (reason) return reason
return message
}
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function isNotice(
message: SessionMessageInfo,
): message is Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }> {
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
if (message.type !== "synthetic") return true
return !!message.description?.trim()
}
}
export namespace MessageComment {
export type MessageComment = {
path: string
comment: string
selection?: {
startLine: number
endLine: number
}
}
export const fromMessage = (message: SessionMessageUser): MessageComment[] => {
const presentation = readPromptPresentation(message.metadata)
const parsed = presentation ? undefined : parseCommentNote(message.text)
const comments = presentation?.comments ?? (parsed ? [parsed] : [])
return comments.map((comment) => ({
path: comment.path,
comment: comment.comment,
selection: comment.selection
? { startLine: comment.selection.startLine, endLine: comment.selection.endLine }
: undefined,
}))
}
}
@@ -1,61 +1,37 @@
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { Data, Equal } from "effect"
export type PartRef = {
messageID: string
partID: string
}
export type PartGroup =
| {
key: string
type: "part"
ref: PartRef
}
| {
key: string
type: "context"
refs: PartRef[]
}
export namespace TimelineRow {
export class TurnGap extends Data.TaggedClass("TurnGap")<{
userMessageID: string
}> {}
export class UserMessage extends Data.TaggedClass("UserMessage")<{
userMessageID: string
}> {}
export class Shell extends Data.TaggedClass("Shell")<{
userMessageID: string
messageID: string
}> {}
export class Notice extends Data.TaggedClass("Notice")<{
userMessageID: string
messageID: string
}> {}
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
userMessageID: string
}> {}
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
}> {}
export class Thinking extends Data.TaggedClass("Thinking")<{
userMessageID: string
reasoningHeading?: string
}> {}
export class Error extends Data.TaggedClass("Error")<{
userMessageID: string
text: string
}> {}
export class Retry extends Data.TaggedClass("Retry")<{
userMessageID: string
}> {}
@@ -71,7 +47,7 @@ export namespace TimelineRow {
| Error
| Retry
export const key = (row: TimelineRow): string => {
export const key = (row: TimelineRow) => {
switch (row._tag) {
case "TurnGap":
return `turn-gap:${row.userMessageID}`
@@ -92,26 +68,9 @@ export namespace TimelineRow {
case "Retry":
return `retry:${row.userMessageID}`
}
return row
}
export function equals(a: TimelineRow, b: TimelineRow) {
return Equal.equals(a, b)
}
}
export type TimelineRowMap = {
TurnGap: { userMessageID: string }
UserMessage: { userMessageID: string }
Shell: { userMessageID: string; messageID: string }
Notice: { userMessageID: string; messageID: string }
TurnDivider: { userMessageID: string }
AssistantPart: {
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
}
Thinking: { userMessageID: string; reasoningHeading?: string }
Retry: { userMessageID: string }
Error: { userMessageID: string; text: string }
}
@@ -1,507 +0,0 @@
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
import { TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "@/pages/session/message-gesture"
import { useLanguage } from "@/context/language"
import {
createEffect,
createMemo,
createSignal,
For,
on,
onCleanup,
onMount,
Show,
type Accessor,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import type { createTimelineProjection } from "./projection"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { filterVirtualIndexes } from "./virtual-items"
const fallbackItemSize = 60
const cache = new Map<string, { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }>()
type Projection = Pick<
ReturnType<typeof createTimelineProjection>,
"activeMessageID" | "messageLastRowIndex" | "messageRowIndex" | "rowByKey" | "rows"
>
type Input = {
sessionKey: Accessor<string>
projection: Projection
showHeader: Accessor<boolean>
shouldAnchorBottom: Accessor<boolean>
hasScrollGesture: Accessor<boolean>
scroll: Accessor<{ overflow: boolean; bottom: boolean; jump: boolean }>
onResumeScroll: () => void
setScrollRef: (element: HTMLDivElement | undefined) => void
setContentRef: (element: HTMLDivElement) => void
onScheduleScrollState: (element: HTMLDivElement) => void
onAutoScrollHandleScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
onMarkScrollGesture: (target?: EventTarget | null) => void
onUserScroll: () => void
onHistoryScroll: () => void
setRevealMessage?: (fn: (id: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
}
type ViewProps = {
header: JSX.Element
workspaceSession: Accessor<boolean>
deferred: (row: TimelineRow.TimelineRow) => boolean
renderRow: (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => JSX.Element
}
export function createTimelineVirtualizer(input: Input) {
const language = useLanguage()
const ownerSessionKey = input.sessionKey()
const cached = cache.get(ownerSessionKey)
const initialMeasurements = cached?.measurements
const coldBottomMount = !initialMeasurements?.length && input.shouldAnchorBottom()
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 20)
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
let touchGesture: number | undefined
let prependAnchor: { key: string; offset: number } | undefined
let prependAnchorFrame: number | undefined
let prependLoading = false
let resizePinnedIndexes: number[] = []
let resizePinFrame: number | undefined
let virtualContent: HTMLDivElement | undefined
const clearPrependAnchor = () => {
prependLoading = false
prependAnchor = undefined
if (prependAnchorFrame === undefined) return
cancelAnimationFrame(prependAnchorFrame)
prependAnchorFrame = undefined
}
const capturePrependAnchor = () => {
prependLoading = true
updatePrependAnchor()
}
const updatePrependAnchor = () => {
const root = listRoot()
if (!root) return
const view = root.getBoundingClientRect()
const anchor = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
.filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
.sort((a, b) => a.rect.top - b.rect.top)[0]
if (!anchor) return
if (!anchor.element.dataset.timelineKey) return
prependAnchor = { key: anchor.element.dataset.timelineKey, offset: anchor.rect.top - view.top }
}
const restorePrependAnchor = (done: boolean) => {
if (done) prependLoading = false
applyPrependAnchor()
}
const applyPrependAnchor = () => {
const root = listRoot()
if (!root || !prependAnchor) return
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
let frames = 0
let stable = 0
const apply = () => {
prependAnchorFrame = undefined
const anchor = prependAnchor
if (!anchor) return
const element = root.querySelector<HTMLElement>(`[data-timeline-key="${CSS.escape(anchor.key)}"]`)
const delta = element
? element.getBoundingClientRect().top - root.getBoundingClientRect().top - anchor.offset
: undefined
if (delta !== undefined && Math.abs(delta) > 0.5) {
root.scrollTop += delta
stable = 0
} else {
stable += 1
}
frames += 1
if (stable >= 30 || frames >= 180) {
if (!prependLoading) prependAnchor = undefined
return
}
prependAnchorFrame = requestAnimationFrame(apply)
}
prependAnchorFrame = requestAnimationFrame(apply)
}
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
get count() {
return rows().length
},
getScrollElement: () => listRoot() ?? null,
observeElementOffset: observeElementOffsetReconnectAware,
initialOffset: () => (input.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => fallbackItemSize,
scrollToFn: (offset, options, instance) => {
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
elementScroll(offset, options, instance)
},
get getItemKey() {
const items = rows()
return (index: number) => {
const row = items[index]
if (!row) return `removed:${index}`
return TimelineRow.key(row)
}
},
anchorTo: "end",
followOnAppend: true,
scrollEndThreshold: 80,
get scrollMargin() {
return input.showHeader() ? 64 : 0
},
overscan: 50,
paddingEnd: 64,
rangeExtractor: (range) => {
const id = input.projection.activeMessageID()
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() })
return filterVirtualIndexes(
[...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
},
})
const resizeItem = virtualizer.resizeItem
let resizeAnchorScheduled = false
const anchorResizedBottom = () => {
if (resizeAnchorScheduled || input.hasScrollGesture()) return
resizeAnchorScheduled = true
queueMicrotask(() => {
resizeAnchorScheduled = false
if (!input.shouldAnchorBottom() || input.hasScrollGesture()) return
virtualizer.scrollToEnd()
})
}
virtualizer.resizeItem = (index, size) => {
const item = virtualizer.measurementsCache[index]
const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined
const root = listRoot()
if (root && previous !== undefined && Math.abs(size - previous) > root.clientHeight) {
const view = root.getBoundingClientRect()
resizePinnedIndexes = [...root.querySelectorAll<HTMLElement>("[data-index]")]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((element) => Number(element.dataset.index))
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
resizePinFrame = requestAnimationFrame(() => {
resizePinFrame = requestAnimationFrame(() => {
resizePinFrame = undefined
resizePinnedIndexes = []
})
})
}
resizeItem(index, size)
if (root && input.shouldAnchorBottom()) anchorResizedBottom()
}
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => {
if (input.shouldAnchorBottom()) return false
const first = virtualizer.range?.startIndex
return first !== undefined && item.index < first
}
const virtualItemByKey = createMemo(
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
)
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
createEffect(() => {
input.setRevealMessage?.((id) => {
const index = input.projection.messageRowIndex().get(id)
if (index === undefined) return
virtualizer.scrollToIndex(index, { align: "center" })
})
input.setScrollToEnd?.(() => virtualizer.scrollToEnd())
input.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor })
})
let overscanFrame: number | undefined
onMount(() => {
overscanFrame = requestAnimationFrame(() => {
if (input.shouldAnchorBottom()) virtualizer.scrollToEnd()
overscanFrame = requestAnimationFrame(() => {
overscanFrame = undefined
if (renderOverscan() < 20) setRenderOverscan(20)
if (input.shouldAnchorBottom()) virtualizer.scrollToEnd()
})
})
})
const maybeAnchorBottom = () => {
if (rows().length === 0) return
if (!input.shouldAnchorBottom() || input.hasScrollGesture()) return
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
clearPrependAnchor()
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
virtualizer.scrollToEnd()
}
let measuredSessionKey = input.sessionKey()
createEffect(() => {
const key = input.sessionKey()
rows().length
if (measuredSessionKey !== key) {
measuredSessionKey = key
virtualizer.measure()
}
maybeAnchorBottom()
})
const bindListRoot = (root: HTMLDivElement) => {
if (root === listRoot()) return
setListRoot(root)
input.setScrollRef(root)
}
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
if (!prependLoading) clearPrependAnchor()
const root = event.currentTarget
const delta = normalizeWheelDelta({
deltaY: event.deltaY,
deltaMode: event.deltaMode,
rootHeight: root.clientHeight,
})
if (!delta) return
markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: input.onMarkScrollGesture })
}
const handleListTouchStart = (event: TouchEvent) => {
if (!prependLoading) clearPrependAnchor()
touchGesture = event.touches[0]?.clientY
}
const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
const next = event.touches[0]?.clientY
const previous = touchGesture
touchGesture = next
if (next === undefined || previous === undefined) return
const delta = previous - next
if (!delta) return
markBoundaryGesture({
root: event.currentTarget,
target: event.target,
delta,
onMarkScrollGesture: input.onMarkScrollGesture,
})
}
const handleListTouchEnd = () => {
touchGesture = undefined
}
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
if (!prependLoading) clearPrependAnchor()
input.onMarkScrollGesture(event.target)
}
const handleListPointerMove = (event: PointerEvent) => {
if (event.buttons !== 1) return
input.onMarkScrollGesture(event.target)
}
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
const key = scrollKey(event)
if (!key) return
if (!isScrollKeyTarget(event.target, key)) return
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
if (!prependLoading) clearPrependAnchor()
input.onMarkScrollGesture(event.currentTarget)
}
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (prependLoading) updatePrependAnchor()
input.onScheduleScrollState(event.currentTarget)
input.onHistoryScroll()
if (!input.hasScrollGesture()) return
input.onUserScroll()
input.onAutoScrollHandleScroll()
input.onMarkScrollGesture(event.currentTarget)
}
function View(props: ViewProps) {
function VirtualRow(rowProps: { rowKey: string }) {
let element: HTMLDivElement
const initialItem = virtualItemByKey().get(rowProps.rowKey)!
const initialRow = rowByKey().get(rowProps.rowKey)!
const item = createMemo(() => virtualItemByKey().get(rowProps.rowKey) ?? initialItem)
const row = createMemo(() => rowByKey().get(rowProps.rowKey) ?? rows()[item().index] ?? initialRow)
const [ready, setReady] = createSignal(initialItem.size <= fallbackItemSize || !props.deferred(initialRow))
let contentMeasureFrame: number | undefined
onMount(() => virtualizer.measureElement(element))
createEffect(
on(
() => item().index,
() => {
virtualizer.measureElement(element)
},
{ defer: true },
),
)
onCleanup(() => {
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
queueMicrotask(() => virtualizer.measureElement(null))
})
return (
<div
data-timeline-key={rowProps.rowKey}
style={{
position: "absolute",
top: `${item().start - (input.showHeader() ? 64 : 0)}px`,
left: "0",
width: "100%",
height: `${item().size}px`,
overflow: "clip",
"overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "0.5px",
}}
>
<div
ref={(value) => {
element = value
}}
data-index={item().index}
style={{ "min-height": ready() ? undefined : `${initialItem.size}px` }}
>
{props.renderRow(row, () => {
setReady(true)
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
contentMeasureFrame = scheduleConnectedMeasure(element, virtualizer.measureElement)
})}
</div>
</div>
)
}
return (
<div class="relative w-full h-full min-w-0" data-workspace-session={props.workspaceSession() ? "" : undefined}>
<div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
"bottom-8": true,
"opacity-100 translate-y-0 scale-100": input.scroll().overflow && input.scroll().jump,
"opacity-0 translate-y-2 pointer-events-none": !input.scroll().overflow || !input.scroll().jump,
"scale-[0.8]": !input.scroll().overflow || !input.scroll().jump,
}}
>
<button
type="button"
aria-label={language.t("session.messages.jumpToLatest")}
class="pointer-events-auto flex items-center justify-center w-8 h-7 px-2 py-1.5 rounded-lg border-none cursor-pointer text-v2-text-text-base backdrop-blur-[2px]"
style={{
background: "color-mix(in srgb, var(--v2-background-bg-base) 92%, transparent)",
"box-shadow": "var(--v2-elevation-raised), 0px 2px 8px var(--v2-background-bg-base)",
}}
onClick={input.onResumeScroll}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M12.3333 8.66665L8 13L3.66667 8.66665M8 12.6667V2.83332"
stroke="currentColor"
stroke-linecap="square"
/>
</svg>
</button>
</div>
<ScrollView
viewportRef={bindListRoot}
onWheel={handleListWheel}
onTouchStart={handleListTouchStart}
onTouchMove={handleListTouchMove}
onTouchEnd={handleListTouchEnd}
onTouchCancel={handleListTouchEnd}
onPointerDown={handleListPointerDown}
onPointerMove={handleListPointerMove}
onKeyDown={handleListKeyDown}
onScroll={handleListScroll}
onClick={input.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full"
style={{ "--sticky-accordion-top": input.showHeader() ? "48px" : "0px" }}
>
<Show when={input.showHeader()}>{props.header}</Show>
<div
data-timeline-virtual-content
ref={(element) => {
virtualContent = element
input.setContentRef(element)
}}
style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", width: "100%" }}
>
<For each={virtualRowKeys()}>{(rowKey) => <VirtualRow rowKey={rowKey} />}</For>
<Show when={rows().length > 0}>
<div
data-timeline-row="bottom-spacer"
aria-hidden="true"
class="h-16 absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
/>
</Show>
</div>
</ScrollView>
</div>
)
}
onCleanup(() => {
clearPrependAnchor()
cache.delete(ownerSessionKey)
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
while (cache.size > 16) cache.delete(cache.keys().next().value!)
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
input.setHistoryAnchor?.({ capture: () => {}, restore: () => {} })
})
return {
disclosure: {
value: (key: string) => toolOpen[key],
set: (key: string, open: boolean) => setToolOpen(key, open),
},
View,
}
}
function boundaryTarget(root: HTMLElement, target: EventTarget | null) {
const current = target instanceof Element ? target : undefined
const nested = current?.closest("[data-scrollable]")
if (!(nested instanceof HTMLElement) || nested === root) return undefined
return nested
}
function markBoundaryGesture(input: {
root: HTMLElement
target: EventTarget | null
delta: number
onMarkScrollGesture: (target?: EventTarget | null) => void
}) {
const target = boundaryTarget(input.root, input.target)
if (
target &&
!shouldMarkBoundaryGesture({
delta: input.delta,
scrollTop: target.scrollTop,
scrollHeight: target.scrollHeight,
clientHeight: target.clientHeight,
})
)
return
input.onMarkScrollGesture(input.root)
}
@@ -52,29 +52,12 @@ export type ReviewPanelV2Props = {
comments?: SessionReviewComment[]
focusedComment?: SessionReviewFocus | null
onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void
fileList?: "tree" | "flat"
}
export function ReviewPanelV2(props: ReviewPanelV2Props) {
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const readFile = async (path: string) =>
serverSDK.api.file
.read({ path, location: { directory: sdk().directory } })
.then((data) => ({ type: "text" as const, content: new TextDecoder().decode(data) }))
.catch((error) => {
console.debug("[session-review-v2] failed to read file", { path, error })
return undefined
})
return <ReviewPanelV2View {...props} readFile={readFile} />
}
export function ReviewPanelV2View(
props: ReviewPanelV2Props & {
readFile?: (path: string) => Promise<{ type: "text"; content: string } | undefined>
},
) {
const diffs = createMemo(() => props.diffs.filter(filterRenderableDiff))
const filteredFiles = createMemo(() =>
filterReviewFiles(
@@ -101,12 +84,12 @@ export function ReviewPanelV2View(
const detailSource = createMemo(() => {
const diff = sourceActiveItem()
const load = props.loadDiff
if (!diff || !load || !reviewDiffNeedsLoad(diff)) return undefined
if (!diff || !load || !reviewDiffNeedsLoad(diff)) return
return { diff, load, version: props.diffVersion }
})
const [loadedDiff] = createResource(detailSource, async ({ diff, load, version }) => {
const value = await load(diff.file, version)
if (value?.file !== diff.file) return undefined
if (value?.file !== diff.file) return
return { source: diff, version, value }
})
@@ -118,6 +101,15 @@ export function ReviewPanelV2View(
return source
})
const readFile = async (path: string) =>
serverSDK.api.file
.read({ path, location: { directory: sdk().directory } })
.then((data) => ({ type: "text" as const, content: new TextDecoder().decode(data) }))
.catch((error) => {
console.debug("[session-review-v2] failed to read file", { path, error })
return undefined
})
return (
<SessionReviewV2
title={props.title}
@@ -137,7 +129,6 @@ export function ReviewPanelV2View(
searching={searching()}
kinds={treeKinds()}
activeDiff={activeDiff()}
flat={props.fileList === "flat"}
/>
}
activeFile={activeDiff()}
@@ -160,7 +151,7 @@ export function ReviewPanelV2View(
diff={diff()}
diffStyle={props.diffStyle}
expandMode={props.state.expandMode()}
readFile={props.readFile}
readFile={readFile}
onLineComment={props.onLineComment}
onLineCommentUpdate={props.onLineCommentUpdate}
onLineCommentDelete={props.onLineCommentDelete}
@@ -188,7 +179,6 @@ function ReviewPanelV2Sidebar(props: {
searching: boolean
kinds: ReturnType<typeof reviewDiffKinds>
activeDiff: string | undefined
flat: boolean
}) {
const language = useLanguage()
const [explicitHighlight, setExplicitHighlight] = createSignal<string | undefined>()
@@ -235,25 +225,13 @@ function ReviewPanelV2Sidebar(props: {
<Show
when={props.searching}
fallback={
<Show
when={props.flat}
fallback={
<FileTreeV2
allowed={props.filteredFiles}
kinds={props.kinds}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.onSelectFile(node.path)}
/>
}
>
<SessionFileListV2
files={props.filteredFiles}
kinds={props.kinds}
active={props.activeDiff}
onFileClick={props.onSelectFile}
/>
</Show>
<FileTreeV2
allowed={props.filteredFiles}
kinds={props.kinds}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.onSelectFile(node.path)}
/>
}
>
<Show
+194 -1
View File
@@ -1,10 +1,203 @@
import type { ProjectListOutput, WorktreeDirectory } from "@opencode-ai/client/promise"
import type { FileDiffInfo, ProjectListOutput, WorktreeDirectory } from "@opencode-ai/client/promise"
export type Project = Omit<ProjectListOutput[number], "canonical"> & {
worktree: string
worktrees: WorktreeDirectory[]
}
type MessageError =
| { name: "ProviderAuthError"; data: { providerID: string; message: string } }
| { name: "UnknownError"; data: { message: string; ref?: string } }
| { name: "MessageOutputLengthError"; data: Record<string, unknown> }
| { name: "MessageAbortedError"; data: { message: string } }
| { name: "StructuredOutputError"; data: { message: string; retries: number } }
| { name: "ContextOverflowError"; data: { message: string; responseBody?: string } }
| { name: "ContentFilterError"; data: { message: string } }
| {
name: "APIError"
data: {
message: string
statusCode?: number
isRetryable: boolean
responseHeaders?: Record<string, string>
responseBody?: string
metadata?: Record<string, string>
}
}
export type UserMessage = {
id: string
sessionID: string
role: "user"
time: { created: number }
format?: { type: "text" } | { type: "json_schema"; schema: Record<string, unknown>; retryCount?: number }
summary?: { title?: string; body?: string; diffs: FileDiffInfo[] }
agent: string
model: { providerID: string; modelID: string; variant?: string }
system?: string
tools?: Record<string, boolean>
}
export type AssistantMessage = {
id: string
sessionID: string
role: "assistant"
time: { created: number; completed?: number }
error?: MessageError
parentID: string
modelID: string
providerID: string
mode: string
agent: string
path: { cwd: string; root: string }
summary?: boolean
cost: number
tokens: {
total?: number
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
structured?: unknown
variant?: string
finish?: string
}
export type Message = UserMessage | AssistantMessage
type PartBase = { id: string; sessionID: string; messageID: string }
export type TextPart = PartBase & {
type: "text"
text: string
synthetic?: boolean
ignored?: boolean
time?: { start: number; end?: number }
metadata?: Record<string, unknown>
}
type FilePartSourceText = { value: string; start: number; end: number }
type FileSource = { text: FilePartSourceText; type: "file"; path: string }
type SymbolSource = {
text: FilePartSourceText
type: "symbol"
path: string
range: { start: { line: number; character: number }; end: { line: number; character: number } }
name: string
kind: number
}
type ResourceSource = { text: FilePartSourceText; type: "resource"; clientName: string; uri: string }
export type FilePartSource = FileSource | SymbolSource | ResourceSource
export type FilePart = PartBase & {
type: "file"
mime: string
filename?: string
url: string
source?: FilePartSource
}
export type ToolState =
| { status: "pending"; input: Record<string, unknown>; raw: string }
| {
status: "running"
input: Record<string, unknown>
title?: string
metadata?: Record<string, unknown>
time: { start: number }
}
| {
status: "completed"
input: Record<string, unknown>
output: string
title: string
metadata: Record<string, unknown>
time: { start: number; end: number; compacted?: number }
attachments?: FilePart[]
}
| {
status: "error"
input: Record<string, unknown>
error: string
metadata?: Record<string, unknown>
time: { start: number; end: number }
}
export type ToolPart = PartBase & {
type: "tool"
callID: string
tool: string
state: ToolState
metadata?: Record<string, unknown>
}
export type AgentPart = PartBase & {
type: "agent"
name: string
source?: { value: string; start: number; end: number }
}
type ReasoningPart = PartBase & {
type: "reasoning"
text: string
metadata?: Record<string, unknown>
time: { start: number; end?: number }
}
type SubtaskPart = PartBase & {
type: "subtask"
prompt: string
description: string
agent: string
model?: { providerID: string; modelID: string }
command?: string
}
type StepStartPart = PartBase & { type: "step-start"; snapshot?: string }
type StepFinishPart = PartBase & {
type: "step-finish"
reason: string
snapshot?: string
cost: number
tokens: AssistantMessage["tokens"]
}
type SnapshotPart = PartBase & { type: "snapshot"; snapshot: string }
type PatchPart = PartBase & { type: "patch"; hash: string; files: string[] }
type RetryPart = PartBase & {
type: "retry"
attempt: number
error: Extract<MessageError, { name: "APIError" }>
time: { created: number }
}
type CompactionPart = PartBase & {
type: "compaction"
auto: boolean
overflow?: boolean
tail_start_id?: string
}
export type Part =
| TextPart
| SubtaskPart
| ReasoningPart
| FilePart
| ToolPart
| StepStartPart
| StepFinishPart
| SnapshotPart
| PatchPart
| AgentPart
| RetryPart
| CompactionPart
export type Todo = {
content: string
status: string
priority: string
}
export type FileNode = {
name: string
path: string
+93
View File
@@ -0,0 +1,93 @@
const prefixes = {
session: "ses",
message: "msg",
permission: "per",
user: "usr",
part: "prt",
pty: "pty",
} as const
const LENGTH = 26
let lastTimestamp = 0
let counter = 0
type Prefix = keyof typeof prefixes
export namespace Identifier {
export function ascending(prefix: Prefix, given?: string) {
return generateID(prefix, false, given)
}
export function descending(prefix: Prefix, given?: string) {
return generateID(prefix, true, given)
}
}
function generateID(prefix: Prefix, descending: boolean, given?: string): string {
if (!given) {
return create(prefix, descending)
}
if (!given.startsWith(prefixes[prefix])) {
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
}
return given
}
function create(prefix: Prefix, descending: boolean, timestamp?: number): string {
const currentTimestamp = timestamp ?? Date.now()
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}
counter += 1
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
if (descending) {
now = ~now
}
const timeBytes = new Uint8Array(6)
for (let i = 0; i < 6; i += 1) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return prefixes[prefix] + "_" + bytesToHex(timeBytes) + randomBase62(LENGTH - 12)
}
function bytesToHex(bytes: Uint8Array): string {
let hex = ""
for (let i = 0; i < bytes.length; i += 1) {
hex += bytes[i].toString(16).padStart(2, "0")
}
return hex
}
function randomBase62(length: number): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const bytes = getRandomBytes(length)
let result = ""
for (let i = 0; i < length; i += 1) {
result += chars[bytes[i] % 62]
}
return result
}
function getRandomBytes(length: number): Uint8Array {
const bytes = new Uint8Array(length)
const cryptoObj = typeof globalThis !== "undefined" ? globalThis.crypto : undefined
if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
cryptoObj.getRandomValues(bytes)
return bytes
}
for (let i = 0; i < length; i += 1) {
bytes[i] = Math.floor(Math.random() * 256)
}
return bytes
}
@@ -0,0 +1,128 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageUser } from "@opencode-ai/client/promise"
import { presentAssistantParts, presentUserParts } from "./session-message"
describe("session message presentation", () => {
test("projects current user content for the DOM renderer", () => {
const message = {
id: "msg_user",
type: "user",
text: "inspect @src/client.ts",
files: [
{
data: "ZXhwb3J0IHt9",
mime: "text/plain",
name: "client.ts",
source: { type: "inline" },
mention: { text: "@src/client.ts", start: 8, end: 22 },
},
],
agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }],
time: { created: 1 },
} satisfies SessionMessageUser
const parts = presentUserParts("ses_1", message)
expect(parts.map((part) => part.id)).toEqual(["msg_user:text:0", "msg_user:file:0", "msg_user:agent:0"])
expect(parts[1]).toMatchObject({
type: "file",
source: {
type: "file",
path: "src/client.ts",
text: { value: "@src/client.ts", start: 8, end: 22 },
},
})
const plainMention = {
...message,
text: "inspect src/client.ts",
files: [
{
...message.files[0],
name: "client.ts",
mention: { text: "src/client.ts", start: 8, end: 21 },
},
],
} satisfies SessionMessageUser
expect(presentUserParts("ses_1", plainMention)[1]).toMatchObject({
type: "file",
source: { type: "file", path: "src/client.ts" },
})
})
test("projects current assistant content for existing DOM tools", () => {
const message = {
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "claude", providerID: "anthropic", variant: "high" },
content: [
{ type: "reasoning", text: "Thinking", time: { created: 2, completed: 3 } },
{ type: "text", text: "Result" },
{
type: "tool",
id: "call_1",
name: "read",
state: {
status: "completed",
input: { filePath: "note.txt" },
metadata: { title: "note.txt" },
content: [{ type: "text", text: "hello" }],
},
time: { created: 3, ran: 4, completed: 5 },
},
],
cost: 0.1,
tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } },
time: { created: 2, completed: 5 },
} satisfies SessionMessageAssistant
const parts = presentAssistantParts("ses_1", message)
expect(parts.map((part) => part.id)).toEqual(["msg_assistant:reasoning:0", "msg_assistant:text:0", "call_1"])
expect(parts[2]).toMatchObject({ type: "tool", tool: "read", state: { status: "completed", output: "hello" } })
})
test("adapts current edit fields only at the renderer boundary", () => {
const message = {
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_edit",
name: "edit",
state: {
status: "completed",
input: { path: "/repo/README.md", oldString: "old", newString: "new" },
content: [{ type: "text", text: "Edited file successfully" }],
metadata: {
files: [{ file: "README.md", patch: "@@ -1 +1 @@\n-old\n+new", additions: 1, deletions: 1 }],
},
},
time: { created: 2, ran: 3, completed: 4 },
},
],
time: { created: 2, completed: 4 },
} satisfies SessionMessageAssistant
expect(presentAssistantParts("ses_1", message)).toEqual([
expect.objectContaining({
type: "tool",
state: expect.objectContaining({
input: expect.objectContaining({ path: "/repo/README.md", filePath: "/repo/README.md" }),
metadata: expect.objectContaining({
filediff: {
file: "README.md",
patch: "@@ -1 +1 @@\n-old\n+new",
additions: 1,
deletions: 1,
},
}),
}),
}),
])
})
})
+241
View File
@@ -0,0 +1,241 @@
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import type { AssistantMessage, FilePart, Part, ToolPart, UserMessage } from "@/types"
import { Option, Schema } from "effect"
import { createCommentMetadata, formatCommentNote, readPromptPresentation } from "./comment-note"
const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const decodeToolInput = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function normalizeToolInput(name: string, input: Record<string, unknown>) {
if (!["edit", "write"].includes(name) || typeof input.path !== "string" || typeof input.filePath === "string")
return input
return { ...input, filePath: input.path }
}
function normalizeToolMetadata(name: string, metadata: Record<string, unknown>) {
if (name !== "edit" || !Array.isArray(metadata.files)) return metadata
const file = metadata.files.find(record)
if (!file || typeof file.file !== "string") return metadata
return {
...metadata,
filediff: {
file: file.file,
patch: typeof file.patch === "string" ? file.patch : undefined,
additions: typeof file.additions === "number" ? file.additions : 0,
deletions: typeof file.deletions === "number" ? file.deletions : 0,
},
}
}
export function sessionMessagePartID(messageID: string, type: "text" | "reasoning", ordinal: number) {
return `${messageID}:${type}:${ordinal}`
}
export function presentUserMessage(
sessionID: string,
message: SessionMessageUser,
agent: string,
model: { id: string; providerID: string; variant?: string },
): UserMessage {
return {
id: message.id,
sessionID,
role: "user",
time: message.time,
agent,
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
}
}
export function presentUserParts(sessionID: string, message: SessionMessageUser): Part[] {
const presentation = readPromptPresentation(message.metadata)
const text = presentation?.displayText ?? message.text
return [
...(text ? [textPart(sessionID, message.id, 0, text)] : []),
...(message.files ?? []).map(
(file, index): FilePart => ({
id: `${message.id}:file:${index}`,
sessionID,
messageID: message.id,
type: "file",
mime: file.mime,
filename: file.name,
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
source: file.mention
? {
type: "file",
text: { value: file.mention.text, start: file.mention.start, end: file.mention.end },
path: file.mention.text.startsWith("@") ? file.mention.text.slice(1) : file.mention.text,
}
: undefined,
}),
),
...(message.agents ?? []).map(
(item, index): Part => ({
id: `${message.id}:agent:${index}`,
sessionID,
messageID: message.id,
type: "agent",
name: item.name,
source: item.mention
? { value: item.mention.text, start: item.mention.start, end: item.mention.end }
: undefined,
}),
),
...(presentation?.comments ?? []).map(
(comment, index): Part => ({
id: `${message.id}:comment:${index}`,
sessionID,
messageID: message.id,
type: "text",
text: formatCommentNote(comment),
synthetic: true,
metadata: createCommentMetadata(comment),
}),
),
]
}
export function presentAssistantMessage(
sessionID: string,
parentID: string,
message: SessionMessageAssistant,
): AssistantMessage {
const error = message.error
? message.error.type.toLowerCase().includes("abort") || message.error.type.toLowerCase().includes("interrupt")
? { name: "MessageAbortedError" as const, data: { message: message.error.message } }
: { name: "UnknownError" as const, data: { message: message.error.message } }
: undefined
return {
id: message.id,
sessionID,
role: "assistant",
time: message.time,
error,
parentID,
modelID: message.model.id,
providerID: message.model.providerID,
variant: message.model.variant,
mode: message.agent,
agent: message.agent,
path: { cwd: "", root: "" },
cost: message.cost ?? 0,
tokens: message.tokens ?? emptyTokens,
finish: message.finish,
}
}
export function presentAssistantParts(sessionID: string, message: SessionMessageAssistant): Part[] {
const ordinals = { text: 0, reasoning: 0 }
return message.content.flatMap((content): Part[] => {
const id =
content.type === "tool" ? content.id : sessionMessagePartID(message.id, content.type, ordinals[content.type]++)
const part = presentAssistantContent(sessionID, message, id, content)
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return []
return [part]
})
}
export function presentAssistantContent(
sessionID: string,
message: SessionMessageAssistant,
id: string,
content: SessionMessageAssistant["content"][number],
): Part {
if (content.type === "text") return { id, sessionID, messageID: message.id, type: "text", text: content.text }
if (content.type === "reasoning")
return {
id,
sessionID,
messageID: message.id,
type: "reasoning",
text: content.text,
metadata: content.state,
time: {
start: content.time?.created ?? message.time.created,
end: content.time?.completed,
},
}
return toolPart(sessionID, message.id, content)
}
function textPart(sessionID: string, messageID: string, ordinal: number, text: string, synthetic?: boolean): Part {
return {
id: sessionMessagePartID(messageID, "text", ordinal),
sessionID,
messageID,
type: "text",
text,
synthetic,
}
}
function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssistantTool): ToolPart {
const start = tool.time.ran ?? tool.time.created
const state = (() => {
if (tool.state.status === "streaming") {
const value = Option.getOrUndefined(decodeToolInput(tool.state.input))
const input = normalizeToolInput(tool.name, record(value) ? value : {})
return { status: "pending" as const, input, raw: tool.state.input }
}
if (tool.state.status === "running") {
return {
status: "running" as const,
input: normalizeToolInput(tool.name, tool.state.input),
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start },
}
}
if (tool.state.status === "error") {
return {
status: "error" as const,
input: normalizeToolInput(tool.name, tool.state.input),
error: tool.state.error.message,
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start, end: tool.time.completed ?? start },
}
}
const attachments = tool.state.content.flatMap((item, index): FilePart[] =>
item.type === "file"
? [
{
id: `${tool.id}:file:${index}`,
sessionID,
messageID,
type: "file",
mime: item.mime,
filename: item.name ?? undefined,
url: item.uri,
},
]
: [],
)
return {
status: "completed" as const,
input: normalizeToolInput(tool.name, tool.state.input),
output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"),
title: tool.name,
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start, end: tool.time.completed ?? start },
attachments: attachments.length ? attachments : undefined,
}
})()
return {
id: tool.id,
sessionID,
messageID,
type: "tool",
callID: tool.id,
tool: tool.name,
state,
metadata: { providerState: tool.providerState, providerResultState: tool.providerResultState },
}
}
@@ -3,8 +3,8 @@ import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments"
import { createPromptState } from "@/context/prompt"
import { createPromptInputV2Attachments } from "@opencode-ai/session-ui/v2/prompt-input/attachments"
import type { PromptInputV2Prompt } from "@opencode-ai/session-ui/v2/prompt-input/types"
import { createPromptInputV2Attachments } from "../../session-ui/src/v2/components/prompt-input/attachments"
import type { PromptInputV2Prompt } from "../../session-ui/src/v2/components/prompt-input/types"
describe("prompt attachment session ownership", () => {
test("adds an asynchronously read image to the session where the read started", async () => {
+68 -10
View File
@@ -1,17 +1,17 @@
export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions } from "@opencode-ai/client/effect/service"
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import { Effect, Option, Redacted, Schedule } from "effect"
import path from "node:path"
import { Effect, FileSystem, Option, Redacted, Schedule, Schema } from "effect"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { ServiceRegistration } from "./services/service-registration"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
@@ -120,13 +120,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
onListen: (address, shutdown) =>
Effect.gen(function* () {
if (!config.password) yield* ServiceConfig.password(password)
return yield* ServiceRegistration.register({
address,
password,
id: instanceID,
file: serviceOptions.file,
shutdown,
})
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
}),
},
transform,
@@ -163,6 +157,70 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
})
const infoJson = Schema.fromJsonString(Service.Info)
const encodeInfo = Schema.encodeEffect(infoJson)
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
const register = Effect.fnUntraced(function* (
address: HttpServer.Address,
password: string,
id: string,
file: string,
shutdown: Effect.Effect<void>,
) {
const fs = yield* FileSystem.FileSystem
const temp = file + "." + id + ".tmp"
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
const info = {
id,
version: OPENCODE_VERSION,
url: HttpServer.formatAddress(address),
pid: process.pid,
password,
}
const encoded = yield* encodeInfo(info)
const current = fs.readFileString(file).pipe(Effect.flatMap(decodeInfo))
const owns = (found: Info) =>
found.id === info.id &&
found.version === info.version &&
found.url === info.url &&
found.pid === info.pid &&
found.password === info.password
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
yield* current.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("managed service registration check failed; shutting down", {
cause,
serviceID: id,
servicePID: process.pid,
registration: file,
}).pipe(Effect.andThen(Effect.failCause(cause))),
),
Effect.tap((found) =>
owns(found)
? Effect.void
: Effect.logWarning("managed service registration replaced; shutting down", {
serviceID: id,
servicePID: process.pid,
registration: file,
observedServiceID: found.id,
observedServicePID: found.pid,
observedVersion: found.version,
observedURL: found.url,
}),
),
Effect.filterOrFail(owns),
Effect.repeat(Schedule.spaced("5 seconds")),
Effect.ignore,
Effect.andThen(shutdown),
Effect.forkScoped,
)
return current.pipe(
Effect.flatMap((found) => (owns(found) ? fs.remove(file) : Effect.void)),
Effect.ignore,
)
})
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe(
Effect.filterOrFail((value) => value !== undefined),
@@ -1,71 +0,0 @@
export * as ServiceRegistration from "./service-registration"
import { Service, type Info } from "@opencode-ai/client/effect/service"
import path from "node:path"
import { Effect, FileSystem, Schedule, Schema } from "effect"
import { HttpServer } from "effect/unstable/http"
import { OPENCODE_VERSION } from "../version"
const infoJson = Schema.fromJsonString(Service.Info)
const encodeInfo = Schema.encodeEffect(infoJson)
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
export const register = Effect.fnUntraced(function* (options: {
readonly address: HttpServer.Address
readonly password: string
readonly id: string
readonly file: string
readonly shutdown: Effect.Effect<void>
}) {
const fs = yield* FileSystem.FileSystem
const temp = options.file + "." + options.id + ".tmp"
yield* fs.makeDirectory(path.dirname(options.file), { recursive: true })
const info = {
id: options.id,
version: OPENCODE_VERSION,
url: HttpServer.formatAddress(options.address),
pid: process.pid,
password: options.password,
}
const encoded = yield* encodeInfo(info)
const current = fs.readFileString(options.file).pipe(Effect.flatMap(decodeInfo))
const owns = (found: Info) =>
found.id === info.id &&
found.version === info.version &&
found.url === info.url &&
found.pid === info.pid &&
found.password === info.password
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, options.file)))
yield* current.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("managed service registration check failed; shutting down", {
cause,
serviceID: options.id,
servicePID: process.pid,
registration: options.file,
}).pipe(Effect.andThen(Effect.failCause(cause))),
),
Effect.tap((found) =>
owns(found)
? Effect.void
: Effect.logWarning("managed service registration replaced; shutting down", {
serviceID: options.id,
servicePID: process.pid,
registration: options.file,
observedServiceID: found.id,
observedServicePID: found.pid,
observedVersion: found.version,
observedURL: found.url,
}),
),
Effect.filterOrFail(owns),
Effect.repeat(Schedule.spaced("5 seconds")),
Effect.ignore,
Effect.andThen(options.shutdown),
Effect.forkScoped,
)
return current.pipe(
Effect.flatMap((found) => (owns(found) ? fs.remove(options.file) : Effect.void)),
Effect.ignore,
)
})
+84 -1
View File
@@ -1,14 +1,97 @@
import { describe, expect, test } from "bun:test"
import { afterEach, describe, expect, test } from "bun:test"
import path from "node:path"
type Message = { readonly id?: number; readonly result?: unknown; readonly error?: unknown }
const children: Bun.Subprocess[] = []
afterEach(async () => {
await Promise.all(
children.splice(0).map(async (child) => {
child.kill("SIGKILL")
await child.exited
}),
)
})
describe("acp command", () => {
test("is registered", async () => {
const result = await cli(["--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("acp Start an Agent Client Protocol server")
})
test("initializes over ndjson and exits on stdin eof", async () => {
const child = spawn()
const stderr = new Response(child.stderr).text()
await child.stdin.write(
new TextEncoder().encode(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: 1,
clientCapabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
},
}) + "\n",
),
)
await child.stdin.flush()
const response = await readMessage(child.stdout)
expect(response.id).toBe(1)
expect(response.error).toBeUndefined()
expect(response.result).toMatchObject({
protocolVersion: 1,
agentCapabilities: { loadSession: true },
agentInfo: { name: "OpenCode" },
})
await child.stdin.end()
const exitCode = await child.exited
const errorOutput = await stderr
if (exitCode !== 0) throw new Error(`ACP exited with ${exitCode}: ${errorOutput}`)
children.splice(children.indexOf(child), 1)
}, 30_000)
})
function spawn() {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", "acp"], {
cwd: path.join(import.meta.dir, "../.."),
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
})
children.push(child)
return child
}
async function readMessage(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
const decoder = new TextDecoder()
let output = ""
while (true) {
const result = await Promise.race([
reader.read(),
Bun.sleep(20_000).then(() => {
throw new Error("timed out waiting for ACP response")
}),
])
if (result.done) throw new Error(`ACP exited before responding: ${output}`)
output += decoder.decode(result.value, { stream: true })
const newline = output.indexOf("\n")
if (newline === -1) continue
reader.releaseLock()
const message: unknown = JSON.parse(output.slice(0, newline))
if (!isMessage(message)) throw new Error(`invalid ACP response: ${output.slice(0, newline)}`)
return message
}
}
function isMessage(value: unknown): value is Message {
return typeof value === "object" && value !== null
}
async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, "../.."),
@@ -77,6 +77,13 @@ describe("acp lifecycle subprocess", () => {
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(false)
}, 60_000)
test("resume capability advertisement", async () => {
await using fixture = await createAcpFixture()
const initialized = await initialize(fixture.spawn())
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}, 60_000)
test("resume request returns session config options", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
+16 -14
View File
@@ -7,7 +7,6 @@ import type {
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { isolatedEnv } from "../fixture/environment"
type JsonRpcRequest = {
readonly jsonrpc: "2.0"
@@ -101,30 +100,33 @@ export async function createAcpFixture(options: { readonly skill?: string } = {}
llm: { requests },
spawn(extraEnv: Record<string, string | undefined> = {}) {
const acp = spawnAcp({
env: isolatedEnv(root, {
env: {
...process.env,
HOME: root,
USERPROFILE: root,
OPENCODE_CONFIG: undefined,
OPENCODE_CONFIG_CONTENT: undefined,
OPENCODE_CONFIG_DIR: config,
OPENCODE_DB: path.join(root, "opencode.db"),
OPENCODE_DISABLE_AUTOUPDATE: "true",
OPENCODE_DISABLE_FILEWATCHER: "true",
OPENCODE_DISABLE_MODELS_FETCH: "true",
OPENCODE_MODELS_PATH: undefined,
OPENCODE_TEST_HOME: root,
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "xdg-config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
...extraEnv,
}),
},
})
processes.add(acp)
return acp
},
async [Symbol.asyncDispose]() {
const processResults = await Promise.allSettled(
[...processes].map((process) => process.close().catch(() => process[Symbol.asyncDispose]())),
)
const serverResults = await Promise.allSettled([llm.stop(true)])
const directoryResults = await Promise.allSettled([
fs.rm(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }),
])
const failure = [...processResults, ...serverResults, ...directoryResults].find(
(result): result is PromiseRejectedResult => result.status === "rejected",
)
if (failure) throw failure.reason
await Promise.all([...processes].map((process) => process[Symbol.asyncDispose]()))
await llm.stop(true)
await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
},
}
}
+1
View File
@@ -349,6 +349,7 @@ test("serializes migration and updates across processes", async () => {
})
try {
await waitForFile(updateReady, update.exited)
expect(await Promise.race([update.exited.then(() => true), Bun.sleep(500).then(() => false)])).toBe(false)
await Bun.write(release, "")
const [migrateCode, updateCode] = await Promise.all([migrate.exited, update.exited])
expect(await new Response(migrate.stderr).text()).toBe("")
-19
View File
@@ -1,19 +0,0 @@
import path from "node:path"
export function isolatedEnv(root: string, overrides: Record<string, string | undefined> = {}) {
return {
...process.env,
HOME: root,
OPENCODE_CONFIG_CONTENT: "{}",
OPENCODE_CONFIG_DIR: path.join(root, "config"),
OPENCODE_DB: path.join(root, "opencode.db"),
OPENCODE_DISABLE_FILEWATCHER: "true",
OPENCODE_DISABLE_MODELS_FETCH: "true",
OPENCODE_TEST_HOME: root,
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "xdg-config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
...overrides,
}
}
+32 -25
View File
@@ -8,7 +8,6 @@ import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { ServiceConfig } from "../src/services/service-config"
import { ServiceRegistration } from "../src/services/service-registration"
test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
@@ -151,6 +150,20 @@ test("preview registration migration never moves stable discovery", async () =>
}
})
test("managed service writes its registration once", async () => {
const service = await startManagedService("opencode-service-once-")
try {
const before = await fs.stat(service.registration)
await Bun.sleep(6_000)
const after = await fs.stat(service.registration)
expect(after.ino).toBe(before.ino)
expect(after.mtimeMs).toBe(before.mtimeMs)
expect(await Bun.file(service.registration).json()).toEqual(service.info)
} finally {
await stopManagedService(service)
}
}, 30_000)
test("deleting a managed service registration stops its owner", async () => {
const service = await startManagedService("opencode-service-delete-")
try {
@@ -442,45 +455,39 @@ test("port contender recognizes an incumbent registered during the bind race", a
}
}, 45_000)
test("service registration replaces a stale owner with the bound address", async () => {
test("stale dead registration is replaced after binding the selected port", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-stale-"))
const port = await availablePort()
const registration = path.join(root, "state", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
await fs.mkdir(path.dirname(registration), { recursive: true })
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
await fs.writeFile(
registration,
JSON.stringify({ id: "dead", version: "dead", url: "http://127.0.0.1:4321", pid: 2_147_483_647 }),
JSON.stringify({ id: "dead", version: "dead", url: `http://127.0.0.1:${port}`, pid: 2_147_483_647 }),
)
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env: serviceEnv(root),
stderr: "pipe",
stdout: "ignore",
})
try {
const cleanup = await Effect.runPromise(
ServiceRegistration.register({
address: { _tag: "TcpAddress", hostname: "127.0.0.1", port: 4321 },
password: "secret",
id: "owner",
file: registration,
shutdown: Effect.never,
}).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)),
)
expect(await Bun.file(registration).json()).toEqual({
id: "owner",
version: OPENCODE_VERSION,
url: "http://127.0.0.1:4321",
pid: process.pid,
password: "secret",
})
await Effect.runPromise(cleanup.pipe(Effect.provide(NodeFileSystem.layer)))
expect(await Bun.file(registration).exists()).toBe(false)
const info = await waitForInfo(registration, (value) => value.id !== "dead")
expect(new URL(info.url).port).toBe(String(port))
expect(info.pid).toBe(owner.pid)
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
await owner.exited
} finally {
owner.kill("SIGTERM")
await owner.exited
await fs.rm(root, { recursive: true, force: true })
}
})
}, 30_000)
test("a failed service stays registered and owns the selected port until stopped", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-failed-"))
const port = await availablePort()
const database = path.join(root, "database")
await fs.mkdir(database)
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
const env = {
...process.env,
HOME: root,
+1 -7
View File
@@ -1,14 +1,10 @@
import { expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { isolatedEnv } from "./fixture/environment"
test("standalone server exits when its owner is killed", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-standalone-"))
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture/standalone-owner.ts")], {
cwd: path.join(import.meta.dir, ".."),
env: isolatedEnv(root, { OPENCODE_SERVER_USERNAME: "custom" }),
env: { ...process.env, OPENCODE_SERVER_USERNAME: "custom" },
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
@@ -32,9 +28,7 @@ test("standalone server exits when its owner is killed", async () => {
expect(await waitForExit(pid)).toBe(true)
} finally {
owner.kill("SIGKILL")
await owner.exited
if (running(pid)) process.kill(pid, "SIGKILL")
await fs.rm(root, { recursive: true, force: true })
}
})
@@ -244,7 +244,7 @@ export type PromptFileAttachment = {
export type PromptAgentAttachment = { name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; text: string; mention?: PromptMention }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
@@ -2567,6 +2567,7 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -2835,6 +2836,7 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -3103,6 +3105,7 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
+47 -120
View File
@@ -34,11 +34,10 @@ import type {
WebSearchProvider,
} from "../promise"
import { Worktree } from "@opencode-ai/schema/worktree"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { isPermissionNotFoundError, type SessionPromptInput } from "../promise"
import { isPermissionNotFoundError } from "../promise"
import { createStore, produce, reconcile } from "solid-js/store"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { batch, createEffect, createSignal, onCleanup } from "solid-js"
import { createEffect, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
@@ -179,6 +178,11 @@ export function createData(config: CreateDataInput) {
setStore("session", "active", sessionID, status)
}
function addPending(item: SessionInboxInfo) {
if (store.session.pending[item.sessionID]?.some((pending) => pending.id === item.id)) return
setStore("session", "pending", item.sessionID, [...(store.session.pending[item.sessionID] ?? []), item])
}
function removePending(sessionID: string, inboxID?: string) {
if (!inboxID) return
if (store.session.pending[sessionID]?.some((item) => item.id === inboxID))
@@ -215,60 +219,6 @@ export function createData(config: CreateDataInput) {
setStore("session", "pending", sessionID, index, { ...item, delivery })
}
// Inbox IDs of optimistic prompt admissions still awaiting their durable
// echo. This is the one deliberate piece of in-flight bookkeeping in this
// layer: it exists so a rejection only rolls back rows the server never
// acknowledged, and so a concurrent pending re-fetch cannot wipe a row the
// server does not know about yet. Entries clear on the enqueued echo or on
// rollback — not on POST success, which typically precedes the echo.
const outbox = new Set<string>()
// Upsert an admitted inbox item into pending, input, and (for user and
// synthetic items) the visible transcript. Used by the inbox.enqueued
// handler and by optimistic prompt admission; the upsert is what reconciles
// the durable echo with an optimistic placeholder — the durable payload and
// times replace the client's guess.
function admitLocal(item: SessionInboxInfo) {
batch(() => {
const pending = store.session.pending[item.sessionID] ?? []
const at = pending.findIndex((entry) => entry.id === item.id)
setStore(
"session",
"pending",
item.sessionID,
at < 0 ? [...pending, item] : pending.map((entry, index) => (index === at ? item : entry)),
)
const input = store.session.input[item.sessionID] ?? []
if (!input.includes(item.id)) setStore("session", "input", item.sessionID, [...input, item.id])
if (item.type !== "user" && item.type !== "synthetic") return
message.update(item.sessionID, (draft, index) => {
const row =
item.type === "user"
? { id: item.id, type: "user" as const, ...item.payload, time: { created: item.timeCreated } }
: { id: item.id, type: "synthetic" as const, ...item.payload, time: { created: item.timeCreated } }
const position = index.get(item.id)
if (position === undefined) return message.append(draft, index, row)
draft[position] = row
})
})
}
// Remove an inbox item from pending, input, and the visible transcript.
// Used by the inbox.cancelled handler and by optimistic rollback.
function retractLocal(sessionID: string, inboxID: string) {
batch(() => {
removePending(sessionID, inboxID)
if (!messageIndex.get(sessionID)?.has(inboxID)) return
message.update(sessionID, (draft, index) => {
const position = index.get(inboxID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(inboxID)
message.reindex(draft, index, position)
})
})
}
const message = {
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
setStore(
@@ -375,7 +325,6 @@ export function createData(config: CreateDataInput) {
}
function removeSession(sessionID: string) {
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
messageIndex.delete(sessionID)
sync.invalidate(`session:${sessionID}`)
sync.invalidate(`session.pending:${sessionID}`)
@@ -544,16 +493,49 @@ export function createData(config: CreateDataInput) {
updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery)
return
case "session.inbox.cancelled": {
retractLocal(event.data.sessionID, event.data.inboxID)
removePending(event.data.sessionID, event.data.inboxID)
if (messageIndex.get(event.data.sessionID)?.has(event.data.inboxID))
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inboxID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(event.data.inboxID)
message.reindex(draft, index, position)
})
return
}
case "session.inbox.enqueued": {
outbox.delete(event.data.inboxID)
admitLocal({
const item = event.data.item
addPending({
id: event.data.inboxID,
sessionID: event.data.sessionID,
timeCreated: event.created,
...event.data.item,
...item,
})
if (!store.session.input[event.data.sessionID]?.includes(event.data.inboxID))
setStore("session", "input", event.data.sessionID, [
...(store.session.input[event.data.sessionID] ?? []),
event.data.inboxID,
])
if (item.type !== "user" && item.type !== "synthetic") return
message.update(event.data.sessionID, (draft, index) => {
message.append(
draft,
index,
item.type === "user"
? {
id: event.data.inboxID,
type: "user",
...item.payload,
time: { created: event.created },
}
: {
id: event.data.inboxID,
type: "synthetic",
...item.payload,
time: { created: event.created },
},
)
})
return
}
@@ -1080,19 +1062,12 @@ export function createData(config: CreateDataInput) {
sync(sessionID: string) {
return sync.run(`session.pending:${sessionID}`, async () => {
const pending = await api().session.inbox.list({ sessionID })
// Keep optimistic rows still awaiting their echo: this fetch may
// have raced ahead of an in-flight admission the server does not
// know about yet.
const inflight = (store.session.pending[sessionID] ?? []).filter(
(item) => outbox.has(item.id) && !pending.some((row) => row.id === item.id),
)
const merged = inflight.length === 0 ? pending : [...pending, ...inflight]
setStore("session", "pending", sessionID, reconcile(merged))
setStore("session", "pending", sessionID, reconcile(pending))
setStore(
"session",
"input",
sessionID,
reconcile(merged.filter((item) => item.type !== "compaction").map((item) => item.id)),
reconcile(pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
)
})
},
@@ -1100,47 +1075,6 @@ export function createData(config: CreateDataInput) {
sync.invalidate(`session.pending:${sessionID}`)
},
},
// Optimistic prompt admission: render the prompt immediately under a
// client-minted ID, send it, and let the durable inbox.enqueued echo
// upsert that same ID with the server's payload. Server admission is
// idempotent per ID, so retrying with the identical payload cannot
// double-admit.
prompt(input: SessionPromptInput) {
const id = input.id ?? SessionMessage.ID.create()
// A retry may reuse an ID that is already rendered — and possibly
// already durable. Admit optimistically only for new IDs so a failed
// retry cannot roll back acknowledged state.
const fresh =
!messageIndex.get(input.sessionID)?.has(id) &&
!store.session.pending[input.sessionID]?.some((item) => item.id === id)
if (fresh) {
outbox.add(id)
admitLocal({
id,
sessionID: input.sessionID,
timeCreated: Date.now(),
type: "user",
delivery: input.delivery ?? "steer",
// Files and skills stay off the optimistic row: their durable
// forms are server-loaded (content, mime, resolution), so they
// fill in when the echo upserts the row.
payload: {
text: input.text,
agents: input.agents?.map((agent) => ({ ...agent })),
metadata: input.metadata,
},
})
}
// Wrapped so even a synchronous client failure reaches the rollback.
return Promise.resolve()
.then(() => api().session.prompt({ ...input, id }))
.catch((error) => {
// Roll back only rows this call admitted and the echo has not
// acknowledged: anything else is server state.
if (fresh && outbox.delete(id)) retractLocal(input.sessionID, id)
throw error
})
},
sync(sessionID: string, options?: { children?: boolean }) {
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
const [info, children] = await Promise.all([
@@ -1180,14 +1114,7 @@ export function createData(config: CreateDataInput) {
sync(sessionID: string) {
return sync.run(`session.message:${sessionID}`, async () => {
const response = await api().message.list({ sessionID, limit: 200, order: "desc" })
const fetched = response.data.toReversed()
// Same protection as the pending sync: a re-fetch racing an
// optimistic admission must not wipe the in-flight transcript row.
const ids = new Set(fetched.map((item) => item.id))
const inflight = (store.session.message[sessionID] ?? []).filter(
(item) => outbox.has(item.id) && !ids.has(item.id),
)
const messages = inflight.length === 0 ? fetched : [...fetched, ...inflight]
const messages = response.data.toReversed()
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, reconcile(messages))
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
+48 -40
View File
@@ -20,16 +20,48 @@ export interface MapInput {
export function map(input: MapInput): Mapping | undefined {
const baseSettings = mapBaseSettings(input.settings)
switch (input.packageName) {
case "@ai-sdk/anthropic":
case "@ai-sdk/openai":
return {
package: "@opencode-ai/ai/providers/openai",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...(typeof input.settings.organization === "string" ? { organization: input.settings.organization } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
...openAIOptions(input.settings),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/anthropic": {
const providerOptions = {
...(isRecord(input.settings.thinking) ? { thinking: input.settings.thinking } : {}),
...(typeof input.settings.effort === "string" ? { effort: input.settings.effort } : {}),
}
return {
package: "@opencode-ai/ai/providers/anthropic",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...(typeof input.settings.authToken === "string" ? { authToken: input.settings.authToken } : {}),
...mapProviderOptions(input.settings, ["apiKey", "authToken", "baseURL"]),
...(Object.keys(providerOptions).length === 0 ? {} : { providerOptions }),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
}
case "@ai-sdk/openai-compatible":
return typeof input.settings.baseURL !== "string"
? undefined
: {
package: "@opencode-ai/ai/providers/openai-compatible",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
provider: input.providerID,
...mapOpenAIOptions(input.settings),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/amazon-bedrock":
return {
package: "@opencode-ai/ai/providers/amazon-bedrock",
@@ -71,10 +103,7 @@ export function map(input: MapInput): Mapping | undefined {
...mapAPIKey(input.settings),
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...mapGoogleOptions(
input.settings,
isStringRecord(input.settings.labels) ? { labels: input.settings.labels } : {},
),
...mapGoogleOptions(input.settings),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
@@ -97,29 +126,6 @@ export function map(input: MapInput): Mapping | undefined {
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/openai":
return {
package: "@opencode-ai/ai/providers/openai",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...(typeof input.settings.organization === "string" ? { organization: input.settings.organization } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
...mapProviderOptions(input.settings, ["apiKey", "baseURL", "organization", "project", "queryParams"]),
},
}
case "@ai-sdk/openai-compatible":
if (typeof input.settings.baseURL !== "string") return
return {
package: "@opencode-ai/ai/providers/openai-compatible",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
provider: input.providerID,
...mapProviderOptions(input.settings, ["apiKey", "baseURL"]),
},
}
case "@openrouter/ai-sdk-provider":
return mapOpenRouter(input.settings, baseSettings)
case "@ai-sdk/xai":
@@ -134,12 +140,6 @@ export function map(input: MapInput): Mapping | undefined {
}
}
function mapProviderOptions(settings: Readonly<Record<string, unknown>>, excluded: ReadonlyArray<string>) {
const options = Object.fromEntries(Object.entries(settings).filter(([name]) => !excluded.includes(name)))
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
}
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
const settings = input.settings
const chat = input.modelID === "openai.gpt-oss-safeguard-20b" || input.modelID === "openai.gpt-oss-safeguard-120b"
@@ -260,17 +260,26 @@ function bedrockRegion(settings: Readonly<Record<string, unknown>>) {
}
function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = openAIOptions(settings)
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
}
function openAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.instructions === "string" ? { instructions: settings.instructions } : {}),
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
...(typeof settings.reasoningSummary === "string" ? { reasoningSummary: settings.reasoningSummary } : {}),
...(Array.isArray(settings.include) ? { include: settings.include } : {}),
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
...(typeof settings.textVerbosity === "string" ? { textVerbosity: settings.textVerbosity } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
...(typeof settings.truncation === "string" ? { truncation: settings.truncation } : {}),
...(isRecord(settings.allowedTools) ? { allowedTools: settings.allowedTools } : {}),
...(typeof settings.maxToolCalls === "number" ? { maxToolCalls: settings.maxToolCalls } : {}),
...(typeof settings.parallelToolCalls === "boolean" ? { parallelToolCalls: settings.parallelToolCalls } : {}),
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
return options
}
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
@@ -283,7 +292,7 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
}
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Readonly<Record<string, unknown>> = {}) {
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
const input = settings.thinkingConfig
const thinkingConfig = {
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
@@ -298,7 +307,6 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Re
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
...extra,
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
-23
View File
@@ -1,23 +0,0 @@
export * as FileRetention from "./file-retention.js"
import { Duration, Effect, Option } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
export const cleanup = Effect.fn("FileRetention.cleanup")(function* (
fs: FSUtil.Interface,
files: ReadonlyArray<string>,
retention: Duration.Input,
) {
const cutoff = Date.now() - Duration.toMillis(retention)
yield* Effect.forEach(
files,
(file) =>
Effect.gen(function* () {
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
const mtime = info && Option.getOrUndefined(info.mtime)
if (!mtime || mtime.getTime() >= cutoff) return
yield* fs.remove(file).pipe(Effect.catch(() => Effect.void))
}),
{ concurrency: 8, discard: true },
)
})
+1 -46
View File
@@ -1,6 +1,6 @@
export * as KV from "./kv.js"
import { and, asc, eq, gt, gte, lt } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "./database/database.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -8,27 +8,10 @@ import { KVTable } from "./kv/sql.js"
export type Value = Schema.Json
export interface Entry {
readonly key: string
readonly value: Value
}
export interface ScanOptions {
readonly prefix: string
readonly after?: string
readonly limit?: number
}
export interface ScanResult {
readonly entries: readonly Entry[]
readonly next?: string
}
export interface Interface {
readonly get: (key: string) => Effect.Effect<Value | undefined>
readonly set: (key: string, value: Value) => Effect.Effect<void>
readonly remove: (key: string) => Effect.Effect<void>
readonly scan: (options: ScanOptions) => Effect.Effect<ScanResult>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/KV") {}
@@ -57,36 +40,8 @@ const layer = Layer.effect(
remove: Effect.fn("KV.remove")(function* (key) {
yield* db.delete(KVTable).where(eq(KVTable.key, key)).run().pipe(Effect.orDie)
}),
scan: Effect.fn("KV.scan")(function* (options) {
const limit = Number.isNaN(options.limit) ? 100 : Math.min(Math.max(Math.floor(options.limit ?? 100), 1), 1000)
const end = prefixEnd(options.prefix)
const rows = yield* db
.select({ key: KVTable.key, value: KVTable.value })
.from(KVTable)
.where(
and(
options.prefix === "" ? undefined : gte(KVTable.key, options.prefix),
end === undefined ? undefined : lt(KVTable.key, end),
options.after === undefined ? undefined : gt(KVTable.key, options.after),
),
)
.orderBy(asc(KVTable.key))
.limit(limit + 1)
.all()
.pipe(Effect.orDie)
const entries = rows.slice(0, limit)
if (rows.length <= limit) return { entries }
return { entries, next: entries[entries.length - 1].key }
}),
})
}),
)
function prefixEnd(prefix: string) {
const points = Array.from(prefix)
const index = points.findLastIndex((value) => value.codePointAt(0)! < 0x10ffff)
if (index < 0) return undefined
return `${points.slice(0, index).join("")}${String.fromCodePoint(points[index].codePointAt(0)! + 1)}`
}
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
+25 -28
View File
@@ -1,7 +1,7 @@
export * as ModelResolver from "./model-resolver.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LanguageModel } from "@opencode-ai/ai"
import { LanguageModel, ProviderPackage } from "@opencode-ai/ai"
import { Auth } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer"
@@ -68,8 +68,6 @@ export interface Resolved {
readonly capabilities: Capabilities
/** Catalog pricing in dollars per million tokens. */
readonly cost: Info["cost"]
/** Catalog token limits used by Core for context management. */
readonly limit: Info["limit"]
}
export interface Interface {
@@ -126,7 +124,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
const resolved = prepareRuntimeModel(model, credential)
const packageName = Provider.packageName(resolved.package)
const configuration = credential?.type === "key" ? credential.configuration : undefined
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
const configured = Provider.mergeOverlay(resolved.settings, configuration) ?? {}
const mapping = Provider.isAISDK(resolved.package)
? AISDKNative.map({
packageName,
@@ -142,7 +140,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
const settings = yield* prepareProviderSettings(
resolved,
Provider.mergeOverlay(resolved.settings, {
...nativeCredentialSettings(resolved.package ?? "", credential),
...legacyCredentialSettings(credential),
...credential?.metadata,
...configuration,
}) ?? {},
@@ -159,16 +157,18 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
const runtime = module.model({
id: resolved.modelID ?? resolved.id,
settings: mapped,
credential: providerCredential(credential),
defaults: {
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
},
})
return LanguageModel.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
@@ -227,25 +227,23 @@ function unresolvedProviderVariables(model: Info, baseURL: string) {
})
}
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
const legacyCredentialSettings = (credential: Credential.Value | undefined) => {
if (!credential) return {}
if (credential.type === "key") return { apiKey: credential.key }
if (
specifier === "@opencode-ai/ai/providers/anthropic" ||
specifier === "@opencode-ai/ai/providers/anthropic-compatible"
)
return { authToken: credential.access }
if (
specifier === "@opencode-ai/ai/providers/google-vertex" ||
specifier.startsWith("@opencode-ai/ai/providers/google-vertex/")
)
return { accessToken: credential.access }
return { apiKey: credential.access }
}
const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings
return rest
const providerCredential = (credential: Credential.Value | undefined): ProviderPackage.Credential | undefined => {
if (!credential) return undefined
if (credential.type === "key" && credential.key.length === 0) return undefined
if (credential.type === "oauth" && credential.access.length === 0) return undefined
if (credential.type === "key")
return {
type: "key",
value: credential.key,
configuration: credential.configuration,
}
return { type: "oauth", accessToken: credential.access }
}
const unsupported = (model: Info) =>
@@ -299,7 +297,6 @@ export const layer = Layer.effect(
}),
capabilities: selected.capabilities,
cost: selected.cost,
limit: selected.limit,
}
})
return Service.of({

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