mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-20 06:53:27 -04:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0608e74ac | |||
| f43474043a | |||
| 5a0ba34d64 | |||
| 9a1de86d9c | |||
| ea7fa43243 | |||
| 730e1935cf | |||
| d6deed6752 | |||
| 1d89e911e8 | |||
| c85b09de6f | |||
| 30db9dd86e | |||
| b6966177fa | |||
| 6b09b9e6a2 | |||
| 3876f7aad6 | |||
| d912202cf2 | |||
| f8c46684eb | |||
| c4afbc4aae | |||
| 98a9d864e6 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Apply shared Session model-request preparation to transient generation.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -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. 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -471,7 +471,9 @@
|
||||
"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:*",
|
||||
@@ -480,6 +482,7 @@
|
||||
"@solidjs/router": "catalog:",
|
||||
"@solidjs/start": "catalog:",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"effect": "catalog:",
|
||||
"hono": "catalog:",
|
||||
"hono-openapi": "catalog:",
|
||||
"js-base64": "3.7.7",
|
||||
@@ -695,6 +698,7 @@
|
||||
"@solidjs/meta": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"dompurify": "3.3.1",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
@@ -833,14 +837,16 @@
|
||||
"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.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",
|
||||
"@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",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
@@ -848,10 +854,11 @@
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"solid-js": "catalog:",
|
||||
"storybook": "^10.2.13",
|
||||
"storybook-solidjs-vite": "^10.0.9",
|
||||
"storybook": "10.4.4",
|
||||
"storybook-solidjs-vite": "10.5.2",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite": "7.1.11",
|
||||
"vite-plugin-solid": "2.11.12",
|
||||
},
|
||||
},
|
||||
"packages/theme": {
|
||||
@@ -2856,25 +2863,25 @@
|
||||
|
||||
"@stoplight/yaml-ast-parser": ["@stoplight/yaml-ast-parser@0.0.50", "", {}, "sha512-Pb6M8TDO9DtSVla9yXSTAxmo9GVEouq5P40DWXdOie69bXogZTkgvopCq+yEvTMA0F6PEvdJmbtTV3ccIp11VQ=="],
|
||||
|
||||
"@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-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-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-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-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-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-onboarding": ["@storybook/addon-onboarding@10.5.7", "", { "peerDependencies": { "storybook": "^10.5.7" } }, "sha512-GDssSoWmmGnz6OnUvAofMcrUuTMD53Y2rnbshjXvhtPnjKw4dufXFjo6C7yZQ6vyoNzTS9HyQwCHlxlhyCKJjQ=="],
|
||||
"@storybook/addon-onboarding": ["@storybook/addon-onboarding@10.4.4", "", { "peerDependencies": { "storybook": "^10.4.4" } }, "sha512-ZTWGm8VXQUTepV4aEmIgHxdY7JMtn57H3uYnM3HD+qR8fmAcpLPoJ9ffXaMWUwsjK6SeferQyDTRN3q3Jd5+mg=="],
|
||||
|
||||
"@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/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/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/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/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/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/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.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=="],
|
||||
"@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=="],
|
||||
|
||||
"@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="],
|
||||
|
||||
@@ -5518,9 +5525,9 @@
|
||||
|
||||
"stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="],
|
||||
|
||||
"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": ["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-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=="],
|
||||
"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=="],
|
||||
|
||||
"stream-replace-string": ["stream-replace-string@2.0.0", "", {}, "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w=="],
|
||||
|
||||
@@ -6372,6 +6379,10 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -6874,12 +6885,14 @@
|
||||
|
||||
"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=="],
|
||||
@@ -7840,57 +7853,9 @@
|
||||
|
||||
"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/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@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/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=="],
|
||||
"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=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
@@ -8796,6 +8761,78 @@
|
||||
|
||||
"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
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-IxkSw0gK/qkMHZGVHqjwgM9BKhzbQX6hyF9SWUNtpzg=",
|
||||
"aarch64-linux": "sha256-YVjpbil0QswVwi6NtVYFq3xCqpsfveG1chlNVCVI0MU=",
|
||||
"aarch64-darwin": "sha256-CdL2mI84pawH2H5i9qu8A6IWbkmKOYHlJS+DI/Mafdw=",
|
||||
"x86_64-darwin": "sha256-NtswwfU5WYv99bEmI4XeLwjhBGcS9ZMYLRo4MQRNtLo="
|
||||
"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="
|
||||
}
|
||||
}
|
||||
|
||||
+5
-10
@@ -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 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.
|
||||
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.
|
||||
|
||||
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, store: false })
|
||||
const openai = OpenAI.configure({ apiKey, baseURL })
|
||||
const model = openai.responses("gpt-4o-mini")
|
||||
|
||||
const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" })
|
||||
@@ -108,22 +108,17 @@ 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({ 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.
|
||||
Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
|
||||
const selected = model({
|
||||
id: "gpt-5",
|
||||
settings: {},
|
||||
credential: { type: "key", value: apiKey },
|
||||
defaults: {},
|
||||
const selected = model("gpt-5", {
|
||||
apiKey,
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
+12
-56
@@ -305,26 +305,19 @@ 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, 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.
|
||||
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.
|
||||
|
||||
### 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({ 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.
|
||||
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
|
||||
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 },
|
||||
},
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
headers: { "x-application": "opencode" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
})
|
||||
```
|
||||
|
||||
@@ -348,57 +341,30 @@ Tuned Vertex Gemini deployments use model ids shaped like `endpoints/1234567890`
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/google-vertex/gemini"
|
||||
|
||||
model({
|
||||
id: "gemini-3.5-flash",
|
||||
settings: { project: "my-project", location: "global" },
|
||||
defaults: {},
|
||||
})
|
||||
model("gemini-3.5-flash", { project: "my-project", location: "global" })
|
||||
```
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/google-vertex/chat"
|
||||
|
||||
model({
|
||||
id: "deepseek-ai/deepseek-v3.2-maas",
|
||||
settings: { project: "my-project", location: "global" },
|
||||
defaults: {},
|
||||
})
|
||||
model("deepseek-ai/deepseek-v3.2-maas", { project: "my-project", location: "global" })
|
||||
```
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/google-vertex/responses"
|
||||
|
||||
model({
|
||||
id: "xai/grok-4.20-reasoning",
|
||||
settings: { project: "my-project", location: "global" },
|
||||
defaults: {},
|
||||
})
|
||||
model("xai/grok-4.20-reasoning", { project: "my-project", location: "global" })
|
||||
```
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
|
||||
|
||||
model({
|
||||
id: "claude-sonnet-4-6",
|
||||
settings: { project: "my-project", location: "global" },
|
||||
defaults: {},
|
||||
})
|
||||
model("claude-sonnet-4-6", { project: "my-project", location: "global" })
|
||||
```
|
||||
|
||||
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path. The entrypoints listed above implement that contract and are covered by `test/provider-package.test.ts`.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
## Provider options & HTTP overlays
|
||||
|
||||
@@ -411,16 +377,6 @@ 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
|
||||
|
||||
@@ -17,12 +17,13 @@ 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, deployment options, authentication, and defaults. Catalog capabilities
|
||||
// remain application-owned and are not part of LanguageModel.
|
||||
// choice, capabilities, deployment options, authentication, and defaults.
|
||||
const model = OpenAI.configure({
|
||||
apiKey,
|
||||
generation: { maxTokens: 160 },
|
||||
store: false,
|
||||
providerOptions: {
|
||||
store: false,
|
||||
},
|
||||
}).model("gpt-4o-mini")
|
||||
|
||||
// 2. Build a provider-neutral request. This is useful when reusing one request
|
||||
@@ -73,8 +74,8 @@ const streamText = LLM.stream(request).pipe(
|
||||
Stream.runDrain,
|
||||
)
|
||||
|
||||
// 5. Tools are typed with Effect Schema. Model calls remain explicit:
|
||||
// advertise definitions on the request, stream one call, dispatch local calls,
|
||||
// 5. Tools are typed with Effect Schema. Provider turns remain explicit:
|
||||
// advertise definitions on the request, stream one turn, dispatch local calls,
|
||||
// then persist/build follow-up history in the enclosing product flow.
|
||||
const tools = {
|
||||
get_weather: Tool.make({
|
||||
@@ -101,7 +102,7 @@ const streamWithTools = Effect.gen(function* () {
|
||||
console.log("tool result", event.name, dispatched.result)
|
||||
|
||||
// A durable agent would persist these messages before starting another
|
||||
// model call. This tutorial keeps the boundary visible instead.
|
||||
// raw model turn. This tutorial keeps the boundary visible instead.
|
||||
const followUp = LLMRequest.update(request, {
|
||||
messages: [
|
||||
...request.messages,
|
||||
|
||||
@@ -37,9 +37,6 @@ 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"
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
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 {}
|
||||
|
||||
export type Credential =
|
||||
| {
|
||||
readonly type: "key"
|
||||
readonly value: string
|
||||
readonly configuration?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
| {
|
||||
readonly type: "oauth"
|
||||
readonly accessToken: string
|
||||
}
|
||||
|
||||
export interface Defaults {
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
readonly body?: Readonly<Record<string, unknown>>
|
||||
readonly limits?: {
|
||||
@@ -25,38 +11,11 @@ export interface Defaults {
|
||||
}
|
||||
}
|
||||
|
||||
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: (input: ModelInput<ProviderSettings>) => LanguageModel<Options>
|
||||
readonly model: (modelID: string, settings: 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 { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,27 +79,29 @@ export const configure = (input: Config = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
|
||||
if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined)
|
||||
const config = (settings: Settings): Config => {
|
||||
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
if (!input.credential && input.settings.auth === "sigv4" && input.settings.apiKey !== undefined)
|
||||
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
return {
|
||||
...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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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 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 model = chatModel
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,21 +50,19 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (input) => {
|
||||
if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined)
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (!input.credential && input.settings.auth === "sigv4" && input.settings.apiKey !== undefined)
|
||||
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
return configure({
|
||||
...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)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,9 +32,7 @@ export const routes = [AnthropicMessages.route]
|
||||
|
||||
const auth = (input: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in input && input.auth) return input.auth
|
||||
return Auth.remove("authorization").andThen(
|
||||
Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key")),
|
||||
)
|
||||
return Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key"))
|
||||
}
|
||||
|
||||
export const configure = (input: Config) => {
|
||||
@@ -59,20 +57,21 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
|
||||
if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined)
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
...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)
|
||||
...(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)
|
||||
}
|
||||
|
||||
export * as AnthropicCompatible from "./anthropic-compatible.js"
|
||||
|
||||
@@ -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 { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,11 +31,9 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in options && options.auth) return options.auth
|
||||
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")),
|
||||
)
|
||||
return 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 = {}) => {
|
||||
@@ -54,17 +52,18 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
|
||||
if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined)
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new Error("Anthropic apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
...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)
|
||||
...(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)
|
||||
}
|
||||
|
||||
@@ -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 { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,29 +120,28 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
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 config = (settings: Settings): Config => {
|
||||
const common = {
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
...(input.credential
|
||||
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "api-key")
|
||||
: { apiKey: settings.apiKey }),
|
||||
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 (baseURL !== undefined) return { ...common, baseURL }
|
||||
if (resourceName !== undefined) return { ...common, resourceName }
|
||||
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
|
||||
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
|
||||
throw new Error("Azure requires resourceName or baseURL")
|
||||
}
|
||||
|
||||
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 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 model = responsesModel
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,15 +68,16 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
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")
|
||||
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")
|
||||
return configure({
|
||||
...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)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Schema, Struct } from "effect"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,15 +100,19 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
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")
|
||||
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")
|
||||
return configure({
|
||||
...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)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,15 +70,19 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
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")
|
||||
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")
|
||||
return configure({
|
||||
...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)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -69,8 +69,9 @@ 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")
|
||||
const auth = input.auth ?? (input.accessToken !== undefined ? Auth.bearer(input.accessToken) : adc(project))
|
||||
return Auth.remove("x-goog-api-key").andThen(auth)
|
||||
if (input.auth) return input.auth
|
||||
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
|
||||
return adc(project)
|
||||
}
|
||||
|
||||
export * as GoogleVertexShared from "./google-vertex-shared.js"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { Gemini } from "../protocols/gemini.js"
|
||||
import { ProviderShared } from "../protocols/shared.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
@@ -94,10 +94,7 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
|
||||
return route.with({
|
||||
...rest,
|
||||
endpoint: { baseURL: endpoint },
|
||||
auth:
|
||||
apiKey === undefined
|
||||
? GoogleVertexShared.oauth(input, project)
|
||||
: Auth.remove("authorization").andThen(Auth.header("x-goog-api-key", apiKey)),
|
||||
auth: apiKey === undefined ? GoogleVertexShared.oauth(input, project) : Auth.header("x-goog-api-key", apiKey),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -114,21 +111,17 @@ export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (input) => {
|
||||
if (!input.credential && input.settings.apiKey !== undefined && input.settings.accessToken !== undefined)
|
||||
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
return configure({
|
||||
...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)
|
||||
...(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)
|
||||
}
|
||||
|
||||
@@ -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 { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,11 +28,9 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in options && options.auth) return options.auth
|
||||
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")),
|
||||
)
|
||||
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"))
|
||||
}
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
@@ -59,14 +57,14 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (input) =>
|
||||
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
...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)
|
||||
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)
|
||||
|
||||
export const image = provider.image
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,11 +46,16 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (input) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
configure({
|
||||
...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)
|
||||
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)
|
||||
|
||||
@@ -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 { ProviderPackage } from "../provider-package.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
@@ -68,14 +68,16 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
...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)
|
||||
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)
|
||||
|
||||
export const baseten = define(profiles.baseten)
|
||||
export const cerebras = define(profiles.cerebras)
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
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 {
|
||||
return openAIProviderOptions({
|
||||
reasoningEffort: "medium",
|
||||
reasoningSummary: "auto",
|
||||
// GPT-5 reasoning models are configured stateless (`store: false`) by
|
||||
@@ -28,13 +44,14 @@ 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({ store: false }, gpt5DefaultOptions(modelID, options))
|
||||
): ProviderOptions | undefined =>
|
||||
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID, options))
|
||||
|
||||
export const withOpenAIOptions = <Options extends { readonly providerOptions?: OpenAIProviderOptionsInput }>(
|
||||
modelID: string,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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 OpenAIConfigOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
import { withOpenAIOptions, 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 = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
OpenAIConfigOptions &
|
||||
export type Config = RouteDefaultsInput &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly queryParams?: Record<string, string>
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface ImageGenerationOptions {
|
||||
@@ -57,12 +57,13 @@ export const imageGeneration = (options: ImageGenerationOptions = {}) =>
|
||||
},
|
||||
})
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings, OpenAIConfigOptions {
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
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")
|
||||
@@ -72,39 +73,6 @@ 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),
|
||||
@@ -114,8 +82,7 @@ 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 split = splitConfigOptions(defaults(input))
|
||||
const modelDefaults = { ...split.rest, providerOptions: split.options }
|
||||
const modelDefaults = defaults(input)
|
||||
const responses = (id: string | ModelID) =>
|
||||
responsesRoute
|
||||
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
||||
@@ -146,30 +113,31 @@ export const configure = (input: Config = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
|
||||
const settings = input.settings
|
||||
const options = splitConfigOptions(settings).options
|
||||
const config = (settings: Settings): Config => {
|
||||
const headers = {
|
||||
...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }),
|
||||
...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }),
|
||||
...input.defaults.headers,
|
||||
...settings.headers,
|
||||
}
|
||||
return {
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: settings.apiKey }),
|
||||
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"] = (input) => {
|
||||
return configure(config(input)).responses(input.id)
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
return configure(config(settings)).responses(modelID)
|
||||
}
|
||||
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
|
||||
configure(config(input)).chat(input.id)
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).chat(modelID)
|
||||
export const responses = provider.responses
|
||||
export const chat = provider.chat
|
||||
export const image = provider.image
|
||||
|
||||
@@ -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 { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,10 +191,15 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (input) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
configure({
|
||||
...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)
|
||||
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)
|
||||
|
||||
@@ -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 { ProviderPackage } from "../provider-package.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
|
||||
export const id = ProviderID.make("xai")
|
||||
|
||||
@@ -95,13 +95,15 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (input) =>
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
...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)
|
||||
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)
|
||||
export const responses = provider.responses
|
||||
export const chat = provider.chat
|
||||
export const image = provider.image
|
||||
|
||||
@@ -81,22 +81,8 @@ OpenAI.configure({
|
||||
}).responses("gpt-4.1-mini")
|
||||
OpenAI.configure({
|
||||
generation: { maxTokens: 100 },
|
||||
store: false,
|
||||
providerOptions: { 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", {})
|
||||
@@ -111,7 +97,7 @@ OpenAI.configure({ bogus: true })
|
||||
OpenAI.configure({ generation: { maxTokens: "many" } })
|
||||
|
||||
// @ts-expect-error provider-native options remain typed.
|
||||
OpenAI.configure({ store: "false" })
|
||||
OpenAI.configure({ providerOptions: { 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") })
|
||||
@@ -159,12 +145,8 @@ Anthropic.configure({
|
||||
}).model("claude-haiku")
|
||||
// @ts-expect-error Anthropic model selectors only accept model ids.
|
||||
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {})
|
||||
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 Anthropic package settings accept only one auth source.
|
||||
Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" })
|
||||
// @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.
|
||||
@@ -180,15 +162,11 @@ 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", {})
|
||||
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: {},
|
||||
// @ts-expect-error Anthropic-compatible package settings accept only one auth source.
|
||||
AnthropicCompatible.model("compatible-model", {
|
||||
apiKey: "messages-key",
|
||||
authToken: "messages-token",
|
||||
baseURL: "https://messages.example.com/v1",
|
||||
})
|
||||
|
||||
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash")
|
||||
@@ -211,23 +189,15 @@ 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" })
|
||||
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: {},
|
||||
})
|
||||
// @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" })
|
||||
|
||||
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",
|
||||
)
|
||||
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: {},
|
||||
})
|
||||
// @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.configure({ accessToken: "vertex-token", project: "project" }).model(
|
||||
"deepseek-ai/deepseek-v3.2-maas",
|
||||
// @ts-expect-error Vertex Chat model selectors only accept model ids.
|
||||
@@ -244,12 +214,8 @@ GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project
|
||||
GoogleVertexResponses.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
|
||||
"xai/grok-4.20-reasoning",
|
||||
)
|
||||
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: {},
|
||||
})
|
||||
// @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.configure({ accessToken: "vertex-token", project: "project" }).model(
|
||||
"xai/grok-4.20-reasoning",
|
||||
// @ts-expect-error Vertex Responses model selectors only accept model ids.
|
||||
@@ -267,12 +233,8 @@ GoogleVertexMessages.configure({
|
||||
project: "project",
|
||||
providerOptions: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" },
|
||||
}).model("claude-sonnet-4-6")
|
||||
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: {},
|
||||
})
|
||||
// @ts-expect-error Vertex Messages package settings do not accept API keys.
|
||||
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
|
||||
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",
|
||||
|
||||
@@ -1,72 +1,6 @@
|
||||
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([
|
||||
@@ -111,18 +45,14 @@ describe("provider package entrypoints", () => {
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
}
|
||||
const openrouter = OpenRouter.model(
|
||||
packageInput("anthropic/claude-sonnet-4", {
|
||||
...settings,
|
||||
providerOptions: { usage: true },
|
||||
}),
|
||||
)
|
||||
const xai = XAI.model(
|
||||
packageInput("grok-4", {
|
||||
...settings,
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
}),
|
||||
)
|
||||
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
|
||||
...settings,
|
||||
providerOptions: { usage: true },
|
||||
})
|
||||
const xai = XAI.model("grok-4", {
|
||||
...settings,
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
})
|
||||
|
||||
for (const selected of [openrouter, xai]) {
|
||||
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
|
||||
@@ -135,151 +65,32 @@ describe("provider package entrypoints", () => {
|
||||
})
|
||||
|
||||
test("maps package settings onto the executable model", () => {
|
||||
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,
|
||||
}),
|
||||
)
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://api.openai.test/v1",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
unrelatedInheritedSetting: true,
|
||||
})
|
||||
|
||||
expect(selected.route.id).toBe("openai-responses")
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
||||
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
||||
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(
|
||||
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 },
|
||||
}),
|
||||
)
|
||||
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 },
|
||||
})
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
expect(selected.route.id).toBe("openai-compatible-responses")
|
||||
@@ -295,17 +106,15 @@ 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(
|
||||
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" },
|
||||
}),
|
||||
)
|
||||
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" },
|
||||
})
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
expect(selected.route.id).toBe("anthropic-messages")
|
||||
@@ -321,12 +130,10 @@ 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(
|
||||
packageInput("claude-sonnet-4-6", {
|
||||
apiKey: "fixture",
|
||||
providerOptions: { thinking: { type: "adaptive" } },
|
||||
}),
|
||||
)
|
||||
const selected = Anthropic.model("claude-sonnet-4-6", {
|
||||
apiKey: "fixture",
|
||||
providerOptions: { thinking: { type: "adaptive" } },
|
||||
})
|
||||
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
|
||||
})
|
||||
@@ -334,7 +141,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, [packageInput("compatible-model", { apiKey: "fixture" })]),
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
|
||||
).toThrow("Anthropic-compatible providers require a baseURL")
|
||||
})
|
||||
|
||||
@@ -343,28 +150,25 @@ describe("provider package entrypoints", () => {
|
||||
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
|
||||
expect(() =>
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, [
|
||||
packageInput("compatible-model", {
|
||||
"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, [
|
||||
packageInput("claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }),
|
||||
]),
|
||||
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
|
||||
).toThrow("Anthropic apiKey cannot be combined with authToken")
|
||||
})
|
||||
|
||||
test("maps legacy OpenAI organization and project settings to headers", () => {
|
||||
const selected = model(
|
||||
packageInput("gpt-5", {
|
||||
apiKey: "fixture",
|
||||
organization: "org_123",
|
||||
project: "proj_123",
|
||||
}),
|
||||
)
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: "fixture",
|
||||
organization: "org_123",
|
||||
project: "proj_123",
|
||||
})
|
||||
|
||||
expect(selected.route.defaults.headers).toMatchObject({
|
||||
"OpenAI-Organization": "org_123",
|
||||
@@ -384,10 +188,10 @@ describe("provider package entrypoints", () => {
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
}
|
||||
|
||||
const responses = AzureResponses.model(packageInput("deployment", settings))
|
||||
const chat = AzureChat.model(packageInput("deployment", settings))
|
||||
const responses = AzureResponses.model("deployment", settings)
|
||||
const chat = AzureChat.model("deployment", settings)
|
||||
|
||||
expect(Azure.model(packageInput("deployment", settings)).route.id).toBe("azure-openai-responses")
|
||||
expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses")
|
||||
expect(responses.route.id).toBe("azure-openai-responses")
|
||||
expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
|
||||
expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
@@ -398,20 +202,16 @@ 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(
|
||||
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/",
|
||||
}),
|
||||
)
|
||||
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/",
|
||||
})
|
||||
|
||||
expect(deployment.route.endpoint).toMatchObject({
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment",
|
||||
@@ -423,16 +223,14 @@ 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(
|
||||
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 } },
|
||||
}),
|
||||
)
|
||||
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 } },
|
||||
})
|
||||
|
||||
expect(selected.route.id).toBe("gemini")
|
||||
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
|
||||
@@ -448,35 +246,27 @@ 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(
|
||||
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",
|
||||
}),
|
||||
)
|
||||
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",
|
||||
})
|
||||
|
||||
expect(GoogleVertexGemini.model).toBe(GoogleVertex.model)
|
||||
expect(gemini.route.id).toBe("google-vertex-gemini")
|
||||
@@ -486,13 +276,11 @@ 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(
|
||||
packageInput("gemini-3.5-flash", {
|
||||
accessToken: "fixture",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
}),
|
||||
).route.endpoint.baseURL,
|
||||
GoogleVertex.model("gemini-3.5-flash", {
|
||||
accessToken: "fixture",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
}).route.endpoint.baseURL,
|
||||
).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google")
|
||||
expect(messages.route.id).toBe("google-vertex-messages")
|
||||
expect(messages.route.protocol).toBe("anthropic-messages")
|
||||
@@ -522,11 +310,8 @@ describe("provider package entrypoints", () => {
|
||||
const Providers = await import("@opencode-ai/ai/providers")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertex.model, undefined, [
|
||||
packageInput("gemini-3.5-flash", {
|
||||
accessToken: "token",
|
||||
apiKey: "fixture",
|
||||
project: "vertex-project",
|
||||
}),
|
||||
"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, [
|
||||
@@ -535,7 +320,8 @@ 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, [
|
||||
packageInput("claude-sonnet-4-6", { apiKey: "fixture", project: "vertex-project" }),
|
||||
"claude-sonnet-4-6",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
expect(() =>
|
||||
@@ -545,7 +331,8 @@ describe("provider package entrypoints", () => {
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexChat.model, undefined, [
|
||||
packageInput("deepseek-ai/deepseek-v3.2-maas", { apiKey: "fixture", project: "vertex-project" }),
|
||||
"deepseek-ai/deepseek-v3.2-maas",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
expect(() =>
|
||||
@@ -555,7 +342,8 @@ describe("provider package entrypoints", () => {
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexResponses.model, undefined, [
|
||||
packageInput("xai/grok-4.20-reasoning", { apiKey: "fixture", project: "vertex-project" }),
|
||||
"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", { filePath: "src/a.ts" }),
|
||||
toolPart(contextIDs[0]!, "read", "completed", { path: "src/a.ts" }),
|
||||
toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
textPart(followingID, "Following responsive timeline content that wraps on narrow screens."),
|
||||
]),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test } from "@playwright/test"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
@@ -18,16 +19,20 @@ import {
|
||||
} from "./fixture"
|
||||
|
||||
const profiles = [
|
||||
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
|
||||
{
|
||||
name: "edit",
|
||||
tool: "edit",
|
||||
input: { path: "src/edit.ts", oldString: "export const value = 1", newString: "export const value = 2" },
|
||||
},
|
||||
{
|
||||
name: "multi patch",
|
||||
tool: "apply_patch",
|
||||
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
|
||||
tool: "patch",
|
||||
input: { patchText: "Update generated files" },
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const profile of profiles) {
|
||||
test(`stabilizes ${profile.name} pending to completed`, async ({ page }, testInfo) => {
|
||||
test(`stabilizes ${profile.name} streaming 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, {
|
||||
@@ -35,7 +40,7 @@ for (const profile of profiles) {
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(partID, profile.tool, "pending", profile.input),
|
||||
toolPart(partID, profile.tool, "streaming", profile.input),
|
||||
textPart(followingID, `Following ${profile.name}`),
|
||||
],
|
||||
{ completed: false },
|
||||
@@ -89,34 +94,27 @@ function completedPart(partID: string, profile: (typeof profiles)[number]) {
|
||||
if (profile.tool === "edit") {
|
||||
return toolPart(partID, profile.tool, "completed", profile.input, {
|
||||
metadata: {
|
||||
filediff: {
|
||||
file: "src/edit.ts",
|
||||
additions: 50,
|
||||
deletions: 50,
|
||||
before: source(50, false),
|
||||
after: source(50, true),
|
||||
},
|
||||
files: [patchFile("src/edit.ts", "modified", 50)],
|
||||
},
|
||||
})
|
||||
}
|
||||
const files = [
|
||||
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" },
|
||||
patchFile("src/a.ts", "modified", 20),
|
||||
patchFile("src/b.ts", "added", 20),
|
||||
patchFile("src/old.ts", "deleted", 20),
|
||||
]
|
||||
return toolPart(partID, profile.tool, "completed", profile.input, { metadata: { files } })
|
||||
}
|
||||
|
||||
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
|
||||
function patchFile(file: string, status: "added" | "modified" | "deleted", lines: number) {
|
||||
const before = status === "added" ? "" : source(lines, false)
|
||||
const after = status === "deleted" ? "" : source(lines, true)
|
||||
return {
|
||||
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),
|
||||
file,
|
||||
status,
|
||||
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
|
||||
additions: status === "deleted" ? 0 : lines,
|
||||
deletions: status === "added" ? 0 : lines,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
@@ -20,13 +21,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", "update")
|
||||
const first = patchFile("src/a.ts", "modified")
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
||||
toolPart(patchID, "patch", "running", { patchText: "Update files" }, { metadata: { files: [first] } }),
|
||||
textPart(followingID, "Following incremental patch"),
|
||||
],
|
||||
{ completed: false },
|
||||
@@ -55,15 +56,15 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
const second = patchFile("src/b.ts", "add")
|
||||
const third = patchFile("src/old.ts", "delete")
|
||||
const second = patchFile("src/b.ts", "added")
|
||||
const third = patchFile("src/old.ts", "deleted")
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"patch",
|
||||
"running",
|
||||
{ files: [first.filePath, second.filePath] },
|
||||
{ patchText: "Update files" },
|
||||
{ metadata: { files: [first, second] } },
|
||||
),
|
||||
),
|
||||
@@ -73,9 +74,9 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||
partUpdated(
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ files: [first.filePath, second.filePath, third.filePath] },
|
||||
{ patchText: "Update files" },
|
||||
{ metadata: { files: [first, second, third] } },
|
||||
),
|
||||
),
|
||||
@@ -106,15 +107,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(filePath: string, type: "add" | "update" | "delete") {
|
||||
function patchFile(file: string, status: "added" | "modified" | "deleted") {
|
||||
const before = status === "added" ? "" : source(false)
|
||||
const after = status === "deleted" ? "" : source(true)
|
||||
return {
|
||||
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),
|
||||
file,
|
||||
status,
|
||||
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
|
||||
additions: status === "deleted" ? 0 : 4,
|
||||
deletions: status === "added" ? 0 : 3,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("timeline fixture validation", () => {
|
||||
userMessage(),
|
||||
{
|
||||
...assistantMessage(),
|
||||
content: [{ type: "tool", id: "call_invalid", name: "bash", state: { status: "completed" } }],
|
||||
content: [{ type: "tool", id: "call_invalid", name: "shell", state: { status: "completed" } }],
|
||||
} as never,
|
||||
]),
|
||||
).toThrow()
|
||||
@@ -60,12 +60,11 @@ 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 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 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 error fields are not valid after completion.
|
||||
toolPart("prt_invalid_completed", "bash", "completed", {}, { error: "impossible" })
|
||||
toolPart("prt_invalid_completed", "shell", "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"
|
||||
callID: string
|
||||
tool: string
|
||||
name: string
|
||||
messageID?: string
|
||||
executed?: boolean
|
||||
providerState?: Record<string, unknown>
|
||||
providerResultState?: Record<string, unknown>
|
||||
state:
|
||||
| { status: "pending"; input: Record<string, unknown>; raw: string }
|
||||
| { status: "streaming"; 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 "pending"
|
||||
type ToolOptions<State extends ToolStatus> = State extends "streaming"
|
||||
? { output?: never; title?: never; metadata?: never; error?: never }
|
||||
: State extends "running"
|
||||
? { title?: string; metadata?: Record<string, unknown>; output?: never; error?: never }
|
||||
? { title?: string; metadata?: Record<string, unknown>; output?: string; 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,6 +371,15 @@ 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
|
||||
? []
|
||||
@@ -542,9 +551,9 @@ export function reasoningPart(id: string, text: string): ReasoningSeed {
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "pending",
|
||||
state: "streaming",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"pending">,
|
||||
options?: ToolOptions<"streaming">,
|
||||
): ToolSeed
|
||||
export function toolPart(
|
||||
id: string,
|
||||
@@ -574,14 +583,15 @@ export function toolPart(
|
||||
input: Record<string, unknown>,
|
||||
options: ToolOptions<ToolStatus> = {},
|
||||
): ToolSeed {
|
||||
const base = { id, type: "tool" as const, callID: id, tool }
|
||||
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
|
||||
const base = { id, type: "tool" as const, name: tool }
|
||||
if (state === "streaming") 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 },
|
||||
@@ -612,12 +622,10 @@ export function toolPart(
|
||||
}
|
||||
|
||||
export function shell(id: string, state: ToolStatus, output = "", command = `echo ${id}`): ToolSeed {
|
||||
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 } })
|
||||
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 })
|
||||
}
|
||||
|
||||
export function completedAssistantInfo(info: SessionMessageAssistant): SessionMessageAssistant {
|
||||
@@ -655,7 +663,7 @@ function messageContent(
|
||||
): SessionMessageAssistant["content"][number] {
|
||||
if (part.type === "tool") {
|
||||
partRefs.set(part.id, { messageID, type: part.type })
|
||||
toolStates.set(part.callID, part.state.status)
|
||||
toolStates.set(part.id, part.state.status)
|
||||
} else {
|
||||
partRefs.set(part.id, { messageID, type: part.type, ordinal: ordinals[part.type]++ })
|
||||
startedParts.add(part.id)
|
||||
@@ -675,8 +683,8 @@ function messageContent(
|
||||
const completed = state.status === "completed" || state.status === "error" ? state.time.end : undefined
|
||||
const base = {
|
||||
type: "tool" as const,
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
id: part.id,
|
||||
name: part.name,
|
||||
time: {
|
||||
created: time?.start ?? 1700000001000,
|
||||
...(time?.start === undefined ? {} : { ran: time.start }),
|
||||
@@ -686,11 +694,18 @@ function messageContent(
|
||||
...(part.providerState ? { providerState: jsonRecord(part.providerState) } : {}),
|
||||
...(part.providerResultState ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
|
||||
}
|
||||
if (state.status === "pending") return { ...base, state: { status: "streaming", input: state.raw } }
|
||||
if (state.status === "streaming") 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: {
|
||||
status: "running",
|
||||
input: jsonRecord(state.input),
|
||||
metadata: jsonRecord({
|
||||
...state.metadata,
|
||||
...(state.output === undefined ? {} : { output: state.output }),
|
||||
}),
|
||||
},
|
||||
}
|
||||
if (state.status === "error")
|
||||
return {
|
||||
@@ -714,7 +729,7 @@ function messageContent(
|
||||
}
|
||||
|
||||
function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[] {
|
||||
const previous = toolStates.get(part.callID)
|
||||
const previous = toolStates.get(part.id)
|
||||
if (previous === "completed" || previous === "error") return []
|
||||
|
||||
const events: OpenCodeEvent[] = []
|
||||
@@ -723,27 +738,27 @@ function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[]
|
||||
makeEvent("session.tool.input.started", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
id: part.id,
|
||||
name: part.name,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (part.state.status === "pending") {
|
||||
toolStates.set(part.callID, part.state.status)
|
||||
if (part.state.status === "streaming") {
|
||||
toolStates.set(part.id, part.state.status)
|
||||
return events
|
||||
}
|
||||
if (!previous || previous === "pending") {
|
||||
if (!previous || previous === "streaming") {
|
||||
events.push(
|
||||
makeEvent("session.tool.input.ended", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
id: part.id,
|
||||
text: JSON.stringify(part.state.input),
|
||||
}),
|
||||
makeEvent("session.tool.called", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
id: part.id,
|
||||
input: part.state.input,
|
||||
executed: part.executed ?? true,
|
||||
state: jsonRecord(part.providerState),
|
||||
@@ -751,16 +766,20 @@ function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[]
|
||||
)
|
||||
}
|
||||
if (part.state.status === "running") {
|
||||
if (previous === "running" || Object.keys(part.state.metadata).length)
|
||||
const metadata = {
|
||||
...part.state.metadata,
|
||||
...(part.state.output === undefined ? {} : { output: part.state.output }),
|
||||
}
|
||||
if (previous === "running" || Object.keys(metadata).length)
|
||||
events.push(
|
||||
makeEvent("session.tool.progress", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
metadata: jsonRecord(part.state.metadata),
|
||||
id: part.id,
|
||||
metadata: jsonRecord(metadata),
|
||||
}),
|
||||
)
|
||||
toolStates.set(part.callID, part.state.status)
|
||||
toolStates.set(part.id, part.state.status)
|
||||
return events
|
||||
}
|
||||
if (part.state.status === "error") {
|
||||
@@ -768,28 +787,28 @@ function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[]
|
||||
makeEvent("session.tool.failed", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
id: part.id,
|
||||
error: { type: "ToolError", message: part.state.error },
|
||||
metadata: jsonRecord(part.state.metadata),
|
||||
executed: part.executed ?? true,
|
||||
resultState: jsonRecord(part.providerResultState),
|
||||
}),
|
||||
)
|
||||
toolStates.set(part.callID, part.state.status)
|
||||
toolStates.set(part.id, part.state.status)
|
||||
return events
|
||||
}
|
||||
events.push(
|
||||
makeEvent("session.tool.success", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
id: part.id,
|
||||
content: [{ type: "text", text: part.state.output }],
|
||||
metadata: jsonRecord(part.state.metadata),
|
||||
executed: part.executed ?? true,
|
||||
resultState: jsonRecord(part.providerResultState),
|
||||
}),
|
||||
)
|
||||
toolStates.set(part.callID, part.state.status)
|
||||
toolStates.set(part.id, part.state.status)
|
||||
return events
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
@@ -58,14 +59,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 page.waitForTimeout(500)
|
||||
await waitForVisualSettle(page, [regions.shell.selector, regions.following.selector])
|
||||
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 page.waitForTimeout(500)
|
||||
await waitForVisualSettle(page, [regions.shell.selector, regions.following.selector])
|
||||
const collapsed = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "shell-collapse", collapsed, plan)
|
||||
})
|
||||
@@ -83,7 +84,7 @@ test("expands and collapses a completed context group without overlap", async ({
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
|
||||
toolPart(ids[0]!, "read", "completed", { path: "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" }),
|
||||
@@ -110,7 +111,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 page.waitForTimeout(500)
|
||||
await waitForVisualSettle(page, [regions.context.selector, regions.following.selector])
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
@@ -142,16 +143,23 @@ test("expands and collapses an edit diff without moving twice", async ({ page },
|
||||
editID,
|
||||
"edit",
|
||||
"completed",
|
||||
{ filePath: "src/edit.ts" },
|
||||
{ path: "src/edit.ts", oldString: "export const value = 1", newString: "export const value = 2" },
|
||||
{
|
||||
metadata: {
|
||||
filediff: {
|
||||
file: "src/edit.ts",
|
||||
additions: 40,
|
||||
deletions: 40,
|
||||
before: source(40, false),
|
||||
after: source(40, true),
|
||||
},
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
@@ -182,7 +190,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 page.waitForTimeout(900)
|
||||
await waitForVisualSettle(page, [regions.edit.selector, regions.following.selector])
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
|
||||
@@ -17,32 +17,32 @@ import {
|
||||
userMessage,
|
||||
} from "./fixture"
|
||||
|
||||
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" }
|
||||
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." }
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([toolPart(taskID, "task", "running", input)], { completed: false })],
|
||||
messages: [userMessage(), assistantMessage([toolPart(taskID, "subagent", "running", input)], { completed: false })],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect child" })],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
task: { selector: `[data-timeline-part-id="${renderedPartID(taskID)}"] [data-slot="collapsible-trigger"]` },
|
||||
subagent: { selector: `[data-timeline-part-id="${renderedPartID(taskID)}"] [data-slot="collapsible-trigger"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(taskID, "task", "completed", input, { metadata: { sessionId: childID } })),
|
||||
partUpdated(toolPart(taskID, "subagent", "completed", input, { metadata: { sessionID: childID } })),
|
||||
500,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"task-link",
|
||||
"subagent-link",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["task"] },
|
||||
{ type: "unique", regions: ["task"] },
|
||||
{ type: "stable", regions: ["task"] },
|
||||
{ type: "required", regions: ["subagent"] },
|
||||
{ type: "unique", regions: ["subagent"] },
|
||||
{ type: "stable", regions: ["subagent"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
|
||||
@@ -21,24 +21,30 @@ import {
|
||||
} from "./fixture"
|
||||
|
||||
test.describe("timeline tool state stability", () => {
|
||||
test("moves lightweight tools through pending, running, and completed without replacing rows", async ({
|
||||
test("moves lightweight tools through streaming, running, and completed without replacing rows", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const ids = ["webfetch", "websearch", "task", "skill", "custom"] as const
|
||||
const ids = ["webfetch", "websearch", "subagent", "skill", "custom"] as const
|
||||
const inputs = {
|
||||
webfetch: { url: "https://example.com/docs" },
|
||||
websearch: { query: "timeline stability" },
|
||||
task: { description: "Inspect timeline", subagent_type: "explore" },
|
||||
subagent: { description: "Inspect timeline", agent: "explore", prompt: "Inspect the timeline." },
|
||||
skill: { name: "stability" },
|
||||
custom: { target: "timeline", depth: 2 },
|
||||
}
|
||||
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
|
||||
const names = {
|
||||
webfetch: "webfetch",
|
||||
websearch: "websearch",
|
||||
subagent: "subagent",
|
||||
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], "pending", inputs[id])),
|
||||
toolPart(questionID, "question", "pending", questionInput()),
|
||||
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
...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" }] }),
|
||||
textPart("prt_state_following", "Following lightweight tools"),
|
||||
]
|
||||
const childID = "ses_timeline_child"
|
||||
@@ -55,14 +61,14 @@ test.describe("timeline tool state stability", () => {
|
||||
const regionIDs = [
|
||||
"prt_state_webfetch",
|
||||
"prt_state_websearch",
|
||||
"prt_state_task",
|
||||
"prt_state_subagent",
|
||||
"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_task: toolRegion(regionIDs[2]),
|
||||
prt_state_subagent: toolRegion(regionIDs[2]),
|
||||
prt_state_skill: toolRegion(regionIDs[3]),
|
||||
prt_state_custom: toolRegion(regionIDs[4]),
|
||||
})
|
||||
@@ -73,9 +79,9 @@ test.describe("timeline tool state stability", () => {
|
||||
[80, 240, 100, 360, 140][index],
|
||||
)
|
||||
}
|
||||
for (const [index, id] of ["skill", "webfetch", "custom", "task", "websearch"].entries()) {
|
||||
for (const [index, id] of ["skill", "webfetch", "custom", "subagent", "websearch"].entries()) {
|
||||
const key = id as (typeof ids)[number]
|
||||
const metadata = key === "task" ? { sessionId: childID } : key === "websearch" ? { provider: "exa" } : {}
|
||||
const metadata = key === "subagent" ? { 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 })),
|
||||
@@ -121,12 +127,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 = [
|
||||
{ filePath: "src/a.ts", offset: 0, limit: 120 },
|
||||
{ path: "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]!, "pending", inputs[index]!))
|
||||
const context = ids.map((id, index) => toolPart(id, tools[index]!, "streaming", inputs[index]!))
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
@@ -26,28 +27,29 @@ const userMessage = {
|
||||
|
||||
const editPart: ToolSeed = {
|
||||
id: editPartID,
|
||||
sessionID,
|
||||
messageID: assistantMessageID,
|
||||
type: "tool",
|
||||
callID: "call_edit_regression",
|
||||
tool: "edit",
|
||||
name: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "src/regression.ts" },
|
||||
output: "Edited src/regression.ts",
|
||||
title: "src/regression.ts",
|
||||
metadata: {
|
||||
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",
|
||||
input: {
|
||||
path: "src/regression.ts",
|
||||
oldString: "export const value = 'before'",
|
||||
newString: "export const value = 'after'",
|
||||
},
|
||||
content: [{ type: "text", text: "Edited src/regression.ts" }],
|
||||
metadata: {
|
||||
files: [
|
||||
currentFile(
|
||||
"src/regression.ts",
|
||||
"export const value = 'before'\n",
|
||||
"export const value = 'after'\n",
|
||||
1,
|
||||
1,
|
||||
),
|
||||
],
|
||||
},
|
||||
time: { start: 1700000001000, end: 1700000002000 },
|
||||
},
|
||||
time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 },
|
||||
}
|
||||
|
||||
const assistantMessage = {
|
||||
@@ -204,20 +206,20 @@ function performanceTurn(index: number) {
|
||||
? [
|
||||
{
|
||||
id: `prt_0000_${suffix}_edit`,
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "tool",
|
||||
callID: `call_0000_${suffix}_edit`,
|
||||
tool: "edit",
|
||||
name: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: `src/history-${index}.ts` },
|
||||
output: `Edited src/history-${index}.ts`,
|
||||
title: `src/history-${index}.ts`,
|
||||
input: { path: `src/history-${index}.ts`, oldString: before, newString: after },
|
||||
content: [{ type: "text", text: `Edited src/history-${index}.ts` }],
|
||||
metadata: {
|
||||
filediff: { file: `src/history-${index}.ts`, additions: 48, deletions: 48, before, after },
|
||||
files: [currentFile(`src/history-${index}.ts`, before, after, 48, 48)],
|
||||
},
|
||||
time: { start: 1690000001200 + index * 2_000, end: 1690000001400 + index * 2_000 },
|
||||
},
|
||||
time: {
|
||||
created: 1690000001200 + index * 2_000,
|
||||
ran: 1690000001200 + index * 2_000,
|
||||
completed: 1690000001400 + index * 2_000,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -226,20 +228,18 @@ function performanceTurn(index: number) {
|
||||
? [
|
||||
{
|
||||
id: `prt_0000_${suffix}_write`,
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "tool",
|
||||
callID: `call_0000_${suffix}_write`,
|
||||
tool: "write",
|
||||
name: "write",
|
||||
state: {
|
||||
status: "completed",
|
||||
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 },
|
||||
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,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -248,31 +248,24 @@ function performanceTurn(index: number) {
|
||||
? [
|
||||
{
|
||||
id: `prt_0000_${suffix}_patch`,
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "tool",
|
||||
callID: `call_0000_${suffix}_patch`,
|
||||
tool: "apply_patch",
|
||||
name: "patch",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { patchText: realisticPatch(index) },
|
||||
output: "Success. Updated src/components/SessionCard.tsx",
|
||||
title: "src/components/SessionCard.tsx",
|
||||
content: [{ type: "text", text: "Success. Updated src/components/SessionCard.tsx" }],
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
filePath: "src/components/SessionCard.tsx",
|
||||
relativePath: "src/components/SessionCard.tsx",
|
||||
type: "update",
|
||||
additions: 8,
|
||||
deletions: 3,
|
||||
patch: realisticPatch(index),
|
||||
before,
|
||||
after,
|
||||
...currentFile("src/components/SessionCard.tsx", before, after, 8, 3),
|
||||
},
|
||||
],
|
||||
},
|
||||
time: { start: 1690000001500 + index * 2_000, end: 1690000001700 + index * 2_000 },
|
||||
},
|
||||
time: {
|
||||
created: 1690000001500 + index * 2_000,
|
||||
ran: 1690000001500 + index * 2_000,
|
||||
completed: 1690000001700 + index * 2_000,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -309,20 +302,16 @@ function performanceTurn(index: number) {
|
||||
}
|
||||
|
||||
type ToolSeed = {
|
||||
id?: string
|
||||
sessionID?: string
|
||||
messageID?: string
|
||||
id: string
|
||||
type: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
name: string
|
||||
state: {
|
||||
status: string
|
||||
status: "completed"
|
||||
input: Record<string, unknown>
|
||||
output: string
|
||||
title?: string
|
||||
content: [{ type: "text"; text: 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 }
|
||||
@@ -335,13 +324,13 @@ type ContentSeed =
|
||||
function toolContent(part: ToolSeed): SessionMessageAssistant["content"][number] {
|
||||
return {
|
||||
type: "tool",
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
time: { created: part.state.time.start, ran: part.state.time.start, completed: part.state.time.end },
|
||||
id: part.id,
|
||||
name: part.name,
|
||||
time: part.time,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: part.state.input as Record<string, JsonValue>,
|
||||
content: [{ type: "text", text: part.state.output }],
|
||||
content: part.state.content,
|
||||
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||
},
|
||||
}
|
||||
@@ -422,6 +411,16 @@ 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,4 +1,6 @@
|
||||
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)
|
||||
@@ -12,6 +14,13 @@ 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,3 +1,5 @@
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
|
||||
const words = [
|
||||
"alpha",
|
||||
"bravo",
|
||||
@@ -28,22 +30,21 @@ 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" | "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 }
|
||||
}
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
function lorem(seed: number, length: number) {
|
||||
let out = ""
|
||||
@@ -102,17 +103,16 @@ 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.callID ?? part.id,
|
||||
name: part.tool!,
|
||||
time: { created: state.time.start, ran: state.time.start, completed: state.time.end },
|
||||
id: part.id,
|
||||
name: part.name,
|
||||
time: part.time,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: state.input as Record<string, JsonValue>,
|
||||
content: [{ type: "text", text: state.output }],
|
||||
metadata: state.metadata as Record<string, JsonValue>,
|
||||
input: part.state.input as Record<string, JsonValue>,
|
||||
content: part.state.content,
|
||||
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -149,43 +149,46 @@ function toolPart(
|
||||
): MessagePart {
|
||||
const metadata =
|
||||
metadataOverride ??
|
||||
(tool === "apply_patch"
|
||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||
(tool === "patch"
|
||||
? {
|
||||
files: [
|
||||
patchFile(index, "modified"),
|
||||
patchFile(index + 1, index % 2 === 0 ? "added" : "deleted"),
|
||||
],
|
||||
}
|
||||
: tool === "edit" || tool === "write"
|
||||
? {
|
||||
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
|
||||
diff: patch(index, outputLength),
|
||||
preview: patch(index + 1, 420),
|
||||
}
|
||||
? { files: [fileDiff(String(input.path ?? `src/generated/file-${index}.ts`), index)] }
|
||||
: tool === "question"
|
||||
? { answers: [["Proceed"], ["Keep sample output"]] }
|
||||
: {})
|
||||
return {
|
||||
id: id(`prt_tool_${tool}_${partIndex}`, index),
|
||||
id: id(`call_${tool}_${partIndex}`, index),
|
||||
type: "tool",
|
||||
callID: id("call", index * 10 + partIndex),
|
||||
tool,
|
||||
name: tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input,
|
||||
output: lorem(index * 23 + partIndex, outputLength),
|
||||
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
|
||||
content: [{ type: "text", text: lorem(index * 23 + partIndex, outputLength) }],
|
||||
metadata,
|
||||
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
|
||||
},
|
||||
time: {
|
||||
created: 1700000000000 + index * 10_000,
|
||||
ran: 1700000000000 + index * 10_000,
|
||||
completed: 1700000000000 + index * 10_000 + 400,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function patchFile(seed: number, type: "add" | "update" | "delete") {
|
||||
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)
|
||||
return {
|
||||
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),
|
||||
file,
|
||||
status,
|
||||
additions: status === "deleted" ? 0 : (seed % 7) + 1,
|
||||
deletions: status === "added" ? 0 : seed % 4,
|
||||
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,17 +203,13 @@ 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,
|
||||
before,
|
||||
after,
|
||||
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, 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 },
|
||||
@@ -225,22 +224,24 @@ function turn(index: number): SessionMessageInfo[] {
|
||||
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
|
||||
...(index % 3 === 0
|
||||
? [
|
||||
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
|
||||
toolPart(index, 0, "read", { path: `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", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
|
||||
...(index % 4 === 0
|
||||
? [toolPart(index, 3, "edit", { path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" }, 700)]
|
||||
: []),
|
||||
...(index % 6 === 0
|
||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
? [toolPart(index, 7, "write", { path: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
: []),
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0
|
||||
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||
? [toolPart(index, 4, "shell", { 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)] : []),
|
||||
@@ -256,7 +257,15 @@ function turn(index: number): SessionMessageInfo[] {
|
||||
]
|
||||
: []),
|
||||
...(index % 17 === 0
|
||||
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
||||
? [
|
||||
toolPart(
|
||||
index,
|
||||
12,
|
||||
"subagent",
|
||||
{ description: "Inspect generated fixture", agent: "explore", prompt: "Inspect the fixture." },
|
||||
160,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
]
|
||||
return [user, assistantMessage(targetID, index, user.id, parts)]
|
||||
@@ -272,10 +281,10 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||
toolPart(
|
||||
index + 1000,
|
||||
1,
|
||||
"task",
|
||||
{ description: "Inspect child navigation", subagent_type: "explore" },
|
||||
"subagent",
|
||||
{ description: "Inspect child navigation", agent: "explore", prompt: "Inspect child navigation." },
|
||||
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: "bash",
|
||||
action: "shell",
|
||||
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: "bash",
|
||||
action: "shell",
|
||||
resources: ["git diff"],
|
||||
metadata: {},
|
||||
save: [],
|
||||
|
||||
@@ -85,7 +85,7 @@ test("shows a pending permission dock", async ({ page }) => {
|
||||
{
|
||||
id: "permission-request",
|
||||
sessionID,
|
||||
permission: "bash",
|
||||
permission: "shell",
|
||||
patterns: ["git status", "git diff"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
|
||||
@@ -2,6 +2,7 @@ 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"
|
||||
@@ -40,18 +41,28 @@ const editPart = {
|
||||
tool: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "src/regression.ts" },
|
||||
input: {
|
||||
path: "src/regression.ts",
|
||||
oldString: "export const value = 'before'",
|
||||
newString: "export const value = 'after'",
|
||||
},
|
||||
output: "Edited src/regression.ts",
|
||||
title: "src/regression.ts",
|
||||
metadata: {
|
||||
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",
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
time: { start: 1700000001000, end: 1700000002000 },
|
||||
},
|
||||
@@ -149,13 +160,15 @@ test.describe("regression: session timeline local row state", () => {
|
||||
...editPart.state,
|
||||
metadata: {
|
||||
...editPart.state.metadata,
|
||||
filediff: {
|
||||
file: "src/regression.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
before: lines,
|
||||
after,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
file: "src/regression.ts",
|
||||
patch: createTwoFilesPatch("a/src/regression.ts", "b/src/regression.ts", lines, after),
|
||||
additions: 5,
|
||||
deletions: 5,
|
||||
status: "modified",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
id("msg_assistant", 10),
|
||||
["read", "glob", "grep", "list"][index]!,
|
||||
[
|
||||
{ filePath: "src/recent-a.ts" },
|
||||
{ path: "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",
|
||||
{ filePath: "src/recent-a.ts", offset: 0, limit: 120 },
|
||||
{ path: "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.filePath || input.path || input.pattern || "completed",
|
||||
title: 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: { filePath: "src/a.ts", offset: 0, limit: 120 },
|
||||
read: { path: "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", { filePath: "src/write.ts", content: "export const written = true\n" }),
|
||||
toolPart(id, "write", "completed", { path: "src/write.ts", content: "export const written = true\n" }),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
@@ -24,20 +24,19 @@ test("renders a completed single-file patch", async ({ page }) => {
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
id,
|
||||
"apply_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ files: ["src/a.ts"] },
|
||||
{ patchText: "Update src/a.ts" },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
filePath: "src/a.ts",
|
||||
relativePath: "src/a.ts",
|
||||
type: "update",
|
||||
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",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
before: "export const value = 1\n",
|
||||
after: "export const value = 2\n",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
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", "update"), patchFile("src/b.ts", "add"), patchFile("src/old.ts", "delete")]
|
||||
const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")]
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ files: files.map((file) => file.filePath) },
|
||||
{ patchText: "Update three files" },
|
||||
{ metadata: { files } },
|
||||
),
|
||||
]),
|
||||
@@ -31,15 +32,15 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
|
||||
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
function patchFile(filePath: string, type: "add" | "update" | "delete") {
|
||||
function patchFile(file: string, status: "added" | "modified" | "deleted") {
|
||||
const before = status === "added" ? "" : source(false)
|
||||
const after = status === "deleted" ? "" : source(true)
|
||||
return {
|
||||
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),
|
||||
file,
|
||||
status,
|
||||
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
|
||||
additions: status === "deleted" ? 0 : 4,
|
||||
deletions: status === "added" ? 0 : 3,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ for (const profile of [
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
|
||||
toolPart(ids[0]!, "read", "completed", { path: "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", { filePath: "src/a.ts" }),
|
||||
toolPart("prt_01_read", "read", "completed", { path: "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,16 +24,20 @@ test.describe("session timeline projection", () => {
|
||||
{ query: "timeline stability" },
|
||||
{ output: "https://example.com/result" },
|
||||
),
|
||||
toolPart("prt_task", "task", "completed", { description: "Inspect timeline", subagent_type: "explore" }),
|
||||
toolPart("prt_task", "subagent", "completed", {
|
||||
description: "Inspect timeline",
|
||||
agent: "explore",
|
||||
prompt: "Inspect the timeline implementation.",
|
||||
}),
|
||||
toolPart(
|
||||
"prt_bash",
|
||||
"bash",
|
||||
"shell",
|
||||
"completed",
|
||||
{ command: "printf stable" },
|
||||
{ output: "stable", title: "printf stable" },
|
||||
),
|
||||
editPart("prt_edit"),
|
||||
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
|
||||
toolPart("prt_write", "write", "completed", { path: "src/new.ts", content: "export const stable = true\n" }),
|
||||
patchPart("prt_patch"),
|
||||
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
toolPart(
|
||||
@@ -175,16 +179,10 @@ function editPart(id: string) {
|
||||
id,
|
||||
"edit",
|
||||
"completed",
|
||||
{ filePath: "src/a.ts" },
|
||||
{ path: "src/a.ts", oldString: "export const value = 1", newString: "export const value = 2" },
|
||||
{
|
||||
metadata: {
|
||||
filediff: {
|
||||
file: "src/a.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
before: "export const value = 1\n",
|
||||
after: "export const value = 2\n",
|
||||
},
|
||||
files: [patchFile("src/a.ts", "modified")],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -193,31 +191,33 @@ function editPart(id: string) {
|
||||
function patchPart(id: string) {
|
||||
return toolPart(
|
||||
id,
|
||||
"apply_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ files: ["src/a.ts", "src/b.ts"] },
|
||||
{ patchText: "Update the projected files" },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
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" },
|
||||
patchFile("src/a.ts", "modified"),
|
||||
patchFile("src/b.ts", "added"),
|
||||
patchFile("src/old.ts", "deleted"),
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
|
||||
function patchFile(file: string, status: "added" | "modified" | "deleted") {
|
||||
return {
|
||||
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",
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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", { filePath: "src/a.ts" }),
|
||||
toolPart("prt_boundary_01_read", "read", "completed", { path: "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,19 +67,18 @@ 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 = {
|
||||
filePath: "src/outline.ts",
|
||||
relativePath: "src/outline.ts",
|
||||
type: "update",
|
||||
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",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
before: "const outline = false\n",
|
||||
after: "const outline = true\n",
|
||||
}
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
|
||||
toolPart(patchID, "patch", "completed", { patchText: "Update src/outline.ts" }, { 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 = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
||||
const ordinary = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "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, "bash", "pending", { command: "exit 1" }),
|
||||
toolPart(questionID, "question", "pending", questionInput()),
|
||||
toolPart(shellID, "shell", "streaming", { command: "exit 1" }),
|
||||
toolPart(questionID, "question", "streaming", 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, "bash", "running", { command: "exit 1" })), 120)
|
||||
await timeline.send(partUpdated(toolPart(shellID, "shell", "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, "bash", "error", { command: "exit 1" }, { error: "Command exited 1" })),
|
||||
partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })),
|
||||
180,
|
||||
)
|
||||
await timeline.send(
|
||||
@@ -147,12 +147,13 @@ function questionInput() {
|
||||
}
|
||||
|
||||
function errorInput(tool: string) {
|
||||
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 === "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 === "webfetch") return { url: "https://example.com" }
|
||||
if (tool === "websearch") return { query: "failure" }
|
||||
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
|
||||
if (tool === "subagent")
|
||||
return { description: "Fail subagent", agent: "explore", prompt: "Inspect the failure." }
|
||||
if (tool === "skill") return { name: "failure" }
|
||||
return { target: "failure" }
|
||||
}
|
||||
|
||||
@@ -167,14 +167,14 @@ function parentMessages(): SessionMessageInfo[] {
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_task_0001",
|
||||
name: "task",
|
||||
id: "call_subagent_0001",
|
||||
name: "subagent",
|
||||
time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { description: taskDescription, subagent_type: "explore" },
|
||||
input: { description: taskDescription, agent: "explore", prompt: "Inspect the delegated work." },
|
||||
content: [{ type: "text", text: "Subagent finished" }],
|
||||
metadata: { sessionId: childID },
|
||||
metadata: { sessionID: childID },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
|
||||
const words = [
|
||||
"alpha",
|
||||
"bravo",
|
||||
@@ -28,22 +30,21 @@ 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" | "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 }
|
||||
}
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
function lorem(seed: number, length: number) {
|
||||
let out = ""
|
||||
@@ -102,17 +103,16 @@ 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.callID ?? part.id,
|
||||
name: part.tool!,
|
||||
time: { created: state.time.start, ran: state.time.start, completed: state.time.end },
|
||||
id: part.id,
|
||||
name: part.name,
|
||||
time: part.time,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: state.input as Record<string, JsonValue>,
|
||||
content: [{ type: "text", text: state.output }],
|
||||
metadata: state.metadata as Record<string, JsonValue>,
|
||||
input: part.state.input as Record<string, JsonValue>,
|
||||
content: part.state.content,
|
||||
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -138,60 +138,61 @@ function toolPart(
|
||||
outputLength = 160,
|
||||
): MessagePart {
|
||||
const metadata =
|
||||
tool === "apply_patch"
|
||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||
tool === "patch"
|
||||
? {
|
||||
files: [
|
||||
patchFile(index, "modified"),
|
||||
patchFile(index + 1, index % 2 === 0 ? "added" : "deleted"),
|
||||
],
|
||||
}
|
||||
: tool === "edit" || tool === "write"
|
||||
? {
|
||||
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
|
||||
diff: patch(index, outputLength),
|
||||
preview: patch(index + 1, 420),
|
||||
}
|
||||
? { files: [fileDiff(String(input.path ?? `src/generated/file-${index}.ts`), index)] }
|
||||
: tool === "question"
|
||||
? { answers: [["Proceed"], ["Keep sample output"]] }
|
||||
: {}
|
||||
return {
|
||||
id: id(`prt_tool_${tool}_${partIndex}`, index),
|
||||
id: id(`call_${tool}_${partIndex}`, index),
|
||||
type: "tool",
|
||||
callID: id("call", index * 100 + partIndex),
|
||||
tool,
|
||||
name: tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input,
|
||||
output: lorem(index * 23 + partIndex, outputLength),
|
||||
title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
|
||||
content: [{ type: "text", text: lorem(index * 23 + partIndex, outputLength) }],
|
||||
metadata,
|
||||
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
|
||||
},
|
||||
time: {
|
||||
created: 1700000000000 + index * 10_000,
|
||||
ran: 1700000000000 + index * 10_000,
|
||||
completed: 1700000000000 + index * 10_000 + 400,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function patchFile(seed: number, type: "add" | "update" | "delete") {
|
||||
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)
|
||||
return {
|
||||
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),
|
||||
file,
|
||||
status,
|
||||
additions: status === "deleted" ? 0 : (seed % 7) + 1,
|
||||
deletions: status === "added" ? 0 : seed % 4,
|
||||
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
before: code(seed, 32),
|
||||
after: code(seed + 1, 38),
|
||||
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, 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) {
|
||||
return Array.from({ length: lines }, (_, index) => `export const value${index} = "${lorem(seed + index, 32)}"`).join(
|
||||
"\n",
|
||||
@@ -205,21 +206,23 @@ function turn(index: number): SessionMessageInfo[] {
|
||||
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
|
||||
...(index % 3 === 0
|
||||
? [
|
||||
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
|
||||
toolPart(index, 0, "read", { path: `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", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
|
||||
...(index % 4 === 0
|
||||
? [toolPart(index, 3, "edit", { path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" }, 700)]
|
||||
: []),
|
||||
...(index % 6 === 0
|
||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
? [toolPart(index, 7, "write", { path: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
: []),
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
|
||||
...(index % 7 === 0 ? [toolPart(index, 4, "shell", { 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
|
||||
@@ -234,7 +237,15 @@ function turn(index: number): SessionMessageInfo[] {
|
||||
]
|
||||
: []),
|
||||
...(index % 17 === 0
|
||||
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
||||
? [
|
||||
toolPart(
|
||||
index,
|
||||
12,
|
||||
"subagent",
|
||||
{ description: "Inspect generated fixture", agent: "explore", prompt: "Inspect the fixture." },
|
||||
160,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
]
|
||||
return [user, assistantMessage(targetID, index, user.id, parts)]
|
||||
@@ -311,7 +322,7 @@ export const fixture = {
|
||||
targetPartIDs: targetMessages.flatMap(currentPartIDs),
|
||||
expandedShellPartID: targetMessages
|
||||
.flatMap((message) => (message.type === "assistant" ? message.content : []))
|
||||
.flatMap((part) => (part.type === "tool" && part.name === "bash" ? [part.id] : []))[0],
|
||||
.flatMap((part) => (part.type === "tool" && part.name === "shell" ? [part.id] : []))[0],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -518,7 +518,7 @@ async function expectCanScrollToStart(
|
||||
let current = await timelineState(page)
|
||||
let unchangedAtTop = 0
|
||||
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
for (let attempt = 0; attempt < 800; attempt++) {
|
||||
collectSeen(current, seenParts, seenMessages)
|
||||
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
|
||||
expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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"
|
||||
@@ -26,7 +24,7 @@ import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
import { LanguageProvider, UiI18nBridge, type Locale, useLanguage } from "@/context/language"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
@@ -101,23 +99,6 @@ 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__?: {
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
// @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,6 +235,9 @@ 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" } }),
|
||||
@@ -414,7 +417,9 @@ describe("prompt submit worktree selection", () => {
|
||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||
},
|
||||
})
|
||||
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
||||
// 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()
|
||||
})
|
||||
|
||||
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 { Identifier } from "@/utils/id"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { getDirectory } from "@opencode-ai/util/path"
|
||||
import { buildPromptRequest } from "./build-prompt-request"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
@@ -40,7 +40,6 @@ type FollowupSendInput = {
|
||||
data: Data
|
||||
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||
draft: FollowupDraft
|
||||
messageID?: string
|
||||
optimisticBusy?: boolean
|
||||
}
|
||||
|
||||
@@ -69,10 +68,9 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
) {
|
||||
setBusy()
|
||||
try {
|
||||
const messageID = Identifier.ascending("message")
|
||||
await input.api.command({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: messageID,
|
||||
id: SessionMessage.ID.create(),
|
||||
command: cmd,
|
||||
arguments: tail.join(" "),
|
||||
agent: input.draft.agent,
|
||||
@@ -95,7 +93,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const messageID = input.messageID ?? Identifier.ascending("message")
|
||||
const encodedImages = await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
...attachment,
|
||||
@@ -132,9 +129,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
})
|
||||
}
|
||||
|
||||
await input.api.prompt({
|
||||
// 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({
|
||||
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,
|
||||
@@ -446,12 +444,11 @@ 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: messageID,
|
||||
id: SessionMessage.ID.create(),
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
@@ -476,7 +473,6 @@ 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()
|
||||
@@ -486,7 +482,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
data: submissionData,
|
||||
session: () => session,
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
}).catch((err) => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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,12 +6,9 @@ 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()
|
||||
@@ -23,8 +20,7 @@ export function SessionHeader() {
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
|
||||
statusVisible: status(),
|
||||
statusLabel: language.t("status.popover.trigger"),
|
||||
status: status() ? { label: language.t("status.popover.trigger"), content: () => <StatusPopoverV2 /> } : undefined,
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewVisible: isDesktop(),
|
||||
@@ -44,52 +40,3 @@ 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,8 +1,10 @@
|
||||
import * as i18n from "@solid-primitives/i18n"
|
||||
import { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, type JSX } 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,
|
||||
@@ -260,3 +262,20 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const popularProviders = [
|
||||
"opencode",
|
||||
"opencode-go",
|
||||
"anthropic",
|
||||
"github-copilot",
|
||||
"openai",
|
||||
"google",
|
||||
"openrouter",
|
||||
"vercel",
|
||||
]
|
||||
@@ -5,17 +5,9 @@ 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 const popularProviders = [
|
||||
"opencode",
|
||||
"opencode-go",
|
||||
"anthropic",
|
||||
"github-copilot",
|
||||
"openai",
|
||||
"google",
|
||||
"openrouter",
|
||||
"vercel",
|
||||
]
|
||||
export { popularProviders } from "./provider-order"
|
||||
const popularProviderSet = new Set(popularProviders)
|
||||
|
||||
export function useProviders(directory: Accessor<string | undefined>) {
|
||||
|
||||
@@ -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,6 +81,7 @@ 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"
|
||||
@@ -90,7 +91,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 { Identifier } from "@/utils/id"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||
import { requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
@@ -264,27 +265,6 @@ 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()
|
||||
@@ -1564,7 +1544,7 @@ export default function Page() {
|
||||
const queueFollowup = (draft: FollowupDraft) => {
|
||||
setFollowup("items", draft.sessionID, (items) => [
|
||||
...(items ?? []),
|
||||
{ id: Identifier.ascending("message"), ...draft },
|
||||
{ id: SessionMessage.ID.create(), ...draft },
|
||||
])
|
||||
setFollowup("failed", draft.sessionID, undefined)
|
||||
setFollowup("paused", draft.sessionID, undefined)
|
||||
@@ -1613,14 +1593,15 @@ 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 = (file: FilePart) => {
|
||||
const openAttachment: NonNullable<SessionUserActions["openAttachment"]> = (file) => {
|
||||
const url = file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`
|
||||
const download = () => {
|
||||
const anchor = document.createElement("a")
|
||||
anchor.href = file.url
|
||||
anchor.download = getFilename(file.filename) || "attachment"
|
||||
anchor.href = url
|
||||
anchor.download = getFilename(file.name) || "attachment"
|
||||
anchor.click()
|
||||
}
|
||||
const path = file.filename ?? ""
|
||||
const path = file.name ?? ""
|
||||
const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path)
|
||||
if (platform.revealPath && absolute) {
|
||||
void platform.revealPath(path).then(
|
||||
@@ -1634,7 +1615,7 @@ export default function Page() {
|
||||
download()
|
||||
}
|
||||
|
||||
const actions = { revert, openAttachment }
|
||||
const actions = { revert, openAttachment } satisfies SessionUserActions
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = controller.identity.params.id
|
||||
|
||||
@@ -7,8 +7,32 @@ 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: SessionComposerRegionController
|
||||
controller: SessionComposerRegionViewController
|
||||
promptInput: JSX.Element
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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"
|
||||
/>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
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,9 +1,8 @@
|
||||
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 "./row-reconciliation"
|
||||
export { reuseTimelineRows } from "@opencode-ai/session-ui/timeline/projection"
|
||||
|
||||
export function createTimelineProjection(input: {
|
||||
sessionMessages: Accessor<SessionMessageInfo[]>
|
||||
@@ -75,7 +74,7 @@ export function createTimelineProjection(input: {
|
||||
return result
|
||||
})
|
||||
const projection = createMemo(() =>
|
||||
Timeline.constructSessionMessageRows(input.sessionMessages(), input.showReasoningSummaries(), input.status().type),
|
||||
Timeline.constructSessionMessageRows(input.sessionMessages(), input.showReasoningSummaries(), input.status()),
|
||||
)
|
||||
const activeMessageID = createMemo(() => projection().activeMessageID)
|
||||
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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,385 +0,0 @@
|
||||
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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
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,12 +52,29 @@ 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(
|
||||
@@ -84,12 +101,12 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
||||
const detailSource = createMemo(() => {
|
||||
const diff = sourceActiveItem()
|
||||
const load = props.loadDiff
|
||||
if (!diff || !load || !reviewDiffNeedsLoad(diff)) return
|
||||
if (!diff || !load || !reviewDiffNeedsLoad(diff)) return undefined
|
||||
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
|
||||
if (value?.file !== diff.file) return undefined
|
||||
return { source: diff, version, value }
|
||||
})
|
||||
|
||||
@@ -101,15 +118,6 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
||||
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}
|
||||
@@ -129,6 +137,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
||||
searching={searching()}
|
||||
kinds={treeKinds()}
|
||||
activeDiff={activeDiff()}
|
||||
flat={props.fileList === "flat"}
|
||||
/>
|
||||
}
|
||||
activeFile={activeDiff()}
|
||||
@@ -151,7 +160,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
||||
diff={diff()}
|
||||
diffStyle={props.diffStyle}
|
||||
expandMode={props.state.expandMode()}
|
||||
readFile={readFile}
|
||||
readFile={props.readFile}
|
||||
onLineComment={props.onLineComment}
|
||||
onLineCommentUpdate={props.onLineCommentUpdate}
|
||||
onLineCommentDelete={props.onLineCommentDelete}
|
||||
@@ -179,6 +188,7 @@ function ReviewPanelV2Sidebar(props: {
|
||||
searching: boolean
|
||||
kinds: ReturnType<typeof reviewDiffKinds>
|
||||
activeDiff: string | undefined
|
||||
flat: boolean
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [explicitHighlight, setExplicitHighlight] = createSignal<string | undefined>()
|
||||
@@ -225,13 +235,25 @@ function ReviewPanelV2Sidebar(props: {
|
||||
<Show
|
||||
when={props.searching}
|
||||
fallback={
|
||||
<FileTreeV2
|
||||
allowed={props.filteredFiles}
|
||||
kinds={props.kinds}
|
||||
draggable={false}
|
||||
active={props.activeDiff}
|
||||
onFileClick={(node) => props.onSelectFile(node.path)}
|
||||
/>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
|
||||
+1
-194
@@ -1,203 +1,10 @@
|
||||
import type { FileDiffInfo, ProjectListOutput, WorktreeDirectory } from "@opencode-ai/client/promise"
|
||||
import type { 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
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,241 +0,0 @@
|
||||
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 "../../session-ui/src/v2/components/prompt-input/attachments"
|
||||
import type { PromptInputV2Prompt } from "../../session-ui/src/v2/components/prompt-input/types"
|
||||
import { createPromptInputV2Attachments } from "@opencode-ai/session-ui/v2/prompt-input/attachments"
|
||||
import type { PromptInputV2Prompt } from "@opencode-ai/session-ui/v2/prompt-input/types"
|
||||
|
||||
describe("prompt attachment session ownership", () => {
|
||||
test("adds an asynchronously read image to the session where the read started", async () => {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
export * as ServerProcess from "./server-process"
|
||||
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
|
||||
import { Service, type DiscoverOptions } 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 path from "node:path"
|
||||
import { Effect, FileSystem, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { Effect, Option, Redacted, Schedule } 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,7 +120,13 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
onListen: (address, shutdown) =>
|
||||
Effect.gen(function* () {
|
||||
if (!config.password) yield* ServiceConfig.password(password)
|
||||
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
|
||||
return yield* ServiceRegistration.register({
|
||||
address,
|
||||
password,
|
||||
id: instanceID,
|
||||
file: serviceOptions.file,
|
||||
shutdown,
|
||||
})
|
||||
}),
|
||||
},
|
||||
transform,
|
||||
@@ -157,70 +163,6 @@ 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),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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,
|
||||
)
|
||||
})
|
||||
@@ -1,97 +1,14 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { 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,13 +77,6 @@ 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()
|
||||
|
||||
@@ -7,6 +7,7 @@ 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"
|
||||
@@ -100,33 +101,30 @@ export async function createAcpFixture(options: { readonly skill?: string } = {}
|
||||
llm: { requests },
|
||||
spawn(extraEnv: Record<string, string | undefined> = {}) {
|
||||
const acp = spawnAcp({
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
env: isolatedEnv(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]() {
|
||||
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 })
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,7 +349,6 @@ 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("")
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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)
|
||||
@@ -150,20 +151,6 @@ 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 {
|
||||
@@ -455,39 +442,45 @@ test("port contender recognizes an incumbent registered during the bind race", a
|
||||
}
|
||||
}, 45_000)
|
||||
|
||||
test("stale dead registration is replaced after binding the selected port", async () => {
|
||||
test("service registration replaces a stale owner with the bound address", 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:${port}`, pid: 2_147_483_647 }),
|
||||
JSON.stringify({ id: "dead", version: "dead", url: "http://127.0.0.1:4321", 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 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
|
||||
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)
|
||||
} 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,10 +1,14 @@
|
||||
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: { ...process.env, OPENCODE_SERVER_USERNAME: "custom" },
|
||||
env: isolatedEnv(root, { OPENCODE_SERVER_USERNAME: "custom" }),
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
@@ -28,7 +32,9 @@ 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; text: string; mention?: PromptMention }
|
||||
export type PromptSkillAttachment = { id: string; name: string; mention?: PromptMention }
|
||||
|
||||
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
|
||||
|
||||
@@ -2567,7 +2567,6 @@ 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"
|
||||
@@ -2836,7 +2835,6 @@ 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"
|
||||
@@ -3105,7 +3103,6 @@ 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"
|
||||
|
||||
@@ -34,10 +34,11 @@ import type {
|
||||
WebSearchProvider,
|
||||
} from "../promise"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { isPermissionNotFoundError } from "../promise"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { isPermissionNotFoundError, type SessionPromptInput } from "../promise"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { batch, createEffect, createSignal, onCleanup } from "solid-js"
|
||||
|
||||
export type DataSessionStatus = "idle" | "running"
|
||||
|
||||
@@ -178,11 +179,6 @@ 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))
|
||||
@@ -219,6 +215,60 @@ 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(
|
||||
@@ -325,6 +375,7 @@ 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}`)
|
||||
@@ -493,49 +544,16 @@ export function createData(config: CreateDataInput) {
|
||||
updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery)
|
||||
return
|
||||
case "session.inbox.cancelled": {
|
||||
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)
|
||||
})
|
||||
retractLocal(event.data.sessionID, event.data.inboxID)
|
||||
return
|
||||
}
|
||||
case "session.inbox.enqueued": {
|
||||
const item = event.data.item
|
||||
addPending({
|
||||
outbox.delete(event.data.inboxID)
|
||||
admitLocal({
|
||||
id: event.data.inboxID,
|
||||
sessionID: event.data.sessionID,
|
||||
timeCreated: event.created,
|
||||
...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 },
|
||||
},
|
||||
)
|
||||
...event.data.item,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1062,12 +1080,19 @@ export function createData(config: CreateDataInput) {
|
||||
sync(sessionID: string) {
|
||||
return sync.run(`session.pending:${sessionID}`, async () => {
|
||||
const pending = await api().session.inbox.list({ sessionID })
|
||||
setStore("session", "pending", sessionID, reconcile(pending))
|
||||
// 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",
|
||||
"input",
|
||||
sessionID,
|
||||
reconcile(pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
|
||||
reconcile(merged.filter((item) => item.type !== "compaction").map((item) => item.id)),
|
||||
)
|
||||
})
|
||||
},
|
||||
@@ -1075,6 +1100,47 @@ 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([
|
||||
@@ -1114,7 +1180,14 @@ 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 messages = response.data.toReversed()
|
||||
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]
|
||||
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)
|
||||
|
||||
@@ -20,48 +20,16 @@ export interface MapInput {
|
||||
export function map(input: MapInput): Mapping | undefined {
|
||||
const baseSettings = mapBaseSettings(input.settings)
|
||||
switch (input.packageName) {
|
||||
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 } : {}),
|
||||
}
|
||||
case "@ai-sdk/anthropic":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/anthropic",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...(typeof input.settings.authToken === "string" ? { authToken: input.settings.authToken } : {}),
|
||||
...(Object.keys(providerOptions).length === 0 ? {} : { providerOptions }),
|
||||
...mapProviderOptions(input.settings, ["apiKey", "authToken", "baseURL"]),
|
||||
},
|
||||
...(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",
|
||||
@@ -103,7 +71,10 @@ 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),
|
||||
...mapGoogleOptions(
|
||||
input.settings,
|
||||
isStringRecord(input.settings.labels) ? { labels: input.settings.labels } : {},
|
||||
),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
@@ -126,6 +97,29 @@ 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":
|
||||
@@ -140,6 +134,12 @@ 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,26 +260,17 @@ 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 } : {}),
|
||||
}
|
||||
return options
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
}
|
||||
|
||||
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
|
||||
@@ -292,7 +283,7 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
|
||||
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
|
||||
}
|
||||
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Readonly<Record<string, unknown>> = {}) {
|
||||
const input = settings.thinkingConfig
|
||||
const thinkingConfig = {
|
||||
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
|
||||
@@ -307,6 +298,7 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
...(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 }
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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 },
|
||||
)
|
||||
})
|
||||
+46
-1
@@ -1,6 +1,6 @@
|
||||
export * as KV from "./kv.js"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
import { and, asc, eq, gt, gte, lt } 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,10 +8,27 @@ 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") {}
|
||||
@@ -40,8 +57,36 @@ 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] })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ModelResolver from "./model-resolver.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LanguageModel, ProviderPackage } from "@opencode-ai/ai"
|
||||
import { LanguageModel } from "@opencode-ai/ai"
|
||||
import { Auth } from "@opencode-ai/ai/route"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { produce } from "immer"
|
||||
@@ -68,6 +68,8 @@ 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 {
|
||||
@@ -124,7 +126,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 = Provider.mergeOverlay(resolved.settings, configuration) ?? {}
|
||||
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
|
||||
const mapping = Provider.isAISDK(resolved.package)
|
||||
? AISDKNative.map({
|
||||
packageName,
|
||||
@@ -140,7 +142,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
const settings = yield* prepareProviderSettings(
|
||||
resolved,
|
||||
Provider.mergeOverlay(resolved.settings, {
|
||||
...legacyCredentialSettings(credential),
|
||||
...nativeCredentialSettings(resolved.package ?? "", credential),
|
||||
...credential?.metadata,
|
||||
...configuration,
|
||||
}) ?? {},
|
||||
@@ -157,18 +159,16 @@ 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({
|
||||
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 },
|
||||
},
|
||||
})
|
||||
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
|
||||
return LanguageModel.update(runtime, {
|
||||
provider: resolved.providerID,
|
||||
compatibility: resolved.compatibility
|
||||
@@ -227,23 +227,25 @@ function unresolvedProviderVariables(model: Info, baseURL: string) {
|
||||
})
|
||||
}
|
||||
|
||||
const legacyCredentialSettings = (credential: Credential.Value | undefined) => {
|
||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||
if (!credential) return {}
|
||||
if (credential.type === "key") return { apiKey: credential.key }
|
||||
if (
|
||||
specifier === "@opencode-ai/ai/providers/anthropic" ||
|
||||
specifier === "@opencode-ai/ai/providers/anthropic-compatible"
|
||||
)
|
||||
return { authToken: credential.access }
|
||||
if (
|
||||
specifier === "@opencode-ai/ai/providers/google-vertex" ||
|
||||
specifier.startsWith("@opencode-ai/ai/providers/google-vertex/")
|
||||
)
|
||||
return { accessToken: credential.access }
|
||||
return { apiKey: credential.access }
|
||||
}
|
||||
|
||||
const 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 withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
|
||||
const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings
|
||||
return rest
|
||||
}
|
||||
|
||||
const unsupported = (model: Info) =>
|
||||
@@ -297,6 +299,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
capabilities: selected.capabilities,
|
||||
cost: selected.cost,
|
||||
limit: selected.limit,
|
||||
}
|
||||
})
|
||||
return Service.of({
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Catalog } from "./catalog.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
import { Location } from "./location.js"
|
||||
import { PluginHost } from "./plugin/host.js"
|
||||
@@ -41,16 +42,18 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const kv = yield* KV.Service
|
||||
const scope = yield* Scope.make()
|
||||
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let inventory: Plugin.Info[] = []
|
||||
let host: Parameters<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>[0]
|
||||
|
||||
const load = Effect.fnUntraced(function* (plugin: Versioned) {
|
||||
const child = yield* Scope.fork(scope)
|
||||
const inherit = yield* State.inherit()
|
||||
const loaded = yield* Effect.suspend(() => plugin.effect(host)).pipe(
|
||||
const loaded = yield* Effect.suspend(() =>
|
||||
plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) }),
|
||||
).pipe(
|
||||
inherit,
|
||||
Effect.updateContext((context: Context.Context<never>) =>
|
||||
Context.make(Scope.Scope, child).pipe(
|
||||
@@ -189,6 +192,7 @@ export const node = makeLocationNode({
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
MCP.node,
|
||||
Location.node,
|
||||
Reference.node,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Command } from "../command.js"
|
||||
import { Credential } from "../credential.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Integration } from "../integration.js"
|
||||
import { KV } from "../kv.js"
|
||||
import { Location } from "../location.js"
|
||||
import { Model } from "../model.js"
|
||||
import { MCP } from "../mcp/index.js"
|
||||
@@ -28,7 +29,10 @@ import { WebSearch } from "../websearch.js"
|
||||
import { PluginHooks } from "./hooks.js"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../plugin.js").Interface) {
|
||||
export const make = Effect.fn("PluginHost.make")(function* (
|
||||
plugin: import("../plugin.js").Interface,
|
||||
pluginID: string = "test",
|
||||
) {
|
||||
const app = yield* App.Metadata
|
||||
const agents = yield* Agent.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
@@ -36,6 +40,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
const commands = yield* Command.Service
|
||||
const bus = yield* Bus.Service
|
||||
const integration = yield* Integration.Service
|
||||
const kv = yield* KV.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const location = yield* Location.Service
|
||||
const reference = yield* Reference.Service
|
||||
@@ -340,6 +345,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
})
|
||||
}),
|
||||
},
|
||||
storage: storage(kv, pluginID),
|
||||
shell: {
|
||||
hook: (name, callback) => hooks.register("shell", name, callback),
|
||||
},
|
||||
@@ -406,6 +412,35 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
} satisfies Plugin.Context
|
||||
})
|
||||
|
||||
export function storage(kv: KV.Interface, pluginID: string): Plugin.Context["storage"] {
|
||||
const namespace = `plugin:${pluginID
|
||||
.split("")
|
||||
.map((value) => value.charCodeAt(0).toString(16).padStart(4, "0"))
|
||||
.join("")}:`
|
||||
return {
|
||||
get: (key) => kv.get(namespace + key),
|
||||
set: (key, value) => kv.set(namespace + key, value),
|
||||
remove: (key) => kv.remove(namespace + key),
|
||||
scan: (options) =>
|
||||
kv
|
||||
.scan({
|
||||
prefix: namespace + options.prefix,
|
||||
after: options.after === undefined ? undefined : namespace + options.after,
|
||||
limit: options.limit,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) => {
|
||||
const entries = result.entries.map((entry) => ({
|
||||
key: entry.key.slice(namespace.length),
|
||||
value: entry.value,
|
||||
}))
|
||||
if (result.next === undefined) return { entries }
|
||||
return { entries, next: result.next.slice(namespace.length) }
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {
|
||||
if ("authorize" in input) {
|
||||
const refresh = input.refresh
|
||||
|
||||
@@ -3,7 +3,7 @@ export { Service, type Interface } from "./supervisor-service.js"
|
||||
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Cause, Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Cause, Effect, Latch, Layer, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
@@ -137,7 +137,7 @@ export const layer = Layer.effect(
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const bus = yield* Bus.Service
|
||||
const ready = { current: yield* Deferred.make<void>() }
|
||||
const ready = yield* Latch.make()
|
||||
let observed = 0
|
||||
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
|
||||
@@ -164,7 +164,7 @@ export const layer = Layer.effect(
|
||||
Stream.mapEffect(() =>
|
||||
Effect.gen(function* () {
|
||||
observed++
|
||||
if (yield* Deferred.isDone(ready.current)) ready.current = yield* Deferred.make<void>()
|
||||
yield* ready.close
|
||||
return observed
|
||||
}),
|
||||
),
|
||||
@@ -176,12 +176,12 @@ export const layer = Layer.effect(
|
||||
Stream.runForEach((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* activate()
|
||||
if (observed === target) yield* Deferred.succeed(ready.current, undefined)
|
||||
if (observed === target) yield* ready.open
|
||||
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({ flush: Effect.suspend(() => Deferred.await(ready.current)) })
|
||||
return Service.of({ flush: ready.await })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user