mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 09:26:26 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f736e6f111 | ||
|
|
e91480201a | ||
|
|
a995cd8396 | ||
|
|
fabc387e85 | ||
|
|
768e3d45a2 | ||
|
|
0674ddf043 | ||
|
|
41b66bc560 | ||
|
|
49a5baa059 | ||
|
|
1cf576ab55 | ||
|
|
f7d0f69e6c | ||
|
|
a66e6cd55e | ||
|
|
93b3725b14 | ||
|
|
371e344ba6 | ||
|
|
6559dcb01b | ||
|
|
35dbdfe287 | ||
|
|
cd504dc66a | ||
|
|
c4f2926ffb | ||
|
|
aa48433fa0 | ||
|
|
8da7526616 | ||
|
|
7a4ad68af6 | ||
|
|
2960c61f9c | ||
|
|
7ad705225e | ||
|
|
5045b3ce18 | ||
|
|
4306c07b34 | ||
|
|
7de1e86b5d | ||
|
|
a6fd1f8b22 |
@@ -91,7 +91,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
bun-version: 1.4.1
|
||||
bun-version: 1.4.2
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
id: build
|
||||
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.1
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.2
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"name": "@opencode-ai/ai",
|
||||
"version": "1.17.20",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -338,7 +339,6 @@
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.88",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
@@ -350,7 +350,6 @@
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.23",
|
||||
"@ai-sdk/vercel": "2.0.39",
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@ff-labs/fff-bun": "0.10.5",
|
||||
"@ff-labs/fff-node": "0.10.5",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
@@ -1067,7 +1066,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.1",
|
||||
"bun-types": "1.4.2",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
@@ -1189,7 +1188,7 @@
|
||||
|
||||
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
|
||||
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||
|
||||
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
@@ -3487,7 +3486,7 @@
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="],
|
||||
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
@@ -5867,6 +5866,8 @@
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
@@ -5875,8 +5876,6 @@
|
||||
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
@@ -5923,9 +5922,9 @@
|
||||
|
||||
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
|
||||
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
@@ -6385,8 +6384,6 @@
|
||||
|
||||
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||
|
||||
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
@@ -7247,10 +7244,6 @@
|
||||
|
||||
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
|
||||
"ai/@ai-sdk/provider-utils/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-IDORw4Nup1Yj+RZSLGo0pDnwrkveYA8GvYQVNTJUnMM=",
|
||||
"aarch64-linux": "sha256-jxjyAI2imF7csdZNUqvs9mFj8SnYqGg1CapEEk15rxA=",
|
||||
"aarch64-darwin": "sha256-Hbh+cw2DPOJpYCDjasK6m0SLqznyAw1ODKSb7w87GG8=",
|
||||
"x86_64-darwin": "sha256-Aj0/MtMPoeccrN36mu2wUM6gyRKaTF2AbjZ0/wZtKnA="
|
||||
"x86_64-linux": "sha256-bWKV3fV8Yc+3ILaNRChIYajsRcvEG2OkydsD2KMUXCM=",
|
||||
"aarch64-linux": "sha256-zIavB09LW+BU3GOq0PO7Lqfc3MVchkiOUf3trnAjhRM=",
|
||||
"aarch64-darwin": "sha256-Jlvz7QsXsb8GS9Ty4TmUIfGOawiueZWWEYmyEPms3dc=",
|
||||
"x86_64-darwin": "sha256-TzTDtcDtj1c3+eP2WZAHOfkszxIS06J6QUwlnscLaUM="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.1",
|
||||
"packageManager": "bun@1.4.2",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev": "bun run --cwd packages/cli src/index.ts",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
@@ -159,7 +159,7 @@
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.1",
|
||||
"bun-types": "1.4.2",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
|
||||
@@ -2,13 +2,12 @@ import { AwsV4Signer } from "aws4fetch"
|
||||
import { Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Auth, type AuthInput } from "../../route/auth.js"
|
||||
import { AIError, AuthenticationError } from "../../schema/index.js"
|
||||
import { ProviderShared } from "../shared.js"
|
||||
|
||||
/**
|
||||
* AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth,
|
||||
* which provider facades configure as route auth instead of SigV4. STS-vended
|
||||
* credentials should be refreshed by the consumer (rebuild the model) before
|
||||
* they expire; the route does not refresh.
|
||||
* which provider facades configure as route auth instead of SigV4.
|
||||
*/
|
||||
export interface Credentials {
|
||||
readonly region: string
|
||||
@@ -17,6 +16,44 @@ export interface Credentials {
|
||||
readonly sessionToken?: string
|
||||
}
|
||||
|
||||
/** Static credentials or an effect resolved before every request. */
|
||||
export type CredentialSource = Credentials | Effect.Effect<Credentials, AIError>
|
||||
|
||||
export interface DefaultChainOptions {
|
||||
readonly region: string
|
||||
/** Shared config profile passed to the AWS default chain. */
|
||||
readonly profile?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve credentials through the AWS default provider chain: environment
|
||||
* variables, shared config and SSO caches, web identity tokens, process
|
||||
* credentials, and container or instance metadata. A fresh chain runs on every
|
||||
* request so credentials rotated on disk without an expiration (for example
|
||||
* shared-config keys rewritten by a corporate SSO tool) are always re-read;
|
||||
* the SDK's own memoization would otherwise pin them for the process lifetime.
|
||||
*/
|
||||
export const defaultChain = (options: DefaultChainOptions): Effect.Effect<Credentials, AIError> =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const { fromNodeProviderChain } = await import("@aws-sdk/credential-providers")
|
||||
const identity = await fromNodeProviderChain(options.profile === undefined ? {} : { profile: options.profile })()
|
||||
return {
|
||||
region: options.region,
|
||||
accessKeyId: identity.accessKeyId,
|
||||
secretAccessKey: identity.secretAccessKey,
|
||||
...(identity.sessionToken === undefined ? {} : { sessionToken: identity.sessionToken }),
|
||||
}
|
||||
},
|
||||
catch: (error) =>
|
||||
new AIError({
|
||||
reason: new AuthenticationError({
|
||||
message: `AWS default credential chain failed: ${ProviderShared.errorText(error)}`,
|
||||
cause: error,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const signRequest = (input: {
|
||||
readonly url: string
|
||||
readonly body: string
|
||||
@@ -48,16 +85,17 @@ const signRequest = (input: {
|
||||
|
||||
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
|
||||
export const sigV4 = (
|
||||
credentials: Credentials | undefined,
|
||||
source: CredentialSource | undefined,
|
||||
options: { readonly service?: string; readonly name?: string } = {},
|
||||
) =>
|
||||
Auth.custom((input: AuthInput) => {
|
||||
return Effect.gen(function* () {
|
||||
if (!credentials) {
|
||||
if (!source) {
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
`${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
|
||||
)
|
||||
}
|
||||
const credentials = Effect.isEffect(source) ? yield* source : source
|
||||
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
|
||||
const signed = yield* signRequest({
|
||||
url: input.url,
|
||||
@@ -74,4 +112,35 @@ export const sigV4 = (
|
||||
/** Bedrock route auth defaults to SigV4 and expects credentials from route configuration. */
|
||||
export const auth = sigV4(undefined)
|
||||
|
||||
export const resolveRegion = (input: {
|
||||
readonly region?: string
|
||||
readonly credentials?: { readonly region: string }
|
||||
}) =>
|
||||
input.region ?? input.credentials?.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1"
|
||||
|
||||
export interface ResolveAuthInput {
|
||||
readonly apiKey?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly profile?: string
|
||||
}
|
||||
|
||||
export interface ResolveAuthOptions {
|
||||
readonly service?: string
|
||||
readonly name?: string
|
||||
/** `sigv4` ignores an ambient `AWS_BEARER_TOKEN_BEDROCK`; `bearer` is validated by the caller. */
|
||||
readonly mode?: "bearer" | "sigv4"
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer wins over SigV4 and explicit static credentials win over the default
|
||||
* chain, matching the AWS SDK's own precedence for `AWS_BEARER_TOKEN_BEDROCK`.
|
||||
* The region is applied to the SigV4 scope so it always matches the endpoint host.
|
||||
*/
|
||||
export const resolveAuth = (input: ResolveAuthInput, region: string, options: ResolveAuthOptions = {}) => {
|
||||
const apiKey = options.mode === "sigv4" ? undefined : (input.apiKey ?? process.env.AWS_BEARER_TOKEN_BEDROCK)
|
||||
if (apiKey !== undefined) return Auth.bearer(apiKey)
|
||||
if (input.credentials !== undefined) return sigV4({ ...input.credentials, region }, options)
|
||||
return sigV4(defaultChain({ region, profile: input.profile }), options)
|
||||
}
|
||||
|
||||
export * as BedrockAuth from "./bedrock-auth.js"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
@@ -10,9 +9,15 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
|
||||
export type Config = RouteDefaultsInput & {
|
||||
/** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */
|
||||
readonly apiKey?: string
|
||||
/** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
/** Static SigV4 credentials. When omitted the AWS default credential chain resolves them per request. */
|
||||
readonly credentials?: Credentials
|
||||
/** Shared config profile for the default credential chain. */
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
@@ -22,6 +27,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
@@ -47,23 +53,35 @@ const chatRoute = OpenAIChat.route.with({
|
||||
export const routes = [responsesRoute, chatRoute]
|
||||
|
||||
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
|
||||
const region = input.region ?? input.credentials?.region ?? "us-east-1"
|
||||
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
|
||||
const region = BedrockAuth.resolveRegion(input)
|
||||
return route.with({
|
||||
endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` },
|
||||
auth:
|
||||
input.apiKey === undefined
|
||||
? BedrockAuth.sigV4(credentials, { service: "bedrock-mantle", name: "Bedrock Mantle" })
|
||||
: Auth.bearer(input.apiKey),
|
||||
auth: BedrockAuth.resolveAuth(input, region, {
|
||||
service: "bedrock-mantle",
|
||||
name: "Bedrock Mantle",
|
||||
mode: input.auth,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const defaults = (input: Config) => {
|
||||
const { apiKey: _, baseURL: _baseURL, credentials: _credentials, region: _region, ...rest } = input
|
||||
const {
|
||||
apiKey: _,
|
||||
auth: _auth,
|
||||
baseURL: _baseURL,
|
||||
credentials: _credentials,
|
||||
profile: _profile,
|
||||
region: _region,
|
||||
...rest
|
||||
} = input
|
||||
return rest
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
if (input.auth === "sigv4" && input.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
||||
const configuredChatRoute = configuredRoute(chatRoute, input)
|
||||
const modelDefaults = defaults(input)
|
||||
@@ -87,29 +105,26 @@ export const configure = (input: Config = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
const config = (settings: Settings): Config => {
|
||||
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
return {
|
||||
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||
const fromSettings = (settings: Settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
auth: settings.auth,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
profile: settings.profile,
|
||||
providerOptions: settings.providerOptions,
|
||||
region: settings.region,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).chat(modelID)
|
||||
) => fromSettings(settings).chat(modelID)
|
||||
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).responses(modelID)
|
||||
) => fromSettings(settings).responses(modelID)
|
||||
export const model = responsesModel
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as BedrockConverse from "../protocols/bedrock-converse.js"
|
||||
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
|
||||
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
|
||||
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
|
||||
export type Config = RouteDefaultsInput & {
|
||||
/** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */
|
||||
readonly apiKey?: string
|
||||
/** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly headers?: Record<string, string>
|
||||
/** Static SigV4 credentials. When omitted the AWS default credential chain resolves them per request. */
|
||||
readonly credentials?: BedrockCredentials
|
||||
/** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */
|
||||
/** Shared config profile for the default credential chain. */
|
||||
readonly profile?: string
|
||||
/** AWS region. Falls back to `credentials.region`, `AWS_REGION`, `AWS_DEFAULT_REGION`, then `us-east-1`. */
|
||||
readonly region?: string
|
||||
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
|
||||
readonly baseURL?: string
|
||||
@@ -22,6 +28,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: BedrockCredentials
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
}
|
||||
@@ -30,14 +37,17 @@ export const routes = [BedrockConverse.route]
|
||||
const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
const { apiKey, credentials, region, baseURL, ...rest } = input
|
||||
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
|
||||
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
|
||||
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
const resolvedRegion = BedrockAuth.resolveRegion(input)
|
||||
return BedrockConverse.route.with({
|
||||
...rest,
|
||||
provider: id,
|
||||
providerMetadataKey: "bedrock",
|
||||
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
|
||||
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
|
||||
auth: BedrockAuth.resolveAuth({ apiKey, credentials, profile }, resolvedRegion, { mode: auth }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,18 +61,15 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
return configure({
|
||||
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
auth: settings.auth,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
profile: settings.profile,
|
||||
region: settings.region,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Effect } from "effect"
|
||||
|
||||
/**
|
||||
* Run an effect with `process.env` overrides, restoring the previous values
|
||||
* afterwards. Use for code that reads `process.env` directly, such as AWS SDK
|
||||
* credential providers, where Effect `ConfigProvider` layers do not apply.
|
||||
* `undefined` removes a variable for the duration of the effect.
|
||||
*/
|
||||
export const withProcessEnv =
|
||||
(env: Record<string, string | undefined>) =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(env).map((name) => [name, process.env[name]]))
|
||||
apply(env)
|
||||
return previous
|
||||
}),
|
||||
() => effect,
|
||||
(previous) => Effect.sync(() => apply(previous)),
|
||||
)
|
||||
|
||||
const apply = (env: Record<string, string | undefined>) => {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name]
|
||||
continue
|
||||
}
|
||||
process.env[name] = value
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Encoding, Ref, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
CacheHint,
|
||||
GenerationOptions,
|
||||
type LanguageModel,
|
||||
LLM,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
@@ -18,7 +20,8 @@ import { compileRequest } from "../../src/route/client.js"
|
||||
import { AmazonBedrock } from "../../src/providers.js"
|
||||
import * as BedrockConverse from "../../src/protocols/bedrock-converse.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { withProcessEnv } from "../lib/env.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import {
|
||||
eventSummary,
|
||||
expectWeatherToolLoop,
|
||||
@@ -95,6 +98,41 @@ const fixedByteChunks = (...chunks: ReadonlyArray<Uint8Array>) =>
|
||||
{ headers: { "content-type": "application/vnd.amazon.eventstream" } },
|
||||
)
|
||||
|
||||
// Clears every input the AWS default chain reads so tests do not pick up the
|
||||
// developer's profiles, SSO cache, or instance metadata.
|
||||
const noAmbientAWS = {
|
||||
AWS_ACCESS_KEY_ID: undefined,
|
||||
AWS_SECRET_ACCESS_KEY: undefined,
|
||||
AWS_SESSION_TOKEN: undefined,
|
||||
AWS_BEARER_TOKEN_BEDROCK: undefined,
|
||||
AWS_PROFILE: undefined,
|
||||
AWS_REGION: undefined,
|
||||
AWS_DEFAULT_REGION: undefined,
|
||||
AWS_WEB_IDENTITY_TOKEN_FILE: undefined,
|
||||
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined,
|
||||
AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined,
|
||||
AWS_EC2_METADATA_DISABLED: "true",
|
||||
}
|
||||
|
||||
const captureHeaders = (target: LanguageModel) =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<Headers> = []
|
||||
yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: target })).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request)
|
||||
seen.push(request.headers)
|
||||
return input.respond(eventStreamBody(["messageStop", { stopReason: "end_turn" }]), {
|
||||
headers: { "content-type": "application/vnd.amazon.eventstream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return seen[0]!
|
||||
})
|
||||
|
||||
const model = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
apiKey: "test-bearer",
|
||||
@@ -1311,35 +1349,137 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects requests with no auth path", () =>
|
||||
it.effect("fails with an authentication error when the default chain cannot resolve credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const unsignedModel = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
profile: "opencode-test-missing-profile",
|
||||
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
const error = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: unsignedModel })).pipe(
|
||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.message).toContain("Bedrock Converse requires either route bearer auth or AWS credentials")
|
||||
}),
|
||||
expect(error.reason._tag).toBe("Authentication")
|
||||
expect(error.message).toContain("AWS default credential chain failed")
|
||||
}).pipe(withProcessEnv(noAmbientAWS)),
|
||||
)
|
||||
|
||||
it.effect("signs requests with SigV4 when AWS credentials are provided (deterministic plumbing check)", () =>
|
||||
it.effect("signs with static credentials using the configured region for the SigV4 scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const signed = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
region: "eu-west-1",
|
||||
credentials: {
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
},
|
||||
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
const prepared = yield* compileRequest(LLMRequest.update(baseRequest, { model: signed }))
|
||||
const headers = yield* captureHeaders(signed)
|
||||
|
||||
expect(prepared.route).toBe("bedrock-converse")
|
||||
expect(prepared.model).toBe(signed)
|
||||
}),
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIAIOSFODNN7EXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/eu-west-1/bedrock/aws4_request")
|
||||
expect(headers.get("x-amz-security-token")).toBeNull()
|
||||
}).pipe(withProcessEnv(noAmbientAWS)),
|
||||
)
|
||||
|
||||
it.effect("resolves SigV4 credentials through the AWS default chain on every request", () =>
|
||||
Effect.gen(function* () {
|
||||
const chained = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", region: "us-west-2" }).model(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
)
|
||||
const headers = yield* captureHeaders(chained)
|
||||
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/us-west-2/bedrock/aws4_request")
|
||||
expect(headers.get("x-amz-security-token")).toBe("chain-session-token")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
AWS_SESSION_TOKEN: "chain-session-token",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("re-resolves rotated chain credentials on the next request without rebuilding the model", () =>
|
||||
Effect.gen(function* () {
|
||||
const chained = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", region: "us-west-2" }).model(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
)
|
||||
const first = yield* captureHeaders(chained)
|
||||
const second = yield* captureHeaders(chained).pipe(
|
||||
withProcessEnv({ AWS_ACCESS_KEY_ID: "AKIAROTATEDEXAMPLE", AWS_SECRET_ACCESS_KEY: "rotated-secret" }),
|
||||
)
|
||||
|
||||
expect(first.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(second.get("authorization")).toContain("Credential=AKIAROTATEDEXAMPLE/")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("prefers AWS_BEARER_TOKEN_BEDROCK over the credential chain", () =>
|
||||
Effect.gen(function* () {
|
||||
const bearer = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test" }).model(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
)
|
||||
const headers = yield* captureHeaders(bearer)
|
||||
|
||||
expect(headers.get("authorization")).toBe("Bearer env-bearer-token")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
AWS_BEARER_TOKEN_BEDROCK: "env-bearer-token",
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("auth: sigv4 ignores an ambient bearer token from configure and settings", () =>
|
||||
Effect.gen(function* () {
|
||||
const configured = AmazonBedrock.configure({ auth: "sigv4", baseURL: "https://bedrock-runtime.test" }).model(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
)
|
||||
const fromSettings = AmazonBedrock.model("anthropic.claude-3-5-sonnet-20240620-v1:0", {
|
||||
auth: "sigv4",
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
})
|
||||
|
||||
for (const target of [configured, fromSettings]) {
|
||||
const headers = yield* captureHeaders(target)
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
|
||||
}
|
||||
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
AWS_BEARER_TOKEN_BEDROCK: "env-bearer-token",
|
||||
AWS_REGION: "ap-southeast-2",
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("derives the endpoint region from AWS_REGION then AWS_DEFAULT_REGION", () =>
|
||||
Effect.gen(function* () {
|
||||
const fromRegion = AmazonBedrock.configure({ apiKey: "k" }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
expect(fromRegion.route.endpoint.baseURL).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com")
|
||||
|
||||
const fromDefault = yield* Effect.sync(() =>
|
||||
AmazonBedrock.configure({ apiKey: "k" }).model("anthropic.claude-3-5-sonnet-20240620-v1:0"),
|
||||
).pipe(withProcessEnv({ AWS_REGION: undefined }))
|
||||
expect(fromDefault.route.endpoint.baseURL).toBe("https://bedrock-runtime.us-gov-west-1.amazonaws.com")
|
||||
}).pipe(withProcessEnv({ ...noAmbientAWS, AWS_REGION: "eu-central-1", AWS_DEFAULT_REGION: "us-gov-west-1" })),
|
||||
)
|
||||
|
||||
it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { model } from "../../src/providers/amazon-bedrock/mantle.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { compileRequest, LLMClient } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { withProcessEnv } from "../lib/env.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
@@ -82,6 +83,36 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("signs with the Mantle service using default-chain credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<string | undefined> = []
|
||||
const model = AmazonBedrockMantle.configure({ region: "us-west-1" }).responses("openai.gpt-oss-120b")
|
||||
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request)
|
||||
seen.push(request.headers.get("authorization") ?? undefined)
|
||||
return input.respond("", { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(seen[0]).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(seen[0]).toContain("/us-west-1/bedrock-mantle/aws4_request")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
AWS_BEARER_TOKEN_BEDROCK: undefined,
|
||||
AWS_PROFILE: undefined,
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
AWS_SESSION_TOKEN: undefined,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("supports bearer authentication and custom base URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
|
||||
|
||||
@@ -1,16 +1,139 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--empty-draft")
|
||||
const composer = component.locator('[data-component="composer"]')
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
for (const alternate of ["none", "queue", "steer"]) {
|
||||
story(`scrolls overflowing controls beside fixed ${alternate} actions in ${direction}`, async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--toolbar-overflow", {
|
||||
args: { alternate },
|
||||
globals: { direction },
|
||||
})
|
||||
const controls = component.locator('[data-slot="composer-controls"]')
|
||||
const actions = component.locator('[data-slot="composer-actions"]')
|
||||
const submit = component.locator('[data-action="composer-submit"]')
|
||||
const add = component.locator('[data-action="composer-attach"]')
|
||||
const agent = controls.getByRole("button", { name: "Choose agent" })
|
||||
const variant = controls.getByRole("button", { name: "Choose model variant" })
|
||||
await expect(controls).toHaveCSS("overflow-x", "auto")
|
||||
await expect(controls).toHaveCSS("overscroll-behavior-x", "contain")
|
||||
await expect(controls).toHaveCSS("direction", direction)
|
||||
await expect(controls).toHaveCSS("padding-inline-start", "0px")
|
||||
await expect(controls).toHaveCSS("padding-inline-end", "0px")
|
||||
await expect(component.locator('[data-action="composer-alternate-delivery"]')).toHaveCount(
|
||||
alternate === "none" ? 0 : 1,
|
||||
)
|
||||
|
||||
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
|
||||
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
|
||||
for (const width of [1024, 360]) {
|
||||
await page.setViewportSize({ width, height: 720 })
|
||||
await expect
|
||||
.poll(() => controls.evaluate((element) => element.scrollWidth - element.clientWidth))
|
||||
.toBeGreaterThan(0)
|
||||
const fixed = await submit.boundingBox()
|
||||
const fixedAdd = await add.boundingBox()
|
||||
const viewport = await controls.boundingBox()
|
||||
const action = await actions.boundingBox()
|
||||
expect(fixed).not.toBeNull()
|
||||
expect(viewport).not.toBeNull()
|
||||
expect(action).not.toBeNull()
|
||||
expect(fixedAdd).not.toBeNull()
|
||||
if (!fixed || !viewport || !action || !fixedAdd) return
|
||||
expect(direction === "ltr" ? fixedAdd.x + fixedAdd.width : viewport.x + viewport.width).toBeCloseTo(
|
||||
direction === "ltr" ? viewport.x - 4 : fixedAdd.x - 4,
|
||||
1,
|
||||
)
|
||||
expect(direction === "ltr" ? viewport.x + viewport.width : action.x + action.width).toBeCloseTo(
|
||||
direction === "ltr" ? action.x - 12 : viewport.x - 12,
|
||||
1,
|
||||
)
|
||||
|
||||
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
|
||||
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
|
||||
await controls.evaluate((element) => {
|
||||
element.scrollLeft = 0
|
||||
})
|
||||
await expect(controls).toHaveAttribute("data-overflow-start", "false")
|
||||
await expect(controls).toHaveAttribute("data-overflow-end", "true")
|
||||
await expect(controls).toHaveCSS(
|
||||
"mask-image",
|
||||
`linear-gradient(to ${direction === "ltr" ? "right" : "left"}, rgba(0, 0, 0, 0), rgb(0, 0, 0) 0px, rgb(0, 0, 0) calc(100% - 16px), rgba(0, 0, 0, 0))`,
|
||||
)
|
||||
const first = await agent.boundingBox()
|
||||
if (!first) throw new Error("Missing agent control")
|
||||
expect(
|
||||
direction === "ltr" ? first.x - viewport.x : viewport.x + viewport.width - first.x - first.width,
|
||||
).toBeCloseTo(0, 0)
|
||||
await controls.evaluate((element) => {
|
||||
element.scrollLeft =
|
||||
((getComputedStyle(element).direction === "rtl" ? -1 : 1) * (element.scrollWidth - element.clientWidth)) / 2
|
||||
})
|
||||
await expect(controls).toHaveAttribute("data-overflow-start", "true")
|
||||
await expect(controls).toHaveAttribute("data-overflow-end", "true")
|
||||
const scrolled = (await controls.boundingBox())!
|
||||
expect(scrolled.x).toBeCloseTo(viewport.x, 1)
|
||||
expect(scrolled.width).toBeCloseTo(viewport.width, 1)
|
||||
await controls.hover()
|
||||
await page.mouse.wheel(direction === "ltr" ? 1000 : -1000, 0)
|
||||
await expect.poll(() => controls.evaluate((element) => Math.abs(element.scrollLeft))).toBeGreaterThan(0)
|
||||
expect(await submit.boundingBox()).toEqual(fixed)
|
||||
expect(await add.boundingBox()).toEqual(fixedAdd)
|
||||
|
||||
// The fade disappears at the endpoint instead of reserving padding.
|
||||
await variant.focus()
|
||||
await controls.evaluate((element) => {
|
||||
element.scrollLeft =
|
||||
getComputedStyle(element).direction === "rtl" ? -element.scrollWidth : element.scrollWidth
|
||||
})
|
||||
await expect(controls).toHaveAttribute("data-overflow-start", "true")
|
||||
await expect(controls).toHaveAttribute("data-overflow-end", "false")
|
||||
const last = await variant.boundingBox()
|
||||
if (!last) throw new Error("Missing variant control")
|
||||
expect(
|
||||
Math.abs(direction === "ltr" ? viewport.x + viewport.width - last.x - last.width : last.x - viewport.x),
|
||||
).toBeLessThan(1)
|
||||
expect(
|
||||
Math.abs((direction === "ltr" ? action.x - last.x - last.width : last.x - action.x - action.width) - 12),
|
||||
).toBeLessThan(1)
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(page.getByRole("menuitemradio", { name: "high", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(variant).toBeFocused()
|
||||
await expect(submit).toBeInViewport()
|
||||
await expect(add).toBeInViewport()
|
||||
await add.click()
|
||||
await expect(page.getByRole("menuitem", { name: "Images and files" })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
expect(await add.boundingBox()).toEqual(fixedAdd)
|
||||
}
|
||||
|
||||
if (alternate !== "none") {
|
||||
const width = (await controls.boundingBox())!.width
|
||||
await component.getByRole("textbox", { name: "Prompt", exact: true }).fill("")
|
||||
await expect(component.locator('[data-action="composer-alternate-delivery"]')).toHaveCount(0)
|
||||
await expect.poll(async () => (await controls.boundingBox())!.width).toBeGreaterThan(width)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
story("does not mask or pad controls when they fit", async ({ mount }) => {
|
||||
const component = await mount("opencode-composer-flow--model-and-variant")
|
||||
const controls = component.locator('[data-slot="composer-controls"]')
|
||||
await expect(controls).toHaveAttribute("data-overflow-start", "false")
|
||||
await expect(controls).toHaveAttribute("data-overflow-end", "false")
|
||||
await expect(controls).toHaveCSS("mask-image", "none")
|
||||
await expect(controls).toHaveCSS("padding-inline-start", "0px")
|
||||
await expect(controls).toHaveCSS("padding-inline-end", "0px")
|
||||
})
|
||||
|
||||
// ThemeProvider writes resolved token values into a <style> block, so toggling data-color-scheme by hand
|
||||
// leaves every --v2-* variable at its previous value. Switch themes through the Storybook global instead.
|
||||
for (const [theme, background] of [
|
||||
["light", "rgb(255, 255, 255)"],
|
||||
["dark", "rgb(36, 36, 36)"],
|
||||
] as const) {
|
||||
story(`raises the docked composer only in dark mode (${theme})`, async ({ mount }) => {
|
||||
const component = await mount("opencode-composer-flow--empty-draft", { globals: { theme } })
|
||||
await expect(component.locator('[data-component="composer"]')).toHaveCSS("background-color", background)
|
||||
})
|
||||
}
|
||||
|
||||
story("centers add menu shortcuts in a consistent column", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--empty-draft")
|
||||
await component.locator('[data-action="composer-attach"]').click()
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
for (const theme of ["light", "dark"]) {
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
story(`shimmers once over the visible wordmark (${theme}, ${direction})`, async ({ mount, page }, testInfo) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" })
|
||||
const component = await mount("app-new-session-wordmark--reveal", { globals: { theme, direction } })
|
||||
const logo = component.locator('[data-component="new-session-wordmark"]')
|
||||
const reveal = logo.locator('[data-slot="wordmark-reveal"]')
|
||||
const shimmer = logo.locator(".wordmark-shimmer")
|
||||
const base = logo.locator("svg").first()
|
||||
await expect(base).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
|
||||
await expect(shimmer).toHaveCSS("animation-iteration-count", "1")
|
||||
await expect(logo.locator("g[mask]")).toHaveCount(0)
|
||||
await expect(shimmer).toHaveCSS("color", "rgb(255, 255, 255)")
|
||||
await expect(shimmer).toHaveCSS("mix-blend-mode", "screen")
|
||||
await expect(base.locator("g[fill]")).toHaveAttribute("fill", "currentColor")
|
||||
|
||||
await logo.evaluate((element) => {
|
||||
element.getAnimations({ subtree: true }).forEach((animation) => {
|
||||
animation.pause()
|
||||
animation.currentTime = 0
|
||||
})
|
||||
})
|
||||
await expect(reveal).toHaveCSS("opacity", "1")
|
||||
await expect(reveal).toHaveCSS("animation-name", "none")
|
||||
await expect(shimmer).toHaveCSS("mask-position", "100% 0px")
|
||||
|
||||
await logo.evaluate((element) => {
|
||||
element.getAnimations({ subtree: true }).forEach((animation) => (animation.currentTime = 400))
|
||||
})
|
||||
await expect(shimmer).toHaveCSS("mask-position", "50% 0px")
|
||||
await expect(shimmer).toHaveCSS("opacity", theme === "dark" ? "0.0672" : "0.45")
|
||||
const swept = await logo.screenshot({ path: testInfo.outputPath("wordmark-shimmer.png") })
|
||||
|
||||
await logo.evaluate((element) => {
|
||||
element.getAnimations({ subtree: true }).forEach((animation) => animation.finish())
|
||||
})
|
||||
await expect(reveal).toHaveCSS("opacity", "1")
|
||||
await expect(shimmer).toHaveCSS("opacity", "0")
|
||||
await expect(shimmer).toHaveCSS("mask-position", "0% 0px")
|
||||
const settled = await logo.screenshot({ path: testInfo.outputPath("wordmark-settled.png") })
|
||||
const brightness = await page.evaluate(
|
||||
async (screenshots) => {
|
||||
const pixels = await Promise.all(
|
||||
screenshots.map(async (screenshot) => {
|
||||
const image = new Image()
|
||||
image.src = `data:image/png;base64,${screenshot}`
|
||||
await image.decode()
|
||||
const canvas = new OffscreenCanvas(image.width, image.height)
|
||||
const context = canvas.getContext("2d")
|
||||
if (!context) throw new Error("Cannot read screenshot pixels")
|
||||
context.drawImage(image, 0, 0)
|
||||
return context.getImageData(0, 0, image.width, image.height).data
|
||||
}),
|
||||
)
|
||||
return {
|
||||
min: pixels[0].reduce(
|
||||
(min, value, index) => (index % 4 === 3 ? min : Math.min(min, value - pixels[1][index])),
|
||||
0,
|
||||
),
|
||||
max: pixels[0].reduce(
|
||||
(max, value, index) => (index % 4 === 3 ? max : Math.max(max, value - pixels[1][index])),
|
||||
0,
|
||||
),
|
||||
}
|
||||
},
|
||||
[swept.toString("base64"), settled.toString("base64")],
|
||||
)
|
||||
expect(brightness.min).toBeGreaterThanOrEqual(0)
|
||||
expect(brightness.max).toBeGreaterThanOrEqual(5)
|
||||
})
|
||||
}
|
||||
|
||||
story(`shows a static wordmark with reduced motion (${theme})`, async ({ mount, page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" })
|
||||
await page.setViewportSize({ width: 360, height: 640 })
|
||||
const component = await mount("app-new-session-wordmark--reveal", { globals: { theme } })
|
||||
const logo = component.locator('[data-component="new-session-wordmark"]')
|
||||
await expect(logo.locator("svg").first()).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
|
||||
await expect(logo.locator('[data-slot="wordmark-reveal"]')).toHaveCSS("opacity", "1")
|
||||
await expect(logo.locator(".wordmark-shimmer")).toHaveCSS("opacity", "0")
|
||||
expect(await logo.evaluate((element) => element.getAnimations({ subtree: true }).length)).toBe(0)
|
||||
expect(await logo.evaluate((element) => element.getBoundingClientRect().right <= innerWidth)).toBe(true)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test.use({
|
||||
deviceScaleFactor: 2,
|
||||
// Emulating DPR alone does not exercise Chromium's native hairline border rounding.
|
||||
launchOptions: { args: ["--force-device-scale-factor=2"] },
|
||||
})
|
||||
|
||||
for (const width of [1400, 390]) {
|
||||
test(`patch file borders retain a full CSS pixel at ${width}px on high-density displays`, async ({ page }) => {
|
||||
const file = `src/${"long-directory/".repeat(12)}patch-border.ts`
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
"prt_border_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update file" },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: createTwoFilesPatch(file, file, "const value = 1\n", "const value = 2\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
reducedMotion: true,
|
||||
viewport: { width, height: 900 },
|
||||
})
|
||||
await page.getByRole("button", { name: "Used 1 Patch", exact: true }).click()
|
||||
const patch = page.locator('[data-component="apply-patch-tool"]')
|
||||
const trigger = patch.getByRole("button", { name: /patch-border.ts/ })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(trigger).toHaveCSS("height", "32px")
|
||||
for (const side of ["top", "right", "bottom", "left"]) {
|
||||
await expect(trigger).toHaveCSS(`border-${side}-width`, "1px")
|
||||
}
|
||||
|
||||
const box = await trigger.boundingBox()
|
||||
await trigger.hover()
|
||||
expect(await trigger.boundingBox()).toEqual(box)
|
||||
await page.mouse.move(0, 0)
|
||||
expect(await trigger.boundingBox()).toEqual(box)
|
||||
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
const content = patch.locator('[data-slot="accordion-content"]')
|
||||
await expect(content).toBeVisible()
|
||||
for (const side of ["left", "right", "bottom"]) {
|
||||
await expect(content).toHaveCSS(`border-${side}-width`, "1px")
|
||||
}
|
||||
await expect(content).toHaveCSS("border-top-width", "0px")
|
||||
await trigger.press("Enter")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
}
|
||||
@@ -301,11 +301,14 @@ for (const count of [0, 26]) {
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
|
||||
test(`vertical tabs keep Status pinned without Settings in ${direction}`, async ({ page }, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA, sessionB, directory }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ appearance: { tabLayout: "vertical" }, general: { showStatus: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
@@ -322,23 +325,27 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
},
|
||||
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
|
||||
)
|
||||
await page.goto("/")
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionA.id}`)
|
||||
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
const settings = sidebar.getByRole("button", { name: "Settings", exact: true })
|
||||
const status = sidebar.getByRole("button", { name: "Status", exact: true })
|
||||
const scroll = sidebar.locator('[data-slot="vertical-tabs-scroll"]')
|
||||
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
|
||||
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
|
||||
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
|
||||
await expect(settings).toHaveText("Settings")
|
||||
await expect(status).toHaveText("Status")
|
||||
await expect(settings).toHaveCount(0)
|
||||
await expect(status.locator('[data-slot="status-indicator"]')).toBeVisible()
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
|
||||
for (const width of [1280, 800]) {
|
||||
await page.setViewportSize({ width, height: 360 })
|
||||
await expect(settings).toBeInViewport({ ratio: 1 })
|
||||
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
|
||||
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
|
||||
await expect(settings).toHaveCSS("margin-top", "8px")
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCSS("margin-top", "8px")
|
||||
await expect(status).toBeInViewport({ ratio: 1 })
|
||||
await expect(status).toHaveCSS("height", "28px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
|
||||
@@ -353,12 +360,12 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
|
||||
await scroll.evaluate((element) => element.scrollTo(0, 0))
|
||||
await expect(scroll).toHaveJSProperty("scrollTop", 0)
|
||||
const pinned = await settings.boundingBox()
|
||||
const pinnedStatus = await status.boundingBox()
|
||||
await scroll.hover()
|
||||
await page.mouse.wheel(0, 200)
|
||||
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
|
||||
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
|
||||
await testInfo.attach(`vertical-tabs-settings-${width}`, {
|
||||
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
|
||||
await testInfo.attach(`vertical-tabs-status-${width}`, {
|
||||
body: await sidebar.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
@@ -372,17 +379,14 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
|
||||
})
|
||||
.toBe(true)
|
||||
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
|
||||
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
|
||||
await expect(settings).toHaveCount(0)
|
||||
}
|
||||
|
||||
await settings.click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
await expect(settings).toHaveAttribute("aria-pressed", "true")
|
||||
await sidebar.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeHidden()
|
||||
await settings.focus()
|
||||
await settings.press("Enter")
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
await status.click()
|
||||
await expect(status).toHaveAttribute("aria-expanded", "true")
|
||||
await status.press("Escape")
|
||||
await expect(status).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -67,12 +67,12 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await testInfo.attach("workspace-accent", { path, contentType: "image/png" })
|
||||
}
|
||||
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await view.send.hover()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await view.composer.locator('[data-action="composer-model"]').press("Tab")
|
||||
await expect(view.send).toBeFocused()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
const message = page.locator('[data-slot="user-message-text"]')
|
||||
await expect(message).toHaveText("Check this fixture workspace.")
|
||||
await expectToken(
|
||||
@@ -92,7 +92,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
const view = await openSession(page, workspace, [{ directory: root }])
|
||||
await view.input.fill("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
const url = page.url()
|
||||
|
||||
const refreshed = page.waitForResponse(
|
||||
@@ -109,14 +109,14 @@ for (const theme of ["light", "dark"] as const) {
|
||||
data: { projectID },
|
||||
})
|
||||
expect((await refreshed).ok()).toBe(true)
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await expect(page).toHaveURL(url)
|
||||
await expect(view.input).toHaveText("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
|
||||
await view.input.fill("")
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
|
||||
view.events.push({
|
||||
id: "evt_workspace_accent_running",
|
||||
@@ -127,29 +127,29 @@ for (const theme of ["light", "dark"] as const) {
|
||||
})
|
||||
const stop = view.composer.getByRole("button", { name: "Stop", exact: true })
|
||||
await expect(stop).toBeEnabled()
|
||||
await expectBackground(stop, "contrast")
|
||||
await expectBackground(stop, "icon-button-contrast")
|
||||
|
||||
await view.input.fill("Send a follow-up instead of stopping.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await expect(page).toHaveURL(url)
|
||||
})
|
||||
|
||||
test("new workspace send button stays neutral", async ({ page }) => {
|
||||
const view = await openSession(page, root, [...inventory], true)
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await view.input.fill("Inspect this fixture workspace.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await view.send.hover()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await view.composer.locator('[data-action="composer-model"]').press("Tab")
|
||||
await expect(view.send).toBeFocused()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ function ComposerStory(props: {
|
||||
label?: string
|
||||
inspectRequest?: boolean
|
||||
continueOnStop?: boolean
|
||||
longLabels?: boolean
|
||||
alternate?: "queue" | "steer"
|
||||
}) {
|
||||
const [draft, setDraft] = createStore<ComposerPersistedState>({
|
||||
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
|
||||
@@ -66,11 +68,15 @@ function ComposerStory(props: {
|
||||
activity: props.label ?? "Ready",
|
||||
variant: STORY_MODEL.variant,
|
||||
})
|
||||
const modelOption = createMemo(() => ({
|
||||
...selectedModel,
|
||||
name: props.longLabels ? "Claude Sonnet with an unusually long model name" : selectedModel.name,
|
||||
}))
|
||||
const modelSelection = {
|
||||
ready: Object.assign(() => true, { promise: undefined }),
|
||||
current: () => selectedModel,
|
||||
recent: () => [selectedModel],
|
||||
list: () => [selectedModel],
|
||||
current: () => modelOption(),
|
||||
recent: () => [modelOption()],
|
||||
list: () => [modelOption()],
|
||||
cycle() {},
|
||||
set() {},
|
||||
visible: () => true,
|
||||
@@ -126,7 +132,7 @@ function ComposerStory(props: {
|
||||
placeholder: () => "Ask anything, / for commands, @ for context...",
|
||||
agent: {
|
||||
options: () => [
|
||||
{ id: "build", label: "build" },
|
||||
{ id: "build", label: props.longLabels ? "Build agent with an unusually long name" : "build" },
|
||||
{ id: "review", label: "review" },
|
||||
],
|
||||
current: () => "build",
|
||||
@@ -135,7 +141,7 @@ function ComposerStory(props: {
|
||||
variant: {
|
||||
options: () => [
|
||||
{ id: "default", label: "default" },
|
||||
{ id: "balanced", label: "balanced" },
|
||||
{ id: "balanced", label: props.longLabels ? "Balanced reasoning with an extended label" : "balanced" },
|
||||
{ id: "high", label: "high" },
|
||||
],
|
||||
current: () => story.variant,
|
||||
@@ -144,6 +150,17 @@ function ComposerStory(props: {
|
||||
submit: {
|
||||
stopping: () => !!props.stopping,
|
||||
working: () => !!props.working,
|
||||
queue: props.alternate
|
||||
? {
|
||||
count: () => 0,
|
||||
delivery: () => (props.alternate === "queue" ? "steer" : "queue"),
|
||||
alternate: () => props.alternate,
|
||||
editing: () => undefined,
|
||||
confirmEdit() {},
|
||||
cancelEdit() {},
|
||||
editFirst: () => false,
|
||||
}
|
||||
: undefined,
|
||||
onSubmit: () => {
|
||||
const value = draft.prompt.map((part) => ("content" in part ? part.content : `[${part.filename}]`)).join("")
|
||||
const request = props.inspectRequest
|
||||
@@ -290,6 +307,21 @@ export const NarrowLayout = {
|
||||
),
|
||||
}
|
||||
|
||||
export const ToolbarOverflow = {
|
||||
args: { alternate: "queue" },
|
||||
argTypes: { alternate: { control: "select", options: ["none", "queue", "steer"] } },
|
||||
render: (args: { alternate: "none" | "queue" | "steer" }) => (
|
||||
<div class="w-[min(420px,calc(100vw-80px))]">
|
||||
<ComposerStory
|
||||
longLabels
|
||||
working
|
||||
prompt={text("Keep all controls reachable when the toolbar overflows")}
|
||||
alternate={args.alternate === "none" ? undefined : args.alternate}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DemoFirstClassSkillIDs = {
|
||||
name: "Demo: First-class skill IDs",
|
||||
render: () => (
|
||||
|
||||
@@ -2,6 +2,29 @@
|
||||
content: "\200B";
|
||||
}
|
||||
|
||||
/* Masks only cover hidden content; they do not reserve space in the toolbar. */
|
||||
[data-slot="composer-controls"][data-overflow-start="true"] {
|
||||
--mask-start: 16px;
|
||||
}
|
||||
|
||||
[data-slot="composer-controls"][data-overflow-end="true"] {
|
||||
--mask-end: 16px;
|
||||
}
|
||||
|
||||
[data-slot="composer-controls"]:dir(rtl) {
|
||||
--mask-direction: to left;
|
||||
}
|
||||
|
||||
[data-slot="composer-controls"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
|
||||
mask-image: linear-gradient(
|
||||
var(--mask-direction, to right),
|
||||
transparent,
|
||||
black var(--mask-start, 0px),
|
||||
black calc(100% - var(--mask-end, 0px)),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createEffect, createMemo, createSignal, For, Show, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -56,6 +57,23 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
const view = props.controller.view
|
||||
let editor: HTMLDivElement | undefined
|
||||
let viewport: HTMLDivElement | undefined
|
||||
let controlsViewport!: HTMLDivElement
|
||||
let controlsContent!: HTMLDivElement
|
||||
const [overflow, setOverflow] = createStore({ start: false, end: false })
|
||||
const updateOverflow = () => {
|
||||
const offset = Math.abs(controlsViewport.scrollLeft)
|
||||
setOverflow({
|
||||
start: offset > 1,
|
||||
end: controlsViewport.scrollWidth - controlsViewport.clientWidth - offset > 1,
|
||||
})
|
||||
}
|
||||
onMount(() => {
|
||||
const observer = new ResizeObserver(updateOverflow)
|
||||
observer.observe(controlsViewport)
|
||||
observer.observe(controlsContent)
|
||||
updateOverflow()
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
let localInput = false
|
||||
const updateCursor = () => {
|
||||
if (!editor || !window.getSelection()?.isCollapsed) return
|
||||
@@ -226,7 +244,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
|
||||
<div class="flex h-11 items-center px-2">
|
||||
<div
|
||||
class="flex min-w-0 flex-1 items-center gap-1"
|
||||
class="flex shrink-0 items-center"
|
||||
aria-hidden={state.mode === "shell"}
|
||||
inert={state.mode === "shell" ? true : undefined}
|
||||
style={buttons()}
|
||||
@@ -245,64 +263,80 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
onContext={props.controller.openContext}
|
||||
onShell={props.controller.openShell}
|
||||
/>
|
||||
<Show when={view.agent} keyed>
|
||||
{(control) => (
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseAgent")}
|
||||
keybind={["Mod", "."]}
|
||||
control={control}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.modelControlsVisible ?? true}>
|
||||
{props.modelControl}
|
||||
<Show when={view.variant} keyed>
|
||||
</div>
|
||||
<div
|
||||
ref={controlsViewport}
|
||||
data-slot="composer-controls"
|
||||
data-overflow-start={overflow.start}
|
||||
data-overflow-end={overflow.end}
|
||||
class="ms-1 me-3 h-full min-w-0 flex-1 overflow-x-auto overscroll-x-contain no-scrollbar"
|
||||
onScroll={updateOverflow}
|
||||
aria-hidden={state.mode === "shell"}
|
||||
inert={state.mode === "shell" ? true : undefined}
|
||||
style={buttons()}
|
||||
>
|
||||
<div ref={controlsContent} class="flex h-full w-max min-w-full items-center gap-1">
|
||||
<Show when={view.agent} keyed>
|
||||
{(control) => (
|
||||
<Show when={control.options().length > 1}>
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseVariant")}
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control}
|
||||
class={control.current() === "default" ? "composer-variant-default" : undefined}
|
||||
/>
|
||||
</Show>
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseAgent")}
|
||||
keybind={["Mod", "."]}
|
||||
control={control}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={props.modelControlsVisible ?? true}>
|
||||
{props.modelControl}
|
||||
<Show when={view.variant} keyed>
|
||||
{(control) => (
|
||||
<Show when={control.options().length > 1}>
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseVariant")}
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control}
|
||||
class={control.current() === "default" ? "composer-variant-default" : undefined}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={state.mode === "normal"}>
|
||||
<ComposerEditorAlternateDelivery
|
||||
controller={props.controller}
|
||||
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
|
||||
<div data-slot="composer-actions" class="flex shrink-0 items-center">
|
||||
<Show when={state.mode === "normal"}>
|
||||
<ComposerEditorAlternateDelivery
|
||||
controller={props.controller}
|
||||
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={state.mode === "shell"}>
|
||||
<Button
|
||||
data-action="composer-exit-shell"
|
||||
type="button"
|
||||
variant="ghost-faint"
|
||||
size="small"
|
||||
class="me-3 gap-1.5 px-1.5"
|
||||
onClick={() => {
|
||||
props.controller.dispatch({ type: "mode.normal" })
|
||||
props.controller.restoreFocus()
|
||||
}}
|
||||
>
|
||||
{i18n.t("ui.promptInput.exitShell")}
|
||||
<span class="hidden sm:block">
|
||||
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
|
||||
</span>
|
||||
</Button>
|
||||
</Show>
|
||||
<ComposerEditorSubmitButton
|
||||
mode={state.mode}
|
||||
stopping={view.submit.stopping()}
|
||||
disabled={!props.controller.canSubmit()}
|
||||
sendLabel={i18n.t("ui.promptInput.send")}
|
||||
stopLabel={i18n.t("ui.promptInput.stop")}
|
||||
onSubmit={() => props.controller.submit()}
|
||||
onStop={props.controller.stop}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={state.mode === "shell"}>
|
||||
<Button
|
||||
data-action="composer-exit-shell"
|
||||
type="button"
|
||||
variant="ghost-faint"
|
||||
size="small"
|
||||
class="me-3 gap-1.5 px-1.5"
|
||||
onClick={() => {
|
||||
props.controller.dispatch({ type: "mode.normal" })
|
||||
props.controller.restoreFocus()
|
||||
}}
|
||||
>
|
||||
{i18n.t("ui.promptInput.exitShell")}
|
||||
<span class="hidden sm:block">
|
||||
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
|
||||
</span>
|
||||
</Button>
|
||||
</Show>
|
||||
<ComposerEditorSubmitButton
|
||||
mode={state.mode}
|
||||
stopping={view.submit.stopping()}
|
||||
disabled={!props.controller.canSubmit()}
|
||||
sendLabel={i18n.t("ui.promptInput.send")}
|
||||
stopLabel={i18n.t("ui.promptInput.stop")}
|
||||
onSubmit={() => props.controller.submit()}
|
||||
onStop={props.controller.stop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -808,11 +842,7 @@ export function ComposerEditorSubmitButton(props: {
|
||||
tabIndex={props.mode === "normal" ? undefined : -1}
|
||||
icon={<Icon name={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"} />}
|
||||
variant="contrast"
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
style={{
|
||||
"background-image":
|
||||
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||
}}
|
||||
class="size-7 rounded-md p-[6px] disabled:opacity-50"
|
||||
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
/** Inline new-session content width — keep in sync with session composer `placement === "inline"`. */
|
||||
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[720px] px-0"
|
||||
/** Allows the prompt to extend 80px beyond each side of the 720px wordmark. */
|
||||
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[880px] px-0"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Wordmark } from "@opencode-ai/ui/wordmark"
|
||||
import { Show, createMemo, createSignal } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import createPresence from "solid-presence"
|
||||
@@ -23,6 +22,7 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import type { NewSessionWorkspaceController } from "./workspace/controller"
|
||||
import { NewSessionWordmark } from "./wordmark"
|
||||
|
||||
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -61,7 +61,7 @@ export function NewSessionView(props: {
|
||||
/>
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
|
||||
<NewSessionWordmark />
|
||||
<div class="mt-8 flex flex-col gap-8">
|
||||
<Composer model={props.composer} />
|
||||
<Show when={props.project.empty()}>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
[data-component="new-session-wordmark"] {
|
||||
--wordmark-shimmer-opacity: 0.45;
|
||||
|
||||
.wordmark-shimmer {
|
||||
/* Blend above the base logo's opacity so the sweep only adds light in either theme. */
|
||||
color: white;
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0;
|
||||
mask-image: linear-gradient(to right, transparent 40%, black 50%, transparent 60%);
|
||||
mask-size: 300% 100%;
|
||||
mask-repeat: no-repeat;
|
||||
animation: new-session-wordmark-shimmer 800ms linear both;
|
||||
}
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="new-session-wordmark"] {
|
||||
/* Preserve the previous dark highlight strength: 0.16 × 0.7 × 0.6. */
|
||||
--wordmark-shimmer-opacity: 0.0672;
|
||||
}
|
||||
|
||||
/* Oversizing the mask moves its highlight physically left to right, including in RTL. */
|
||||
@keyframes new-session-wordmark-shimmer {
|
||||
0% {
|
||||
mask-position: 100% 0;
|
||||
opacity: 0;
|
||||
}
|
||||
20%,
|
||||
80% {
|
||||
opacity: var(--wordmark-shimmer-opacity);
|
||||
}
|
||||
100% {
|
||||
mask-position: 0% 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="new-session-wordmark"] {
|
||||
.wordmark-shimmer {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NewSessionWordmark } from "./wordmark"
|
||||
|
||||
export default {
|
||||
title: "App/New Session/Wordmark",
|
||||
id: "app-new-session-wordmark",
|
||||
component: NewSessionWordmark,
|
||||
}
|
||||
|
||||
export const Reveal = {
|
||||
render: () => <NewSessionWordmark />,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Wordmark } from "@opencode-ai/ui/wordmark"
|
||||
import "./wordmark.css"
|
||||
|
||||
export function NewSessionWordmark() {
|
||||
return (
|
||||
<div
|
||||
data-component="new-session-wordmark"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none mx-auto w-full max-w-[720px] text-v2-background-bg-inverse"
|
||||
>
|
||||
<div data-slot="wordmark-reveal" class="relative">
|
||||
<Wordmark fade={false} class="block h-auto w-full opacity-60 [[data-color-scheme=dark]_&]:opacity-50" />
|
||||
<Wordmark fade={false} muted={false} class="wordmark-shimmer absolute inset-0 h-auto w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1045,8 +1045,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||
"settings.general.row.showStatus.title": "Server status",
|
||||
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
|
||||
"settings.general.row.showProjectIcon.title": "Project icon",
|
||||
"settings.general.row.showProjectIcon.description": "Show the project icon in the session header",
|
||||
"settings.general.row.showProjectIcon.title": "Show project icon",
|
||||
"settings.general.row.showProjectIcon.description": "Show project icon in session header",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
||||
"settings.general.row.mobileTitlebarBottom.description": "Place the title bar at the bottom of the screen on mobile",
|
||||
"settings.general.row.mobileDiffWrap.description":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Component, Show } from "solid-js"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -61,6 +61,22 @@ export const SettingsExperimental: Component = () => {
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<Show when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showProjectIcon.title")}
|
||||
description={language.t("settings.general.row.showProjectIcon.description")}
|
||||
>
|
||||
<div data-action="settings-show-project-icon">
|
||||
<Switch
|
||||
checked={settings.general.showProjectIcon()}
|
||||
onChange={settings.general.setShowProjectIcon}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.general.row.showProjectIcon.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -348,20 +348,6 @@ export const SettingsGeneral: Component<{
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<Show when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showProjectIcon.title")}
|
||||
description={language.t("settings.general.row.showProjectIcon.description")}
|
||||
>
|
||||
<div data-action="settings-show-project-icon">
|
||||
<Switch
|
||||
checked={settings.general.showProjectIcon()}
|
||||
onChange={(checked) => settings.general.setShowProjectIcon(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
|
||||
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
||||
|
||||
@@ -20,6 +20,7 @@ export function StatusPopover() {
|
||||
const sdk = useWorkspaceLocation()
|
||||
const settings = useSettings()
|
||||
const desktop = createMediaQuery("(min-width: 768px)")
|
||||
const sidebar = () => desktop() && settings.appearance.tabLayout() === "vertical"
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
|
||||
@@ -41,8 +42,9 @@ export function StatusPopover() {
|
||||
serverHealth: serverHealth(),
|
||||
attention: attention(),
|
||||
issue: issue(),
|
||||
placement: desktop() && settings.appearance.tabLayout() === "vertical" ? "top-start" : "bottom-end",
|
||||
shift: desktop() && settings.appearance.tabLayout() === "vertical" ? 0 : -168,
|
||||
sidebar: sidebar(),
|
||||
placement: sidebar() ? "top-start" : "bottom-end",
|
||||
shift: sidebar() ? 0 : -168,
|
||||
label: language.t("status.popover.trigger"),
|
||||
onOpenChange: setShown,
|
||||
body: () => (
|
||||
@@ -61,6 +63,7 @@ type StatusPopoverState = {
|
||||
serverHealth: boolean | undefined
|
||||
attention: boolean
|
||||
issue: boolean
|
||||
sidebar: boolean
|
||||
placement: "top-start" | "bottom-end"
|
||||
shift: number
|
||||
label: string
|
||||
@@ -93,21 +96,37 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
|
||||
<Popover
|
||||
open={props.state.shown}
|
||||
onOpenChange={props.state.onOpenChange}
|
||||
triggerAs={IconButton}
|
||||
triggerProps={{
|
||||
variant: "ghost-muted",
|
||||
size: "large",
|
||||
class: "!w-9 shrink-0",
|
||||
state: props.state.shown ? "pressed" : undefined,
|
||||
"aria-label": props.state.label,
|
||||
}}
|
||||
triggerAs={props.state.sidebar ? "button" : IconButton}
|
||||
triggerProps={
|
||||
props.state.sidebar
|
||||
? {
|
||||
type: "button",
|
||||
class:
|
||||
"flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02 [app-region:no-drag]",
|
||||
"data-state": props.state.shown ? "pressed" : undefined,
|
||||
"aria-label": props.state.label,
|
||||
}
|
||||
: {
|
||||
variant: "ghost-muted",
|
||||
size: "large",
|
||||
class: "!w-9 shrink-0",
|
||||
state: props.state.shown ? "pressed" : undefined,
|
||||
"aria-label": props.state.label,
|
||||
}
|
||||
}
|
||||
trigger={
|
||||
<div class="relative size-4">
|
||||
<Icon name={props.state.shown ? "status-active" : "status"} />
|
||||
<div
|
||||
class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div class="relative size-4 shrink-0">
|
||||
<Icon name={props.state.shown ? "status-active" : "status"} />
|
||||
<div
|
||||
data-slot="status-indicator"
|
||||
class={`absolute -top-1 -end-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
|
||||
/>
|
||||
</div>
|
||||
<Show when={props.state.sidebar}>
|
||||
<span class="min-w-0 truncate">{props.state.label}</span>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
{...popoverProps}
|
||||
>
|
||||
|
||||
@@ -38,9 +38,15 @@ export function createTitlebarRightSlot(): TitlebarRightSlot {
|
||||
}
|
||||
}
|
||||
|
||||
export function TitlebarRightMount() {
|
||||
export function TitlebarRightMount(props: { vertical?: boolean }) {
|
||||
const slot = useTitlebarRightSlot()
|
||||
return <div ref={slot.setMount} id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
|
||||
return (
|
||||
<div
|
||||
ref={slot.setMount}
|
||||
id="opencode-titlebar-right"
|
||||
class={props.vertical ? "flex w-full shrink-0 flex-col" : "flex shrink-0 items-center justify-end gap-0"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function TitlebarRight(props: ParentProps) {
|
||||
|
||||
@@ -680,20 +680,8 @@ export function Titlebar(props: {
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-action="vertical-tabs-settings"
|
||||
data-state={layout.route().type === "settings" ? "pressed" : undefined}
|
||||
class="mt-2 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02 [app-region:no-drag]"
|
||||
onClick={openSettings}
|
||||
aria-label={language.t("sidebar.settings")}
|
||||
aria-pressed={layout.route().type === "settings"}
|
||||
>
|
||||
<Icon name="settings-gear" />
|
||||
{language.t("sidebar.settings")}
|
||||
</button>
|
||||
<div data-slot="vertical-tabs-footer" class="flex w-full shrink-0 items-center gap-1.5">
|
||||
<TitlebarRightMount />
|
||||
<div data-slot="vertical-tabs-footer" class="mt-2 flex w-full shrink-0 flex-col">
|
||||
<TitlebarRightMount vertical />
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
} from "./shared"
|
||||
|
||||
const integrationPriority = new Map([
|
||||
["opencode", 0],
|
||||
["opencode-go", 1],
|
||||
["opencode-go", 0],
|
||||
["opencode", 1],
|
||||
["openai", 2],
|
||||
["github-copilot", 3],
|
||||
["google", 4],
|
||||
|
||||
@@ -18,7 +18,9 @@ export const loadIntegrations = Effect.fn("cli.auth.integrations")(function* (cl
|
||||
// The model endpoint is the existing public readiness boundary for the initial plugin generation.
|
||||
yield* request((signal) => client.model.default({ location }, { signal }))
|
||||
return yield* request((signal) => client.integration.list({ location }, { signal })).pipe(
|
||||
Effect.map((response) => response.data),
|
||||
Effect.map((response) =>
|
||||
response.data.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go")),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -68,6 +68,36 @@ describe("auth command", () => {
|
||||
expect(requests.indexOf("/api/model/default")).toBeLessThan(requests.indexOf("/api/integration"))
|
||||
})
|
||||
|
||||
test("lists OpenCode Go before Zen and preserves the remaining integration order", async () => {
|
||||
using server = authServer((request, url) => {
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
located(
|
||||
[
|
||||
{ id: "opencode", name: "OpenCode Zen" },
|
||||
{ id: "anthropic", name: "Anthropic" },
|
||||
{ id: "opencode-go", name: "OpenCode Go" },
|
||||
].map((integration) => ({
|
||||
...integration,
|
||||
methods: [],
|
||||
connections: [{ type: "env", name: "TEST_API_KEY" }],
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
})
|
||||
|
||||
const result = await cli(["auth", "list", "--server", server.url.toString()])
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(
|
||||
result.stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => line.split(/\s{2,}/)[0]),
|
||||
).toEqual(["OpenCode Go", "OpenCode Zen", "Anthropic"])
|
||||
})
|
||||
|
||||
test("runs command authentication without interactive input", async () => {
|
||||
const requests: Array<{ method: string; path: string }> = []
|
||||
using server = authServer((request, url) => {
|
||||
|
||||
@@ -2045,6 +2045,7 @@ export type ConfigEntry =
|
||||
experimental?: {
|
||||
portable_shell_scanner?: boolean
|
||||
subagent_depth?: number
|
||||
subagent_fork?: boolean
|
||||
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ export type CreateDataInput = {
|
||||
|
||||
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
||||
const messagePageLimit = 20
|
||||
// Trailing window for event bursts that each ask for the same refetch.
|
||||
export const settleMs = 150
|
||||
|
||||
// Global MCP elicitations temporarily use "global" instead of a real session ID, so the
|
||||
// server cannot recover their Location when settling them. Preserve the event Location
|
||||
@@ -141,12 +143,18 @@ function formRequestOptions(sessionID: string, ref?: LocationRef) {
|
||||
}
|
||||
|
||||
function createSync() {
|
||||
type Pending = { promise: Promise<void>; invalidated: boolean }
|
||||
// `started` is false while a reload waits for the load it replaces. Invalidations that land in
|
||||
// that window are already covered, since the reload has not read anything yet.
|
||||
type Pending = { promise: Promise<void>; invalidated: boolean; started: boolean }
|
||||
const state = new Map<string, true | Pending>()
|
||||
const start = (key: string, load: () => Promise<void>, wait?: Promise<void>) => {
|
||||
const entry: Pending = { promise: Promise.resolve(), invalidated: false }
|
||||
const entry: Pending = { promise: Promise.resolve(), invalidated: false, started: !wait }
|
||||
state.set(key, entry)
|
||||
entry.promise = (wait ? wait.catch(() => undefined).then(load) : load())
|
||||
const run = () => {
|
||||
entry.started = true
|
||||
return load()
|
||||
}
|
||||
entry.promise = (wait ? wait.catch(() => undefined).then(run) : run())
|
||||
.then(() => {
|
||||
if (state.get(key) === entry && !entry.invalidated) state.set(key, true)
|
||||
})
|
||||
@@ -178,12 +186,12 @@ function createSync() {
|
||||
if (key) {
|
||||
const active = state.get(key)
|
||||
if (active === true) state.delete(key)
|
||||
if (active !== undefined && active !== true) active.invalidated = true
|
||||
if (active !== undefined && active !== true && active.started) active.invalidated = true
|
||||
return
|
||||
}
|
||||
state.forEach((active, current) => {
|
||||
if (active === true) state.delete(current)
|
||||
if (active !== true) active.invalidated = true
|
||||
if (active !== true && active.started) active.invalidated = true
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -203,6 +211,20 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
}
|
||||
|
||||
// Runs `load` once a burst of same-key events goes quiet, so N events cost one refetch.
|
||||
const settling = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
onCleanup(() => settling.forEach((timer) => clearTimeout(timer)))
|
||||
function settle(key: string, load: () => Promise<unknown>) {
|
||||
clearTimeout(settling.get(key))
|
||||
settling.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
settling.delete(key)
|
||||
refresh(load)
|
||||
}, settleMs),
|
||||
)
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore<Store>({
|
||||
session: {
|
||||
info: {},
|
||||
@@ -1032,17 +1054,16 @@ export function createData(config: CreateDataInput) {
|
||||
if (store.session.info[event.data.sessionID]) {
|
||||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
||||
}
|
||||
// Inbox ordering is server-owned; IDs do not encode delivery order.
|
||||
result.session.pending.invalidate(event.data.sessionID)
|
||||
if (store.session.pending[event.data.sessionID]?.length)
|
||||
refresh(() => result.session.pending.sync(event.data.sessionID))
|
||||
// The projector also deletes inbox items enqueued at or after the boundary without a cancel event.
|
||||
setStore(
|
||||
"session",
|
||||
"pending",
|
||||
event.data.sessionID,
|
||||
(store.session.pending[event.data.sessionID] ?? []).filter((item) => item.id < event.data.to),
|
||||
)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = draft.findIndex((item) => item.id === event.data.to)
|
||||
if (position === -1) {
|
||||
result.session.message.invalidate(event.data.sessionID)
|
||||
refresh(() => result.session.message.sync(event.data.sessionID))
|
||||
return
|
||||
}
|
||||
const position = draft.findIndex((item) => item.id >= event.data.to)
|
||||
if (position === -1) return
|
||||
for (const item of draft.splice(position)) index.delete(item.id)
|
||||
})
|
||||
return
|
||||
@@ -1227,10 +1248,11 @@ export function createData(config: CreateDataInput) {
|
||||
refresh(() => result.location.websearch.refresh(location))
|
||||
break
|
||||
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
|
||||
// so the mcp list syncs here rather than off integration.updated.
|
||||
// so the mcp list syncs here rather than off integration.updated. The server emits one event
|
||||
// per MCP server as each settles, so a location booting nine servers emitted nine refetches.
|
||||
case "mcp.status.changed":
|
||||
result.location.mcp.server.invalidate(location)
|
||||
refresh(() => result.location.mcp.server.sync(location))
|
||||
settle(`mcp.status:${locationKey(location)}`, () => result.location.mcp.server.sync(location))
|
||||
break
|
||||
case "mcp.resources.changed":
|
||||
result.location.mcp.resource.invalidate(location)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { createData, settleMs, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
|
||||
|
||||
test("config reads and update refreshes are opt-in", async () => {
|
||||
@@ -165,3 +165,86 @@ test.each(["reconnecting", "disposed"] as const)("background reads respect %s ow
|
||||
expect(state.requests).toBe(1)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("a burst of mcp.status.changed events refetches the server list once it settles", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const requests: string[] = []
|
||||
const location = { directory: "/project" }
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(new URL(request.url).pathname)
|
||||
return Response.json({ location, data: [] })
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: location.directory,
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
try {
|
||||
await setup.data.location.mcp.server.sync()
|
||||
expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(1)
|
||||
for (const server of ["a", "b", "c", "d", "e"]) {
|
||||
const event: OpenCodeEvent = { id: `evt_${server}`, created: 1, type: "mcp.status.changed", location, data: { server } }
|
||||
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
||||
}
|
||||
expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(1)
|
||||
await new Promise((resolve) => setTimeout(resolve, settleMs * 2))
|
||||
expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(2)
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("invalidations that land before a queued reload starts are absorbed by it", async () => {
|
||||
const pending: Array<PromiseWithResolvers<Response>> = []
|
||||
const location = { directory: "/project" }
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: () => {
|
||||
const deferred = Promise.withResolvers<Response>()
|
||||
pending.push(deferred)
|
||||
return deferred.promise
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: location.directory,
|
||||
event: { on: () => () => {}, listen: () => () => {} },
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
try {
|
||||
const first = setup.data.location.agent.sync()
|
||||
expect(pending).toHaveLength(1)
|
||||
setup.data.location.agent.invalidate()
|
||||
const second = setup.data.location.agent.sync()
|
||||
setup.data.location.agent.invalidate()
|
||||
const third = setup.data.location.agent.sync()
|
||||
setup.data.location.agent.invalidate()
|
||||
expect(pending).toHaveLength(1)
|
||||
pending[0].resolve(Response.json({ location, data: [] }))
|
||||
await first
|
||||
await Promise.resolve()
|
||||
expect(pending).toHaveLength(2)
|
||||
pending[1].resolve(Response.json({ location, data: [{ name: "reviewer" }] }))
|
||||
await Promise.all([second, third])
|
||||
expect(pending).toHaveLength(2)
|
||||
await setup.data.location.agent.sync()
|
||||
expect(pending).toHaveLength(2)
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
|
||||
SHELL ["/bin/bash", "-lc"]
|
||||
|
||||
ARG NODE_VERSION=24.4.0
|
||||
ARG BUN_VERSION=1.4.1
|
||||
ARG BUN_VERSION=1.4.2
|
||||
|
||||
ENV BUN_INSTALL=/opt/bun
|
||||
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
@@ -100,7 +100,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.88",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
@@ -112,7 +111,6 @@
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.23",
|
||||
"@ai-sdk/vercel": "2.0.39",
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@ff-labs/fff-bun": "0.10.5",
|
||||
|
||||
+6
-147
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "685a01ff-9b49-4789-a8a6-f1b5779a866c",
|
||||
"prevIds": ["be60f352-8da1-40e1-8d70-dc41121cfbc5"],
|
||||
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
|
||||
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -72,10 +72,6 @@
|
||||
"name": "session_v2",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "timeline",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"entityType": "tables"
|
||||
@@ -944,16 +940,6 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "timeline_id",
|
||||
"entityType": "columns",
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
@@ -1084,16 +1070,6 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "timeline_id",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
@@ -1444,36 +1420,6 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "timeline"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "base_id",
|
||||
"entityType": "columns",
|
||||
"table": "timeline"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "base_seq",
|
||||
"entityType": "columns",
|
||||
"table": "timeline"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
@@ -1642,13 +1588,13 @@
|
||||
"table": "session_inbox"
|
||||
},
|
||||
{
|
||||
"columns": ["timeline_id"],
|
||||
"tableTo": "timeline",
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_message_timeline_id_timeline_id_fk",
|
||||
"name": "fk_session_message_session_id_session_v2_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session_message"
|
||||
},
|
||||
@@ -1663,17 +1609,6 @@
|
||||
"entityType": "fks",
|
||||
"table": "session_pending"
|
||||
},
|
||||
{
|
||||
"columns": ["timeline_id"],
|
||||
"tableTo": "timeline",
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "NO ACTION",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_v2_timeline_id_timeline_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id"],
|
||||
"tableTo": "project",
|
||||
@@ -1685,17 +1620,6 @@
|
||||
"entityType": "fks",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": ["base_id"],
|
||||
"tableTo": "timeline",
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "NO ACTION",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_timeline_base_id_timeline_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "timeline"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id"],
|
||||
"tableTo": "project",
|
||||
@@ -1833,13 +1757,6 @@
|
||||
"table": "session_v2",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "timeline_pk",
|
||||
"table": "timeline",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
@@ -1960,68 +1877,10 @@
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_message_session_seq_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "timeline_id",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "seq",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_message_timeline_seq_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "timeline_id",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "type",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "seq",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_message_timeline_type_seq_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "timeline_id",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "seq",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "((\"session_message\".\"type\" = 'assistant' AND json_extract(\"session_message\".\"data\", '$.time.completed') IS NULL)\n OR (\"session_message\".\"type\" IN ('shell', 'compaction') AND json_extract(\"session_message\".\"data\", '$.status') = 'running'))",
|
||||
"origin": "manual",
|
||||
"name": "session_message_unsettled_idx",
|
||||
"name": "session_message_session_seq_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_message"
|
||||
},
|
||||
|
||||
@@ -195,7 +195,9 @@ function mapBedrockSettings(
|
||||
? { baseURL: settings.endpoint }
|
||||
: {}),
|
||||
...(apiKey === undefined ? {} : { apiKey }),
|
||||
...(settings.auth === "bearer" || settings.auth === "sigv4" ? { auth: settings.auth } : {}),
|
||||
...(credentials === undefined ? {} : { credentials }),
|
||||
...(typeof settings.profile === "string" ? { profile: settings.profile } : {}),
|
||||
...(typeof settings.region === "string" ? { region: settings.region } : {}),
|
||||
...(typeof settings.topP === "number" ? { topP: settings.topP } : {}),
|
||||
}
|
||||
|
||||
@@ -426,6 +426,15 @@ function normalizeExperimental(
|
||||
)
|
||||
if (value !== undefined) result.subagent_depth = value
|
||||
}
|
||||
if (own(experimental, "subagent_fork")) {
|
||||
const value = decodeEncoded(
|
||||
ConfigExperimental.Info.fields.subagent_fork,
|
||||
experimental.subagent_fork,
|
||||
["experimental", "subagent_fork"],
|
||||
diagnostics,
|
||||
)
|
||||
if (value !== undefined) result.subagent_fork = value
|
||||
}
|
||||
native.push(
|
||||
...decodeList(
|
||||
experimental.policies,
|
||||
|
||||
-2
@@ -45,7 +45,6 @@ import m42 from "./migration/20260812181746_session_inbox.js"
|
||||
import m43 from "./migration/20260812213948_worktree.js"
|
||||
import m44 from "./migration/20260819222447_session_viewed_state.js"
|
||||
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
|
||||
import m46 from "./migration/20260906003536_timeline.js"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -94,5 +93,4 @@ export const migrations = [
|
||||
m43,
|
||||
m44,
|
||||
m45,
|
||||
m46,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
// Each existing Session, including copied forks, starts with an independent root.
|
||||
// Rebuilds preserve IDs, payloads, sequence numbers and timestamps. Session dependents
|
||||
// are evacuated inside this transaction so engines with mandatory FK cascades are safe.
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260906003536_timeline",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`CREATE TABLE \`timeline\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`base_id\` text,
|
||||
\`base_seq\` integer,
|
||||
CONSTRAINT \`fk_timeline_base_id_timeline_id_fk\` FOREIGN KEY (\`base_id\`) REFERENCES \`timeline\`(\`id\`)
|
||||
);`)
|
||||
// Use the small Session table to map existing messages to independent roots.
|
||||
// The replacement table below enforces NOT NULL on the backfilled IDs.
|
||||
yield* tx.run(`ALTER TABLE session_v2 ADD timeline_id text`)
|
||||
yield* tx.run(`UPDATE session_v2 SET timeline_id = 'tml_' || lower(hex(randomblob(16)))`)
|
||||
yield* tx.run(`INSERT INTO timeline (id) SELECT timeline_id FROM session_v2`)
|
||||
yield* tx.run(`CREATE TABLE \`__new_session_message\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`timeline_id\` text NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`seq\` integer NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
CONSTRAINT \`fk_session_message_timeline_id_timeline_id_fk\` FOREIGN KEY (\`timeline_id\`) REFERENCES \`timeline\`(\`id\`) ON DELETE CASCADE
|
||||
);`)
|
||||
yield* tx.run(
|
||||
`INSERT INTO \`__new_session_message\` (\`id\`, \`session_id\`, \`timeline_id\`, \`type\`, \`seq\`, \`time_created\`, \`time_updated\`, \`data\`) SELECT \`id\`, \`session_id\`, (SELECT timeline_id FROM session_v2 WHERE session_v2.id = session_message.session_id), \`type\`, \`seq\`, \`time_created\`, \`time_updated\`, \`data\` FROM \`session_message\``,
|
||||
)
|
||||
yield* tx.run(`DROP TABLE \`session_message\``)
|
||||
yield* tx.run(`ALTER TABLE \`__new_session_message\` RENAME TO \`session_message\``)
|
||||
yield* tx.run(`CREATE TABLE __timeline_instruction_entry AS SELECT * FROM instruction_entry`)
|
||||
yield* tx.run(`DELETE FROM instruction_entry`)
|
||||
yield* tx.run(`CREATE TABLE __timeline_instruction_state AS SELECT * FROM instruction_state`)
|
||||
yield* tx.run(`DELETE FROM instruction_state`)
|
||||
yield* tx.run(`CREATE TABLE __timeline_session_inbox AS SELECT * FROM session_inbox`)
|
||||
yield* tx.run(`DELETE FROM session_inbox`)
|
||||
yield* tx.run(`CREATE TABLE __timeline_session_pending AS SELECT * FROM session_pending`)
|
||||
yield* tx.run(`DELETE FROM session_pending`)
|
||||
yield* tx.run(`CREATE TABLE \`__new_session_v2\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`timeline_id\` text NOT NULL,
|
||||
\`project_id\` text NOT NULL,
|
||||
\`workspace_id\` text,
|
||||
\`parent_id\` text,
|
||||
\`fork_session_id\` text,
|
||||
\`fork_boundary\` text,
|
||||
\`slug\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`path\` text,
|
||||
\`title\` text,
|
||||
\`version\` text NOT NULL,
|
||||
\`share_url\` text,
|
||||
\`summary_additions\` integer,
|
||||
\`summary_deletions\` integer,
|
||||
\`summary_files\` integer,
|
||||
\`summary_diffs\` text,
|
||||
\`metadata\` text,
|
||||
\`cost\` real DEFAULT 0 NOT NULL,
|
||||
\`tokens_input\` integer DEFAULT 0 NOT NULL,
|
||||
\`tokens_output\` integer DEFAULT 0 NOT NULL,
|
||||
\`tokens_reasoning\` integer DEFAULT 0 NOT NULL,
|
||||
\`tokens_cache_read\` integer DEFAULT 0 NOT NULL,
|
||||
\`tokens_cache_write\` integer DEFAULT 0 NOT NULL,
|
||||
\`revert\` text,
|
||||
\`permission\` text,
|
||||
\`agent\` text,
|
||||
\`model\` text,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`time_idle\` integer,
|
||||
\`time_viewed\` integer,
|
||||
\`idle_outcome\` text,
|
||||
\`time_compacting\` integer,
|
||||
\`time_archived\` integer,
|
||||
\`time_suspended\` integer,
|
||||
\`resume_attempts\` integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT \`fk_session_v2_timeline_id_timeline_id_fk\` FOREIGN KEY (\`timeline_id\`) REFERENCES \`timeline\`(\`id\`),
|
||||
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);`)
|
||||
yield* tx.run(
|
||||
`INSERT INTO \`__new_session_v2\` (\`id\`, \`timeline_id\`, \`project_id\`, \`workspace_id\`, \`parent_id\`, \`fork_session_id\`, \`fork_boundary\`, \`slug\`, \`directory\`, \`path\`, \`title\`, \`version\`, \`share_url\`, \`summary_additions\`, \`summary_deletions\`, \`summary_files\`, \`summary_diffs\`, \`metadata\`, \`cost\`, \`tokens_input\`, \`tokens_output\`, \`tokens_reasoning\`, \`tokens_cache_read\`, \`tokens_cache_write\`, \`revert\`, \`permission\`, \`agent\`, \`model\`, \`time_created\`, \`time_updated\`, \`time_idle\`, \`time_viewed\`, \`idle_outcome\`, \`time_compacting\`, \`time_archived\`, \`time_suspended\`, \`resume_attempts\`) SELECT \`id\`, \`timeline_id\`, \`project_id\`, \`workspace_id\`, \`parent_id\`, \`fork_session_id\`, \`fork_boundary\`, \`slug\`, \`directory\`, \`path\`, \`title\`, \`version\`, \`share_url\`, \`summary_additions\`, \`summary_deletions\`, \`summary_files\`, \`summary_diffs\`, \`metadata\`, \`cost\`, \`tokens_input\`, \`tokens_output\`, \`tokens_reasoning\`, \`tokens_cache_read\`, \`tokens_cache_write\`, \`revert\`, \`permission\`, \`agent\`, \`model\`, \`time_created\`, \`time_updated\`, \`time_idle\`, \`time_viewed\`, \`idle_outcome\`, \`time_compacting\`, \`time_archived\`, \`time_suspended\`, \`resume_attempts\` FROM \`session_v2\``,
|
||||
)
|
||||
yield* tx.run(`DROP TABLE \`session_v2\``)
|
||||
yield* tx.run(`ALTER TABLE \`__new_session_v2\` RENAME TO \`session_v2\``)
|
||||
yield* tx.run(`INSERT INTO instruction_entry SELECT * FROM __timeline_instruction_entry`)
|
||||
yield* tx.run(`DROP TABLE __timeline_instruction_entry`)
|
||||
yield* tx.run(`INSERT INTO instruction_state SELECT * FROM __timeline_instruction_state`)
|
||||
yield* tx.run(`DROP TABLE __timeline_instruction_state`)
|
||||
yield* tx.run(`INSERT INTO session_inbox SELECT * FROM __timeline_session_inbox`)
|
||||
yield* tx.run(`DROP TABLE __timeline_session_inbox`)
|
||||
yield* tx.run(`INSERT INTO session_pending SELECT * FROM __timeline_session_pending`)
|
||||
yield* tx.run(`DROP TABLE __timeline_session_pending`)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_message_timeline_seq_idx\` ON \`session_message\` (\`timeline_id\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_timeline_type_seq_idx\` ON \`session_message\` (\`timeline_id\`,\`type\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_unsettled_idx\` ON \`session_message\` (\`timeline_id\`,\`seq\`) WHERE (("session_message"."type" = 'assistant' AND json_extract("session_message"."data", '$.time.completed') IS NULL)
|
||||
OR ("session_message"."type" IN ('shell', 'compaction') AND json_extract("session_message"."data", '$.status') = 'running'));`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
|
||||
)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
|
||||
)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -158,13 +158,12 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
CREATE TABLE \`session_message\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`timeline_id\` text NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`seq\` integer NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
CONSTRAINT \`fk_session_message_timeline_id_timeline_id_fk\` FOREIGN KEY (\`timeline_id\`) REFERENCES \`timeline\`(\`id\`) ON DELETE CASCADE
|
||||
CONSTRAINT \`fk_session_message_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
@@ -182,7 +181,6 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`session_v2\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`timeline_id\` text NOT NULL,
|
||||
\`project_id\` text NOT NULL,
|
||||
\`workspace_id\` text,
|
||||
\`parent_id\` text,
|
||||
@@ -218,18 +216,9 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
\`time_archived\` integer,
|
||||
\`time_suspended\` integer,
|
||||
\`resume_attempts\` integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT \`fk_session_v2_timeline_id_timeline_id_fk\` FOREIGN KEY (\`timeline_id\`) REFERENCES \`timeline\`(\`id\`),
|
||||
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`timeline\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`base_id\` text,
|
||||
\`base_seq\` integer,
|
||||
CONSTRAINT \`fk_timeline_base_id_timeline_id_fk\` FOREIGN KEY (\`base_id\`) REFERENCES \`timeline\`(\`id\`)
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`workspace\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
@@ -260,17 +249,9 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_inbox_session_enqueued_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`enqueued_seq\`);`,
|
||||
)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_message_timeline_seq_idx\` ON \`session_message\` (\`timeline_id\`,\`seq\`);`,
|
||||
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_timeline_type_seq_idx\` ON \`session_message\` (\`timeline_id\`,\`type\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(`
|
||||
CREATE INDEX \`session_message_unsettled_idx\` ON \`session_message\` (\`timeline_id\`,\`seq\`) WHERE (("session_message"."type" = 'assistant' AND json_extract("session_message"."data", '$.time.completed') IS NULL)
|
||||
OR ("session_message"."type" IN ('shell', 'compaction') AND json_extract("session_message"."data", '$.status') = 'running'));
|
||||
`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`,
|
||||
)
|
||||
|
||||
@@ -10,9 +10,10 @@ import { KVTable } from "../kv/sql.js"
|
||||
import { EventSequenceTable } from "../event/sql.js"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { existsSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import type { Database as SQLiteDatabase } from "bun:sqlite"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Timeline } from "../session/timeline.js"
|
||||
|
||||
export type SourceMessage = {
|
||||
readonly id: string
|
||||
@@ -87,6 +88,10 @@ type RunResult = {
|
||||
readonly status: "completed"
|
||||
}
|
||||
|
||||
type Options = {
|
||||
readonly nextDatabasePath?: string
|
||||
}
|
||||
|
||||
type MigrationState = { readonly phase: "sessions"; readonly cursor?: string } | { readonly phase: "completed" }
|
||||
|
||||
type RuntimeState =
|
||||
@@ -94,6 +99,118 @@ type RuntimeState =
|
||||
| { readonly status: "running"; readonly progress: Progress }
|
||||
| { readonly status: "error"; readonly error: string }
|
||||
|
||||
type NextProject = {
|
||||
readonly id: string
|
||||
readonly worktree: string
|
||||
readonly vcs: string | null
|
||||
readonly name: string | null
|
||||
readonly icon_url: string | null
|
||||
readonly icon_url_override: string | null
|
||||
readonly icon_color: string | null
|
||||
readonly time_created: number
|
||||
readonly time_updated: number
|
||||
readonly time_initialized: number | null
|
||||
readonly sandboxes: string
|
||||
readonly commands: string | null
|
||||
}
|
||||
|
||||
type NextColumns<A> = Record<keyof A, "required" | "nullable" | { readonly fallback: keyof A & string }>
|
||||
|
||||
const NEXT_PROJECT_COLUMNS = {
|
||||
id: "required",
|
||||
worktree: "required",
|
||||
vcs: "nullable",
|
||||
name: "nullable",
|
||||
icon_url: "nullable",
|
||||
icon_url_override: { fallback: "icon_url" },
|
||||
icon_color: "nullable",
|
||||
time_created: "required",
|
||||
time_updated: "required",
|
||||
time_initialized: "nullable",
|
||||
sandboxes: "required",
|
||||
commands: "nullable",
|
||||
} satisfies NextColumns<NextProject>
|
||||
|
||||
type NextSession = {
|
||||
readonly id: string
|
||||
readonly project_id: string
|
||||
readonly workspace_id: string | null
|
||||
readonly parent_id: string | null
|
||||
readonly fork_session_id: string | null
|
||||
readonly fork_boundary: string | null
|
||||
readonly slug: string
|
||||
readonly directory: string
|
||||
readonly path: string | null
|
||||
readonly title: string | null
|
||||
readonly version: string
|
||||
readonly share_url: string | null
|
||||
readonly summary_additions: number | null
|
||||
readonly summary_deletions: number | null
|
||||
readonly summary_files: number | null
|
||||
readonly summary_diffs: string | null
|
||||
readonly metadata: string | null
|
||||
readonly cost: number
|
||||
readonly tokens_input: number
|
||||
readonly tokens_output: number
|
||||
readonly tokens_reasoning: number
|
||||
readonly tokens_cache_read: number
|
||||
readonly tokens_cache_write: number
|
||||
readonly revert: string | null
|
||||
readonly permission: string | null
|
||||
readonly agent: string | null
|
||||
readonly model: string | null
|
||||
readonly time_created: number
|
||||
readonly time_updated: number
|
||||
readonly time_compacting: number | null
|
||||
readonly time_archived: number | null
|
||||
readonly time_suspended: number | null
|
||||
}
|
||||
|
||||
const NEXT_SESSION_COLUMNS = {
|
||||
id: "required",
|
||||
project_id: "required",
|
||||
workspace_id: "nullable",
|
||||
parent_id: "nullable",
|
||||
fork_session_id: "nullable",
|
||||
fork_boundary: "nullable",
|
||||
slug: "required",
|
||||
directory: "required",
|
||||
path: "nullable",
|
||||
title: "nullable",
|
||||
version: "required",
|
||||
share_url: "nullable",
|
||||
summary_additions: "nullable",
|
||||
summary_deletions: "nullable",
|
||||
summary_files: "nullable",
|
||||
summary_diffs: "nullable",
|
||||
metadata: "nullable",
|
||||
cost: "required",
|
||||
tokens_input: "required",
|
||||
tokens_output: "required",
|
||||
tokens_reasoning: "required",
|
||||
tokens_cache_read: "required",
|
||||
tokens_cache_write: "required",
|
||||
revert: "nullable",
|
||||
permission: "nullable",
|
||||
agent: "nullable",
|
||||
model: "nullable",
|
||||
time_created: "required",
|
||||
time_updated: "required",
|
||||
time_compacting: "nullable",
|
||||
time_archived: "nullable",
|
||||
time_suspended: "nullable",
|
||||
} satisfies NextColumns<NextSession>
|
||||
|
||||
type NextMessage = {
|
||||
readonly id: string
|
||||
readonly session_id: string
|
||||
readonly type: string
|
||||
readonly seq: number
|
||||
readonly time_created: number
|
||||
readonly time_updated: number
|
||||
readonly data: string
|
||||
}
|
||||
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const MIGRATION_STATE_KEY = "migration.v1-v2"
|
||||
const EVENT_DELETE_BATCH_SIZE = 1_000
|
||||
@@ -396,7 +513,7 @@ function updateProgress(progress: Progress) {
|
||||
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
|
||||
}
|
||||
|
||||
export function run(): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
|
||||
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
|
||||
return lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
@@ -429,6 +546,7 @@ export function run(): Effect.Effect<RunResult, never, Database.Service | Global
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
|
||||
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
|
||||
const cursor = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const migrated =
|
||||
@@ -436,7 +554,12 @@ export function run(): Effect.Effect<RunResult, never, Database.Service | Global
|
||||
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
|
||||
?.value ?? 0)
|
||||
: 0
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator: legacyTotal })
|
||||
const denominator = sourceTotal + legacyTotal
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
|
||||
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
|
||||
})
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
|
||||
const projects = new Set(
|
||||
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
|
||||
)
|
||||
@@ -466,21 +589,15 @@ export function run(): Effect.Effect<RunResult, never, Database.Service | Global
|
||||
sessionID: nextID.id,
|
||||
projectID: nextID.project_id,
|
||||
})
|
||||
const existing = yield* tx
|
||||
.select({ timelineID: SessionTable.timeline_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
|
||||
.get()
|
||||
const timelineID = existing?.timelineID ?? (yield* Timeline.create(tx))
|
||||
yield* tx.run(sql`
|
||||
INSERT OR IGNORE INTO session_v2 (
|
||||
id, timeline_id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
||||
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
|
||||
)
|
||||
SELECT
|
||||
id, ${timelineID}, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
||||
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
|
||||
@@ -510,7 +627,6 @@ export function run(): Effect.Effect<RunResult, never, Database.Service | Global
|
||||
.values({
|
||||
id: SessionMessage.ID.make(message.id),
|
||||
session_id: SessionSchema.ID.make(message.session_id),
|
||||
timeline_id: next.timeline_id,
|
||||
type: message.type,
|
||||
seq: message.seq,
|
||||
time_created: message.time_created,
|
||||
@@ -541,7 +657,7 @@ export function run(): Effect.Effect<RunResult, never, Database.Service | Global
|
||||
progress: {
|
||||
label: "Migrating sessions",
|
||||
numerator: (runtimeState.progress.numerator ?? 0) + 1,
|
||||
denominator: legacyTotal,
|
||||
denominator,
|
||||
},
|
||||
}
|
||||
yield* Effect.yieldNow
|
||||
@@ -565,6 +681,182 @@ export function run(): Effect.Effect<RunResult, never, Database.Service | Global
|
||||
)
|
||||
}
|
||||
|
||||
function nextPath(options: Options, data: string) {
|
||||
if (options.nextDatabasePath) return options.nextDatabasePath
|
||||
if (process.env.OPENCODE_DB === ":memory:") return undefined
|
||||
return path.join(data, "opencode-next.db")
|
||||
}
|
||||
|
||||
function openNextDatabase(sourcePath: string) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.gen(function* () {
|
||||
const sqlite = yield* Effect.promise(() => import("bun:sqlite"))
|
||||
return new sqlite.Database(sourcePath, { readonly: true, strict: true })
|
||||
}),
|
||||
(source) => Effect.sync(() => source.close()),
|
||||
)
|
||||
}
|
||||
|
||||
function countNextSessions(sourcePath: string | undefined) {
|
||||
if (!sourcePath || !existsSync(sourcePath)) return Effect.succeed(0)
|
||||
return Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const source = yield* openNextDatabase(sourcePath)
|
||||
if (!isNextDatabase(source)) return 0
|
||||
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function importNextDatabase(
|
||||
db: Database.Interface["db"],
|
||||
sourcePath: string | undefined,
|
||||
onProgress: (completed: number) => void,
|
||||
): Effect.Effect<void, unknown> {
|
||||
if (!sourcePath || !existsSync(sourcePath)) return Effect.void
|
||||
return Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const source = yield* openNextDatabase(sourcePath)
|
||||
if (!isNextDatabase(source)) {
|
||||
yield* Effect.logWarning("Skipped incompatible opencode-next.db", { path: sourcePath })
|
||||
return
|
||||
}
|
||||
source.run("BEGIN")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
if (source.inTransaction) source.run("ROLLBACK")
|
||||
}),
|
||||
)
|
||||
const projects = new Map(
|
||||
selectNextRows<NextProject>(source, "project", NEXT_PROJECT_COLUMNS).map((project) => [project.id, project]),
|
||||
)
|
||||
const sessions = selectNextRows<NextSession>(source, "session", NEXT_SESSION_COLUMNS)
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
const project = projects.get(session.project_id)
|
||||
const projectID = project ? session.project_id : Project.ID.global
|
||||
if (!project) {
|
||||
yield* Effect.logWarning("Reassigned previous V2 session with missing project", {
|
||||
sessionID: session.id,
|
||||
projectID: session.project_id,
|
||||
})
|
||||
}
|
||||
const messages = source
|
||||
.query<
|
||||
NextMessage,
|
||||
[string]
|
||||
>("SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq")
|
||||
.all(session.id)
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
if (project)
|
||||
yield* tx.run(sql`
|
||||
INSERT OR IGNORE INTO project (
|
||||
id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
|
||||
time_created, time_updated, time_initialized, sandboxes, commands
|
||||
) VALUES (
|
||||
${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url},
|
||||
${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated},
|
||||
${project.time_initialized}, ${project.sandboxes}, ${project.commands}
|
||||
)
|
||||
`)
|
||||
const existing = yield* tx
|
||||
.select({ id: SessionTable.id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, SessionSchema.ID.make(session.id)))
|
||||
.get()
|
||||
if (existing) return
|
||||
yield* tx.run(sql`
|
||||
INSERT INTO session_v2 (
|
||||
id, project_id, workspace_id, parent_id, fork_session_id, fork_boundary, slug, directory,
|
||||
path, title, version, share_url, summary_additions, summary_deletions, summary_files,
|
||||
summary_diffs, metadata, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
|
||||
tokens_cache_write, revert, permission, agent, model, time_created, time_updated, time_compacting,
|
||||
time_archived, time_suspended
|
||||
) VALUES (
|
||||
${session.id}, ${projectID}, ${session.workspace_id}, ${session.parent_id},
|
||||
${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory},
|
||||
${session.path}, ${session.title}, ${session.version}, ${session.share_url},
|
||||
${session.summary_additions}, ${session.summary_deletions}, ${session.summary_files},
|
||||
${session.summary_diffs}, ${session.metadata}, ${session.cost}, ${session.tokens_input},
|
||||
${session.tokens_output}, ${session.tokens_reasoning}, ${session.tokens_cache_read},
|
||||
${session.tokens_cache_write}, ${session.revert}, ${session.permission}, ${session.agent},
|
||||
${session.model}, ${session.time_created}, ${session.time_updated}, ${session.time_compacting},
|
||||
${session.time_archived}, ${session.time_suspended}
|
||||
)
|
||||
`)
|
||||
yield* Effect.forEach(messages, (message) =>
|
||||
tx
|
||||
.insert(SessionMessageTable)
|
||||
.values({
|
||||
id: SessionMessage.ID.make(message.id),
|
||||
session_id: SessionSchema.ID.make(message.session_id),
|
||||
type: message.type as SessionMessage.Type,
|
||||
seq: message.seq,
|
||||
time_created: message.time_created,
|
||||
time_updated: message.time_updated,
|
||||
data: sql`${message.data}`,
|
||||
})
|
||||
.run(),
|
||||
)
|
||||
yield* tx
|
||||
.insert(EventSequenceTable)
|
||||
.values({ aggregate_id: session.id, seq: messages.at(-1)?.seq ?? -1 })
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq: messages.at(-1)?.seq ?? -1, owner_id: null },
|
||||
})
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
onProgress(index + 1)
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
source.run("COMMIT")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function isNextDatabase(source: SQLiteDatabase) {
|
||||
const tables = new Set(
|
||||
source
|
||||
.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.all()
|
||||
.map((table) => table.name),
|
||||
)
|
||||
return tables.has("project") && tables.has("session") && tables.has("session_message")
|
||||
}
|
||||
|
||||
function selectNextRows<A>(source: SQLiteDatabase, table: "project" | "session", definition: NextColumns<A>) {
|
||||
const columns = new Set(
|
||||
source
|
||||
.query<{ name: string }, [string]>("SELECT name FROM pragma_table_info(?)")
|
||||
.all(table)
|
||||
.map((column) => column.name),
|
||||
)
|
||||
const missing = Object.entries(definition)
|
||||
.filter(([column, strategy]) => strategy === "required" && !columns.has(column))
|
||||
.map(([column]) => column)
|
||||
if (missing.length)
|
||||
throw new Error(`Incompatible opencode-next.db: ${table} is missing required columns: ${missing.join(", ")}`)
|
||||
const projection = Object.entries(definition).map(([column, strategy]) => {
|
||||
if (columns.has(column)) return `"${column}"`
|
||||
if (
|
||||
typeof strategy === "object" &&
|
||||
strategy !== null &&
|
||||
"fallback" in strategy &&
|
||||
typeof strategy.fallback === "string" &&
|
||||
columns.has(strategy.fallback)
|
||||
)
|
||||
return `"${strategy.fallback}" AS "${column}"`
|
||||
return `NULL AS "${column}"`
|
||||
})
|
||||
return source
|
||||
.query<A, []>(`SELECT ${projection.join(", ")} FROM "${table}"${table === "session" ? ' ORDER BY "id" DESC' : ""}`)
|
||||
.all()
|
||||
}
|
||||
|
||||
function row(
|
||||
source: SourceMessage,
|
||||
message: {
|
||||
|
||||
@@ -245,7 +245,8 @@ export const connect = Effect.fnUntraced(function* (
|
||||
|
||||
return yield* open(url).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!addedCodemode || !(error instanceof StreamableHTTPError) || error.code !== 404) return Effect.fail(error)
|
||||
if (!addedCodemode || !(error instanceof StreamableHTTPError) || (error.code !== 400 && error.code !== 404))
|
||||
return Effect.fail(error)
|
||||
// Some servers reject unknown query params. Retry once with the user's original URL.
|
||||
return open(new URL(config.url))
|
||||
}),
|
||||
|
||||
@@ -2,14 +2,60 @@ export * as PluginModule from "./module.js"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { createPluginSources } from "@opencode-ai/plugin/source"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Deferred, Effect, FiberSet, PubSub, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { stat } from "node:fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import type { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import type { Generation } from "../plugin.js"
|
||||
import { PluginPromise } from "./promise.js"
|
||||
import { Watcher } from "../filesystem/watcher.js"
|
||||
|
||||
export const make = Effect.fn("PluginModule.make")(function* () {
|
||||
const watcher = yield* Watcher.Service
|
||||
const scope = yield* Effect.scope
|
||||
const runPromise = yield* FiberSet.makeRuntimePromise()
|
||||
const changes = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
const watch = Effect.fn("PluginModule.watch")(function* (file: string) {
|
||||
if (watched.has(file)) return
|
||||
watched.add(file)
|
||||
const ready = yield* Deferred.make<void>()
|
||||
const target = yield* Effect.promise(() => watchTarget(file))
|
||||
const updates = yield* watcher.subscribe(target, Deferred.succeed(ready, undefined))
|
||||
yield* updates.pipe(
|
||||
Stream.runForEach(() => PubSub.publish(changes, undefined)),
|
||||
Effect.ensuring(Deferred.succeed(ready, undefined)),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
yield* Deferred.await(ready)
|
||||
})
|
||||
const sources = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => createPluginSources((file) => runPromise(watch(file)))),
|
||||
(sources) => Effect.sync(() => sources.dispose()),
|
||||
)
|
||||
return {
|
||||
load: (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
options?: { readonly install?: boolean },
|
||||
) => load(operation, sources, options),
|
||||
changes: () => Stream.fromPubSub(changes),
|
||||
}
|
||||
})
|
||||
|
||||
// A missing dependency may have missing parents too. Watch the nearest existing
|
||||
// ancestor recursively so creating the rest of the path can trigger recovery.
|
||||
function watchTarget(file: string): Promise<Watcher.WatchInput> {
|
||||
return stat(file).then(
|
||||
(info) => ({ path: file, type: info.isDirectory() ? "directory" : "file" }),
|
||||
(cause) => {
|
||||
if (path.dirname(file) === file) throw cause
|
||||
return watchTarget(path.dirname(file))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const Module = Schema.Struct({
|
||||
default: Schema.Union([
|
||||
@@ -31,8 +77,9 @@ export class LoadError extends Schema.TaggedError<LoadError>()("PluginModule.Loa
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export const load = Effect.fn("PluginModule.load")(function* (
|
||||
const load = Effect.fn("PluginModule.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
sources: ReturnType<typeof createPluginSources>,
|
||||
options?: { readonly install?: boolean },
|
||||
) {
|
||||
const local = path.isAbsolute(operation.target)
|
||||
@@ -51,12 +98,13 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
const entrypoint = entrypoints.server
|
||||
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
|
||||
if (!entrypoint) return yield* new LoadError({ message: `Plugin entrypoint not found: ${operation.target}` })
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const target = typeof Bun !== "undefined" ? fileURLToPath(entrypoint).replaceAll("\\", "/") : entrypoint
|
||||
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
|
||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
|
||||
const mod = yield* Effect.promise(() => Host.load(source))
|
||||
const value = (yield* Schema.decodeUnknownEffect(Module)(mod).pipe(
|
||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint })
|
||||
const loaded = yield* Effect.promise(() =>
|
||||
local
|
||||
? sources.read(entrypoint)
|
||||
: Host.load(entrypoint).then((module) => ({ module, version: installed?.revision })),
|
||||
)
|
||||
const value = (yield* Schema.decodeUnknownEffect(Module)(loaded.module).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new LoadError({
|
||||
@@ -72,7 +120,7 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
...(entrypoints.tui ? { tui: true as const } : {}),
|
||||
...(entrypoints.rpc ? { rpc: true as const } : {}),
|
||||
},
|
||||
revision: JSON.stringify([operation, installed?.revision]),
|
||||
revision: JSON.stringify([operation, loaded.version]),
|
||||
source: path.isAbsolute(operation.target)
|
||||
? { type: "local" as const, path: fileURLToPath(entrypoint) }
|
||||
: {
|
||||
|
||||
@@ -1,123 +1,59 @@
|
||||
import { Effect } from "effect"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
type MantleSDK = {
|
||||
chat: (modelID: string) => LanguageModelV3
|
||||
responses: (modelID: string) => LanguageModelV3
|
||||
}
|
||||
// Ambient inputs the AWS default credential chain can turn into credentials
|
||||
// without any key stored in opencode. Mirrors the presence checks the AWS CLI
|
||||
// and SDK use before consulting shared config.
|
||||
const CHAIN_ENV = [
|
||||
"AWS_PROFILE",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_WEB_IDENTITY_TOKEN_FILE",
|
||||
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
|
||||
"AWS_CONTAINER_CREDENTIALS_FULL_URI",
|
||||
]
|
||||
|
||||
// Bedrock cross-region inference profiles require regional prefixes only for
|
||||
// specific model/region combinations. Keep the mapping narrow and avoid
|
||||
// double-prefixing model IDs that models.dev already marks as global/us/eu/etc.
|
||||
function resolveModelID(modelID: string, region: string | undefined) {
|
||||
const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
|
||||
if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) return modelID
|
||||
|
||||
const resolvedRegion = region ?? "us-east-1"
|
||||
const regionPrefix = resolvedRegion.split("-")[0]
|
||||
if (regionPrefix === "us") {
|
||||
const requiresPrefix = ["nova-micro", "nova-lite", "nova-pro", "nova-premier", "nova-2", "claude", "deepseek"].some(
|
||||
(item) => modelID.includes(item),
|
||||
)
|
||||
if (requiresPrefix && !resolvedRegion.startsWith("us-gov")) return `${regionPrefix}.${modelID}`
|
||||
return modelID
|
||||
}
|
||||
if (regionPrefix === "eu") {
|
||||
const regionRequiresPrefix = [
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"eu-north-1",
|
||||
"eu-central-1",
|
||||
"eu-south-1",
|
||||
"eu-south-2",
|
||||
].some((item) => resolvedRegion.includes(item))
|
||||
const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((item) =>
|
||||
modelID.includes(item),
|
||||
)
|
||||
return regionRequiresPrefix && modelRequiresPrefix ? `${regionPrefix}.${modelID}` : modelID
|
||||
}
|
||||
if (regionPrefix !== "ap") return modelID
|
||||
|
||||
const australia = ["ap-southeast-2", "ap-southeast-4"].includes(resolvedRegion)
|
||||
if (australia && ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((item) => modelID.includes(item))) {
|
||||
return `au.${modelID}`
|
||||
}
|
||||
|
||||
const prefix = resolvedRegion === "ap-northeast-1" ? "jp" : "apac"
|
||||
return ["claude", "nova-lite", "nova-micro", "nova-pro"].some((item) => modelID.includes(item))
|
||||
? `${prefix}.${modelID}`
|
||||
: modelID
|
||||
}
|
||||
|
||||
function selectMantleModel(sdk: MantleSDK, modelID: string) {
|
||||
if (modelID === "openai.gpt-oss-safeguard-20b" || modelID === "openai.gpt-oss-safeguard-120b")
|
||||
return sdk.chat(modelID)
|
||||
return sdk.responses(modelID)
|
||||
const isBedrock = (item: { readonly package: string }) => {
|
||||
const name = Provider.packageName(item.package)
|
||||
return name.startsWith("@ai-sdk/amazon-bedrock") || name.startsWith("@opencode-ai/ai/providers/amazon-bedrock")
|
||||
}
|
||||
|
||||
export const AmazonBedrockPlugin = define({
|
||||
id: "opencode.provider.amazon.bedrock",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
// models.dev advertises AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and
|
||||
// AWS_REGION alongside the bearer token. Only the bearer token is a key;
|
||||
// the rest feed the SigV4 credential chain and must not become one.
|
||||
editor.method.update({
|
||||
integrationID: Provider.ID.amazonBedrock,
|
||||
method: { type: "env", names: ["AWS_BEARER_TOKEN_BEDROCK"] },
|
||||
})
|
||||
})
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (!Provider.isAISDK(item.provider.package)) continue
|
||||
if (Provider.packageName(item.provider.package) !== "@ai-sdk/amazon-bedrock") continue
|
||||
if (!isBedrock(item.provider)) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (typeof provider.settings?.endpoint !== "string") return
|
||||
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
|
||||
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
|
||||
provider.settings.baseURL = provider.settings.endpoint
|
||||
const settings = provider.settings ?? {}
|
||||
const chain = typeof settings.profile === "string" || CHAIN_ENV.some((name) => process.env[name])
|
||||
// SigV4 authenticates through the AWS default chain rather than a key
|
||||
// credential, so ambient AWS configuration is what makes Bedrock usable.
|
||||
if (chain && provider.activation === "auto") provider.activation = "enabled"
|
||||
// Same default the native package uses, made explicit here so catalog
|
||||
// `${AWS_REGION}` URLs resolve without any region configured.
|
||||
const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1"
|
||||
provider.settings = {
|
||||
...settings,
|
||||
...(typeof settings.region !== "string" ? { region } : {}),
|
||||
// Users configure Bedrock private/VPC endpoints as `endpoint`; move it
|
||||
// into the catalog base URL once.
|
||||
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
|
||||
? { baseURL: settings.endpoint }
|
||||
: {}),
|
||||
}
|
||||
delete provider.settings.endpoint
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return
|
||||
const options = { ...evt.options }
|
||||
const profile = typeof options.profile === "string" ? options.profile : process.env.AWS_PROFILE
|
||||
const region = typeof options.region === "string" ? options.region : (process.env.AWS_REGION ?? "us-east-1")
|
||||
const bearerToken =
|
||||
process.env.AWS_BEARER_TOKEN_BEDROCK ??
|
||||
(typeof options.bearerToken === "string" ? options.bearerToken : undefined)
|
||||
if (bearerToken && !process.env.AWS_BEARER_TOKEN_BEDROCK) process.env.AWS_BEARER_TOKEN_BEDROCK = bearerToken
|
||||
options.region = region
|
||||
if (typeof options.endpoint === "string") options.baseURL = options.endpoint
|
||||
if (!bearerToken && options.credentialProvider === undefined) {
|
||||
// Do not gate SDK creation on explicit AWS env vars. The default chain
|
||||
// also handles ~/.aws/credentials, SSO, process creds, and instance roles.
|
||||
const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers"))
|
||||
options.credentialProvider = fromNodeProviderChain(profile ? { profile } : {})
|
||||
}
|
||||
|
||||
if (evt.package === "@ai-sdk/amazon-bedrock/mantle") {
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock/mantle"))
|
||||
evt.sdk = mod.createBedrockMantle(options)
|
||||
return
|
||||
}
|
||||
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock"))
|
||||
evt.sdk = mod.createAmazonBedrock(options)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== Provider.ID.amazonBedrock) return
|
||||
if (
|
||||
Provider.isAISDK(evt.model.package) &&
|
||||
Provider.packageName(evt.model.package) === "@ai-sdk/amazon-bedrock/mantle"
|
||||
) {
|
||||
evt.language = selectMantleModel(evt.sdk, evt.model.modelID ?? evt.model.id)
|
||||
return
|
||||
}
|
||||
const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION
|
||||
evt.language = evt.sdk.languageModel(resolveModelID(evt.model.modelID ?? evt.model.id, region))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -261,6 +261,7 @@ export const GithubCopilotPlugin = define({
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
const interaction = interactionType(evt.kind, session?.parentID !== undefined)
|
||||
evt.headers["X-Interaction-Type"] = interaction
|
||||
evt.headers["X-Interaction-Id"] = evt.sessionID
|
||||
if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent"
|
||||
}),
|
||||
{ providerID: Provider.ID.githubCopilot },
|
||||
|
||||
@@ -13,8 +13,10 @@ import { PluginInternal } from "./internal.js"
|
||||
import { PluginModule } from "./module.js"
|
||||
import { SdkPlugins } from "./sdk.js"
|
||||
import { PluginUpdate } from "./update.js"
|
||||
import { Watcher } from "../filesystem/watcher.js"
|
||||
|
||||
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
modules: Effect.Success<ReturnType<typeof PluginModule.make>>,
|
||||
pre: readonly Plugin.Generation[],
|
||||
post: readonly Plugin.Generation[],
|
||||
operations: readonly ConfigPluginSource.Operation[],
|
||||
@@ -53,7 +55,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
continue
|
||||
}
|
||||
|
||||
const plugin = yield* PluginModule.load(operation, { install }).pipe(
|
||||
const plugin = yield* modules.load(operation, { install }).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
const error = Cause.squash(cause)
|
||||
@@ -117,6 +119,7 @@ export const layer = Layer.effectDiscard(
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const instance = yield* InstancePlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const modules = yield* PluginModule.make()
|
||||
const bus = yield* Bus.Service
|
||||
const updates = yield* PluginUpdate.Service
|
||||
const internal = yield* PluginInternal.list()
|
||||
@@ -149,7 +152,7 @@ export const layer = Layer.effectDiscard(
|
||||
}))
|
||||
const operations = yield* sources.operations()
|
||||
// Activate everything available locally before waiting on missing package installs.
|
||||
const immediate = yield* resolve(pre, post, operations, false, running)
|
||||
const immediate = yield* resolve(modules, pre, post, operations, false, running)
|
||||
const source = (source: Plugin.Source) =>
|
||||
source.type === "package"
|
||||
? {
|
||||
@@ -164,7 +167,9 @@ export const layer = Layer.effectDiscard(
|
||||
resolved.failures.map((failure) => ({ ...failure, source: source(failure.source) })),
|
||||
)
|
||||
yield* apply(immediate)
|
||||
const resolved = immediate.pending.length ? yield* resolve(pre, post, operations, true, running) : immediate
|
||||
const resolved = immediate.pending.length
|
||||
? yield* resolve(modules, pre, post, operations, true, running)
|
||||
: immediate
|
||||
if (resolved !== immediate) yield* apply(resolved)
|
||||
running = resolved.packages
|
||||
const targets = new Set(
|
||||
@@ -203,6 +208,7 @@ export const layer = Layer.effectDiscard(
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* watch(sources.changes())
|
||||
yield* watch(modules.changes())
|
||||
yield* watch(Stream.fromEffectRepeat(Effect.sleep("24 hours")))
|
||||
yield* watch(bus.subscribe([Event.Updated, SdkPlugins.Updated]))
|
||||
yield* watch(
|
||||
@@ -240,6 +246,7 @@ const nodeDeps = [
|
||||
PluginUpdate.node,
|
||||
Bus.node,
|
||||
Npm.node,
|
||||
Watcher.node,
|
||||
PluginInternal.requirements,
|
||||
] as const
|
||||
|
||||
|
||||
@@ -2,42 +2,50 @@ export * as SystemPromptPlugin from "./system-prompt.js"
|
||||
|
||||
import { SystemPart } from "@opencode-ai/ai"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Effect } from "effect"
|
||||
import { SessionSystemPrompt } from "../session/system-prompt.js"
|
||||
|
||||
import PROMPT_ANTHROPIC from "./system-prompt/anthropic.txt"
|
||||
import PROMPT_GPT from "./system-prompt/gpt-extension.txt"
|
||||
import PROMPT_GPT from "./system-prompt/gpt.txt"
|
||||
import PROMPT_ASTRA from "./system-prompt/gpt-astra.txt"
|
||||
import PROMPT_KIMI from "./system-prompt/kimi.txt"
|
||||
import PROMPT_META from "./system-prompt/meta.txt"
|
||||
import PROMPT_TRINITY from "./system-prompt/trinity.txt"
|
||||
|
||||
export const OpenAIPlugin = make("openai", (id) => (id.includes("gpt") ? PROMPT_GPT : undefined), {
|
||||
operation: "append",
|
||||
})
|
||||
export const OpenAIPlugin = make(
|
||||
"openai",
|
||||
(model) => {
|
||||
if (!model.id.toLowerCase().includes("gpt")) return
|
||||
|
||||
export const AnthropicPlugin = make("anthropic", (id) => (id.includes("claude") ? PROMPT_ANTHROPIC : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const KimiPlugin = make("kimi", (id) => (id.includes("kimi") ? PROMPT_KIMI : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const ArceePlugin = make("arcee", (id) => (id.includes("trinity") ? PROMPT_TRINITY : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const MetaPlugin = make(
|
||||
"meta",
|
||||
(id) => {
|
||||
if (!id.includes("muse")) return
|
||||
const name = id.includes("muse-glimmer") ? "Muse Glimmer" : "Muse Spark"
|
||||
return PROMPT_META.replaceAll("{{MODEL_NAME}}", name)
|
||||
if (model.id.toLowerCase().includes("gpt-6")) return PROMPT_ASTRA
|
||||
|
||||
return PROMPT_GPT
|
||||
},
|
||||
{ operation: "replace" },
|
||||
)
|
||||
|
||||
export const Plugins = [OpenAIPlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
|
||||
export const KimiPlugin = make("kimi", (model) => (model.id.toLowerCase().includes("kimi") ? PROMPT_KIMI : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const ArceePlugin = make(
|
||||
"arcee",
|
||||
(model) => (model.id.toLowerCase().includes("trinity") ? PROMPT_TRINITY : undefined),
|
||||
{ operation: "replace" },
|
||||
)
|
||||
export const MetaPlugin = make(
|
||||
"meta",
|
||||
(model) => {
|
||||
if (!model.id.toLowerCase().includes("muse")) return
|
||||
return PROMPT_META.replaceAll("{{MODEL_NAME}}", model.name)
|
||||
},
|
||||
{ operation: "replace" },
|
||||
)
|
||||
|
||||
export const Plugins = [OpenAIPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
|
||||
|
||||
function make(
|
||||
id: string,
|
||||
getPrompt: (modelID: string) => string | undefined,
|
||||
getPrompt: (model: Model.Info) => string | undefined,
|
||||
options: { operation: "replace" | "append" },
|
||||
) {
|
||||
return define({
|
||||
@@ -51,8 +59,9 @@ function make(
|
||||
const model = (yield* ctx.catalog.model.list()).data.find(
|
||||
(model) => model.providerID === event.model.providerID && model.id === event.model.id,
|
||||
)
|
||||
const prompt = getPrompt(`${model?.modelID ?? event.model.id} ${model?.family ?? ""}`.toLowerCase())
|
||||
if (!prompt) return
|
||||
const template = getPrompt(model ?? Model.Info.default(event.model.providerID, event.model.id))
|
||||
if (!template) return
|
||||
const prompt = SessionSystemPrompt.render(template, Object.keys(event.tools))
|
||||
if (options.operation === "append") {
|
||||
event.system.splice(1, 0, SystemPart.make(prompt))
|
||||
return
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
You are OpenCode, the best coding agent on the planet.
|
||||
|
||||
You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
||||
|
||||
If the user asks for help or wants to give feedback inform them of the following:
|
||||
- ctrl+p to list available actions
|
||||
- To give feedback, users should report the issue at
|
||||
https://github.com/anomalyco/opencode
|
||||
|
||||
When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the webfetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/v2/docs/
|
||||
|
||||
# Tone and style
|
||||
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||||
- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
||||
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like the shell tool or code comments as means to communicate with the user during the session.
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
|
||||
|
||||
# Professional objectivity
|
||||
Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs.
|
||||
|
||||
# Doing tasks
|
||||
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
|
||||
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.
|
||||
|
||||
|
||||
# Tool usage policy
|
||||
- When doing file search, prefer to use the subagent tool in order to reduce context usage.
|
||||
- You should proactively use the subagent tool with specialized agents when the task at hand matches the agent's description.
|
||||
|
||||
- When webfetch returns a message about a redirect to a different host, you should immediately make a new webfetch request with the redirect URL provided in the response.
|
||||
- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls.
|
||||
- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple subagent tool calls.
|
||||
- Use specialized tools instead of shell commands when possible, as this provides a better user experience. For file operations, use dedicated tools: read for reading files instead of cat/head/tail, edit for editing instead of sed/awk, and write for creating files instead of cat with heredoc or echo redirection. Reserve the shell tool exclusively for actual system commands and terminal operations that require shell execution. NEVER use shell echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
|
||||
- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the subagent tool instead of running search commands directly.
|
||||
<example>
|
||||
user: Where are errors from the client handled?
|
||||
assistant: [Uses the subagent tool to find the files that handle client errors instead of using glob or grep directly]
|
||||
</example>
|
||||
<example>
|
||||
user: What is the codebase structure?
|
||||
assistant: [Uses the subagent tool]
|
||||
</example>
|
||||
|
||||
# Code References
|
||||
|
||||
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
|
||||
|
||||
<example>
|
||||
user: Where are errors from the client handled?
|
||||
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
|
||||
</example>
|
||||
@@ -0,0 +1,45 @@
|
||||
You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.
|
||||
|
||||
# Harness
|
||||
- Responses are rendered as GitHub-flavored Markdown.
|
||||
- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.
|
||||
- Prefer parallelizing independent tool calls.
|
||||
- Do not use a skill based solely on keywords, superficial relevance, or its availability. Avoid re-reading skills already available in the conversation unless needed.
|
||||
${OPENCODE_TOOL_GUIDANCE}
|
||||
|
||||
# Communication
|
||||
|
||||
State the main point clearly and early. Keep responses clear and concise, and avoid unnecessary technical jargon. Use only as much structure as needed, and include technical detail only when it helps the conversation. Use clear file paths when referring to files.
|
||||
|
||||
When describing your work, avoid adding what you won't do, what will remain unchanged, or how you'll separate or categorize results. Do not introduce unprompted alternatives through framing such as "X, not Y" or "This isn't about X. It's about Y."
|
||||
|
||||
## Autonomy
|
||||
|
||||
Infer the user's intent and your task scope from their instructions and the prior conversation context. You should bias towards action and carry out the user's intended task until it is completed. If the intent is unclear, progress towards the goal using the available information and ask for clarification while continuing independent work when possible.
|
||||
|
||||
When the user's prompt indicates a request for action, such as "can you...", "I want to...", "help me..." and similar expressions, treat these as instructions to take action. Do not stop at acknowledging capability (e.g. "Yes…"), proposing a plan, or offering to continue. Do not settle for a partial or "helpful enough" solution to save time, effort, or tokens. Continue until the user's intended goal is fulfilled, even when it requires sustained work.
|
||||
|
||||
## Intermediate Commentary
|
||||
|
||||
As you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.
|
||||
|
||||
By default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.
|
||||
|
||||
Do not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.
|
||||
|
||||
## Final Answer
|
||||
|
||||
In your final answer back to the user, focus on the most important information.
|
||||
|
||||
# Working in codebases
|
||||
|
||||
- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.
|
||||
- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.
|
||||
- Do not introduce unsolicited warnings, disclaimers, approval flows, or safety/compliance checklists due to hypothetical risk.
|
||||
- Do not write tests for reversible, low-impact changes or that mirror the implementation. If you do choose to verify your work with tests, make sure that the tests are meaningful and necessary to verify implementation.
|
||||
- Run tests appropriate to the change and complete required checks. Once those pass, broaden or repeat testing only when new changes, failures, or unresolved concerns justify it; otherwise, continue toward completing the task.
|
||||
|
||||
|
||||
# Delegation
|
||||
|
||||
Do not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.
|
||||
+22
-2
@@ -1,10 +1,30 @@
|
||||
# Response channels
|
||||
You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.
|
||||
|
||||
# Harness
|
||||
- Responses are rendered as GitHub-flavored Markdown.
|
||||
- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.
|
||||
- Prefer parallelizing independent tool calls.
|
||||
${OPENCODE_TOOL_GUIDANCE}
|
||||
|
||||
# Communication
|
||||
|
||||
Use clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.
|
||||
|
||||
## Intermediate Commentary
|
||||
|
||||
As you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.
|
||||
|
||||
By default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.
|
||||
|
||||
Do not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.
|
||||
|
||||
In the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and skip explanation the user did not ask for. Include technical detail only where it helps.
|
||||
## Final Answer
|
||||
|
||||
In the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.
|
||||
|
||||
# Working in codebases
|
||||
- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.
|
||||
- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.
|
||||
|
||||
# Delegation
|
||||
|
||||
@@ -4,7 +4,7 @@ export * from "./session/schema.js"
|
||||
import { Effect, Layer, Schema, Context, Stream } from "effect"
|
||||
import { LLMClient } from "@opencode-ai/ai"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import { Project } from "./project.js"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Location } from "./location.js"
|
||||
@@ -63,7 +63,6 @@ import { Job } from "./job.js"
|
||||
import type { Command } from "./command.js"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
import { Timeline } from "./session/timeline.js"
|
||||
|
||||
// get project -> project.locations
|
||||
//
|
||||
@@ -94,6 +93,7 @@ type CompactInput = Parameters<Session.Handle["compact"]>[0] & { sessionID: Sess
|
||||
type ForkInput = {
|
||||
sessionID: SessionSchema.ID
|
||||
boundary: SessionSchema.ForkRequestBoundary
|
||||
parentID?: SessionSchema.ID
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -293,18 +293,28 @@ const layer = Layer.effect(
|
||||
}),
|
||||
fork: Effect.fn("Session.fork")(function* (input) {
|
||||
const parent = yield* result.get(input.sessionID)
|
||||
const ranges = yield* Timeline.forSession(db, input.sessionID)
|
||||
const [boundary] = yield* Timeline.rows(db, ranges, {
|
||||
where: input.boundary.type === "before" ? eq(SessionMessageTable.id, input.boundary.messageID) : undefined,
|
||||
limit: 1,
|
||||
})
|
||||
const boundary = yield* db
|
||||
.select({ id: SessionMessageTable.id })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, input.sessionID),
|
||||
input.boundary.type === "before" ? eq(SessionMessageTable.id, input.boundary.messageID) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!boundary && input.boundary.type === "before")
|
||||
return yield* new MessageNotFoundError({
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.boundary.messageID,
|
||||
})
|
||||
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
|
||||
const sessionID = SessionSchema.ID.create()
|
||||
const sessionID = input.parentID
|
||||
? (yield* result.create({ parentID: input.parentID })).id
|
||||
: SessionSchema.ID.create()
|
||||
const inherited = yield* db
|
||||
.transaction(() =>
|
||||
Effect.all({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, gte, sql } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { MessageDecodeError } from "./error.js"
|
||||
@@ -7,25 +7,26 @@ import { SessionSchema } from "./schema.js"
|
||||
import { Instructions } from "../instructions/index.js"
|
||||
import { InstructionState } from "./instruction-state.js"
|
||||
import { SessionMessageTable } from "./sql.js"
|
||||
import { Timeline } from "./timeline.js"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
|
||||
export const latestCompaction = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
ranges?: readonly Timeline.Range[],
|
||||
) {
|
||||
const [row] = yield* Timeline.rows(db, ranges ?? (yield* Timeline.forSession(db, sessionID)), {
|
||||
where: and(
|
||||
eq(SessionMessageTable.type, "compaction"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'completed'`,
|
||||
),
|
||||
limit: 1,
|
||||
})
|
||||
return row
|
||||
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, "compaction"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'completed'`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
@@ -40,12 +41,19 @@ export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =
|
||||
)
|
||||
|
||||
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const ranges = yield* Timeline.forSession(db, sessionID)
|
||||
const compaction = yield* latestCompaction(db, sessionID, ranges)
|
||||
const rows = yield* Timeline.rows(db, ranges, {
|
||||
where: compaction ? gte(SessionMessageTable.seq, compaction.seq) : undefined,
|
||||
order: "asc",
|
||||
})
|
||||
const compaction = yield* latestCompaction(db, sessionID)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
compaction ? gte(SessionMessageTable.seq, compaction.seq) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return yield* Effect.forEach(rows, (row) =>
|
||||
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
|
||||
)
|
||||
@@ -104,11 +112,13 @@ export const firstUserMessage = Effect.fn("SessionHistory.firstUserMessage")(fun
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const [row] = yield* Timeline.rows(db, yield* Timeline.forSession(db, sessionID), {
|
||||
where: eq(SessionMessageTable.type, "user"),
|
||||
order: "asc",
|
||||
limit: 1,
|
||||
})
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "user")))
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return undefined
|
||||
const message = yield* decodeMessageRow(row).pipe(Effect.orElseSucceed(() => undefined))
|
||||
return message?.type === "user" ? message : undefined
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionProjector from "./projector.js"
|
||||
|
||||
import { and, desc, eq, gt, gte, inArray, isNull, lt, lte, not, or, sql } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Database } from "../database/database.js"
|
||||
@@ -14,7 +14,7 @@ import { SessionMessageUpdater } from "./message-updater.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { InstructionState } from "./instruction-state.js"
|
||||
import { SessionInboxTable, SessionMessageTable, SessionTable, unsettled } from "./sql.js"
|
||||
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { InstructionEntry } from "./instruction-entry.js"
|
||||
import { Slug } from "../util/slug.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -24,7 +24,6 @@ import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath, RelativePath } from "../schema.js"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
import { ProjectTable } from "../project/sql.js"
|
||||
import { Timeline } from "./timeline.js"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
type MessageEvent = Exclude<
|
||||
@@ -116,42 +115,54 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
.pipe(Effect.orDie)
|
||||
if (!parent) return yield* Effect.die(new Error(`Fork parent session not found: ${event.data.parentID}`))
|
||||
const boundary = yield* db
|
||||
.select()
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, event.data.boundary.messageID))
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.parentID),
|
||||
eq(SessionMessageTable.id, event.data.boundary.messageID),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!boundary)
|
||||
return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.boundary.messageID}`))
|
||||
const ranges = yield* Timeline.ranges(db, boundary.timeline_id)
|
||||
const end = boundary.seq + (event.data.boundary.type === "through" ? 1 : 0)
|
||||
const [copied] = yield* Timeline.rows(db, ranges, { where: lt(SessionMessageTable.seq, end), limit: 1 })
|
||||
const copied = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.parentID),
|
||||
event.data.boundary.type === "before"
|
||||
? lt(SessionMessageTable.seq, boundary.seq)
|
||||
: lte(SessionMessageTable.seq, boundary.seq),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const copiedSeq = copied?.seq
|
||||
const [active] = yield* Timeline.rows(db, ranges, {
|
||||
where: and(lt(SessionMessageTable.seq, end), unsettled(SessionMessageTable)),
|
||||
order: "asc",
|
||||
limit: 1,
|
||||
})
|
||||
const base = yield* Timeline.prefix(db, ranges, active?.seq ?? end)
|
||||
const timelineID = yield* Timeline.create(db, base)
|
||||
|
||||
const inherited = {
|
||||
fork_session_id: event.data.parentID,
|
||||
fork_boundary: event.data.boundary,
|
||||
project_id: parent.project_id,
|
||||
workspace_id: parent.workspace_id,
|
||||
directory: parent.directory,
|
||||
path: parent.path,
|
||||
title: forkTitle(parent.title ?? undefined),
|
||||
agent: parent.agent,
|
||||
model: parent.model,
|
||||
metadata: parent.metadata,
|
||||
}
|
||||
const stored = yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: event.data.sessionID,
|
||||
timeline_id: timelineID,
|
||||
parent_id: null,
|
||||
fork_session_id: event.data.parentID,
|
||||
fork_boundary: event.data.boundary,
|
||||
project_id: parent.project_id,
|
||||
workspace_id: parent.workspace_id,
|
||||
...inherited,
|
||||
slug: Slug.create(),
|
||||
directory: parent.directory,
|
||||
path: parent.path,
|
||||
title: forkTitle(parent.title ?? undefined),
|
||||
agent: parent.agent,
|
||||
model: parent.model,
|
||||
metadata: parent.metadata,
|
||||
version: parent.version,
|
||||
cost: 0,
|
||||
tokens_input: 0,
|
||||
@@ -162,7 +173,12 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
time_created: event.created,
|
||||
time_updated: event.created,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
// Created records optional ownership; Forked supplies the source's history and defaults.
|
||||
.onConflictDoUpdate({
|
||||
target: SessionTable.id,
|
||||
set: { ...inherited, time_updated: event.created },
|
||||
setWhere: and(isNotNull(SessionTable.parent_id), isNull(SessionTable.fork_session_id)),
|
||||
})
|
||||
.returning({ sessionID: SessionTable.id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
@@ -171,19 +187,26 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
if (event.data.instructionEntries)
|
||||
yield* InstructionEntry.initialize(db, event.data.sessionID, event.data.instructionEntries, event.created)
|
||||
|
||||
// Active forks only copy the settled suffix after the first omitted message;
|
||||
// the preceding immutable prefix is shared.
|
||||
let cursor = (active?.seq ?? end) - 1
|
||||
while (cursor < (copiedSeq ?? -1)) {
|
||||
const rows = yield* Timeline.rows(db, ranges, {
|
||||
where: and(
|
||||
gt(SessionMessageTable.seq, cursor),
|
||||
lt(SessionMessageTable.seq, end),
|
||||
not(unsettled(SessionMessageTable)),
|
||||
),
|
||||
order: "asc",
|
||||
limit: ForkBatchSize,
|
||||
})
|
||||
let cursor = -1
|
||||
while (copiedSeq !== undefined) {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.parentID),
|
||||
gt(SessionMessageTable.seq, cursor),
|
||||
lt(SessionMessageTable.seq, copiedSeq + 1),
|
||||
// Terminal events for active projections stay on the parent, so forks copy only settled history.
|
||||
sql`${SessionMessageTable.type} != 'assistant' or json_extract(${SessionMessageTable.data}, '$.time.completed') is not null`,
|
||||
sql`${SessionMessageTable.type} != 'shell' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
|
||||
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.limit(ForkBatchSize)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
if (rows.length === 0) break
|
||||
|
||||
yield* db
|
||||
@@ -192,7 +215,6 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
rows.map((row) => ({
|
||||
id: SessionMessage.ID.make(`${SessionMessage.ID.fromEvent(event.id)}_${row.seq}`),
|
||||
session_id: event.data.sessionID,
|
||||
timeline_id: timelineID,
|
||||
type: row.type,
|
||||
seq: row.seq,
|
||||
time_created: row.time_created,
|
||||
@@ -287,10 +309,7 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.timeline_id, Timeline.current(event.data.sessionID)),
|
||||
eq(SessionMessageTable.type, "assistant"),
|
||||
),
|
||||
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "assistant")),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(1)
|
||||
@@ -348,7 +367,7 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.timeline_id, Timeline.current(event.data.sessionID)),
|
||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||
eq(SessionMessageTable.type, "compaction"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`,
|
||||
),
|
||||
@@ -379,7 +398,6 @@ function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, me
|
||||
.values({
|
||||
id: SessionMessage.ID.make(id),
|
||||
session_id: event.data.sessionID,
|
||||
timeline_id: Timeline.current(event.data.sessionID),
|
||||
type,
|
||||
seq: event.durable.seq,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
@@ -426,12 +444,10 @@ const layer = Layer.effectDiscard(
|
||||
const db = (yield* Database.Service).db
|
||||
yield* bus.project(SessionEvent.Created, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const timelineID = yield* Timeline.create(db)
|
||||
const stored = yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: event.data.sessionID,
|
||||
timeline_id: timelineID,
|
||||
project_id: event.data.projectID,
|
||||
workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null,
|
||||
parent_id: event.data.parentID,
|
||||
@@ -531,10 +547,7 @@ const layer = Layer.effectDiscard(
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Deleted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie)
|
||||
yield* Timeline.collect(db)
|
||||
}),
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.AgentSelected, (event) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -715,14 +728,20 @@ const layer = Layer.effectDiscard(
|
||||
)
|
||||
yield* bus.project(SessionEvent.RevertEvent.Committed, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const boundary = yield* Timeline.find(db, event.data.sessionID, event.data.to)
|
||||
const boundary = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.id, event.data.to)),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.to}`))
|
||||
const base = yield* Timeline.prefix(db, yield* Timeline.ranges(db, boundary.timeline_id), boundary.seq)
|
||||
const timelineID = yield* Timeline.create(db, base)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ timeline_id: timelineID, revert: null, time_updated: event.created })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.delete(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, event.data.sessionID), gte(SessionMessageTable.seq, boundary.seq)),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
@@ -735,6 +754,12 @@ const layer = Layer.effectDiscard(
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ revert: null, time_updated: event.created })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* InstructionState.reset(db, event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionRevert from "./revert.js"
|
||||
|
||||
import { and, eq, gt } from "drizzle-orm"
|
||||
import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -12,7 +12,6 @@ import { MessageNotFoundError } from "./error.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionMessageTable } from "./sql.js"
|
||||
import { Timeline } from "./timeline.js"
|
||||
|
||||
export { MessageNotFoundError }
|
||||
|
||||
@@ -85,12 +84,26 @@ export const commit = Effect.fn("SessionRevert.commit")(function* (bus: Bus.Inte
|
||||
})
|
||||
|
||||
const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface["db"], input: BoundaryInput) {
|
||||
const boundary = yield* Timeline.find(db, input.sessionID, input.messageID)
|
||||
const boundary = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!boundary) return yield* new MessageNotFoundError(input)
|
||||
const rows = yield* Timeline.rows(db, yield* Timeline.forSession(db, input.sessionID), {
|
||||
where: and(eq(SessionMessageTable.type, "assistant"), gt(SessionMessageTable.seq, boundary.seq)),
|
||||
order: "asc",
|
||||
})
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, input.sessionID),
|
||||
eq(SessionMessageTable.type, "assistant"),
|
||||
gt(SessionMessageTable.seq, boundary.seq),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const files = new Map<RelativePath, Snapshot.ID>()
|
||||
for (const row of rows) {
|
||||
|
||||
@@ -17,7 +17,6 @@ import { SessionMessage } from "../message.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { SessionStore } from "../store.js"
|
||||
import { SessionMessageTable } from "../sql.js"
|
||||
import { Timeline } from "../timeline.js"
|
||||
import { SessionTitle } from "../title.js"
|
||||
import { DrainResult, Service, type Interface } from "./index.js"
|
||||
import { Snapshot } from "../../snapshot.js"
|
||||
@@ -294,7 +293,7 @@ const layer = Layer.effect(
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.timeline_id, Timeline.current(sessionID)),
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, "compaction"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`,
|
||||
),
|
||||
|
||||
@@ -58,7 +58,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
return session
|
||||
})
|
||||
const message = Effect.fn("Session.message")(function* (sessionID: SessionSchema.ID, messageID: SessionMessage.ID) {
|
||||
const stored = yield* store.message(messageID, sessionID)
|
||||
const stored = yield* store.message(messageID)
|
||||
return stored?.sessionID === sessionID ? stored.message : undefined
|
||||
})
|
||||
const updateMessage = Effect.fn("Session.updateMessage")(function* (
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import {
|
||||
sqliteTable,
|
||||
text,
|
||||
integer,
|
||||
index,
|
||||
primaryKey,
|
||||
real,
|
||||
uniqueIndex,
|
||||
type AnySQLiteColumn,
|
||||
} from "drizzle-orm/sqlite-core"
|
||||
import { sql, type SQLWrapper } from "drizzle-orm"
|
||||
import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { directoryColumn, pathColumn } from "../database/path.js"
|
||||
import { ProjectTable } from "../project/sql.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
@@ -24,27 +15,14 @@ import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { CompactionPayload, MovePayload, SyntheticPayload, UserPayload } from "@opencode-ai/schema/session-inbox"
|
||||
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
|
||||
import type { Schema } from "effect"
|
||||
import type { Timeline } from "./timeline.js"
|
||||
|
||||
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
|
||||
type SessionMessageData = DistributiveOmit<(typeof SessionMessage.Info)["Encoded"], "type" | "id">
|
||||
|
||||
export const TimelineTable = sqliteTable("timeline", {
|
||||
id: text().$type<Timeline.ID>().primaryKey(),
|
||||
base_id: text()
|
||||
.$type<Timeline.ID>()
|
||||
.references((): AnySQLiteColumn => TimelineTable.id),
|
||||
base_seq: integer(),
|
||||
})
|
||||
|
||||
export const SessionTable = sqliteTable(
|
||||
"session_v2",
|
||||
{
|
||||
id: text().$type<SessionSchema.ID>().primaryKey(),
|
||||
timeline_id: text()
|
||||
.$type<Timeline.ID>()
|
||||
.notNull()
|
||||
.references(() => TimelineTable.id),
|
||||
project_id: text()
|
||||
.$type<Project.ID>()
|
||||
.notNull()
|
||||
@@ -102,34 +80,23 @@ export const SessionMessageTable = sqliteTable(
|
||||
"session_message",
|
||||
{
|
||||
id: text().$type<SessionMessage.ID>().primaryKey(),
|
||||
// Provenance survives deletion of the originating session while forks still reference its history.
|
||||
session_id: text().$type<SessionSchema.ID>().notNull(),
|
||||
timeline_id: text()
|
||||
.$type<Timeline.ID>()
|
||||
session_id: text()
|
||||
.$type<SessionSchema.ID>()
|
||||
.notNull()
|
||||
.references(() => TimelineTable.id, { onDelete: "cascade" }),
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
type: text().$type<SessionMessage.Type>().notNull(),
|
||||
seq: integer().notNull(),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
|
||||
},
|
||||
(table) => [
|
||||
// A restored Session ID may have retained messages in another timeline.
|
||||
index("session_message_session_seq_idx").on(table.session_id, table.seq),
|
||||
uniqueIndex("session_message_timeline_seq_idx").on(table.timeline_id, table.seq),
|
||||
index("session_message_timeline_type_seq_idx").on(table.timeline_id, table.type, table.seq),
|
||||
index("session_message_unsettled_idx").on(table.timeline_id, table.seq).where(unsettled(table)),
|
||||
uniqueIndex("session_message_session_seq_idx").on(table.session_id, table.seq),
|
||||
index("session_message_session_type_seq_idx").on(table.session_id, table.type, table.seq),
|
||||
index("session_message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id),
|
||||
index("session_message_time_created_idx").on(table.time_created),
|
||||
],
|
||||
)
|
||||
|
||||
export function unsettled(table: { type: SQLWrapper; data: SQLWrapper }) {
|
||||
return sql`((${table.type} = 'assistant' AND json_extract(${table.data}, '$.time.completed') IS NULL)
|
||||
OR (${table.type} IN ('shell', 'compaction') AND json_extract(${table.data}, '$.status') = 'running'))`
|
||||
}
|
||||
|
||||
export const SessionPendingTable = sqliteTable(
|
||||
"session_pending",
|
||||
{
|
||||
|
||||
@@ -13,7 +13,6 @@ import { SessionMessage } from "./message.js"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { fromRow } from "./info.js"
|
||||
import { Timeline } from "./timeline.js"
|
||||
|
||||
const ListInputBase = {
|
||||
workspaceID: Workspace.ID.pipe(Schema.optional),
|
||||
@@ -57,7 +56,6 @@ export interface Interface {
|
||||
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
sessionID?: Session.ID,
|
||||
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined>
|
||||
/**
|
||||
* Top-level Sessions holding an execution claim. Recoverable background
|
||||
@@ -142,36 +140,49 @@ const layer = Layer.effect(
|
||||
const direction = input.cursor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const anchor = input.cursor ? yield* Timeline.find(db, input.sessionID, input.cursor.id) : undefined
|
||||
const anchor = input.cursor
|
||||
? yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (input.cursor && !anchor) return []
|
||||
const boundary = anchor
|
||||
? order === "asc"
|
||||
? gt(SessionMessageTable.seq, anchor.seq)
|
||||
: lt(SessionMessageTable.seq, anchor.seq)
|
||||
: undefined
|
||||
const rows = yield* Timeline.rows(db, yield* Timeline.forSession(db, input.sessionID), {
|
||||
where: boundary,
|
||||
order,
|
||||
limit: input.limit,
|
||||
})
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(where)
|
||||
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return yield* Effect.forEach(
|
||||
direction === "previous" ? rows.toReversed() : rows,
|
||||
SessionHistory.decodeMessageRow,
|
||||
)
|
||||
}),
|
||||
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID)),
|
||||
message: Effect.fn("SessionStore.message")(function* (messageID, sessionID) {
|
||||
const row = sessionID
|
||||
? yield* Timeline.find(db, sessionID, messageID)
|
||||
: yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, messageID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
message: Effect.fn("SessionStore.message")(function* (messageID) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, messageID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row
|
||||
? {
|
||||
sessionID: sessionID ?? Session.ID.make(row.session_id),
|
||||
sessionID: Session.ID.make(row.session_id),
|
||||
message: yield* SessionHistory.decodeMessageRow(row).pipe(Effect.orDie),
|
||||
}
|
||||
: undefined
|
||||
|
||||
@@ -3,10 +3,15 @@ export * as SessionSystemPrompt from "./system-prompt.js"
|
||||
import PROMPT from "./runner/prompt/system.txt"
|
||||
|
||||
export function make(tools: string[]) {
|
||||
return render(PROMPT, tools)
|
||||
}
|
||||
|
||||
export function render(prompt: string, tools: string[]) {
|
||||
const instructions: string[] = []
|
||||
if (tools.includes("shell")) {
|
||||
instructions.push(
|
||||
"- Prefer dedicated tools over shell commands; fall back to the shell when a tool cannot do what you need.",
|
||||
"- Do not chain shell commands with separators like `echo \"====\";` or `printf '---'`; the output becomes noisy in a way that makes the user's side of the conversation worse.",
|
||||
)
|
||||
}
|
||||
if (tools.includes("write")) {
|
||||
@@ -19,5 +24,5 @@ export function make(tools: string[]) {
|
||||
"- Use the edit tool for targeted changes to existing text files. It replaces the exact text in `oldString` with `newString`, and the values must differ. By default, `oldString` must occur exactly once. If it occurs multiple times, include more surrounding context to make it unique or set `replaceAll` to true to replace every occurrence.",
|
||||
)
|
||||
}
|
||||
return PROMPT.replace("${OPENCODE_TOOL_GUIDANCE}", instructions.join("\n"))
|
||||
return prompt.replace("${OPENCODE_TOOL_GUIDANCE}", instructions.join("\n"))
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
export * as Timeline from "./timeline.js"
|
||||
|
||||
import { and, asc, desc, eq, lt, sql, type SQL } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { statics } from "@opencode-ai/schema/schema"
|
||||
import type { Database } from "../database/database.js"
|
||||
import { SessionMessageTable, SessionTable, TimelineTable } from "./sql.js"
|
||||
|
||||
export const ID = Schema.String.check(Schema.isStartsWith("tml_")).pipe(
|
||||
Schema.brand("Timeline.ID"),
|
||||
statics((schema) => ({ create: () => schema.make(`tml_${crypto.randomUUID()}`) })),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
type DB = Omit<Database.Interface["db"], "$client">
|
||||
|
||||
export type Position = { readonly id: ID; readonly seq: number }
|
||||
export type Range = { readonly id: ID; readonly end: number | null }
|
||||
|
||||
export const create = Effect.fn("Timeline.create")(function* (db: DB, base?: Position) {
|
||||
const id = ID.create()
|
||||
yield* db.insert(TimelineTable).values({ id, base_id: base?.id, base_seq: base?.seq }).run().pipe(Effect.orDie)
|
||||
return id
|
||||
})
|
||||
|
||||
export const current = (sessionID: Session.ID) => sql`(SELECT timeline_id FROM ${SessionTable} WHERE id = ${sessionID})`
|
||||
|
||||
/** Resolve ancestry once, then read each physical range using (timeline_id, seq). */
|
||||
export const ranges = Effect.fn("Timeline.ranges")(function* (db: DB, id: ID) {
|
||||
return yield* db
|
||||
.all<Range>(
|
||||
sql`
|
||||
WITH RECURSIVE lineage(id, base_id, base_seq, end, depth) AS (
|
||||
SELECT id, base_id, base_seq, NULL, 0 FROM timeline WHERE id = ${id}
|
||||
UNION ALL
|
||||
SELECT base.id, base.base_id, base.base_seq,
|
||||
CASE WHEN lineage.end IS NULL THEN lineage.base_seq
|
||||
ELSE min(lineage.end, lineage.base_seq) END,
|
||||
lineage.depth + 1
|
||||
FROM timeline AS base JOIN lineage ON base.id = lineage.base_id
|
||||
)
|
||||
SELECT id, end FROM lineage ORDER BY depth ASC
|
||||
`,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const forSession = Effect.fn("Timeline.forSession")(function* (db: DB, sessionID: Session.ID) {
|
||||
const session = yield* db
|
||||
.select({ id: SessionTable.timeline_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return session ? yield* ranges(db, session.id) : []
|
||||
})
|
||||
|
||||
export const includes = (ranges: readonly Range[], row: { timeline_id: ID; seq: number }) =>
|
||||
ranges.some((range) => range.id === row.timeline_id && (range.end === null || row.seq < range.end))
|
||||
|
||||
export const find = Effect.fn("Timeline.find")(function* (db: DB, sessionID: Session.ID, messageID: SessionMessage.ID) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, messageID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return undefined
|
||||
return includes(yield* forSession(db, sessionID), row) ? row : undefined
|
||||
})
|
||||
|
||||
export const rows = Effect.fn("Timeline.rows")(function* (
|
||||
db: DB,
|
||||
ranges: readonly Range[],
|
||||
input: { readonly where?: SQL; readonly order?: "asc" | "desc"; readonly limit?: number } = {},
|
||||
) {
|
||||
const result: (typeof SessionMessageTable.$inferSelect)[] = []
|
||||
for (const range of input.order === "asc" ? ranges.toReversed() : ranges) {
|
||||
if (input.limit !== undefined && result.length >= input.limit) break
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.timeline_id, range.id),
|
||||
range.end === null ? undefined : lt(SessionMessageTable.seq, range.end),
|
||||
input.where,
|
||||
),
|
||||
)
|
||||
.orderBy(input.order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||
result.push(
|
||||
...(yield* (input.limit === undefined ? query.all() : query.limit(input.limit - result.length).all()).pipe(
|
||||
Effect.orDie,
|
||||
)),
|
||||
)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
/** Root at the physical owner of the last retained message, skipping empty intermediate timelines. */
|
||||
export const prefix = Effect.fn("Timeline.prefix")(function* (db: DB, ranges: readonly Range[], end: number) {
|
||||
const [last] = yield* rows(db, ranges, { where: lt(SessionMessageTable.seq, end), limit: 1 })
|
||||
return last ? { id: last.timeline_id, seq: last.seq + 1 } : undefined
|
||||
})
|
||||
|
||||
/** Session deletion releases a head; references from surviving histories keep their storage alive. */
|
||||
export const collect = Effect.fn("Timeline.collect")(function* (db: DB) {
|
||||
yield* db
|
||||
.run(
|
||||
sql`
|
||||
WITH RECURSIVE retained(id) AS (
|
||||
SELECT timeline_id FROM session_v2
|
||||
UNION
|
||||
SELECT timeline.base_id FROM timeline JOIN retained ON timeline.id = retained.id
|
||||
WHERE timeline.base_id IS NOT NULL
|
||||
)
|
||||
DELETE FROM timeline WHERE id NOT IN (SELECT id FROM retained)
|
||||
`,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
@@ -3,7 +3,7 @@ export * as SessionTransfer from "./transfer.js"
|
||||
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { eq, inArray } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Clock, Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { map } from "effect/Array"
|
||||
import path from "path"
|
||||
@@ -21,7 +21,6 @@ import { SessionEvent } from "./event.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionProjector } from "./projector.js"
|
||||
import { SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { Timeline } from "./timeline.js"
|
||||
|
||||
export const Data = SessionTransfer.Data
|
||||
export type Data = SessionTransfer.Data
|
||||
@@ -75,31 +74,12 @@ const layer = Layer.effect(
|
||||
const project = yield* projects.resolve(input.location.directory)
|
||||
yield* upsertProject(db, project).pipe(Effect.orDie)
|
||||
const importedAt = yield* Clock.currentTimeMillis
|
||||
// Related exports may share message IDs. Imports materialize independent
|
||||
// snapshots, so give colliding rows fresh identities instead of stealing ownership.
|
||||
const ids = input.data.messages.map((message) => message.id)
|
||||
const batches = Array.from({ length: Math.ceil(ids.length / 500) }, (_, index) =>
|
||||
ids.slice(index * 500, (index + 1) * 500),
|
||||
)
|
||||
const existing = new Set(
|
||||
(yield* Effect.forEach(batches, (batch) =>
|
||||
db
|
||||
.select({ id: SessionMessageTable.id })
|
||||
.from(SessionMessageTable)
|
||||
.where(inArray(SessionMessageTable.id, batch))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
))
|
||||
.flat()
|
||||
.map((row) => row.id),
|
||||
)
|
||||
const messages = input.data.messages.filter(isSettled).map((message, index) => {
|
||||
const encoded = encodeMessage(message)
|
||||
const { id: _, type, ...data } = encoded
|
||||
return {
|
||||
id: existing.has(message.id) ? SessionMessage.ID.create() : message.id,
|
||||
id: message.id,
|
||||
session_id: sessionID,
|
||||
timeline_id: Timeline.current(sessionID),
|
||||
type,
|
||||
seq: index + 1,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { ConfigEntryObserver } from "../../config/plugin/entry-observer.js"
|
||||
import { Job } from "../../job.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { Session } from "../../session.js"
|
||||
@@ -38,6 +39,13 @@ export const Input = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
const ForkInput = Schema.Struct({
|
||||
...Input.fields,
|
||||
fork: Schema.optionalKey(Schema.Boolean).annotate({
|
||||
description: "Give the subagent your conversation history before this response.",
|
||||
}),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
sessionID: SessionSchema.ID,
|
||||
status: Schema.Literals(["completed", "running"]),
|
||||
@@ -61,17 +69,26 @@ export const Plugin = {
|
||||
const config = yield* Config.Service
|
||||
const permission = yield* Permission.Service
|
||||
const subagents = yield* SubagentJob.make
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.tool.reload())
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((editor) =>
|
||||
.transform((editor) => {
|
||||
const fork = Config.latest(loaded.entries, "experimental")?.subagent_fork === true
|
||||
editor.add({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description,
|
||||
input: Input,
|
||||
description: fork
|
||||
? description.replace(
|
||||
"New child sessions start with fresh context, so include all relevant context and instructions when you don't pass a sessionID.",
|
||||
"New child sessions start with fresh context by default, so include the context needed for the task.",
|
||||
)
|
||||
: description,
|
||||
input: fork ? ForkInput : Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
execute: (input: typeof ForkInput.Type, context) =>
|
||||
Effect.gen(function* () {
|
||||
if (fork && input.fork !== undefined && input.sessionID !== undefined)
|
||||
return yield* new ToolFailure({ message: "Cannot use fork with sessionID. Omit one of them." })
|
||||
const parent = yield* sessions
|
||||
.get(context.sessionID)
|
||||
.pipe(
|
||||
@@ -150,18 +167,40 @@ export const Plugin = {
|
||||
const model = agent.model ?? parent.model
|
||||
const child =
|
||||
existing ??
|
||||
(yield* sessions
|
||||
.create({
|
||||
parentID: context.sessionID,
|
||||
title: input.description,
|
||||
agent: Agent.ID.make(input.agent),
|
||||
model,
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
|
||||
),
|
||||
))
|
||||
(yield* (
|
||||
fork && input.fork
|
||||
? sessions.fork({
|
||||
sessionID: context.sessionID,
|
||||
parentID: context.sessionID,
|
||||
boundary: { type: "before", messageID: context.messageID },
|
||||
})
|
||||
: sessions.create({
|
||||
parentID: context.sessionID,
|
||||
title: input.description,
|
||||
agent: Agent.ID.make(input.agent),
|
||||
model,
|
||||
})
|
||||
).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message:
|
||||
fork && input.fork
|
||||
? `Failed to create subagent: ${error.message}`
|
||||
: `Parent session not found: ${context.sessionID}`,
|
||||
error,
|
||||
}),
|
||||
),
|
||||
))
|
||||
|
||||
if (fork && input.fork)
|
||||
yield* sessions.rename({ sessionID: child.id, title: input.description }).pipe(
|
||||
Effect.andThen(sessions.switchAgent({ sessionID: child.id, agent: agent.id })),
|
||||
Effect.andThen(model ? sessions.switchModel({ sessionID: child.id, model }) : Effect.void),
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Failed to configure subagent: ${child.id}`, error }),
|
||||
),
|
||||
)
|
||||
|
||||
const background = input.background === true
|
||||
yield* context.progress({ sessionID: child.id, status: "running" })
|
||||
@@ -173,7 +212,12 @@ export const Plugin = {
|
||||
sessionID: child.id,
|
||||
text:
|
||||
existing === undefined
|
||||
? ["You are a subagent spawned by another session.", input.prompt].join("\n")
|
||||
? [
|
||||
fork && input.fork
|
||||
? "You are a forked subagent. Use the inherited history as context and perform only the task below."
|
||||
: "You are a subagent spawned by another session.",
|
||||
input.prompt,
|
||||
].join("\n")
|
||||
: input.prompt,
|
||||
...(background && existing === undefined ? { resume: false } : {}),
|
||||
})
|
||||
@@ -230,8 +274,8 @@ export const Plugin = {
|
||||
metadata: { sessionID: output.sessionID, status: output.status },
|
||||
})),
|
||||
),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
|
||||
@@ -311,7 +311,6 @@ describe("AISDKNative", () => {
|
||||
region: "eu-west-1",
|
||||
},
|
||||
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/v1",
|
||||
profile: "ignored",
|
||||
credentialProvider: "ignored",
|
||||
fetch: "ignored",
|
||||
store: false,
|
||||
@@ -333,6 +332,19 @@ describe("AISDKNative", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("forwards Bedrock profile and auth mode for the default credential chain", () => {
|
||||
expect(
|
||||
map("@ai-sdk/amazon-bedrock", { profile: "work", auth: "sigv4", region: "eu-west-1" }, "anthropic.claude"),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/amazon-bedrock",
|
||||
settings: { profile: "work", auth: "sigv4", region: "eu-west-1" },
|
||||
})
|
||||
expect(map("@ai-sdk/amazon-bedrock", { auth: "bogus" }, "anthropic.claude")).toEqual({
|
||||
package: "@opencode-ai/ai/providers/amazon-bedrock",
|
||||
settings: {},
|
||||
})
|
||||
})
|
||||
|
||||
test("maps the legacy Bedrock endpoint override", () => {
|
||||
expect(
|
||||
map(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Exit, Fiber, Stream } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
@@ -38,7 +37,6 @@ const seed = Effect.fn(function* (ref: Location.Ref = a) {
|
||||
yield* database.db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(database.db),
|
||||
id,
|
||||
project_id: Project.ID.global,
|
||||
directory: ref.directory,
|
||||
|
||||
@@ -409,12 +409,14 @@ describe("ConfigNormalize", () => {
|
||||
experimental: {
|
||||
portable_shell_scanner: true,
|
||||
subagent_depth: 0,
|
||||
subagent_fork: true,
|
||||
policies: [{ action: "provider.use", resource: "custom", effect: "allow" }],
|
||||
},
|
||||
}).encoded.experimental,
|
||||
).toEqual({
|
||||
portable_shell_scanner: true,
|
||||
subagent_depth: 0,
|
||||
subagent_fork: true,
|
||||
policies: [
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "anthropic", effect: "allow" },
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n- Use clear file paths when referring to files.\\n- Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Response channels\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and skip explanation the user did not ask for. Include technical detail only where it helps.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
@@ -53,7 +52,6 @@ const setup = (sessionID: SessionSchema.ID) =>
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "instruction-state-test",
|
||||
|
||||
@@ -906,14 +906,16 @@ for (const entry of [
|
||||
)
|
||||
}
|
||||
|
||||
for (const query of ["", "?source=hello%20world&tag=a&tag=b"]) {
|
||||
testEffect(Layer.empty).live(`retries an MCP initialization 404 with the original URL: ${query || "no query"}`, () =>
|
||||
for (const { status, query } of [400, 404].flatMap((status) =>
|
||||
["", "?source=hello%20world&tag=a&tag=b"].map((query) => ({ status, query })),
|
||||
)) {
|
||||
testEffect(Layer.empty).live(`retries MCP initialization ${status} at the original URL: ${query || "no query"}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const headers: Array<string | null> = []
|
||||
const server = yield* resourceServer({
|
||||
respond: (request) => {
|
||||
headers.push(request.headers.get("x-mcp-test"))
|
||||
return new URL(request.url).searchParams.has("codemode") ? new Response(null, { status: 404 }) : undefined
|
||||
return new URL(request.url).searchParams.has("codemode") ? new Response(null, { status }) : undefined
|
||||
},
|
||||
})
|
||||
const config = new ConfigMCP.Remote({
|
||||
@@ -939,7 +941,7 @@ for (const query of ["", "?source=hello%20world&tag=a&tag=b"]) {
|
||||
|
||||
for (const entry of [
|
||||
{ name: "second 404", status: 404, query: "", codemode: undefined, attempts: 2 },
|
||||
{ name: "400", status: 400, query: "", codemode: undefined, attempts: 1 },
|
||||
{ name: "second 400", status: 400, query: "", codemode: undefined, attempts: 2 },
|
||||
{ name: "401", status: 401, query: "", codemode: undefined, attempts: 1 },
|
||||
{ name: "403", status: 403, query: "", codemode: undefined, attempts: 1 },
|
||||
{ name: "500", status: 500, query: "", codemode: undefined, attempts: 1 },
|
||||
@@ -947,6 +949,10 @@ for (const entry of [
|
||||
{ name: "user codemode=false", status: 404, query: "?codemode=false", codemode: undefined, attempts: 1 },
|
||||
{ name: "empty user codemode", status: 404, query: "?codemode=", codemode: undefined, attempts: 1 },
|
||||
{ name: "direct tools", status: 404, query: "", codemode: false, attempts: 1 },
|
||||
{ name: "400 with user codemode=true", status: 400, query: "?codemode=true", codemode: undefined, attempts: 1 },
|
||||
{ name: "400 with user codemode=false", status: 400, query: "?codemode=false", codemode: undefined, attempts: 1 },
|
||||
{ name: "400 with empty user codemode", status: 400, query: "?codemode=", codemode: undefined, attempts: 1 },
|
||||
{ name: "400 with direct tools", status: 400, query: "", codemode: false, attempts: 1 },
|
||||
]) {
|
||||
testEffect(Layer.empty).live(`does not retry MCP beyond the query fallback: ${entry.name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -970,23 +976,25 @@ for (const entry of [
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(Layer.empty).live("does not strip codemode for an MCP 404 after initialization", () =>
|
||||
Effect.gen(function* () {
|
||||
let expired = false
|
||||
const server = yield* resourceServer({
|
||||
respond: (request) => (expired && request.method === "POST" ? new Response(null, { status: 404 }) : undefined),
|
||||
})
|
||||
const config = new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false })
|
||||
const connection = yield* connect("resources", config, import.meta.dir)
|
||||
expired = true
|
||||
expect(yield* connection.tools().pipe(Effect.flip)).toBeInstanceOf(Error)
|
||||
for (const status of [400, 404]) {
|
||||
testEffect(Layer.empty).live(`does not strip codemode for an MCP ${status} after initialization`, () =>
|
||||
Effect.gen(function* () {
|
||||
let expired = false
|
||||
const server = yield* resourceServer({
|
||||
respond: (request) => (expired && request.method === "POST" ? new Response(null, { status }) : undefined),
|
||||
})
|
||||
const config = new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false })
|
||||
const connection = yield* connect("resources", config, import.meta.dir)
|
||||
expired = true
|
||||
expect(yield* connection.tools().pipe(Effect.flip)).toBeInstanceOf(Error)
|
||||
|
||||
// The SDK tries to recover the expired session, but must keep the same URL.
|
||||
expect(server.state.initializations).toBe(2)
|
||||
expect(new Set(server.state.urls)).toEqual(new Set([server.url + "?codemode=false"]))
|
||||
expect(server.state.toolLists).toBe(0)
|
||||
}),
|
||||
)
|
||||
// The SDK tries to recover an expired session on 404, but must keep the same URL.
|
||||
expect(server.state.initializations).toBe(status === 404 ? 2 : 1)
|
||||
expect(new Set(server.state.urls)).toEqual(new Set([server.url + "?codemode=false"]))
|
||||
expect(server.state.toolLists).toBe(0)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
test("lists, reads, and reports MCP resource changes", async () => {
|
||||
await Effect.runPromise(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
@@ -44,7 +43,6 @@ function setup(rules: Permission.Ruleset = [], sessionID = Session.ID.make("ses_
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginModule } from "@opencode-ai/core/plugin/module"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { fromPromise } from "@opencode-ai/plugin/promise/adapter"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -90,7 +91,8 @@ it.live("loads a local plugin with its configured options", () =>
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
yield* plugins.awaitActivation
|
||||
const definition = yield* PluginModule.load({
|
||||
const modules = yield* PluginModule.make().pipe(Effect.provide(Watcher.testLayer))
|
||||
const definition = yield* modules.load({
|
||||
type: "add",
|
||||
target: path.join(import.meta.dir, "plugin/fixtures/greeting.ts"),
|
||||
options: { description: "Configured greeting" },
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Schedule, Scope, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { PluginModule } from "@opencode-ai/core/plugin/module"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
AppNodeBuilder.build(Npm.node, [Global.node.replace(tempGlobalLayer)]),
|
||||
Watcher.layer().pipe(Layer.provide(Watcher.nativeLayer)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("watches creation of an external helper with missing parent directories", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const entry = path.join(directory.path, "plugin/index.ts")
|
||||
yield* Effect.promise(() => Bun.write(entry, 'export { default } from "../shared/new/nested/helper.ts"'))
|
||||
const modules = yield* PluginModule.make()
|
||||
const operation = { type: "add" as const, target: path.dirname(entry), options: {} }
|
||||
expect(Exit.isFailure(yield* modules.load(operation).pipe(Effect.exit))).toBe(true)
|
||||
const changed = yield* modules
|
||||
.changes()
|
||||
.pipe(Stream.runHead, Effect.timeout("5 seconds"), Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(directory.path, "shared/new/nested/helper.ts"),
|
||||
'export default { id: "appeared", async setup() {} }',
|
||||
),
|
||||
)
|
||||
yield* Fiber.join(changed)
|
||||
const loaded = yield* modules.load(operation)
|
||||
expect(loaded).toMatchObject({ id: "appeared" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("interrupts pending watcher setup when the loader scope closes during module evaluation", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const entry = path.join(directory.path, "index.ts")
|
||||
const entered = path.join(directory.path, "entered")
|
||||
const release = path.join(directory.path, "release")
|
||||
const started = yield* Deferred.make<void>()
|
||||
const stopped = yield* Deferred.make<void>()
|
||||
const gate = yield* Deferred.make<void>()
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => Bun.write(release, "release")).pipe(
|
||||
Effect.andThen(Deferred.succeed(gate, undefined)),
|
||||
Effect.andThen(Scope.close(scope, Exit.void)),
|
||||
),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
entry,
|
||||
`
|
||||
await Bun.write(${JSON.stringify(entered)}, "entered")
|
||||
while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(5)
|
||||
export default { id: "pending", async setup() {} }
|
||||
`,
|
||||
),
|
||||
)
|
||||
const modules = yield* PluginModule.make().pipe(
|
||||
Effect.provideService(Scope.Scope, scope),
|
||||
Effect.provideService(Watcher.Service, {
|
||||
subscribe: () =>
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(gate)),
|
||||
Effect.ensuring(Deferred.succeed(stopped, undefined)),
|
||||
Effect.as(Stream.never),
|
||||
),
|
||||
}),
|
||||
)
|
||||
yield* modules.load({ type: "add", target: directory.path, options: {} }).pipe(Effect.forkIn(scope))
|
||||
yield* Deferred.await(started)
|
||||
yield* Effect.promise(() => Bun.file(entered).exists()).pipe(
|
||||
Effect.repeat({ until: (exists) => exists, schedule: Schedule.spaced("5 millis") }),
|
||||
Effect.timeout("2 seconds"),
|
||||
)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* Deferred.isDone(stopped)).toBe(true)
|
||||
yield* Effect.promise(() => Bun.write(release, "release"))
|
||||
// Let the uncancellable native import finish; Bun's test runner detects unhandled rejections.
|
||||
yield* Effect.promise(() => Bun.sleep(50))
|
||||
}),
|
||||
)
|
||||
@@ -1,13 +1,11 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { fakeSelectorSdk } from "../fixture/selector"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -45,529 +43,183 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Ef
|
||||
)
|
||||
}
|
||||
|
||||
function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
|
||||
const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID)
|
||||
return (language as { config: { baseUrl: () => string } }).config.baseUrl()
|
||||
const noAmbientAWS = {
|
||||
AWS_PROFILE: undefined,
|
||||
AWS_ACCESS_KEY_ID: undefined,
|
||||
AWS_WEB_IDENTITY_TOKEN_FILE: undefined,
|
||||
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined,
|
||||
AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined,
|
||||
AWS_REGION: undefined,
|
||||
AWS_DEFAULT_REGION: undefined,
|
||||
}
|
||||
|
||||
function bedrockFetch(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
|
||||
const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID)
|
||||
return (
|
||||
language as { config: { fetch: (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response> } }
|
||||
).config.fetch
|
||||
}
|
||||
|
||||
function openAIUrl(language: unknown, path: string, modelId: string) {
|
||||
return (language as { config: { url: (input: { path: string; modelId: string }) => string } }).config.url({
|
||||
path,
|
||||
modelId,
|
||||
const seedBedrock = Effect.fn(function* (settings?: Record<string, unknown>) {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(Provider.ID.amazonBedrock, (item) => {
|
||||
item.package = Provider.aisdk("@ai-sdk/amazon-bedrock")
|
||||
if (settings) item.settings = settings
|
||||
})
|
||||
})
|
||||
}
|
||||
return catalog
|
||||
})
|
||||
|
||||
describe("AmazonBedrockPlugin", () => {
|
||||
it.effect("moves endpoint setting to baseURL", () =>
|
||||
withEnv(noAmbientAWS, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seedBedrock({ endpoint: "https://bedrock.example" })
|
||||
yield* addPlugin()
|
||||
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
|
||||
expect(result.package).toBe(Provider.aisdk("@ai-sdk/amazon-bedrock"))
|
||||
expect(result.settings).toEqual({ baseURL: "https://bedrock.example", region: "us-east-1" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("keeps an explicit baseURL over endpoint", () =>
|
||||
withEnv(noAmbientAWS, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seedBedrock({ baseURL: "https://base.example", endpoint: "https://endpoint.example" })
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.amazonBedrock)).settings).toEqual({
|
||||
baseURL: "https://base.example",
|
||||
region: "us-east-1",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("only treats the bearer token env var as a key credential", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(Provider.ID.amazonBedrock, (item) => {
|
||||
item.package = Provider.aisdk("@ai-sdk/amazon-bedrock")
|
||||
item.settings = { endpoint: "https://bedrock.example" }
|
||||
const integrations = yield* Integration.Service
|
||||
const integrationID = Integration.ID.make(Provider.ID.amazonBedrock)
|
||||
yield* integrations.transform((editor) => {
|
||||
editor.method.update({ integrationID, method: { type: "key" } })
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: {
|
||||
type: "env",
|
||||
names: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION", "AWS_BEARER_TOKEN_BEDROCK"],
|
||||
},
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
|
||||
expect(result.package).toBe(Provider.aisdk("@ai-sdk/amazon-bedrock"))
|
||||
expect(result.settings).toEqual({ baseURL: "https://bedrock.example" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prefers endpoint over baseURL for SDK base URL", () =>
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
bearerToken: "token",
|
||||
baseURL: "https://base.example",
|
||||
endpoint: "https://endpoint.example",
|
||||
region: "us-east-1",
|
||||
},
|
||||
})
|
||||
expect(bedrockBaseURL(result.sdk)).toBe("https://endpoint.example")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses baseURL as SDK base URL", () =>
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
bearerToken: "token",
|
||||
baseURL: "https://base.example",
|
||||
region: "us-east-1",
|
||||
},
|
||||
})
|
||||
expect(bedrockBaseURL(result.sdk)).toBe("https://base.example")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("creates SDK without explicit credential env so the default AWS chain can resolve credentials", () =>
|
||||
withEnv(
|
||||
{
|
||||
AWS_ACCESS_KEY_ID: undefined,
|
||||
AWS_BEARER_TOKEN_BEDROCK: undefined,
|
||||
AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined,
|
||||
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined,
|
||||
AWS_PROFILE: undefined,
|
||||
AWS_REGION: undefined,
|
||||
AWS_WEB_IDENTITY_TOKEN_FILE: undefined,
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: { name: "amazon-bedrock" },
|
||||
})
|
||||
expect(result.sdk).toBeDefined()
|
||||
expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses config region over AWS_REGION for SDK base URL", () =>
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: { name: "amazon-bedrock", region: "eu-west-1" },
|
||||
})
|
||||
expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses AWS_REGION for SDK base URL when config region is absent", () =>
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: { name: "amazon-bedrock" },
|
||||
})
|
||||
expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("defaults SDK region to us-east-1", () =>
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: { name: "amazon-bedrock" },
|
||||
})
|
||||
expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("loads bearer token option into env and uses bearer auth", () =>
|
||||
withEnv({ AWS_ACCESS_KEY_ID: undefined, AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const headers: Array<string | null> = []
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
bearerToken: "option-token",
|
||||
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
headers.push(new Headers(init?.headers).get("Authorization"))
|
||||
return new Response("{}")
|
||||
},
|
||||
},
|
||||
})
|
||||
yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" }))
|
||||
expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("option-token")
|
||||
expect(headers).toEqual(["Bearer option-token"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("prefers bearer token env over bearer token option", () =>
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "env-token" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const headers: Array<string | null> = []
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
bearerToken: "option-token",
|
||||
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
headers.push(new Headers(init?.headers).get("Authorization"))
|
||||
return new Response("{}")
|
||||
},
|
||||
},
|
||||
})
|
||||
yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" }))
|
||||
expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("env-token")
|
||||
expect(headers).toEqual(["Bearer env-token"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("creates Mantle SDK with GPT-5 OpenAI base path", () =>
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("openai.gpt-5.5")),
|
||||
modelID: Model.ID.make("openai.gpt-5.5"),
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock/mantle",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
bearerToken: "token",
|
||||
baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1",
|
||||
region: "us-east-2",
|
||||
},
|
||||
})
|
||||
const language = result.sdk.responses("openai.gpt-5.5")
|
||||
expect(openAIUrl(language, "/responses", "openai.gpt-5.5")).toBe(
|
||||
"https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("selects Mantle APIs without Bedrock cross-region prefixes", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("openai.gpt-5.5")),
|
||||
modelID: Model.ID.make("openai.gpt-5.5"),
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"),
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" },
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("openai.gpt-oss-safeguard-120b")),
|
||||
modelID: Model.ID.make("openai.gpt-oss-safeguard-120b"),
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"),
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { region: "us-east-1" },
|
||||
})
|
||||
expect(calls).toEqual(["responses:openai.gpt-5.5", "chat:openai.gpt-oss-safeguard-120b"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores other Bedrock provider subpaths", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock/anthropic"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock/anthropic",
|
||||
options: { name: "amazon-bedrock" },
|
||||
})
|
||||
expect(result.sdk).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses SigV4 credential env when bearer token is absent", () =>
|
||||
withEnv(
|
||||
{
|
||||
AWS_ACCESS_KEY_ID: "test-access-key",
|
||||
AWS_BEARER_TOKEN_BEDROCK: undefined,
|
||||
AWS_REGION: "us-east-1",
|
||||
AWS_SECRET_ACCESS_KEY: "test-secret-key",
|
||||
AWS_SESSION_TOKEN: "test-session-token",
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const headers: Array<string | null> = []
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
headers.push(new Headers(init?.headers).get("Authorization"))
|
||||
return new Response("{}")
|
||||
},
|
||||
},
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
bedrockFetch(result.sdk)("https://bedrock-runtime.us-east-1.amazonaws.com/model/test/invoke", {
|
||||
body: "{}",
|
||||
method: "POST",
|
||||
}),
|
||||
)
|
||||
expect(headers[0]?.startsWith("AWS4-HMAC-SHA256 ")).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies legacy cross-region inference prefixes", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "eu-west-1" },
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("global.anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("global.anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "eu-west-1" },
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "ap-northeast-1" },
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "ap-southeast-2" },
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
"languageModel:us.anthropic.claude-sonnet-4-5",
|
||||
"languageModel:eu.anthropic.claude-sonnet-4-5",
|
||||
"languageModel:global.anthropic.claude-sonnet-4-5",
|
||||
"languageModel:jp.anthropic.claude-sonnet-4-5",
|
||||
"languageModel:au.anthropic.claude-sonnet-4-5",
|
||||
expect((yield* integrations.get(integrationID))?.methods).toEqual([
|
||||
{ type: "key" },
|
||||
{ type: "env", names: ["AWS_BEARER_TOKEN_BEDROCK"] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses AWS_REGION for language prefixes when region option is absent", () =>
|
||||
withEnv({ AWS_REGION: "eu-west-1" }, () =>
|
||||
it.effect("leaves activation on auto without ambient AWS configuration", () =>
|
||||
withEnv(noAmbientAWS, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
const catalog = yield* seedBedrock()
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
})
|
||||
expect(calls).toEqual(["languageModel:eu.anthropic.claude-sonnet-4-5"])
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.amazonBedrock)).activation).toBe("auto")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies the full legacy cross-region prefix matrix", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
const cases = [
|
||||
{ region: "us-east-1", modelID: "amazon.nova-micro-v1:0", expected: "us.amazon.nova-micro-v1:0" },
|
||||
{ region: "us-east-1", modelID: "amazon.nova-lite-v1:0", expected: "us.amazon.nova-lite-v1:0" },
|
||||
{ region: "us-east-1", modelID: "amazon.nova-pro-v1:0", expected: "us.amazon.nova-pro-v1:0" },
|
||||
{ region: "us-east-1", modelID: "amazon.nova-premier-v1:0", expected: "us.amazon.nova-premier-v1:0" },
|
||||
{ region: "us-east-1", modelID: "amazon.nova-2-lite-v1:0", expected: "us.amazon.nova-2-lite-v1:0" },
|
||||
{ region: "us-east-1", modelID: "anthropic.claude-sonnet-4-5", expected: "us.anthropic.claude-sonnet-4-5" },
|
||||
{ region: "us-east-1", modelID: "deepseek.r1-v1:0", expected: "us.deepseek.r1-v1:0" },
|
||||
{ region: "us-gov-west-1", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" },
|
||||
{ region: "us-east-1", modelID: "cohere.command-r-plus-v1:0", expected: "cohere.command-r-plus-v1:0" },
|
||||
{ region: "eu-west-1", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" },
|
||||
{ region: "eu-west-2", modelID: "amazon.nova-lite-v1:0", expected: "eu.amazon.nova-lite-v1:0" },
|
||||
{ region: "eu-west-3", modelID: "amazon.nova-micro-v1:0", expected: "eu.amazon.nova-micro-v1:0" },
|
||||
{
|
||||
region: "eu-north-1",
|
||||
modelID: "meta.llama3-70b-instruct-v1:0",
|
||||
expected: "eu.meta.llama3-70b-instruct-v1:0",
|
||||
},
|
||||
{ region: "eu-central-1", modelID: "mistral.pixtral-large-v1:0", expected: "eu.mistral.pixtral-large-v1:0" },
|
||||
{ region: "eu-south-1", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" },
|
||||
{ region: "eu-south-2", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" },
|
||||
{ region: "eu-central-2", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" },
|
||||
{ region: "eu-west-1", modelID: "cohere.command-r-plus-v1:0", expected: "cohere.command-r-plus-v1:0" },
|
||||
{
|
||||
region: "ap-southeast-2",
|
||||
modelID: "anthropic.claude-sonnet-4-5",
|
||||
expected: "au.anthropic.claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
region: "ap-southeast-4",
|
||||
modelID: "anthropic.claude-haiku-v1:0",
|
||||
expected: "au.anthropic.claude-haiku-v1:0",
|
||||
},
|
||||
{ region: "ap-southeast-2", modelID: "anthropic.claude-opus-4", expected: "apac.anthropic.claude-opus-4" },
|
||||
{
|
||||
region: "ap-northeast-1",
|
||||
modelID: "anthropic.claude-sonnet-4-5",
|
||||
expected: "jp.anthropic.claude-sonnet-4-5",
|
||||
},
|
||||
{ region: "ap-northeast-1", modelID: "amazon.nova-pro-v1:0", expected: "jp.amazon.nova-pro-v1:0" },
|
||||
{ region: "ap-south-1", modelID: "anthropic.claude-sonnet-4-5", expected: "apac.anthropic.claude-sonnet-4-5" },
|
||||
{ region: "ap-south-1", modelID: "amazon.nova-lite-v1:0", expected: "apac.amazon.nova-lite-v1:0" },
|
||||
{ region: "ca-central-1", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" },
|
||||
{
|
||||
region: "us-east-1",
|
||||
modelID: "global.anthropic.claude-sonnet-4-5",
|
||||
expected: "global.anthropic.claude-sonnet-4-5",
|
||||
},
|
||||
{ region: "us-east-1", modelID: "us.anthropic.claude-sonnet-4-5", expected: "us.anthropic.claude-sonnet-4-5" },
|
||||
{ region: "eu-west-1", modelID: "eu.anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" },
|
||||
{
|
||||
region: "ap-northeast-1",
|
||||
modelID: "jp.anthropic.claude-sonnet-4-5",
|
||||
expected: "jp.anthropic.claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
region: "ap-south-1",
|
||||
modelID: "apac.anthropic.claude-sonnet-4-5",
|
||||
expected: "apac.anthropic.claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
region: "ap-southeast-2",
|
||||
modelID: "au.anthropic.claude-sonnet-4-5",
|
||||
expected: "au.anthropic.claude-sonnet-4-5",
|
||||
},
|
||||
]
|
||||
yield* addPlugin()
|
||||
for (const item of cases) {
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make(item.modelID)),
|
||||
modelID: Model.ID.make(item.modelID),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: item.region },
|
||||
})
|
||||
}
|
||||
expect(calls).toEqual(cases.map((item) => `languageModel:${item.expected}`))
|
||||
}),
|
||||
for (const name of Object.keys(noAmbientAWS).filter((name) => !name.includes("REGION"))) {
|
||||
it.effect(`enables the provider when ${name} is set`, () =>
|
||||
withEnv({ ...noAmbientAWS, [name]: "value" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seedBedrock()
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.amazonBedrock)).activation).toBe("enabled")
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("enables the provider when a profile is configured", () =>
|
||||
withEnv(noAmbientAWS, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seedBedrock({ profile: "work" })
|
||||
yield* addPlugin()
|
||||
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
|
||||
expect(result.activation).toBe("enabled")
|
||||
expect(result.settings).toEqual({ profile: "work", region: "us-east-1" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("ignores non-Bedrock providers for language selection", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.openai, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "eu-west-1" },
|
||||
})
|
||||
expect(calls).toEqual([])
|
||||
expect(result.language).toBeUndefined()
|
||||
}),
|
||||
it.effect("does not override a disabled provider", () =>
|
||||
withEnv({ ...noAmbientAWS, AWS_PROFILE: "work" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(Provider.ID.amazonBedrock, (item) => {
|
||||
item.package = Provider.aisdk("@ai-sdk/amazon-bedrock")
|
||||
item.activation = "disabled"
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.amazonBedrock)).activation).toBe("disabled")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("fills region from AWS_REGION then AWS_DEFAULT_REGION without overriding config", () =>
|
||||
withEnv({ ...noAmbientAWS, AWS_REGION: "eu-west-1", AWS_DEFAULT_REGION: "us-west-2" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seedBedrock()
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.amazonBedrock)).settings).toEqual({
|
||||
region: "eu-west-1",
|
||||
})
|
||||
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(Provider.ID.amazonBedrock, (item) => {
|
||||
item.settings = { region: "ap-southeast-2" }
|
||||
})
|
||||
})
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.amazonBedrock)).settings).toEqual({
|
||||
region: "ap-southeast-2",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("falls back to AWS_DEFAULT_REGION then us-east-1", () =>
|
||||
withEnv({ ...noAmbientAWS, AWS_DEFAULT_REGION: "us-west-2" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seedBedrock()
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.amazonBedrock)).settings).toEqual({
|
||||
region: "us-west-2",
|
||||
})
|
||||
const fallback = yield* Effect.gen(function* () {
|
||||
yield* catalog.reload()
|
||||
return required(yield* catalog.provider.get(Provider.ID.amazonBedrock)).settings
|
||||
}).pipe((fx) => withEnv({ AWS_DEFAULT_REGION: undefined }, () => fx))
|
||||
expect(fallback).toEqual({ region: "us-east-1" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies to Mantle and native Bedrock packages", () =>
|
||||
withEnv({ ...noAmbientAWS, AWS_PROFILE: "work" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(Provider.ID.make("mantle"), (item) => {
|
||||
item.package = Provider.aisdk("@ai-sdk/amazon-bedrock/mantle")
|
||||
})
|
||||
catalog.provider.update(Provider.ID.make("native"), (item) => {
|
||||
item.package = "@opencode-ai/ai/providers/amazon-bedrock"
|
||||
})
|
||||
catalog.provider.update(Provider.ID.make("other"), (item) => {
|
||||
item.package = Provider.aisdk("@ai-sdk/anthropic")
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.make("mantle"))).activation).toBe("enabled")
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.make("native"))).activation).toBe("enabled")
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.make("other"))).activation).toBe("auto")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "primary")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" })
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent", "X-Interaction-Id": event.sessionID })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -183,7 +183,11 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).child, "primary")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-subagent", "x-initiator": "agent" })
|
||||
expect(event.headers).toEqual({
|
||||
"X-Interaction-Type": "conversation-subagent",
|
||||
"X-Interaction-Id": event.sessionID,
|
||||
"x-initiator": "agent",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -191,7 +195,11 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "title")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-background", "x-initiator": "agent" })
|
||||
expect(event.headers).toEqual({
|
||||
"X-Interaction-Type": "conversation-background",
|
||||
"X-Interaction-Id": event.sessionID,
|
||||
"x-initiator": "agent",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -199,7 +207,11 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).child, "compaction", "build")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-compaction", "x-initiator": "agent" })
|
||||
expect(event.headers).toEqual({
|
||||
"X-Interaction-Type": "conversation-compaction",
|
||||
"X-Interaction-Id": event.sessionID,
|
||||
"x-initiator": "agent",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -207,7 +219,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "primary", "compaction")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" })
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent", "X-Interaction-Id": event.sessionID })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -252,7 +264,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "generate")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" })
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent", "X-Interaction-Id": event.sessionID })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Instance } from "@opencode-ai/core/instance"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { Rpc } from "@opencode-ai/core/rpc"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
@@ -110,6 +111,86 @@ const failed = (plugins: Plugin.Interface) =>
|
||||
)
|
||||
|
||||
describe("PluginSupervisor reload", () => {
|
||||
;(
|
||||
[
|
||||
{ name: "on a helper-only save", helper: "nested/helper.ts", touchEntry: false },
|
||||
{ name: "when the entrypoint also changes", helper: "nested/helper.ts", touchEntry: true },
|
||||
{ name: "outside the configured plugin directory", helper: "../shared/helper.ts", touchEntry: false },
|
||||
] as const
|
||||
).forEach((scenario) => {
|
||||
it.live(`reloads helper-defined RPC methods ${scenario.name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const root = path.join(directory.path, "external/greeter")
|
||||
const file = path.join(root, "index.ts")
|
||||
const helper = path.join(root, scenario.helper)
|
||||
const entry = `export { default } from ${JSON.stringify("./" + scenario.helper)}`
|
||||
const source = (version: number) => `import { Schema } from ${JSON.stringify(import.meta.resolve("effect"))}
|
||||
export default {
|
||||
id: "greeter",
|
||||
async setup(ctx) {
|
||||
await ctx.rpc.register({ id: "greeter", methods: {
|
||||
status: { input: Schema.Unknown, output: Schema.Number },
|
||||
${version > 1 ? "info: { input: Schema.Unknown, output: Schema.Number }," : ""}
|
||||
}, events: {} }, {
|
||||
status: async () => ${version},
|
||||
${version > 1 ? `info: async () => ${version},` : ""}
|
||||
})
|
||||
await ctx.command.transform(editor => editor.add({ name: "greet-v${version}", execute: async () => {} }))
|
||||
}
|
||||
}`
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(file, entry)
|
||||
await Bun.write(helper, source(1))
|
||||
await Bun.write(path.join(directory.path, ".opencode/opencode.json"), JSON.stringify({ plugins: [root] }))
|
||||
})
|
||||
const watcher = yield* Watcher.Test
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
const commands = yield* Command.Service
|
||||
yield* plugins.awaitActivation
|
||||
expect(yield* rpc.call("greeter", "status", {})).toBe(1)
|
||||
expect(yield* rpc.call("greeter", "info", {}).pipe(Effect.flip)).toMatchObject({
|
||||
type: "rpc.method_not_found",
|
||||
})
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(helper, source(2))
|
||||
if (scenario.touchEntry) {
|
||||
await Bun.write(file, entry + "; // updated entry")
|
||||
await fs.utimes(file, new Date(), new Date(Date.now() + 1000))
|
||||
}
|
||||
})
|
||||
yield* watcher.emit({ path: scenario.touchEntry ? file : helper, type: "update" })
|
||||
yield* rpc
|
||||
.call("greeter", "info", {})
|
||||
.pipe(Effect.retry({ times: 80, schedule: Schedule.spaced("25 millis") }))
|
||||
expect(yield* rpc.call("greeter", "info", {})).toBe(2)
|
||||
expect(yield* commands.get("greet-v1")).toBeUndefined()
|
||||
expect(yield* commands.get("greet-v2")).toBeDefined()
|
||||
|
||||
// Failed helper evaluations retain the active registration and recover on the next save.
|
||||
yield* Effect.promise(() => Bun.write(helper, 'throw new Error("broken helper"); export default {}'))
|
||||
yield* watcher.emit({ path: helper, type: "update" })
|
||||
yield* failed(plugins)
|
||||
expect(yield* rpc.call("greeter", "info", {})).toBe(2)
|
||||
yield* Effect.promise(() => Bun.write(helper, source(3)))
|
||||
yield* watcher.emit({ path: helper, type: "update" })
|
||||
yield* commands.get("greet-v3").pipe(
|
||||
Effect.flatMap((command) => (command ? Effect.void : Effect.fail("activation pending"))),
|
||||
Effect.retry({ times: 80, schedule: Schedule.spaced("25 millis") }),
|
||||
)
|
||||
expect(yield* rpc.call("greeter", "info", {})).toBe(3)
|
||||
expect(yield* commands.get("greet-v2")).toBeUndefined()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
;(["discovered", "configured"] as const).forEach((mode) => {
|
||||
it.effect(`retains a ${mode} plugin change during initial activation`, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -54,7 +54,6 @@ describe("SystemPromptPlugin", () => {
|
||||
test("uses granular IDs with a common prefix", () => {
|
||||
expect(SystemPromptPlugin.Plugins.map((plugin) => plugin.id)).toEqual([
|
||||
"opencode.prompt.openai",
|
||||
"opencode.prompt.anthropic",
|
||||
"opencode.prompt.kimi",
|
||||
"opencode.prompt.arcee",
|
||||
"opencode.prompt.meta",
|
||||
@@ -67,19 +66,23 @@ describe("SystemPromptPlugin", () => {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
yield* catalog.transform((editor) => {
|
||||
for (const id of ["gpt-5", "gpt-4.1", "gpt-5-codex"])
|
||||
for (const id of ["gpt-5", "gpt-4.1", "gpt-5-codex", "gpt-6-astra"])
|
||||
editor.model.update(Provider.ID.make("test"), Model.ID.make(id), () => {})
|
||||
editor.model.update(Provider.ID.make("test"), Model.ID.make("meta/muse-spark-1.1"), (model) => {
|
||||
model.name = "Muse Spark"
|
||||
})
|
||||
})
|
||||
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
|
||||
discard: true,
|
||||
})
|
||||
const cases = [
|
||||
["gpt-5", "# Response channels"],
|
||||
["gpt-4.1", "# Response channels"],
|
||||
["gpt-5", "# Delegation"],
|
||||
["gpt-4.1", "# Delegation"],
|
||||
["o3", fallback],
|
||||
["gpt-5-codex", "# Response channels"],
|
||||
["gpt-5-codex", "# Delegation"],
|
||||
["gpt-6-astra", "Do not settle for a partial"],
|
||||
["gemini-2.5-pro", fallback],
|
||||
["claude-sonnet-4", "# Professional objectivity"],
|
||||
["claude-sonnet-4", fallback],
|
||||
["kimi-k2", "# Prompt and Tool Use"],
|
||||
["trinity", "what command should I run to list files"],
|
||||
["meta/muse-spark-1.1", "powered by Muse Spark"],
|
||||
@@ -103,7 +106,7 @@ describe("SystemPromptPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("appends the OpenAI extension after the baseline", () =>
|
||||
it.effect("renders the OpenAI prompt and preserves project instructions", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
@@ -113,26 +116,42 @@ describe("SystemPromptPlugin", () => {
|
||||
)
|
||||
yield* SystemPromptPlugin.OpenAIPlugin.effect(pluginHost)
|
||||
const event = context("gpt-5")
|
||||
event.system.push(SystemPart.make("Project instructions"))
|
||||
event.tools.shell = { description: "Run a command", input: { type: "object" } }
|
||||
|
||||
yield* hooks.trigger("session", "context", event)
|
||||
|
||||
expect(event.system.map((part) => part.text)).toEqual([fallback, expect.stringContaining("# Delegation")])
|
||||
expect(event.system.map((part) => part.text)).toEqual([
|
||||
expect.stringContaining("# Delegation"),
|
||||
"Project instructions",
|
||||
])
|
||||
expect(event.system[0]?.text).toStartWith("You are an AI agent powered by OpenCode")
|
||||
expect(event.system[0]?.text).toContain("Prefer dedicated tools over shell commands")
|
||||
expect(event.system[0]?.text).not.toContain("${OPENCODE_TOOL_GUIDANCE}")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("selects the Meta prompt for Muse family model IDs", () =>
|
||||
it.effect("uses catalog names in Meta prompts for Muse model IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
const cases = [
|
||||
["meta/muse-spark-preview", "Muse Spark Preview"],
|
||||
["muse-spark-1.2", "Muse Spark 1.2"],
|
||||
["meta/muse-glimmer-30b", "Muse Glimmer 30B"],
|
||||
["muse-glimmer-30b", "Muse Glimmer"],
|
||||
] as const
|
||||
yield* catalog.transform((editor) => {
|
||||
for (const [id, name] of cases)
|
||||
editor.model.update(Provider.ID.make("test"), Model.ID.make(id), (model) => {
|
||||
model.name = name
|
||||
})
|
||||
})
|
||||
yield* SystemPromptPlugin.MetaPlugin.effect(pluginHost)
|
||||
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
["meta/muse-spark-preview", "Muse Spark"],
|
||||
["muse-spark-1.2", "Muse Spark"],
|
||||
["meta/muse-glimmer-30b", "Muse Glimmer"],
|
||||
["muse-glimmer-30b", "Muse Glimmer"],
|
||||
] as const,
|
||||
cases,
|
||||
([id, name]) => {
|
||||
const event = context(id)
|
||||
return hooks.trigger("session", "context", event).pipe(
|
||||
@@ -190,19 +209,19 @@ describe("SystemPromptPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
yield* SystemPromptPlugin.AnthropicPlugin.effect(pluginHost)
|
||||
yield* SystemPromptPlugin.KimiPlugin.effect(pluginHost)
|
||||
const gemini = context("gemini-2.5-pro")
|
||||
const claude = context("claude-sonnet-4")
|
||||
const kimi = context("kimi-k2")
|
||||
|
||||
yield* hooks.trigger("session", "context", gemini)
|
||||
yield* hooks.trigger("session", "context", claude)
|
||||
yield* hooks.trigger("session", "context", kimi)
|
||||
|
||||
expect(gemini.system[0]?.text).toBe(fallback)
|
||||
expect(claude.system[0]?.text).toContain("# Professional objectivity")
|
||||
expect(kimi.system[0]?.text).toContain("# Prompt and Tool Use")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("selects against the catalog model ID instead of its alias", () =>
|
||||
it.effect("selects against the catalog ID rather than the physical model ID or family", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
@@ -228,12 +247,9 @@ describe("SystemPromptPlugin", () => {
|
||||
yield* hooks.trigger("session", "context", physicalCustom)
|
||||
yield* hooks.trigger("session", "context", familyOpenAI)
|
||||
|
||||
expect(physicalOpenAI.system.map((part) => part.text)).toEqual([
|
||||
fallback,
|
||||
expect.stringContaining("# Delegation"),
|
||||
])
|
||||
expect(physicalCustom.system.map((part) => part.text)).toEqual([fallback])
|
||||
expect(familyOpenAI.system.map((part) => part.text)).toEqual([fallback, expect.stringContaining("# Delegation")])
|
||||
expect(physicalOpenAI.system.map((part) => part.text)).toEqual([fallback])
|
||||
expect(physicalCustom.system.map((part) => part.text)).toEqual([expect.stringContaining("# Delegation")])
|
||||
expect(familyOpenAI.system.map((part) => part.text)).toEqual([fallback])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLMClient, LLMEvent, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
@@ -285,7 +284,6 @@ const insertSession = (id: Session.ID, overrides?: Partial<typeof SessionTable.$
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id,
|
||||
project_id: Project.ID.global,
|
||||
slug: id,
|
||||
|
||||
@@ -500,7 +500,7 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forks a session through shared projected history", () =>
|
||||
it.effect("forks a session by replaying a durable fork event into copied projected rows", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
@@ -523,7 +523,7 @@ describe("Session.create", () => {
|
||||
expect(forked).toMatchObject({ title: "Parent (fork #1)", fork: { sessionID: parent.id } })
|
||||
expect(forked.parentID).toBeUndefined()
|
||||
expect(forkContext).toMatchObject([Expected.user("First"), { type: "synthetic", text: "parent note" }])
|
||||
expect(forkContext.map((message) => message.id)).toEqual(parentContext.map((message) => message.id))
|
||||
expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
|
||||
expect(history).toHaveLength(1)
|
||||
expect(history[0]).toMatchObject({
|
||||
type: "session.forked",
|
||||
@@ -533,10 +533,8 @@ describe("Session.create", () => {
|
||||
expect(yield* SessionInbox.find(db, forkContext[0].id)).toBeUndefined()
|
||||
expect(yield* SessionInbox.find(db, forkContext[1].id)).toBeUndefined()
|
||||
expect(
|
||||
yield* Effect.flip(
|
||||
session.prompt({ id: forkContext[0].id, sessionID: forked.id, text: "First", resume: false }),
|
||||
),
|
||||
).toMatchObject({ _tag: "Session.PromptConflictError", messageID: forkContext[0].id })
|
||||
yield* session.prompt({ id: forkContext[0].id, sessionID: forked.id, text: "First", resume: false }),
|
||||
).toMatchObject({ id: forkContext[0].id, type: "user", payload: { text: "First" } })
|
||||
|
||||
yield* session.prompt({
|
||||
sessionID: parent.id,
|
||||
@@ -580,40 +578,73 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays a fork with stable projected identities", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const parent = yield* session.create({ location, title: "Parent" })
|
||||
yield* session.prompt({ sessionID: parent.id, text: "First", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, parent.id, "steer")
|
||||
yield* session.synthetic({ sessionID: parent.id, text: "Second", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, parent.id, "steer")
|
||||
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
|
||||
const original = (yield* session.context(forked.id)).map((message) => message.id)
|
||||
const recorded = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, forked.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!recorded) return yield* Effect.die(new Error("Fork event not found"))
|
||||
for (const ownership of ["none", "source", "other"] as const) {
|
||||
it.effect(`replays a fork with ${ownership} ownership and stable projected identities`, () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const source = yield* session.create({
|
||||
location,
|
||||
title: "Source",
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ id: Model.ID.make("source"), providerID: Provider.ID.make("test") }),
|
||||
metadata: { source: true },
|
||||
})
|
||||
const parentID =
|
||||
ownership === "none"
|
||||
? undefined
|
||||
: ownership === "source"
|
||||
? source.id
|
||||
: (yield* session.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/owner") }),
|
||||
title: "Owner",
|
||||
metadata: { owner: true },
|
||||
})).id
|
||||
yield* session.prompt({ sessionID: source.id, text: "First", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, source.id, "steer")
|
||||
yield* session.synthetic({ sessionID: source.id, text: "Second", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, source.id, "steer")
|
||||
const forked = yield* session.fork({ sessionID: source.id, boundary: { type: "through" }, parentID })
|
||||
expect(forked.parentID).toBe(parentID)
|
||||
expect(forked).toMatchObject({
|
||||
title: "Source (fork #1)",
|
||||
agent: source.agent,
|
||||
model: source.model,
|
||||
metadata: source.metadata,
|
||||
location: source.location,
|
||||
fork: { sessionID: source.id },
|
||||
})
|
||||
const original = (yield* session.context(forked.id)).map((message) => message.id)
|
||||
const recorded = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, forked.id))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(recorded.map((event) => event.type)).toEqual(
|
||||
parentID ? ["session.created.1", "session.forked.2"] : ["session.forked.2"],
|
||||
)
|
||||
|
||||
yield* bus.remove(forked.id)
|
||||
yield* db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run().pipe(Effect.orDie)
|
||||
yield* bus.replay({
|
||||
id: recorded.id,
|
||||
created: recorded.created,
|
||||
aggregateID: recorded.aggregate_id,
|
||||
seq: recorded.seq,
|
||||
type: recorded.type,
|
||||
data: recorded.data,
|
||||
})
|
||||
yield* bus.remove(forked.id)
|
||||
yield* db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run().pipe(Effect.orDie)
|
||||
yield* Effect.forEach(recorded, (event) =>
|
||||
bus.replay({
|
||||
id: event.id,
|
||||
created: event.created,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
}),
|
||||
)
|
||||
|
||||
expect((yield* session.context(forked.id)).map((message) => message.id)).toEqual(original)
|
||||
}),
|
||||
)
|
||||
expect((yield* session.context(forked.id)).map((message) => message.id)).toEqual(original)
|
||||
expect(yield* session.get(forked.id)).toEqual(forked)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("inherits instruction entries when forking", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -821,7 +852,7 @@ describe("Session.create", () => {
|
||||
boundary: { type: "before", messageID: second.id },
|
||||
})
|
||||
expect(context).toMatchObject([{ text: "First" }])
|
||||
expect(context[0]?.id).toBe(first.id)
|
||||
expect(context[0]?.id).not.toBe(first.id)
|
||||
expect(history[0]).toMatchObject({
|
||||
data: { boundary: { type: "before", messageID: second.id } },
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { AIError, TransportError } from "@opencode-ai/ai"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -1283,13 +1282,11 @@ function seedSessions(
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const timelines = yield* Effect.forEach(sessionIDs, () => Timeline.create(database.db))
|
||||
yield* database.db
|
||||
.insert(SessionTable)
|
||||
.values(
|
||||
sessionIDs.map((id, index) => ({
|
||||
sessionIDs.map((id) => ({
|
||||
id,
|
||||
timeline_id: timelines[index],
|
||||
project_id: Project.ID.global,
|
||||
slug: id,
|
||||
directory: "/project",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { expect } from "bun:test"
|
||||
import { LLMClient, LLMEvent, LLMResponse, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
@@ -210,7 +209,6 @@ const setup = Effect.gen(function* () {
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: (yield* projects.resolve(AbsolutePath.make("/project"))).id,
|
||||
slug: "generate-test",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Schema, Stream } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -107,7 +106,6 @@ describe("Session.log", () => {
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "empty-log",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Fiber, Option, Schema, Stream } from "effect"
|
||||
import { asc, eq, sql } from "drizzle-orm"
|
||||
@@ -60,15 +59,7 @@ const assistantRow = (
|
||||
} = encodeMessage(
|
||||
SessionMessage.Assistant.make({ id, type: "assistant", agent: build, model, content: [], time, ...usage }),
|
||||
)
|
||||
return {
|
||||
id,
|
||||
session_id: sessionID,
|
||||
timeline_id: Timeline.current(sessionID),
|
||||
type,
|
||||
seq,
|
||||
time_created: DateTime.toEpochMillis(time.created),
|
||||
data,
|
||||
}
|
||||
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
|
||||
}
|
||||
|
||||
const seedSession = (overrides?: Partial<typeof SessionTable.$inferInsert>) =>
|
||||
@@ -81,7 +72,6 @@ const seedSession = (overrides?: Partial<typeof SessionTable.$inferInsert>) =>
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
@@ -200,9 +190,9 @@ describe("SessionProjector", () => {
|
||||
sessionID,
|
||||
to: boundary,
|
||||
})
|
||||
expect((yield* Timeline.rows(db, yield* Timeline.forSession(db, sessionID))).map((row) => row.id)).toEqual([
|
||||
earlier,
|
||||
])
|
||||
expect(
|
||||
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
|
||||
).toEqual([earlier])
|
||||
expect(yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()).toMatchObject({
|
||||
cost: Money.USD.make(1.25),
|
||||
tokens_input: 10,
|
||||
@@ -281,7 +271,6 @@ describe("SessionProjector", () => {
|
||||
.values({
|
||||
id: messageID,
|
||||
session_id: sessionID,
|
||||
timeline_id: Timeline.current(sessionID),
|
||||
type: "user",
|
||||
seq: 0,
|
||||
data: { text: "valid before corruption", time: { created: 0 } },
|
||||
@@ -487,15 +476,7 @@ describe("SessionProjector", () => {
|
||||
const { id: _, type, ...data } = encodeMessage({ id, type: "synthetic", text: "existing", time: { created } })
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values({
|
||||
id,
|
||||
session_id: sessionID,
|
||||
timeline_id: Timeline.current(sessionID),
|
||||
type,
|
||||
seq: 0,
|
||||
time_created: 0,
|
||||
data,
|
||||
})
|
||||
.values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data })
|
||||
.run()
|
||||
|
||||
const exit = yield* bus
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { DateTime, Effect, Fiber, Layer, LayerMap, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
@@ -114,7 +113,6 @@ const setup = Effect.gen(function* () {
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
@@ -167,7 +165,7 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => {
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
}),
|
||||
)
|
||||
return { id, session_id: sessionID, timeline_id: Timeline.current(sessionID), type, seq, time_created: 0, data }
|
||||
return { id, session_id: sessionID, type, seq, time_created: 0, data }
|
||||
}
|
||||
|
||||
describe("Session.prompt", () => {
|
||||
@@ -279,7 +277,9 @@ describe("Session.prompt", () => {
|
||||
|
||||
expect((yield* session.get(sessionID)).revert).toBeUndefined()
|
||||
expect(
|
||||
(yield* Timeline.rows(db, yield* Timeline.forSession(db, sessionID))).map((row) => row.id),
|
||||
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all().pipe(Effect.orDie)).map(
|
||||
(row) => row.id,
|
||||
),
|
||||
).not.toContainAnyValues([boundary.id, stale])
|
||||
expect(yield* SessionInbox.find(db, boundary.id)).toBeUndefined()
|
||||
}),
|
||||
@@ -813,7 +813,6 @@ describe("Session.prompt", () => {
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: other,
|
||||
project_id: Project.ID.global,
|
||||
slug: "other",
|
||||
@@ -850,15 +849,7 @@ describe("Session.prompt", () => {
|
||||
})
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values({
|
||||
id: messageID,
|
||||
session_id: sessionID,
|
||||
timeline_id: Timeline.current(sessionID),
|
||||
type,
|
||||
seq: 0,
|
||||
time_created: 0,
|
||||
data,
|
||||
})
|
||||
.values({ id: messageID, session_id: sessionID, type, seq: 0, time_created: 0, data })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
|
||||
import { Auth, LLMClient, type LLMClientService, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
@@ -205,7 +204,6 @@ describe("SessionRunnerLLM recorded", () => {
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Schema } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
@@ -149,14 +148,7 @@ testEffect(
|
||||
.run()
|
||||
yield* database.db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(database.db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "publish",
|
||||
directory: "/project",
|
||||
version: "test",
|
||||
})
|
||||
.values({ id: sessionID, project_id: Project.ID.global, slug: "publish", directory: "/project", version: "test" })
|
||||
.run()
|
||||
const publisher = createLLMEventPublisher(
|
||||
{
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
AIError,
|
||||
@@ -496,7 +495,6 @@ const insertSession = (id: Session.ID) =>
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id,
|
||||
project_id: Project.ID.global,
|
||||
slug: id,
|
||||
@@ -1467,7 +1465,6 @@ describe("SessionRunnerLLM", () => {
|
||||
.all()
|
||||
yield* s.bus.remove(forked.id)
|
||||
yield* s.db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run()
|
||||
yield* Timeline.collect(s.db)
|
||||
yield* Effect.forEach(
|
||||
recorded.map((event) => ({
|
||||
id: event.id,
|
||||
@@ -1593,7 +1590,6 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
||||
defaultSystem,
|
||||
expect.stringContaining("# Delegation"),
|
||||
"Initial context",
|
||||
])
|
||||
@@ -1614,7 +1610,6 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
||||
defaultSystem,
|
||||
expect.stringContaining("# Delegation"),
|
||||
"Initial context",
|
||||
])
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
@@ -45,17 +44,9 @@ describe("SessionStats", () => {
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values([
|
||||
{
|
||||
id: sessionID,
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
project_id: projectID,
|
||||
slug: "root",
|
||||
directory: "/stats",
|
||||
version: "test",
|
||||
},
|
||||
{ id: sessionID, project_id: projectID, slug: "root", directory: "/stats", version: "test" },
|
||||
{
|
||||
id: childID,
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
project_id: projectID,
|
||||
parent_id: sessionID,
|
||||
slug: "child",
|
||||
@@ -64,7 +55,6 @@ describe("SessionStats", () => {
|
||||
},
|
||||
{
|
||||
id: forkID,
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
project_id: projectID,
|
||||
fork_session_id: sessionID,
|
||||
slug: "fork",
|
||||
@@ -72,22 +62,8 @@ describe("SessionStats", () => {
|
||||
version: "test",
|
||||
time_created: Date.UTC(2026, 0, 4),
|
||||
},
|
||||
{
|
||||
id: usageOnlyID,
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
project_id: projectID,
|
||||
slug: "usage",
|
||||
directory: "/stats",
|
||||
version: "test",
|
||||
},
|
||||
{
|
||||
id: otherSessionID,
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
project_id: otherProjectID,
|
||||
slug: "other",
|
||||
directory: "/other",
|
||||
version: "test",
|
||||
},
|
||||
{ id: usageOnlyID, project_id: projectID, slug: "usage", directory: "/stats", version: "test" },
|
||||
{ id: otherSessionID, project_id: otherProjectID, slug: "other", directory: "/other", version: "test" },
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
@@ -312,16 +288,12 @@ function assistant(
|
||||
})
|
||||
}
|
||||
|
||||
function messageRow(sessionID: Session.ID, seq: number, message: SessionMessage.Info) {
|
||||
function messageRow(
|
||||
sessionID: Session.ID,
|
||||
seq: number,
|
||||
message: SessionMessage.Info,
|
||||
): typeof SessionMessageTable.$inferInsert {
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
return {
|
||||
id: SessionMessage.ID.make(id),
|
||||
session_id: sessionID,
|
||||
timeline_id: Timeline.current(sessionID),
|
||||
type,
|
||||
seq,
|
||||
time_created: encoded.time.created,
|
||||
data,
|
||||
}
|
||||
return { id: SessionMessage.ID.make(id), session_id: sessionID, type, seq, time_created: encoded.time.created, data }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { expect } from "bun:test"
|
||||
import { LanguageModel, LLM, LLMEvent } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
|
||||
@@ -67,14 +66,7 @@ for (const fixture of [
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "step",
|
||||
directory: "/project",
|
||||
version: "test",
|
||||
})
|
||||
.values({ id: sessionID, project_id: Project.ID.global, slug: "step", directory: "/project", version: "test" })
|
||||
.run()
|
||||
const model = SessionRunnerModel.resolved(
|
||||
LanguageModel.make({ id: "test-model", provider: "test", route: OpenAIChat.route }),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { beforeEach, expect } from "bun:test"
|
||||
import { AIError, LLMClient, LLMEvent, LanguageModel, TransportError, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
@@ -140,7 +139,6 @@ const insertSession = (id: Session.ID, title?: string, created?: number, model?:
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id,
|
||||
project_id: Project.ID.global,
|
||||
slug: id,
|
||||
@@ -341,7 +339,6 @@ it.effect("generates a title for an explicitly requested child session", () =>
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
parent_id: Session.ID.make("ses_title_parent"),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
@@ -44,7 +43,6 @@ describe("Session tool progress", () => {
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "progress",
|
||||
|
||||
@@ -30,11 +30,12 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -177,6 +178,291 @@ const withSubagent = (location: Location.Ref) =>
|
||||
})
|
||||
|
||||
describe("SubagentTool", () => {
|
||||
for (const enabled of [undefined, false, true]) {
|
||||
productionIt.live(`gates the fork parameter with experimental.subagent_fork=${enabled}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ experimental: { subagent_fork: enabled } })),
|
||||
)
|
||||
const sessions = yield* Session.Service
|
||||
const parent = yield* sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(dir.path) }),
|
||||
})
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const snapshot = yield* registry.snapshot()
|
||||
const definition = snapshot.definitions.find((tool) => tool.name === SubagentTool.name)!
|
||||
const context = yield* hooks.trigger("session", "context", {
|
||||
sessionID: parent.id,
|
||||
agent: toolIdentity.agent,
|
||||
model: parentModel,
|
||||
system: [],
|
||||
messages: [],
|
||||
tools: { subagent: { description: definition.description, input: { ...definition.inputSchema } } },
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
})
|
||||
expect(Object.keys(context.tools.subagent.input.properties ?? {})).toContain("sessionID")
|
||||
expect(Object.keys(context.tools.subagent.input.properties ?? {}).includes("fork")).toBe(enabled === true)
|
||||
expect(context.tools.subagent.input).toEqual(definition.inputSchema)
|
||||
expect(Object.keys(definition.inputSchema.properties ?? {}).includes("fork")).toBe(enabled === true)
|
||||
if (enabled === true) return
|
||||
expect(Object.keys(definition.inputSchema.properties ?? {})).toEqual([
|
||||
"agent",
|
||||
"description",
|
||||
"prompt",
|
||||
"sessionID",
|
||||
"background",
|
||||
])
|
||||
expect(definition.description).toBe(SubagentTool.description)
|
||||
expect(definition.description).toContain(
|
||||
"New child sessions start with fresh context, so include all relevant context and instructions when you don't pass a sessionID.",
|
||||
)
|
||||
expect(JSON.stringify(context.tools.subagent)).not.toMatch(/fork/i)
|
||||
// An unknown field keeps the original schema's behavior; it cannot enable forking or advertise it.
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-disabled-fork",
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "review", prompt: "review", fork: true },
|
||||
},
|
||||
})
|
||||
expect(result.status).toBe("completed")
|
||||
const childID = outputSessionID(result.metadata)
|
||||
expect((yield* sessions.get(childID)).fork).toBeUndefined()
|
||||
expect((yield* sessions.inbox(childID)).find((message) => message.type === "user")?.payload.text).toBe(
|
||||
"You are a subagent spawned by another session.\nreview",
|
||||
)
|
||||
const continued = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-disabled-fork-continuation",
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "review", prompt: "continue", fork: true, sessionID: childID },
|
||||
},
|
||||
})
|
||||
expect(continued.status).toBe("completed")
|
||||
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("rejects fork together with sessionID without changing the child", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ experimental: { subagent_fork: true } })),
|
||||
)
|
||||
const sessions = yield* Session.Service
|
||||
const parent = yield* sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(dir.path) }),
|
||||
})
|
||||
const child = yield* sessions.create({ parentID: parent.id })
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
for (const fork of [false, true]) {
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: `call-conflicting-fork-${fork}`,
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "review", prompt: "review", fork, sessionID: child.id },
|
||||
},
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
status: "error",
|
||||
error: { message: "Cannot use fork with sessionID. Omit one of them." },
|
||||
})
|
||||
}
|
||||
expect(yield* sessions.get(child.id)).toEqual(child)
|
||||
expect(yield* sessions.inbox(child.id)).toEqual([])
|
||||
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
completionIt.live("sends inherited history to a forked child and preserves it on continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ experimental: { subagent_fork: true } })),
|
||||
)
|
||||
const sessions = yield* Session.Service
|
||||
const parent = yield* sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(dir.path) }),
|
||||
agent: toolIdentity.agent,
|
||||
model: parentModel,
|
||||
metadata: { source: "fork-test" },
|
||||
})
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const requests: PluginHooks.Domains["session"]["context"][] = []
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(event)
|
||||
}),
|
||||
)
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* sessions.prompt({ sessionID: parent.id, text: "Remember the project context", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, parent.id, "steer")
|
||||
const previous = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: parent.id,
|
||||
assistantMessageID: previous,
|
||||
agent: toolIdentity.agent,
|
||||
model: parentModel,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: parent.id,
|
||||
assistantMessageID: previous,
|
||||
finish: "tool-calls",
|
||||
cost: Money.USD.zero,
|
||||
tokens,
|
||||
})
|
||||
yield* sessions.updateMessage({
|
||||
sessionID: parent.id,
|
||||
messageID: previous,
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-parent-read",
|
||||
name: "read",
|
||||
time: { created: parent.time.created },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "README.md" },
|
||||
content: [{ type: "text", text: "Inherited file contents" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const spawning = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: parent.id,
|
||||
assistantMessageID: spawning,
|
||||
agent: toolIdentity.agent,
|
||||
model: parentModel,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Text.Started, { sessionID: parent.id, assistantMessageID: spawning, ordinal: 0 })
|
||||
yield* bus.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: parent.id,
|
||||
assistantMessageID: spawning,
|
||||
ordinal: 0,
|
||||
text: "Spawning response must not be inherited",
|
||||
})
|
||||
yield* sessions.prompt({ sessionID: parent.id, text: "Later parent message", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, parent.id, "steer")
|
||||
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
messageID: spawning,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-fork",
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "fork review", prompt: "Review the file", fork: true },
|
||||
},
|
||||
})
|
||||
expect(result.status).toBe("completed")
|
||||
const child = yield* sessions.get(outputSessionID(result.metadata))
|
||||
expect(child).toMatchObject({
|
||||
parentID: parent.id,
|
||||
title: "fork review",
|
||||
agent: "reviewer",
|
||||
model: childModel,
|
||||
metadata: parent.metadata,
|
||||
fork: { sessionID: parent.id, boundary: { type: "before", messageID: spawning } },
|
||||
})
|
||||
const request = requests.find((request) => request.sessionID === child.id)!
|
||||
expect(request.agent).toBe(Agent.ID.make("reviewer"))
|
||||
expect(request.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "user", content: [{ type: "text", text: "Remember the project context" }] }),
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: [expect.objectContaining({ type: "tool-call", id: "call-parent-read" })],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: "tool",
|
||||
content: [
|
||||
expect.objectContaining({
|
||||
type: "tool-result",
|
||||
result: { type: "text", value: "Inherited file contents" },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "You are a forked subagent. Use the inherited history as context and perform only the task below.\nReview the file",
|
||||
},
|
||||
],
|
||||
}),
|
||||
]),
|
||||
)
|
||||
expect(JSON.stringify(request.messages)).not.toContain("Spawning response must not be inherited")
|
||||
expect(JSON.stringify(request.messages)).not.toContain("Later parent message")
|
||||
expect((yield* sessions.context(child.id)).map((message) => message.id)).not.toContain(previous)
|
||||
|
||||
const continued = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-fork-continue",
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "follow up", prompt: "Continue reviewing", sessionID: child.id },
|
||||
},
|
||||
})
|
||||
expect(outputSessionID(continued.metadata)).toBe(child.id)
|
||||
const latest = requests.findLast((request) => request.sessionID === child.id)!
|
||||
expect(JSON.stringify(latest.messages)).toContain("Inherited file contents")
|
||||
expect(JSON.stringify(latest.messages)).toContain("Continue reviewing")
|
||||
expect(JSON.stringify(latest.messages)).not.toContain("Later parent message")
|
||||
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1)
|
||||
for (const fork of [undefined, false]) {
|
||||
const fresh = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: `call-fresh-${fork}`,
|
||||
name: SubagentTool.name,
|
||||
input: {
|
||||
agent: "fallback",
|
||||
description: "fresh",
|
||||
prompt: "Start fresh",
|
||||
...(fork === undefined ? {} : { fork }),
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(fresh.status).toBe("completed")
|
||||
const freshChild = yield* sessions.get(outputSessionID(fresh.metadata))
|
||||
expect(freshChild.fork).toBeUndefined()
|
||||
expect(freshChild.model).toMatchObject(parentModel)
|
||||
const freshRequest = requests.find((request) => request.sessionID === freshChild.id)!
|
||||
expect(JSON.stringify(freshRequest.messages)).not.toContain("Inherited file contents")
|
||||
expect(JSON.stringify(freshRequest.messages)).toContain("Start fresh")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
completionIt.live("admits one durable completion across live delivery and restart replay", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -8,11 +8,13 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, Fiber, Layer, Logger, Schedule, Schema, Scope } from "effect"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import path from "path"
|
||||
|
||||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
@@ -30,7 +32,6 @@ const session = (
|
||||
overrides: Partial<V1Migration.TransformInput["session"]> = {},
|
||||
): V1Migration.TransformInput["session"] => ({
|
||||
id: SessionSchema.ID.make("ses_test"),
|
||||
timeline_id: Timeline.ID.create(),
|
||||
project_id: Project.ID.global,
|
||||
workspace_id: null,
|
||||
parent_id: null,
|
||||
@@ -830,6 +831,179 @@ describe("V1Migration database workflow", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("imports previous V2 sessions and messages containing apostrophes", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "opencode-next.db")
|
||||
const sqlite = await import("bun:sqlite")
|
||||
const source = new sqlite.Database(filename)
|
||||
source.run(`
|
||||
CREATE TABLE project (
|
||||
id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text, icon_url text, icon_url_override text,
|
||||
icon_color text, time_created integer NOT NULL, time_updated integer NOT NULL, time_initialized integer,
|
||||
sandboxes text NOT NULL, commands text
|
||||
);
|
||||
CREATE TABLE session (
|
||||
id text PRIMARY KEY, project_id text NOT NULL, workspace_id text, parent_id text, fork_session_id text,
|
||||
fork_boundary text, slug text NOT NULL, directory text NOT NULL, path text, title text, version text NOT NULL,
|
||||
share_url text, summary_additions integer, summary_deletions integer, summary_files integer, summary_diffs text,
|
||||
metadata text, cost real DEFAULT 0 NOT NULL, tokens_input integer DEFAULT 0 NOT NULL,
|
||||
tokens_output integer DEFAULT 0 NOT NULL, tokens_reasoning integer DEFAULT 0 NOT NULL,
|
||||
tokens_cache_read integer DEFAULT 0 NOT NULL, tokens_cache_write integer DEFAULT 0 NOT NULL, revert text,
|
||||
permission text, agent text, model text, time_created integer NOT NULL, time_updated integer NOT NULL,
|
||||
time_compacting integer, time_archived integer, time_suspended integer
|
||||
);
|
||||
CREATE TABLE session_message (
|
||||
id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL,
|
||||
time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL
|
||||
);
|
||||
INSERT INTO project VALUES (
|
||||
'next-project', 'C:/Users/sewer', 'git', 'Source project', NULL, NULL, NULL, 1, 2, NULL, '[]', NULL
|
||||
);
|
||||
INSERT INTO session (
|
||||
id, project_id, slug, directory, title, version, agent, model, time_created, time_updated
|
||||
) VALUES
|
||||
('ses_next', 'next-project', 'next', 'C:/Users/sewer', 'Imported', '2', 'build',
|
||||
'{"id":"model","providerID":"provider"}', 10, 20),
|
||||
('ses_existing', 'next-project', 'source-existing', '/tmp/next', 'Source existing', '2', NULL, NULL, 11, 21),
|
||||
('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '2', NULL, NULL, 12, 22);
|
||||
INSERT INTO session_message VALUES
|
||||
('msg_next', 'ses_next', 'user', 4, 12, 13, '{"text":"from next''s history","time":{"created":12}}'),
|
||||
('msg_source_existing', 'ses_existing', 'user', 2, 12, 13, '{"text":"source","time":{"created":12}}'),
|
||||
('msg_orphan', 'ses_orphan', 'user', 0, 12, 13, '{"text":"orphan","time":{"created":12}}');
|
||||
`)
|
||||
source.close()
|
||||
|
||||
await database(
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db.run(sql`
|
||||
INSERT INTO project (id, worktree, name, time_created, time_updated, sandboxes)
|
||||
VALUES ('next-project', '/tmp/current', 'Current project', 1, 2, '[]')
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
INSERT INTO session_v2 (id, project_id, slug, directory, title, version, time_created, time_updated)
|
||||
VALUES ('ses_existing', 'next-project', 'current-existing', '/tmp/current', 'Current existing', '2', 1, 2)
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data)
|
||||
VALUES ('msg_current_existing', 'ses_existing', 'user', 0, 1, 2, '{"text":"current","time":{"created":1}}')
|
||||
`)
|
||||
|
||||
expect(yield* V1Migration.status()).toEqual({
|
||||
status: "required",
|
||||
})
|
||||
expect(yield* V1Migration.run({ nextDatabasePath: filename })).toEqual({ status: "completed" })
|
||||
expect(yield* V1Migration.status()).toEqual({
|
||||
status: "completed",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT title, agent, model FROM session_v2 WHERE id = 'ses_next'`)).toEqual({
|
||||
title: "Imported",
|
||||
agent: "build",
|
||||
model: '{"id":"model","providerID":"provider"}',
|
||||
})
|
||||
expect(
|
||||
yield* db
|
||||
.select({ directory: SessionTable.directory })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, SessionSchema.ID.make("ses_next")))
|
||||
.get(),
|
||||
).toEqual({ directory: process.platform === "win32" ? "C:\\Users\\sewer" : "C:/Users/sewer" })
|
||||
expect(yield* db.all(sql`SELECT id, seq, data FROM session_message WHERE session_id = 'ses_next'`)).toEqual([
|
||||
{
|
||||
id: "msg_next",
|
||||
seq: 4,
|
||||
data: '{"text":"from next\'s history","time":{"created":12}}',
|
||||
},
|
||||
])
|
||||
expect(yield* db.get(sql`SELECT seq, owner_id FROM event_sequence WHERE aggregate_id = 'ses_next'`)).toEqual({
|
||||
seq: 4,
|
||||
owner_id: null,
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT title FROM session_v2 WHERE id = 'ses_existing'`)).toEqual({
|
||||
title: "Current existing",
|
||||
})
|
||||
expect(yield* db.all(sql`SELECT id FROM session_message WHERE session_id = 'ses_existing'`)).toEqual([
|
||||
{ id: "msg_current_existing" },
|
||||
])
|
||||
expect(yield* db.get(sql`SELECT project_id FROM session_v2 WHERE id = 'ses_orphan'`)).toEqual({
|
||||
project_id: "global",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT name, worktree FROM project WHERE id = 'next-project'`)).toEqual({
|
||||
name: "Current project",
|
||||
worktree: "/tmp/current",
|
||||
})
|
||||
yield* db.run(sql`UPDATE project SET worktree = 'C:/Users/sewer' WHERE id = 'next-project'`)
|
||||
expect(
|
||||
yield* db
|
||||
.select({ worktree: ProjectTable.worktree })
|
||||
.from(ProjectTable)
|
||||
.where(eq(ProjectTable.id, Project.ID.make("next-project")))
|
||||
.get(),
|
||||
).toEqual({
|
||||
worktree: AbsolutePath.make(process.platform === "win32" ? "C:\\Users\\sewer" : "C:/Users/sewer"),
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({
|
||||
value: '{"phase":"completed"}',
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("imports previous V2 databases missing newer nullable columns", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "opencode-next.db")
|
||||
const sqlite = await import("bun:sqlite")
|
||||
const source = new sqlite.Database(filename)
|
||||
source.run(`
|
||||
CREATE TABLE project (
|
||||
id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text, icon_url text,
|
||||
time_created integer NOT NULL, time_updated integer NOT NULL, time_initialized integer,
|
||||
sandboxes text NOT NULL
|
||||
);
|
||||
CREATE TABLE session (
|
||||
id text PRIMARY KEY, project_id text NOT NULL, workspace_id text, parent_id text, fork_session_id text,
|
||||
slug text NOT NULL, directory text NOT NULL, path text, title text, version text NOT NULL,
|
||||
share_url text, summary_additions integer, summary_deletions integer, summary_files integer, summary_diffs text,
|
||||
metadata text, cost real DEFAULT 0 NOT NULL, tokens_input integer DEFAULT 0 NOT NULL,
|
||||
tokens_output integer DEFAULT 0 NOT NULL, tokens_reasoning integer DEFAULT 0 NOT NULL,
|
||||
tokens_cache_read integer DEFAULT 0 NOT NULL, tokens_cache_write integer DEFAULT 0 NOT NULL, revert text,
|
||||
permission text, agent text, model text, time_created integer NOT NULL, time_updated integer NOT NULL,
|
||||
time_compacting integer, time_archived integer
|
||||
);
|
||||
CREATE TABLE session_message (
|
||||
id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL,
|
||||
time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL
|
||||
);
|
||||
INSERT INTO project VALUES (
|
||||
'next-project', '/tmp/next', 'git', 'Source project', 'https://example.com/icon.png', 1, 2, NULL, '[]'
|
||||
);
|
||||
INSERT INTO session (
|
||||
id, project_id, slug, directory, title, version, time_created, time_updated
|
||||
) VALUES ('ses_next', 'next-project', 'next', '/tmp/next', 'Imported', '2', 10, 20);
|
||||
`)
|
||||
source.close()
|
||||
|
||||
await database(
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
expect(yield* V1Migration.run({ nextDatabasePath: filename })).toEqual({ status: "completed" })
|
||||
expect(
|
||||
yield* database.db.get(sql`SELECT fork_boundary, time_suspended FROM session_v2 WHERE id = 'ses_next'`),
|
||||
).toEqual({ fork_boundary: null, time_suspended: null })
|
||||
expect(
|
||||
yield* database.db.get(
|
||||
sql`SELECT icon_url, icon_url_override, icon_color, commands FROM project WHERE id = 'next-project'`,
|
||||
),
|
||||
).toEqual({
|
||||
icon_url: "https://example.com/icon.png",
|
||||
icon_url_override: "https://example.com/icon.png",
|
||||
icon_color: null,
|
||||
commands: null,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("derives required status from the durable cursor", async () => {
|
||||
await database(
|
||||
Effect.gen(function* () {
|
||||
@@ -897,9 +1071,8 @@ describe("V1Migration database workflow", () => {
|
||||
yield* db.run(
|
||||
sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('prt_1', ${source.id}, 'ses_test', 1, 2, ${sourcePart.data})`,
|
||||
)
|
||||
const timelineID = yield* Timeline.create(db)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_message (id, session_id, timeline_id, type, seq, time_created, time_updated, data) VALUES ('msg_stale', 'ses_test', ${timelineID}, 'user', 0, 1, 1, '{"text":"stale","time":{"created":1}}')`,
|
||||
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_stale', 'ses_test', 'user', 0, 1, 1, '{"text":"stale","time":{"created":1}}')`,
|
||||
)
|
||||
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('ses_test', 9)`)
|
||||
yield* db.run(
|
||||
@@ -969,9 +1142,8 @@ describe("V1Migration database workflow", () => {
|
||||
yield* db.run(
|
||||
sql`CREATE TRIGGER fail_b BEFORE UPDATE ON session_v2 WHEN NEW.id = 'ses_b' BEGIN SELECT RAISE(ABORT, 'stop'); END`,
|
||||
)
|
||||
const timelineID = yield* Timeline.create(db)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_message (id, session_id, timeline_id, type, seq, time_created, time_updated, data) VALUES ('msg_stale_b', 'ses_b', ${timelineID}, 'user', 0, 7, 8, '{"text":"stale","time":{"created":7}}')`,
|
||||
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_stale_b', 'ses_b', 'user', 0, 7, 8, '{"text":"stale","time":{"created":7}}')`,
|
||||
)
|
||||
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq, owner_id) VALUES ('ses_b', 7, 'owner')`)
|
||||
yield* db.run(
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { addRendererHeaders, documentPolicyHeader, jsCallStacksDocumentPolicy, upsertHeader } from "./headers"
|
||||
|
||||
describe("renderer response headers", () => {
|
||||
test("keeps the server's exact allow-headers list so Chromium can reuse the cached preflight", () => {
|
||||
const headers = {
|
||||
"access-control-allow-origin": ["oc://renderer"],
|
||||
"access-control-allow-headers": ["authorization"],
|
||||
"access-control-max-age": ["86400"],
|
||||
}
|
||||
addRendererHeaders(headers, { document: false })
|
||||
expect(headers).toEqual({
|
||||
"access-control-allow-origin": ["*"],
|
||||
"access-control-allow-headers": ["authorization"],
|
||||
"access-control-max-age": ["86400"],
|
||||
})
|
||||
})
|
||||
|
||||
test("fills CORS headers for servers that send none, naming authorization explicitly", () => {
|
||||
const headers: Record<string, string[]> = { "content-type": ["application/json"] }
|
||||
addRendererHeaders(headers, { document: false })
|
||||
expect(headers["Access-Control-Allow-Origin"]).toEqual(["*"])
|
||||
expect(headers["Access-Control-Allow-Headers"]).toEqual(["*, authorization"])
|
||||
expect(headers["Access-Control-Max-Age"]).toEqual(["7200"])
|
||||
})
|
||||
|
||||
test("adds the crash-report document policy only to renderer documents", () => {
|
||||
const document = {}
|
||||
addRendererHeaders(document, { document: true })
|
||||
expect(Reflect.get(document, documentPolicyHeader)).toEqual([jsCallStacksDocumentPolicy])
|
||||
const asset = {}
|
||||
addRendererHeaders(asset, { document: false })
|
||||
expect(Object.keys(asset)).not.toContain(documentPolicyHeader)
|
||||
})
|
||||
|
||||
test("upsert replaces a header regardless of key casing", () => {
|
||||
const headers = { "X-Test": ["a"] }
|
||||
upsertHeader(headers, "x-test", ["b"])
|
||||
expect(headers).toEqual({ "X-Test": ["b"] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
export const documentPolicyHeader = "Document-Policy"
|
||||
export const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports"
|
||||
|
||||
// The renderer lives at oc://renderer, so every server it talks to is cross-origin. Servers that
|
||||
// send no CORS headers get permissive ones here so the renderer can still reach them.
|
||||
//
|
||||
// A server's own `Access-Control-Allow-Headers` is kept as-is. Chromium reuses a cached preflight
|
||||
// only when that header names `authorization` explicitly (`*` never covers it), so overwriting the
|
||||
// server's exact list with `*` forced a fresh OPTIONS round trip in front of every API call.
|
||||
export function addRendererHeaders(headers: object, options: { document: boolean }) {
|
||||
upsertHeader(headers, "Access-Control-Allow-Origin", ["*"])
|
||||
if (!hasHeader(headers, "Access-Control-Allow-Headers")) {
|
||||
upsertHeader(headers, "Access-Control-Allow-Headers", ["*, authorization"])
|
||||
}
|
||||
if (!hasHeader(headers, "Access-Control-Max-Age")) {
|
||||
// Chromium caps preflight cache lifetime at two hours; without the header it caches for 5s.
|
||||
upsertHeader(headers, "Access-Control-Max-Age", ["7200"])
|
||||
}
|
||||
if (options.document) upsertHeader(headers, documentPolicyHeader, [jsCallStacksDocumentPolicy])
|
||||
}
|
||||
|
||||
export function hasHeader(headers: object, key: string) {
|
||||
return Object.keys(headers).some((header) => header.toLowerCase() === key.toLowerCase())
|
||||
}
|
||||
|
||||
export function upsertHeader(headers: object, key: string, value: string | string[]) {
|
||||
const current = Object.keys(headers).find((header) => header.toLowerCase() === key.toLowerCase())
|
||||
Reflect.set(headers, current ?? key, value)
|
||||
}
|
||||
@@ -4,11 +4,10 @@ import { pathToFileURL } from "node:url"
|
||||
import { Effect, Path } from "effect"
|
||||
import { scoped } from "../native/logging"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
|
||||
|
||||
const rendererProtocol = "oc"
|
||||
const rendererHost = "renderer"
|
||||
const documentPolicyHeader = "Document-Policy"
|
||||
const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports"
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
@@ -85,17 +84,6 @@ export function isRendererUrl(value?: string, html = false) {
|
||||
return url.origin === new URL(devUrl).origin
|
||||
}
|
||||
|
||||
export function addRendererHeaders(value: string, headers: object) {
|
||||
upsertHeader(headers, "Access-Control-Allow-Origin", ["*"])
|
||||
upsertHeader(headers, "Access-Control-Allow-Headers", ["*"])
|
||||
if (isRendererUrl(value, true)) upsertHeader(headers, documentPolicyHeader, [jsCallStacksDocumentPolicy])
|
||||
}
|
||||
|
||||
export function upsertHeader(headers: object, key: string, value: string | string[]) {
|
||||
const current = Object.keys(headers).find((header) => header.toLowerCase() === key.toLowerCase())
|
||||
Reflect.set(headers, current ?? key, value)
|
||||
}
|
||||
|
||||
function addDocumentPolicy(response: Response, file: string) {
|
||||
if (!file.toLowerCase().endsWith(".html")) return response
|
||||
const headers = new Headers(response.headers)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { addRendererHeaders, isRendererUrl, upsertHeader } from "./protocol"
|
||||
import { addRendererHeaders } from "./headers"
|
||||
import { isRendererUrl } from "./protocol"
|
||||
|
||||
const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"])
|
||||
|
||||
@@ -30,13 +31,9 @@ export function wireNavigationPolicy(win: BrowserWindow, openExternalURL: (url:
|
||||
}
|
||||
|
||||
export function wireRendererHeaders(win: BrowserWindow) {
|
||||
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
upsertHeader(details.requestHeaders, "Access-Control-Allow-Origin", ["*"])
|
||||
callback({ requestHeaders: details.requestHeaders })
|
||||
})
|
||||
win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
||||
const responseHeaders = details.responseHeaders ?? {}
|
||||
addRendererHeaders(details.url, responseHeaders)
|
||||
addRendererHeaders(responseHeaders, { document: isRendererUrl(details.url, true) })
|
||||
callback({ responseHeaders })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
"./tui": "./src/tui/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#plugin-source": {
|
||||
"bun": "./src/source.bun.ts",
|
||||
"node": "./src/source.node.ts",
|
||||
"default": "./src/source.node.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user