Compare commits

...

18 Commits

Author SHA1 Message Date
Aiden ff7b40ed4a refactor(tui): handle reasoning starts uniformly 2026-08-20 03:31:18 +00:00
Aiden 4a94dbe8af fix(tui): show encrypted reasoning status 2026-08-20 03:21:22 +00:00
Kit Langton f43474043a feat(core): acknowledge session interruption immediately (#43552) 2026-08-20 03:16:32 +00:00
Aiden Cline 5a0ba34d64 fix(core): expire stale shell output (#43554) 2026-08-19 21:58:58 -05:00
opencode-agent[bot] 9a1de86d9c refactor(core): own resolved model limits (#43545)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 21:40:33 -05:00
Kit Langton ea7fa43243 refactor(core): share session model requests (#42680) 2026-08-20 02:33:46 +00:00
opencode-agent[bot] 730e1935cf chore: update nix node_modules hashes 2026-08-20 02:33:37 +00:00
Luke Parker d6deed6752 refactor(session-ui): render current messages directly across surfaces (#43345) 2026-08-20 12:16:03 +10:00
Kit Langton 1d89e911e8 refactor(core): make prompt ID reuse idempotent (#43548) 2026-08-20 01:56:40 +00:00
opencode-agent[bot] c85b09de6f fix(core): default unknown model token limits (#43541)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 20:04:01 -05:00
Kit Langton 30db9dd86e chore(app): use schema ID minting instead of hand-rolled encoder (#43542) 2026-08-19 20:59:35 -04:00
Kit Langton b6966177fa refactor(core): simplify interrupt continuation (#42810) 2026-08-19 20:40:32 -04:00
Kit Langton 6b09b9e6a2 feat(client): optimistic prompt admission with client-minted IDs (#43520) 2026-08-19 20:34:12 -04:00
xdagiz 3876f7aad6 fix(desktop): show window on did-finish-load fallback for wayland (#42681) 2026-08-20 00:02:46 +00:00
Filip d912202cf2 feat: better skill ux (#43523) 2026-08-20 01:27:57 +02:00
Kit Langton f8c46684eb fix: eliminate flaky CI races (#43522) 2026-08-19 22:12:41 +00:00
Kit Langton c4afbc4aae fix(tui): handle form clipboard shortcut (#43526) 2026-08-19 18:10:44 -04:00
Dax 98a9d864e6 feat(plugin): add durable storage API (#43525) 2026-08-19 21:51:58 +00:00
236 changed files with 10746 additions and 12720 deletions
+5
View File
@@ -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.
+5
View File
@@ -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.
+1 -1
View File
@@ -176,7 +176,7 @@ const table = sqliteTable("session", {
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. 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.
+107 -70
View File
@@ -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
View File
@@ -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",
metadata: {
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: "export const value = 'before'\n",
after: "export const value = 'after'\n",
},
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
input: {
path: "src/regression.ts",
oldString: "export const value = 'before'",
newString: "export const value = 'after'",
},
content: [{ type: "text", text: "Edited src/regression.ts" }],
metadata: {
files: [
currentFile(
"src/regression.ts",
"export const value = 'before'\n",
"export const value = 'after'\n",
1,
1,
),
],
},
time: { start: 1700000001000, end: 1700000002000 },
},
time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 },
}
const assistantMessage = {
@@ -204,20 +206,20 @@ function performanceTurn(index: number) {
? [
{
id: `prt_0000_${suffix}_edit`,
sessionID,
messageID: assistantID,
type: "tool",
callID: `call_0000_${suffix}_edit`,
tool: "edit",
name: "edit",
state: {
status: "completed",
input: { filePath: `src/history-${index}.ts` },
output: `Edited src/history-${index}.ts`,
title: `src/history-${index}.ts`,
input: { path: `src/history-${index}.ts`, oldString: before, newString: after },
content: [{ type: "text", text: `Edited src/history-${index}.ts` }],
metadata: {
filediff: { file: `src/history-${index}.ts`, additions: 48, deletions: 48, before, after },
files: [currentFile(`src/history-${index}.ts`, before, after, 48, 48)],
},
time: { start: 1690000001200 + index * 2_000, end: 1690000001400 + index * 2_000 },
},
time: {
created: 1690000001200 + index * 2_000,
ran: 1690000001200 + index * 2_000,
completed: 1690000001400 + index * 2_000,
},
},
]
@@ -226,20 +228,18 @@ function performanceTurn(index: number) {
? [
{
id: `prt_0000_${suffix}_write`,
sessionID,
messageID: assistantID,
type: "tool",
callID: `call_0000_${suffix}_write`,
tool: "write",
name: "write",
state: {
status: "completed",
input: { filePath: `src/generated-${index}.tsx`, content: after },
output: `Wrote src/generated-${index}.tsx`,
title: `src/generated-${index}.tsx`,
metadata: {
filediff: { file: `src/generated-${index}.tsx`, additions: 32, deletions: 0, before: "", after },
},
time: { start: 1690000001400 + index * 2_000, end: 1690000001500 + index * 2_000 },
input: { path: `src/generated-${index}.tsx`, content: after },
content: [{ type: "text", text: `Wrote src/generated-${index}.tsx` }],
metadata: { files: [currentFile(`src/generated-${index}.tsx`, "", after, 32, 0)] },
},
time: {
created: 1690000001400 + index * 2_000,
ran: 1690000001400 + index * 2_000,
completed: 1690000001500 + index * 2_000,
},
},
]
@@ -248,31 +248,24 @@ function performanceTurn(index: number) {
? [
{
id: `prt_0000_${suffix}_patch`,
sessionID,
messageID: assistantID,
type: "tool",
callID: `call_0000_${suffix}_patch`,
tool: "apply_patch",
name: "patch",
state: {
status: "completed",
input: { patchText: realisticPatch(index) },
output: "Success. Updated src/components/SessionCard.tsx",
title: "src/components/SessionCard.tsx",
content: [{ type: "text", text: "Success. Updated src/components/SessionCard.tsx" }],
metadata: {
files: [
{
filePath: "src/components/SessionCard.tsx",
relativePath: "src/components/SessionCard.tsx",
type: "update",
additions: 8,
deletions: 3,
patch: realisticPatch(index),
before,
after,
...currentFile("src/components/SessionCard.tsx", before, after, 8, 3),
},
],
},
time: { start: 1690000001500 + index * 2_000, end: 1690000001700 + index * 2_000 },
},
time: {
created: 1690000001500 + index * 2_000,
ran: 1690000001500 + index * 2_000,
completed: 1690000001700 + index * 2_000,
},
},
]
@@ -309,20 +302,16 @@ function performanceTurn(index: number) {
}
type ToolSeed = {
id?: string
sessionID?: string
messageID?: string
id: string
type: "tool"
callID: string
tool: string
name: string
state: {
status: string
status: "completed"
input: Record<string, unknown>
output: string
title?: string
content: [{ type: "text"; text: string }]
metadata: Record<string, unknown>
time: { start: number; end: number }
}
time: { created: number; ran: number; completed: number }
}
type ContentSeedBase = { id?: string; sessionID?: string; messageID?: string }
@@ -335,13 +324,13 @@ type ContentSeed =
function toolContent(part: ToolSeed): SessionMessageAssistant["content"][number] {
return {
type: "tool",
id: part.callID,
name: part.tool,
time: { created: part.state.time.start, ran: part.state.time.start, completed: part.state.time.end },
id: part.id,
name: part.name,
time: part.time,
state: {
status: "completed",
input: part.state.input as Record<string, JsonValue>,
content: [{ type: "text", text: part.state.output }],
content: part.state.content,
metadata: part.state.metadata as Record<string, JsonValue>,
},
}
@@ -422,6 +411,16 @@ export function MessageSummary(props: { messages: Message[]; locale: string }) {
`
}
function currentFile(file: string, before: string, after: string, additions: number, deletions: number) {
return {
file,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
additions,
deletions,
status: before ? (after ? "modified" : "deleted") : "added",
}
}
function realisticPatch(index: number) {
return `*** Begin Patch
*** Update File: src/components/SessionCard.tsx
@@ -1,4 +1,6 @@
import type { CDPSession, Page } from "@playwright/test"
import path from "node:path"
import { mkdir, writeFile } from "node:fs/promises"
export async function startTimelineProfile(page: Page, options: { cpuThrottle: number; profileCPU: boolean }) {
const cdp = await page.context().newCDPSession(page)
@@ -12,6 +14,13 @@ export async function startTimelineProfile(page: Page, options: { cpuThrottle: n
async stop() {
if (!options.profileCPU) return
const result = await cdp.send("Profiler.stop")
const directory = process.env.TIMELINE_CPU_PROFILE_DIR
if (directory) {
await mkdir(directory, { recursive: true })
const file = path.join(directory, `${process.env.OPENCODE_PERFORMANCE_RUN_ID ?? "manual"}-timeline.cpuprofile`)
await writeFile(file, JSON.stringify(result.profile))
console.log("timeline cpu profile file", file)
}
const self = new Map<number, number>()
result.profile.samples?.forEach((id, index) => {
const duration = (result.profile.timeDeltas?.[index] ?? 0) / 1_000
@@ -1,3 +1,5 @@
import { createTwoFilesPatch } from "diff"
const words = [
"alpha",
"bravo",
@@ -28,22 +30,21 @@ const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type MessagePart = {
id: string
type: "text" | "reasoning" | "tool"
text?: string
time?: { start: number; end?: number }
callID?: string
tool?: string
state?: {
status: "completed"
input: Record<string, unknown>
output: string
title: unknown
metadata: Record<string, unknown>
time: { start: number; end: number }
}
}
type MessagePart =
| { id: string; type: "text"; text: string }
| { id: string; type: "reasoning"; text: string; time?: { start: number; end?: number } }
| {
id: string
type: "tool"
name: string
state: {
status: "completed"
input: Record<string, unknown>
content: [{ type: "text"; text: string }]
metadata: Record<string, unknown>
}
time: { created: number; ran: number; completed: number }
}
function lorem(seed: number, length: number) {
let out = ""
@@ -102,17 +103,16 @@ function messageContent(part: MessagePart): SessionMessageAssistant["content"][n
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
: undefined,
}
const state = part.state!
return {
type: "tool",
id: part.callID ?? part.id,
name: part.tool!,
time: { created: state.time.start, ran: state.time.start, completed: state.time.end },
id: part.id,
name: part.name,
time: part.time,
state: {
status: "completed",
input: state.input as Record<string, JsonValue>,
content: [{ type: "text", text: state.output }],
metadata: state.metadata as Record<string, JsonValue>,
input: part.state.input as Record<string, JsonValue>,
content: part.state.content,
metadata: part.state.metadata as Record<string, JsonValue>,
},
}
}
@@ -149,43 +149,46 @@ function toolPart(
): MessagePart {
const metadata =
metadataOverride ??
(tool === "apply_patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
(tool === "patch"
? {
files: [
patchFile(index, "modified"),
patchFile(index + 1, index % 2 === 0 ? "added" : "deleted"),
],
}
: tool === "edit" || tool === "write"
? {
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
diff: patch(index, outputLength),
preview: patch(index + 1, 420),
}
? { files: [fileDiff(String(input.path ?? `src/generated/file-${index}.ts`), index)] }
: tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] }
: {})
return {
id: id(`prt_tool_${tool}_${partIndex}`, index),
id: id(`call_${tool}_${partIndex}`, index),
type: "tool",
callID: id("call", index * 10 + partIndex),
tool,
name: tool,
state: {
status: "completed",
input,
output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
content: [{ type: "text", text: lorem(index * 23 + partIndex, outputLength) }],
metadata,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
},
time: {
created: 1700000000000 + index * 10_000,
ran: 1700000000000 + index * 10_000,
completed: 1700000000000 + index * 10_000 + 400,
},
}
}
function patchFile(seed: number, type: "add" | "update" | "delete") {
function patchFile(seed: number, status: "added" | "modified" | "deleted") {
const file = `src/generated/patch-${seed}.ts`
const before = status === "added" ? "" : code(seed, 18)
const after = status === "deleted" ? "" : code(seed + 1, 24)
return {
filePath: `src/generated/patch-${seed}.ts`,
relativePath: `src/generated/patch-${seed}.ts`,
type,
additions: (seed % 7) + 1,
deletions: type === "add" ? 0 : seed % 4,
patch: patch(seed, 520),
before: type === "add" ? undefined : code(seed, 18),
after: type === "delete" ? undefined : code(seed + 1, 24),
file,
status,
additions: status === "deleted" ? 0 : (seed % 7) + 1,
deletions: status === "added" ? 0 : seed % 4,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
}
}
@@ -200,17 +203,13 @@ function fileDiff(file: string, seed: number) {
: before.replace("value4", "updatedValue4").replace("value20", "updatedValue20")
return {
file,
status: "modified" as const,
additions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
deletions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
before,
after,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
}
}
function patch(seed: number, length: number) {
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
}
function code(seed: number, lines: number, width = 32) {
return Array.from(
{ length: lines },
@@ -225,22 +224,24 @@ function turn(index: number): SessionMessageInfo[] {
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
...(index % 3 === 0
? [
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 0, "read", { path: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
]
: []),
textPart(index, 2, 160 + (index % 6) * 90),
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
...(index % 4 === 0
? [toolPart(index, 3, "edit", { path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" }, 700)]
: []),
...(index % 6 === 0
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
? [toolPart(index, 7, "write", { path: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)]
: []),
...(index % 7 === 0
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
? [toolPart(index, 4, "shell", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
@@ -256,7 +257,15 @@ function turn(index: number): SessionMessageInfo[] {
]
: []),
...(index % 17 === 0
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
? [
toolPart(
index,
12,
"subagent",
{ description: "Inspect generated fixture", agent: "explore", prompt: "Inspect the fixture." },
160,
),
]
: []),
]
return [user, assistantMessage(targetID, index, user.id, parts)]
@@ -272,10 +281,10 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
toolPart(
index + 1000,
1,
"task",
{ description: "Inspect child navigation", subagent_type: "explore" },
"subagent",
{ description: "Inspect child navigation", agent: "explore", prompt: "Inspect child navigation." },
160,
{ sessionId: childID },
{ sessionID: childID },
),
]
: []),
@@ -89,7 +89,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
data: {
id: "permission-background-a",
sessionID: sessionA.id,
action: "bash",
action: "shell",
resources: ["git status"],
metadata: {},
save: [],
@@ -116,7 +116,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
data: {
id: "permission-background-a-child",
sessionID: childSessionA.id,
action: "bash",
action: "shell",
resources: ["git diff"],
metadata: {},
save: [],
@@ -85,7 +85,7 @@ test("shows a pending permission dock", async ({ page }) => {
{
id: "permission-request",
sessionID,
permission: "bash",
permission: "shell",
patterns: ["git status", "git diff"],
metadata: {},
always: [],
@@ -2,6 +2,7 @@ import { expect, test, type Locator, type Page } from "@playwright/test"
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
import { createTwoFilesPatch } from "diff"
const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression"
@@ -40,18 +41,28 @@ const editPart = {
tool: "edit",
state: {
status: "completed",
input: { filePath: "src/regression.ts" },
input: {
path: "src/regression.ts",
oldString: "export const value = 'before'",
newString: "export const value = 'after'",
},
output: "Edited src/regression.ts",
title: "src/regression.ts",
metadata: {
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: "export const value = 'before'\n",
after: "export const value = 'after'\n",
},
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
files: [
{
file: "src/regression.ts",
patch: createTwoFilesPatch(
"a/src/regression.ts",
"b/src/regression.ts",
"export const value = 'before'\n",
"export const value = 'after'\n",
),
additions: 1,
deletions: 1,
status: "modified",
},
],
},
time: { start: 1700000001000, end: 1700000002000 },
},
@@ -149,13 +160,15 @@ test.describe("regression: session timeline local row state", () => {
...editPart.state,
metadata: {
...editPart.state.metadata,
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: lines,
after,
},
files: [
{
file: "src/regression.ts",
patch: createTwoFilesPatch("a/src/regression.ts", "b/src/regression.ts", lines, after),
additions: 5,
deletions: 5,
status: "modified",
},
],
},
},
}
@@ -78,7 +78,7 @@ test.describe("regression: session timeline context group resize", () => {
id("msg_assistant", 10),
["read", "glob", "grep", "list"][index]!,
[
{ filePath: "src/recent-a.ts" },
{ path: "src/recent-a.ts" },
{ path: directory, pattern: "**/*.ts" },
{ path: directory, pattern: "Explored" },
{ path: "src" },
@@ -213,7 +213,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
contextIDs[0]!,
assistantID,
"read",
{ filePath: "src/recent-a.ts", offset: 0, limit: 120 },
{ path: "src/recent-a.ts", offset: 0, limit: 120 },
status,
),
),
@@ -270,7 +270,7 @@ function contextTool(
status,
input,
output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
title: input.filePath || input.path || input.pattern || "completed",
title: input.path || input.pattern || "completed",
metadata: {},
time: { start: 1700000000000, end: 1700000000100 },
},
@@ -10,7 +10,7 @@ import {
test("preserves a collapsed context group through count and status updates", async ({ page }) => {
const ids = ["prt_closed_01_read", "prt_closed_02_glob"]
const inputs = {
read: { filePath: "src/a.ts", offset: 0, limit: 120 },
read: { path: "src/a.ts", offset: 0, limit: 120 },
glob: { path: ".", pattern: "**/*.ts" },
}
const timeline = await setupTimeline(page, {
@@ -7,7 +7,7 @@ test("renders completed write content", async ({ page }) => {
messages: [
userMessage(),
assistantMessage([
toolPart(id, "write", "completed", { filePath: "src/write.ts", content: "export const written = true\n" }),
toolPart(id, "write", "completed", { path: "src/write.ts", content: "export const written = true\n" }),
]),
],
settings: { editToolPartsExpanded: true },
@@ -24,20 +24,19 @@ test("renders a completed single-file patch", async ({ page }) => {
assistantMessage([
toolPart(
id,
"apply_patch",
"patch",
"completed",
{ files: ["src/a.ts"] },
{ patchText: "Update src/a.ts" },
{
metadata: {
files: [
{
filePath: "src/a.ts",
relativePath: "src/a.ts",
type: "update",
file: "src/a.ts",
status: "modified",
patch:
"diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-export const value = 1\n+export const value = 2\n",
additions: 1,
deletions: 1,
before: "export const value = 1\n",
after: "export const value = 2\n",
},
],
},
@@ -1,18 +1,19 @@
import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
import { createTwoFilesPatch } from "diff"
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
const patchID = "prt_nested_patch"
const files = [patchFile("src/a.ts", "update"), patchFile("src/b.ts", "add"), patchFile("src/old.ts", "delete")]
const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")]
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
patchID,
"apply_patch",
"patch",
"completed",
{ files: files.map((file) => file.filePath) },
{ patchText: "Update three files" },
{ metadata: { files } },
),
]),
@@ -31,15 +32,15 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
})
function patchFile(filePath: string, type: "add" | "update" | "delete") {
function patchFile(file: string, status: "added" | "modified" | "deleted") {
const before = status === "added" ? "" : source(false)
const after = status === "deleted" ? "" : source(true)
return {
filePath,
relativePath: filePath,
type,
additions: type === "delete" ? 0 : 4,
deletions: type === "add" ? 0 : 3,
before: type === "add" ? undefined : source(false),
after: type === "delete" ? undefined : source(true),
file,
status,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
additions: status === "deleted" ? 0 : 4,
deletions: status === "added" ? 0 : 3,
}
}
@@ -11,7 +11,7 @@ for (const profile of [
messages: [
userMessage(),
assistantMessage([
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
toolPart(ids[0]!, "read", "completed", { path: "src/a.ts" }),
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
]),
],
@@ -12,7 +12,7 @@ import {
test.describe("session timeline projection", () => {
test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => {
const parts = [
toolPart("prt_01_read", "read", "completed", { filePath: "src/a.ts" }),
toolPart("prt_01_read", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }),
toolPart("prt_04_list", "list", "completed", { path: "src" }),
@@ -24,16 +24,20 @@ test.describe("session timeline projection", () => {
{ query: "timeline stability" },
{ output: "https://example.com/result" },
),
toolPart("prt_task", "task", "completed", { description: "Inspect timeline", subagent_type: "explore" }),
toolPart("prt_task", "subagent", "completed", {
description: "Inspect timeline",
agent: "explore",
prompt: "Inspect the timeline implementation.",
}),
toolPart(
"prt_bash",
"bash",
"shell",
"completed",
{ command: "printf stable" },
{ output: "stable", title: "printf stable" },
),
editPart("prt_edit"),
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
toolPart("prt_write", "write", "completed", { path: "src/new.ts", content: "export const stable = true\n" }),
patchPart("prt_patch"),
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
toolPart(
@@ -175,16 +179,10 @@ function editPart(id: string) {
id,
"edit",
"completed",
{ filePath: "src/a.ts" },
{ path: "src/a.ts", oldString: "export const value = 1", newString: "export const value = 2" },
{
metadata: {
filediff: {
file: "src/a.ts",
additions: 1,
deletions: 1,
before: "export const value = 1\n",
after: "export const value = 2\n",
},
files: [patchFile("src/a.ts", "modified")],
},
},
)
@@ -193,31 +191,33 @@ function editPart(id: string) {
function patchPart(id: string) {
return toolPart(
id,
"apply_patch",
"patch",
"completed",
{ files: ["src/a.ts", "src/b.ts"] },
{ patchText: "Update the projected files" },
{
metadata: {
files: [
patchFile("src/a.ts", "update"),
patchFile("src/b.ts", "add"),
patchFile("src/old.ts", "delete"),
{ ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
patchFile("src/a.ts", "modified"),
patchFile("src/b.ts", "added"),
patchFile("src/old.ts", "deleted"),
],
},
},
)
}
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
function patchFile(file: string, status: "added" | "modified" | "deleted") {
return {
filePath,
relativePath: filePath,
type,
additions: type === "delete" ? 0 : 1,
deletions: type === "add" ? 0 : 1,
before: type === "add" ? undefined : "export const before = true\n",
after: type === "delete" ? undefined : "export const after = true\n",
file,
status,
patch:
status === "added"
? "@@ -0,0 +1 @@\n+export const after = true"
: status === "deleted"
? "@@ -1 +0,0 @@\n-export const before = true"
: "@@ -1 +1 @@\n-export const before = true\n+export const after = true",
additions: status === "deleted" ? 0 : 1,
deletions: status === "added" ? 0 : 1,
}
}
@@ -15,7 +15,7 @@ import {
test("groups singleton and separated context operations at correct boundaries", async ({ page }) => {
const parts = [
toolPart("prt_boundary_01_read", "read", "completed", { filePath: "src/a.ts" }),
toolPart("prt_boundary_01_read", "read", "completed", { path: "src/a.ts" }),
textPart("prt_boundary_02_text", "Boundary text"),
toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }),
@@ -67,19 +67,18 @@ for (const deviceScaleFactor of [1.25, 1.5]) {
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
const patchID = "prt_patch_outline"
const file = {
filePath: "src/outline.ts",
relativePath: "src/outline.ts",
type: "update",
file: "src/outline.ts",
status: "modified",
patch:
"diff --git a/src/outline.ts b/src/outline.ts\n--- a/src/outline.ts\n+++ b/src/outline.ts\n@@ -1 +1 @@\n-const outline = false\n+const outline = true\n",
additions: 1,
deletions: 1,
before: "const outline = false\n",
after: "const outline = true\n",
}
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
toolPart(patchID, "patch", "completed", { patchText: "Update src/outline.ts" }, { metadata: { files: [file] } }),
]),
],
settings: { editToolPartsExpanded: true },
@@ -8,7 +8,7 @@ import {
} from "../performance/timeline-stability/fixture"
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
const ordinary = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
const parts = ordinary.map((tool, index) =>
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
)
@@ -37,8 +37,8 @@ test("transitions shell and question through running error outcomes", async ({ p
userMessage(),
assistantMessage(
[
toolPart(shellID, "bash", "pending", { command: "exit 1" }),
toolPart(questionID, "question", "pending", questionInput()),
toolPart(shellID, "shell", "streaming", { command: "exit 1" }),
toolPart(questionID, "question", "streaming", questionInput()),
],
{ completed: false },
),
@@ -46,11 +46,11 @@ test("transitions shell and question through running error outcomes", async ({ p
})
await timeline.waitForPart(shellID)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(toolPart(shellID, "bash", "running", { command: "exit 1" })), 120)
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120)
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(
partUpdated(toolPart(shellID, "bash", "error", { command: "exit 1" }, { error: "Command exited 1" })),
partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })),
180,
)
await timeline.send(
@@ -147,12 +147,13 @@ function questionInput() {
}
function errorInput(tool: string) {
if (tool === "bash") return { command: "exit 1" }
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
if (tool === "apply_patch") return { files: ["src/error.ts"] }
if (tool === "shell") return { command: "exit 1" }
if (["edit", "write"].includes(tool)) return { path: "src/error.ts", content: "" }
if (tool === "patch") return { patchText: "Update src/error.ts" }
if (tool === "webfetch") return { url: "https://example.com" }
if (tool === "websearch") return { query: "failure" }
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
if (tool === "subagent")
return { description: "Fail subagent", agent: "explore", prompt: "Inspect the failure." }
if (tool === "skill") return { name: "failure" }
return { target: "failure" }
}
@@ -167,14 +167,14 @@ function parentMessages(): SessionMessageInfo[] {
content: [
{
type: "tool",
id: "call_task_0001",
name: "task",
id: "call_subagent_0001",
name: "subagent",
time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 },
state: {
status: "completed",
input: { description: taskDescription, subagent_type: "explore" },
input: { description: taskDescription, agent: "explore", prompt: "Inspect the delegated work." },
content: [{ type: "text", text: "Subagent finished" }],
metadata: { sessionId: childID },
metadata: { sessionID: childID },
},
},
],
@@ -1,3 +1,5 @@
import { createTwoFilesPatch } from "diff"
const words = [
"alpha",
"bravo",
@@ -28,22 +30,21 @@ const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type MessagePart = {
id: string
type: "text" | "reasoning" | "tool"
text?: string
time?: { start: number; end?: number }
callID?: string
tool?: string
state?: {
status: "completed"
input: Record<string, unknown>
output: string
title: unknown
metadata: Record<string, unknown>
time: { start: number; end: number }
}
}
type MessagePart =
| { id: string; type: "text"; text: string }
| { id: string; type: "reasoning"; text: string; time?: { start: number; end?: number } }
| {
id: string
type: "tool"
name: string
state: {
status: "completed"
input: Record<string, unknown>
content: [{ type: "text"; text: string }]
metadata: Record<string, unknown>
}
time: { created: number; ran: number; completed: number }
}
function lorem(seed: number, length: number) {
let out = ""
@@ -102,17 +103,16 @@ function messageContent(part: MessagePart): SessionMessageAssistant["content"][n
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
: undefined,
}
const state = part.state!
return {
type: "tool",
id: part.callID ?? part.id,
name: part.tool!,
time: { created: state.time.start, ran: state.time.start, completed: state.time.end },
id: part.id,
name: part.name,
time: part.time,
state: {
status: "completed",
input: state.input as Record<string, JsonValue>,
content: [{ type: "text", text: state.output }],
metadata: state.metadata as Record<string, JsonValue>,
input: part.state.input as Record<string, JsonValue>,
content: part.state.content,
metadata: part.state.metadata as Record<string, JsonValue>,
},
}
}
@@ -138,60 +138,61 @@ function toolPart(
outputLength = 160,
): MessagePart {
const metadata =
tool === "apply_patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
tool === "patch"
? {
files: [
patchFile(index, "modified"),
patchFile(index + 1, index % 2 === 0 ? "added" : "deleted"),
],
}
: tool === "edit" || tool === "write"
? {
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
diff: patch(index, outputLength),
preview: patch(index + 1, 420),
}
? { files: [fileDiff(String(input.path ?? `src/generated/file-${index}.ts`), index)] }
: tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] }
: {}
return {
id: id(`prt_tool_${tool}_${partIndex}`, index),
id: id(`call_${tool}_${partIndex}`, index),
type: "tool",
callID: id("call", index * 100 + partIndex),
tool,
name: tool,
state: {
status: "completed",
input,
output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
content: [{ type: "text", text: lorem(index * 23 + partIndex, outputLength) }],
metadata,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
},
time: {
created: 1700000000000 + index * 10_000,
ran: 1700000000000 + index * 10_000,
completed: 1700000000000 + index * 10_000 + 400,
},
}
}
function patchFile(seed: number, type: "add" | "update" | "delete") {
function patchFile(seed: number, status: "added" | "modified" | "deleted") {
const file = `src/generated/patch-${seed}.ts`
const before = status === "added" ? "" : code(seed, 18)
const after = status === "deleted" ? "" : code(seed + 1, 24)
return {
filePath: `src/generated/patch-${seed}.ts`,
relativePath: `src/generated/patch-${seed}.ts`,
type,
additions: (seed % 7) + 1,
deletions: type === "add" ? 0 : seed % 4,
patch: patch(seed, 520),
before: type === "add" ? undefined : code(seed, 18),
after: type === "delete" ? undefined : code(seed + 1, 24),
file,
status,
additions: status === "deleted" ? 0 : (seed % 7) + 1,
deletions: status === "added" ? 0 : seed % 4,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
}
}
function fileDiff(file: string, seed: number) {
const before = code(seed, 32)
const after = code(seed + 1, 38)
return {
file,
status: "modified" as const,
additions: (seed % 9) + 1,
deletions: seed % 4,
before: code(seed, 32),
after: code(seed + 1, 38),
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
}
}
function patch(seed: number, length: number) {
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
}
function code(seed: number, lines: number) {
return Array.from({ length: lines }, (_, index) => `export const value${index} = "${lorem(seed + index, 32)}"`).join(
"\n",
@@ -205,21 +206,23 @@ function turn(index: number): SessionMessageInfo[] {
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
...(index % 3 === 0
? [
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 0, "read", { path: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
]
: []),
textPart(index, 2, 160 + (index % 6) * 90),
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
...(index % 4 === 0
? [toolPart(index, 3, "edit", { path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" }, 700)]
: []),
...(index % 6 === 0
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
? [toolPart(index, 7, "write", { path: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)]
: []),
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
...(index % 7 === 0 ? [toolPart(index, 4, "shell", { command: "bun typecheck" }, 620)] : []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
...(index % 13 === 0
@@ -234,7 +237,15 @@ function turn(index: number): SessionMessageInfo[] {
]
: []),
...(index % 17 === 0
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
? [
toolPart(
index,
12,
"subagent",
{ description: "Inspect generated fixture", agent: "explore", prompt: "Inspect the fixture." },
160,
),
]
: []),
]
return [user, assistantMessage(targetID, index, user.id, parts)]
@@ -311,7 +322,7 @@ export const fixture = {
targetPartIDs: targetMessages.flatMap(currentPartIDs),
expandedShellPartID: targetMessages
.flatMap((message) => (message.type === "assistant" ? message.content : []))
.flatMap((part) => (part.type === "tool" && part.name === "bash" ? [part.id] : []))[0],
.flatMap((part) => (part.type === "tool" && part.name === "shell" ? [part.id] : []))[0],
},
}
@@ -518,7 +518,7 @@ async function expectCanScrollToStart(
let current = await timelineState(page)
let unchangedAtTop = 0
for (let attempt = 0; attempt < 600; attempt++) {
for (let attempt = 0; attempt < 800; attempt++) {
collectSeen(current, seenParts, seenMessages)
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText)
+1 -20
View File
@@ -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>
)
}
+20 -1
View File
@@ -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>
)
}
+10
View File
@@ -0,0 +1,10 @@
export const popularProviders = [
"opencode",
"opencode-go",
"anthropic",
"github-copilot",
"openai",
"google",
"openrouter",
"vercel",
]
+2 -10
View File
@@ -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>) {
+10 -29
View File
@@ -1,5 +1,5 @@
import type { FilePart } from "@/types"
import type { FileDiffInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import type { SessionUserActions } from "@opencode-ai/session-ui/message"
import { getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
@@ -81,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
View File
@@ -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
-93
View File
@@ -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,
},
}),
}),
}),
])
})
})
-241
View File
@@ -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 () => {
+10 -68
View File
@@ -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 -84
View File
@@ -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()
+14 -16
View File
@@ -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
},
}
}
-1
View File
@@ -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("")
+19
View File
@@ -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,
}
}
+25 -32
View File
@@ -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,
+7 -1
View File
@@ -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"
+120 -47
View File
@@ -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)
+23
View File
@@ -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
View File
@@ -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] })
+3
View File
@@ -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({
+6 -2
View File
@@ -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,
+36 -1
View File
@@ -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
+9 -21
View File
@@ -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,
})
})
+16 -26
View File
@@ -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,
+24 -15
View File
@@ -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,
}),
+21 -63
View File
@@ -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],
})
+19 -58
View File
@@ -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)
+52 -60
View File
@@ -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,
}
})
+47 -79
View File
@@ -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 }
})
+74 -13
View File
@@ -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 ?? []),
]
-1
View File
@@ -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,
+46 -5
View File
@@ -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,
],
})
+1
View File
@@ -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>"]),
+6 -13
View File
@@ -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,
)
})
+2 -2
View File
@@ -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
+9 -10
View File
@@ -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"),
+2 -1
View File
@@ -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))))
}
+1
View File
@@ -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,
})
}),
)
+56
View File
@@ -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",
+48
View File
@@ -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
+2
View File
@@ -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,
+6
View File
@@ -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"),
},
+26
View File
@@ -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