mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-20 08:36:03 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff7b40ed4a | ||
|
|
4a94dbe8af | ||
|
|
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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
input: {
|
||||
path: "src/regression.ts",
|
||||
oldString: "export const value = 'before'",
|
||||
newString: "export const value = 'after'",
|
||||
},
|
||||
content: [{ type: "text", text: "Edited 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: [
|
||||
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)
|
||||
|
||||
@@ -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] })
|
||||
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
|
||||
@@ -608,10 +608,9 @@ const layer = Layer.effect(
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
if (
|
||||
admitted.type !== "user" ||
|
||||
!SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: admittedInput })
|
||||
)
|
||||
// First admission wins: same-session reuse is idempotent and ignores the
|
||||
// retried payload, metadata, and delivery mode.
|
||||
if (admitted.type !== "user" || admitted.sessionID !== input.sessionID)
|
||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
if (input.resume !== false) {
|
||||
if (activeShells.has(admitted.sessionID)) return admitted
|
||||
@@ -791,7 +790,7 @@ const layer = Layer.effect(
|
||||
payload,
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
const recovered = yield* SessionInbox.serialized(
|
||||
yield* SessionInbox.serialized(
|
||||
input.sessionID,
|
||||
Effect.gen(function* () {
|
||||
const latest = yield* result.get(input.sessionID)
|
||||
@@ -802,25 +801,16 @@ const layer = Layer.effect(
|
||||
)
|
||||
const moved = [SessionEvent.Moved, { sessionID: input.sessionID, ...payload }] as const
|
||||
const first = cancellations[0]
|
||||
if (!first) {
|
||||
yield* bus.publish(...moved)
|
||||
return true
|
||||
}
|
||||
yield* bus.publishAll([first, ...cancellations.slice(1), moved])
|
||||
return true
|
||||
if (!first) return yield* bus.publish(...moved).pipe(Effect.asVoid)
|
||||
return yield* bus.publishAll([first, ...cancellations.slice(1), moved])
|
||||
}
|
||||
yield* SessionInbox.admit(db, bus, {
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID: input.sessionID,
|
||||
item,
|
||||
})
|
||||
return false
|
||||
}),
|
||||
)
|
||||
if (recovered) {
|
||||
yield* execution.wakeActive(input.sessionID)
|
||||
return
|
||||
}
|
||||
yield* execution.wake(input.sessionID)
|
||||
}),
|
||||
compact: Effect.fn("Session.compact")(function* (input) {
|
||||
@@ -892,10 +882,9 @@ const layer = Layer.effect(
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
if (
|
||||
admitted.type !== "synthetic" ||
|
||||
!SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: admittedInput })
|
||||
)
|
||||
// First admission wins: same-session reuse is idempotent and ignores the
|
||||
// retried payload, metadata, and delivery mode.
|
||||
if (admitted.type !== "synthetic" || admitted.sessionID !== input.sessionID)
|
||||
return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID })
|
||||
if (input.resume !== false && !(yield* result.get(input.sessionID)).revert)
|
||||
yield* execution.wake(input.sessionID)
|
||||
@@ -982,7 +971,6 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
|
||||
return Effect.succeed({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
text: Skill.toModelOutput(skill, []),
|
||||
mention: attachment.mention,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionCompaction from "./compaction.js"
|
||||
|
||||
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
|
||||
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
@@ -18,7 +18,6 @@ import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { toSessionError } from "./to-session-error.js"
|
||||
import { Token } from "../util/token.js"
|
||||
import type { Info, Ref } from "../model.js"
|
||||
import { SessionUsage } from "./usage.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { Agent } from "../agent.js"
|
||||
@@ -83,9 +82,7 @@ type Dependencies = {
|
||||
export type AutoInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly model: LanguageModel
|
||||
readonly ref: Ref
|
||||
readonly cost: Info["cost"]
|
||||
readonly resolved: SessionRunnerModel.Resolved
|
||||
}
|
||||
|
||||
export type ManualInput = {
|
||||
@@ -95,13 +92,11 @@ export type ManualInput = {
|
||||
readonly started?: boolean
|
||||
}
|
||||
|
||||
type RequiredInput = Omit<AutoInput, "ref">
|
||||
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
|
||||
|
||||
type Plan = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly model: LanguageModel
|
||||
readonly ref: Ref
|
||||
readonly cost: Info["cost"]
|
||||
readonly resolved: SessionRunnerModel.Resolved
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
readonly prompt: string
|
||||
readonly recent: string
|
||||
@@ -138,8 +133,7 @@ const serialize = (message: SessionMessage.Info) => {
|
||||
(file) =>
|
||||
`[Attached ${file.mime}: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}]`,
|
||||
) ?? []
|
||||
const skills = message.skills?.map((skill) => `[Attached skill: ${skill.name}]\n${skill.text}`) ?? []
|
||||
return [`[User]: ${message.text}`, ...skills, ...files].join("\n")
|
||||
return [`[User]: ${message.text}`, ...files].join("\n")
|
||||
}
|
||||
if (message.type === "location-switched")
|
||||
return `[User]: The working directory has been changed to ${message.location.directory}.`
|
||||
@@ -274,9 +268,9 @@ const make = (dependencies: Dependencies) => {
|
||||
)
|
||||
const request = yield* SessionModelHook.apply(
|
||||
dependencies.hooks,
|
||||
{ sessionID: plan.session.id, agent: Agent.ID.make("compaction"), model: plan.ref },
|
||||
{ sessionID: plan.session.id, agent: Agent.ID.make("compaction"), model: plan.resolved.ref },
|
||||
LLM.request({
|
||||
model: plan.model,
|
||||
model: plan.resolved.model,
|
||||
promptCacheKey: SessionPromptCacheKey.make(plan.session.id),
|
||||
http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) },
|
||||
messages: [Message.user(plan.prompt)],
|
||||
@@ -288,7 +282,7 @@ const make = (dependencies: Dependencies) => {
|
||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||
sessionID: plan.session.id,
|
||||
agent: Agent.ID.make("compaction"),
|
||||
model: plan.ref,
|
||||
model: plan.resolved.ref,
|
||||
}),
|
||||
})
|
||||
.pipe(
|
||||
@@ -306,7 +300,7 @@ const make = (dependencies: Dependencies) => {
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, plan.cost)
|
||||
const step = SessionUsage.record(event.usage, plan.resolved.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
@@ -355,9 +349,7 @@ const make = (dependencies: Dependencies) => {
|
||||
if (content)
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
model: input.model,
|
||||
ref: input.ref,
|
||||
cost: input.cost,
|
||||
resolved: input.resolved,
|
||||
reason: "auto",
|
||||
...content,
|
||||
})
|
||||
@@ -371,17 +363,17 @@ const make = (dependencies: Dependencies) => {
|
||||
const required = (input: RequiredInput) => {
|
||||
const config = state.get()
|
||||
if (!config.auto) return false
|
||||
const context = input.model.route.defaults.limits?.context
|
||||
if (context === undefined || context <= 0) return false
|
||||
const limit = input.resolved.limit
|
||||
const context = limit.context
|
||||
if (context <= 0) return false
|
||||
const last = input.messages.findLast(
|
||||
(message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } =>
|
||||
message.type === "assistant" && message.tokens !== undefined,
|
||||
)
|
||||
if (!last) return false
|
||||
const limits = input.model.route.defaults.limits
|
||||
const output = Math.min(limits?.output ?? 0, OUTPUT_TOKEN_MAX)
|
||||
const output = Math.min(limit.output, OUTPUT_TOKEN_MAX)
|
||||
const promptCeiling = Math.min(
|
||||
limits?.input === undefined ? Number.POSITIVE_INFINITY : limits.input - config.buffer,
|
||||
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
|
||||
context - Math.max(output, config.buffer),
|
||||
)
|
||||
const used =
|
||||
@@ -411,9 +403,7 @@ const make = (dependencies: Dependencies) => {
|
||||
if ("status" in resolved) return resolved
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
model: resolved.model,
|
||||
ref: resolved.ref,
|
||||
cost: resolved.cost,
|
||||
resolved,
|
||||
reason: "manual",
|
||||
inputID: input.inputID,
|
||||
started: input.started,
|
||||
|
||||
@@ -21,9 +21,11 @@ export interface Interface {
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Wakes only an active execution, preserving its current input eligibility. */
|
||||
readonly wakeActive: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||
/**
|
||||
* Interrupt active work owned by this process. Idle interruption is a no-op. Resolves once
|
||||
* the interruption is accepted; cleanup settles asynchronously in the execution fiber.
|
||||
* Compose with `awaitIdle` when settlement matters.
|
||||
*/
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
|
||||
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
||||
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
@@ -32,7 +34,7 @@ export interface Interface {
|
||||
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionExecution") {}
|
||||
|
||||
type InterruptReason = "user" | "shutdown" | "superseded"
|
||||
type InterruptReason = "user" | "shutdown"
|
||||
|
||||
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: InterruptReason) {
|
||||
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
|
||||
@@ -113,8 +115,8 @@ export const layer = Layer.effect(
|
||||
return
|
||||
}
|
||||
if (outcome.type === "interrupted") {
|
||||
// A user cancel (or a superseding execution) releases the claim: the turn must not
|
||||
// resurrect at the next boot. Shutdown interruption keeps it for restart continuity.
|
||||
// A user cancel releases the claim: the turn must not resurrect at the next
|
||||
// boot. Shutdown interruption keeps it for restart continuity.
|
||||
yield* bus.publish(
|
||||
SessionEvent.Execution.Interrupted,
|
||||
{ sessionID, reason: outcome.reason },
|
||||
@@ -137,16 +139,24 @@ export const layer = Layer.effect(
|
||||
return Service.of({
|
||||
active: coordinator.active,
|
||||
interrupt: (sessionID, options) =>
|
||||
coordinator.interrupt(
|
||||
sessionID,
|
||||
"user",
|
||||
options?.continue
|
||||
? { continue: { request: "steer", when: SessionInbox.has(db, sessionID, "steer") } }
|
||||
: undefined,
|
||||
),
|
||||
Effect.gen(function* () {
|
||||
yield* coordinator.interrupt(sessionID, "user")
|
||||
if (!options?.continue) return
|
||||
// Resume steering input and between-turn control work from the interrupted
|
||||
// intent. Queued next-turn prompts stay parked: a steer-scoped drain never
|
||||
// promotes them, and a control item behind a queued prompt waits its turn.
|
||||
// Interruption acknowledges before cleanup settles, so this wake usually lands
|
||||
// on the stopping execution's doorbell and starts the successor at settle.
|
||||
// Reading the inbox concurrently with the dying drain is safe: delivery consumes
|
||||
// rows inside uninterruptible publications, so a steer row is either still
|
||||
// promotable here or was fully delivered and needs no resumption.
|
||||
const next = yield* SessionInbox.nextPromotable(db, sessionID, "input")
|
||||
if (next === undefined) return
|
||||
if (next.delivery === "steer" || next.type === "compaction" || next.type === "move")
|
||||
yield* coordinator.wake(sessionID, "steer")
|
||||
}),
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
wakeActive: coordinator.wakeActive,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
@@ -165,7 +175,6 @@ export const noopLayer = Layer.succeed(
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: () => Effect.void,
|
||||
wake: () => Effect.void,
|
||||
wakeActive: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
|
||||
@@ -1,96 +1,54 @@
|
||||
export * as SessionGenerateNode from "./generate-node.js"
|
||||
|
||||
import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { LLMClient, Message } from "@opencode-ai/ai"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "../app.js"
|
||||
import { llmClient } from "../effect/app-node-platform.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { SessionContext } from "./context.js"
|
||||
import { SessionGenerate } from "./generate.js"
|
||||
import { SessionHistory } from "./history.js"
|
||||
import { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionModelHook } from "./model-hook.js"
|
||||
import { SessionModelHttp } from "./model-http.js"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSystemPrompt } from "./system-prompt.js"
|
||||
import { toLLMMessages } from "./runner/to-llm-message.js"
|
||||
|
||||
export const layer = Layer.effect(
|
||||
SessionGenerate.Service,
|
||||
Effect.gen(function* () {
|
||||
const context = yield* SessionContext.Service
|
||||
const database = yield* Database.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const app = yield* App.Metadata
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
|
||||
return SessionGenerate.Service.of({
|
||||
generate: Effect.fn("SessionGenerate.generate")(function* (input) {
|
||||
const selection = yield* context.select(input.sessionID)
|
||||
const model = yield* models.resolve(selection.session)
|
||||
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
|
||||
const providerMetadataKey = model.model.route.providerMetadataKey ?? model.model.provider
|
||||
const tools = selection.tools
|
||||
const toolDefinitions = tools.definitions
|
||||
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
|
||||
const contextEvent = yield* hooks.trigger("session", "context", {
|
||||
sessionID: selection.session.id,
|
||||
agent: selection.agent.id,
|
||||
model: model.ref,
|
||||
system: [
|
||||
selection.agent.info.system
|
||||
? selection.agent.info.system
|
||||
: SessionSystemPrompt.make(toolDefinitions.map((tool) => tool.name)),
|
||||
history.initial,
|
||||
]
|
||||
.filter((part) => part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: [
|
||||
...toLLMMessages(history.messages, model.ref, providerMetadataKey),
|
||||
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
|
||||
Message.user(input.prompt),
|
||||
],
|
||||
tools: Object.fromEntries(
|
||||
toolDefinitions.map((tool) => [
|
||||
tool.name,
|
||||
{ description: tool.description, input: { ...tool.inputSchema } },
|
||||
]),
|
||||
),
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: selection.agent.info,
|
||||
model,
|
||||
tools: selection.tools,
|
||||
initial: history.initial,
|
||||
messages: history.messages,
|
||||
})
|
||||
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
|
||||
const registered = toolsByName.get(name)
|
||||
return registered
|
||||
? [Object.assign({}, registered, { description: tool.description, inputSchema: tool.input })]
|
||||
: []
|
||||
const prepared = yield* modelRequests.prepare({
|
||||
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
messages: [
|
||||
...transcript.messages,
|
||||
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
|
||||
Message.user(input.prompt),
|
||||
],
|
||||
},
|
||||
})
|
||||
yield* Effect.logInfo("sending session generation request", {
|
||||
sessionID: selection.session.id,
|
||||
providerID: model.ref.providerID,
|
||||
modelID: model.ref.id,
|
||||
})
|
||||
const request = yield* SessionModelHook.apply(
|
||||
hooks,
|
||||
{ sessionID: selection.session.id, agent: selection.agent.id, model: model.ref },
|
||||
LLM.request({
|
||||
model: model.model,
|
||||
http: { headers: SessionModelHeaders.make(selection.session, app) },
|
||||
promptCacheKey: SessionPromptCacheKey.make(selection.session.id),
|
||||
system: contextEvent.system,
|
||||
messages: contextEvent.messages,
|
||||
tools: hookedTools,
|
||||
}),
|
||||
)
|
||||
const response = yield* llm.generate(request, {
|
||||
http: SessionModelHttp.middleware(hooks, {
|
||||
sessionID: selection.session.id,
|
||||
agent: selection.agent.id,
|
||||
model: model.ref,
|
||||
}),
|
||||
})
|
||||
const response = yield* llm.generate(prepared.request, prepared.options)
|
||||
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
|
||||
return response.text
|
||||
}),
|
||||
@@ -101,5 +59,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: SessionGenerate.Service,
|
||||
layer,
|
||||
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, App.node, llmClient],
|
||||
deps: [SessionContext.node, Database.node, SessionModelRequest.node, SessionRunnerModel.node, llmClient],
|
||||
})
|
||||
|
||||
@@ -333,49 +333,29 @@ export const moveIDs = Effect.fn("SessionInbox.moveIDs")(function* (db: Database
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const nextQueued = Effect.fn("SessionInbox.nextQueued")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionInboxTable)
|
||||
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue")))
|
||||
.orderBy(asc(SessionInboxTable.enqueued_seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row ? fromRow(row) : undefined
|
||||
})
|
||||
|
||||
export const nextSteer = Effect.fn("SessionInbox.nextSteer")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionInboxTable)
|
||||
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
|
||||
.orderBy(asc(SessionInboxTable.enqueued_seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row ? fromRow(row) : undefined
|
||||
})
|
||||
|
||||
export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: Promotable,
|
||||
) {
|
||||
return (yield* nextSteer(db, sessionID)) ?? (promotable === "input" ? yield* nextQueued(db, sessionID) : undefined)
|
||||
const next = (delivery: Delivery) =>
|
||||
db
|
||||
.select()
|
||||
.from(SessionInboxTable)
|
||||
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, delivery)))
|
||||
.orderBy(asc(SessionInboxTable.enqueued_seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const steer = yield* next("steer")
|
||||
if (steer) return fromRow(steer)
|
||||
if (promotable !== "input") return undefined
|
||||
const queued = yield* next("queue")
|
||||
return queued ? fromRow(queued) : undefined
|
||||
})
|
||||
|
||||
/**
|
||||
* Which pending rows count: "any" counts every row, while "input" means any
|
||||
* item in either delivery mode.
|
||||
*/
|
||||
export type Scope = "any" | "input" | Delivery
|
||||
/** Which pending rows count: "input" means any item in either delivery mode. */
|
||||
export type Scope = "input" | Delivery
|
||||
|
||||
export const has = Effect.fn("SessionInbox.has")(function* (
|
||||
db: DatabaseService,
|
||||
@@ -388,11 +368,9 @@ export const has = Effect.fn("SessionInbox.has")(function* (
|
||||
.where(
|
||||
and(
|
||||
eq(SessionInboxTable.session_id, sessionID),
|
||||
scope === "any"
|
||||
? undefined
|
||||
: scope === "input"
|
||||
? or(eq(SessionInboxTable.delivery, "steer"), eq(SessionInboxTable.delivery, "queue"))
|
||||
: eq(SessionInboxTable.delivery, scope),
|
||||
scope === "input"
|
||||
? or(eq(SessionInboxTable.delivery, "steer"), eq(SessionInboxTable.delivery, "queue"))
|
||||
: eq(SessionInboxTable.delivery, scope),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
@@ -401,23 +379,6 @@ export const has = Effect.fn("SessionInbox.has")(function* (
|
||||
return row !== undefined
|
||||
})
|
||||
|
||||
export const equivalent = (input: Info, expected: { readonly sessionID: SessionSchema.ID; readonly item: Item }) => {
|
||||
if (
|
||||
input.type !== expected.item.type ||
|
||||
input.delivery !== expected.item.delivery ||
|
||||
input.sessionID !== expected.sessionID
|
||||
)
|
||||
return false
|
||||
if (input.type === "user" && expected.item.type === "user")
|
||||
return JSON.stringify(encodeUser(input.payload)) === JSON.stringify(encodeUser(expected.item.payload))
|
||||
if (input.type === "synthetic" && expected.item.type === "synthetic")
|
||||
return JSON.stringify(encodeSynthetic(input.payload)) === JSON.stringify(encodeSynthetic(expected.item.payload))
|
||||
if (input.type === "compaction" && expected.item.type === "compaction") return true
|
||||
if (input.type === "move" && expected.item.type === "move")
|
||||
return JSON.stringify(encodeMove(input.payload)) === JSON.stringify(encodeMove(expected.item.payload))
|
||||
return false
|
||||
}
|
||||
|
||||
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
|
||||
serialized(input.sessionID, effect).pipe(Effect.asVoid)
|
||||
|
||||
|
||||
@@ -12,16 +12,17 @@ import { Permission } from "../permission.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { QuestionTool } from "../tool/plugin/question.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { SessionContext } from "./context.js"
|
||||
import { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionModelHook } from "./model-hook.js"
|
||||
import { SessionModelHttp } from "./model-http.js"
|
||||
import { SessionModelTransport } from "./model-transport.js"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics.js"
|
||||
import { MAX_STEPS_PROMPT } from "./runner/max-steps.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionSystemPrompt } from "./system-prompt.js"
|
||||
import { toLLMMessages } from "./runner/to-llm-message.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import type { Agent } from "../agent.js"
|
||||
|
||||
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
|
||||
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
|
||||
@@ -48,20 +49,49 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
|
||||
interface Prepared {
|
||||
readonly request: LLMRequest
|
||||
readonly options: StreamOptions
|
||||
/** False when Session HTTP hooks require the request to remain on HTTP. */
|
||||
readonly webSocketEligible: boolean
|
||||
/**
|
||||
* One request-scoped execution operation. Unknown, hook-removed, and
|
||||
* step-limit-violating calls fail individually through the same seam.
|
||||
* One request-scoped execution operation. Unknown and hook-removed calls
|
||||
* fail individually through the same seam.
|
||||
*/
|
||||
readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError>
|
||||
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */
|
||||
readonly stepLimitReached: boolean
|
||||
}
|
||||
|
||||
interface PrepareInput {
|
||||
readonly context: SessionContext.Loaded
|
||||
readonly step: number
|
||||
readonly scope: {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agentID: Agent.ID
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
readonly tools: Tool.Snapshot
|
||||
}
|
||||
readonly transcript: {
|
||||
readonly system: Array<SystemPart>
|
||||
readonly messages: Array<Message>
|
||||
}
|
||||
readonly toolChoice?: LLM.RequestInput["toolChoice"]
|
||||
/** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */
|
||||
readonly webSocket?: "session"
|
||||
}
|
||||
|
||||
export const baseTranscript = (input: {
|
||||
readonly agent: Agent.Info
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
readonly tools: Tool.Snapshot
|
||||
readonly initial: string
|
||||
readonly messages: ReadonlyArray<SessionMessage.Info>
|
||||
}) => {
|
||||
const providerMetadataKey = input.model.model.route.providerMetadataKey ?? input.model.model.provider
|
||||
return {
|
||||
providerMetadataKey,
|
||||
system: [
|
||||
input.agent.system
|
||||
? input.agent.system
|
||||
: SessionSystemPrompt.make(input.tools.definitions.map((tool) => tool.name)),
|
||||
input.initial,
|
||||
]
|
||||
.filter((part) => part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: toLLMMessages(input.messages, input.model.ref, providerMetadataKey),
|
||||
}
|
||||
}
|
||||
|
||||
const mimeToModality = (mime: string) => {
|
||||
@@ -175,30 +205,11 @@ export const layer = Layer.effect(
|
||||
Config.withDefault(false),
|
||||
Effect.orDie,
|
||||
)
|
||||
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
|
||||
Config.withDefault(false),
|
||||
Effect.orDie,
|
||||
)
|
||||
const promptCacheSnapshots = diagnostics ? new Map<string, PromptCacheDiagnostics.Snapshot>() : undefined
|
||||
|
||||
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
|
||||
const session = input.context.session
|
||||
const agent = input.context.agent
|
||||
const resolved = input.context.model
|
||||
const session = input.scope.session
|
||||
const resolved = input.scope.model
|
||||
const model = resolved.model
|
||||
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
|
||||
const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps
|
||||
// The final Step keeps definitions available to protocols with native "none",
|
||||
// preserving their prompt cache prefix. Calls are still rejected at execution.
|
||||
const tools = input.context.tools
|
||||
const system = [
|
||||
agent.info.system ? agent.info.system : SessionSystemPrompt.make(tools.definitions.map((tool) => tool.name)),
|
||||
input.context.initial,
|
||||
]
|
||||
.filter((part) => part.length > 0)
|
||||
.map(SystemPart.make)
|
||||
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
|
||||
const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history
|
||||
const tools = input.scope.tools
|
||||
const registry = new Map(tools.definitions.map((tool) => [tool.name, tool]))
|
||||
// The definition objects we hand to hooks, mapped back to their tools. Hooks rename a
|
||||
// tool by moving its definition to a new key; recognizing the object recovers the tool.
|
||||
@@ -210,10 +221,10 @@ export const layer = Layer.effect(
|
||||
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
|
||||
const context = yield* hooks.trigger("session", "context", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
agent: input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
system,
|
||||
messages,
|
||||
system: input.transcript.system,
|
||||
messages: input.transcript.messages,
|
||||
tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])),
|
||||
})
|
||||
// Match each surviving entry back to its tool, by recognizing a moved definition or
|
||||
@@ -229,7 +240,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
const request = yield* SessionModelHook.apply(
|
||||
hooks,
|
||||
{ sessionID: session.id, agent: agent.id, model: resolved.ref },
|
||||
{ sessionID: session.id, agent: input.scope.agentID, model: resolved.ref },
|
||||
LLM.request({
|
||||
model,
|
||||
http: {
|
||||
@@ -240,7 +251,7 @@ export const layer = Layer.effect(
|
||||
system: context.system,
|
||||
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
|
||||
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
toolChoice: input.toolChoice,
|
||||
}),
|
||||
)
|
||||
const webSocketEligible =
|
||||
@@ -250,37 +261,20 @@ export const layer = Layer.effect(
|
||||
? undefined
|
||||
: SessionModelHttp.middleware(hooks, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
agent: input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
})
|
||||
const options: StreamOptions = {
|
||||
...(http ? { http } : {}),
|
||||
...(webSocket &&
|
||||
...(input.webSocket === "session" &&
|
||||
webSocket &&
|
||||
webSocketEligible &&
|
||||
resolved.ref.providerID === Provider.ID.openai &&
|
||||
request.model.route.id === "openai-responses"
|
||||
? { webSocket: transport.bind(session.id) }
|
||||
: {}),
|
||||
}
|
||||
if (promptCacheSnapshots) {
|
||||
const current = PromptCacheDiagnostics.snapshot(request)
|
||||
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(session.id), current)
|
||||
promptCacheSnapshots.delete(session.id)
|
||||
promptCacheSnapshots.set(session.id, current)
|
||||
const oldest = promptCacheSnapshots.keys().next().value
|
||||
if (promptCacheSnapshots.size > 100 && oldest !== undefined) promptCacheSnapshots.delete(oldest)
|
||||
yield* Effect.logInfo("prompt cache prefix").pipe(
|
||||
Effect.annotateLogs({
|
||||
sessionID: session.id,
|
||||
toolCount: current.tools.length,
|
||||
systemParts: current.system.length,
|
||||
messageCount: current.messages.length,
|
||||
...comparison,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const executeTool: Prepared["executeTool"] = (input) => {
|
||||
if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
||||
const tool = hooked.get(input.call.name)
|
||||
// A registered tool absent from the hooked set was removed or renamed by a hook.
|
||||
if (!tool && registry.has(input.call.name))
|
||||
@@ -292,9 +286,7 @@ export const layer = Layer.effect(
|
||||
return {
|
||||
request,
|
||||
options,
|
||||
webSocketEligible,
|
||||
executeTool,
|
||||
stepLimitReached,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -10,41 +10,38 @@ export interface Coordinator<Key, E, Reason = never> {
|
||||
/** Starts an execution while idle, or joins the active execution and returns its exit. */
|
||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
|
||||
readonly wake: (key: Key, request?: Request) => Effect.Effect<void>
|
||||
/** Rings the current execution's doorbell with its existing request. Idle keys remain idle. */
|
||||
readonly wakeActive: (key: Key) => Effect.Effect<void>
|
||||
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
|
||||
readonly interrupt: (
|
||||
key: Key,
|
||||
reason?: Reason,
|
||||
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
|
||||
) => Effect.Effect<void>
|
||||
readonly wake: (key: Key, scope?: Promotable) => Effect.Effect<void>
|
||||
/**
|
||||
* Stops the active execution and clears its doorbell. No-op when idle. Resolves once the
|
||||
* interruption is accepted, not when cleanup settles: the execution fiber finishes its
|
||||
* finalizers and settled hook on its own time. Compose with `awaitIdle` for settlement.
|
||||
*/
|
||||
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
|
||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export type Request = Promotable
|
||||
|
||||
/**
|
||||
* One execution is a busy period for one key: one fiber that drains from the first wake
|
||||
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
|
||||
* execution rings it with its eligibility request, and the execution loop drains again
|
||||
* execution rings it with the scope that work needs, and the execution loop drains again
|
||||
* instead of ending. The doorbell closes the gap between a drain's last eligibility check
|
||||
* and the idle transition, since those cannot be one atomic step. `done` resolves joiners
|
||||
* with this execution's exit.
|
||||
*/
|
||||
type Execution<E, Reason> = {
|
||||
readonly done: Deferred.Deferred<void, E>
|
||||
/**
|
||||
* Resolves with the execution's exit as a success value. Success-valued on purpose:
|
||||
* completing a Deferred with an interrupted exit interrupts suspended waiters as it
|
||||
* resumes them, and can starve later waiters of their resume entirely
|
||||
* (Effect-TS/effect#7364). Joiners flatten the exit; idleness waiters just await.
|
||||
*/
|
||||
readonly done: Deferred.Deferred<Exit.Exit<void, E>>
|
||||
owner?: Fiber.Fiber<void>
|
||||
request: Request
|
||||
pendingWake?: Request
|
||||
scope: Promotable
|
||||
pendingWake?: Promotable
|
||||
stopping: boolean
|
||||
interruptionReason?: Reason
|
||||
continuation?: {
|
||||
readonly request: Request
|
||||
readonly when: Effect.Effect<boolean>
|
||||
signaled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +56,7 @@ type Execution<E, Reason> = {
|
||||
* ```
|
||||
*/
|
||||
export const make = <Key, E, Reason = never>(options: {
|
||||
readonly drain: (key: Key, force: boolean, request: Request) => Effect.Effect<void, E>
|
||||
readonly drain: (key: Key, force: boolean, scope: Promotable) => Effect.Effect<void, E>
|
||||
/** Runs once when a process-local busy period begins, before its first drain. */
|
||||
readonly started?: (key: Key) => Effect.Effect<void>
|
||||
/**
|
||||
@@ -73,11 +70,11 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
|
||||
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
|
||||
Effect.suspend(() => options.drain(key, force, execution.request)).pipe(
|
||||
Effect.suspend(() => options.drain(key, force, execution.scope)).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Effect.suspend(() => {
|
||||
if (execution.stopping || execution.pendingWake === undefined) return Effect.void
|
||||
execution.request = execution.pendingWake
|
||||
execution.scope = execution.pendingWake
|
||||
execution.pendingWake = undefined
|
||||
// Trampoline so drains that complete synchronously cannot grow the stack.
|
||||
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
|
||||
@@ -85,10 +82,10 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
),
|
||||
)
|
||||
|
||||
const start = (key: Key, force: boolean, request: Request) => {
|
||||
const start = (key: Key, force: boolean, scope: Promotable) => {
|
||||
const execution: Execution<E, Reason> = {
|
||||
done: Deferred.makeUnsafe<void, E>(),
|
||||
request,
|
||||
done: Deferred.makeUnsafe<Exit.Exit<void, E>>(),
|
||||
scope,
|
||||
stopping: false,
|
||||
}
|
||||
executions.set(key, execution)
|
||||
@@ -104,7 +101,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
execution.owner = undefined
|
||||
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
|
||||
),
|
||||
Effect.onExit((exit) => finish(key, execution, exit)),
|
||||
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
|
||||
Effect.exit,
|
||||
Effect.asVoid,
|
||||
),
|
||||
@@ -114,20 +111,10 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
|
||||
// A doorbell that survives the execution loop (rung after the loop decided to end, or
|
||||
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
|
||||
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>, resume: boolean) => {
|
||||
if (resume && execution.continuation) start(key, false, execution.continuation.request)
|
||||
else if (execution.pendingWake) start(key, false, execution.pendingWake)
|
||||
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||
if (execution.pendingWake) start(key, false, execution.pendingWake)
|
||||
else executions.delete(key)
|
||||
Deferred.doneUnsafe(execution.done, exit)
|
||||
}
|
||||
|
||||
const finish = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||
if (!execution.continuation) return Effect.sync(() => settle(key, execution, exit, false))
|
||||
return execution.continuation.when.pipe(
|
||||
Effect.flatMap((ready) =>
|
||||
Effect.sync(() => settle(key, execution, exit, ready || execution.continuation?.signaled === true)),
|
||||
),
|
||||
)
|
||||
Deferred.doneUnsafe(execution.done, Exit.succeed(exit))
|
||||
}
|
||||
|
||||
const run = (key: Key): Effect.Effect<void, E> =>
|
||||
@@ -136,61 +123,42 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
if (execution !== undefined) {
|
||||
// A stopping execution refuses joiners: wait out its cleanup, then run fresh.
|
||||
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
|
||||
return Deferred.await(execution.done)
|
||||
return Deferred.await(execution.done).pipe(Effect.flatten)
|
||||
}
|
||||
return Deferred.await(start(key, true, "input").done)
|
||||
return Deferred.await(start(key, true, "input").done).pipe(Effect.flatten)
|
||||
})
|
||||
|
||||
const wake = (key: Key, request: Request = "input") =>
|
||||
const wake = (key: Key, scope: Promotable = "input") =>
|
||||
Effect.sync(() => {
|
||||
const execution = executions.get(key)
|
||||
if (execution !== undefined) {
|
||||
if (execution.stopping) {
|
||||
if (execution.continuation) execution.continuation.signaled = true
|
||||
else execution.continuation = { request, when: Effect.succeed(true), signaled: true }
|
||||
return
|
||||
}
|
||||
// Coalesced wakes keep the widest request: "input" subsumes "steer".
|
||||
execution.pendingWake = execution.pendingWake === "input" ? "input" : request
|
||||
// Coalesced wakes keep the widest scope: "input" subsumes "steer".
|
||||
execution.pendingWake = execution.pendingWake === "input" ? "input" : scope
|
||||
return
|
||||
}
|
||||
start(key, false, request)
|
||||
start(key, false, scope)
|
||||
})
|
||||
|
||||
const wakeActive = (key: Key) =>
|
||||
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
return execution ? wake(key, execution.request) : Effect.void
|
||||
})
|
||||
|
||||
const interrupt = (
|
||||
key: Key,
|
||||
reason?: Reason,
|
||||
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
|
||||
): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
if (execution === undefined) return Effect.void
|
||||
if (execution.stopping) {
|
||||
if (options?.continue)
|
||||
execution.continuation = {
|
||||
...options.continue,
|
||||
signaled: execution.continuation?.signaled ?? false,
|
||||
}
|
||||
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
|
||||
}
|
||||
if (execution === undefined || execution.stopping) return Effect.void
|
||||
if (execution.owner === undefined) {
|
||||
if (!options?.continue) return Effect.void
|
||||
execution.stopping = true
|
||||
// Settlement window: the owner exited but the settled hook has not finished. The
|
||||
// terminal outcome is already decided, so no reason attaches — but the interrupt
|
||||
// still claims the recorded wakes so settle does not start a dead-intent successor.
|
||||
execution.pendingWake = undefined
|
||||
execution.continuation = { ...options.continue, signaled: false }
|
||||
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
|
||||
return Effect.void
|
||||
}
|
||||
execution.stopping = true
|
||||
// Wakes recorded so far belong to the interrupted intent; the interrupt claims them.
|
||||
// Wakes arriving during cleanup are new admissions and restart normally at settle.
|
||||
execution.pendingWake = undefined
|
||||
execution.interruptionReason = reason
|
||||
if (options?.continue) execution.continuation = { ...options.continue, signaled: false }
|
||||
return Fiber.interrupt(execution.owner)
|
||||
// Fire and forget: nobody benefits from waiting out cleanup here, and callers like
|
||||
// the interrupt endpoint must acknowledge immediately even when finalizers are slow.
|
||||
fork(Fiber.interrupt(execution.owner))
|
||||
return Effect.void
|
||||
})
|
||||
|
||||
// One execution's `done` already spans coalesced continuations; re-check after it
|
||||
@@ -199,8 +167,8 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
if (execution === undefined) return Effect.void
|
||||
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
|
||||
return Deferred.await(execution.done).pipe(Effect.andThen(awaitIdle(key)))
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, wakeActive, interrupt, awaitIdle }
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
|
||||
})
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
LLMClient,
|
||||
AIError,
|
||||
LLMEvent,
|
||||
Message,
|
||||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
type ToolCall,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
|
||||
import { Cause, Config, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
|
||||
import { Database } from "../../database/database.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
@@ -35,6 +36,9 @@ import { SessionRunnerRetry } from "./retry.js"
|
||||
import { SessionUsage } from "../usage.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
import { PluginSupervisor } from "../../plugin/supervisor.js"
|
||||
import { Tool } from "../../tool.js"
|
||||
import { PromptCacheDiagnostics } from "../prompt-cache-diagnostics.js"
|
||||
import { MAX_STEPS_PROMPT } from "./max-steps.js"
|
||||
|
||||
/** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */
|
||||
type CallOutcome = Data.TaggedEnum<{
|
||||
@@ -118,6 +122,32 @@ const layer = Layer.effect(
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
const toolOutput = yield* ToolOutput.Service
|
||||
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
|
||||
Config.withDefault(false),
|
||||
Effect.orDie,
|
||||
)
|
||||
const promptCacheSnapshots = diagnostics ? new Map<string, PromptCacheDiagnostics.Snapshot>() : undefined
|
||||
const diagnosePromptCache = Effect.fn("SessionRunner.diagnosePromptCache")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
request: Parameters<typeof PromptCacheDiagnostics.snapshot>[0],
|
||||
) {
|
||||
if (!promptCacheSnapshots) return
|
||||
const current = PromptCacheDiagnostics.snapshot(request)
|
||||
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(sessionID), current)
|
||||
promptCacheSnapshots.delete(sessionID)
|
||||
promptCacheSnapshots.set(sessionID, current)
|
||||
const oldest = promptCacheSnapshots.keys().next().value
|
||||
if (promptCacheSnapshots.size > 100 && oldest !== undefined) promptCacheSnapshots.delete(oldest)
|
||||
yield* Effect.logInfo("prompt cache prefix").pipe(
|
||||
Effect.annotateLogs({
|
||||
sessionID,
|
||||
toolCount: current.tools.length,
|
||||
systemParts: current.system.length,
|
||||
messageCount: current.messages.length,
|
||||
...comparison,
|
||||
}),
|
||||
)
|
||||
})
|
||||
// Title generation starts once input is visible and must not delay model execution.
|
||||
// The in-flight set coalesces overlapping prompts while title presence records success durably.
|
||||
const titlesRunning = new Set<SessionSchema.ID>()
|
||||
@@ -135,26 +165,36 @@ const layer = Layer.effect(
|
||||
let force = input.force
|
||||
let continuation = input.continuation
|
||||
const promotable = input.promotable ?? "input"
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
||||
if (!force && !continuation && !(yield* eligible(input.sessionID, promotable)))
|
||||
return { type: "complete" as const }
|
||||
yield* plugins.flush
|
||||
yield* settleStaleToolCalls(input.sessionID)
|
||||
while (true) {
|
||||
if (yield* runPendingCompaction(input.sessionID, promotable)) {
|
||||
// Between-turn control items run under any drain scope: scope gates which user
|
||||
// input may promote, not whether admitted housekeeping runs. Enqueue order still
|
||||
// holds — a control item behind a queued prompt is not the next eligible item.
|
||||
if (yield* runPendingCompaction(input.sessionID, "input")) {
|
||||
force = false
|
||||
continue
|
||||
}
|
||||
if (yield* runPendingMove(input.sessionID, promotable)) return { type: "moved" as const }
|
||||
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
||||
return { type: "complete" as const }
|
||||
const result = yield* runSteps(input.sessionID, continuation, promotable)
|
||||
if (result.type === "moved") return result
|
||||
if (promotable === "steer") return { type: "complete" as const }
|
||||
force = false
|
||||
continuation = undefined
|
||||
}
|
||||
})
|
||||
|
||||
/** Work this drain may perform: scoped input, or a between-turn control item next in line. */
|
||||
const eligible = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, promotable: SessionInbox.Promotable) {
|
||||
if (yield* SessionInbox.has(db, sessionID, promotable)) return true
|
||||
if (promotable === "input") return false
|
||||
const next = yield* SessionInbox.nextPromotable(db, sessionID, "input")
|
||||
return next?.type === "compaction" || next?.type === "move"
|
||||
})
|
||||
|
||||
/**
|
||||
* Runs logical steps until no tool result or newly admitted steer requires another
|
||||
* model call. Queued inputs remain pending until the current model work reaches idle.
|
||||
@@ -278,17 +318,39 @@ const layer = Layer.effect(
|
||||
const model = resolved.model
|
||||
// Make room: history must fit the context window before the call. A pending manual
|
||||
// compaction owns this instead; the runner executes it between steps.
|
||||
const compactionInput = { session, messages: loaded.messages, model, ref: resolved.ref, cost: resolved.cost }
|
||||
const compactionInput = { session, messages: loaded.messages, resolved }
|
||||
if (compaction.required(compactionInput)) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
if (compacted.status === "completed")
|
||||
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
|
||||
return yield* new StepFailedError({ error: compacted.error })
|
||||
}
|
||||
const prepared = yield* modelRequests.prepare({
|
||||
context: loaded,
|
||||
step: currentStep,
|
||||
const stepLimitReached = agent.info.steps !== undefined && currentStep >= agent.info.steps
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: agent.info,
|
||||
model: resolved,
|
||||
tools: loaded.tools,
|
||||
initial: loaded.initial,
|
||||
messages: loaded.messages,
|
||||
})
|
||||
const prepared = yield* modelRequests.prepare({
|
||||
scope: { session, agentID: agent.id, model: resolved, tools: loaded.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
messages: stepLimitReached
|
||||
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
|
||||
: transcript.messages,
|
||||
},
|
||||
// The final Step keeps definitions available to protocols with native "none",
|
||||
// preserving their prompt cache prefix. Calls are still rejected at execution.
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
webSocket: "session",
|
||||
})
|
||||
yield* diagnosePromptCache(session.id, prepared.request)
|
||||
const executeTool = (input: Parameters<typeof prepared.executeTool>[0]) => {
|
||||
if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
||||
return prepared.executeTool(input)
|
||||
}
|
||||
// Every local tool call forked here is owned until it reaches one durable settlement.
|
||||
const toolRuns: Array<{
|
||||
readonly call: ToolCall
|
||||
@@ -302,7 +364,7 @@ const layer = Layer.effect(
|
||||
// The selected catalog identity, not model.id: route-level ids are provider API
|
||||
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
|
||||
model: resolved.ref,
|
||||
providerMetadataKey: model.route.providerMetadataKey ?? model.provider,
|
||||
providerMetadataKey: transcript.providerMetadataKey,
|
||||
snapshot: startSnapshot,
|
||||
assistantMessageID,
|
||||
})
|
||||
@@ -365,7 +427,7 @@ const layer = Layer.effect(
|
||||
call: event,
|
||||
fiber: yield* Effect.uninterruptibleMask((restore) =>
|
||||
restore(
|
||||
prepared.executeTool({
|
||||
executeTool({
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
messageID: assistantMessageID,
|
||||
@@ -513,8 +575,7 @@ const layer = Layer.effect(
|
||||
// A local call or malformed tool input requires another model step, unless
|
||||
// this step already exhausted the agent's allowance.
|
||||
needsContinuation:
|
||||
!prepared.stepLimitReached &&
|
||||
record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)),
|
||||
!stepLimitReached && record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)),
|
||||
step: currentStep,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -53,6 +53,7 @@ export const resolved = (
|
||||
readonly capabilities: Capabilities
|
||||
readonly variant?: VariantID
|
||||
readonly cost: Info["cost"]
|
||||
readonly limit: Info["limit"]
|
||||
},
|
||||
): Resolved => ({
|
||||
model,
|
||||
@@ -63,6 +64,7 @@ export const resolved = (
|
||||
}),
|
||||
capabilities: options.capabilities,
|
||||
cost: options.cost,
|
||||
limit: options.limit,
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
|
||||
@@ -227,7 +227,6 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
]
|
||||
case "user":
|
||||
const content = [
|
||||
...(message.skills ?? []).map((skill) => Message.text(skill.text)),
|
||||
...(message.text === "" ? [] : [Message.text(message.text)]),
|
||||
...userAttachmentContent(message.files ?? []),
|
||||
]
|
||||
|
||||
@@ -207,7 +207,6 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
|
||||
skills: message.skills?.map((skill, index) => ({
|
||||
...skill,
|
||||
name: Skill.Name.make(redact("skill-name", String(index), skill.name)),
|
||||
text: redact("skill", String(index), skill.text),
|
||||
mention: skill.mention
|
||||
? { ...skill.mention, text: redact("skill-mention", String(index), skill.mention.text) }
|
||||
: undefined,
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
export * as Shell from "./shell.js"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Deferred, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Context, Deferred, Duration, Effect, Fiber, Layer, Schema, Schedule, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { produce } from "immer"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Environment } from "./environment/index.js"
|
||||
import { FileRetention } from "./file-retention.js"
|
||||
import { Location } from "./location.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { ShellSelect } from "./shell/select.js"
|
||||
@@ -21,9 +23,11 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Shell.No
|
||||
id: Shell.ID,
|
||||
}) {}
|
||||
|
||||
// Exited processes stay observable (status, exit code, retained output) until removed explicitly.
|
||||
// Cap retention so abandoned commands do not accumulate unbounded state and output files.
|
||||
// Keep recent exited processes observable in memory, including their file-backed output.
|
||||
// The process-local cap complements the time-based sweep, which also cleans files left by restarts.
|
||||
const EXITED_LIMIT = 25
|
||||
export const RETENTION = Duration.days(7)
|
||||
export const DIRECTORY = "shell"
|
||||
|
||||
type Info = Shell.Info
|
||||
|
||||
@@ -67,6 +71,42 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
|
||||
|
||||
export const cleanup = Effect.fn("Shell.cleanup")(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.data, DIRECTORY)
|
||||
const projects = yield* fs.readDirectoryEntries(directory).pipe(
|
||||
Effect.map((entries) => entries.filter((entry) => entry.type === "directory")),
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
)
|
||||
const files = yield* Effect.forEach(
|
||||
projects,
|
||||
(project) =>
|
||||
fs.readDirectoryEntries(path.join(directory, project.name)).pipe(
|
||||
Effect.map((entries) =>
|
||||
entries.flatMap((entry) =>
|
||||
entry.type === "file" && /^sh_[0-9a-f]{12}.*\.out$/.test(entry.name)
|
||||
? [path.join(directory, project.name, entry.name)]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
),
|
||||
{ concurrency: 8 },
|
||||
)
|
||||
yield* FileRetention.cleanup(fs, files.flat(), RETENTION)
|
||||
})
|
||||
|
||||
const cleanupLayer = Layer.effectDiscard(
|
||||
cleanup().pipe(Effect.repeat(Schedule.spaced(Duration.hours(1))), Effect.forkScoped),
|
||||
)
|
||||
|
||||
const cleanupNode = makeGlobalNode({
|
||||
name: "shell-output-cleanup",
|
||||
layer: cleanupLayer,
|
||||
deps: [FSUtil.node, Global.node],
|
||||
})
|
||||
|
||||
const layer = () =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
@@ -83,7 +123,7 @@ const layer = () =>
|
||||
const sessions = new Map<string, Active>()
|
||||
const exitOrder: string[] = []
|
||||
|
||||
const outputDir = path.join(global.data, "shell", location.project.id)
|
||||
const outputDir = path.join(global.data, DIRECTORY, location.project.id)
|
||||
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
|
||||
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
|
||||
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
|
||||
@@ -358,5 +398,6 @@ export const node = makeLocationNode({
|
||||
Environment.node,
|
||||
PluginHooks.node,
|
||||
SessionEnvironment.node,
|
||||
cleanupNode,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ const render = (skills: ReadonlyArray<Summary>) =>
|
||||
[
|
||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
"When the user references a skill with @skill-id, load that skill with the skill tool.",
|
||||
...(skills.length === 0
|
||||
? ["No skills are currently available."]
|
||||
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
|
||||
|
||||
@@ -2,10 +2,11 @@ export * as ToolOutput from "./tool-output.js"
|
||||
|
||||
import path from "path"
|
||||
import type { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
|
||||
import { Context, Duration, Effect, Layer, Schedule } from "effect"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { FileRetention } from "./file-retention.js"
|
||||
import { Identifier } from "./id/id.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
@@ -33,22 +34,14 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolOutput") {}
|
||||
|
||||
const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface, directory: string) {
|
||||
const cutoff = Date.now() - Duration.toMillis(RETENTION)
|
||||
const entries = yield* fs.readDirectory(directory).pipe(
|
||||
Effect.map((entries) => entries.filter((entry) => /^tool_[0-9a-f]{12}/.test(entry))),
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
entries,
|
||||
(entry) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, entry)
|
||||
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 },
|
||||
yield* FileRetention.cleanup(
|
||||
fs,
|
||||
entries.map((entry) => path.join(directory, entry)),
|
||||
RETENTION,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export const name = "skill"
|
||||
const FILE_LIMIT = 10
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
id: Skill.ID.annotate({ description: "The ID of the skill from the available skills list" }),
|
||||
id: Skill.ID.annotate({ description: "The ID of an available skill or a skill explicitly referenced by the user" }),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
@@ -23,7 +23,7 @@ export const Output = Schema.Struct({
|
||||
export const description = [
|
||||
"Load a specialized skill's instructions and resources into the current conversation when the task at hand matches its description.",
|
||||
"",
|
||||
"The skill ID must match one of the available skills in the instructions.",
|
||||
"The skill ID must match an available skill or a skill explicitly referenced by the user.",
|
||||
].join("\n")
|
||||
|
||||
export const toModelOutput = Skill.toModelOutput
|
||||
|
||||
@@ -26,7 +26,13 @@ import { host } from "../plugin/host"
|
||||
const model = LanguageModel.make({
|
||||
id: "test-model",
|
||||
provider: "test-provider",
|
||||
route: OpenAIChat.route.with({ limits: { context: 100_000, output: 1_000 } }),
|
||||
route: OpenAIChat.route,
|
||||
})
|
||||
const limit = { context: 100_000, output: 1_000 }
|
||||
const resolved = SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit,
|
||||
})
|
||||
const config = Config.testLayer()
|
||||
const it = testEffect(
|
||||
@@ -42,13 +48,7 @@ const it = testEffect(
|
||||
[
|
||||
SessionRunnerModel.node,
|
||||
Layer.mock(SessionRunnerModel.Service)({
|
||||
resolve: () =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
}),
|
||||
),
|
||||
resolve: () => Effect.succeed(resolved),
|
||||
}),
|
||||
],
|
||||
[Config.node, config],
|
||||
@@ -150,8 +150,7 @@ const session = Session.Info.make({
|
||||
})
|
||||
const input = (tokens: number) => ({
|
||||
session,
|
||||
model,
|
||||
cost: [],
|
||||
resolved,
|
||||
messages: [
|
||||
Schema.decodeUnknownSync(SessionMessage.Assistant)({
|
||||
id: SessionMessage.ID.make("msg_compaction_config"),
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defaults custom models to agent capabilities", () =>
|
||||
it.effect("defaults custom model metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("custom")
|
||||
@@ -100,6 +100,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
|
||||
const model = required(yield* catalog.model.get(providerID, modelID))
|
||||
expect(model.capabilities).toEqual({ tools: true, input: ["text", "image"], output: ["text"] })
|
||||
expect(model.limit).toEqual({ context: 200_000, output: 32_000 })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -262,7 +262,6 @@ describe("cross-spawn spawner", () => {
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
|
||||
const started = Date.now()
|
||||
const exit = yield* Effect.exit(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)')
|
||||
@@ -271,7 +270,6 @@ describe("cross-spawn spawner", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -35,28 +35,6 @@ const pluginNode = makeLocationNode({
|
||||
deps: [],
|
||||
})
|
||||
|
||||
describe("Watcher.testLayer", () => {
|
||||
it.effect("records subscriptions and broadcasts emitted updates through the service", () =>
|
||||
Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Service
|
||||
const test = yield* Watcher.Test
|
||||
const updates = yield* watcher.subscribe({ path: "/root", type: "directory" })
|
||||
const received = yield* updates.pipe(
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* test.emit({ type: "update", path: "/root/file.md" })
|
||||
|
||||
expect(Array.from(yield* Fiber.join(received))).toEqual([{ type: "update", path: "/root/file.md" }])
|
||||
// subscriptions() reports acquired watches, so paths come back resolved.
|
||||
expect(yield* test.subscriptions()).toEqual([{ path: path.resolve("/root"), type: "directory" }])
|
||||
}).pipe(Effect.provide(Watcher.testLayer)),
|
||||
)
|
||||
})
|
||||
|
||||
function withNative(native: Watcher.NativeInterface) {
|
||||
return Effect.provide(Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))))
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
|
||||
ref: Ref.make({ providerID: selected.providerID, id: selected.id }),
|
||||
capabilities: selected.capabilities,
|
||||
cost: selected.cost,
|
||||
limit: selected.limit,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -18,8 +18,64 @@ describe("KV", () => {
|
||||
yield* kv.set("wellknown:sources", ["https://example.com", "https://example.org"])
|
||||
expect(yield* kv.get("wellknown:sources")).toEqual(["https://example.com", "https://example.org"])
|
||||
|
||||
yield* kv.remove("wellknown:sources")
|
||||
yield* kv.remove("wellknown:sources")
|
||||
expect(yield* kv.get("wellknown:sources")).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("scans prefixes in deterministic pages", () =>
|
||||
Effect.gen(function* () {
|
||||
const kv = yield* KV.Service
|
||||
const prefix = "scan:%_:/雪/"
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
[`${prefix}beta`, { order: 2 }],
|
||||
[`${prefix}alpha`, { order: 1 }],
|
||||
[`${prefix}éclair`, { order: 3 }],
|
||||
["scan:other", { order: 0 }],
|
||||
] as const,
|
||||
([key, value]) => kv.set(key, value),
|
||||
{ discard: true },
|
||||
)
|
||||
|
||||
const first = yield* kv.scan({ prefix, limit: 2 })
|
||||
expect(first).toEqual({
|
||||
entries: [
|
||||
{ key: `${prefix}alpha`, value: { order: 1 } },
|
||||
{ key: `${prefix}beta`, value: { order: 2 } },
|
||||
],
|
||||
next: `${prefix}beta`,
|
||||
})
|
||||
expect(yield* kv.scan({ prefix, after: first.next, limit: 2 })).toEqual({
|
||||
entries: [{ key: `${prefix}éclair`, value: { order: 3 } }],
|
||||
})
|
||||
expect(yield* kv.scan({ prefix: `${prefix}%_` })).toEqual({ entries: [] })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defaults, normalizes, and caps scan limits", () =>
|
||||
Effect.gen(function* () {
|
||||
const kv = yield* KV.Service
|
||||
const prefix = "scan:limits/"
|
||||
yield* Effect.forEach(
|
||||
Array.from({ length: 1001 }, (_, index) => `${prefix}${index.toString().padStart(4, "0")}`),
|
||||
(key) => kv.set(key, key),
|
||||
{ discard: true },
|
||||
)
|
||||
|
||||
const defaultPage = yield* kv.scan({ prefix })
|
||||
expect(defaultPage.entries).toHaveLength(100)
|
||||
expect(defaultPage.next).toBe(`${prefix}0099`)
|
||||
|
||||
const cappedPage = yield* kv.scan({ prefix, limit: 10_000 })
|
||||
expect(cappedPage.entries).toHaveLength(1000)
|
||||
expect(cappedPage.next).toBe(`${prefix}0999`)
|
||||
|
||||
expect((yield* kv.scan({ prefix, limit: 2.9 })).entries).toHaveLength(2)
|
||||
expect((yield* kv.scan({ prefix, limit: 0 })).entries).toHaveLength(1)
|
||||
expect((yield* kv.scan({ prefix, limit: -10 })).entries).toHaveLength(1)
|
||||
expect((yield* kv.scan({ prefix, limit: Number.NaN })).entries).toHaveLength(100)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -346,6 +346,7 @@ describe("ModelResolver", () => {
|
||||
const resolver = yield* ModelResolver.Service
|
||||
const resolved = yield* resolver.resolveModel(selected)
|
||||
|
||||
expect(resolved.limit).toEqual(selected.limit)
|
||||
const headers = yield* resolved.model.route.auth.apply({
|
||||
request: LLM.request({ model: resolved.model, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
|
||||
@@ -338,6 +338,54 @@ describe("Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("provides isolated durable storage for each plugin ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const storage = new Map<string, EffectPlugin.Context["storage"]>()
|
||||
yield* plugins.activate(
|
||||
["a", "a:b", "雪"].map((id) => ({
|
||||
id,
|
||||
version: "1",
|
||||
effect: (context: EffectPlugin.Context) => Effect.sync(() => storage.set(id, context.storage)),
|
||||
})),
|
||||
)
|
||||
const first = storage.get("a")
|
||||
const second = storage.get("a:b")
|
||||
const unicode = storage.get("雪")
|
||||
if (!first || !second || !unicode) return yield* Effect.die("plugin storage was not activated")
|
||||
|
||||
yield* first.set("b:c", { plugin: "a" })
|
||||
yield* second.set("c", { plugin: "a:b" })
|
||||
yield* unicode.set("c", { plugin: "雪" })
|
||||
expect(yield* first.get("b:c")).toEqual({ plugin: "a" })
|
||||
expect(yield* second.get("c")).toEqual({ plugin: "a:b" })
|
||||
expect(yield* unicode.get("c")).toEqual({ plugin: "雪" })
|
||||
expect(yield* first.get("c")).toBeUndefined()
|
||||
|
||||
const prefix = "%_:/雪/"
|
||||
yield* first.set(`${prefix}beta`, [2])
|
||||
yield* first.set(`${prefix}alpha`, [1])
|
||||
const firstPage = yield* first.scan({ prefix, limit: 1 })
|
||||
expect(firstPage).toEqual({ entries: [{ key: `${prefix}alpha`, value: [1] }], next: `${prefix}alpha` })
|
||||
expect(yield* first.scan({ prefix, after: firstPage.next, limit: 1 })).toEqual({
|
||||
entries: [{ key: `${prefix}beta`, value: [2] }],
|
||||
})
|
||||
expect(yield* first.scan({ prefix: `${prefix}%_` })).toEqual({ entries: [] })
|
||||
expect(yield* first.scan({ prefix: "" })).toEqual({
|
||||
entries: [
|
||||
{ key: `${prefix}alpha`, value: [1] },
|
||||
{ key: `${prefix}beta`, value: [2] },
|
||||
{ key: "b:c", value: { plugin: "a" } },
|
||||
],
|
||||
})
|
||||
|
||||
yield* first.remove("b:c")
|
||||
yield* first.remove("b:c")
|
||||
expect(yield* first.get("b:c")).toBeUndefined()
|
||||
return undefined
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers location tools through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -11,6 +11,7 @@ import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
@@ -52,6 +53,7 @@ export const PluginTestLayer = LayerNode.compile(
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
MCP.node,
|
||||
PluginRuntime.node,
|
||||
PluginHooks.node,
|
||||
|
||||
@@ -94,6 +94,12 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
transform: () => Effect.die("unused skill.transform"),
|
||||
reload: () => Effect.die("unused skill.reload"),
|
||||
},
|
||||
storage: overrides.storage ?? {
|
||||
get: () => Effect.die("unused storage.get"),
|
||||
set: () => Effect.die("unused storage.set"),
|
||||
remove: () => Effect.die("unused storage.remove"),
|
||||
scan: () => Effect.die("unused storage.scan"),
|
||||
},
|
||||
shell: overrides.shell ?? {
|
||||
hook: () => Effect.die("unused shell.hook"),
|
||||
},
|
||||
|
||||
@@ -27,6 +27,32 @@ import { host as testHost } from "./host"
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
describe("fromPromise", () => {
|
||||
it.effect("adapts plugin storage methods", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const adapted = PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-storage",
|
||||
setup: async (ctx) => {
|
||||
expect(await ctx.storage.get("missing")).toBeUndefined()
|
||||
await ctx.storage.set("items/b", { order: 2 })
|
||||
await ctx.storage.set("items/a", { order: 1 })
|
||||
expect(await ctx.storage.get("items/a")).toEqual({ order: 1 })
|
||||
expect(await ctx.storage.scan({ prefix: "items/", limit: 1 })).toEqual({
|
||||
entries: [{ key: "items/a", value: { order: 1 } }],
|
||||
next: "items/a",
|
||||
})
|
||||
await ctx.storage.remove("items/a")
|
||||
await ctx.storage.remove("items/a")
|
||||
expect(await ctx.storage.get("items/a")).toBeUndefined()
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
yield* plugins.activate([{ ...adapted, version: "1" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adapts session creation through the protocol schema", () =>
|
||||
Effect.gen(function* () {
|
||||
let seen: unknown
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user