Compare commits

..

1 Commits

Author SHA1 Message Date
rekram1-node 2f60a0b2a2 fix(tui): apply cursor config in mini 2026-08-19 21:47:19 +00:00
244 changed files with 12797 additions and 10807 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Prompt and synthetic inbox ID reuse is now idempotent: reusing an ID within the same Session succeeds and returns the first admission, ignoring the retried payload, metadata, and delivery mode. Previously reuse with a differing payload failed with a conflict. Cross-Session and cross-type reuse still fail, and control items keep their operation-specific conflict behavior.
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Apply shared Session model-request preparation to transient generation.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Simplify interrupt continuation: the steer-scoped resume decision now lives in SessionExecution as a post-cleanup inbox check, and the run coordinator drops its continuation state machine. Wakes arriving during cancellation cleanup now restart a normal full drain, and interrupting an idle session with continue now resumes pending steering input. Recovery-applied moves now end with the same full wake as inbox-admitted moves, retrying any stranded inbox work at the new location. Interrupting with continue now also resumes a next-in-line control item: between-turn manual compaction and moves run under any drain scope, while queued prompts remain parked.
+1 -1
View File
@@ -176,7 +176,7 @@ const table = sqliteTable("session", {
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views. - Keep durable 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. - Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. Reusing a user or synthetic inbox item ID is idempotent when Session and type match: the first admission wins and the retried payload, metadata, and delivery mode are ignored, whether the item is still pending or already delivered (reconciled from the projected message without retained enqueue history). Cross-Session or cross-type reuse fails. Control items keep their operation-specific conflict behavior. - Reusing a Session ID adopts the existing Session. While a user or synthetic inbox item is pending, reusing its ID reconciles only when Session, type, complete payload, metadata, and delivery match; conflicting reuse fails. Once delivered, retry reconciliation for those message-producing items uses the projected message and does not require retained enqueue history or the original delivery mode. Control items keep their operation-specific conflict behavior.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session. - Keep `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. - 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. - Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
+70 -107
View File
@@ -471,9 +471,7 @@
"version": "1.18.15", "version": "1.18.15",
"dependencies": { "dependencies": {
"@hono/standard-validator": "catalog:", "@hono/standard-validator": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/session-ui": "workspace:*", "@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -482,7 +480,6 @@
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@solidjs/start": "catalog:", "@solidjs/start": "catalog:",
"aws4fetch": "^1.0.20", "aws4fetch": "^1.0.20",
"effect": "catalog:",
"hono": "catalog:", "hono": "catalog:",
"hono-openapi": "catalog:", "hono-openapi": "catalog:",
"js-base64": "3.7.7", "js-base64": "3.7.7",
@@ -698,7 +695,6 @@
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"diff": "catalog:", "diff": "catalog:",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
@@ -837,16 +833,14 @@
"packages/storybook": { "packages/storybook": {
"name": "@opencode-ai/storybook", "name": "@opencode-ai/storybook",
"devDependencies": { "devDependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/session-ui": "workspace:*", "@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@storybook/addon-a11y": "10.4.4", "@storybook/addon-a11y": "^10.2.13",
"@storybook/addon-docs": "10.4.4", "@storybook/addon-docs": "^10.2.13",
"@storybook/addon-links": "10.4.4", "@storybook/addon-links": "^10.2.13",
"@storybook/addon-onboarding": "10.4.4", "@storybook/addon-onboarding": "^10.2.13",
"@storybook/addon-vitest": "10.4.4", "@storybook/addon-vitest": "^10.2.13",
"@storybook/builder-vite": "10.4.4",
"@tailwindcss/vite": "catalog:", "@tailwindcss/vite": "catalog:",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
@@ -854,11 +848,10 @@
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"solid-js": "catalog:", "solid-js": "catalog:",
"storybook": "10.4.4", "storybook": "^10.2.13",
"storybook-solidjs-vite": "10.5.2", "storybook-solidjs-vite": "^10.0.9",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "7.1.11", "vite": "catalog:",
"vite-plugin-solid": "2.11.12",
}, },
}, },
"packages/theme": { "packages/theme": {
@@ -2863,25 +2856,25 @@
"@stoplight/yaml-ast-parser": ["@stoplight/yaml-ast-parser@0.0.50", "", {}, "sha512-Pb6M8TDO9DtSVla9yXSTAxmo9GVEouq5P40DWXdOie69bXogZTkgvopCq+yEvTMA0F6PEvdJmbtTV3ccIp11VQ=="], "@stoplight/yaml-ast-parser": ["@stoplight/yaml-ast-parser@0.0.50", "", {}, "sha512-Pb6M8TDO9DtSVla9yXSTAxmo9GVEouq5P40DWXdOie69bXogZTkgvopCq+yEvTMA0F6PEvdJmbtTV3ccIp11VQ=="],
"@storybook/addon-a11y": ["@storybook/addon-a11y@10.4.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.4.4" } }, "sha512-/eUCx/6Ozq5grauwm/NqKtlW0oJ26b6GNesXrMuFID8WLg/qLEKf79Awfz9XrmyWxe7loD40K952r7AA5Oc23A=="], "@storybook/addon-a11y": ["@storybook/addon-a11y@10.5.7", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.5.7" } }, "sha512-I30rsNz6aA3xg3811MEry40uJDHP3l5SOkfqtmNkp7y4NdqTDdKhmbhhDAZQX1WWEk6GeMBGr3AJ3TCz7r7JmQ=="],
"@storybook/addon-docs": ["@storybook/addon-docs@10.4.4", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.4.4", "@storybook/icons": "^2.0.2", "@storybook/react-dom-shim": "10.4.4", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.4.4" }, "optionalPeers": ["@types/react"] }, "sha512-yPshCvtmQTq52T2sXuXgjy7B/QbhA/WIZxLYggptNjBL8BJMvbOfp9bAfCKh7+KpRWGqDZ6Y6tWL1Q48Wj3vtw=="], "@storybook/addon-docs": ["@storybook/addon-docs@10.5.7", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.5.7", "@storybook/icons": "^2.0.2", "@storybook/react-dom-shim": "10.5.7", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.7" }, "optionalPeers": ["@types/react"] }, "sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA=="],
"@storybook/addon-links": ["@storybook/addon-links@10.4.4", "", { "dependencies": { "@storybook/global": "^5.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.4.4" }, "optionalPeers": ["@types/react", "react"] }, "sha512-sWydPWLgduT24p/NJ/hXHcHsPlAyzQP+cOtCGliSI989K9yBP/TOL3A8sz7LIDfukI9DVAsylPhJ1jDSiAEI1w=="], "@storybook/addon-links": ["@storybook/addon-links@10.5.7", "", { "dependencies": { "@storybook/global": "^5.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.7" }, "optionalPeers": ["@types/react", "react"] }, "sha512-17PxEOocLhAEaPeQ4q+8yul/LF9YEIePS1arknCAS7U1pQXTe0uj+R0pB6uPLVflM5gECQMiP4WzIj4tEiL6+A=="],
"@storybook/addon-onboarding": ["@storybook/addon-onboarding@10.4.4", "", { "peerDependencies": { "storybook": "^10.4.4" } }, "sha512-ZTWGm8VXQUTepV4aEmIgHxdY7JMtn57H3uYnM3HD+qR8fmAcpLPoJ9ffXaMWUwsjK6SeferQyDTRN3q3Jd5+mg=="], "@storybook/addon-onboarding": ["@storybook/addon-onboarding@10.5.7", "", { "peerDependencies": { "storybook": "^10.5.7" } }, "sha512-GDssSoWmmGnz6OnUvAofMcrUuTMD53Y2rnbshjXvhtPnjKw4dufXFjo6C7yZQ6vyoNzTS9HyQwCHlxlhyCKJjQ=="],
"@storybook/addon-vitest": ["@storybook/addon-vitest@10.4.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2" }, "peerDependencies": { "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", "storybook": "^10.4.4", "vitest": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@vitest/browser", "@vitest/browser-playwright", "@vitest/runner", "vitest"] }, "sha512-VPpBwf1Elr+0g33am8ZE6aHhLB+r1TPxUsnDuCVNhxGjRxMFyQkAE8+jPJFPvS/YIUGMbVXarzaV7PcI/sJuVQ=="], "@storybook/addon-vitest": ["@storybook/addon-vitest@10.5.7", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2" }, "peerDependencies": { "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", "storybook": "^10.5.7", "vitest": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@vitest/browser", "@vitest/browser-playwright", "@vitest/runner", "vitest"] }, "sha512-7NK7Kzazc2vb2h8nGlH15QlUn5J6/LV0xF70VziAK0bxu3r1JupLCoBvckX39vHzFXiAZljXZtt1Wq/OidBxow=="],
"@storybook/builder-vite": ["@storybook/builder-vite@10.4.4", "", { "dependencies": { "@storybook/csf-plugin": "10.4.4", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.4.4", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-VyuZ4mEvhhVXjJa1qXMWKH8ohnas0rgEuJDf6u4aJ54XeENFebPUEAHde1Qo2PflJ4rUdVdXieOZzKbYwP5RAQ=="], "@storybook/builder-vite": ["@storybook/builder-vite@10.5.7", "", { "dependencies": { "@storybook/csf-plugin": "10.5.7", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.5.7", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-fShF/aQaITqcJuMCLr42BGNUAbhDi4IboqvlbZqXAwgrrTslnZEUnY8GcEcvpZmjl11VwlmazhMJdH50fIgBPg=="],
"@storybook/csf-plugin": ["@storybook/csf-plugin@10.4.4", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.4.4", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-1mzZyAwVUmAcw4WEUsJDVdSupkJf+Kf/f5uNAs4RzlBXA75P8YRkDKAb2EoMwsB5URiXFi9XoeAN/vWke0G6+w=="], "@storybook/csf-plugin": ["@storybook/csf-plugin@10.5.7", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.5.7", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag=="],
"@storybook/global": ["@storybook/global@5.0.0", "", {}, "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ=="], "@storybook/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/icons": ["@storybook/icons@2.1.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg=="],
"@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.4.4", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.4.4" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-y6SObmoW78AydE6VfKQSUmCkuqiaMPy9LgMpMdMEyWfJ/pSxBDMIKycr9dlRMJP1cvNgByaJgrusWtA46ndSQw=="], "@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.5.7", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.7" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA=="],
"@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="], "@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="],
@@ -5525,9 +5518,9 @@
"stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="],
"storybook": ["storybook@10.4.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15" }, "optionalPeers": ["@types/react", "prettier", "vite-plus"], "bin": "./dist/bin/dispatcher.js" }, "sha512-Nn0qFRxU5fyABa6dGRftfL3lz0Y+HkKOaAkfytF8S4Q2K6Szwwq7TwPAEs3Wsj8hBQbYhsobrKADcPsyXQpJaA=="], "storybook": ["storybook@10.5.7", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", "jsonc-parser": "^3.3.1", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.21.1" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15 || ^0.2.0" }, "optionalPeers": ["@types/react", "prettier", "vite-plus"], "bin": "./dist/bin/dispatcher.js" }, "sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg=="],
"storybook-solidjs-vite": ["storybook-solidjs-vite@10.5.2", "", { "dependencies": { "@storybook/builder-vite": "10.4.4", "@storybook/global": "5.0.0", "@volar/language-core": "2.4.28", "@volar/typescript": "2.4.28", "semver": "7.8.1" }, "peerDependencies": { "@solidjs/web": "^2.0.0-0", "solid-js": "^1.8.0-0 || ^2.0.0-0", "storybook": "^0.0.0-0 || ^10.0.0", "typescript": "^4.0.0 || ^5.0.0 || ^6.0.0", "vite": "7.1.11", "vite-plugin-solid": "2.11.12" }, "optionalPeers": ["@solidjs/web", "typescript"] }, "sha512-u+XSoTxE8JoVZ4IdI+gJRuNMQL00PCLA1BinBPdwHRKSSSVczf+JQhcfBJ2xxODs430RJp+JjAajKZeaSIHj9Q=="], "storybook-solidjs-vite": ["storybook-solidjs-vite@10.6.0", "", { "dependencies": { "@storybook/builder-vite": "^10.4.4", "@storybook/global": "^5.0.0", "@volar/language-core": "^2.4.28", "@volar/typescript": "^2.4.28", "semver": "^7.8.1" }, "peerDependencies": { "@solidjs/web": "^2.0.0-0", "solid-js": "^1.8.0-0 || ^2.0.0-0", "storybook": "^0.0.0-0 || ^10.0.0", "typescript": "^4.0.0 || ^5.0.0 || ^6.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vite-plugin-solid": "^2.0.0-0 || ^3.0.0-0" }, "optionalPeers": ["@solidjs/web", "typescript"] }, "sha512-/nNRk0D8Uwvqny/DKVNBsCzajjmC//cATifF66ebnpjpRBCwBrflg2ymsGEE0D9LmDsdcMBn1rsoe6Z4C2Jzbw=="],
"stream-replace-string": ["stream-replace-string@2.0.0", "", {}, "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w=="], "stream-replace-string": ["stream-replace-string@2.0.0", "", {}, "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w=="],
@@ -6379,10 +6372,6 @@
"@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="], "@opencode-ai/storybook/@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/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=="], "@opencode-ai/updates/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
@@ -6885,14 +6874,12 @@
"sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="], "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/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/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/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=="], "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -7853,9 +7840,57 @@
"rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"storybook-solidjs-vite/vite/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "storybook/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"storybook-solidjs-vite/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "storybook/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"storybook/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"storybook/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"storybook/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"storybook/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"storybook/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"storybook/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"storybook/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"storybook/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"storybook/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"storybook/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"storybook/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"storybook/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"storybook/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"storybook/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"storybook/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"storybook/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"storybook/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"storybook/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"storybook/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"storybook/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"storybook/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"storybook/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"storybook/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"storybook/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -8761,78 +8796,6 @@
"rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "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=="], "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=="], "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": { "nodeModules": {
"x86_64-linux": "sha256-9IJxoe/MdL6nmoeKvxZop+77HJ/b3HbmpytNUvLqYkc=", "x86_64-linux": "sha256-IxkSw0gK/qkMHZGVHqjwgM9BKhzbQX6hyF9SWUNtpzg=",
"aarch64-linux": "sha256-KR1J102RDTRDZIkAD3jQfXeP1G2DN9Es7KkdKo+daec=", "aarch64-linux": "sha256-YVjpbil0QswVwi6NtVYFq3xCqpsfveG1chlNVCVI0MU=",
"aarch64-darwin": "sha256-HCgSoq1W6XU6m73Ck3wV8gjB687caUTM3w/EO+V7wsE=", "aarch64-darwin": "sha256-CdL2mI84pawH2H5i9qu8A6IWbkmKOYHlJS+DI/Mafdw=",
"x86_64-darwin": "sha256-GrKTDvjg0XeDIWbOvayWQjj0SdJd8WPm2+q3IVS17Jg=" "x86_64-darwin": "sha256-NtswwfU5WYv99bEmI4XeLwjhBGcS9ZMYLRo4MQRNtLo="
} }
} }
@@ -179,7 +179,7 @@ test.describe("timeline adverse visual stability", () => {
userMessage(), userMessage(),
assistantMessage([ assistantMessage([
shell(shellID, "completed", wideLines(15)), shell(shellID, "completed", wideLines(15)),
toolPart(contextIDs[0]!, "read", "completed", { path: "src/a.ts" }), toolPart(contextIDs[0]!, "read", "completed", { filePath: "src/a.ts" }),
toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }), toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
textPart(followingID, "Following responsive timeline content that wraps on narrow screens."), textPart(followingID, "Following responsive timeline content that wraps on narrow screens."),
]), ]),
@@ -1,5 +1,4 @@
import { test } from "@playwright/test" import { test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import { import {
defineVisualRegions, defineVisualRegions,
reportVisualStability, reportVisualStability,
@@ -19,20 +18,16 @@ import {
} from "./fixture" } from "./fixture"
const profiles = [ 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", name: "multi patch",
tool: "patch", tool: "apply_patch",
input: { patchText: "Update generated files" }, input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
}, },
] as const ] as const
for (const profile of profiles) { for (const profile of profiles) {
test(`stabilizes ${profile.name} streaming to completed`, async ({ page }, testInfo) => { test(`stabilizes ${profile.name} pending to completed`, async ({ page }, testInfo) => {
const partID = `prt_file_matrix_${profiles.indexOf(profile)}` const partID = `prt_file_matrix_${profiles.indexOf(profile)}`
const followingID = `prt_file_matrix_following_${profiles.indexOf(profile)}` const followingID = `prt_file_matrix_following_${profiles.indexOf(profile)}`
const timeline = await setupTimeline(page, { const timeline = await setupTimeline(page, {
@@ -40,7 +35,7 @@ for (const profile of profiles) {
userMessage(), userMessage(),
assistantMessage( assistantMessage(
[ [
toolPart(partID, profile.tool, "streaming", profile.input), toolPart(partID, profile.tool, "pending", profile.input),
textPart(followingID, `Following ${profile.name}`), textPart(followingID, `Following ${profile.name}`),
], ],
{ completed: false }, { completed: false },
@@ -94,27 +89,34 @@ function completedPart(partID: string, profile: (typeof profiles)[number]) {
if (profile.tool === "edit") { if (profile.tool === "edit") {
return toolPart(partID, profile.tool, "completed", profile.input, { return toolPart(partID, profile.tool, "completed", profile.input, {
metadata: { metadata: {
files: [patchFile("src/edit.ts", "modified", 50)], filediff: {
file: "src/edit.ts",
additions: 50,
deletions: 50,
before: source(50, false),
after: source(50, true),
},
}, },
}) })
} }
const files = [ const files = [
patchFile("src/a.ts", "modified", 20), patchFile("src/a.ts", "update"),
patchFile("src/b.ts", "added", 20), patchFile("src/b.ts", "add"),
patchFile("src/old.ts", "deleted", 20), patchFile("src/old.ts", "delete"),
{ ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
] ]
return toolPart(partID, profile.tool, "completed", profile.input, { metadata: { files } }) return toolPart(partID, profile.tool, "completed", profile.input, { metadata: { files } })
} }
function patchFile(file: string, status: "added" | "modified" | "deleted", lines: number) { function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
const before = status === "added" ? "" : source(lines, false)
const after = status === "deleted" ? "" : source(lines, true)
return { return {
file, filePath,
status, relativePath: filePath,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after), type,
additions: status === "deleted" ? 0 : lines, additions: type === "delete" ? 0 : 20,
deletions: status === "added" ? 0 : lines, deletions: type === "add" ? 0 : 20,
before: type === "add" ? undefined : source(20, false),
after: type === "delete" ? undefined : source(20, true),
} }
} }
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import { import {
defineVisualRegions, defineVisualRegions,
reportVisualStability, reportVisualStability,
@@ -21,13 +20,13 @@ import {
test("adds patch files incrementally without resetting outer expansion", async ({ page }, testInfo) => { test("adds patch files incrementally without resetting outer expansion", async ({ page }, testInfo) => {
const patchID = "prt_incremental_01_patch" const patchID = "prt_incremental_01_patch"
const followingID = "prt_incremental_02_following" const followingID = "prt_incremental_02_following"
const first = patchFile("src/a.ts", "modified") const first = patchFile("src/a.ts", "update")
const timeline = await setupTimeline(page, { const timeline = await setupTimeline(page, {
messages: [ messages: [
userMessage(), userMessage(),
assistantMessage( assistantMessage(
[ [
toolPart(patchID, "patch", "running", { patchText: "Update files" }, { metadata: { files: [first] } }), toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
textPart(followingID, "Following incremental patch"), textPart(followingID, "Following incremental patch"),
], ],
{ completed: false }, { completed: false },
@@ -56,15 +55,15 @@ test("adds patch files incrementally without resetting outer expansion", async (
}, },
}) })
await startVisualProbe(page, regions) await startVisualProbe(page, regions)
const second = patchFile("src/b.ts", "added") const second = patchFile("src/b.ts", "add")
const third = patchFile("src/old.ts", "deleted") const third = patchFile("src/old.ts", "delete")
await timeline.send( await timeline.send(
partUpdated( partUpdated(
toolPart( toolPart(
patchID, patchID,
"patch", "apply_patch",
"running", "running",
{ patchText: "Update files" }, { files: [first.filePath, second.filePath] },
{ metadata: { files: [first, second] } }, { metadata: { files: [first, second] } },
), ),
), ),
@@ -74,9 +73,9 @@ test("adds patch files incrementally without resetting outer expansion", async (
partUpdated( partUpdated(
toolPart( toolPart(
patchID, patchID,
"patch", "apply_patch",
"completed", "completed",
{ patchText: "Update files" }, { files: [first.filePath, second.filePath, third.filePath] },
{ metadata: { files: [first, second, third] } }, { metadata: { files: [first, second, third] } },
), ),
), ),
@@ -107,15 +106,15 @@ test("adds patch files incrementally without resetting outer expansion", async (
await expect(page.locator('[data-scope="apply-patch"] [data-type="delete"]')).toBeVisible() await expect(page.locator('[data-scope="apply-patch"] [data-type="delete"]')).toBeVisible()
}) })
function patchFile(file: string, status: "added" | "modified" | "deleted") { function patchFile(filePath: string, type: "add" | "update" | "delete") {
const before = status === "added" ? "" : source(false)
const after = status === "deleted" ? "" : source(true)
return { return {
file, filePath,
status, relativePath: filePath,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after), type,
additions: status === "deleted" ? 0 : 4, additions: type === "delete" ? 0 : 4,
deletions: status === "added" ? 0 : 3, deletions: type === "add" ? 0 : 3,
before: type === "add" ? undefined : source(false),
after: type === "delete" ? undefined : source(true),
} }
} }
@@ -35,7 +35,7 @@ describe("timeline fixture validation", () => {
userMessage(), userMessage(),
{ {
...assistantMessage(), ...assistantMessage(),
content: [{ type: "tool", id: "call_invalid", name: "shell", state: { status: "completed" } }], content: [{ type: "tool", id: "call_invalid", name: "bash", state: { status: "completed" } }],
} as never, } as never,
]), ]),
).toThrow() ).toThrow()
@@ -60,11 +60,12 @@ if (false) {
const userSeed = { id: "prt_type_user", type: "text", text: "typed" } satisfies PartSeed<"user"> const userSeed = { id: "prt_type_user", type: "text", text: "typed" } satisfies PartSeed<"user">
userMessage([userSeed]) userMessage([userSeed])
// @ts-expect-error Tool completion fields are not valid while streaming. // @ts-expect-error Tool completion fields are not valid while pending.
toolPart("prt_invalid_streaming", "shell", "streaming", {}, { output: "impossible" }) toolPart("prt_invalid_pending", "bash", "pending", {}, { output: "impossible" })
toolPart("prt_valid_running", "shell", "running", {}, { output: "progressive output" }) // @ts-expect-error Tool completion fields are not valid while running.
toolPart("prt_invalid_running", "bash", "running", {}, { output: "impossible" })
// @ts-expect-error Tool error fields are not valid after completion. // @ts-expect-error Tool error fields are not valid after completion.
toolPart("prt_invalid_completed", "shell", "completed", {}, { error: "impossible" }) toolPart("prt_invalid_completed", "bash", "completed", {}, { error: "impossible" })
assistantMessage([ assistantMessage([
// @ts-expect-error Agent references belong to user messages, not assistant messages. // @ts-expect-error Agent references belong to user messages, not assistant messages.
@@ -60,17 +60,17 @@ type ReasoningSeed = {
type ToolSeed = { type ToolSeed = {
id: string id: string
type: "tool" type: "tool"
name: string callID: string
tool: string
messageID?: string messageID?: string
executed?: boolean executed?: boolean
providerState?: Record<string, unknown> providerState?: Record<string, unknown>
providerResultState?: Record<string, unknown> providerResultState?: Record<string, unknown>
state: state:
| { status: "streaming"; input: Record<string, unknown>; raw: string } | { status: "pending"; input: Record<string, unknown>; raw: string }
| { | {
status: "running" status: "running"
input: Record<string, unknown> input: Record<string, unknown>
output?: string
title?: string title?: string
metadata: Record<string, unknown> metadata: Record<string, unknown>
time: { start: number } time: { start: number }
@@ -100,10 +100,10 @@ export type PartSeed<Owner extends "user" | "assistant"> = Owner extends "user"
? TextSeed | FileSeed | AgentSeed ? TextSeed | FileSeed | AgentSeed
: TextSeed | ReasoningSeed | ToolSeed : TextSeed | ReasoningSeed | ToolSeed
type ToolOptions<State extends ToolStatus> = State extends "streaming" type ToolOptions<State extends ToolStatus> = State extends "pending"
? { output?: never; title?: never; metadata?: never; error?: never } ? { output?: never; title?: never; metadata?: never; error?: never }
: State extends "running" : State extends "running"
? { title?: string; metadata?: Record<string, unknown>; output?: string; error?: never } ? { title?: string; metadata?: Record<string, unknown>; output?: never; error?: never }
: State extends "error" : State extends "error"
? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never } ? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
: { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never } : { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
@@ -371,15 +371,6 @@ export function partUpdated(part: PartSeed<"assistant">): readonly OpenCodeEvent
} }
if (part.type === "reasoning") { if (part.type === "reasoning") {
startedParts.add(part.id) startedParts.add(part.id)
if (!started && !part.text)
return [
makeEvent("session.reasoning.started", {
sessionID,
assistantMessageID: messageID,
ordinal: ref.ordinal!,
state: jsonRecord(part.metadata),
}),
]
return [ return [
...(started ...(started
? [] ? []
@@ -551,9 +542,9 @@ export function reasoningPart(id: string, text: string): ReasoningSeed {
export function toolPart( export function toolPart(
id: string, id: string,
tool: string, tool: string,
state: "streaming", state: "pending",
input: Record<string, unknown>, input: Record<string, unknown>,
options?: ToolOptions<"streaming">, options?: ToolOptions<"pending">,
): ToolSeed ): ToolSeed
export function toolPart( export function toolPart(
id: string, id: string,
@@ -583,15 +574,14 @@ export function toolPart(
input: Record<string, unknown>, input: Record<string, unknown>,
options: ToolOptions<ToolStatus> = {}, options: ToolOptions<ToolStatus> = {},
): ToolSeed { ): ToolSeed {
const base = { id, type: "tool" as const, name: tool } const base = { id, type: "tool" as const, callID: id, tool }
if (state === "streaming") return { ...base, state: { status: state, input, raw: "" } } if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
if (state === "running") if (state === "running")
return { return {
...base, ...base,
state: { state: {
status: state, status: state,
input, input,
...(options.output === undefined ? {} : { output: options.output }),
title: options.title, title: options.title,
metadata: options.metadata ?? {}, metadata: options.metadata ?? {},
time: { start: 1700000001000 }, time: { start: 1700000001000 },
@@ -622,10 +612,12 @@ export function toolPart(
} }
export function shell(id: string, state: ToolStatus, output = "", command = `echo ${id}`): ToolSeed { export function shell(id: string, state: ToolStatus, output = "", command = `echo ${id}`): ToolSeed {
if (state === "streaming") return toolPart(id, "shell", state, { command }) if (state === "pending") return toolPart(id, "bash", state, { command })
if (state === "running") return toolPart(id, "shell", state, { command }, { title: command, output }) if (state === "running")
if (state === "error") return toolPart(id, "shell", state, { command }, { error: output || undefined }) return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
return toolPart(id, "shell", state, { command }, { title: command, output }) if (state === "error")
return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } })
return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
} }
export function completedAssistantInfo(info: SessionMessageAssistant): SessionMessageAssistant { export function completedAssistantInfo(info: SessionMessageAssistant): SessionMessageAssistant {
@@ -663,7 +655,7 @@ function messageContent(
): SessionMessageAssistant["content"][number] { ): SessionMessageAssistant["content"][number] {
if (part.type === "tool") { if (part.type === "tool") {
partRefs.set(part.id, { messageID, type: part.type }) partRefs.set(part.id, { messageID, type: part.type })
toolStates.set(part.id, part.state.status) toolStates.set(part.callID, part.state.status)
} else { } else {
partRefs.set(part.id, { messageID, type: part.type, ordinal: ordinals[part.type]++ }) partRefs.set(part.id, { messageID, type: part.type, ordinal: ordinals[part.type]++ })
startedParts.add(part.id) startedParts.add(part.id)
@@ -683,8 +675,8 @@ function messageContent(
const completed = state.status === "completed" || state.status === "error" ? state.time.end : undefined const completed = state.status === "completed" || state.status === "error" ? state.time.end : undefined
const base = { const base = {
type: "tool" as const, type: "tool" as const,
id: part.id, id: part.callID,
name: part.name, name: part.tool,
time: { time: {
created: time?.start ?? 1700000001000, created: time?.start ?? 1700000001000,
...(time?.start === undefined ? {} : { ran: time.start }), ...(time?.start === undefined ? {} : { ran: time.start }),
@@ -694,18 +686,11 @@ function messageContent(
...(part.providerState ? { providerState: jsonRecord(part.providerState) } : {}), ...(part.providerState ? { providerState: jsonRecord(part.providerState) } : {}),
...(part.providerResultState ? { providerResultState: jsonRecord(part.providerResultState) } : {}), ...(part.providerResultState ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
} }
if (state.status === "streaming") return { ...base, state: { status: "streaming", input: state.raw } } if (state.status === "pending") return { ...base, state: { status: "streaming", input: state.raw } }
if (state.status === "running") if (state.status === "running")
return { return {
...base, ...base,
state: { state: { status: "running", input: jsonRecord(state.input), metadata: jsonRecord(state.metadata) },
status: "running",
input: jsonRecord(state.input),
metadata: jsonRecord({
...state.metadata,
...(state.output === undefined ? {} : { output: state.output }),
}),
},
} }
if (state.status === "error") if (state.status === "error")
return { return {
@@ -729,7 +714,7 @@ function messageContent(
} }
function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[] { function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[] {
const previous = toolStates.get(part.id) const previous = toolStates.get(part.callID)
if (previous === "completed" || previous === "error") return [] if (previous === "completed" || previous === "error") return []
const events: OpenCodeEvent[] = [] const events: OpenCodeEvent[] = []
@@ -738,27 +723,27 @@ function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[]
makeEvent("session.tool.input.started", { makeEvent("session.tool.input.started", {
sessionID, sessionID,
assistantMessageID: messageID, assistantMessageID: messageID,
id: part.id, id: part.callID,
name: part.name, name: part.tool,
}), }),
) )
} }
if (part.state.status === "streaming") { if (part.state.status === "pending") {
toolStates.set(part.id, part.state.status) toolStates.set(part.callID, part.state.status)
return events return events
} }
if (!previous || previous === "streaming") { if (!previous || previous === "pending") {
events.push( events.push(
makeEvent("session.tool.input.ended", { makeEvent("session.tool.input.ended", {
sessionID, sessionID,
assistantMessageID: messageID, assistantMessageID: messageID,
id: part.id, id: part.callID,
text: JSON.stringify(part.state.input), text: JSON.stringify(part.state.input),
}), }),
makeEvent("session.tool.called", { makeEvent("session.tool.called", {
sessionID, sessionID,
assistantMessageID: messageID, assistantMessageID: messageID,
id: part.id, id: part.callID,
input: part.state.input, input: part.state.input,
executed: part.executed ?? true, executed: part.executed ?? true,
state: jsonRecord(part.providerState), state: jsonRecord(part.providerState),
@@ -766,20 +751,16 @@ function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[]
) )
} }
if (part.state.status === "running") { if (part.state.status === "running") {
const metadata = { if (previous === "running" || Object.keys(part.state.metadata).length)
...part.state.metadata,
...(part.state.output === undefined ? {} : { output: part.state.output }),
}
if (previous === "running" || Object.keys(metadata).length)
events.push( events.push(
makeEvent("session.tool.progress", { makeEvent("session.tool.progress", {
sessionID, sessionID,
assistantMessageID: messageID, assistantMessageID: messageID,
id: part.id, id: part.callID,
metadata: jsonRecord(metadata), metadata: jsonRecord(part.state.metadata),
}), }),
) )
toolStates.set(part.id, part.state.status) toolStates.set(part.callID, part.state.status)
return events return events
} }
if (part.state.status === "error") { if (part.state.status === "error") {
@@ -787,28 +768,28 @@ function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[]
makeEvent("session.tool.failed", { makeEvent("session.tool.failed", {
sessionID, sessionID,
assistantMessageID: messageID, assistantMessageID: messageID,
id: part.id, id: part.callID,
error: { type: "ToolError", message: part.state.error }, error: { type: "ToolError", message: part.state.error },
metadata: jsonRecord(part.state.metadata), metadata: jsonRecord(part.state.metadata),
executed: part.executed ?? true, executed: part.executed ?? true,
resultState: jsonRecord(part.providerResultState), resultState: jsonRecord(part.providerResultState),
}), }),
) )
toolStates.set(part.id, part.state.status) toolStates.set(part.callID, part.state.status)
return events return events
} }
events.push( events.push(
makeEvent("session.tool.success", { makeEvent("session.tool.success", {
sessionID, sessionID,
assistantMessageID: messageID, assistantMessageID: messageID,
id: part.id, id: part.callID,
content: [{ type: "text", text: part.state.output }], content: [{ type: "text", text: part.state.output }],
metadata: jsonRecord(part.state.metadata), metadata: jsonRecord(part.state.metadata),
executed: part.executed ?? true, executed: part.executed ?? true,
resultState: jsonRecord(part.providerResultState), resultState: jsonRecord(part.providerResultState),
}), }),
) )
toolStates.set(part.id, part.state.status) toolStates.set(part.callID, part.state.status)
return events return events
} }
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import { import {
defineVisualRegions, defineVisualRegions,
reportVisualStability, reportVisualStability,
@@ -59,14 +58,14 @@ test("expands and collapses a long completed shell without overlap", async ({ pa
await startVisualProbe(page, regions) await startVisualProbe(page, regions)
await trigger.click() await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true") await expect(trigger).toHaveAttribute("aria-expanded", "true")
await waitForVisualSettle(page, [regions.shell.selector, regions.following.selector]) await page.waitForTimeout(500)
const expanded = await stopVisualProbe<keyof typeof regions>(page) const expanded = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, "shell-expand", expanded, plan) await reportVisualStability(testInfo, "shell-expand", expanded, plan)
await startVisualProbe(page, regions) await startVisualProbe(page, regions)
await trigger.click() await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false") await expect(trigger).toHaveAttribute("aria-expanded", "false")
await waitForVisualSettle(page, [regions.shell.selector, regions.following.selector]) await page.waitForTimeout(500)
const collapsed = await stopVisualProbe<keyof typeof regions>(page) const collapsed = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, "shell-collapse", collapsed, plan) await reportVisualStability(testInfo, "shell-collapse", collapsed, plan)
}) })
@@ -84,7 +83,7 @@ test("expands and collapses a completed context group without overlap", async ({
messages: [ messages: [
userMessage(), userMessage(),
assistantMessage([ assistantMessage([
toolPart(ids[0]!, "read", "completed", { path: "src/a.ts" }), toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }), toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart(ids[2]!, "grep", "completed", { path: ".", pattern: "stable" }), toolPart(ids[2]!, "grep", "completed", { path: ".", pattern: "stable" }),
toolPart(ids[3]!, "list", "completed", { path: "src" }), toolPart(ids[3]!, "list", "completed", { path: "src" }),
@@ -111,7 +110,7 @@ test("expands and collapses a completed context group without overlap", async ({
await startVisualProbe(page, regions) await startVisualProbe(page, regions)
await trigger.click() await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded)) await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await waitForVisualSettle(page, [regions.context.selector, regions.following.selector]) await page.waitForTimeout(500)
const trace = await stopVisualProbe<keyof typeof regions>(page) const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability( await reportVisualStability(
testInfo, testInfo,
@@ -143,23 +142,16 @@ test("expands and collapses an edit diff without moving twice", async ({ page },
editID, editID,
"edit", "edit",
"completed", "completed",
{ path: "src/edit.ts", oldString: "export const value = 1", newString: "export const value = 2" }, { filePath: "src/edit.ts" },
{ {
metadata: { metadata: {
files: [ filediff: {
{ file: "src/edit.ts",
file: "src/edit.ts", additions: 40,
patch: createTwoFilesPatch( deletions: 40,
"a/src/edit.ts", before: source(40, false),
"b/src/edit.ts", after: source(40, true),
source(40, false), },
source(40, true),
),
additions: 40,
deletions: 40,
status: "modified",
},
],
}, },
}, },
), ),
@@ -190,7 +182,7 @@ test("expands and collapses an edit diff without moving twice", async ({ page },
await startVisualProbe(page, regions) await startVisualProbe(page, regions)
await trigger.click() await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true") await expect(trigger).toHaveAttribute("aria-expanded", "true")
await waitForVisualSettle(page, [regions.edit.selector, regions.following.selector]) await page.waitForTimeout(900)
const trace = await stopVisualProbe<keyof typeof regions>(page) const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability( await reportVisualStability(
testInfo, testInfo,
@@ -17,32 +17,32 @@ import {
userMessage, userMessage,
} from "./fixture" } from "./fixture"
test("adds a subagent child-session link without replacing the row", async ({ page }, testInfo) => { test("adds a task child-session link without replacing the task row", async ({ page }, testInfo) => {
const taskID = "prt_subagent_link" const taskID = "prt_task_link"
const childID = "ses_subagent_child" const childID = "ses_task_child"
const input = { description: "Inspect child", agent: "explore", prompt: "Inspect the child Session." } const input = { description: "Inspect child", subagent_type: "explore" }
const timeline = await setupTimeline(page, { const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([toolPart(taskID, "subagent", "running", input)], { completed: false })], messages: [userMessage(), assistantMessage([toolPart(taskID, "task", "running", input)], { completed: false })],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect child" })], sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect child" })],
cpuRate: 4, cpuRate: 4,
}) })
const regions = defineVisualRegions({ const regions = defineVisualRegions({
subagent: { selector: `[data-timeline-part-id="${renderedPartID(taskID)}"] [data-slot="collapsible-trigger"]` }, task: { selector: `[data-timeline-part-id="${renderedPartID(taskID)}"] [data-slot="collapsible-trigger"]` },
}) })
await startVisualProbe(page, regions) await startVisualProbe(page, regions)
await timeline.send( await timeline.send(
partUpdated(toolPart(taskID, "subagent", "completed", input, { metadata: { sessionID: childID } })), partUpdated(toolPart(taskID, "task", "completed", input, { metadata: { sessionId: childID } })),
500, 500,
) )
const trace = await stopVisualProbe<keyof typeof regions>(page) const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability( await reportVisualStability(
testInfo, testInfo,
"subagent-link", "task-link",
trace, trace,
visualPlan(regions, [ visualPlan(regions, [
{ type: "required", regions: ["subagent"] }, { type: "required", regions: ["task"] },
{ type: "unique", regions: ["subagent"] }, { type: "unique", regions: ["task"] },
{ type: "stable", regions: ["subagent"] }, { type: "stable", regions: ["task"] },
{ type: "opacity", regions: "all" }, { type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" }, { type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 }, { type: "motion", regions: "all", maxPositionReversals: 0 },
@@ -21,30 +21,24 @@ import {
} from "./fixture" } from "./fixture"
test.describe("timeline tool state stability", () => { test.describe("timeline tool state stability", () => {
test("moves lightweight tools through streaming, running, and completed without replacing rows", async ({ test("moves lightweight tools through pending, running, and completed without replacing rows", async ({
page, page,
}, testInfo) => { }, testInfo) => {
const ids = ["webfetch", "websearch", "subagent", "skill", "custom"] as const const ids = ["webfetch", "websearch", "task", "skill", "custom"] as const
const inputs = { const inputs = {
webfetch: { url: "https://example.com/docs" }, webfetch: { url: "https://example.com/docs" },
websearch: { query: "timeline stability" }, websearch: { query: "timeline stability" },
subagent: { description: "Inspect timeline", agent: "explore", prompt: "Inspect the timeline." }, task: { description: "Inspect timeline", subagent_type: "explore" },
skill: { name: "stability" }, skill: { name: "stability" },
custom: { target: "timeline", depth: 2 }, custom: { target: "timeline", depth: 2 },
} }
const names = { const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
webfetch: "webfetch",
websearch: "websearch",
subagent: "subagent",
skill: "skill",
custom: "mcp_probe",
}
const questionID = "prt_state_question" const questionID = "prt_state_question"
const todoID = "prt_state_todo" const todoID = "prt_state_todo"
const initial = [ const initial = [
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "streaming", inputs[id])), ...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
toolPart(questionID, "question", "streaming", questionInput()), toolPart(questionID, "question", "pending", questionInput()),
toolPart(todoID, "todowrite", "streaming", { todos: [{ content: "Hidden", status: "pending" }] }), toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
textPart("prt_state_following", "Following lightweight tools"), textPart("prt_state_following", "Following lightweight tools"),
] ]
const childID = "ses_timeline_child" const childID = "ses_timeline_child"
@@ -61,14 +55,14 @@ test.describe("timeline tool state stability", () => {
const regionIDs = [ const regionIDs = [
"prt_state_webfetch", "prt_state_webfetch",
"prt_state_websearch", "prt_state_websearch",
"prt_state_subagent", "prt_state_task",
"prt_state_skill", "prt_state_skill",
"prt_state_custom", "prt_state_custom",
] as const ] as const
const regions = defineVisualRegions({ const regions = defineVisualRegions({
prt_state_webfetch: toolRegion(regionIDs[0]), prt_state_webfetch: toolRegion(regionIDs[0]),
prt_state_websearch: toolRegion(regionIDs[1]), prt_state_websearch: toolRegion(regionIDs[1]),
prt_state_subagent: toolRegion(regionIDs[2]), prt_state_task: toolRegion(regionIDs[2]),
prt_state_skill: toolRegion(regionIDs[3]), prt_state_skill: toolRegion(regionIDs[3]),
prt_state_custom: toolRegion(regionIDs[4]), prt_state_custom: toolRegion(regionIDs[4]),
}) })
@@ -79,9 +73,9 @@ test.describe("timeline tool state stability", () => {
[80, 240, 100, 360, 140][index], [80, 240, 100, 360, 140][index],
) )
} }
for (const [index, id] of ["skill", "webfetch", "custom", "subagent", "websearch"].entries()) { for (const [index, id] of ["skill", "webfetch", "custom", "task", "websearch"].entries()) {
const key = id as (typeof ids)[number] const key = id as (typeof ids)[number]
const metadata = key === "subagent" ? { sessionID: childID } : key === "websearch" ? { provider: "exa" } : {} const metadata = key === "task" ? { sessionId: childID } : key === "websearch" ? { provider: "exa" } : {}
const output = key === "websearch" ? "Result https://example.com/result" : "Completed" const output = key === "websearch" ? "Result https://example.com/result" : "Completed"
await timeline.send( await timeline.send(
partUpdated(toolPart(`prt_state_${key}`, names[key], "completed", inputs[key], { metadata, output })), partUpdated(toolPart(`prt_state_${key}`, names[key], "completed", inputs[key], { metadata, output })),
@@ -127,12 +121,12 @@ test.describe("timeline tool state stability", () => {
const ids = ["prt_ctx_01_read", "prt_ctx_02_glob", "prt_ctx_03_grep", "prt_ctx_04_list"] const ids = ["prt_ctx_01_read", "prt_ctx_02_glob", "prt_ctx_03_grep", "prt_ctx_04_list"]
const tools = ["read", "glob", "grep", "list"] const tools = ["read", "glob", "grep", "list"]
const inputs = [ const inputs = [
{ path: "src/a.ts", offset: 0, limit: 120 }, { filePath: "src/a.ts", offset: 0, limit: 120 },
{ path: directory, pattern: "**/*.ts" }, { path: directory, pattern: "**/*.ts" },
{ path: directory, pattern: "stability", include: "*.ts" }, { path: directory, pattern: "stability", include: "*.ts" },
{ path: "src" }, { path: "src" },
] ]
const context = ids.map((id, index) => toolPart(id, tools[index]!, "streaming", inputs[index]!)) const context = ids.map((id, index) => toolPart(id, tools[index]!, "pending", inputs[index]!))
const timeline = await setupTimeline(page, { const timeline = await setupTimeline(page, {
messages: [ messages: [
userMessage(), userMessage(),
@@ -4,7 +4,6 @@ import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server" import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../../utils/waits" import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
import { expect } from "../benchmark" import { expect } from "../benchmark"
import { createTwoFilesPatch } from "diff"
const directory = "C:/OpenCode/TimelineStateRegression" const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression" const projectID = "proj_timeline_state_regression"
@@ -27,29 +26,28 @@ const userMessage = {
const editPart: ToolSeed = { const editPart: ToolSeed = {
id: editPartID, id: editPartID,
sessionID,
messageID: assistantMessageID,
type: "tool", type: "tool",
name: "edit", callID: "call_edit_regression",
tool: "edit",
state: { state: {
status: "completed", status: "completed",
input: { input: { filePath: "src/regression.ts" },
path: "src/regression.ts", output: "Edited src/regression.ts",
oldString: "export const value = 'before'", title: "src/regression.ts",
newString: "export const value = 'after'",
},
content: [{ type: "text", text: "Edited src/regression.ts" }],
metadata: { metadata: {
files: [ filediff: {
currentFile( file: "src/regression.ts",
"src/regression.ts", additions: 1,
"export const value = 'before'\n", deletions: 1,
"export const value = 'after'\n", before: "export const value = 'before'\n",
1, after: "export const value = 'after'\n",
1, },
), diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
],
}, },
time: { start: 1700000001000, end: 1700000002000 },
}, },
time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 },
} }
const assistantMessage = { const assistantMessage = {
@@ -206,20 +204,20 @@ function performanceTurn(index: number) {
? [ ? [
{ {
id: `prt_0000_${suffix}_edit`, id: `prt_0000_${suffix}_edit`,
sessionID,
messageID: assistantID,
type: "tool", type: "tool",
name: "edit", callID: `call_0000_${suffix}_edit`,
tool: "edit",
state: { state: {
status: "completed", status: "completed",
input: { path: `src/history-${index}.ts`, oldString: before, newString: after }, input: { filePath: `src/history-${index}.ts` },
content: [{ type: "text", text: `Edited src/history-${index}.ts` }], output: `Edited src/history-${index}.ts`,
title: `src/history-${index}.ts`,
metadata: { metadata: {
files: [currentFile(`src/history-${index}.ts`, before, after, 48, 48)], filediff: { file: `src/history-${index}.ts`, additions: 48, deletions: 48, before, after },
}, },
}, 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,
}, },
}, },
] ]
@@ -228,18 +226,20 @@ function performanceTurn(index: number) {
? [ ? [
{ {
id: `prt_0000_${suffix}_write`, id: `prt_0000_${suffix}_write`,
sessionID,
messageID: assistantID,
type: "tool", type: "tool",
name: "write", callID: `call_0000_${suffix}_write`,
tool: "write",
state: { state: {
status: "completed", status: "completed",
input: { path: `src/generated-${index}.tsx`, content: after }, input: { filePath: `src/generated-${index}.tsx`, content: after },
content: [{ type: "text", text: `Wrote src/generated-${index}.tsx` }], output: `Wrote src/generated-${index}.tsx`,
metadata: { files: [currentFile(`src/generated-${index}.tsx`, "", after, 32, 0)] }, title: `src/generated-${index}.tsx`,
}, metadata: {
time: { filediff: { file: `src/generated-${index}.tsx`, additions: 32, deletions: 0, before: "", after },
created: 1690000001400 + index * 2_000, },
ran: 1690000001400 + index * 2_000, time: { start: 1690000001400 + index * 2_000, end: 1690000001500 + index * 2_000 },
completed: 1690000001500 + index * 2_000,
}, },
}, },
] ]
@@ -248,24 +248,31 @@ function performanceTurn(index: number) {
? [ ? [
{ {
id: `prt_0000_${suffix}_patch`, id: `prt_0000_${suffix}_patch`,
sessionID,
messageID: assistantID,
type: "tool", type: "tool",
name: "patch", callID: `call_0000_${suffix}_patch`,
tool: "apply_patch",
state: { state: {
status: "completed", status: "completed",
input: { patchText: realisticPatch(index) }, input: { patchText: realisticPatch(index) },
content: [{ type: "text", text: "Success. Updated src/components/SessionCard.tsx" }], output: "Success. Updated src/components/SessionCard.tsx",
title: "src/components/SessionCard.tsx",
metadata: { metadata: {
files: [ files: [
{ {
...currentFile("src/components/SessionCard.tsx", before, after, 8, 3), filePath: "src/components/SessionCard.tsx",
relativePath: "src/components/SessionCard.tsx",
type: "update",
additions: 8,
deletions: 3,
patch: realisticPatch(index),
before,
after,
}, },
], ],
}, },
}, time: { 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,
}, },
}, },
] ]
@@ -302,16 +309,20 @@ function performanceTurn(index: number) {
} }
type ToolSeed = { type ToolSeed = {
id: string id?: string
sessionID?: string
messageID?: string
type: "tool" type: "tool"
name: string callID: string
tool: string
state: { state: {
status: "completed" status: string
input: Record<string, unknown> input: Record<string, unknown>
content: [{ type: "text"; text: string }] output: string
title?: string
metadata: Record<string, unknown> metadata: Record<string, unknown>
time: { start: number; end: number }
} }
time: { created: number; ran: number; completed: number }
} }
type ContentSeedBase = { id?: string; sessionID?: string; messageID?: string } type ContentSeedBase = { id?: string; sessionID?: string; messageID?: string }
@@ -324,13 +335,13 @@ type ContentSeed =
function toolContent(part: ToolSeed): SessionMessageAssistant["content"][number] { function toolContent(part: ToolSeed): SessionMessageAssistant["content"][number] {
return { return {
type: "tool", type: "tool",
id: part.id, id: part.callID,
name: part.name, name: part.tool,
time: part.time, time: { created: part.state.time.start, ran: part.state.time.start, completed: part.state.time.end },
state: { state: {
status: "completed", status: "completed",
input: part.state.input as Record<string, JsonValue>, input: part.state.input as Record<string, JsonValue>,
content: part.state.content, content: [{ type: "text", text: part.state.output }],
metadata: part.state.metadata as Record<string, JsonValue>, metadata: part.state.metadata as Record<string, JsonValue>,
}, },
} }
@@ -411,16 +422,6 @@ export function MessageSummary(props: { messages: Message[]; locale: string }) {
` `
} }
function currentFile(file: string, before: string, after: string, additions: number, deletions: number) {
return {
file,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
additions,
deletions,
status: before ? (after ? "modified" : "deleted") : "added",
}
}
function realisticPatch(index: number) { function realisticPatch(index: number) {
return `*** Begin Patch return `*** Begin Patch
*** Update File: src/components/SessionCard.tsx *** Update File: src/components/SessionCard.tsx
@@ -1,6 +1,4 @@
import type { CDPSession, Page } from "@playwright/test" 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 }) { export async function startTimelineProfile(page: Page, options: { cpuThrottle: number; profileCPU: boolean }) {
const cdp = await page.context().newCDPSession(page) const cdp = await page.context().newCDPSession(page)
@@ -14,13 +12,6 @@ export async function startTimelineProfile(page: Page, options: { cpuThrottle: n
async stop() { async stop() {
if (!options.profileCPU) return if (!options.profileCPU) return
const result = await cdp.send("Profiler.stop") 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>() const self = new Map<number, number>()
result.profile.samples?.forEach((id, index) => { result.profile.samples?.forEach((id, index) => {
const duration = (result.profile.timeDeltas?.[index] ?? 0) / 1_000 const duration = (result.profile.timeDeltas?.[index] ?? 0) / 1_000
@@ -1,5 +1,3 @@
import { createTwoFilesPatch } from "diff"
const words = [ const words = [
"alpha", "alpha",
"bravo", "bravo",
@@ -30,21 +28,22 @@ const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline" const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type MessagePart = type MessagePart = {
| { id: string; type: "text"; text: string } id: string
| { id: string; type: "reasoning"; text: string; time?: { start: number; end?: number } } type: "text" | "reasoning" | "tool"
| { text?: string
id: string time?: { start: number; end?: number }
type: "tool" callID?: string
name: string tool?: string
state: { state?: {
status: "completed" status: "completed"
input: Record<string, unknown> input: Record<string, unknown>
content: [{ type: "text"; text: string }] output: string
metadata: Record<string, unknown> title: unknown
} metadata: Record<string, unknown>
time: { created: number; ran: number; completed: number } time: { start: number; end: number }
} }
}
function lorem(seed: number, length: number) { function lorem(seed: number, length: number) {
let out = "" let out = ""
@@ -103,16 +102,17 @@ function messageContent(part: MessagePart): SessionMessageAssistant["content"][n
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) } ? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
: undefined, : undefined,
} }
const state = part.state!
return { return {
type: "tool", type: "tool",
id: part.id, id: part.callID ?? part.id,
name: part.name, name: part.tool!,
time: part.time, time: { created: state.time.start, ran: state.time.start, completed: state.time.end },
state: { state: {
status: "completed", status: "completed",
input: part.state.input as Record<string, JsonValue>, input: state.input as Record<string, JsonValue>,
content: part.state.content, content: [{ type: "text", text: state.output }],
metadata: part.state.metadata as Record<string, JsonValue>, metadata: state.metadata as Record<string, JsonValue>,
}, },
} }
} }
@@ -149,46 +149,43 @@ function toolPart(
): MessagePart { ): MessagePart {
const metadata = const metadata =
metadataOverride ?? metadataOverride ??
(tool === "patch" (tool === "apply_patch"
? { ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
files: [
patchFile(index, "modified"),
patchFile(index + 1, index % 2 === 0 ? "added" : "deleted"),
],
}
: tool === "edit" || tool === "write" : tool === "edit" || tool === "write"
? { files: [fileDiff(String(input.path ?? `src/generated/file-${index}.ts`), index)] } ? {
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
diff: patch(index, outputLength),
preview: patch(index + 1, 420),
}
: tool === "question" : tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] } ? { answers: [["Proceed"], ["Keep sample output"]] }
: {}) : {})
return { return {
id: id(`call_${tool}_${partIndex}`, index), id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool", type: "tool",
name: tool, callID: id("call", index * 10 + partIndex),
tool,
state: { state: {
status: "completed", status: "completed",
input, input,
content: [{ type: "text", text: lorem(index * 23 + partIndex, outputLength) }], output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
metadata, 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, status: "added" | "modified" | "deleted") { function patchFile(seed: number, type: "add" | "update" | "delete") {
const file = `src/generated/patch-${seed}.ts`
const before = status === "added" ? "" : code(seed, 18)
const after = status === "deleted" ? "" : code(seed + 1, 24)
return { return {
file, filePath: `src/generated/patch-${seed}.ts`,
status, relativePath: `src/generated/patch-${seed}.ts`,
additions: status === "deleted" ? 0 : (seed % 7) + 1, type,
deletions: status === "added" ? 0 : seed % 4, additions: (seed % 7) + 1,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after), deletions: type === "add" ? 0 : seed % 4,
patch: patch(seed, 520),
before: type === "add" ? undefined : code(seed, 18),
after: type === "delete" ? undefined : code(seed + 1, 24),
} }
} }
@@ -203,13 +200,17 @@ function fileDiff(file: string, seed: number) {
: before.replace("value4", "updatedValue4").replace("value20", "updatedValue20") : before.replace("value4", "updatedValue4").replace("value20", "updatedValue20")
return { return {
file, file,
status: "modified" as const,
additions: lines === 300 ? 300 : lines === 2 ? 1 : 2, additions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
deletions: lines === 300 ? 300 : lines === 2 ? 1 : 2, deletions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after), before,
after,
} }
} }
function patch(seed: number, length: number) {
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
}
function code(seed: number, lines: number, width = 32) { function code(seed: number, lines: number, width = 32) {
return Array.from( return Array.from(
{ length: lines }, { length: lines },
@@ -224,24 +225,22 @@ function turn(index: number): SessionMessageInfo[] {
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []), ...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
...(index % 3 === 0 ...(index % 3 === 0
? [ ? [
toolPart(index, 0, "read", { path: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220), toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140), toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180), toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120), toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
] ]
: []), : []),
textPart(index, 2, 160 + (index % 6) * 90), textPart(index, 2, 160 + (index % 6) * 90),
...(index % 4 === 0 ...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
? [toolPart(index, 3, "edit", { path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" }, 700)]
: []),
...(index % 6 === 0 ...(index % 6 === 0
? [toolPart(index, 7, "write", { path: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)] ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []), : []),
...(index % 8 === 0 ...(index % 8 === 0
? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)] ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []), : []),
...(index % 7 === 0 ...(index % 7 === 0
? [toolPart(index, 4, "shell", { command: "bun typecheck", description: "Verify generated output" }, 620)] ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []), : []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), ...(index % 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 % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
@@ -257,15 +256,7 @@ function turn(index: number): SessionMessageInfo[] {
] ]
: []), : []),
...(index % 17 === 0 ...(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)] return [user, assistantMessage(targetID, index, user.id, parts)]
@@ -281,10 +272,10 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
toolPart( toolPart(
index + 1000, index + 1000,
1, 1,
"subagent", "task",
{ description: "Inspect child navigation", agent: "explore", prompt: "Inspect child navigation." }, { description: "Inspect child navigation", subagent_type: "explore" },
160, 160,
{ sessionID: childID }, { sessionId: childID },
), ),
] ]
: []), : []),
@@ -89,7 +89,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
data: { data: {
id: "permission-background-a", id: "permission-background-a",
sessionID: sessionA.id, sessionID: sessionA.id,
action: "shell", action: "bash",
resources: ["git status"], resources: ["git status"],
metadata: {}, metadata: {},
save: [], save: [],
@@ -116,7 +116,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
data: { data: {
id: "permission-background-a-child", id: "permission-background-a-child",
sessionID: childSessionA.id, sessionID: childSessionA.id,
action: "shell", action: "bash",
resources: ["git diff"], resources: ["git diff"],
metadata: {}, metadata: {},
save: [], save: [],
@@ -85,7 +85,7 @@ test("shows a pending permission dock", async ({ page }) => {
{ {
id: "permission-request", id: "permission-request",
sessionID, sessionID,
permission: "shell", permission: "bash",
patterns: ["git status", "git diff"], patterns: ["git status", "git diff"],
metadata: {}, metadata: {},
always: [], always: [],
@@ -2,7 +2,6 @@ import { expect, test, type Locator, type Page } from "@playwright/test"
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise" import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits" import { expectAppVisible, expectSessionTitle } from "../utils/waits"
import { createTwoFilesPatch } from "diff"
const directory = "C:/OpenCode/TimelineStateRegression" const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression" const projectID = "proj_timeline_state_regression"
@@ -41,28 +40,18 @@ const editPart = {
tool: "edit", tool: "edit",
state: { state: {
status: "completed", status: "completed",
input: { input: { filePath: "src/regression.ts" },
path: "src/regression.ts",
oldString: "export const value = 'before'",
newString: "export const value = 'after'",
},
output: "Edited src/regression.ts", output: "Edited src/regression.ts",
title: "src/regression.ts", title: "src/regression.ts",
metadata: { metadata: {
files: [ filediff: {
{ file: "src/regression.ts",
file: "src/regression.ts", additions: 1,
patch: createTwoFilesPatch( deletions: 1,
"a/src/regression.ts", before: "export const value = 'before'\n",
"b/src/regression.ts", after: "export const value = 'after'\n",
"export const value = 'before'\n", },
"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",
),
additions: 1,
deletions: 1,
status: "modified",
},
],
}, },
time: { start: 1700000001000, end: 1700000002000 }, time: { start: 1700000001000, end: 1700000002000 },
}, },
@@ -160,15 +149,13 @@ test.describe("regression: session timeline local row state", () => {
...editPart.state, ...editPart.state,
metadata: { metadata: {
...editPart.state.metadata, ...editPart.state.metadata,
files: [ filediff: {
{ file: "src/regression.ts",
file: "src/regression.ts", additions: 1,
patch: createTwoFilesPatch("a/src/regression.ts", "b/src/regression.ts", lines, after), deletions: 1,
additions: 5, before: lines,
deletions: 5, after,
status: "modified", },
},
],
}, },
}, },
} }
@@ -78,7 +78,7 @@ test.describe("regression: session timeline context group resize", () => {
id("msg_assistant", 10), id("msg_assistant", 10),
["read", "glob", "grep", "list"][index]!, ["read", "glob", "grep", "list"][index]!,
[ [
{ path: "src/recent-a.ts" }, { filePath: "src/recent-a.ts" },
{ path: directory, pattern: "**/*.ts" }, { path: directory, pattern: "**/*.ts" },
{ path: directory, pattern: "Explored" }, { path: directory, pattern: "Explored" },
{ path: "src" }, { path: "src" },
@@ -213,7 +213,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
contextIDs[0]!, contextIDs[0]!,
assistantID, assistantID,
"read", "read",
{ path: "src/recent-a.ts", offset: 0, limit: 120 }, { filePath: "src/recent-a.ts", offset: 0, limit: 120 },
status, status,
), ),
), ),
@@ -270,7 +270,7 @@ function contextTool(
status, status,
input, input,
output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`, output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
title: input.path || input.pattern || "completed", title: input.filePath || input.path || input.pattern || "completed",
metadata: {}, metadata: {},
time: { start: 1700000000000, end: 1700000000100 }, time: { start: 1700000000000, end: 1700000000100 },
}, },
@@ -10,7 +10,7 @@ import {
test("preserves a collapsed context group through count and status updates", async ({ page }) => { test("preserves a collapsed context group through count and status updates", async ({ page }) => {
const ids = ["prt_closed_01_read", "prt_closed_02_glob"] const ids = ["prt_closed_01_read", "prt_closed_02_glob"]
const inputs = { const inputs = {
read: { path: "src/a.ts", offset: 0, limit: 120 }, read: { filePath: "src/a.ts", offset: 0, limit: 120 },
glob: { path: ".", pattern: "**/*.ts" }, glob: { path: ".", pattern: "**/*.ts" },
} }
const timeline = await setupTimeline(page, { const timeline = await setupTimeline(page, {
@@ -7,7 +7,7 @@ test("renders completed write content", async ({ page }) => {
messages: [ messages: [
userMessage(), userMessage(),
assistantMessage([ assistantMessage([
toolPart(id, "write", "completed", { path: "src/write.ts", content: "export const written = true\n" }), toolPart(id, "write", "completed", { filePath: "src/write.ts", content: "export const written = true\n" }),
]), ]),
], ],
settings: { editToolPartsExpanded: true }, settings: { editToolPartsExpanded: true },
@@ -24,19 +24,20 @@ test("renders a completed single-file patch", async ({ page }) => {
assistantMessage([ assistantMessage([
toolPart( toolPart(
id, id,
"patch", "apply_patch",
"completed", "completed",
{ patchText: "Update src/a.ts" }, { files: ["src/a.ts"] },
{ {
metadata: { metadata: {
files: [ files: [
{ {
file: "src/a.ts", filePath: "src/a.ts",
status: "modified", relativePath: "src/a.ts",
patch: type: "update",
"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, additions: 1,
deletions: 1, deletions: 1,
before: "export const value = 1\n",
after: "export const value = 2\n",
}, },
], ],
}, },
@@ -1,19 +1,18 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" 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 }) => { test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
const patchID = "prt_nested_patch" const patchID = "prt_nested_patch"
const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")] const files = [patchFile("src/a.ts", "update"), patchFile("src/b.ts", "add"), patchFile("src/old.ts", "delete")]
await setupTimeline(page, { await setupTimeline(page, {
messages: [ messages: [
userMessage(), userMessage(),
assistantMessage([ assistantMessage([
toolPart( toolPart(
patchID, patchID,
"patch", "apply_patch",
"completed", "completed",
{ patchText: "Update three files" }, { files: files.map((file) => file.filePath) },
{ metadata: { files } }, { metadata: { files } },
), ),
]), ]),
@@ -32,15 +31,15 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true") await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
}) })
function patchFile(file: string, status: "added" | "modified" | "deleted") { function patchFile(filePath: string, type: "add" | "update" | "delete") {
const before = status === "added" ? "" : source(false)
const after = status === "deleted" ? "" : source(true)
return { return {
file, filePath,
status, relativePath: filePath,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after), type,
additions: status === "deleted" ? 0 : 4, additions: type === "delete" ? 0 : 4,
deletions: status === "added" ? 0 : 3, deletions: type === "add" ? 0 : 3,
before: type === "add" ? undefined : source(false),
after: type === "delete" ? undefined : source(true),
} }
} }
@@ -11,7 +11,7 @@ for (const profile of [
messages: [ messages: [
userMessage(), userMessage(),
assistantMessage([ assistantMessage([
toolPart(ids[0]!, "read", "completed", { path: "src/a.ts" }), toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }), toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
]), ]),
], ],
@@ -12,7 +12,7 @@ import {
test.describe("session timeline projection", () => { test.describe("session timeline projection", () => {
test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => { test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => {
const parts = [ const parts = [
toolPart("prt_01_read", "read", "completed", { path: "src/a.ts" }), toolPart("prt_01_read", "read", "completed", { filePath: "src/a.ts" }),
toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }), toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }), toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }),
toolPart("prt_04_list", "list", "completed", { path: "src" }), toolPart("prt_04_list", "list", "completed", { path: "src" }),
@@ -24,20 +24,16 @@ test.describe("session timeline projection", () => {
{ query: "timeline stability" }, { query: "timeline stability" },
{ output: "https://example.com/result" }, { output: "https://example.com/result" },
), ),
toolPart("prt_task", "subagent", "completed", { toolPart("prt_task", "task", "completed", { description: "Inspect timeline", subagent_type: "explore" }),
description: "Inspect timeline",
agent: "explore",
prompt: "Inspect the timeline implementation.",
}),
toolPart( toolPart(
"prt_bash", "prt_bash",
"shell", "bash",
"completed", "completed",
{ command: "printf stable" }, { command: "printf stable" },
{ output: "stable", title: "printf stable" }, { output: "stable", title: "printf stable" },
), ),
editPart("prt_edit"), editPart("prt_edit"),
toolPart("prt_write", "write", "completed", { path: "src/new.ts", content: "export const stable = true\n" }), toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
patchPart("prt_patch"), patchPart("prt_patch"),
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }), toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
toolPart( toolPart(
@@ -179,10 +175,16 @@ function editPart(id: string) {
id, id,
"edit", "edit",
"completed", "completed",
{ path: "src/a.ts", oldString: "export const value = 1", newString: "export const value = 2" }, { filePath: "src/a.ts" },
{ {
metadata: { metadata: {
files: [patchFile("src/a.ts", "modified")], filediff: {
file: "src/a.ts",
additions: 1,
deletions: 1,
before: "export const value = 1\n",
after: "export const value = 2\n",
},
}, },
}, },
) )
@@ -191,33 +193,31 @@ function editPart(id: string) {
function patchPart(id: string) { function patchPart(id: string) {
return toolPart( return toolPart(
id, id,
"patch", "apply_patch",
"completed", "completed",
{ patchText: "Update the projected files" }, { files: ["src/a.ts", "src/b.ts"] },
{ {
metadata: { metadata: {
files: [ files: [
patchFile("src/a.ts", "modified"), patchFile("src/a.ts", "update"),
patchFile("src/b.ts", "added"), patchFile("src/b.ts", "add"),
patchFile("src/old.ts", "deleted"), patchFile("src/old.ts", "delete"),
{ ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
], ],
}, },
}, },
) )
} }
function patchFile(file: string, status: "added" | "modified" | "deleted") { function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
return { return {
file, filePath,
status, relativePath: filePath,
patch: type,
status === "added" additions: type === "delete" ? 0 : 1,
? "@@ -0,0 +1 @@\n+export const after = true" deletions: type === "add" ? 0 : 1,
: status === "deleted" before: type === "add" ? undefined : "export const before = true\n",
? "@@ -1 +0,0 @@\n-export const before = true" after: type === "delete" ? undefined : "export const after = true\n",
: "@@ -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 }) => { test("groups singleton and separated context operations at correct boundaries", async ({ page }) => {
const parts = [ const parts = [
toolPart("prt_boundary_01_read", "read", "completed", { path: "src/a.ts" }), toolPart("prt_boundary_01_read", "read", "completed", { filePath: "src/a.ts" }),
textPart("prt_boundary_02_text", "Boundary text"), textPart("prt_boundary_02_text", "Boundary text"),
toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }), toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }), toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }),
@@ -67,18 +67,19 @@ for (const deviceScaleFactor of [1.25, 1.5]) {
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => { test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
const patchID = "prt_patch_outline" const patchID = "prt_patch_outline"
const file = { const file = {
file: "src/outline.ts", filePath: "src/outline.ts",
status: "modified", relativePath: "src/outline.ts",
patch: type: "update",
"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, additions: 1,
deletions: 1, deletions: 1,
before: "const outline = false\n",
after: "const outline = true\n",
} }
const timeline = await setupTimeline(page, { const timeline = await setupTimeline(page, {
messages: [ messages: [
userMessage(), userMessage(),
assistantMessage([ assistantMessage([
toolPart(patchID, "patch", "completed", { patchText: "Update src/outline.ts" }, { metadata: { files: [file] } }), toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
]), ]),
], ],
settings: { editToolPartsExpanded: true }, settings: { editToolPartsExpanded: true },
@@ -8,7 +8,7 @@ import {
} from "../performance/timeline-stability/fixture" } from "../performance/timeline-stability/fixture"
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => { test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
const ordinary = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"] const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
const parts = ordinary.map((tool, index) => const parts = ordinary.map((tool, index) =>
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }), 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(), userMessage(),
assistantMessage( assistantMessage(
[ [
toolPart(shellID, "shell", "streaming", { command: "exit 1" }), toolPart(shellID, "bash", "pending", { command: "exit 1" }),
toolPart(questionID, "question", "streaming", questionInput()), toolPart(questionID, "question", "pending", questionInput()),
], ],
{ completed: false }, { completed: false },
), ),
@@ -46,11 +46,11 @@ test("transitions shell and question through running error outcomes", async ({ p
}) })
await timeline.waitForPart(shellID) await timeline.waitForPart(shellID)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120) await timeline.send(partUpdated(toolPart(shellID, "bash", "running", { command: "exit 1" })), 120)
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180) await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send( await timeline.send(
partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })), partUpdated(toolPart(shellID, "bash", "error", { command: "exit 1" }, { error: "Command exited 1" })),
180, 180,
) )
await timeline.send( await timeline.send(
@@ -147,13 +147,12 @@ function questionInput() {
} }
function errorInput(tool: string) { function errorInput(tool: string) {
if (tool === "shell") return { command: "exit 1" } if (tool === "bash") return { command: "exit 1" }
if (["edit", "write"].includes(tool)) return { path: "src/error.ts", content: "" } if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
if (tool === "patch") return { patchText: "Update src/error.ts" } if (tool === "apply_patch") return { files: ["src/error.ts"] }
if (tool === "webfetch") return { url: "https://example.com" } if (tool === "webfetch") return { url: "https://example.com" }
if (tool === "websearch") return { query: "failure" } if (tool === "websearch") return { query: "failure" }
if (tool === "subagent") if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
return { description: "Fail subagent", agent: "explore", prompt: "Inspect the failure." }
if (tool === "skill") return { name: "failure" } if (tool === "skill") return { name: "failure" }
return { target: "failure" } return { target: "failure" }
} }
@@ -167,14 +167,14 @@ function parentMessages(): SessionMessageInfo[] {
content: [ content: [
{ {
type: "tool", type: "tool",
id: "call_subagent_0001", id: "call_task_0001",
name: "subagent", name: "task",
time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 }, time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 },
state: { state: {
status: "completed", status: "completed",
input: { description: taskDescription, agent: "explore", prompt: "Inspect the delegated work." }, input: { description: taskDescription, subagent_type: "explore" },
content: [{ type: "text", text: "Subagent finished" }], content: [{ type: "text", text: "Subagent finished" }],
metadata: { sessionID: childID }, metadata: { sessionId: childID },
}, },
}, },
], ],
@@ -1,5 +1,3 @@
import { createTwoFilesPatch } from "diff"
const words = [ const words = [
"alpha", "alpha",
"bravo", "bravo",
@@ -30,21 +28,22 @@ const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline" const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type MessagePart = type MessagePart = {
| { id: string; type: "text"; text: string } id: string
| { id: string; type: "reasoning"; text: string; time?: { start: number; end?: number } } type: "text" | "reasoning" | "tool"
| { text?: string
id: string time?: { start: number; end?: number }
type: "tool" callID?: string
name: string tool?: string
state: { state?: {
status: "completed" status: "completed"
input: Record<string, unknown> input: Record<string, unknown>
content: [{ type: "text"; text: string }] output: string
metadata: Record<string, unknown> title: unknown
} metadata: Record<string, unknown>
time: { created: number; ran: number; completed: number } time: { start: number; end: number }
} }
}
function lorem(seed: number, length: number) { function lorem(seed: number, length: number) {
let out = "" let out = ""
@@ -103,16 +102,17 @@ function messageContent(part: MessagePart): SessionMessageAssistant["content"][n
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) } ? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
: undefined, : undefined,
} }
const state = part.state!
return { return {
type: "tool", type: "tool",
id: part.id, id: part.callID ?? part.id,
name: part.name, name: part.tool!,
time: part.time, time: { created: state.time.start, ran: state.time.start, completed: state.time.end },
state: { state: {
status: "completed", status: "completed",
input: part.state.input as Record<string, JsonValue>, input: state.input as Record<string, JsonValue>,
content: part.state.content, content: [{ type: "text", text: state.output }],
metadata: part.state.metadata as Record<string, JsonValue>, metadata: state.metadata as Record<string, JsonValue>,
}, },
} }
} }
@@ -138,61 +138,60 @@ function toolPart(
outputLength = 160, outputLength = 160,
): MessagePart { ): MessagePart {
const metadata = const metadata =
tool === "patch" tool === "apply_patch"
? { ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
files: [
patchFile(index, "modified"),
patchFile(index + 1, index % 2 === 0 ? "added" : "deleted"),
],
}
: tool === "edit" || tool === "write" : tool === "edit" || tool === "write"
? { files: [fileDiff(String(input.path ?? `src/generated/file-${index}.ts`), index)] } ? {
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
diff: patch(index, outputLength),
preview: patch(index + 1, 420),
}
: tool === "question" : tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] } ? { answers: [["Proceed"], ["Keep sample output"]] }
: {} : {}
return { return {
id: id(`call_${tool}_${partIndex}`, index), id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool", type: "tool",
name: tool, callID: id("call", index * 100 + partIndex),
tool,
state: { state: {
status: "completed", status: "completed",
input, input,
content: [{ type: "text", text: lorem(index * 23 + partIndex, outputLength) }], output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
metadata, 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, status: "added" | "modified" | "deleted") { function patchFile(seed: number, type: "add" | "update" | "delete") {
const file = `src/generated/patch-${seed}.ts`
const before = status === "added" ? "" : code(seed, 18)
const after = status === "deleted" ? "" : code(seed + 1, 24)
return { return {
file, filePath: `src/generated/patch-${seed}.ts`,
status, relativePath: `src/generated/patch-${seed}.ts`,
additions: status === "deleted" ? 0 : (seed % 7) + 1, type,
deletions: status === "added" ? 0 : seed % 4, additions: (seed % 7) + 1,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after), deletions: type === "add" ? 0 : seed % 4,
patch: patch(seed, 520),
before: type === "add" ? undefined : code(seed, 18),
after: type === "delete" ? undefined : code(seed + 1, 24),
} }
} }
function fileDiff(file: string, seed: number) { function fileDiff(file: string, seed: number) {
const before = code(seed, 32)
const after = code(seed + 1, 38)
return { return {
file, file,
status: "modified" as const,
additions: (seed % 9) + 1, additions: (seed % 9) + 1,
deletions: seed % 4, deletions: seed % 4,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after), before: code(seed, 32),
after: code(seed + 1, 38),
} }
} }
function patch(seed: number, length: number) {
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
}
function code(seed: number, lines: number) { function code(seed: number, lines: number) {
return Array.from({ length: lines }, (_, index) => `export const value${index} = "${lorem(seed + index, 32)}"`).join( return Array.from({ length: lines }, (_, index) => `export const value${index} = "${lorem(seed + index, 32)}"`).join(
"\n", "\n",
@@ -206,23 +205,21 @@ function turn(index: number): SessionMessageInfo[] {
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []), ...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
...(index % 3 === 0 ...(index % 3 === 0
? [ ? [
toolPart(index, 0, "read", { path: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220), toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140), toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180), toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120), toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
] ]
: []), : []),
textPart(index, 2, 160 + (index % 6) * 90), textPart(index, 2, 160 + (index % 6) * 90),
...(index % 4 === 0 ...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
? [toolPart(index, 3, "edit", { path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" }, 700)]
: []),
...(index % 6 === 0 ...(index % 6 === 0
? [toolPart(index, 7, "write", { path: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)] ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []), : []),
...(index % 8 === 0 ...(index % 8 === 0
? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)] ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []), : []),
...(index % 7 === 0 ? [toolPart(index, 4, "shell", { command: "bun typecheck" }, 620)] : []), ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), ...(index % 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 % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
...(index % 13 === 0 ...(index % 13 === 0
@@ -237,15 +234,7 @@ function turn(index: number): SessionMessageInfo[] {
] ]
: []), : []),
...(index % 17 === 0 ...(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)] return [user, assistantMessage(targetID, index, user.id, parts)]
@@ -322,7 +311,7 @@ export const fixture = {
targetPartIDs: targetMessages.flatMap(currentPartIDs), targetPartIDs: targetMessages.flatMap(currentPartIDs),
expandedShellPartID: targetMessages expandedShellPartID: targetMessages
.flatMap((message) => (message.type === "assistant" ? message.content : [])) .flatMap((message) => (message.type === "assistant" ? message.content : []))
.flatMap((part) => (part.type === "tool" && part.name === "shell" ? [part.id] : []))[0], .flatMap((part) => (part.type === "tool" && part.name === "bash" ? [part.id] : []))[0],
}, },
} }
@@ -518,7 +518,7 @@ async function expectCanScrollToStart(
let current = await timelineState(page) let current = await timelineState(page)
let unchangedAtTop = 0 let unchangedAtTop = 0
for (let attempt = 0; attempt < 800; attempt++) { for (let attempt = 0; attempt < 600; attempt++) {
collectSeen(current, seenParts, seenMessages) collectSeen(current, seenParts, seenMessages)
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size)) samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText) expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText)
+20 -1
View File
@@ -1,5 +1,7 @@
import "@/index.css" import "@/index.css"
import * as Sentry from "@sentry/solid" 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 { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { File } from "@opencode-ai/session-ui/file" import { File } from "@opencode-ai/session-ui/file"
@@ -24,7 +26,7 @@ import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
import { GlobalProvider, useGlobal } from "@/context/global" import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights" import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, UiI18nBridge, type Locale, useLanguage } from "@/context/language" import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout" import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models" import { ModelsProvider } from "@/context/models"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
@@ -99,6 +101,23 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
) )
} }
function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return (
<I18nProvider
value={{
locale: language.intl,
layoutLocale: language.layoutLocale,
t: language.t as UiI18n["t"],
plural: language.plural,
pluralForm: language.pluralForm,
}}
>
{props.children}
</I18nProvider>
)
}
declare global { declare global {
interface Window { interface Window {
__OPENCODE__?: { __OPENCODE__?: {
@@ -0,0 +1,125 @@
// @ts-nocheck
import { createStore } from "solid-js/store"
import { createPromptState } from "@/context/prompt"
import { createPromptInputHistory, PromptInput } from "./prompt-input"
function createPromptInputStoryRuntime() {
const state = createPromptState()
return {
state,
history: createPromptInputHistory(),
submission: {
abort() {},
handleSubmit(event: Event) {
event.preventDefault()
state.reset()
},
},
}
}
function PromptInputExample() {
const input = createPromptInputStoryRuntime()
const [controls, setControls] = createStore({
agent: "build",
variant: undefined as string | undefined,
comments: 0,
tabs: [] as string[],
activeTab: undefined as string | undefined,
reviewOpen: false,
})
const storyModel = {
id: "claude-3-7-sonnet",
name: "Claude 3.7 Sonnet",
provider: { id: "anthropic", name: "Anthropic" },
}
const model = {
current: () => storyModel,
list: () => [storyModel],
visible: () => true,
set: () => {},
variant: {
list: () => ["fast", "thinking"],
current: () => controls.variant,
set: (variant?: string) => setControls("variant", variant),
},
}
const inputControls = {
agents: {
available: [{ name: "review", hidden: false, mode: "subagent" }],
options: ["build", "review", "plan"],
get current() {
return controls.agent
},
loading: false,
visible: true,
select: (agent?: string) => setControls("agent", agent ?? "build"),
},
model: {
selection: model,
paid: true,
loading: false,
},
session: {
id: "story-session",
tabs: {
active: () => controls.activeTab,
all: () => controls.tabs,
open: (tab: string) => setControls("tabs", (tabs) => (tabs.includes(tab) ? tabs : [...tabs, tab])),
setActive: (tab: string) => setControls("activeTab", tab),
},
reviewPanel: {
opened: () => controls.reviewOpen,
open: () => setControls("reviewOpen", true),
},
},
}
const addReviewComment = () => {
const comment = controls.comments + 1
setControls("comments", comment)
input.state.context.add({
type: "file",
path: "src/components/prompt-input.tsx",
selection: {
startLine: 84 + comment,
startChar: 0,
endLine: 84 + comment,
endChar: 0,
},
comment: `Review comment ${comment}`,
commentID: `review-comment-${comment}`,
commentOrigin: "review",
preview: "export const PromptInput = ...",
})
}
return (
<div class="flex flex-col gap-3">
<PromptInput controls={inputControls} {...input} />
<div>
<button
type="button"
class="rounded-md border border-border-weak-base bg-background-base px-2.5 py-1.5 text-12-medium text-text-base hover:bg-background-stronger"
onClick={addReviewComment}
>
Add review comment
</button>
</div>
</div>
)
}
export default {
title: "App/PromptInput",
id: "app-prompt-input",
component: PromptInput,
}
export const Basic = {
render: () => (
<div class="pt-10">
<h1 class="mb-4">Prompt Input</h1>
<PromptInputExample />
</div>
),
}
@@ -235,9 +235,6 @@ beforeAll(async () => {
session: { session: {
remember: () => undefined, remember: () => undefined,
setStatus: () => 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: { location: {
info: () => ({ project: { id: "project", directory: "/repo/main" } }), info: () => ({ project: { id: "project", directory: "/repo/main" } }),
@@ -417,9 +414,7 @@ describe("prompt submit worktree selection", () => {
model: { providerID: "provider", modelID: "model", variant: "high" }, model: { providerID: "provider", modelID: "model", variant: "high" },
}, },
}) })
// ID minting is delegated to the data layer, which mints a client ID when expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
// 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 () => { 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 { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useWorkspaceLocation } from "@/context/location" import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk" import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { SessionMessage } from "@opencode-ai/schema/session-message" import { Identifier } from "@/utils/id"
import { getDirectory } from "@opencode-ai/util/path" import { getDirectory } from "@opencode-ai/util/path"
import { buildPromptRequest } from "./build-prompt-request" import { buildPromptRequest } from "./build-prompt-request"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
@@ -40,6 +40,7 @@ type FollowupSendInput = {
data: Data data: Data
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined> session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
draft: FollowupDraft draft: FollowupDraft
messageID?: string
optimisticBusy?: boolean optimisticBusy?: boolean
} }
@@ -68,9 +69,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
) { ) {
setBusy() setBusy()
try { try {
const messageID = Identifier.ascending("message")
await input.api.command({ await input.api.command({
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
id: SessionMessage.ID.create(), id: messageID,
command: cmd, command: cmd,
arguments: tail.join(" "), arguments: tail.join(" "),
agent: input.draft.agent, agent: input.draft.agent,
@@ -93,6 +95,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
} }
} }
const messageID = input.messageID ?? Identifier.ascending("message")
const encodedImages = await Promise.all( const encodedImages = await Promise.all(
images.map(async (attachment) => ({ images.map(async (attachment) => ({
...attachment, ...attachment,
@@ -129,10 +132,9 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}) })
} }
// The data layer admits optimistically under a client-minted ID: the await input.api.prompt({
// prompt renders immediately and rolls back if the server rejects it.
await input.data.session.prompt({
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
id: messageID,
text: request.text, text: request.text,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })), files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents, agents: request.agents,
@@ -444,11 +446,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
?.find((command) => command.name === commandName) ?.find((command) => command.name === commandName)
if (customCommand) { if (customCommand) {
clearInput() clearInput()
const messageID = Identifier.ascending("message")
submissionData.session.setStatus(session.id, "running") submissionData.session.setStatus(session.id, "running")
void submissionServerSDK.api.session void submissionServerSDK.api.session
.command({ .command({
sessionID: session.id, sessionID: session.id,
id: SessionMessage.ID.create(), id: messageID,
command: commandName, command: commandName,
arguments: args.join(" "), arguments: args.join(" "),
agent, agent,
@@ -473,6 +476,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) 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) for (const item of commentItems) submission.target().context.remove(item.key)
clearInput() clearInput()
@@ -482,6 +486,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
data: submissionData, data: submissionData,
session: () => session, session: () => session,
draft, draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory, optimisticBusy: sessionDirectory === projectDirectory,
}).catch((err) => { }).catch((err) => {
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
@@ -1,55 +0,0 @@
import { Show, type JSX } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Tooltip } from "@opencode-ai/ui/tooltip"
export type SessionHeaderV2ActionsState = {
status?: { label: string; content: () => JSX.Element }
reviewLabel: string
reviewKeybind: string[]
reviewVisible: boolean
reviewOpened: boolean
onReviewToggle: () => void
}
export function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
return (
<div class="flex items-center gap-2">
<Show when={props.state.status}>
{(status) => (
<Tooltip appearance="standard" placement="bottom" value={status().label}>
{status().content()}
</Tooltip>
)}
</Show>
<Show when={props.state.reviewVisible}>
<Tooltip
class="shrink-0"
placement="bottom"
value={
<>
{props.state.reviewLabel}
<Show when={props.state.reviewKeybind.length > 0}>
<Keybind keys={props.state.reviewKeybind} variant="neutral" />
</Show>
</>
}
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
state={props.state.reviewOpened ? "pressed" : undefined}
onClick={props.state.onReviewToggle}
aria-label={props.state.reviewLabel}
aria-expanded={props.state.reviewOpened}
aria-controls="review-panel"
icon={<Icon name="sidebar-right" />}
/>
</Tooltip>
</Show>
</div>
)
}
@@ -6,9 +6,12 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { StatusPopoverV2 } from "../status-popover" 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 { reviewTooltipKeybind } from "../command-tooltip-keybind"
import { useTitlebarRightMount } from "../titlebar" import { useTitlebarRightMount } from "../titlebar"
import { SessionHeaderV2Actions, type SessionHeaderV2ActionsState } from "./session-header-actions"
export function SessionHeader() { export function SessionHeader() {
const command = useCommand() const command = useCommand()
@@ -20,7 +23,8 @@ export function SessionHeader() {
const isDesktop = createMediaQuery("(min-width: 768px)") const isDesktop = createMediaQuery("(min-width: 768px)")
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({ const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
status: status() ? { label: language.t("status.popover.trigger"), content: () => <StatusPopoverV2 /> } : undefined, statusVisible: status(),
statusLabel: language.t("status.popover.trigger"),
reviewLabel: language.t("command.review.toggle"), reviewLabel: language.t("command.review.toggle"),
reviewKeybind: reviewTooltipKeybind(command), reviewKeybind: reviewTooltipKeybind(command),
reviewVisible: isDesktop(), reviewVisible: isDesktop(),
@@ -40,3 +44,52 @@ export function SessionHeader() {
</Show> </Show>
) )
} }
type SessionHeaderV2ActionsState = {
statusVisible: boolean
statusLabel: string
reviewLabel: string
reviewKeybind: string[]
reviewVisible: boolean
reviewOpened: boolean
onReviewToggle: () => void
}
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
return (
<div class="flex items-center gap-2">
<Show when={props.state.statusVisible}>
<Tooltip appearance="standard" placement="bottom" value={props.state.statusLabel}>
<StatusPopoverV2 />
</Tooltip>
</Show>
<Show when={props.state.reviewVisible}>
<Tooltip
class="shrink-0"
placement="bottom"
value={
<>
{props.state.reviewLabel}
<Show when={props.state.reviewKeybind.length > 0}>
<Keybind keys={props.state.reviewKeybind} variant="neutral" />
</Show>
</>
}
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
state={props.state.reviewOpened ? "pressed" : undefined}
onClick={props.state.onReviewToggle}
aria-label={props.state.reviewLabel}
aria-expanded={props.state.reviewOpened}
aria-controls="review-panel"
icon={<Icon name="sidebar-right" />}
/>
</Tooltip>
</Show>
</div>
)
}
+1 -20
View File
@@ -1,10 +1,8 @@
import * as i18n from "@solid-primitives/i18n" import * as i18n from "@solid-primitives/i18n"
import { createEffect, createMemo, createResource, type JSX } from "solid-js" import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { import {
I18nProvider,
type UiI18n,
pluralCategory, pluralCategory,
type UiI18nPluralLookupKey, type UiI18nPluralLookupKey,
type UiI18nPluralKey, type UiI18nPluralKey,
@@ -262,20 +260,3 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
} }
}, },
}) })
export function UiI18nBridge(props: { children?: JSX.Element }) {
const language = useLanguage()
return (
<I18nProvider
value={{
locale: language.intl,
layoutLocale: language.layoutLocale,
t: language.t as UiI18n["t"],
plural: language.plural,
pluralForm: language.pluralForm,
}}
>
{props.children}
</I18nProvider>
)
}
-10
View File
@@ -1,10 +0,0 @@
export const popularProviders = [
"opencode",
"opencode-go",
"anthropic",
"github-copilot",
"openai",
"google",
"openrouter",
"vercel",
]
+10 -2
View File
@@ -5,9 +5,17 @@ import { Iterable, pipe } from "effect"
import { createEffect, createMemo, type Accessor } from "solid-js" import { createEffect, createMemo, type Accessor } from "solid-js"
import { emptyProviderCatalog } from "./provider-catalog" import { emptyProviderCatalog } from "./provider-catalog"
import { useIntegrations } from "./use-integrations" import { useIntegrations } from "./use-integrations"
import { popularProviders } from "./provider-order"
export { popularProviders } from "./provider-order" export const popularProviders = [
"opencode",
"opencode-go",
"anthropic",
"github-copilot",
"openai",
"google",
"openrouter",
"vercel",
]
const popularProviderSet = new Set(popularProviders) const popularProviderSet = new Set(popularProviders)
export function useProviders(directory: Accessor<string | undefined>) { export function useProviders(directory: Accessor<string | undefined>) {
+29 -10
View File
@@ -1,5 +1,5 @@
import type { FilePart } from "@/types"
import type { FileDiffInfo, SessionMessageUser } from "@opencode-ai/client/promise" import type { FileDiffInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import type { SessionUserActions } from "@opencode-ai/session-ui/message"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
@@ -81,7 +81,6 @@ import {
} from "@/pages/session/session-panel-width" } from "@/pages/session/session-panel-width"
import { SessionSidePanel } from "@/pages/session/session-side-panel" import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { sessionPanelLayout } from "@/pages/session/session-panel-layout" 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 { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2" import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2" import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
@@ -91,7 +90,7 @@ import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2"
import { useComposerCommands } from "@/pages/session/use-composer-commands" import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { SessionMessage } from "@opencode-ai/schema/session-message" import { Identifier } from "@/utils/id"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { requireServerKey, sessionHref } from "@/utils/session-route" import { requireServerKey, sessionHref } from "@/utils/session-route"
@@ -265,6 +264,27 @@ function MarkSessionNotificationsViewed(props: { sessionID?: () => string | unde
return null 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() { export default function Page() {
const data = useData() const data = useData()
const layout = useLayout() const layout = useLayout()
@@ -1544,7 +1564,7 @@ export default function Page() {
const queueFollowup = (draft: FollowupDraft) => { const queueFollowup = (draft: FollowupDraft) => {
setFollowup("items", draft.sessionID, (items) => [ setFollowup("items", draft.sessionID, (items) => [
...(items ?? []), ...(items ?? []),
{ id: SessionMessage.ID.create(), ...draft }, { id: Identifier.ascending("message"), ...draft },
]) ])
setFollowup("failed", draft.sessionID, undefined) setFollowup("failed", draft.sessionID, undefined)
setFollowup("paused", draft.sessionID, undefined) setFollowup("paused", draft.sessionID, undefined)
@@ -1593,15 +1613,14 @@ export default function Page() {
// attachment bytes are embedded as a data URL, so downloading always works; // 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 // revealing requires the on-disk path captured by the client that attached the file
const openAttachment: NonNullable<SessionUserActions["openAttachment"]> = (file) => { const openAttachment = (file: FilePart) => {
const url = file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`
const download = () => { const download = () => {
const anchor = document.createElement("a") const anchor = document.createElement("a")
anchor.href = url anchor.href = file.url
anchor.download = getFilename(file.name) || "attachment" anchor.download = getFilename(file.filename) || "attachment"
anchor.click() anchor.click()
} }
const path = file.name ?? "" const path = file.filename ?? ""
const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path) const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path)
if (platform.revealPath && absolute) { if (platform.revealPath && absolute) {
void platform.revealPath(path).then( void platform.revealPath(path).then(
@@ -1615,7 +1634,7 @@ export default function Page() {
download() download()
} }
const actions = { revert, openAttachment } satisfies SessionUserActions const actions = { revert, openAttachment }
createEffect(() => { createEffect(() => {
const sessionID = controller.identity.params.id const sessionID = controller.identity.params.id
@@ -7,32 +7,8 @@ import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import { SessionBackgroundDock } from "@/pages/session/composer/session-background-dock" import { SessionBackgroundDock } from "@/pages/session/composer/session-background-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller" 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: { export function SessionComposerRegion(props: {
controller: SessionComposerRegionViewController controller: SessionComposerRegionController
promptInput: JSX.Element promptInput: JSX.Element
}) { }) {
const language = useLanguage() const language = useLanguage()
@@ -1,22 +0,0 @@
import type { ParentProps } from "solid-js"
export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
return (
<div class="relative flex size-full flex-col overflow-hidden" classList={{ "p-2": props.padded }}>
{props.children}
</div>
)
}
export function SessionPanelFrame(props: ParentProps<{ raised?: boolean }>) {
return (
<div
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] bg-v2-background-bg-base"
classList={{
"shadow-[var(--v2-elevation-raised)]": props.raised,
}}
>
{props.children}
</div>
)
}
@@ -1,157 +0,0 @@
import {
activePermissionRequest,
activeQuestionRequest,
attachmentsAndCommentsDocument,
editThenTestDocument,
emptySessionDocument,
largeCompletedDocument,
pendingAndQueuedDocument,
permissionPendingDocument,
questionPendingDocument,
queuedPrompts,
recoveryDocument,
thinkingDocument,
} from "@opencode-ai/session-ui/storybook"
import { SessionPreview } from "./session-preview"
const description = "opencode · modular-session-ui"
const implementAndVerify = () => (
<SessionPreview
title="Update active Session status"
description={description}
document={editThenTestDocument}
draft="Add a browser assertion for the updated status"
/>
)
export default {
title: "OpenCode/Session/Complete workspace",
id: "app-current-session-surface",
component: SessionPreview,
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"A server-free Session workbench for product and design review. It composes the production current timeline, titlebar actions, composer region, prompt input, request docks, queue, and review components.",
},
},
},
}
export const StartACodingTask = {
render: () => (
<SessionPreview
title="New Session"
description={description}
document={emptySessionDocument}
draft="Find why the Session header shifts after the first streamed response"
/>
),
}
export const AgentIsThinking = {
render: () => (
<SessionPreview title="Fix Session header shift" description={description} document={thinkingDocument} />
),
}
export const ImplementAndVerifyLight = {
globals: { theme: "light" },
render: implementAndVerify,
}
export const ImplementAndVerifyDark = {
globals: { theme: "dark" },
render: implementAndVerify,
}
export const QueueAFollowUp = {
render: () => (
<SessionPreview
title="Add deterministic Session stories"
description={description}
document={pendingAndQueuedDocument}
followups={queuedPrompts}
backgroundTasks={[{ id: "task_storybook", type: "subagent", label: "Review the current Storybook scenarios" }]}
/>
),
}
export const PermissionRequired = {
render: () => (
<SessionPreview
title="Publish canary preview"
description={description}
document={permissionPendingDocument}
request={{ type: "permission", value: activePermissionRequest }}
/>
),
}
export const AnswerAProductQuestion = {
render: () => (
<SessionPreview
title="Add the Session review panel"
description={description}
document={questionPendingDocument}
request={{ type: "question", value: activeQuestionRequest }}
/>
),
}
export const ReviewChanges = {
render: () => (
<SessionPreview
title="Update active Session status"
description={description}
document={editThenTestDocument}
reviewOpened
/>
),
}
export const RecoverFromAFailedTest = {
render: () => (
<SessionPreview
title="Keep tool disclosure stable"
description={description}
document={recoveryDocument}
draft="Also run the App browser test"
/>
),
}
export const WorkFromAttachments = {
render: () => (
<SessionPreview
title="Fix narrow Session spacing"
description={description}
document={attachmentsAndCommentsDocument}
draft="Verify the same layout at 360 px"
/>
),
}
export const LongRunningSession = {
render: () => (
<SessionPreview
title="Modularize Session rendering"
description={description}
document={largeCompletedDocument}
draft="Summarize the remaining verification"
/>
),
}
export const MixedDirectionRtl = {
globals: { theme: "dark", direction: "rtl" },
render: () => (
<SessionPreview
title="مراجعة واجهة Session"
description="opencode · packages/app/src/session.tsx"
document={attachmentsAndCommentsDocument}
draft="راجع المسار packages/app/src/session.tsx ثم شغّل bun test"
/>
),
}
@@ -1,352 +0,0 @@
import type { ModelSelection } from "@/context/local"
import { PromptInputV2Composer, type PromptInputV2ComposerController } from "@/components/prompt-input-v2"
import { SessionHeaderV2Actions } from "@/components/session/session-header-actions"
import {
SessionComposerRegion,
type SessionComposerRegionViewController,
} from "@/pages/session/composer/session-composer-region"
import { SessionPanelFrame, SessionRouteFrame } from "@/pages/session/session-frame"
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { SessionDocument } from "@opencode-ai/session-ui/document"
import { CurrentSessionProviders, STORY_MODEL } from "@opencode-ai/session-ui/storybook"
import { SessionTimeline } from "@opencode-ai/session-ui/timeline"
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
import { createPromptInputV2Controller } from "@opencode-ai/session-ui/v2/prompt-input/interaction"
import type { PromptInputV2PersistedState } from "@opencode-ai/session-ui/v2/prompt-input/types"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { ReviewPanelV2View } from "@/pages/session/v2/review-panel-v2"
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
const modelReady = Object.assign(() => true, { promise: undefined }) satisfies ModelSelection["ready"]
const storyComposerModel = {
id: STORY_MODEL.id,
providerID: STORY_MODEL.providerID,
api: { id: STORY_MODEL.id, url: "https://api.anthropic.com", npm: "@ai-sdk/anthropic" },
name: "Claude Sonnet 4",
family: "claude-sonnet",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: true,
},
cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } },
limit: { context: 200_000, output: 64_000 },
status: "active",
options: {},
headers: {},
release_date: "2025-05-22",
variants: { balanced: {} },
provider: {
id: STORY_MODEL.providerID,
name: "Anthropic",
source: "custom",
env: [],
options: {},
models: {},
},
latest: true,
} satisfies NonNullable<ReturnType<ModelSelection["current"]>>
const modelSelection = {
ready: modelReady,
current: () => storyComposerModel,
recent: () => [storyComposerModel],
list: () => [storyComposerModel],
cycle() {},
set() {},
visible: () => true,
setVisibility() {},
variant: {
configured: () => STORY_MODEL.variant,
selected: () => STORY_MODEL.variant,
current: () => STORY_MODEL.variant,
list: () => [STORY_MODEL.variant],
set() {},
cycle() {},
},
} satisfies ModelSelection
export type SessionPreviewProps = {
title: string
description: string
document: SessionDocument
draft?: string
followups?: { id: string; text: string }[]
request?: { type: "permission"; value: PermissionRequest } | { type: "question"; value: FormInfo }
reviewOpened?: boolean
backgroundTasks?: { id: string; type: "shell" | "subagent"; label: string }[]
}
export function SessionPreview(props: SessionPreviewProps) {
const [state, setState] = createStore({ revision: 1 })
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
return (
<QueryClientProvider client={queryClient}>
<Show when={state.revision} keyed>
{(revision) => (
<div data-story-revision={revision}>
<SessionSurfaceState
{...props}
request={
props.request?.type === "question"
? {
type: "question",
value: { ...props.request.value, id: `${props.request.value.id}:${revision}` },
}
: props.request
}
onReset={() => setState("revision", (value) => value + 1)}
/>
</div>
)}
</Show>
</QueryClientProvider>
)
}
function createPromptController(input: {
initial: string
placeholder: string
status: () => SessionStatus
onActivity: (activity: string) => void
onSubmit: (text: string) => void
onStop: () => void
}) {
const draft = createStore<PromptInputV2PersistedState>({
prompt: [{ type: "text", content: input.initial, start: 0, end: input.initial.length }],
cursor: input.initial.length,
model: { providerID: STORY_MODEL.providerID, modelID: STORY_MODEL.id, variant: STORY_MODEL.variant },
context: { items: [] },
})
const interaction = createPromptInputV2Controller({
store: draft,
commands: () => [],
context: () => [],
searchContextFiles: () => [],
view: {
placeholder: () => input.placeholder,
add: { onAttach: () => input.onActivity("Opened the local attachment picker") },
submit: {
stopping: () => false,
working: () => input.status().type !== "idle",
onSubmit: () => {
const value = interaction.value().trim()
if (!value) return
input.onSubmit(value)
draft[1]("prompt", [{ type: "text", content: "", start: 0, end: 0 }])
draft[1]("cursor", 0)
},
onStop: input.onStop,
},
shell: {
onOpen: () => input.onActivity("Changed the composer to shell mode"),
onClose: () => input.onActivity("Changed the composer to prompt mode"),
},
},
})
return {
controller: {
...interaction,
model: { selection: modelSelection, paid: true, loading: false },
} satisfies PromptInputV2ComposerController,
setValue(value: string) {
draft[1]("prompt", [{ type: "text", content: value, start: 0, end: value.length }])
draft[1]("cursor", value.length)
},
}
}
function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void }) {
const language = useLanguage()
const [state, setState] = createStore<{
activity: string
reviewOpened: boolean
followups: { id: string; text: string }[]
request: SessionPreviewProps["request"]
}>({
activity: "Ready",
reviewOpened: props.reviewOpened ?? false,
followups: props.followups?.map((item) => ({ ...item })) ?? [],
request: props.request,
})
const prompt = createPromptController({
initial: props.draft ?? "",
placeholder: language.t("prompt.placeholder.normal"),
status: () => props.document.status,
onActivity: (activity) => setState("activity", activity),
onSubmit: (text) => setState("activity", `Submitted locally: ${text}`),
onStop: () => setState("activity", "Requested a local stop"),
})
const removeFollowup = (id: string) => setState("followups", (items) => items.filter((item) => item.id !== id))
const region = {
state: {
questionRequest: () => (state.request?.type === "question" ? state.request.value : undefined),
permissionRequest: () => (state.request?.type === "permission" ? state.request.value : undefined),
permissionResponding: () => false,
decide: (response) => {
setState("request", undefined)
setState("activity", `Permission response: ${response}`)
},
background: {
blocking: () => [],
tasks: () => props.backgroundTasks ?? [],
move: async () => {
setState("activity", "Requested background execution")
},
},
blocked: () => state.request !== undefined,
},
centered: () => true,
followup: () =>
state.followups.length
? {
items: state.followups,
onSend: (id: string) => {
removeFollowup(id)
setState("activity", "Requested immediate delivery for the queued message")
},
onEdit: (id: string) => {
const item = state.followups.find((value) => value.id === id)
if (item) prompt.setValue(item.text)
removeFollowup(id)
setState("activity", "Moved the queued message into the composer")
},
}
: undefined,
revert: () => undefined,
onResponseSubmit: () => {
setState("request", undefined)
setState("activity", "Submitted the answer locally")
},
openParent: () => setState("activity", "Opened the parent Session locally"),
setPromptRef() {},
setDockRef() {},
parentID: () => undefined,
child: () => false,
showComposer: () => true,
handoffPrompt: () => undefined,
promptReady: () => true,
lift: () => 0,
} satisfies SessionComposerRegionViewController
return (
<div class="mx-auto h-screen min-h-[640px] w-full max-w-[1440px]">
<SessionRouteFrame padded>
<SessionPanelFrame raised>
<main class="flex min-h-0 flex-1 flex-col">
<SessionSurfaceHeader
title={props.title}
description={props.description}
reviewVisible
reviewOpened={state.reviewOpened}
onReviewToggle={() => setState("reviewOpened", (value) => !value)}
onReset={props.onReset}
/>
<CurrentSessionProviders document={props.document}>
<div class="flex min-h-0 flex-1">
<section
classList={{
"min-w-0 flex-1 flex-col bg-background-base": true,
flex: !state.reviewOpened,
"hidden md:flex": state.reviewOpened,
}}
>
<div class="min-h-0 flex-1 overflow-y-auto py-6">
<SessionTimeline
document={props.document}
editToolDefaultOpen
shellToolDefaultOpen
class="mx-auto w-full max-w-[840px]"
/>
</div>
<SessionComposerRegion
controller={region}
promptInput={<PromptInputV2Composer controller={prompt.controller} borderUnderlay />}
/>
</section>
<Show when={state.reviewOpened}>
<aside id="review-panel" class="min-w-0 flex-1 border-l border-border-weak-base md:max-w-[52%]">
<SessionReviewPane diffs={props.document.diffs} />
</aside>
</Show>
</div>
</CurrentSessionProviders>
<output class="sr-only" aria-live="polite">
{state.activity}
</output>
</main>
</SessionPanelFrame>
</SessionRouteFrame>
</div>
)
}
function SessionSurfaceHeader(props: {
title: string
description: string
reviewVisible: boolean
reviewOpened: boolean
onReviewToggle: () => void
onReset: () => void
}) {
const language = useLanguage()
return (
<header class="flex min-h-14 shrink-0 items-center justify-between gap-4 border-b border-border-weak-base px-4 py-2">
<div class="flex min-w-0 items-center gap-3">
<span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-background-stronger text-icon-base">
<Icon name="folder" />
</span>
<div class="min-w-0">
<h1 class="truncate text-14-medium text-text-strong">{props.title}</h1>
<p class="truncate text-12-regular text-text-weak">{props.description}</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<SessionHeaderV2Actions
state={{
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: [],
reviewVisible: props.reviewVisible,
reviewOpened: props.reviewOpened,
onReviewToggle: props.onReviewToggle,
}}
/>
<Button size="small" variant="neutral" onClick={props.onReset}>
Reset
</Button>
</div>
</header>
)
}
function SessionReviewPane(props: { diffs: SessionDocument["diffs"] }) {
const language = useLanguage()
const review = createReviewPanelV2State()
const [state, setState] = createStore({
active: props.diffs[0]?.file,
diffStyle: "unified" as "unified" | "split",
})
return (
<ReviewPanelV2View
title={language.t("ui.sessionReview.title.lastTurn")}
empty={<SessionReviewEmptyChangesV2 />}
diffs={props.diffs}
diffsReady
activeFile={state.active}
onSelectFile={(file) => setState("active", file)}
diffStyle={state.diffStyle}
onDiffStyleChange={(value) => setState("diffStyle", value)}
state={review}
fileList="flat"
/>
)
}
@@ -0,0 +1,115 @@
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import {
ContextToolGroup,
Message,
Part as MessagePart,
partDefaultOpen,
type UserActions,
} from "@opencode-ai/session-ui/message-part"
import type { ToolPart } from "@/types"
import {
presentAssistantMessage,
presentAssistantContent,
presentUserMessage,
presentUserParts,
} from "@/utils/session-message"
import { createMemo, Show } from "solid-js"
export function CurrentUserMessage(props: {
sessionID: string
message: SessionMessageUser
agent: string
model: { id: string; providerID: string; variant?: string }
actions?: UserActions
useV2Actions?: boolean
comments?: { path: string; comment: string; selection?: { startLine: number; endLine: number } }[]
}) {
const message = createMemo(() => presentUserMessage(props.sessionID, props.message, props.agent, props.model))
const parts = createMemo(() => presentUserParts(props.sessionID, props.message))
return (
<Message
message={message()}
parts={parts()}
actions={props.actions}
useV2Actions={props.useV2Actions}
comments={props.comments}
/>
)
}
export function CurrentAssistantContent(props: {
sessionID: string
parentID: string
message: SessionMessageAssistant
content: SessionMessageAssistant["content"][number]
contentID: string
showAssistantCopyPartID?: string | null
turnDurationMs?: number
useV2Actions?: boolean
defaultOpen?: boolean
toolOpen?: boolean
onToolOpenChange?: (open: boolean) => void
onContentRendered?: () => void
}) {
const message = createMemo(() => presentAssistantMessage(props.sessionID, props.parentID, props.message))
const part = createMemo(() => presentAssistantContent(props.sessionID, props.message, props.contentID, props.content))
return (
<Show when={part()}>
{(part) => (
<MessagePart
part={part()}
message={message()}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
useV2Actions={props.useV2Actions}
defaultOpen={props.defaultOpen}
toolOpen={props.toolOpen}
onToolOpenChange={props.onToolOpenChange}
deferToolContent
virtualizeDiff={false}
onContentRendered={props.onContentRendered}
/>
)}
</Show>
)
}
export function CurrentContextToolGroup(props: {
sessionID: string
tools: { message: SessionMessageAssistant; content: SessionMessageAssistantTool; contentID: string }[]
open: boolean
busy: boolean
onOpenChange: (open: boolean) => void
onSizeChange?: () => void
}) {
const parts = createMemo(() =>
props.tools.flatMap(({ message, content, contentID }): ToolPart[] => {
const part = presentAssistantContent(props.sessionID, message, contentID, content)
return part?.type === "tool" ? [part] : []
}),
)
return (
<ContextToolGroup
parts={parts()}
open={props.open}
onOpenChange={props.onOpenChange}
busy={props.busy}
onSizeChange={props.onSizeChange}
/>
)
}
export function currentPartDefaultOpen(
sessionID: string,
message: SessionMessageAssistant,
content: SessionMessageAssistant["content"][number],
contentID: string,
shellExpanded: boolean,
editExpanded: boolean,
) {
return partDefaultOpen(presentAssistantContent(sessionID, message, contentID, content), shellExpanded, editExpanded)
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { ModelRef, SessionMessageInfo } from "@opencode-ai/client/promise" import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { createTimelineProjection, reuseTimelineRows, TimelineRow, type PartGroup } from "./projection" import { reuseTimelineRows } from "./row-reconciliation"
import { TimelineRow } from "./timeline-row"
const context = (key: string, partIDs: string[], userMessageID = "user-1") => const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
new TimelineRow.AssistantPart({ new TimelineRow.AssistantPart({
@@ -93,83 +94,3 @@ describe("reuseTimelineRows", () => {
reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex])) reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex]))
}) })
}) })
describe("createTimelineProjection", () => {
test("builds current message, parent, context, and row indexes", () => {
const selectedModel = { id: "selected", providerID: "provider" } satisfies ModelRef
const assistantModel = { id: "assistant", providerID: "provider", variant: "fast" } satisfies ModelRef
const messages = [
{ id: "agent", type: "agent-switched", agent: "explore", time: { created: 1 } },
{ id: "model", type: "model-switched", model: selectedModel, time: { created: 2 } },
{ id: "user-1", type: "user", text: "first", time: { created: 3 } },
{
id: "assistant-1",
type: "assistant",
agent: "build",
model: assistantModel,
content: [{ type: "text", text: "answer" }],
time: { created: 4, completed: 5 },
},
{
id: "user-2",
type: "user",
text: "second",
metadata: {
agent: "review",
model: { modelID: "override", providerID: "custom", variant: "precise" },
},
time: { created: 6 },
},
] satisfies SessionMessageInfo[]
const result = createTimelineProjection({
sessionMessages: messages,
status: { type: "busy" },
showReasoningSummaries: true,
})
expect(result.activeMessageID).toBe("user-2")
expect(result.messageByID).toBe(result.sessionMessageByID)
expect(result.sessionMessageByID.get("assistant-1")).toBe(messages[3])
expect(result.assistantMessagesByParent.get("user-1")?.map((message) => message.id)).toEqual(["assistant-1"])
expect(result.assistantMessagesByParent.has("user-2")).toBe(false)
expect(result.userContextByID.get("user-1")).toEqual({ agent: "build", model: assistantModel })
expect(result.userContextByID.get("user-2")).toEqual({
agent: "review",
model: { id: "override", providerID: "custom", variant: "precise" },
})
expect(result.messageRowIndex.get("user-1")).toBe(0)
expect(result.messageLastRowIndex.get("user-1")).toBe(3)
expect(result.lastAssistantGroupKey.get("user-1")).toBe("part:assistant-1:assistant-1:text:0")
expect(result.rowByKey.get("user-message:user-1")).toBe(result.rows[2])
})
test("reuses a stable projected row array", () => {
const messages = [
{ id: "user-1", type: "user", text: "first", time: { created: 1 } },
{
id: "assistant-1",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "answer" }],
time: { created: 2, completed: 3 },
},
] satisfies SessionMessageInfo[]
const first = createTimelineProjection({
sessionMessages: messages,
status: { type: "idle" },
showReasoningSummaries: true,
})
const second = createTimelineProjection({
sessionMessages: messages,
status: { type: "idle" },
showReasoningSummaries: true,
previousRows: first.rows,
})
expect(second.rows).toBe(first.rows)
expect(second.rows[0]).toBe(first.rows[0])
expect(second.rows[1]).toBe(first.rows[1])
})
})
@@ -1,8 +1,9 @@
import type { ModelRef, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise" import 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 { createMemo, type Accessor } from "solid-js"
import { reuseTimelineRows } from "./row-reconciliation"
import { Timeline, TimelineRow } from "./rows"
export { reuseTimelineRows } from "@opencode-ai/session-ui/timeline/projection" export { reuseTimelineRows } from "./row-reconciliation"
export function createTimelineProjection(input: { export function createTimelineProjection(input: {
sessionMessages: Accessor<SessionMessageInfo[]> sessionMessages: Accessor<SessionMessageInfo[]>
@@ -74,7 +75,7 @@ export function createTimelineProjection(input: {
return result return result
}) })
const projection = createMemo(() => const projection = createMemo(() =>
Timeline.constructSessionMessageRows(input.sessionMessages(), input.showReasoningSummaries(), input.status()), Timeline.constructSessionMessageRows(input.sessionMessages(), input.showReasoningSummaries(), input.status().type),
) )
const activeMessageID = createMemo(() => projection().activeMessageID) const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
@@ -0,0 +1,56 @@
import { TimelineRow } from "./timeline-row"
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
type PriorContext = { index: number; row: ContextRow }
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
if (!previous?.length) return rows
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
const contextByPart = new Map<string, PriorContext>()
previous.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
row.group.refs.forEach((ref) => contextByPart.set(`${row.userMessageID}:${ref.partID}`, { index, row }))
})
const reserved = new Map<string, number>()
rows.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
const key = TimelineRow.key(row)
if (byKey.has(key) && !reserved.has(key)) reserved.set(key, index)
})
const claimed = new Set<string>()
const next = rows.map((input, index) => {
const row = stabilizeContextKey(contextByPart, reserved, input, index, claimed)
const existing = byKey.get(TimelineRow.key(row))
if (!existing) return row
return TimelineRow.equals(existing, row) ? existing : row
})
if (previous.length === next.length && previous.every((row, index) => row === next[index])) return previous
return next
}
function stabilizeContextKey(
contextByPart: Map<string, PriorContext>,
reserved: Map<string, number>,
row: TimelineRow.TimelineRow,
rowIndex: number,
claimed: Set<string>,
) {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return row
const existing = row.group.refs.reduce<PriorContext | undefined>((result, ref) => {
const candidate = contextByPart.get(`${row.userMessageID}:${ref.partID}`)
if (!candidate) return result
const key = TimelineRow.key(candidate.row)
if (claimed.has(key)) return result
const owner = reserved.get(key)
if (owner !== undefined && owner !== rowIndex) return result
return !result || candidate.index < result.index ? candidate : result
}, undefined)
if (!existing) return row
const key = TimelineRow.key(existing.row)
claimed.add(key)
if (row.group.key === existing.row.group.key) return row
return new TimelineRow.AssistantPart({
...row,
group: { ...row.group, key: existing.row.group.key },
})
}
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise" import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { Timeline, TimelineRow } from "./projection"
const { Timeline, TimelineRow } = await import("./rows")
describe("current session timeline rows", () => { describe("current session timeline rows", () => {
test("derives turns and tagged rows from chronological current messages", () => { test("derives turns and tagged rows from chronological current messages", () => {
@@ -24,7 +25,7 @@ describe("current session timeline rows", () => {
time: { created: 5 }, time: { created: 5 },
}, },
] satisfies SessionMessageInfo[] ] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" }) const result = Timeline.constructSessionMessageRows(source, true, "busy")
expect(result.activeMessageID).toBe("msg_3") expect(result.activeMessageID).toBe("msg_3")
expect(result.rows.map(TimelineRow.key)).toEqual([ expect(result.rows.map(TimelineRow.key)).toEqual([
@@ -49,7 +50,7 @@ describe("current session timeline rows", () => {
time: { created: 1, completed: 2 }, time: { created: 1, completed: 2 },
}, },
] satisfies SessionMessageInfo[] ] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, { type: "idle" }) const result = Timeline.constructSessionMessageRows(source, true, "idle")
expect(result.activeMessageID).toBe("msg_shell") expect(result.activeMessageID).toBe("msg_shell")
expect(result.rows.map(TimelineRow.key)).toEqual(["shell:msg_shell"]) expect(result.rows.map(TimelineRow.key)).toEqual(["shell:msg_shell"])
@@ -74,7 +75,7 @@ describe("current session timeline rows", () => {
}, },
] satisfies SessionMessageInfo[] ] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, { type: "idle" }) const result = Timeline.constructSessionMessageRows(source, true, "idle")
expect(result.activeMessageID).toBe("msg_assistant") expect(result.activeMessageID).toBe("msg_assistant")
expect(result.rows.map(TimelineRow.key)).toEqual([ expect(result.rows.map(TimelineRow.key)).toEqual([
@@ -135,7 +136,7 @@ describe("current session timeline rows", () => {
time: { created: 11 }, time: { created: 11 },
}, },
] satisfies SessionMessageInfo[] ] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, { type: "idle" }) const result = Timeline.constructSessionMessageRows(source, true, "idle")
expect(result.rows.map(TimelineRow.key)).toEqual([ expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_user", "user-message:msg_user",
@@ -155,7 +156,7 @@ describe("current session timeline rows", () => {
{ id: "msg_z", type: "user", text: "existing", time: { created: 1 } }, { id: "msg_z", type: "user", text: "existing", time: { created: 1 } },
{ id: "msg_a", type: "user", text: "pending", time: { created: 2 } }, { id: "msg_a", type: "user", text: "pending", time: { created: 2 } },
] satisfies SessionMessageInfo[] ] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" }) const result = Timeline.constructSessionMessageRows(source, true, "busy")
expect(result.activeMessageID).toBe("msg_a") expect(result.activeMessageID).toBe("msg_a")
expect(result.rows.map(TimelineRow.key)).toEqual([ expect(result.rows.map(TimelineRow.key)).toEqual([
@@ -180,7 +181,7 @@ describe("current session timeline rows", () => {
}, },
] satisfies SessionMessageInfo[] ] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" }) const result = Timeline.constructSessionMessageRows(source, true, "busy")
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Retry"]) expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Retry"])
}) })
@@ -206,122 +207,8 @@ describe("current session timeline rows", () => {
time: { created: 4 }, time: { created: 4 },
}, },
] satisfies SessionMessageInfo[] ] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" }) const result = Timeline.constructSessionMessageRows(source, true, "busy")
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"]) expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"])
}) })
test("keeps content IDs and groups adjacent context tools", () => {
const source = [
{ id: "msg_user", type: "user", text: "inspect", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{ type: "text", text: " " },
{ type: "reasoning", text: "hidden" },
{
type: "tool",
id: "tool_read",
name: "read",
state: { status: "running", input: {}, metadata: {} },
time: { created: 2 },
},
{
type: "tool",
id: "tool_grep",
name: "grep",
state: { status: "running", input: {}, metadata: {} },
time: { created: 3 },
},
{
type: "tool",
id: "tool_todo",
name: "todowrite",
state: { status: "running", input: {}, metadata: {} },
time: { created: 4 },
},
{
type: "tool",
id: "tool_question",
name: "question",
state: { status: "streaming", input: "" },
time: { created: 5 },
},
{ type: "text", text: "visible" },
],
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, false, { type: "idle" })
const groups = result.rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group] : []))
expect(groups).toEqual([
{
type: "context",
key: "context:tool_read",
refs: [
{ messageID: "msg_assistant", partID: "tool_read" },
{ messageID: "msg_assistant", partID: "tool_grep" },
],
},
{
type: "part",
key: "part:msg_assistant:msg_assistant:text:1",
ref: { messageID: "msg_assistant", partID: "msg_assistant:text:1" },
},
])
})
test("places a divider after interrupted output unless the turn compacts", () => {
const messages = [
{ id: "msg_user", type: "user", text: "continue", time: { created: 1 } },
{
id: "msg_interrupted",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "before" }],
error: { type: "ExecutionInterrupted", message: "stopped" },
time: { created: 2, completed: 3 },
},
{
id: "msg_continued",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "after" }],
time: { created: 4, completed: 5 },
},
] satisfies SessionMessageInfo[]
expect(Timeline.constructSessionMessageRows(messages, true, { type: "idle" }).rows.map((row) => row._tag)).toEqual([
"UserMessage",
"AssistantPart",
"TurnDivider",
"AssistantPart",
])
const compacted = [
messages[0],
messages[1],
{
id: "msg_compaction",
type: "compaction",
status: "completed",
reason: "auto",
summary: "summary",
recent: "recent",
time: { created: 4 },
},
messages[2],
] satisfies SessionMessageInfo[]
expect(Timeline.constructSessionMessageRows(compacted, true, { type: "idle" }).rows.map((row) => row._tag)).toEqual(
["UserMessage", "AssistantPart", "Notice", "AssistantPart"],
)
})
}) })
@@ -0,0 +1,385 @@
import { parseCommentNote, readPromptPresentation } from "@/utils/comment-note"
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
SessionMessageShell,
SessionMessageUser,
SessionStatus,
} from "@opencode-ai/client/promise"
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow } from "./timeline-row"
export { TimelineRow } from "./timeline-row"
export type TimelineRowMap = {
TurnGap: { userMessageID: string }
UserMessage: {
userMessageID: string
}
Shell: { userMessageID: string; messageID: string }
Notice: { userMessageID: string; messageID: string }
TurnDivider: {
userMessageID: string
}
AssistantPart: {
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
}
Thinking: { userMessageID: string; reasoningHeading?: string }
Retry: { userMessageID: string }
Error: { userMessageID: string; text: string }
}
type Assistant = SessionMessageAssistant
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Entry = { type: "assistant"; message: Assistant } | { type: "notice"; message: Notice }
type Content = Assistant["content"][number]
type ContentRef = { messageID: string; partID: string }
const contextTools = new Set(["read", "glob", "grep", "list"])
export namespace Timeline {
export function constructSessionMessageRows(
messages: SessionMessageInfo[],
showReasoning: boolean,
status: SessionStatus["type"],
) {
type Turn = {
id: string
time: { created: number }
user?: SessionMessageUser
shell?: SessionMessageShell
entries: Entry[]
}
const turns: Turn[] = []
const turnByUserID = new Map<string, (typeof turns)[number]>()
const leading: Notice[] = []
let current: (typeof turns)[number] | undefined
messages.forEach((message) => {
if (isNotice(message)) {
if (current) current.entries.push({ type: "notice", message })
if (!current) leading.push(message)
return
}
if (message.type === "shell") {
const turn: Turn = { id: message.id, time: message.time, shell: message, entries: [] }
turns.push(turn)
current = turn
return
}
if (message.type === "user") {
if (turnByUserID.has(message.id)) return
const turn: Turn = { id: message.id, time: message.time, user: message, entries: [] }
turns.push(turn)
turnByUserID.set(message.id, turn)
current = turn
return
}
if (message.type !== "assistant") return
const existing = current?.user ? current : undefined
if (existing?.user) {
existing.entries.push({ type: "assistant", message })
current = existing
return
}
if (current && !current.user && !current.shell) {
current.entries.push({ type: "assistant", message })
return
}
const turn: Turn = { id: message.id, time: message.time, entries: [{ type: "assistant", message }] }
turns.push(turn)
current = turn
})
const activeMessageID = turns.at(-1)?.id
return {
activeMessageID,
rows: [
...leading.map(
(message) => new TimelineRow.Notice({ userMessageID: turns[0]?.id ?? message.id, messageID: message.id }),
),
...turns.flatMap((turn, index) => {
if (turn.shell)
return [
...(index > 0 ? [new TimelineRow.TurnGap({ userMessageID: turn.id })] : []),
new TimelineRow.Shell({ userMessageID: turn.id, messageID: turn.shell.id }),
...turn.entries.flatMap((entry) =>
entry.type === "notice"
? [new TimelineRow.Notice({ userMessageID: turn.id, messageID: entry.message.id })]
: [],
),
]
return constructMessageRows(
turn.user,
turn.id,
turn.entries,
index,
showReasoning,
status,
turn.id === activeMessageID,
)
}),
],
}
}
export function constructMessageRows(
userMessage: SessionMessageUser | undefined,
turnID: string,
entries: Entry[],
index: number,
showReasoning: boolean,
status: SessionStatus["type"],
isActive: boolean,
) {
const rows: TimelineRow.TimelineRow[] = []
const assistantMessages = entries.flatMap((entry) => (entry.type === "assistant" ? [entry.message] : []))
const previousUserMessage = index > 0
const compaction = entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction")
const error = assistantMessages.at(-1)?.error
const retry = assistantMessages.at(-1)?.retry
const interrupted = error?.type.toLowerCase().includes("abort") || error?.type.toLowerCase().includes("interrupt")
const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) =>
contentEntries(message)
.filter((entry) => renderable(entry.content, showReasoning))
.map((entry) => ({ messageID: message.id, messageIndex, partID: entry.id, content: entry.content })),
)
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: turnID }))
if (userMessage) rows.push(new TimelineRow.UserMessage({ userMessageID: turnID }))
let assistantGroupIndex = 0
const appendAssistants = (messages: Assistant[]) => {
const ids = new Set(messages.map((message) => message.id))
const refs = assistantPartRefs.filter((ref) => ids.has(ref.messageID))
const interruptedAt = messages.findIndex(
(message) =>
message.error?.type.toLowerCase().includes("abort") ||
message.error?.type.toLowerCase().includes("interrupt"),
)
const interruptedID = messages[interruptedAt]?.id
const interruptedIndex = assistantMessages.findIndex((message) => message.id === interruptedID)
const before = interruptedID ? refs.filter((ref) => ref.messageIndex <= interruptedIndex) : refs
const after = interruptedID ? refs.filter((ref) => ref.messageIndex > interruptedIndex) : []
const appendGroups = (items: typeof refs) =>
groupContent(items).forEach((group) => {
rows.push(
new TimelineRow.AssistantPart({
userMessageID: turnID,
group,
previousAssistantPart: assistantGroupIndex > 0,
}),
)
assistantGroupIndex += 1
})
appendGroups(before)
if (interruptedAt >= 0 && !compaction) rows.push(new TimelineRow.TurnDivider({ userMessageID: turnID }))
appendGroups(after)
}
let assistantSegment: Assistant[] = []
entries.forEach((entry) => {
if (entry.type === "assistant") {
assistantSegment.push(entry.message)
return
}
appendAssistants(assistantSegment)
assistantSegment = []
rows.push(new TimelineRow.Notice({ userMessageID: turnID, messageID: entry.message.id }))
})
appendAssistants(assistantSegment)
if (isActive && status === "busy" && !error && !retry && (showReasoning ? assistantPartRefs.length === 0 : true)) {
const heading = assistantMessages
.flatMap((message) => message.content)
.map((content) => (content.type === "reasoning" && content.text ? reasoningHeading(content.text) : undefined))
.find((value): value is string => !!value)
rows.push(
new TimelineRow.Thinking({
userMessageID: turnID,
reasoningHeading: heading,
}),
)
}
if (isActive && retry) rows.push(new TimelineRow.Retry({ userMessageID: turnID }))
if (error && !interrupted) {
rows.push(
new TimelineRow.Error({
userMessageID: turnID,
text: unwrapErrorMessage(error.message),
}),
)
}
return rows
}
export function resolveContent(message: SessionMessageInfo | undefined, partID: string) {
if (message?.type !== "assistant") return
return contentEntries(message).find((entry) => entry.id === partID)?.content
}
export function contentEntries(message: Assistant) {
const ordinals = { text: 0, reasoning: 0 }
return message.content.map((content) => ({
id: content.type === "tool" ? content.id : `${message.id}:${content.type}:${ordinals[content.type]++}`,
content,
}))
}
function renderable(content: Content, showReasoning: boolean) {
if (content.type === "text") return !!content.text.trim()
if (content.type === "reasoning") return showReasoning && !!content.text.trim()
if (content.name === "todowrite") return false
if (content.name === "question") return content.state.status !== "streaming" && content.state.status !== "running"
return true
}
function groupContent(items: { messageID: string; partID: string; content: Content }[]): PartGroup[] {
const groups: PartGroup[] = []
let context: ContentRef[] = []
const flush = () => {
const first = context[0]
if (!first) return
groups.push({ type: "context", key: `context:${first.partID}`, refs: context })
context = []
}
items.forEach((item) => {
if (item.content.type === "tool" && contextTools.has(item.content.name)) {
context.push({ messageID: item.messageID, partID: item.partID })
return
}
flush()
groups.push({
type: "part",
key: `part:${item.messageID}:${item.partID}`,
ref: { messageID: item.messageID, partID: item.partID },
})
})
flush()
return groups
}
function reasoningHeading(text: string) {
const markdown = text.replace(/\r\n?/g, "\n")
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
if (html?.[1]) {
const value = cleanHeading(html[1].replace(/<[^>]+>/g, " "))
if (value) return value
}
const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m)
if (atx?.[1]) {
const value = cleanHeading(atx[1])
if (value) return value
}
const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m)
if (setext?.[1]) {
const value = cleanHeading(setext[1])
if (value) return value
}
const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m)
if (strong?.[1]) {
const value = cleanHeading(strong[1])
if (value) return value
}
}
function cleanHeading(value: string) {
return value
.replace(/`([^`]+)`/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[*_~]+/g, "")
.trim()
}
function unwrapErrorMessage(message: string) {
const text = message.replace(/^Error:\s*/, "").trim()
const parse = (value: string) => {
try {
return JSON.parse(value) as unknown
} catch {
return undefined
}
}
const read = (value: string) => {
const first = parse(value)
if (typeof first !== "string") return first
return parse(first.trim())
}
let json = read(text)
if (json === undefined) {
const start = text.indexOf("{")
const end = text.lastIndexOf("}")
if (start !== -1 && end > start) json = read(text.slice(start, end + 1))
}
if (!record(json)) return message
const err = record(json.error) ? json.error : undefined
if (err) {
const type = typeof err.type === "string" ? err.type : undefined
const msg = typeof err.message === "string" ? err.message : undefined
if (type && msg) return `${type}: ${msg}`
if (msg) return msg
if (type) return type
const code = typeof err.code === "string" ? err.code : undefined
if (code) return code
}
const msg = typeof json.message === "string" ? json.message : undefined
if (msg) return msg
const reason = typeof json.error === "string" ? json.error : undefined
if (reason) return reason
return message
}
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function isNotice(
message: SessionMessageInfo,
): message is Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }> {
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
if (message.type !== "synthetic") return true
return !!message.description?.trim()
}
}
export namespace MessageComment {
export type MessageComment = {
path: string
comment: string
selection?: {
startLine: number
endLine: number
}
}
export const fromMessage = (message: SessionMessageUser): MessageComment[] => {
const presentation = readPromptPresentation(message.metadata)
const parsed = presentation ? undefined : parseCommentNote(message.text)
const comments = presentation?.comments ?? (parsed ? [parsed] : [])
return comments.map((comment) => ({
path: comment.path,
comment: comment.comment,
selection: comment.selection
? { startLine: comment.selection.startLine, endLine: comment.selection.endLine }
: undefined,
}))
}
}
@@ -1,61 +1,37 @@
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { Data, Equal } from "effect" import { Data, Equal } from "effect"
export type PartRef = {
messageID: string
partID: string
}
export type PartGroup =
| {
key: string
type: "part"
ref: PartRef
}
| {
key: string
type: "context"
refs: PartRef[]
}
export namespace TimelineRow { export namespace TimelineRow {
export class TurnGap extends Data.TaggedClass("TurnGap")<{ export class TurnGap extends Data.TaggedClass("TurnGap")<{
userMessageID: string userMessageID: string
}> {} }> {}
export class UserMessage extends Data.TaggedClass("UserMessage")<{ export class UserMessage extends Data.TaggedClass("UserMessage")<{
userMessageID: string userMessageID: string
}> {} }> {}
export class Shell extends Data.TaggedClass("Shell")<{ export class Shell extends Data.TaggedClass("Shell")<{
userMessageID: string userMessageID: string
messageID: string messageID: string
}> {} }> {}
export class Notice extends Data.TaggedClass("Notice")<{ export class Notice extends Data.TaggedClass("Notice")<{
userMessageID: string userMessageID: string
messageID: string messageID: string
}> {} }> {}
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{ export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
userMessageID: string userMessageID: string
}> {} }> {}
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{ export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
userMessageID: string userMessageID: string
group: PartGroup group: PartGroup
previousAssistantPart: boolean previousAssistantPart: boolean
}> {} }> {}
export class Thinking extends Data.TaggedClass("Thinking")<{ export class Thinking extends Data.TaggedClass("Thinking")<{
userMessageID: string userMessageID: string
reasoningHeading?: string reasoningHeading?: string
}> {} }> {}
export class Error extends Data.TaggedClass("Error")<{ export class Error extends Data.TaggedClass("Error")<{
userMessageID: string userMessageID: string
text: string text: string
}> {} }> {}
export class Retry extends Data.TaggedClass("Retry")<{ export class Retry extends Data.TaggedClass("Retry")<{
userMessageID: string userMessageID: string
}> {} }> {}
@@ -71,7 +47,7 @@ export namespace TimelineRow {
| Error | Error
| Retry | Retry
export const key = (row: TimelineRow): string => { export const key = (row: TimelineRow) => {
switch (row._tag) { switch (row._tag) {
case "TurnGap": case "TurnGap":
return `turn-gap:${row.userMessageID}` return `turn-gap:${row.userMessageID}`
@@ -92,26 +68,9 @@ export namespace TimelineRow {
case "Retry": case "Retry":
return `retry:${row.userMessageID}` return `retry:${row.userMessageID}`
} }
return row
} }
export function equals(a: TimelineRow, b: TimelineRow) { export function equals(a: TimelineRow, b: TimelineRow) {
return Equal.equals(a, b) return Equal.equals(a, b)
} }
} }
export type TimelineRowMap = {
TurnGap: { userMessageID: string }
UserMessage: { userMessageID: string }
Shell: { userMessageID: string; messageID: string }
Notice: { userMessageID: string; messageID: string }
TurnDivider: { userMessageID: string }
AssistantPart: {
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
}
Thinking: { userMessageID: string; reasoningHeading?: string }
Retry: { userMessageID: string }
Error: { userMessageID: string; text: string }
}
@@ -1,507 +0,0 @@
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
import { TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "@/pages/session/message-gesture"
import { useLanguage } from "@/context/language"
import {
createEffect,
createMemo,
createSignal,
For,
on,
onCleanup,
onMount,
Show,
type Accessor,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import type { createTimelineProjection } from "./projection"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { filterVirtualIndexes } from "./virtual-items"
const fallbackItemSize = 60
const cache = new Map<string, { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }>()
type Projection = Pick<
ReturnType<typeof createTimelineProjection>,
"activeMessageID" | "messageLastRowIndex" | "messageRowIndex" | "rowByKey" | "rows"
>
type Input = {
sessionKey: Accessor<string>
projection: Projection
showHeader: Accessor<boolean>
shouldAnchorBottom: Accessor<boolean>
hasScrollGesture: Accessor<boolean>
scroll: Accessor<{ overflow: boolean; bottom: boolean; jump: boolean }>
onResumeScroll: () => void
setScrollRef: (element: HTMLDivElement | undefined) => void
setContentRef: (element: HTMLDivElement) => void
onScheduleScrollState: (element: HTMLDivElement) => void
onAutoScrollHandleScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
onMarkScrollGesture: (target?: EventTarget | null) => void
onUserScroll: () => void
onHistoryScroll: () => void
setRevealMessage?: (fn: (id: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
}
type ViewProps = {
header: JSX.Element
workspaceSession: Accessor<boolean>
deferred: (row: TimelineRow.TimelineRow) => boolean
renderRow: (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => JSX.Element
}
export function createTimelineVirtualizer(input: Input) {
const language = useLanguage()
const ownerSessionKey = input.sessionKey()
const cached = cache.get(ownerSessionKey)
const initialMeasurements = cached?.measurements
const coldBottomMount = !initialMeasurements?.length && input.shouldAnchorBottom()
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 20)
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
let touchGesture: number | undefined
let prependAnchor: { key: string; offset: number } | undefined
let prependAnchorFrame: number | undefined
let prependLoading = false
let resizePinnedIndexes: number[] = []
let resizePinFrame: number | undefined
let virtualContent: HTMLDivElement | undefined
const clearPrependAnchor = () => {
prependLoading = false
prependAnchor = undefined
if (prependAnchorFrame === undefined) return
cancelAnimationFrame(prependAnchorFrame)
prependAnchorFrame = undefined
}
const capturePrependAnchor = () => {
prependLoading = true
updatePrependAnchor()
}
const updatePrependAnchor = () => {
const root = listRoot()
if (!root) return
const view = root.getBoundingClientRect()
const anchor = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
.filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
.sort((a, b) => a.rect.top - b.rect.top)[0]
if (!anchor) return
if (!anchor.element.dataset.timelineKey) return
prependAnchor = { key: anchor.element.dataset.timelineKey, offset: anchor.rect.top - view.top }
}
const restorePrependAnchor = (done: boolean) => {
if (done) prependLoading = false
applyPrependAnchor()
}
const applyPrependAnchor = () => {
const root = listRoot()
if (!root || !prependAnchor) return
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
let frames = 0
let stable = 0
const apply = () => {
prependAnchorFrame = undefined
const anchor = prependAnchor
if (!anchor) return
const element = root.querySelector<HTMLElement>(`[data-timeline-key="${CSS.escape(anchor.key)}"]`)
const delta = element
? element.getBoundingClientRect().top - root.getBoundingClientRect().top - anchor.offset
: undefined
if (delta !== undefined && Math.abs(delta) > 0.5) {
root.scrollTop += delta
stable = 0
} else {
stable += 1
}
frames += 1
if (stable >= 30 || frames >= 180) {
if (!prependLoading) prependAnchor = undefined
return
}
prependAnchorFrame = requestAnimationFrame(apply)
}
prependAnchorFrame = requestAnimationFrame(apply)
}
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
get count() {
return rows().length
},
getScrollElement: () => listRoot() ?? null,
observeElementOffset: observeElementOffsetReconnectAware,
initialOffset: () => (input.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => fallbackItemSize,
scrollToFn: (offset, options, instance) => {
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
elementScroll(offset, options, instance)
},
get getItemKey() {
const items = rows()
return (index: number) => {
const row = items[index]
if (!row) return `removed:${index}`
return TimelineRow.key(row)
}
},
anchorTo: "end",
followOnAppend: true,
scrollEndThreshold: 80,
get scrollMargin() {
return input.showHeader() ? 64 : 0
},
overscan: 50,
paddingEnd: 64,
rangeExtractor: (range) => {
const id = input.projection.activeMessageID()
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() })
return filterVirtualIndexes(
[...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
},
})
const resizeItem = virtualizer.resizeItem
let resizeAnchorScheduled = false
const anchorResizedBottom = () => {
if (resizeAnchorScheduled || input.hasScrollGesture()) return
resizeAnchorScheduled = true
queueMicrotask(() => {
resizeAnchorScheduled = false
if (!input.shouldAnchorBottom() || input.hasScrollGesture()) return
virtualizer.scrollToEnd()
})
}
virtualizer.resizeItem = (index, size) => {
const item = virtualizer.measurementsCache[index]
const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined
const root = listRoot()
if (root && previous !== undefined && Math.abs(size - previous) > root.clientHeight) {
const view = root.getBoundingClientRect()
resizePinnedIndexes = [...root.querySelectorAll<HTMLElement>("[data-index]")]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((element) => Number(element.dataset.index))
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
resizePinFrame = requestAnimationFrame(() => {
resizePinFrame = requestAnimationFrame(() => {
resizePinFrame = undefined
resizePinnedIndexes = []
})
})
}
resizeItem(index, size)
if (root && input.shouldAnchorBottom()) anchorResizedBottom()
}
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => {
if (input.shouldAnchorBottom()) return false
const first = virtualizer.range?.startIndex
return first !== undefined && item.index < first
}
const virtualItemByKey = createMemo(
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
)
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
createEffect(() => {
input.setRevealMessage?.((id) => {
const index = input.projection.messageRowIndex().get(id)
if (index === undefined) return
virtualizer.scrollToIndex(index, { align: "center" })
})
input.setScrollToEnd?.(() => virtualizer.scrollToEnd())
input.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor })
})
let overscanFrame: number | undefined
onMount(() => {
overscanFrame = requestAnimationFrame(() => {
if (input.shouldAnchorBottom()) virtualizer.scrollToEnd()
overscanFrame = requestAnimationFrame(() => {
overscanFrame = undefined
if (renderOverscan() < 20) setRenderOverscan(20)
if (input.shouldAnchorBottom()) virtualizer.scrollToEnd()
})
})
})
const maybeAnchorBottom = () => {
if (rows().length === 0) return
if (!input.shouldAnchorBottom() || input.hasScrollGesture()) return
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
clearPrependAnchor()
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
virtualizer.scrollToEnd()
}
let measuredSessionKey = input.sessionKey()
createEffect(() => {
const key = input.sessionKey()
rows().length
if (measuredSessionKey !== key) {
measuredSessionKey = key
virtualizer.measure()
}
maybeAnchorBottom()
})
const bindListRoot = (root: HTMLDivElement) => {
if (root === listRoot()) return
setListRoot(root)
input.setScrollRef(root)
}
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
if (!prependLoading) clearPrependAnchor()
const root = event.currentTarget
const delta = normalizeWheelDelta({
deltaY: event.deltaY,
deltaMode: event.deltaMode,
rootHeight: root.clientHeight,
})
if (!delta) return
markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: input.onMarkScrollGesture })
}
const handleListTouchStart = (event: TouchEvent) => {
if (!prependLoading) clearPrependAnchor()
touchGesture = event.touches[0]?.clientY
}
const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
const next = event.touches[0]?.clientY
const previous = touchGesture
touchGesture = next
if (next === undefined || previous === undefined) return
const delta = previous - next
if (!delta) return
markBoundaryGesture({
root: event.currentTarget,
target: event.target,
delta,
onMarkScrollGesture: input.onMarkScrollGesture,
})
}
const handleListTouchEnd = () => {
touchGesture = undefined
}
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
if (!prependLoading) clearPrependAnchor()
input.onMarkScrollGesture(event.target)
}
const handleListPointerMove = (event: PointerEvent) => {
if (event.buttons !== 1) return
input.onMarkScrollGesture(event.target)
}
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
const key = scrollKey(event)
if (!key) return
if (!isScrollKeyTarget(event.target, key)) return
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
if (!prependLoading) clearPrependAnchor()
input.onMarkScrollGesture(event.currentTarget)
}
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (prependLoading) updatePrependAnchor()
input.onScheduleScrollState(event.currentTarget)
input.onHistoryScroll()
if (!input.hasScrollGesture()) return
input.onUserScroll()
input.onAutoScrollHandleScroll()
input.onMarkScrollGesture(event.currentTarget)
}
function View(props: ViewProps) {
function VirtualRow(rowProps: { rowKey: string }) {
let element: HTMLDivElement
const initialItem = virtualItemByKey().get(rowProps.rowKey)!
const initialRow = rowByKey().get(rowProps.rowKey)!
const item = createMemo(() => virtualItemByKey().get(rowProps.rowKey) ?? initialItem)
const row = createMemo(() => rowByKey().get(rowProps.rowKey) ?? rows()[item().index] ?? initialRow)
const [ready, setReady] = createSignal(initialItem.size <= fallbackItemSize || !props.deferred(initialRow))
let contentMeasureFrame: number | undefined
onMount(() => virtualizer.measureElement(element))
createEffect(
on(
() => item().index,
() => {
virtualizer.measureElement(element)
},
{ defer: true },
),
)
onCleanup(() => {
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
queueMicrotask(() => virtualizer.measureElement(null))
})
return (
<div
data-timeline-key={rowProps.rowKey}
style={{
position: "absolute",
top: `${item().start - (input.showHeader() ? 64 : 0)}px`,
left: "0",
width: "100%",
height: `${item().size}px`,
overflow: "clip",
"overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "0.5px",
}}
>
<div
ref={(value) => {
element = value
}}
data-index={item().index}
style={{ "min-height": ready() ? undefined : `${initialItem.size}px` }}
>
{props.renderRow(row, () => {
setReady(true)
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
contentMeasureFrame = scheduleConnectedMeasure(element, virtualizer.measureElement)
})}
</div>
</div>
)
}
return (
<div class="relative w-full h-full min-w-0" data-workspace-session={props.workspaceSession() ? "" : undefined}>
<div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
"bottom-8": true,
"opacity-100 translate-y-0 scale-100": input.scroll().overflow && input.scroll().jump,
"opacity-0 translate-y-2 pointer-events-none": !input.scroll().overflow || !input.scroll().jump,
"scale-[0.8]": !input.scroll().overflow || !input.scroll().jump,
}}
>
<button
type="button"
aria-label={language.t("session.messages.jumpToLatest")}
class="pointer-events-auto flex items-center justify-center w-8 h-7 px-2 py-1.5 rounded-lg border-none cursor-pointer text-v2-text-text-base backdrop-blur-[2px]"
style={{
background: "color-mix(in srgb, var(--v2-background-bg-base) 92%, transparent)",
"box-shadow": "var(--v2-elevation-raised), 0px 2px 8px var(--v2-background-bg-base)",
}}
onClick={input.onResumeScroll}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M12.3333 8.66665L8 13L3.66667 8.66665M8 12.6667V2.83332"
stroke="currentColor"
stroke-linecap="square"
/>
</svg>
</button>
</div>
<ScrollView
viewportRef={bindListRoot}
onWheel={handleListWheel}
onTouchStart={handleListTouchStart}
onTouchMove={handleListTouchMove}
onTouchEnd={handleListTouchEnd}
onTouchCancel={handleListTouchEnd}
onPointerDown={handleListPointerDown}
onPointerMove={handleListPointerMove}
onKeyDown={handleListKeyDown}
onScroll={handleListScroll}
onClick={input.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full"
style={{ "--sticky-accordion-top": input.showHeader() ? "48px" : "0px" }}
>
<Show when={input.showHeader()}>{props.header}</Show>
<div
data-timeline-virtual-content
ref={(element) => {
virtualContent = element
input.setContentRef(element)
}}
style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", width: "100%" }}
>
<For each={virtualRowKeys()}>{(rowKey) => <VirtualRow rowKey={rowKey} />}</For>
<Show when={rows().length > 0}>
<div
data-timeline-row="bottom-spacer"
aria-hidden="true"
class="h-16 absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
/>
</Show>
</div>
</ScrollView>
</div>
)
}
onCleanup(() => {
clearPrependAnchor()
cache.delete(ownerSessionKey)
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
while (cache.size > 16) cache.delete(cache.keys().next().value!)
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
input.setHistoryAnchor?.({ capture: () => {}, restore: () => {} })
})
return {
disclosure: {
value: (key: string) => toolOpen[key],
set: (key: string, open: boolean) => setToolOpen(key, open),
},
View,
}
}
function boundaryTarget(root: HTMLElement, target: EventTarget | null) {
const current = target instanceof Element ? target : undefined
const nested = current?.closest("[data-scrollable]")
if (!(nested instanceof HTMLElement) || nested === root) return undefined
return nested
}
function markBoundaryGesture(input: {
root: HTMLElement
target: EventTarget | null
delta: number
onMarkScrollGesture: (target?: EventTarget | null) => void
}) {
const target = boundaryTarget(input.root, input.target)
if (
target &&
!shouldMarkBoundaryGesture({
delta: input.delta,
scrollTop: target.scrollTop,
scrollHeight: target.scrollHeight,
clientHeight: target.clientHeight,
})
)
return
input.onMarkScrollGesture(input.root)
}
@@ -52,29 +52,12 @@ export type ReviewPanelV2Props = {
comments?: SessionReviewComment[] comments?: SessionReviewComment[]
focusedComment?: SessionReviewFocus | null focusedComment?: SessionReviewFocus | null
onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void
fileList?: "tree" | "flat"
} }
export function ReviewPanelV2(props: ReviewPanelV2Props) { export function ReviewPanelV2(props: ReviewPanelV2Props) {
const sdk = useWorkspaceLocation() const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK() 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 diffs = createMemo(() => props.diffs.filter(filterRenderableDiff))
const filteredFiles = createMemo(() => const filteredFiles = createMemo(() =>
filterReviewFiles( filterReviewFiles(
@@ -101,12 +84,12 @@ export function ReviewPanelV2View(
const detailSource = createMemo(() => { const detailSource = createMemo(() => {
const diff = sourceActiveItem() const diff = sourceActiveItem()
const load = props.loadDiff const load = props.loadDiff
if (!diff || !load || !reviewDiffNeedsLoad(diff)) return undefined if (!diff || !load || !reviewDiffNeedsLoad(diff)) return
return { diff, load, version: props.diffVersion } return { diff, load, version: props.diffVersion }
}) })
const [loadedDiff] = createResource(detailSource, async ({ diff, load, version }) => { const [loadedDiff] = createResource(detailSource, async ({ diff, load, version }) => {
const value = await load(diff.file, version) const value = await load(diff.file, version)
if (value?.file !== diff.file) return undefined if (value?.file !== diff.file) return
return { source: diff, version, value } return { source: diff, version, value }
}) })
@@ -118,6 +101,15 @@ export function ReviewPanelV2View(
return source 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 ( return (
<SessionReviewV2 <SessionReviewV2
title={props.title} title={props.title}
@@ -137,7 +129,6 @@ export function ReviewPanelV2View(
searching={searching()} searching={searching()}
kinds={treeKinds()} kinds={treeKinds()}
activeDiff={activeDiff()} activeDiff={activeDiff()}
flat={props.fileList === "flat"}
/> />
} }
activeFile={activeDiff()} activeFile={activeDiff()}
@@ -160,7 +151,7 @@ export function ReviewPanelV2View(
diff={diff()} diff={diff()}
diffStyle={props.diffStyle} diffStyle={props.diffStyle}
expandMode={props.state.expandMode()} expandMode={props.state.expandMode()}
readFile={props.readFile} readFile={readFile}
onLineComment={props.onLineComment} onLineComment={props.onLineComment}
onLineCommentUpdate={props.onLineCommentUpdate} onLineCommentUpdate={props.onLineCommentUpdate}
onLineCommentDelete={props.onLineCommentDelete} onLineCommentDelete={props.onLineCommentDelete}
@@ -188,7 +179,6 @@ function ReviewPanelV2Sidebar(props: {
searching: boolean searching: boolean
kinds: ReturnType<typeof reviewDiffKinds> kinds: ReturnType<typeof reviewDiffKinds>
activeDiff: string | undefined activeDiff: string | undefined
flat: boolean
}) { }) {
const language = useLanguage() const language = useLanguage()
const [explicitHighlight, setExplicitHighlight] = createSignal<string | undefined>() const [explicitHighlight, setExplicitHighlight] = createSignal<string | undefined>()
@@ -235,25 +225,13 @@ function ReviewPanelV2Sidebar(props: {
<Show <Show
when={props.searching} when={props.searching}
fallback={ fallback={
<Show <FileTreeV2
when={props.flat} allowed={props.filteredFiles}
fallback={ kinds={props.kinds}
<FileTreeV2 draggable={false}
allowed={props.filteredFiles} active={props.activeDiff}
kinds={props.kinds} onFileClick={(node) => props.onSelectFile(node.path)}
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 <Show
+194 -1
View File
@@ -1,10 +1,203 @@
import type { ProjectListOutput, WorktreeDirectory } from "@opencode-ai/client/promise" import type { FileDiffInfo, ProjectListOutput, WorktreeDirectory } from "@opencode-ai/client/promise"
export type Project = Omit<ProjectListOutput[number], "canonical"> & { export type Project = Omit<ProjectListOutput[number], "canonical"> & {
worktree: string worktree: string
worktrees: WorktreeDirectory[] 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 = { export type FileNode = {
name: string name: string
path: string path: string
+93
View File
@@ -0,0 +1,93 @@
const prefixes = {
session: "ses",
message: "msg",
permission: "per",
user: "usr",
part: "prt",
pty: "pty",
} as const
const LENGTH = 26
let lastTimestamp = 0
let counter = 0
type Prefix = keyof typeof prefixes
export namespace Identifier {
export function ascending(prefix: Prefix, given?: string) {
return generateID(prefix, false, given)
}
export function descending(prefix: Prefix, given?: string) {
return generateID(prefix, true, given)
}
}
function generateID(prefix: Prefix, descending: boolean, given?: string): string {
if (!given) {
return create(prefix, descending)
}
if (!given.startsWith(prefixes[prefix])) {
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
}
return given
}
function create(prefix: Prefix, descending: boolean, timestamp?: number): string {
const currentTimestamp = timestamp ?? Date.now()
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}
counter += 1
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
if (descending) {
now = ~now
}
const timeBytes = new Uint8Array(6)
for (let i = 0; i < 6; i += 1) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return prefixes[prefix] + "_" + bytesToHex(timeBytes) + randomBase62(LENGTH - 12)
}
function bytesToHex(bytes: Uint8Array): string {
let hex = ""
for (let i = 0; i < bytes.length; i += 1) {
hex += bytes[i].toString(16).padStart(2, "0")
}
return hex
}
function randomBase62(length: number): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const bytes = getRandomBytes(length)
let result = ""
for (let i = 0; i < length; i += 1) {
result += chars[bytes[i] % 62]
}
return result
}
function getRandomBytes(length: number): Uint8Array {
const bytes = new Uint8Array(length)
const cryptoObj = typeof globalThis !== "undefined" ? globalThis.crypto : undefined
if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
cryptoObj.getRandomValues(bytes)
return bytes
}
for (let i = 0; i < length; i += 1) {
bytes[i] = Math.floor(Math.random() * 256)
}
return bytes
}
@@ -0,0 +1,128 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageUser } from "@opencode-ai/client/promise"
import { presentAssistantParts, presentUserParts } from "./session-message"
describe("session message presentation", () => {
test("projects current user content for the DOM renderer", () => {
const message = {
id: "msg_user",
type: "user",
text: "inspect @src/client.ts",
files: [
{
data: "ZXhwb3J0IHt9",
mime: "text/plain",
name: "client.ts",
source: { type: "inline" },
mention: { text: "@src/client.ts", start: 8, end: 22 },
},
],
agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }],
time: { created: 1 },
} satisfies SessionMessageUser
const parts = presentUserParts("ses_1", message)
expect(parts.map((part) => part.id)).toEqual(["msg_user:text:0", "msg_user:file:0", "msg_user:agent:0"])
expect(parts[1]).toMatchObject({
type: "file",
source: {
type: "file",
path: "src/client.ts",
text: { value: "@src/client.ts", start: 8, end: 22 },
},
})
const plainMention = {
...message,
text: "inspect src/client.ts",
files: [
{
...message.files[0],
name: "client.ts",
mention: { text: "src/client.ts", start: 8, end: 21 },
},
],
} satisfies SessionMessageUser
expect(presentUserParts("ses_1", plainMention)[1]).toMatchObject({
type: "file",
source: { type: "file", path: "src/client.ts" },
})
})
test("projects current assistant content for existing DOM tools", () => {
const message = {
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "claude", providerID: "anthropic", variant: "high" },
content: [
{ type: "reasoning", text: "Thinking", time: { created: 2, completed: 3 } },
{ type: "text", text: "Result" },
{
type: "tool",
id: "call_1",
name: "read",
state: {
status: "completed",
input: { filePath: "note.txt" },
metadata: { title: "note.txt" },
content: [{ type: "text", text: "hello" }],
},
time: { created: 3, ran: 4, completed: 5 },
},
],
cost: 0.1,
tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } },
time: { created: 2, completed: 5 },
} satisfies SessionMessageAssistant
const parts = presentAssistantParts("ses_1", message)
expect(parts.map((part) => part.id)).toEqual(["msg_assistant:reasoning:0", "msg_assistant:text:0", "call_1"])
expect(parts[2]).toMatchObject({ type: "tool", tool: "read", state: { status: "completed", output: "hello" } })
})
test("adapts current edit fields only at the renderer boundary", () => {
const message = {
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_edit",
name: "edit",
state: {
status: "completed",
input: { path: "/repo/README.md", oldString: "old", newString: "new" },
content: [{ type: "text", text: "Edited file successfully" }],
metadata: {
files: [{ file: "README.md", patch: "@@ -1 +1 @@\n-old\n+new", additions: 1, deletions: 1 }],
},
},
time: { created: 2, ran: 3, completed: 4 },
},
],
time: { created: 2, completed: 4 },
} satisfies SessionMessageAssistant
expect(presentAssistantParts("ses_1", message)).toEqual([
expect.objectContaining({
type: "tool",
state: expect.objectContaining({
input: expect.objectContaining({ path: "/repo/README.md", filePath: "/repo/README.md" }),
metadata: expect.objectContaining({
filediff: {
file: "README.md",
patch: "@@ -1 +1 @@\n-old\n+new",
additions: 1,
deletions: 1,
},
}),
}),
}),
])
})
})
+241
View File
@@ -0,0 +1,241 @@
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import type { AssistantMessage, FilePart, Part, ToolPart, UserMessage } from "@/types"
import { Option, Schema } from "effect"
import { createCommentMetadata, formatCommentNote, readPromptPresentation } from "./comment-note"
const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const decodeToolInput = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function normalizeToolInput(name: string, input: Record<string, unknown>) {
if (!["edit", "write"].includes(name) || typeof input.path !== "string" || typeof input.filePath === "string")
return input
return { ...input, filePath: input.path }
}
function normalizeToolMetadata(name: string, metadata: Record<string, unknown>) {
if (name !== "edit" || !Array.isArray(metadata.files)) return metadata
const file = metadata.files.find(record)
if (!file || typeof file.file !== "string") return metadata
return {
...metadata,
filediff: {
file: file.file,
patch: typeof file.patch === "string" ? file.patch : undefined,
additions: typeof file.additions === "number" ? file.additions : 0,
deletions: typeof file.deletions === "number" ? file.deletions : 0,
},
}
}
export function sessionMessagePartID(messageID: string, type: "text" | "reasoning", ordinal: number) {
return `${messageID}:${type}:${ordinal}`
}
export function presentUserMessage(
sessionID: string,
message: SessionMessageUser,
agent: string,
model: { id: string; providerID: string; variant?: string },
): UserMessage {
return {
id: message.id,
sessionID,
role: "user",
time: message.time,
agent,
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
}
}
export function presentUserParts(sessionID: string, message: SessionMessageUser): Part[] {
const presentation = readPromptPresentation(message.metadata)
const text = presentation?.displayText ?? message.text
return [
...(text ? [textPart(sessionID, message.id, 0, text)] : []),
...(message.files ?? []).map(
(file, index): FilePart => ({
id: `${message.id}:file:${index}`,
sessionID,
messageID: message.id,
type: "file",
mime: file.mime,
filename: file.name,
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
source: file.mention
? {
type: "file",
text: { value: file.mention.text, start: file.mention.start, end: file.mention.end },
path: file.mention.text.startsWith("@") ? file.mention.text.slice(1) : file.mention.text,
}
: undefined,
}),
),
...(message.agents ?? []).map(
(item, index): Part => ({
id: `${message.id}:agent:${index}`,
sessionID,
messageID: message.id,
type: "agent",
name: item.name,
source: item.mention
? { value: item.mention.text, start: item.mention.start, end: item.mention.end }
: undefined,
}),
),
...(presentation?.comments ?? []).map(
(comment, index): Part => ({
id: `${message.id}:comment:${index}`,
sessionID,
messageID: message.id,
type: "text",
text: formatCommentNote(comment),
synthetic: true,
metadata: createCommentMetadata(comment),
}),
),
]
}
export function presentAssistantMessage(
sessionID: string,
parentID: string,
message: SessionMessageAssistant,
): AssistantMessage {
const error = message.error
? message.error.type.toLowerCase().includes("abort") || message.error.type.toLowerCase().includes("interrupt")
? { name: "MessageAbortedError" as const, data: { message: message.error.message } }
: { name: "UnknownError" as const, data: { message: message.error.message } }
: undefined
return {
id: message.id,
sessionID,
role: "assistant",
time: message.time,
error,
parentID,
modelID: message.model.id,
providerID: message.model.providerID,
variant: message.model.variant,
mode: message.agent,
agent: message.agent,
path: { cwd: "", root: "" },
cost: message.cost ?? 0,
tokens: message.tokens ?? emptyTokens,
finish: message.finish,
}
}
export function presentAssistantParts(sessionID: string, message: SessionMessageAssistant): Part[] {
const ordinals = { text: 0, reasoning: 0 }
return message.content.flatMap((content): Part[] => {
const id =
content.type === "tool" ? content.id : sessionMessagePartID(message.id, content.type, ordinals[content.type]++)
const part = presentAssistantContent(sessionID, message, id, content)
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return []
return [part]
})
}
export function presentAssistantContent(
sessionID: string,
message: SessionMessageAssistant,
id: string,
content: SessionMessageAssistant["content"][number],
): Part {
if (content.type === "text") return { id, sessionID, messageID: message.id, type: "text", text: content.text }
if (content.type === "reasoning")
return {
id,
sessionID,
messageID: message.id,
type: "reasoning",
text: content.text,
metadata: content.state,
time: {
start: content.time?.created ?? message.time.created,
end: content.time?.completed,
},
}
return toolPart(sessionID, message.id, content)
}
function textPart(sessionID: string, messageID: string, ordinal: number, text: string, synthetic?: boolean): Part {
return {
id: sessionMessagePartID(messageID, "text", ordinal),
sessionID,
messageID,
type: "text",
text,
synthetic,
}
}
function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssistantTool): ToolPart {
const start = tool.time.ran ?? tool.time.created
const state = (() => {
if (tool.state.status === "streaming") {
const value = Option.getOrUndefined(decodeToolInput(tool.state.input))
const input = normalizeToolInput(tool.name, record(value) ? value : {})
return { status: "pending" as const, input, raw: tool.state.input }
}
if (tool.state.status === "running") {
return {
status: "running" as const,
input: normalizeToolInput(tool.name, tool.state.input),
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start },
}
}
if (tool.state.status === "error") {
return {
status: "error" as const,
input: normalizeToolInput(tool.name, tool.state.input),
error: tool.state.error.message,
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start, end: tool.time.completed ?? start },
}
}
const attachments = tool.state.content.flatMap((item, index): FilePart[] =>
item.type === "file"
? [
{
id: `${tool.id}:file:${index}`,
sessionID,
messageID,
type: "file",
mime: item.mime,
filename: item.name ?? undefined,
url: item.uri,
},
]
: [],
)
return {
status: "completed" as const,
input: normalizeToolInput(tool.name, tool.state.input),
output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"),
title: tool.name,
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start, end: tool.time.completed ?? start },
attachments: attachments.length ? attachments : undefined,
}
})()
return {
id: tool.id,
sessionID,
messageID,
type: "tool",
callID: tool.id,
tool: tool.name,
state,
metadata: { providerState: tool.providerState, providerResultState: tool.providerResultState },
}
}
@@ -3,8 +3,8 @@ import { createRoot } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments" import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments"
import { createPromptState } from "@/context/prompt" import { createPromptState } from "@/context/prompt"
import { createPromptInputV2Attachments } from "@opencode-ai/session-ui/v2/prompt-input/attachments" import { createPromptInputV2Attachments } from "../../session-ui/src/v2/components/prompt-input/attachments"
import type { PromptInputV2Prompt } from "@opencode-ai/session-ui/v2/prompt-input/types" import type { PromptInputV2Prompt } from "../../session-ui/src/v2/components/prompt-input/types"
describe("prompt attachment session ownership", () => { describe("prompt attachment session ownership", () => {
test("adds an asynchronously read image to the session where the read started", async () => { test("adds an asynchronously read image to the session where the read started", async () => {
+68 -10
View File
@@ -1,17 +1,17 @@
export * as ServerProcess from "./server-process" export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node" import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions } from "@opencode-ai/client/effect/service" import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version" import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process" import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto" import { randomBytes, randomUUID } from "node:crypto"
import { Effect, Option, Redacted, Schedule } from "effect" import path from "node:path"
import { Effect, FileSystem, Option, Redacted, Schedule, Schema } from "effect"
import { HttpServer } from "effect/unstable/http" import { HttpServer } from "effect/unstable/http"
import { Env } from "./env" import { Env } from "./env"
import { ServiceConfig } from "./services/service-config" import { ServiceConfig } from "./services/service-config"
import { ServiceRegistration } from "./services/service-registration"
import { Updater } from "./services/updater" import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui" import { WebUi } from "./services/web-ui"
@@ -120,13 +120,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
onListen: (address, shutdown) => onListen: (address, shutdown) =>
Effect.gen(function* () { Effect.gen(function* () {
if (!config.password) yield* ServiceConfig.password(password) if (!config.password) yield* ServiceConfig.password(password)
return yield* ServiceRegistration.register({ return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
address,
password,
id: instanceID,
file: serviceOptions.file,
shutdown,
})
}), }),
}, },
transform, transform,
@@ -163,6 +157,70 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
) )
}) })
const infoJson = Schema.fromJsonString(Service.Info)
const encodeInfo = Schema.encodeEffect(infoJson)
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
const register = Effect.fnUntraced(function* (
address: HttpServer.Address,
password: string,
id: string,
file: string,
shutdown: Effect.Effect<void>,
) {
const fs = yield* FileSystem.FileSystem
const temp = file + "." + id + ".tmp"
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
const info = {
id,
version: OPENCODE_VERSION,
url: HttpServer.formatAddress(address),
pid: process.pid,
password,
}
const encoded = yield* encodeInfo(info)
const current = fs.readFileString(file).pipe(Effect.flatMap(decodeInfo))
const owns = (found: Info) =>
found.id === info.id &&
found.version === info.version &&
found.url === info.url &&
found.pid === info.pid &&
found.password === info.password
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
yield* current.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("managed service registration check failed; shutting down", {
cause,
serviceID: id,
servicePID: process.pid,
registration: file,
}).pipe(Effect.andThen(Effect.failCause(cause))),
),
Effect.tap((found) =>
owns(found)
? Effect.void
: Effect.logWarning("managed service registration replaced; shutting down", {
serviceID: id,
servicePID: process.pid,
registration: file,
observedServiceID: found.id,
observedServicePID: found.pid,
observedVersion: found.version,
observedURL: found.url,
}),
),
Effect.filterOrFail(owns),
Effect.repeat(Schedule.spaced("5 seconds")),
Effect.ignore,
Effect.andThen(shutdown),
Effect.forkScoped,
)
return current.pipe(
Effect.flatMap((found) => (owns(found) ? fs.remove(file) : Effect.void)),
Effect.ignore,
)
})
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) { const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe( const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe(
Effect.filterOrFail((value) => value !== undefined), Effect.filterOrFail((value) => value !== undefined),
@@ -1,71 +0,0 @@
export * as ServiceRegistration from "./service-registration"
import { Service, type Info } from "@opencode-ai/client/effect/service"
import path from "node:path"
import { Effect, FileSystem, Schedule, Schema } from "effect"
import { HttpServer } from "effect/unstable/http"
import { OPENCODE_VERSION } from "../version"
const infoJson = Schema.fromJsonString(Service.Info)
const encodeInfo = Schema.encodeEffect(infoJson)
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
export const register = Effect.fnUntraced(function* (options: {
readonly address: HttpServer.Address
readonly password: string
readonly id: string
readonly file: string
readonly shutdown: Effect.Effect<void>
}) {
const fs = yield* FileSystem.FileSystem
const temp = options.file + "." + options.id + ".tmp"
yield* fs.makeDirectory(path.dirname(options.file), { recursive: true })
const info = {
id: options.id,
version: OPENCODE_VERSION,
url: HttpServer.formatAddress(options.address),
pid: process.pid,
password: options.password,
}
const encoded = yield* encodeInfo(info)
const current = fs.readFileString(options.file).pipe(Effect.flatMap(decodeInfo))
const owns = (found: Info) =>
found.id === info.id &&
found.version === info.version &&
found.url === info.url &&
found.pid === info.pid &&
found.password === info.password
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, options.file)))
yield* current.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("managed service registration check failed; shutting down", {
cause,
serviceID: options.id,
servicePID: process.pid,
registration: options.file,
}).pipe(Effect.andThen(Effect.failCause(cause))),
),
Effect.tap((found) =>
owns(found)
? Effect.void
: Effect.logWarning("managed service registration replaced; shutting down", {
serviceID: options.id,
servicePID: process.pid,
registration: options.file,
observedServiceID: found.id,
observedServicePID: found.pid,
observedVersion: found.version,
observedURL: found.url,
}),
),
Effect.filterOrFail(owns),
Effect.repeat(Schedule.spaced("5 seconds")),
Effect.ignore,
Effect.andThen(options.shutdown),
Effect.forkScoped,
)
return current.pipe(
Effect.flatMap((found) => (owns(found) ? fs.remove(options.file) : Effect.void)),
Effect.ignore,
)
})
+84 -1
View File
@@ -1,14 +1,97 @@
import { describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import path from "node:path" 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", () => { describe("acp command", () => {
test("is registered", async () => { test("is registered", async () => {
const result = await cli(["--help"]) const result = await cli(["--help"])
expect(result.exitCode).toBe(0) expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("acp Start an Agent Client Protocol server") 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[]) { async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], { const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, "../.."), cwd: path.join(import.meta.dir, "../.."),
@@ -77,6 +77,13 @@ describe("acp lifecycle subprocess", () => {
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(false) expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(false)
}, 60_000) }, 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 () => { test("resume request returns session config options", async () => {
await using fixture = await createAcpFixture() await using fixture = await createAcpFixture()
const acp = fixture.spawn() const acp = fixture.spawn()
+16 -14
View File
@@ -7,7 +7,6 @@ import type {
import fs from "node:fs/promises" import fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
import { isolatedEnv } from "../fixture/environment"
type JsonRpcRequest = { type JsonRpcRequest = {
readonly jsonrpc: "2.0" readonly jsonrpc: "2.0"
@@ -101,30 +100,33 @@ export async function createAcpFixture(options: { readonly skill?: string } = {}
llm: { requests }, llm: { requests },
spawn(extraEnv: Record<string, string | undefined> = {}) { spawn(extraEnv: Record<string, string | undefined> = {}) {
const acp = spawnAcp({ const acp = spawnAcp({
env: isolatedEnv(root, { env: {
...process.env,
HOME: root,
USERPROFILE: root, USERPROFILE: root,
OPENCODE_CONFIG: undefined, OPENCODE_CONFIG: undefined,
OPENCODE_CONFIG_CONTENT: undefined, OPENCODE_CONFIG_CONTENT: undefined,
OPENCODE_CONFIG_DIR: config,
OPENCODE_DB: path.join(root, "opencode.db"),
OPENCODE_DISABLE_AUTOUPDATE: "true", OPENCODE_DISABLE_AUTOUPDATE: "true",
OPENCODE_DISABLE_FILEWATCHER: "true",
OPENCODE_DISABLE_MODELS_FETCH: "true",
OPENCODE_MODELS_PATH: undefined, 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, ...extraEnv,
}), },
}) })
processes.add(acp) processes.add(acp)
return acp return acp
}, },
async [Symbol.asyncDispose]() { async [Symbol.asyncDispose]() {
const processResults = await Promise.allSettled( await Promise.all([...processes].map((process) => process[Symbol.asyncDispose]()))
[...processes].map((process) => process.close().catch(() => process[Symbol.asyncDispose]())), await llm.stop(true)
) await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
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,6 +349,7 @@ test("serializes migration and updates across processes", async () => {
}) })
try { try {
await waitForFile(updateReady, update.exited) await waitForFile(updateReady, update.exited)
expect(await Promise.race([update.exited.then(() => true), Bun.sleep(500).then(() => false)])).toBe(false)
await Bun.write(release, "") await Bun.write(release, "")
const [migrateCode, updateCode] = await Promise.all([migrate.exited, update.exited]) const [migrateCode, updateCode] = await Promise.all([migrate.exited, update.exited])
expect(await new Response(migrate.stderr).text()).toBe("") expect(await new Response(migrate.stderr).text()).toBe("")
-19
View File
@@ -1,19 +0,0 @@
import path from "node:path"
export function isolatedEnv(root: string, overrides: Record<string, string | undefined> = {}) {
return {
...process.env,
HOME: root,
OPENCODE_CONFIG_CONTENT: "{}",
OPENCODE_CONFIG_DIR: path.join(root, "config"),
OPENCODE_DB: path.join(root, "opencode.db"),
OPENCODE_DISABLE_FILEWATCHER: "true",
OPENCODE_DISABLE_MODELS_FETCH: "true",
OPENCODE_TEST_HOME: root,
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "xdg-config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
...overrides,
}
}
+32 -25
View File
@@ -8,7 +8,6 @@ import fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
import { ServiceConfig } from "../src/services/service-config" import { ServiceConfig } from "../src/services/service-config"
import { ServiceRegistration } from "../src/services/service-registration"
test("managed service ports are stable per installation channel", () => { test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de) expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
@@ -151,6 +150,20 @@ test("preview registration migration never moves stable discovery", async () =>
} }
}) })
test("managed service writes its registration once", async () => {
const service = await startManagedService("opencode-service-once-")
try {
const before = await fs.stat(service.registration)
await Bun.sleep(6_000)
const after = await fs.stat(service.registration)
expect(after.ino).toBe(before.ino)
expect(after.mtimeMs).toBe(before.mtimeMs)
expect(await Bun.file(service.registration).json()).toEqual(service.info)
} finally {
await stopManagedService(service)
}
}, 30_000)
test("deleting a managed service registration stops its owner", async () => { test("deleting a managed service registration stops its owner", async () => {
const service = await startManagedService("opencode-service-delete-") const service = await startManagedService("opencode-service-delete-")
try { try {
@@ -442,45 +455,39 @@ test("port contender recognizes an incumbent registered during the bind race", a
} }
}, 45_000) }, 45_000)
test("service registration replaces a stale owner with the bound address", async () => { test("stale dead registration is replaced after binding the selected port", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-stale-")) const 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") 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.mkdir(path.dirname(registration), { recursive: true })
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
await fs.writeFile( await fs.writeFile(
registration, registration,
JSON.stringify({ id: "dead", version: "dead", url: "http://127.0.0.1:4321", pid: 2_147_483_647 }), JSON.stringify({ id: "dead", version: "dead", url: `http://127.0.0.1:${port}`, pid: 2_147_483_647 }),
) )
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env: serviceEnv(root),
stderr: "pipe",
stdout: "ignore",
})
try { try {
const cleanup = await Effect.runPromise( const info = await waitForInfo(registration, (value) => value.id !== "dead")
ServiceRegistration.register({ expect(new URL(info.url).port).toBe(String(port))
address: { _tag: "TcpAddress", hostname: "127.0.0.1", port: 4321 }, expect(info.pid).toBe(owner.pid)
password: "secret", await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
id: "owner", await owner.exited
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 { } finally {
owner.kill("SIGTERM")
await owner.exited
await fs.rm(root, { recursive: true, force: true }) await fs.rm(root, { recursive: true, force: true })
} }
}) }, 30_000)
test("a failed service stays registered and owns the selected port until stopped", async () => { 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 root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-failed-"))
const port = await availablePort()
const database = path.join(root, "database") const database = path.join(root, "database")
await fs.mkdir(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 = { const env = {
...process.env, ...process.env,
HOME: root, HOME: root,
+1 -7
View File
@@ -1,14 +1,10 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path" import path from "node:path"
import { isolatedEnv } from "./fixture/environment"
test("standalone server exits when its owner is killed", async () => { 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")], { const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture/standalone-owner.ts")], {
cwd: path.join(import.meta.dir, ".."), cwd: path.join(import.meta.dir, ".."),
env: isolatedEnv(root, { OPENCODE_SERVER_USERNAME: "custom" }), env: { ...process.env, OPENCODE_SERVER_USERNAME: "custom" },
stdin: "ignore", stdin: "ignore",
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
@@ -32,9 +28,7 @@ test("standalone server exits when its owner is killed", async () => {
expect(await waitForExit(pid)).toBe(true) expect(await waitForExit(pid)).toBe(true)
} finally { } finally {
owner.kill("SIGKILL") owner.kill("SIGKILL")
await owner.exited
if (running(pid)) process.kill(pid, "SIGKILL") 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 PromptAgentAttachment = { name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; mention?: PromptMention } export type PromptSkillAttachment = { id: string; name: string; text: string; mention?: PromptMention }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null } export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
@@ -2567,6 +2567,7 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{ readonly skills?: ReadonlyArray<{
readonly id: string readonly id: string
readonly name: string readonly name: string
readonly text: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly type: "user" readonly type: "user"
@@ -2835,6 +2836,7 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{ readonly skills?: ReadonlyArray<{
readonly id: string readonly id: string
readonly name: string readonly name: string
readonly text: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly type: "user" readonly type: "user"
@@ -3103,6 +3105,7 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{ readonly skills?: ReadonlyArray<{
readonly id: string readonly id: string
readonly name: string readonly name: string
readonly text: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string } readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}> }>
readonly type: "user" readonly type: "user"
+47 -120
View File
@@ -34,11 +34,10 @@ import type {
WebSearchProvider, WebSearchProvider,
} from "../promise" } from "../promise"
import { Worktree } from "@opencode-ai/schema/worktree" import { Worktree } from "@opencode-ai/schema/worktree"
import { SessionMessage } from "@opencode-ai/schema/session-message" import { isPermissionNotFoundError } from "../promise"
import { isPermissionNotFoundError, type SessionPromptInput } from "../promise"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox" import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { batch, createEffect, createSignal, onCleanup } from "solid-js" import { createEffect, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running" export type DataSessionStatus = "idle" | "running"
@@ -179,6 +178,11 @@ export function createData(config: CreateDataInput) {
setStore("session", "active", sessionID, status) 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) { function removePending(sessionID: string, inboxID?: string) {
if (!inboxID) return if (!inboxID) return
if (store.session.pending[sessionID]?.some((item) => item.id === inboxID)) if (store.session.pending[sessionID]?.some((item) => item.id === inboxID))
@@ -215,60 +219,6 @@ export function createData(config: CreateDataInput) {
setStore("session", "pending", sessionID, index, { ...item, delivery }) 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 = { const message = {
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) { update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
setStore( setStore(
@@ -375,7 +325,6 @@ export function createData(config: CreateDataInput) {
} }
function removeSession(sessionID: string) { function removeSession(sessionID: string) {
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
messageIndex.delete(sessionID) messageIndex.delete(sessionID)
sync.invalidate(`session:${sessionID}`) sync.invalidate(`session:${sessionID}`)
sync.invalidate(`session.pending:${sessionID}`) sync.invalidate(`session.pending:${sessionID}`)
@@ -544,16 +493,49 @@ export function createData(config: CreateDataInput) {
updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery) updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery)
return return
case "session.inbox.cancelled": { case "session.inbox.cancelled": {
retractLocal(event.data.sessionID, event.data.inboxID) removePending(event.data.sessionID, event.data.inboxID)
if (messageIndex.get(event.data.sessionID)?.has(event.data.inboxID))
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inboxID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(event.data.inboxID)
message.reindex(draft, index, position)
})
return return
} }
case "session.inbox.enqueued": { case "session.inbox.enqueued": {
outbox.delete(event.data.inboxID) const item = event.data.item
admitLocal({ addPending({
id: event.data.inboxID, id: event.data.inboxID,
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
timeCreated: event.created, timeCreated: event.created,
...event.data.item, ...item,
})
if (!store.session.input[event.data.sessionID]?.includes(event.data.inboxID))
setStore("session", "input", event.data.sessionID, [
...(store.session.input[event.data.sessionID] ?? []),
event.data.inboxID,
])
if (item.type !== "user" && item.type !== "synthetic") return
message.update(event.data.sessionID, (draft, index) => {
message.append(
draft,
index,
item.type === "user"
? {
id: event.data.inboxID,
type: "user",
...item.payload,
time: { created: event.created },
}
: {
id: event.data.inboxID,
type: "synthetic",
...item.payload,
time: { created: event.created },
},
)
}) })
return return
} }
@@ -1080,19 +1062,12 @@ export function createData(config: CreateDataInput) {
sync(sessionID: string) { sync(sessionID: string) {
return sync.run(`session.pending:${sessionID}`, async () => { return sync.run(`session.pending:${sessionID}`, async () => {
const pending = await api().session.inbox.list({ sessionID }) const pending = await api().session.inbox.list({ sessionID })
// Keep optimistic rows still awaiting their echo: this fetch may setStore("session", "pending", sessionID, reconcile(pending))
// 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( setStore(
"session", "session",
"input", "input",
sessionID, sessionID,
reconcile(merged.filter((item) => item.type !== "compaction").map((item) => item.id)), reconcile(pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
) )
}) })
}, },
@@ -1100,47 +1075,6 @@ export function createData(config: CreateDataInput) {
sync.invalidate(`session.pending:${sessionID}`) 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 }) { sync(sessionID: string, options?: { children?: boolean }) {
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => { return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
const [info, children] = await Promise.all([ const [info, children] = await Promise.all([
@@ -1180,14 +1114,7 @@ export function createData(config: CreateDataInput) {
sync(sessionID: string) { sync(sessionID: string) {
return sync.run(`session.message:${sessionID}`, async () => { return sync.run(`session.message:${sessionID}`, async () => {
const response = await api().message.list({ sessionID, limit: 200, order: "desc" }) const response = await api().message.list({ sessionID, limit: 200, order: "desc" })
const fetched = response.data.toReversed() const messages = 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]))) messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, reconcile(messages)) setStore("session", "message", sessionID, reconcile(messages))
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined) setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
-23
View File
@@ -1,23 +0,0 @@
export * as FileRetention from "./file-retention.js"
import { Duration, Effect, Option } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
export const cleanup = Effect.fn("FileRetention.cleanup")(function* (
fs: FSUtil.Interface,
files: ReadonlyArray<string>,
retention: Duration.Input,
) {
const cutoff = Date.now() - Duration.toMillis(retention)
yield* Effect.forEach(
files,
(file) =>
Effect.gen(function* () {
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
const mtime = info && Option.getOrUndefined(info.mtime)
if (!mtime || mtime.getTime() >= cutoff) return
yield* fs.remove(file).pipe(Effect.catch(() => Effect.void))
}),
{ concurrency: 8, discard: true },
)
})
+1 -46
View File
@@ -1,6 +1,6 @@
export * as KV from "./kv.js" export * as KV from "./kv.js"
import { and, asc, eq, gt, gte, lt } from "drizzle-orm" import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "./database/database.js" import { Database } from "./database/database.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -8,27 +8,10 @@ import { KVTable } from "./kv/sql.js"
export type Value = Schema.Json export 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 { export interface Interface {
readonly get: (key: string) => Effect.Effect<Value | undefined> readonly get: (key: string) => Effect.Effect<Value | undefined>
readonly set: (key: string, value: Value) => Effect.Effect<void> readonly set: (key: string, value: Value) => Effect.Effect<void>
readonly remove: (key: string) => 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") {} export class Service extends Context.Service<Service, Interface>()("@opencode/KV") {}
@@ -57,36 +40,8 @@ const layer = Layer.effect(
remove: Effect.fn("KV.remove")(function* (key) { remove: Effect.fn("KV.remove")(function* (key) {
yield* db.delete(KVTable).where(eq(KVTable.key, key)).run().pipe(Effect.orDie) 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] }) export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
-3
View File
@@ -68,8 +68,6 @@ export interface Resolved {
readonly capabilities: Capabilities readonly capabilities: Capabilities
/** Catalog pricing in dollars per million tokens. */ /** Catalog pricing in dollars per million tokens. */
readonly cost: Info["cost"] readonly cost: Info["cost"]
/** Catalog token limits used by Core for context management. */
readonly limit: Info["limit"]
} }
export interface Interface { export interface Interface {
@@ -299,7 +297,6 @@ export const layer = Layer.effect(
}), }),
capabilities: selected.capabilities, capabilities: selected.capabilities,
cost: selected.cost, cost: selected.cost,
limit: selected.limit,
} }
}) })
return Service.of({ return Service.of({
+2 -6
View File
@@ -11,7 +11,6 @@ import { Catalog } from "./catalog.js"
import { Command } from "./command.js" import { Command } from "./command.js"
import { Bus } from "./bus.js" import { Bus } from "./bus.js"
import { Integration } from "./integration.js" import { Integration } from "./integration.js"
import { KV } from "./kv.js"
import { MCP } from "./mcp/index.js" import { MCP } from "./mcp/index.js"
import { Location } from "./location.js" import { Location } from "./location.js"
import { PluginHost } from "./plugin/host.js" import { PluginHost } from "./plugin/host.js"
@@ -42,18 +41,16 @@ const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const kv = yield* KV.Service
const scope = yield* Scope.make() const scope = yield* Scope.make()
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>() const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1) const lock = Semaphore.makeUnsafe(1)
let inventory: Plugin.Info[] = [] let inventory: Plugin.Info[] = []
let host: Parameters<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>[0] let host: Parameters<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>[0]
const load = Effect.fnUntraced(function* (plugin: Versioned) { const load = Effect.fnUntraced(function* (plugin: Versioned) {
const child = yield* Scope.fork(scope) const child = yield* Scope.fork(scope)
const inherit = yield* State.inherit() const inherit = yield* State.inherit()
const loaded = yield* Effect.suspend(() => const loaded = yield* Effect.suspend(() => plugin.effect(host)).pipe(
plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) }),
).pipe(
inherit, inherit,
Effect.updateContext((context: Context.Context<never>) => Effect.updateContext((context: Context.Context<never>) =>
Context.make(Scope.Scope, child).pipe( Context.make(Scope.Scope, child).pipe(
@@ -192,7 +189,6 @@ export const node = makeLocationNode({
Catalog.node, Catalog.node,
Command.node, Command.node,
Integration.node, Integration.node,
KV.node,
MCP.node, MCP.node,
Location.node, Location.node,
Reference.node, Reference.node,
+1 -36
View File
@@ -14,7 +14,6 @@ import { Command } from "../command.js"
import { Credential } from "../credential.js" import { Credential } from "../credential.js"
import { Bus } from "../bus.js" import { Bus } from "../bus.js"
import { Integration } from "../integration.js" import { Integration } from "../integration.js"
import { KV } from "../kv.js"
import { Location } from "../location.js" import { Location } from "../location.js"
import { Model } from "../model.js" import { Model } from "../model.js"
import { MCP } from "../mcp/index.js" import { MCP } from "../mcp/index.js"
@@ -29,10 +28,7 @@ import { WebSearch } from "../websearch.js"
import { PluginHooks } from "./hooks.js" import { PluginHooks } from "./hooks.js"
const mutable = <T>(value: T) => value as DeepMutable<T> const mutable = <T>(value: T) => value as DeepMutable<T>
export const make = Effect.fn("PluginHost.make")(function* ( export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../plugin.js").Interface) {
plugin: import("../plugin.js").Interface,
pluginID: string = "test",
) {
const app = yield* App.Metadata const app = yield* App.Metadata
const agents = yield* Agent.Service const agents = yield* Agent.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
@@ -40,7 +36,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
const commands = yield* Command.Service const commands = yield* Command.Service
const bus = yield* Bus.Service const bus = yield* Bus.Service
const integration = yield* Integration.Service const integration = yield* Integration.Service
const kv = yield* KV.Service
const mcp = yield* MCP.Service const mcp = yield* MCP.Service
const location = yield* Location.Service const location = yield* Location.Service
const reference = yield* Reference.Service const reference = yield* Reference.Service
@@ -345,7 +340,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
}) })
}), }),
}, },
storage: storage(kv, pluginID),
shell: { shell: {
hook: (name, callback) => hooks.register("shell", name, callback), hook: (name, callback) => hooks.register("shell", name, callback),
}, },
@@ -412,35 +406,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
} satisfies Plugin.Context } 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 { function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {
if ("authorize" in input) { if ("authorize" in input) {
const refresh = input.refresh const refresh = input.refresh
+21 -9
View File
@@ -608,9 +608,10 @@ const layer = Layer.effect(
: Effect.die(defect), : Effect.die(defect),
), ),
) )
// First admission wins: same-session reuse is idempotent and ignores the if (
// retried payload, metadata, and delivery mode. admitted.type !== "user" ||
if (admitted.type !== "user" || admitted.sessionID !== input.sessionID) !SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: admittedInput })
)
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) { if (input.resume !== false) {
if (activeShells.has(admitted.sessionID)) return admitted if (activeShells.has(admitted.sessionID)) return admitted
@@ -790,7 +791,7 @@ const layer = Layer.effect(
payload, payload,
delivery: input.delivery ?? "steer", delivery: input.delivery ?? "steer",
}) })
yield* SessionInbox.serialized( const recovered = yield* SessionInbox.serialized(
input.sessionID, input.sessionID,
Effect.gen(function* () { Effect.gen(function* () {
const latest = yield* result.get(input.sessionID) const latest = yield* result.get(input.sessionID)
@@ -801,16 +802,25 @@ const layer = Layer.effect(
) )
const moved = [SessionEvent.Moved, { sessionID: input.sessionID, ...payload }] as const const moved = [SessionEvent.Moved, { sessionID: input.sessionID, ...payload }] as const
const first = cancellations[0] const first = cancellations[0]
if (!first) return yield* bus.publish(...moved).pipe(Effect.asVoid) if (!first) {
return yield* bus.publishAll([first, ...cancellations.slice(1), moved]) yield* bus.publish(...moved)
return true
}
yield* bus.publishAll([first, ...cancellations.slice(1), moved])
return true
} }
yield* SessionInbox.admit(db, bus, { yield* SessionInbox.admit(db, bus, {
id: SessionMessage.ID.create(), id: SessionMessage.ID.create(),
sessionID: input.sessionID, sessionID: input.sessionID,
item, item,
}) })
return false
}), }),
) )
if (recovered) {
yield* execution.wakeActive(input.sessionID)
return
}
yield* execution.wake(input.sessionID) yield* execution.wake(input.sessionID)
}), }),
compact: Effect.fn("Session.compact")(function* (input) { compact: Effect.fn("Session.compact")(function* (input) {
@@ -882,9 +892,10 @@ const layer = Layer.effect(
: Effect.die(defect), : Effect.die(defect),
), ),
) )
// First admission wins: same-session reuse is idempotent and ignores the if (
// retried payload, metadata, and delivery mode. admitted.type !== "synthetic" ||
if (admitted.type !== "synthetic" || admitted.sessionID !== input.sessionID) !SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: admittedInput })
)
return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID }) return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID })
if (input.resume !== false && !(yield* result.get(input.sessionID)).revert) if (input.resume !== false && !(yield* result.get(input.sessionID)).revert)
yield* execution.wake(input.sessionID) yield* execution.wake(input.sessionID)
@@ -971,6 +982,7 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
return Effect.succeed({ return Effect.succeed({
id: skill.id, id: skill.id,
name: skill.name, name: skill.name,
text: Skill.toModelOutput(skill, []),
mention: attachment.mention, mention: attachment.mention,
}) })
}) })
+26 -16
View File
@@ -1,6 +1,6 @@
export * as SessionCompaction from "./compaction.js" export * as SessionCompaction from "./compaction.js"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai" import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route" import type { StreamOptions } from "@opencode-ai/ai/route"
import { SessionError } from "@opencode-ai/schema/session-error" import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect" import { Context, Effect, Layer, Stream } from "effect"
@@ -18,6 +18,7 @@ import { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js" import { SessionSchema } from "./schema.js"
import { toSessionError } from "./to-session-error.js" import { toSessionError } from "./to-session-error.js"
import { Token } from "../util/token.js" import { Token } from "../util/token.js"
import type { Info, Ref } from "../model.js"
import { SessionUsage } from "./usage.js" import { SessionUsage } from "./usage.js"
import { PluginHooks } from "../plugin/hooks.js" import { PluginHooks } from "../plugin/hooks.js"
import { Agent } from "../agent.js" import { Agent } from "../agent.js"
@@ -82,7 +83,9 @@ type Dependencies = {
export type AutoInput = { export type AutoInput = {
readonly session: SessionSchema.Info readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[] readonly messages: readonly SessionMessage.Info[]
readonly resolved: SessionRunnerModel.Resolved readonly model: LanguageModel
readonly ref: Ref
readonly cost: Info["cost"]
} }
export type ManualInput = { export type ManualInput = {
@@ -92,11 +95,13 @@ export type ManualInput = {
readonly started?: boolean readonly started?: boolean
} }
type RequiredInput = Pick<AutoInput, "messages" | "resolved"> type RequiredInput = Omit<AutoInput, "ref">
type Plan = { type Plan = {
readonly session: SessionSchema.Info readonly session: SessionSchema.Info
readonly resolved: SessionRunnerModel.Resolved readonly model: LanguageModel
readonly ref: Ref
readonly cost: Info["cost"]
readonly reason: SessionMessage.Compaction["reason"] readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string readonly prompt: string
readonly recent: string readonly recent: string
@@ -133,7 +138,8 @@ const serialize = (message: SessionMessage.Info) => {
(file) => (file) =>
`[Attached ${file.mime}: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}]`, `[Attached ${file.mime}: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}]`,
) ?? [] ) ?? []
return [`[User]: ${message.text}`, ...files].join("\n") const skills = message.skills?.map((skill) => `[Attached skill: ${skill.name}]\n${skill.text}`) ?? []
return [`[User]: ${message.text}`, ...skills, ...files].join("\n")
} }
if (message.type === "location-switched") if (message.type === "location-switched")
return `[User]: The working directory has been changed to ${message.location.directory}.` return `[User]: The working directory has been changed to ${message.location.directory}.`
@@ -268,9 +274,9 @@ const make = (dependencies: Dependencies) => {
) )
const request = yield* SessionModelHook.apply( const request = yield* SessionModelHook.apply(
dependencies.hooks, dependencies.hooks,
{ sessionID: plan.session.id, agent: Agent.ID.make("compaction"), model: plan.resolved.ref }, { sessionID: plan.session.id, agent: Agent.ID.make("compaction"), model: plan.ref },
LLM.request({ LLM.request({
model: plan.resolved.model, model: plan.model,
promptCacheKey: SessionPromptCacheKey.make(plan.session.id), promptCacheKey: SessionPromptCacheKey.make(plan.session.id),
http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) }, http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) },
messages: [Message.user(plan.prompt)], messages: [Message.user(plan.prompt)],
@@ -282,7 +288,7 @@ const make = (dependencies: Dependencies) => {
http: SessionModelHttp.middleware(dependencies.hooks, { http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: plan.session.id, sessionID: plan.session.id,
agent: Agent.ID.make("compaction"), agent: Agent.ID.make("compaction"),
model: plan.resolved.ref, model: plan.ref,
}), }),
}) })
.pipe( .pipe(
@@ -300,7 +306,7 @@ const make = (dependencies: Dependencies) => {
}) })
} }
if (LLMEvent.is.stepFinish(event)) { if (LLMEvent.is.stepFinish(event)) {
const step = SessionUsage.record(event.usage, plan.resolved.cost) const step = SessionUsage.record(event.usage, plan.cost)
usage = usage ? SessionUsage.add(usage, step) : step usage = usage ? SessionUsage.add(usage, step) : step
} }
return Effect.void return Effect.void
@@ -349,7 +355,9 @@ const make = (dependencies: Dependencies) => {
if (content) if (content)
return yield* execute({ return yield* execute({
session: input.session, session: input.session,
resolved: input.resolved, model: input.model,
ref: input.ref,
cost: input.cost,
reason: "auto", reason: "auto",
...content, ...content,
}) })
@@ -363,17 +371,17 @@ const make = (dependencies: Dependencies) => {
const required = (input: RequiredInput) => { const required = (input: RequiredInput) => {
const config = state.get() const config = state.get()
if (!config.auto) return false if (!config.auto) return false
const limit = input.resolved.limit const context = input.model.route.defaults.limits?.context
const context = limit.context if (context === undefined || context <= 0) return false
if (context <= 0) return false
const last = input.messages.findLast( const last = input.messages.findLast(
(message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } => (message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } =>
message.type === "assistant" && message.tokens !== undefined, message.type === "assistant" && message.tokens !== undefined,
) )
if (!last) return false if (!last) return false
const output = Math.min(limit.output, OUTPUT_TOKEN_MAX) const limits = input.model.route.defaults.limits
const output = Math.min(limits?.output ?? 0, OUTPUT_TOKEN_MAX)
const promptCeiling = Math.min( const promptCeiling = Math.min(
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer, limits?.input === undefined ? Number.POSITIVE_INFINITY : limits.input - config.buffer,
context - Math.max(output, config.buffer), context - Math.max(output, config.buffer),
) )
const used = const used =
@@ -403,7 +411,9 @@ const make = (dependencies: Dependencies) => {
if ("status" in resolved) return resolved if ("status" in resolved) return resolved
return yield* execute({ return yield* execute({
session: input.session, session: input.session,
resolved, model: resolved.model,
ref: resolved.ref,
cost: resolved.cost,
reason: "manual", reason: "manual",
inputID: input.inputID, inputID: input.inputID,
started: input.started, started: input.started,
+15 -24
View File
@@ -21,11 +21,9 @@ export interface Interface {
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError> readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */ /** Registers newly recorded work. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void> readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** /** Wakes only an active execution, preserving its current input eligibility. */
* Interrupt active work owned by this process. Idle interruption is a no-op. Resolves once readonly wakeActive: (sessionID: SessionSchema.ID) => Effect.Effect<void>
* the interruption is accepted; cleanup settles asynchronously in the execution fiber. /** Interrupt active work owned by this process. Idle interruption is a no-op. */
* Compose with `awaitIdle` when settlement matters.
*/
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void> 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. */ /** 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> readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
@@ -34,7 +32,7 @@ export interface Interface {
/** Routes execution from a Session ID to the runner owned by that Session's Location. */ /** 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") {} export class Service extends Context.Service<Service, Interface>()("@opencode/SessionExecution") {}
type InterruptReason = "user" | "shutdown" type InterruptReason = "user" | "shutdown" | "superseded"
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: InterruptReason) { export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: InterruptReason) {
if (Exit.isSuccess(exit)) return { type: "succeeded" as const } if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
@@ -115,8 +113,8 @@ export const layer = Layer.effect(
return return
} }
if (outcome.type === "interrupted") { if (outcome.type === "interrupted") {
// A user cancel releases the claim: the turn must not resurrect at the next // A user cancel (or a superseding execution) releases the claim: the turn must not
// boot. Shutdown interruption keeps it for restart continuity. // resurrect at the next boot. Shutdown interruption keeps it for restart continuity.
yield* bus.publish( yield* bus.publish(
SessionEvent.Execution.Interrupted, SessionEvent.Execution.Interrupted,
{ sessionID, reason: outcome.reason }, { sessionID, reason: outcome.reason },
@@ -139,24 +137,16 @@ export const layer = Layer.effect(
return Service.of({ return Service.of({
active: coordinator.active, active: coordinator.active,
interrupt: (sessionID, options) => interrupt: (sessionID, options) =>
Effect.gen(function* () { coordinator.interrupt(
yield* coordinator.interrupt(sessionID, "user") sessionID,
if (!options?.continue) return "user",
// Resume steering input and between-turn control work from the interrupted options?.continue
// intent. Queued next-turn prompts stay parked: a steer-scoped drain never ? { continue: { request: "steer", when: SessionInbox.has(db, sessionID, "steer") } }
// promotes them, and a control item behind a queued prompt waits its turn. : undefined,
// 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, resume: coordinator.run,
wake: coordinator.wake, wake: coordinator.wake,
wakeActive: coordinator.wakeActive,
awaitIdle: coordinator.awaitIdle, awaitIdle: coordinator.awaitIdle,
}) })
}), }),
@@ -175,6 +165,7 @@ export const noopLayer = Layer.succeed(
active: Effect.succeed(new Set()), active: Effect.succeed(new Set()),
resume: () => Effect.void, resume: () => Effect.void,
wake: () => Effect.void, wake: () => Effect.void,
wakeActive: () => Effect.void,
interrupt: () => Effect.void, interrupt: () => Effect.void,
awaitIdle: () => Effect.void, awaitIdle: () => Effect.void,
}), }),
+63 -21
View File
@@ -1,54 +1,96 @@
export * as SessionGenerateNode from "./generate-node.js" export * as SessionGenerateNode from "./generate-node.js"
import { LLMClient, Message } from "@opencode-ai/ai" import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { Database } from "../database/database.js" import { Database } from "../database/database.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app.js"
import { llmClient } from "../effect/app-node-platform.js" import { llmClient } from "../effect/app-node-platform.js"
import { PluginHooks } from "../plugin/hooks.js"
import { SessionContext } from "./context.js" import { SessionContext } from "./context.js"
import { SessionGenerate } from "./generate.js" import { SessionGenerate } from "./generate.js"
import { SessionHistory } from "./history.js" import { SessionHistory } from "./history.js"
import { SessionModelRequest } from "./model-request.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 { SessionRunnerModel } from "./runner/model.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( export const layer = Layer.effect(
SessionGenerate.Service, SessionGenerate.Service,
Effect.gen(function* () { Effect.gen(function* () {
const context = yield* SessionContext.Service const context = yield* SessionContext.Service
const database = yield* Database.Service const database = yield* Database.Service
const hooks = yield* PluginHooks.Service
const llm = yield* LLMClient.Service const llm = yield* LLMClient.Service
const models = yield* SessionRunnerModel.Service const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service const app = yield* App.Metadata
return SessionGenerate.Service.of({ return SessionGenerate.Service.of({
generate: Effect.fn("SessionGenerate.generate")(function* (input) { generate: Effect.fn("SessionGenerate.generate")(function* (input) {
const selection = yield* context.select(input.sessionID) const selection = yield* context.select(input.sessionID)
const model = yield* models.resolve(selection.session) const model = yield* models.resolve(selection.session)
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions) const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
const transcript = SessionModelRequest.baseTranscript({ const providerMetadataKey = model.model.route.providerMetadataKey ?? model.model.provider
agent: selection.agent.info, const tools = selection.tools
model, const toolDefinitions = tools.definitions
tools: selection.tools, const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
initial: history.initial, const contextEvent = yield* hooks.trigger("session", "context", {
messages: history.messages, 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 prepared = yield* modelRequests.prepare({ const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools }, const registered = toolsByName.get(name)
transcript: { return registered
system: transcript.system, ? [Object.assign({}, registered, { description: tool.description, inputSchema: tool.input })]
messages: [ : []
...transcript.messages,
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
Message.user(input.prompt),
],
},
}) })
yield* Effect.logInfo("sending session generation request", { yield* Effect.logInfo("sending session generation request", {
sessionID: selection.session.id, sessionID: selection.session.id,
providerID: model.ref.providerID, providerID: model.ref.providerID,
modelID: model.ref.id, modelID: model.ref.id,
}) })
const response = yield* llm.generate(prepared.request, prepared.options) 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,
}),
})
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage }) yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
return response.text return response.text
}), }),
@@ -59,5 +101,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({ export const node = makeLocationNode({
service: SessionGenerate.Service, service: SessionGenerate.Service,
layer, layer,
deps: [SessionContext.node, Database.node, SessionModelRequest.node, SessionRunnerModel.node, llmClient], deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, App.node, llmClient],
}) })
+58 -19
View File
@@ -333,29 +333,49 @@ export const moveIDs = Effect.fn("SessionInbox.moveIDs")(function* (db: Database
.pipe(Effect.orDie) .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* ( export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function* (
db: DatabaseService, db: DatabaseService,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
promotable: Promotable, promotable: Promotable,
) { ) {
const next = (delivery: Delivery) => return (yield* nextSteer(db, sessionID)) ?? (promotable === "input" ? yield* nextQueued(db, sessionID) : undefined)
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: "input" means any item in either delivery mode. */ /**
export type Scope = "input" | Delivery * Which pending rows count: "any" counts every row, while "input" means any
* item in either delivery mode.
*/
export type Scope = "any" | "input" | Delivery
export const has = Effect.fn("SessionInbox.has")(function* ( export const has = Effect.fn("SessionInbox.has")(function* (
db: DatabaseService, db: DatabaseService,
@@ -368,9 +388,11 @@ export const has = Effect.fn("SessionInbox.has")(function* (
.where( .where(
and( and(
eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.session_id, sessionID),
scope === "input" scope === "any"
? or(eq(SessionInboxTable.delivery, "steer"), eq(SessionInboxTable.delivery, "queue")) ? undefined
: eq(SessionInboxTable.delivery, scope), : scope === "input"
? or(eq(SessionInboxTable.delivery, "steer"), eq(SessionInboxTable.delivery, "queue"))
: eq(SessionInboxTable.delivery, scope),
), ),
) )
.limit(1) .limit(1)
@@ -379,6 +401,23 @@ export const has = Effect.fn("SessionInbox.has")(function* (
return row !== undefined 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>) => const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
serialized(input.sessionID, effect).pipe(Effect.asVoid) serialized(input.sessionID, effect).pipe(Effect.asVoid)
+60 -52
View File
@@ -12,17 +12,16 @@ import { Permission } from "../permission.js"
import { PluginHooks } from "../plugin/hooks.js" import { PluginHooks } from "../plugin/hooks.js"
import { QuestionTool } from "../tool/plugin/question.js" import { QuestionTool } from "../tool/plugin/question.js"
import { Tool } from "../tool.js" import { Tool } from "../tool.js"
import { SessionContext } from "./context.js"
import { SessionModelHeaders } from "./model-headers.js" import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHook } from "./model-hook.js" import { SessionModelHook } from "./model-hook.js"
import { SessionModelHttp } from "./model-http.js" import { SessionModelHttp } from "./model-http.js"
import { SessionModelTransport } from "./model-transport.js" import { SessionModelTransport } from "./model-transport.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js" import { SessionPromptCacheKey } from "./prompt-cache-key.js"
import { SessionRunnerModel } from "./runner/model.js" import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics.js"
import { SessionSchema } from "./schema.js" import { MAX_STEPS_PROMPT } from "./runner/max-steps.js"
import { SessionSystemPrompt } from "./system-prompt.js" import { SessionSystemPrompt } from "./system-prompt.js"
import { toLLMMessages } from "./runner/to-llm-message.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_TRIGGER = 25 * 1024 * 1024 // 25 MiB
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
@@ -49,49 +48,20 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
interface Prepared { interface Prepared {
readonly request: LLMRequest readonly request: LLMRequest
readonly options: StreamOptions readonly options: StreamOptions
/** False when Session HTTP hooks require the request to remain on HTTP. */
readonly webSocketEligible: boolean
/** /**
* One request-scoped execution operation. Unknown and hook-removed calls * One request-scoped execution operation. Unknown, hook-removed, and
* fail individually through the same seam. * step-limit-violating calls fail individually through the same seam.
*/ */
readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError> 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 { interface PrepareInput {
readonly scope: { readonly context: SessionContext.Loaded
readonly session: SessionSchema.Info readonly step: number
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) => { const mimeToModality = (mime: string) => {
@@ -205,11 +175,30 @@ export const layer = Layer.effect(
Config.withDefault(false), Config.withDefault(false),
Effect.orDie, 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 prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
const session = input.scope.session const session = input.context.session
const resolved = input.scope.model const agent = input.context.agent
const resolved = input.context.model
const model = resolved.model const model = resolved.model
const tools = input.scope.tools 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 registry = new Map(tools.definitions.map((tool) => [tool.name, tool])) 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 // 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. // tool by moving its definition to a new key; recognizing the object recovers the tool.
@@ -221,10 +210,10 @@ export const layer = Layer.effect(
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove. // Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
const context = yield* hooks.trigger("session", "context", { const context = yield* hooks.trigger("session", "context", {
sessionID: session.id, sessionID: session.id,
agent: input.scope.agentID, agent: agent.id,
model: resolved.ref, model: resolved.ref,
system: input.transcript.system, system,
messages: input.transcript.messages, messages,
tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])), 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 // Match each surviving entry back to its tool, by recognizing a moved definition or
@@ -240,7 +229,7 @@ export const layer = Layer.effect(
) )
const request = yield* SessionModelHook.apply( const request = yield* SessionModelHook.apply(
hooks, hooks,
{ sessionID: session.id, agent: input.scope.agentID, model: resolved.ref }, { sessionID: session.id, agent: agent.id, model: resolved.ref },
LLM.request({ LLM.request({
model, model,
http: { http: {
@@ -251,7 +240,7 @@ export const layer = Layer.effect(
system: context.system, system: context.system,
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)), messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })), tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
toolChoice: input.toolChoice, toolChoice: stepLimitReached ? "none" : undefined,
}), }),
) )
const webSocketEligible = const webSocketEligible =
@@ -261,20 +250,37 @@ export const layer = Layer.effect(
? undefined ? undefined
: SessionModelHttp.middleware(hooks, { : SessionModelHttp.middleware(hooks, {
sessionID: session.id, sessionID: session.id,
agent: input.scope.agentID, agent: agent.id,
model: resolved.ref, model: resolved.ref,
}) })
const options: StreamOptions = { const options: StreamOptions = {
...(http ? { http } : {}), ...(http ? { http } : {}),
...(input.webSocket === "session" && ...(webSocket &&
webSocket &&
webSocketEligible && webSocketEligible &&
resolved.ref.providerID === Provider.ID.openai && resolved.ref.providerID === Provider.ID.openai &&
request.model.route.id === "openai-responses" request.model.route.id === "openai-responses"
? { webSocket: transport.bind(session.id) } ? { 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) => { 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) const tool = hooked.get(input.call.name)
// A registered tool absent from the hooked set was removed or renamed by a hook. // A registered tool absent from the hooked set was removed or renamed by a hook.
if (!tool && registry.has(input.call.name)) if (!tool && registry.has(input.call.name))
@@ -286,7 +292,9 @@ export const layer = Layer.effect(
return { return {
request, request,
options, options,
webSocketEligible,
executeTool, executeTool,
stepLimitReached,
} }
}) })
+79 -47
View File
@@ -10,38 +10,41 @@ export interface Coordinator<Key, E, Reason = never> {
/** Starts an execution while idle, or joins the active execution and returns its exit. */ /** Starts an execution while idle, or joins the active execution and returns its exit. */
readonly run: (key: Key) => Effect.Effect<void, E> readonly run: (key: Key) => Effect.Effect<void, E>
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */ /** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
readonly wake: (key: Key, scope?: Promotable) => Effect.Effect<void> readonly wake: (key: Key, request?: Request) => Effect.Effect<void>
/** /** Rings the current execution's doorbell with its existing request. Idle keys remain idle. */
* Stops the active execution and clears its doorbell. No-op when idle. Resolves once the readonly wakeActive: (key: Key) => Effect.Effect<void>
* interruption is accepted, not when cleanup settles: the execution fiber finishes its /** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
* finalizers and settled hook on its own time. Compose with `awaitIdle` for settlement. readonly interrupt: (
*/ key: Key,
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void> reason?: Reason,
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */ /** 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> 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 * 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 * until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
* execution rings it with the scope that work needs, and the execution loop drains again * execution rings it with its eligibility request, and the execution loop drains again
* instead of ending. The doorbell closes the gap between a drain's last eligibility check * 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 * and the idle transition, since those cannot be one atomic step. `done` resolves joiners
* with this execution's exit. * with this execution's exit.
*/ */
type Execution<E, Reason> = { 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> owner?: Fiber.Fiber<void>
scope: Promotable request: Request
pendingWake?: Promotable pendingWake?: Request
stopping: boolean stopping: boolean
interruptionReason?: Reason interruptionReason?: Reason
continuation?: {
readonly request: Request
readonly when: Effect.Effect<boolean>
signaled: boolean
}
} }
/** /**
@@ -56,7 +59,7 @@ type Execution<E, Reason> = {
* ``` * ```
*/ */
export const make = <Key, E, Reason = never>(options: { export const make = <Key, E, Reason = never>(options: {
readonly drain: (key: Key, force: boolean, scope: Promotable) => Effect.Effect<void, E> readonly drain: (key: Key, force: boolean, request: Request) => Effect.Effect<void, E>
/** Runs once when a process-local busy period begins, before its first drain. */ /** Runs once when a process-local busy period begins, before its first drain. */
readonly started?: (key: Key) => Effect.Effect<void> readonly started?: (key: Key) => Effect.Effect<void>
/** /**
@@ -70,11 +73,11 @@ export const make = <Key, E, Reason = never>(options: {
const fork = yield* FiberSet.makeRuntime<never, void, never>() const fork = yield* FiberSet.makeRuntime<never, void, never>()
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> => const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
Effect.suspend(() => options.drain(key, force, execution.scope)).pipe( Effect.suspend(() => options.drain(key, force, execution.request)).pipe(
Effect.flatMap(() => Effect.flatMap(() =>
Effect.suspend(() => { Effect.suspend(() => {
if (execution.stopping || execution.pendingWake === undefined) return Effect.void if (execution.stopping || execution.pendingWake === undefined) return Effect.void
execution.scope = execution.pendingWake execution.request = execution.pendingWake
execution.pendingWake = undefined execution.pendingWake = undefined
// Trampoline so drains that complete synchronously cannot grow the stack. // Trampoline so drains that complete synchronously cannot grow the stack.
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false))) return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
@@ -82,10 +85,10 @@ export const make = <Key, E, Reason = never>(options: {
), ),
) )
const start = (key: Key, force: boolean, scope: Promotable) => { const start = (key: Key, force: boolean, request: Request) => {
const execution: Execution<E, Reason> = { const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<Exit.Exit<void, E>>(), done: Deferred.makeUnsafe<void, E>(),
scope, request,
stopping: false, stopping: false,
} }
executions.set(key, execution) executions.set(key, execution)
@@ -101,7 +104,7 @@ export const make = <Key, E, Reason = never>(options: {
execution.owner = undefined execution.owner = undefined
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)), }).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
), ),
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))), Effect.onExit((exit) => finish(key, execution, exit)),
Effect.exit, Effect.exit,
Effect.asVoid, Effect.asVoid,
), ),
@@ -111,10 +114,20 @@ export const make = <Key, E, Reason = never>(options: {
// A doorbell that survives the execution loop (rung after the loop decided to end, or // 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. // 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>) => { const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>, resume: boolean) => {
if (execution.pendingWake) start(key, false, execution.pendingWake) if (resume && execution.continuation) start(key, false, execution.continuation.request)
else if (execution.pendingWake) start(key, false, execution.pendingWake)
else executions.delete(key) else executions.delete(key)
Deferred.doneUnsafe(execution.done, Exit.succeed(exit)) 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)),
),
)
} }
const run = (key: Key): Effect.Effect<void, E> => const run = (key: Key): Effect.Effect<void, E> =>
@@ -123,42 +136,61 @@ export const make = <Key, E, Reason = never>(options: {
if (execution !== undefined) { if (execution !== undefined) {
// A stopping execution refuses joiners: wait out its cleanup, then run fresh. // 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))) if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
return Deferred.await(execution.done).pipe(Effect.flatten) return Deferred.await(execution.done)
} }
return Deferred.await(start(key, true, "input").done).pipe(Effect.flatten) return Deferred.await(start(key, true, "input").done)
}) })
const wake = (key: Key, scope: Promotable = "input") => const wake = (key: Key, request: Request = "input") =>
Effect.sync(() => { Effect.sync(() => {
const execution = executions.get(key) const execution = executions.get(key)
if (execution !== undefined) { if (execution !== undefined) {
// Coalesced wakes keep the widest scope: "input" subsumes "steer". if (execution.stopping) {
execution.pendingWake = execution.pendingWake === "input" ? "input" : scope 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
return return
} }
start(key, false, scope) start(key, false, request)
}) })
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> => const wakeActive = (key: Key) =>
Effect.suspend(() => { Effect.suspend(() => {
const execution = executions.get(key) const execution = executions.get(key)
if (execution === undefined || execution.stopping) return Effect.void 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.owner === undefined) { if (execution.owner === undefined) {
// Settlement window: the owner exited but the settled hook has not finished. The if (!options?.continue) return Effect.void
// terminal outcome is already decided, so no reason attaches — but the interrupt execution.stopping = true
// still claims the recorded wakes so settle does not start a dead-intent successor.
execution.pendingWake = undefined execution.pendingWake = undefined
return Effect.void execution.continuation = { ...options.continue, signaled: false }
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
} }
execution.stopping = true 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.pendingWake = undefined
execution.interruptionReason = reason execution.interruptionReason = reason
// Fire and forget: nobody benefits from waiting out cleanup here, and callers like if (options?.continue) execution.continuation = { ...options.continue, signaled: false }
// the interrupt endpoint must acknowledge immediately even when finalizers are slow. return Fiber.interrupt(execution.owner)
fork(Fiber.interrupt(execution.owner))
return Effect.void
}) })
// One execution's `done` already spans coalesced continuations; re-check after it // One execution's `done` already spans coalesced continuations; re-check after it
@@ -167,8 +199,8 @@ export const make = <Key, E, Reason = never>(options: {
Effect.suspend(() => { Effect.suspend(() => {
const execution = executions.get(key) const execution = executions.get(key)
if (execution === undefined) return Effect.void if (execution === undefined) return Effect.void
return Deferred.await(execution.done).pipe(Effect.andThen(awaitIdle(key))) return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
}) })
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle } return { active: Effect.sync(() => new Set(executions.keys())), run, wake, wakeActive, interrupt, awaitIdle }
}) })
+12 -73
View File
@@ -4,12 +4,11 @@ import {
LLMClient, LLMClient,
AIError, AIError,
LLMEvent, LLMEvent,
Message,
isContextOverflowFailure, isContextOverflowFailure,
type ProviderErrorEvent, type ProviderErrorEvent,
type ToolCall, type ToolCall,
} from "@opencode-ai/ai" } from "@opencode-ai/ai"
import { Cause, Config, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect" import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
import { Database } from "../../database/database.js" import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js" import { Bus } from "../../bus.js"
import { Permission } from "../../permission.js" import { Permission } from "../../permission.js"
@@ -36,9 +35,6 @@ import { SessionRunnerRetry } from "./retry.js"
import { SessionUsage } from "../usage.js" import { SessionUsage } from "../usage.js"
import { ToolOutput } from "../../tool-output.js" import { ToolOutput } from "../../tool-output.js"
import { PluginSupervisor } from "../../plugin/supervisor.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. */ /** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */
type CallOutcome = Data.TaggedEnum<{ type CallOutcome = Data.TaggedEnum<{
@@ -122,32 +118,6 @@ const layer = Layer.effect(
const plugins = yield* PluginSupervisor.Service const plugins = yield* PluginSupervisor.Service
const title = yield* SessionTitle.Service const title = yield* SessionTitle.Service
const toolOutput = yield* ToolOutput.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. // 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. // The in-flight set coalesces overlapping prompts while title presence records success durably.
const titlesRunning = new Set<SessionSchema.ID>() const titlesRunning = new Set<SessionSchema.ID>()
@@ -165,36 +135,26 @@ const layer = Layer.effect(
let force = input.force let force = input.force
let continuation = input.continuation let continuation = input.continuation
const promotable = input.promotable ?? "input" const promotable = input.promotable ?? "input"
if (!force && !continuation && !(yield* eligible(input.sessionID, promotable))) if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return { type: "complete" as const } return { type: "complete" as const }
yield* plugins.flush yield* plugins.flush
yield* settleStaleToolCalls(input.sessionID) yield* settleStaleToolCalls(input.sessionID)
while (true) { while (true) {
// Between-turn control items run under any drain scope: scope gates which user if (yield* runPendingCompaction(input.sessionID, promotable)) {
// 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 force = false
continue continue
} }
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const } if (yield* runPendingMove(input.sessionID, promotable)) return { type: "moved" as const }
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable))) if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return { type: "complete" as const } return { type: "complete" as const }
const result = yield* runSteps(input.sessionID, continuation, promotable) const result = yield* runSteps(input.sessionID, continuation, promotable)
if (result.type === "moved") return result if (result.type === "moved") return result
if (promotable === "steer") return { type: "complete" as const }
force = false force = false
continuation = undefined 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 * 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. * model call. Queued inputs remain pending until the current model work reaches idle.
@@ -318,39 +278,17 @@ const layer = Layer.effect(
const model = resolved.model const model = resolved.model
// Make room: history must fit the context window before the call. A pending manual // Make room: history must fit the context window before the call. A pending manual
// compaction owns this instead; the runner executes it between steps. // compaction owns this instead; the runner executes it between steps.
const compactionInput = { session, messages: loaded.messages, resolved } const compactionInput = { session, messages: loaded.messages, model, ref: resolved.ref, cost: resolved.cost }
if (compaction.required(compactionInput)) { if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput) const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed") if (compacted.status === "completed")
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false }) return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
return yield* new StepFailedError({ error: compacted.error }) return yield* new StepFailedError({ error: compacted.error })
} }
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({ const prepared = yield* modelRequests.prepare({
scope: { session, agentID: agent.id, model: resolved, tools: loaded.tools }, context: loaded,
transcript: { step: currentStep,
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. // Every local tool call forked here is owned until it reaches one durable settlement.
const toolRuns: Array<{ const toolRuns: Array<{
readonly call: ToolCall readonly call: ToolCall
@@ -364,7 +302,7 @@ const layer = Layer.effect(
// The selected catalog identity, not model.id: route-level ids are provider API // 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 ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
model: resolved.ref, model: resolved.ref,
providerMetadataKey: transcript.providerMetadataKey, providerMetadataKey: model.route.providerMetadataKey ?? model.provider,
snapshot: startSnapshot, snapshot: startSnapshot,
assistantMessageID, assistantMessageID,
}) })
@@ -427,7 +365,7 @@ const layer = Layer.effect(
call: event, call: event,
fiber: yield* Effect.uninterruptibleMask((restore) => fiber: yield* Effect.uninterruptibleMask((restore) =>
restore( restore(
executeTool({ prepared.executeTool({
sessionID: session.id, sessionID: session.id,
agent: agent.id, agent: agent.id,
messageID: assistantMessageID, messageID: assistantMessageID,
@@ -575,7 +513,8 @@ const layer = Layer.effect(
// A local call or malformed tool input requires another model step, unless // A local call or malformed tool input requires another model step, unless
// this step already exhausted the agent's allowance. // this step already exhausted the agent's allowance.
needsContinuation: needsContinuation:
!stepLimitReached && record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)), !prepared.stepLimitReached &&
record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)),
step: currentStep, step: currentStep,
}) })
}), }),
@@ -53,7 +53,6 @@ export const resolved = (
readonly capabilities: Capabilities readonly capabilities: Capabilities
readonly variant?: VariantID readonly variant?: VariantID
readonly cost: Info["cost"] readonly cost: Info["cost"]
readonly limit: Info["limit"]
}, },
): Resolved => ({ ): Resolved => ({
model, model,
@@ -64,7 +63,6 @@ export const resolved = (
}), }),
capabilities: options.capabilities, capabilities: options.capabilities,
cost: options.cost, cost: options.cost,
limit: options.limit,
}) })
const layer = Layer.effect( const layer = Layer.effect(
@@ -227,6 +227,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
] ]
case "user": case "user":
const content = [ const content = [
...(message.skills ?? []).map((skill) => Message.text(skill.text)),
...(message.text === "" ? [] : [Message.text(message.text)]), ...(message.text === "" ? [] : [Message.text(message.text)]),
...userAttachmentContent(message.files ?? []), ...userAttachmentContent(message.files ?? []),
] ]
+1
View File
@@ -207,6 +207,7 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
skills: message.skills?.map((skill, index) => ({ skills: message.skills?.map((skill, index) => ({
...skill, ...skill,
name: Skill.Name.make(redact("skill-name", String(index), skill.name)), name: Skill.Name.make(redact("skill-name", String(index), skill.name)),
text: redact("skill", String(index), skill.text),
mention: skill.mention mention: skill.mention
? { ...skill.mention, text: redact("skill-mention", String(index), skill.mention.text) } ? { ...skill.mention, text: redact("skill-mention", String(index), skill.mention.text) }
: undefined, : undefined,
+5 -46
View File
@@ -1,16 +1,14 @@
export * as Shell from "./shell.js" export * as Shell from "./shell.js"
import path from "path" import path from "path"
import { Context, Deferred, Duration, Effect, Fiber, Layer, Schema, Schedule, Stream } from "effect" import { Context, Deferred, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { produce } from "immer" import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell" import { Shell } from "@opencode-ai/schema/shell"
import { AppProcess } from "@opencode-ai/util/process" import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bus } from "./bus.js" import { Bus } from "./bus.js"
import { Environment } from "./environment/index.js" import { Environment } from "./environment/index.js"
import { FileRetention } from "./file-retention.js"
import { Location } from "./location.js" import { Location } from "./location.js"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { ShellSelect } from "./shell/select.js" import { ShellSelect } from "./shell/select.js"
@@ -23,11 +21,9 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Shell.No
id: Shell.ID, id: Shell.ID,
}) {} }) {}
// Keep recent exited processes observable in memory, including their file-backed output. // Exited processes stay observable (status, exit code, retained output) until removed explicitly.
// The process-local cap complements the time-based sweep, which also cleans files left by restarts. // Cap retention so abandoned commands do not accumulate unbounded state and output files.
const EXITED_LIMIT = 25 const EXITED_LIMIT = 25
export const RETENTION = Duration.days(7)
export const DIRECTORY = "shell"
type Info = Shell.Info type Info = Shell.Info
@@ -71,42 +67,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {} 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 = () => const layer = () =>
Layer.effect( Layer.effect(
Service, Service,
@@ -123,7 +83,7 @@ const layer = () =>
const sessions = new Map<string, Active>() const sessions = new Map<string, Active>()
const exitOrder: string[] = [] const exitOrder: string[] = []
const outputDir = path.join(global.data, DIRECTORY, location.project.id) const outputDir = path.join(global.data, "shell", location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises")) const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs")) const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
yield* Effect.promise(() => mkdir(outputDir, { recursive: true })) yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
@@ -398,6 +358,5 @@ export const node = makeLocationNode({
Environment.node, Environment.node,
PluginHooks.node, PluginHooks.node,
SessionEnvironment.node, SessionEnvironment.node,
cleanupNode,
], ],
}) })
-1
View File
@@ -26,7 +26,6 @@ const render = (skills: ReadonlyArray<Summary>) =>
[ [
"Skills provide specialized instructions and workflows for specific tasks.", "Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.", "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 ...(skills.length === 0
? ["No skills are currently available."] ? ["No skills are currently available."]
: ["<available_skills>", ...entries(skills), "</available_skills>"]), : ["<available_skills>", ...entries(skills), "</available_skills>"]),
+13 -6
View File
@@ -2,11 +2,10 @@ export * as ToolOutput from "./tool-output.js"
import path from "path" import path from "path"
import type { Tool } from "@opencode-ai/schema/tool" import type { Tool } from "@opencode-ai/schema/tool"
import { Context, Duration, Effect, Layer, Schedule } from "effect" import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
import { makeGlobalNode, 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 { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { FileRetention } from "./file-retention.js"
import { Identifier } from "./id/id.js" import { Identifier } from "./id/id.js"
import { State } from "./state.js" import { State } from "./state.js"
@@ -34,14 +33,22 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolOutput") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ToolOutput") {}
const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface, directory: string) { 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( const entries = yield* fs.readDirectory(directory).pipe(
Effect.map((entries) => entries.filter((entry) => /^tool_[0-9a-f]{12}/.test(entry))), Effect.map((entries) => entries.filter((entry) => /^tool_[0-9a-f]{12}/.test(entry))),
Effect.catch(() => Effect.succeed([])), Effect.catch(() => Effect.succeed([])),
) )
yield* FileRetention.cleanup( yield* Effect.forEach(
fs, entries,
entries.map((entry) => path.join(directory, entry)), (entry) =>
RETENTION, 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 },
) )
}) })
+2 -2
View File
@@ -12,7 +12,7 @@ export const name = "skill"
const FILE_LIMIT = 10 const FILE_LIMIT = 10
export const Input = Schema.Struct({ export const Input = Schema.Struct({
id: Skill.ID.annotate({ description: "The ID of an available skill or a skill explicitly referenced by the user" }), id: Skill.ID.annotate({ description: "The ID of the skill from the available skills list" }),
}) })
export const Output = Schema.Struct({ export const Output = Schema.Struct({
@@ -23,7 +23,7 @@ export const Output = Schema.Struct({
export const description = [ export const description = [
"Load a specialized skill's instructions and resources into the current conversation when the task at hand matches its 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 an available skill or a skill explicitly referenced by the user.", "The skill ID must match one of the available skills in the instructions.",
].join("\n") ].join("\n")
export const toModelOutput = Skill.toModelOutput export const toModelOutput = Skill.toModelOutput
+10 -9
View File
@@ -26,13 +26,7 @@ import { host } from "../plugin/host"
const model = LanguageModel.make({ const model = LanguageModel.make({
id: "test-model", id: "test-model",
provider: "test-provider", provider: "test-provider",
route: OpenAIChat.route, route: OpenAIChat.route.with({ limits: { context: 100_000, output: 1_000 } }),
})
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 config = Config.testLayer()
const it = testEffect( const it = testEffect(
@@ -48,7 +42,13 @@ const it = testEffect(
[ [
SessionRunnerModel.node, SessionRunnerModel.node,
Layer.mock(SessionRunnerModel.Service)({ Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(resolved), resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
}),
),
}), }),
], ],
[Config.node, config], [Config.node, config],
@@ -150,7 +150,8 @@ const session = Session.Info.make({
}) })
const input = (tokens: number) => ({ const input = (tokens: number) => ({
session, session,
resolved, model,
cost: [],
messages: [ messages: [
Schema.decodeUnknownSync(SessionMessage.Assistant)({ Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.make("msg_compaction_config"), id: SessionMessage.ID.make("msg_compaction_config"),
+1 -2
View File
@@ -77,7 +77,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
}), }),
) )
it.effect("defaults custom model metadata", () => it.effect("defaults custom models to agent capabilities", () =>
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("custom") const providerID = Provider.ID.make("custom")
@@ -100,7 +100,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
const model = required(yield* catalog.model.get(providerID, modelID)) const model = required(yield* catalog.model.get(providerID, modelID))
expect(model.capabilities).toEqual({ tools: true, input: ["text", "image"], output: ["text"] }) expect(model.capabilities).toEqual({ tools: true, input: ["text", "image"], output: ["text"] })
expect(model.limit).toEqual({ context: 200_000, output: 32_000 })
}), }),
) )
@@ -262,6 +262,7 @@ describe("cross-spawn spawner", () => {
Effect.gen(function* () { Effect.gen(function* () {
if (process.platform === "win32") return if (process.platform === "win32") return
const started = Date.now()
const exit = yield* Effect.exit( const exit = yield* Effect.exit(
Effect.gen(function* () { Effect.gen(function* () {
const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)') const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)')
@@ -270,6 +271,7 @@ describe("cross-spawn spawner", () => {
}), }),
) )
expect(Date.now() - started).toBeLessThan(1_000)
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
}), }),
) )
@@ -35,6 +35,28 @@ const pluginNode = makeLocationNode({
deps: [], 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) { function withNative(native: Watcher.NativeInterface) {
return Effect.provide(Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native)))) return Effect.provide(Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))))
} }
-1
View File
@@ -92,7 +92,6 @@ resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
ref: Ref.make({ providerID: selected.providerID, id: selected.id }), ref: Ref.make({ providerID: selected.providerID, id: selected.id }),
capabilities: selected.capabilities, capabilities: selected.capabilities,
cost: selected.cost, cost: selected.cost,
limit: selected.limit,
}) })
}), }),
) )
-56
View File
@@ -18,64 +18,8 @@ describe("KV", () => {
yield* kv.set("wellknown:sources", ["https://example.com", "https://example.org"]) yield* kv.set("wellknown:sources", ["https://example.com", "https://example.org"])
expect(yield* kv.get("wellknown:sources")).toEqual(["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") yield* kv.remove("wellknown:sources")
expect(yield* kv.get("wellknown:sources")).toBeUndefined() 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,7 +346,6 @@ describe("ModelResolver", () => {
const resolver = yield* ModelResolver.Service const resolver = yield* ModelResolver.Service
const resolved = yield* resolver.resolveModel(selected) const resolved = yield* resolver.resolveModel(selected)
expect(resolved.limit).toEqual(selected.limit)
const headers = yield* resolved.model.route.auth.apply({ const headers = yield* resolved.model.route.auth.apply({
request: LLM.request({ model: resolved.model, prompt: "Hello" }), request: LLM.request({ model: resolved.model, prompt: "Hello" }),
method: "POST", method: "POST",
-48
View File
@@ -338,54 +338,6 @@ 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", () => it.effect("registers location tools through the plugin context", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugins = yield* Plugin.Service const plugins = yield* Plugin.Service

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