Compare commits

..
186 changed files with 2450 additions and 7146 deletions
+2 -2
View File
@@ -91,7 +91,7 @@ jobs:
- uses: ./.github/actions/setup-bun
with:
bun-version: 1.4.2
bun-version: 1.4.1
- 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.2
BUN_COMPILE_RELEASE: bun-v1.4.1
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }}
+15 -26
View File
@@ -33,7 +33,6 @@
"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",
@@ -339,6 +338,7 @@
"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,6 +350,7 @@
"@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:",
@@ -357,7 +358,6 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -605,21 +605,6 @@
"solid-js",
],
},
"packages/plugin-browser": {
"name": "@opencode-ai/plugin-browser",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/posts": {
"name": "@opencode-ai/posts",
"dependencies": {
@@ -1082,7 +1067,7 @@
"@opentui/solid": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"bun-types": "1.4.2",
"bun-types": "1.4.1",
"effect": "catalog:",
"solid-js": "catalog:",
},
@@ -1204,7 +1189,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.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": ["@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-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=="],
@@ -2162,8 +2147,6 @@
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/plugin-browser": ["@opencode-ai/plugin-browser@workspace:packages/plugin-browser"],
"@opencode-ai/posts": ["@opencode-ai/posts@workspace:packages/posts"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
@@ -3504,7 +3487,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -5884,8 +5867,6 @@
"@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=="],
@@ -5894,6 +5875,8 @@
"@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=="],
@@ -5940,9 +5923,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.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@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-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/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/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=="],
@@ -6402,6 +6385,8 @@
"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=="],
@@ -7262,6 +7247,10 @@
"@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
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-bWKV3fV8Yc+3ILaNRChIYajsRcvEG2OkydsD2KMUXCM=",
"aarch64-linux": "sha256-zIavB09LW+BU3GOq0PO7Lqfc3MVchkiOUf3trnAjhRM=",
"aarch64-darwin": "sha256-Jlvz7QsXsb8GS9Ty4TmUIfGOawiueZWWEYmyEPms3dc=",
"x86_64-darwin": "sha256-TzTDtcDtj1c3+eP2WZAHOfkszxIS06J6QUwlnscLaUM="
"x86_64-linux": "sha256-IDORw4Nup1Yj+RZSLGo0pDnwrkveYA8GvYQVNTJUnMM=",
"aarch64-linux": "sha256-jxjyAI2imF7csdZNUqvs9mFj8SnYqGg1CapEEk15rxA=",
"aarch64-darwin": "sha256-Hbh+cw2DPOJpYCDjasK6m0SLqznyAw1ODKSb7w87GG8=",
"x86_64-darwin": "sha256-Aj0/MtMPoeccrN36mu2wUM6gyRKaTF2AbjZ0/wZtKnA="
}
}
+3 -3
View File
@@ -5,9 +5,9 @@
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
"packageManager": "bun@1.4.1",
"scripts": {
"dev": "bun run --cwd packages/cli src/index.ts",
"dev": "bun run --cwd packages/cli --conditions=browser 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.2",
"bun-types": "1.4.1",
"effect": "catalog:",
"solid-js": "catalog:"
},
-1
View File
@@ -28,7 +28,6 @@
"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,12 +2,13 @@ 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.
* 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.
*/
export interface Credentials {
readonly region: string
@@ -16,44 +17,6 @@ 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
@@ -85,17 +48,16 @@ const signRequest = (input: {
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
export const sigV4 = (
source: CredentialSource | undefined,
credentials: Credentials | undefined,
options: { readonly service?: string; readonly name?: string } = {},
) =>
Auth.custom((input: AuthInput) => {
return Effect.gen(function* () {
if (!source) {
if (!credentials) {
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,
@@ -112,35 +74,4 @@ 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,3 +1,4 @@
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"
@@ -9,15 +10,9 @@ 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
}
@@ -27,7 +22,6 @@ 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
@@ -53,35 +47,23 @@ const chatRoute = OpenAIChat.route.with({
export const routes = [responsesRoute, chatRoute]
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
const region = BedrockAuth.resolveRegion(input)
const region = input.region ?? input.credentials?.region ?? "us-east-1"
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
return route.with({
endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` },
auth: BedrockAuth.resolveAuth(input, region, {
service: "bedrock-mantle",
name: "Bedrock Mantle",
mode: input.auth,
}),
auth:
input.apiKey === undefined
? BedrockAuth.sigV4(credentials, { service: "bedrock-mantle", name: "Bedrock Mantle" })
: Auth.bearer(input.apiKey),
})
}
const defaults = (input: Config) => {
const {
apiKey: _,
auth: _auth,
baseURL: _baseURL,
credentials: _credentials,
profile: _profile,
region: _region,
...rest
} = input
const { apiKey: _, baseURL: _baseURL, credentials: _credentials, 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)
@@ -105,26 +87,29 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
const fromSettings = (settings: Settings) =>
configure({
apiKey: settings.apiKey,
auth: settings.auth,
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,
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,
) => fromSettings(settings).chat(modelID)
) => configure(config(settings)).chat(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => fromSettings(settings).responses(modelID)
) => configure(config(settings)).responses(modelID)
export const model = responsesModel
+13 -20
View File
@@ -1,23 +1,17 @@
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
/** 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`. */
/** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */
readonly region?: string
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
readonly baseURL?: string
@@ -28,7 +22,6 @@ 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
}
@@ -37,17 +30,14 @@ export const routes = [BedrockConverse.route]
const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
const configuredRoute = (input: Config) => {
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)
const { apiKey, credentials, region, baseURL, ...rest } = input
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
return BedrockConverse.route.with({
...rest,
provider: id,
providerMetadataKey: "bedrock",
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
auth: BedrockAuth.resolveAuth({ apiKey, credentials, profile }, resolvedRegion, { mode: auth }),
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
})
}
@@ -61,15 +51,18 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
auth: settings.auth,
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,
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)
}
-30
View File
@@ -1,30 +0,0 @@
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,11 +2,9 @@ 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,
@@ -20,8 +18,7 @@ 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 { withProcessEnv } from "../lib/env.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { fixedResponse } from "../lib/http.js"
import {
eventSummary,
expectWeatherToolLoop,
@@ -98,41 +95,6 @@ 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",
@@ -1349,137 +1311,35 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("fails with an authentication error when the default chain cannot resolve credentials", () =>
it.effect("rejects requests with no auth path", () =>
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.reason._tag).toBe("Authentication")
expect(error.message).toContain("AWS default credential chain failed")
}).pipe(withProcessEnv(noAmbientAWS)),
expect(error.message).toContain("Bedrock Converse requires either route bearer auth or AWS credentials")
}),
)
it.effect("signs with static credentials using the configured region for the SigV4 scope", () =>
it.effect("signs requests with SigV4 when AWS credentials are provided (deterministic plumbing check)", () =>
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 headers = yield* captureHeaders(signed)
const prepared = yield* compileRequest(LLMRequest.update(baseRequest, { model: 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" })),
expect(prepared.route).toBe("bedrock-converse")
expect(prepared.model).toBe(signed)
}),
)
it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
@@ -7,7 +7,6 @@ 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"
@@ -83,36 +82,6 @@ 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 }> = []
+7 -130
View File
@@ -1,139 +1,16 @@
import { expect, story } from "../../storybook/playwright/story"
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,
)
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 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", "light"))
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
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")
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
})
// 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()
@@ -1,86 +0,0 @@
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)
})
}
@@ -1,79 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
story("enables Any only after confirmation and supports resetting the preview", async ({ mount }) => {
const component = await mount("app-current-session-surface--web-search-request")
const card = component.getByRole("region", { name: "Third-party web search" })
await expect(card.getByRole("button", { name: "Enable", exact: true })).toBeEnabled()
await expect(card.getByRole("button", { name: "Search provider Any", exact: true })).toBeVisible()
await card.getByRole("button", { name: "Enable", exact: true }).click()
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): random")
await expect(card).toHaveCount(0)
await component.getByRole("button", { name: "Reset", exact: true }).click()
await expect(card.getByRole("button", { name: "Enable", exact: true })).toBeEnabled()
})
story("declining search is an explicit disabled selection", async ({ mount }) => {
const component = await mount("app-current-session-surface--web-search-request")
const card = component.getByRole("region", { name: "Third-party web search" })
await card.getByRole("button", { name: "Dont use search", exact: true }).click()
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): false")
await expect(card).toHaveCount(0)
})
story("sizes provider options to their content", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--web-search-request")
await component.getByRole("button", { name: "Search provider Any", exact: true }).click()
const menu = page.getByRole("listbox", { name: "Search provider", exact: true })
await expect(menu).toBeVisible()
const metrics = await menu.evaluate((listbox) => {
const items = Array.from(listbox.querySelectorAll('[data-component="menu-v2-item"]'))
const longest = items
.flatMap((item) => {
const label = item.querySelector('[data-slot="menu-v2-item-content"]')?.getBoundingClientRect()
const check = item.querySelector('[data-slot="menu-v2-item-indicator"]')?.getBoundingClientRect()
return label && check ? [{ label, check }] : []
})
.toSorted((a, b) => b.label.width - a.label.width)[0]
return {
width: listbox.getBoundingClientRect().width,
gap: longest ? longest.check.left - longest.label.right : 0,
}
})
expect(metrics.width).toBeLessThan(160)
expect(metrics.gap).toBe(24)
})
for (const width of [360, 1200]) {
for (const direction of ["ltr", "rtl"]) {
for (const theme of ["light", "dark"]) {
story(`selects and confirms a provider at ${width}px in ${direction} ${theme}`, async ({ mount, page }) => {
await page.setViewportSize({ width, height: 900 })
const component = await mount("app-current-session-surface--web-search-request", {
globals: { theme, direction },
})
const card = component.getByRole("region", { name: "Third-party web search" })
const select = card.getByRole("button", { name: /^Search provider/ })
await expect(select).toBeEnabled()
await expect(card).toHaveCSS("direction", direction)
expect(await card.evaluate((node) => node.scrollWidth <= node.clientWidth)).toBe(true)
await select.focus()
await select.press("Enter")
const list = page.getByRole("listbox", { name: "Search provider", exact: true })
await expect(list).toHaveCSS("direction", direction)
await list.getByRole("option", { name: "Parallel", exact: true }).click()
await expect(select).toHaveText("Parallel")
await expect(card).toBeVisible()
await expect(component.getByRole("status")).toHaveText("Ready")
await expect(select).toBeFocused()
await select.press("Tab")
await expect(card.getByRole("button", { name: "Dont use search", exact: true })).toBeFocused()
await page.keyboard.press("Tab")
const enable = card.getByRole("button", { name: "Enable", exact: true })
await expect(enable).toBeFocused()
await enable.press("Enter")
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): parallel")
await expect(card).toHaveCount(0)
})
}
}
}
@@ -7,13 +7,11 @@ const directory = "C:/OpenCode/OpenFileExpand"
const projectID = "proj_open_file_expand"
const sessionID = "ses_open_file_expand"
const title = "Open file expand"
const longFilename = "a-very-long-file-name-that-must-overflow-the-file-sidebar-instead-of-being-truncated.ts"
const longPath = `frontend/${longFilename}`
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("expands Windows paths and horizontally scrolls long filenames", async ({ page }) => {
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -46,17 +44,7 @@ test("expands Windows paths and horizontally scrolls long filenames", async ({ p
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [
{
file: longPath,
before: "",
after: "export const added = true\n",
additions: 1,
deletions: 0,
status: "added",
patch: "@@ -0,0 +1 @@\n+export const added = true\n",
},
],
vcsDiff: [],
fileList: (path) => {
if (path === "frontend\\" || path === "frontend") {
return [
@@ -67,13 +55,6 @@ test("expands Windows paths and horizontally scrolls long filenames", async ({ p
type: "file" as const,
ignored: false,
},
{
name: longFilename,
path: `frontend\\${longFilename}`,
absolute: `${directory}/${longPath}`,
type: "file" as const,
ignored: false,
},
]
}
if (path) return []
@@ -94,7 +75,6 @@ test("expands Windows paths and horizontally scrolls long filenames", async ({ p
},
]
},
findFiles: ({ query }) => (longPath.includes(query) ? [longPath] : []),
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
pageMessages: () => ({ items: [] }),
})
@@ -139,93 +119,6 @@ test("expands Windows paths and horizontally scrolls long filenames", async ({ p
await frontendRow.click()
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
const viewport = sidebar.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
const longRow = panel.getByRole("button", { name: longFilename })
await expect(longRow).toBeVisible()
await expect.poll(() => viewport.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0)
expect(
await longRow.evaluate((element) => getComputedStyle(element.querySelector("bdi")!.parentElement!).textOverflow),
).toBe("clip")
expect(await longRow.evaluate((element) => element.getBoundingClientRect().width)).toBeGreaterThanOrEqual(
await viewport.evaluate((element) => element.clientWidth),
)
await expect
.poll(() =>
panel.locator('[data-slot="file-tree-v2-row"]').evaluateAll((rows) => {
const widths = rows.map((row) => row.getBoundingClientRect().width)
return Math.max(...widths) - Math.min(...widths)
}),
)
.toBeLessThanOrEqual(0.5)
await expect(longRow.locator('[data-slot="file-tree-v2-label"]')).toHaveCSS("margin-inline-end", "12px")
const status = longRow.locator('[data-slot="file-tree-v2-change"]')
await expect(status).toHaveText("A")
const statusBox = await status.boundingBox()
if (!statusBox) throw new Error("File status has no bounding box")
const viewportBox = await viewport.boundingBox()
if (!viewportBox) throw new Error("File tree viewport has no bounding box")
expect(viewportBox.x + viewportBox.width - statusBox.x - statusBox.width).toBeLessThanOrEqual(24)
await viewport.hover()
const horizontalThumb = sidebar.locator('.scroll-view__thumb[data-orientation="horizontal"]')
await expect(horizontalThumb).toHaveCSS("opacity", "1")
await page.mouse.wheel(1_000, 0)
await expect.poll(() => viewport.evaluate((element) => Math.abs(element.scrollLeft))).toBeGreaterThan(0)
await expect(horizontalThumb).toHaveAttribute("data-visible", "true")
await expect
.poll(() =>
status.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
return viewport.right - element.getBoundingClientRect().right
}),
)
.toBeLessThanOrEqual(24)
const beforeDrag = await viewport.evaluate((element) => Math.abs(element.scrollLeft))
const thumbBox = await horizontalThumb.boundingBox()
if (!thumbBox) throw new Error("Horizontal scrollbar thumb has no bounding box")
await page.mouse.move(thumbBox.x + thumbBox.width / 2, thumbBox.y + thumbBox.height / 2)
await page.mouse.down()
await page.mouse.move(thumbBox.x + thumbBox.width / 2 - 40, thumbBox.y + thumbBox.height / 2)
await page.mouse.up()
await expect.poll(() => viewport.evaluate((element) => Math.abs(element.scrollLeft))).toBeLessThan(beforeDrag)
const filter = panel.getByRole("combobox", { name: "Filter files" })
await filter.fill(longFilename)
const filteredRow = panel.getByRole("option", { name: longFilename })
await expect(filteredRow).toBeVisible()
const filteredStatus = filteredRow.locator('[data-slot="file-tree-v2-change"]')
await expect(filteredStatus).toHaveText("A")
await expect.poll(() => viewport.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0)
await viewport.evaluate((element) => {
element.setAttribute("dir", "rtl")
element.scrollLeft = 0
element.dispatchEvent(new Event("scroll"))
})
await expect
.poll(() =>
filteredStatus.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
return element.getBoundingClientRect().left - viewport.left
}),
)
.toBeLessThanOrEqual(24)
const rtlThumbBox = await horizontalThumb.boundingBox()
if (!rtlThumbBox) throw new Error("RTL horizontal scrollbar thumb has no bounding box")
await page.mouse.move(rtlThumbBox.x + rtlThumbBox.width / 2, rtlThumbBox.y + rtlThumbBox.height / 2)
await page.mouse.down()
await page.mouse.move(rtlThumbBox.x + rtlThumbBox.width / 2 - 40, rtlThumbBox.y + rtlThumbBox.height / 2)
await page.mouse.up()
await expect.poll(() => viewport.evaluate((element) => element.scrollLeft)).toBeLessThan(0)
await viewport.evaluate((element) => {
element.removeAttribute("dir")
element.scrollLeft = 0
element.dispatchEvent(new Event("scroll"))
})
await filter.fill("")
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
await expect(appRow).toBeVisible()
await appRow.click()
@@ -1,68 +0,0 @@
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,14 +301,11 @@ for (const count of [0, 26]) {
}
for (const direction of ["ltr", "rtl"]) {
test(`vertical tabs keep Status pinned without Settings in ${direction}`, async ({ page }, testInfo) => {
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, directory }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({ appearance: { tabLayout: "vertical" }, general: { showStatus: true } }),
)
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
@@ -325,27 +322,23 @@ for (const direction of ["ltr", "rtl"]) {
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionA.id}`)
await page.goto("/")
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(status).toHaveText("Status")
await expect(settings).toHaveCount(0)
await expect(status.locator('[data-slot="status-indicator"]')).toBeVisible()
await expect(settings).toHaveText("Settings")
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(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(settings).toHaveCSS("margin-top", "8px")
await expect
.poll(() =>
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
@@ -360,12 +353,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 pinnedStatus = await status.boundingBox()
const pinned = await settings.boundingBox()
await scroll.hover()
await page.mouse.wheel(0, 200)
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await testInfo.attach(`vertical-tabs-status-${width}`, {
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
await testInfo.attach(`vertical-tabs-settings-${width}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
@@ -379,14 +372,17 @@ for (const direction of ["ltr", "rtl"]) {
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
})
.toBe(true)
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await expect(settings).toHaveCount(0)
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
}
await status.click()
await expect(status).toHaveAttribute("aria-expanded", "true")
await status.press("Escape")
await expect(status).toHaveAttribute("aria-expanded", "false")
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()
})
}
@@ -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, "icon-button-contrast")
await expectBackground(view.send, "contrast")
await view.send.hover()
await expectBackground(view.send, "icon-button-contrast")
await expectBackground(view.send, "contrast")
await view.composer.locator('[data-action="composer-model"]').press("Tab")
await expect(view.send).toBeFocused()
await expectBackground(view.send, "icon-button-contrast")
await expectBackground(view.send, "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, "icon-button-contrast")
await expectBackground(view.send, "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, "icon-button-contrast")
await expectBackground(view.send, "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, "icon-button-contrast")
await expectBackground(view.send, "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, "icon-button-contrast")
await expectBackground(stop, "contrast")
await view.input.fill("Send a follow-up instead of stopping.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "icon-button-contrast")
await expectBackground(view.send, "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, "icon-button-contrast")
await expectBackground(view.send, "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, "icon-button-contrast")
await expectBackground(view.send, "contrast")
await view.send.hover()
await expectBackground(view.send, "icon-button-contrast")
await expectBackground(view.send, "contrast")
await view.composer.locator('[data-action="composer-model"]').press("Tab")
await expect(view.send).toBeFocused()
await expectBackground(view.send, "icon-button-contrast")
await expectBackground(view.send, "contrast")
})
})
}
+5 -37
View File
@@ -55,8 +55,6 @@ 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 }],
@@ -68,15 +66,11 @@ 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: () => modelOption(),
recent: () => [modelOption()],
list: () => [modelOption()],
current: () => selectedModel,
recent: () => [selectedModel],
list: () => [selectedModel],
cycle() {},
set() {},
visible: () => true,
@@ -132,7 +126,7 @@ function ComposerStory(props: {
placeholder: () => "Ask anything, / for commands, @ for context...",
agent: {
options: () => [
{ id: "build", label: props.longLabels ? "Build agent with an unusually long name" : "build" },
{ id: "build", label: "build" },
{ id: "review", label: "review" },
],
current: () => "build",
@@ -141,7 +135,7 @@ function ComposerStory(props: {
variant: {
options: () => [
{ id: "default", label: "default" },
{ id: "balanced", label: props.longLabels ? "Balanced reasoning with an extended label" : "balanced" },
{ id: "balanced", label: "balanced" },
{ id: "high", label: "high" },
],
current: () => story.variant,
@@ -150,17 +144,6 @@ 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
@@ -307,21 +290,6 @@ 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,29 +2,6 @@
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;
+60 -90
View File
@@ -1,5 +1,4 @@
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createEffect, createMemo, createSignal, For, Show, type JSX } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -57,23 +56,6 @@ 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
@@ -244,7 +226,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
<div class="flex h-11 items-center px-2">
<div
class="flex shrink-0 items-center"
class="flex min-w-0 flex-1 items-center gap-1"
aria-hidden={state.mode === "shell"}
inert={state.mode === "shell" ? true : undefined}
style={buttons()}
@@ -263,80 +245,64 @@ export function ComposerEditor(props: ComposerEditorProps) {
onContext={props.controller.openContext}
onShell={props.controller.openShell}
/>
</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>
<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>
{(control) => (
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseAgent")}
keybind={["Mod", "."]}
control={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 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>
</Show>
</div>
<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 when={state.mode === "normal"}>
<ComposerEditorAlternateDelivery
controller={props.controller}
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
/>
</div>
</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>
</form>
</div>
@@ -842,7 +808,11 @@ 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] disabled:opacity-50"
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%)",
}}
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
onClick={(event) => {
event.preventDefault()
+2 -2
View File
@@ -1,2 +1,2 @@
/** 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"
/** 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"
+2 -2
View File
@@ -1,6 +1,7 @@
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"
@@ -22,7 +23,6 @@ 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}>
<NewSessionWordmark />
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
<div class="mt-8 flex flex-col gap-8">
<Composer model={props.composer} />
<Show when={props.project.empty()}>
-43
View File
@@ -1,43 +0,0 @@
[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;
}
}
}
@@ -1,11 +0,0 @@
import { NewSessionWordmark } from "./wordmark"
export default {
title: "App/New Session/Wordmark",
id: "app-new-session-wordmark",
component: NewSessionWordmark,
}
export const Reveal = {
render: () => <NewSessionWordmark />,
}
-17
View File
@@ -1,17 +0,0 @@
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>
)
}
+3 -13
View File
@@ -676,7 +676,7 @@ export const dict = {
"session.error.incompatible.description":
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.moveRunning": "Move to background",
"session.background.moveRunning": "Move running work to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
"session.background.running": "Running work in background",
@@ -736,16 +736,6 @@ export const dict = {
"session.todo.expand": "Expand",
"session.todo.progress": "{{done}} of {{total}} todos completed",
"session.question.progress": "{{current}} of {{total}} questions",
"session.websearch.title": "Third-party web search",
"session.websearch.description": "Select the search provider agents use to search the web",
"session.websearch.provider": "Search provider",
"session.websearch.any": "Any",
"session.websearch.disable": "Dont use search",
"session.websearch.enable": "Enable",
"session.websearch.loadFailed": "Could not load search providers.",
"session.websearch.empty": "No search providers available.",
"session.websearch.failed": "Could not save your choice. Please try again.",
"session.websearch.retry": "Retry",
"session.question.minimize": "Minimize question",
"session.question.restore": "Restore question",
"session.question.pending.one": "{{count}} pending question",
@@ -1055,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": "Show project icon",
"settings.general.row.showProjectIcon.description": "Show project icon in session header",
"settings.general.row.showProjectIcon.title": "Project icon",
"settings.general.row.showProjectIcon.description": "Show the project icon in the 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,22 +1,19 @@
import { describe, expect, test } from "bun:test"
import { createRequestQueue } from "./request-queue"
function setup(input?: { limit?: number; stallMs?: number; headersTimeoutMs?: number }) {
const pending: Array<{ url: string; signal: AbortSignal; resolve: () => void }> = []
function setup(input?: { limit?: number; stallMs?: number }) {
const pending: Array<{ url: string; resolve: () => void }> = []
const logs: Array<{ message: string; data: Record<string, unknown> }> = []
let clock = 0
const queue = createRequestQueue({
limit: input?.limit ?? 2,
stallMs: input?.stallMs,
headersTimeoutMs: input?.headersTimeoutMs,
now: () => clock,
log: (message, data) => logs.push({ message, data }),
fetch: Object.assign(
(resource: RequestInfo | URL) =>
new Promise<Response>((resolve, reject) => {
const request = new Request(resource)
request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true })
pending.push({ url: request.url, signal: request.signal, resolve: () => resolve(new Response("ok")) })
new Promise<Response>((resolve) => {
pending.push({ url: new Request(resource).url, resolve: () => resolve(new Response("ok")) })
}),
{ preconnect() {} },
),
@@ -62,34 +59,6 @@ describe("createRequestQueue", () => {
expect(input.queue.inflight()).toBe(0)
})
test("a request the server never answers times out and frees its slot", async () => {
const input = setup({ limit: 1, headersTimeoutMs: 10 })
const dead = input.queue.fetch("http://server/api/dead")
const next = input.queue.fetch("http://server/api/next")
await input.settle()
expect(input.queue.queued()).toBe(1)
const error = await dead.catch((cause: unknown) => cause)
expect(error).toBeInstanceOf(DOMException)
expect((error as DOMException).name).toBe("TimeoutError")
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/dead", "/api/next"])
input.pending[1]!.resolve()
await expect(next).resolves.toBeInstanceOf(Response)
expect(input.queue.inflight()).toBe(0)
})
test("caller aborts still reach the underlying request", async () => {
const input = setup({ limit: 1 })
const controller = new AbortController()
const request = input.queue.fetch("http://server/api/slow", { signal: controller.signal })
await input.settle()
expect(input.pending[0]!.signal.aborted).toBe(false)
controller.abort()
expect(input.pending[0]!.signal.aborted).toBe(true)
await expect(request).rejects.toBeInstanceOf(DOMException)
expect(input.queue.inflight()).toBe(0)
})
test("a burst that drains promptly is not thrashing", async () => {
const input = setup({ stallMs: 5 })
const responses = Array.from({ length: 12 }, (_, index) => input.queue.fetch(`http://server/api/${index}`))
@@ -9,22 +9,15 @@ export const requestQueueLimit = 4
// for a slot indicates the server is not keeping up.
export const requestStallMs = 2_000
// A socket that dies while the device sleeps can leave fetch waiting for response headers until the
// OS gives up on TCP retransmits, which takes minutes. Bound that so a dead request frees its slot
// instead of wedging every later API call; the body may still stream for as long as it needs.
export const requestHeadersTimeoutMs = 60_000
export function createRequestQueue(input: {
fetch: typeof globalThis.fetch
limit?: number
stallMs?: number
headersTimeoutMs?: number
log?: (message: string, data: Record<string, unknown>) => void
now?: () => number
}) {
const limit = input.limit ?? requestQueueLimit
const stallMs = input.stallMs ?? requestStallMs
const headersTimeoutMs = input.headersTimeoutMs ?? requestHeadersTimeoutMs
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
const base = input.fetch
const now = input.now ?? Date.now
@@ -77,16 +70,7 @@ export function createRequestQueue(input: {
release(entry)
throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError")
}
const controller = new AbortController()
request.signal.addEventListener("abort", () => controller.abort(request.signal.reason), { once: true })
const timer = setTimeout(
() => controller.abort(new DOMException("Timed out waiting for the server to respond", "TimeoutError")),
headersTimeoutMs,
)
return base(new Request(request, { signal: controller.signal })).finally(() => {
clearTimeout(timer)
release(entry)
})
return base(request).finally(() => release(entry))
},
// Bun's fetch type carries preconnect; the browser never calls it.
{ preconnect: () => {} },
@@ -2,12 +2,11 @@ import { Show, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { SessionPermissionDock } from "@/session/requests/session-permission-dock"
import { SessionQuestionDock } from "@/session/requests/session-question-dock"
import { SessionWebSearchDock } from "@/session/requests/session-websearch-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller"
type SessionComposerRegionState = Pick<
SessionComposerRegionController["state"],
"questionRequest" | "websearch" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
"questionRequest" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
>
export type SessionComposerRegionViewController = Pick<
@@ -33,9 +32,6 @@ export function SessionComposerRegion(props: {
"md:max-w-[1000px] md:mx-auto": controller.centered(),
}}
>
<Show when={controller.state.websearch.request()}>
<SessionWebSearchDock model={controller.state.websearch} onSubmit={controller.onResponseSubmit} />
</Show>
<Show when={controller.state.questionRequest()} keyed>
{(request) => (
<div>
+23 -62
View File
@@ -24,11 +24,6 @@ import {
type FileTreeV2Node,
} from "@/session/files/file-tree-v2-model"
import { virtualScrollElement } from "@/session/files/virtual-scroll"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useOpenInApp } from "@/session/files/open-in-app"
import { OpenInAppContextMenuV2 } from "@/session/files/open-in-app-button"
import { resolveOpenInAppPath } from "@/session/files/open-in-app-path"
import { usePlatform } from "@/runtime/platform/platform"
export type { Kind } from "@/session/files/file-tree"
@@ -104,7 +99,7 @@ const FileTreeNodeV2 = (
{...rest}
>
{local.children}
<span data-slot="file-tree-v2-label" class="flex-1 shrink-0 text-start text-12-medium whitespace-nowrap">
<span class="flex-1 min-w-0 text-start text-12-medium whitespace-nowrap truncate">
<bdi dir="auto">
{local.node.type === "directory"
? normalizeFileTreeV2Path(local.node.path).split("/").at(-1)
@@ -141,9 +136,6 @@ export default function FileTreeV2(props: {
onFileDoubleClick?: (file: FileNode) => void
}) {
const file = useFile()
const location = useWorkspaceLocation()
const platform = usePlatform()
const openIn = platform.platform === "desktop" ? useOpenInApp({ path: () => location().directory }) : undefined
const live = () => props.allowed === undefined
const draggable = () => props.draggable ?? true
const active = () => normalizeFileTreeV2Path(props.active ?? "")
@@ -225,19 +217,6 @@ export default function FileTreeV2(props: {
)
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
createEffect(() => {
rows()
const element = root()
if (!element) return
element.style.removeProperty("width")
syncFileTreeV2Width(element)
})
createEffect(() => {
virtualRowKeys()
syncFileTreeV2Width(root())
})
return (
<div
ref={setRoot}
@@ -256,7 +235,6 @@ export default function FileTreeV2(props: {
top: "0",
"inset-inline-start": "0",
width: "100%",
"min-width": "max-content",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,
}}
@@ -266,36 +244,29 @@ export default function FileTreeV2(props: {
<Show
when={row().node.type === "directory"}
fallback={
<OpenInAppContextMenuV2
state={openIn}
path={() =>
resolveOpenInAppPath(location().directory, row().node.absolute || row().node.originalPath)
}
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
kinds={props.kinds}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() => selectFile(row().node, props.onFileClick)}
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
>
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
kinds={props.kinds}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() => selectFile(row().node, props.onFileClick)}
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
<div class="w-4 shrink-0" />
</Show>
<span class="filetree-iconpair size-4">
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</FileTreeNodeV2>
</OpenInAppContextMenuV2>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
<div class="w-4 shrink-0" />
</Show>
<span class="filetree-iconpair size-4">
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</FileTreeNodeV2>
}
>
<FileTreeNodeV2
@@ -332,13 +303,3 @@ export default function FileTreeV2(props: {
</div>
)
}
export function syncFileTreeV2Width(element?: HTMLDivElement) {
if (!element) return
queueMicrotask(() => {
if (!element.isConnected) return
const width = Math.max(element.clientWidth, ...Array.from(element.children, (child) => child.scrollWidth))
if (width <= element.clientWidth) return
element.style.width = `${width}px`
})
}
+34 -56
View File
@@ -2,15 +2,10 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import "@opencode-ai/ui/file-tree.css"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { kindChange, kindLabel, syncFileTreeV2Width, type Kind } from "@/session/files/file-tree-v2"
import { kindChange, kindLabel, type Kind } from "@/session/files/file-tree-v2"
import { normalizePath } from "@/session/review/review-diff-kinds"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { virtualScrollElement } from "@/session/files/virtual-scroll"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useOpenInApp } from "@/session/files/open-in-app"
import { OpenInAppContextMenuV2 } from "@/session/files/open-in-app-button"
import { resolveOpenInAppPath } from "@/session/files/open-in-app-path"
import { usePlatform } from "@/runtime/platform/platform"
// Drives the highlight/selection of the flat search-result list from the filter
// input's keyboard events.
@@ -54,9 +49,6 @@ export function SessionFileList(props: {
onFileClick: (path: string) => void
onFileDoubleClick?: (path: string) => void
}) {
const location = useWorkspaceLocation()
const platform = usePlatform()
const openIn = platform.platform === "desktop" ? useOpenInApp({ path: () => location().directory }) : undefined
const active = () => normalizePath(props.active ?? "")
const highlighted = () => normalizePath(props.highlighted ?? "")
const normalized = createMemo(() => props.files.map(normalizePath))
@@ -97,19 +89,6 @@ export function SessionFileList(props: {
)
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
createEffect(() => {
normalized()
const element = root()
if (!element) return
element.style.removeProperty("width")
syncFileTreeV2Width(element)
})
createEffect(() => {
virtualRowKeys()
syncFileTreeV2Width(root())
})
return (
<div
ref={setRoot}
@@ -135,48 +114,47 @@ export function SessionFileList(props: {
style={{
position: "absolute",
top: "0",
"inset-inline-start": "0",
left: "0",
width: "100%",
"min-width": "max-content",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,
}}
>
<OpenInAppContextMenuV2 state={openIn} path={() => resolveOpenInAppPath(location().directory, path)}>
<button
type="button"
id={props.optionID?.(path)}
role={props.role ? "option" : undefined}
aria-selected={props.role ? selected() : undefined}
data-slot="file-tree-v2-row"
data-path={path}
data-selected={selected() ? "" : undefined}
data-highlighted={highlightedRow() ? "" : undefined}
style="padding-inline-start: 8px"
onFocus={() => setFocused(path)}
onBlur={() => setFocused(undefined)}
onClick={() => props.onFileClick(path)}
onDblClick={() => props.onFileDoubleClick?.(path)}
>
<span class="filetree-iconpair size-4">
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
<span data-slot="file-tree-v2-label" class="flex flex-1 shrink-0 items-center whitespace-nowrap">
<Show when={directory()}>
{(value) => <span class="text-12-medium text-text-muted shrink-0">{value()}</span>}
</Show>
<span class="text-12-medium text-text-base shrink-0">{filename()}</span>
</span>
<Show when={kind()}>
<button
type="button"
id={props.optionID?.(path)}
role={props.role ? "option" : undefined}
aria-selected={props.role ? selected() : undefined}
data-slot="file-tree-v2-row"
data-path={path}
data-selected={selected() ? "" : undefined}
data-highlighted={highlightedRow() ? "" : undefined}
style="padding-left: 8px"
onFocus={() => setFocused(path)}
onBlur={() => setFocused(undefined)}
onClick={() => props.onFileClick(path)}
onDblClick={() => props.onFileDoubleClick?.(path)}
>
<span class="filetree-iconpair size-4">
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
<span class="flex min-w-0 flex-1 items-center overflow-hidden whitespace-nowrap">
<Show when={directory()}>
{(value) => (
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
{kindLabel(value())}
</span>
<span class="text-12-medium text-text-muted truncate min-w-0 shrink">{value()}</span>
)}
</Show>
</button>
</OpenInAppContextMenuV2>
<span class="text-12-medium text-text-base truncate min-w-0 shrink-0">{filename()}</span>
</span>
<Show when={kind()}>
{(value) => (
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
{kindLabel(value())}
</span>
)}
</Show>
</button>
</div>
)}
</Show>
@@ -1,4 +1,4 @@
import { createSignal, For, Show, type ParentProps } from "solid-js"
import { For, Show } from "solid-js"
import { AppIcon } from "@opencode-ai/ui/app-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
@@ -10,7 +10,7 @@ import { type OpenApp, useOpenInApp } from "@/session/files/open-in-app"
export function OpenInAppButton(props: { directory: () => string }) {
const language = useLanguage()
const state = useOpenInApp({ path: props.directory })
const state = useOpenInApp(props)
return (
<Show when={props.directory() && state.canOpen()}>
@@ -25,7 +25,7 @@ export function OpenInAppButton(props: { directory: () => string }) {
onClick={(event) => {
event.stopPropagation()
if (state.opening()) return
state.openPath(state.current().id)
state.openDir(state.current().id)
}}
disabled={state.opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
@@ -52,7 +52,42 @@ export function OpenInAppButton(props: { directory: () => string }) {
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="open-in-app-v2-menu">
<OpenInAppMenuItemsV2 state={state} close={() => state.setMenu("open", false)} />
<Menu.Group>
<Menu.GroupLabel>{language.t("session.header.openIn")}</Menu.GroupLabel>
<Menu.RadioGroup
value={state.current().id}
onChange={(value) => {
state.selectApp(value as OpenApp)
}}
>
<For each={state.options()}>
{(option) => (
<Menu.RadioItem
value={option.id}
disabled={state.opening()}
onSelect={() => {
state.selectApp(option.id)
state.setMenu("open", false)
state.openDir(option.id)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.Group>
<Menu.Separator />
<Menu.Item
onSelect={() => {
state.setMenu("open", false)
state.copyPath()
}}
>
<Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
@@ -60,116 +95,3 @@ export function OpenInAppButton(props: { directory: () => string }) {
</Show>
)
}
type OpenInAppState = ReturnType<typeof useOpenInApp>
function OpenInAppMenuItemsV2(props: {
state: OpenInAppState
path?: () => string
reveal?: boolean
selection?: boolean
close?: () => void
}) {
const language = useLanguage()
const path = () => props.path?.()
return (
<>
<Menu.Group>
<Menu.GroupLabel>{language.t("session.header.openIn")}</Menu.GroupLabel>
<Show
when={props.selection !== false}
fallback={
<For each={props.state.options()}>
{(option) => (
<Menu.Item
disabled={props.state.opening()}
onSelect={() => {
props.state.selectApp(option.id)
props.close?.()
props.state.openPath(option.id, path(), props.reveal)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.Item>
)}
</For>
}
>
<Menu.RadioGroup
value={props.state.current().id}
onChange={(value) => {
props.state.selectApp(value as OpenApp)
}}
>
<For each={props.state.options()}>
{(option) => (
<Menu.RadioItem
value={option.id}
closeOnSelect
disabled={props.state.opening()}
onSelect={() => {
props.state.selectApp(option.id)
props.close?.()
props.state.openPath(option.id, path(), props.reveal)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Show>
</Menu.Group>
<Menu.Separator />
<Menu.Item
onSelect={() => {
props.close?.()
props.state.copyPath(path())
}}
>
<Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")}
</Menu.Item>
</>
)
}
export function OpenInAppContextMenuV2(
props: ParentProps<{
state?: OpenInAppState
path: () => string
}>,
) {
const state = props.state
if (!state) return props.children
const [open, setOpen] = createSignal(false)
return (
<Show when={state.canOpen() && props.path()} fallback={props.children}>
<Menu.Context modal={false} onOpenChange={setOpen}>
<Menu.Context.Trigger
as="div"
class="h-full w-full min-w-max"
data-slot="file-tree-v2-context-trigger"
data-context-menu-open={open() ? "" : undefined}
>
{props.children}
</Menu.Context.Trigger>
<Menu.Context.Portal>
<Menu.Context.Content class="open-in-app-v2-menu">
<OpenInAppMenuItemsV2
state={state}
path={props.path}
reveal
selection={false}
close={() => setOpen(false)}
/>
</Menu.Context.Content>
</Menu.Context.Portal>
</Menu.Context>
</Show>
)
}
@@ -1,36 +0,0 @@
import { describe, expect, test } from "bun:test"
import { openInAppParentPath, resolveOpenInAppPath } from "./open-in-app-path"
describe("resolveOpenInAppPath", () => {
test("joins relative paths using the workspace separator", () => {
expect(resolveOpenInAppPath("/workspace/project", "src/file.ts")).toBe("/workspace/project/src/file.ts")
expect(resolveOpenInAppPath("C:\\workspace\\project", "src/file.ts")).toBe("C:\\workspace\\project\\src\\file.ts")
})
test("does not duplicate root separators", () => {
expect(resolveOpenInAppPath("/workspace/project/", "src/file.ts")).toBe("/workspace/project/src/file.ts")
expect(resolveOpenInAppPath("C:/workspace/project/", "src\\file.ts")).toBe("C:/workspace/project/src/file.ts")
})
test("preserves backslashes in POSIX filenames", () => {
expect(resolveOpenInAppPath("/workspace", "src\\file.ts")).toBe("/workspace/src\\file.ts")
expect(resolveOpenInAppPath("/workspace", "\\file.ts")).toBe("/workspace/\\file.ts")
})
test("preserves absolute POSIX, Windows, and UNC paths", () => {
expect(resolveOpenInAppPath("/workspace", "/tmp/file.ts")).toBe("/tmp/file.ts")
expect(resolveOpenInAppPath("C:/workspace", "D:\\src\\file.ts")).toBe("D:\\src\\file.ts")
expect(resolveOpenInAppPath("C:/workspace", "\\\\server\\share\\file.ts")).toBe("\\\\server\\share\\file.ts")
expect(resolveOpenInAppPath("C:/workspace", "\\src\\file.ts")).toBe("\\src\\file.ts")
})
})
describe("openInAppParentPath", () => {
test("preserves POSIX and Windows roots", () => {
expect(openInAppParentPath("/file.ts")).toBe("/")
expect(openInAppParentPath("/workspace/file.ts")).toBe("/workspace")
expect(openInAppParentPath("C:\\file.ts")).toBe("C:\\")
expect(openInAppParentPath("C:\\workspace\\file.ts")).toBe("C:\\workspace")
expect(openInAppParentPath("\\\\server\\share\\file.ts")).toBe("\\\\server\\share")
})
})
@@ -1,19 +0,0 @@
export function resolveOpenInAppPath(root: string, path: string) {
if (!path) return root
const windowsRoot = root.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(root)
if (path.startsWith("/") || (windowsRoot && path.startsWith("\\")) || /^[A-Za-z]:[\\/]/.test(path)) return path
if (!root) return path
const separator = root.includes("\\") ? "\\" : "/"
const relative = windowsRoot ? path.replace(/^[\\/]+/, "") : path
return `${root.replace(/[\\/]+$/, "")}${separator}${windowsRoot ? relative.replaceAll(separator === "\\" ? "/" : "\\", separator) : relative}`
}
export function openInAppParentPath(path: string) {
const value = path.replace(/[\\/]+$/, "")
const index = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"))
if (index < 0) return path
if (index === 0) return value.slice(0, 1)
if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3)
return value.slice(0, index)
}
+21 -30
View File
@@ -7,8 +7,6 @@ import { showToast } from "@/shell/notifications/toast"
import { useServer } from "@/runtime/server/current"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { fileManagerApp } from "@/home/projects/file-manager"
import { openInAppParentPath } from "@/session/files/open-in-app-path"
export const OPEN_APPS = [
"vscode",
@@ -34,8 +32,6 @@ export const OpenAppPreferences = Persistence.struct({
app: Schema.Literals(OPEN_APPS),
})
const appExistence = new Map<string, Promise<boolean>>()
export const MAC_OPEN_APPS = [
{
id: "vscode",
@@ -112,7 +108,9 @@ export function detectOpenAppOS(platform: ReturnType<typeof usePlatform>): OpenA
}
export function openAppFileManager(os: OpenAppOS) {
return fileManagerApp(os)
if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
return { label: "session.header.open.fileManager", icon: "finder" as const }
}
export function openAppsForOS(os: OpenAppOS) {
@@ -129,7 +127,7 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
})
}
export function useOpenInApp(input: { path: () => string }) {
export function useOpenInApp(input: { directory: () => string }) {
const platform = usePlatform()
const server = useServer()
const language = useLanguage()
@@ -151,7 +149,12 @@ export function useOpenInApp(input: { path: () => string }) {
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
void Promise.all(
list.map((app) => checkAppExists(platform, app.openWith).then((ok) => [app.id, ok] as const)),
list.map((app) =>
Promise.resolve(platform.checkAppExists?.(app.openWith))
.then((value) => Boolean(value))
.catch(() => false)
.then((ok) => [app.id, ok] as const),
),
).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
})
@@ -186,35 +189,33 @@ export function useOpenInApp(input: { path: () => string }) {
setPrefs("app", app)
}
const openPath = (app: OpenApp | "finder", target = input.path(), reveal = false) => {
const openDir = (app: OpenApp | "finder") => {
if (opening() || !canOpen() || !platform.openPath) return
if (!target) return
const directory = input.directory()
if (!directory) return
const open = (path: string, openWith?: string) => platform.openPath!(path, openWith)
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
setOpenRequest("app", app)
const request =
app === "finder" && reveal && platform.revealPath
? platform.revealPath(target).then((revealed) => (revealed ? undefined : open(openInAppParentPath(target))))
: open(target, openWith)
request
platform
.openPath(directory, openWith)
.catch((err: unknown) => showRequestError(language, err))
.finally(() => {
setOpenRequest("app", undefined)
})
}
const copyPath = (target = input.path()) => {
if (!target) return
const copyPath = () => {
const directory = input.directory()
if (!directory) return
navigator.clipboard
.writeText(target)
.writeText(directory)
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("common.copied"),
description: target,
description: directory,
})
})
.catch((err: unknown) => showRequestError(language, err))
@@ -227,18 +228,8 @@ export function useOpenInApp(input: { path: () => string }) {
options,
menu,
setMenu,
openPath,
openDir,
selectApp,
copyPath,
}
}
function checkAppExists(platform: ReturnType<typeof usePlatform>, app: string) {
const cached = appExistence.get(app)
if (cached) return cached
const request = Promise.resolve(platform.checkAppExists?.(app))
.then(Boolean)
.catch(() => false)
appExistence.set(app, request)
return request
}
+5 -35
View File
@@ -7,8 +7,7 @@ import { useServerSDK } from "@/runtime/server/client"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { sessionPermissionRequest, sessionFormRequest, sessionTreeIDs } from "@/session/requests/session-request-tree"
import { createWebSearchRequest } from "./websearch"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { createSessionBackground } from "@/session/requests/background"
import { useData } from "@/runtime/server/current"
@@ -25,40 +24,12 @@ export function createSessionRequestModel() {
void Promise.all([
data.shell.sync({ directory: sdk().directory }),
data.session.permission.sync(id),
data.session.form.sync(id),
]).catch(() => undefined)
})
createEffect(() => {
const id = params.id
if (!id || serverSDK.connection.status() !== "connected") return
void Promise.all(
sessionTreeIDs(data.session.list(), id).map((sessionID) => data.session.form.sync(sessionID)),
).catch(() => undefined)
})
const formRequest = createMemo((): FormInfo | undefined => {
return sessionFormRequest(data.session.list(), data.session.form.list, params.id)
})
const websearch = createWebSearchRequest({
owner: () => params.id,
connected: () => serverSDK.connection.status() === "connected",
request: () => {
const form = formRequest()
return form?.metadata?.kind === "websearch.provider" ? form : undefined
},
providers: async (sessionID) => {
const session = data.session.get(sessionID) ?? (await serverSDK.api.session.get({ sessionID }))
const result = await serverSDK.api.websearch.providers({
location: { directory: session.location.directory, workspace: session.location.workspaceID },
})
return result.data.map((provider) => ({ value: provider.id, label: provider.name }))
},
reply: (input) => data.session.form.reply(input),
events: serverSDK.event,
})
const questionRequest = createMemo(() => {
if (websearch.request()) return
const form = formRequest()
return form?.metadata?.kind === "question" ? form : undefined
const questionRequest = createMemo((): FormInfo | undefined => {
return sessionQuestionForm(data.session.list(), data.session.form.list, params.id)
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
@@ -69,7 +40,7 @@ export function createSessionRequestModel() {
const blocked = createMemo(() => {
const id = params.id
if (!id) return false
return !!permissionRequest() || !!questionRequest() || !!websearch.request()
return !!permissionRequest() || !!questionRequest()
})
const primary = () => {
@@ -125,7 +96,6 @@ export function createSessionRequestModel() {
return {
blocked,
questionRequest,
websearch,
permissionRequest,
permissionResponding,
background: {
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { sessionPermissionRequest, sessionFormRequest, sessionTreeIDs } from "@/session/requests/session-request-tree"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
const session = (input: { id: string; parentID?: string }) =>
({
@@ -23,22 +23,6 @@ const question = (id: string, sessionID: string) =>
fields: [{ key: "q0", type: "string" }],
}) as FormInfo
describe("sessionTreeIDs", () => {
test("returns only the current session and its descendants", () => {
const sessions = [
session({ id: "root" }),
session({ id: "child", parentID: "root" }),
session({ id: "grand", parentID: "child" }),
session({ id: "sibling", parentID: "root" }),
session({ id: "other" }),
]
expect(sessionTreeIDs(sessions, "child")).toEqual(["child", "grand"])
expect(sessionTreeIDs(sessions, "root")).toEqual(["root", "child", "sibling", "grand"])
expect(sessionTreeIDs(sessions)).toEqual([])
})
})
describe("sessionPermissionRequest", () => {
test("prefers the current session permission", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
@@ -97,7 +81,7 @@ describe("sessionPermissionRequest", () => {
})
})
describe("sessionFormRequest", () => {
describe("sessionQuestionForm", () => {
test("prefers the current session question", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const questions = {
@@ -105,7 +89,7 @@ describe("sessionFormRequest", () => {
child: [question("q-child", "child")],
}
expect(sessionFormRequest(sessions, questions, "root")?.id).toBe("q-root")
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-root")
})
test("returns a nested child question", () => {
@@ -118,29 +102,15 @@ describe("sessionFormRequest", () => {
grand: [question("q-grand", "grand")],
}
expect(sessionFormRequest(sessions, questions, "root")?.id).toBe("q-grand")
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-grand")
})
test("skips unsupported forms", () => {
test("skips forms that are not questions", () => {
const sessions = [session({ id: "root" })]
const forms = {
root: [{ ...question("form", "root"), metadata: { kind: "integration" } }],
}
expect(sessionFormRequest(sessions, forms, "root")).toBeUndefined()
})
test("finds web search consent in a nested child session", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const form = { ...question("search", "child"), metadata: { kind: "websearch.provider" } }
expect(sessionFormRequest(sessions, { child: [form] }, "root")).toBe(form)
})
test("preserves request order across questions and web search", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const form = { ...question("search", "root"), metadata: { kind: "websearch.provider" } }
expect(sessionFormRequest(sessions, { root: [form, question("q", "root")] }, "root")).toBe(form)
expect(sessionFormRequest(sessions, { root: [question("q", "root"), form] }, "root")?.id).toBe("q")
expect(sessionFormRequest(sessions, { root: [form], child: [question("q", "child")] }, "root")).toBe(form)
expect(sessionQuestionForm(sessions, forms, "root")).toBeUndefined()
})
})
@@ -6,16 +6,8 @@ function sessionTreeRequest<T>(
sessionID?: string,
include: (item: T) => boolean = () => true,
) {
const ids = sessionTreeIDs(session, sessionID)
if (!ids.length) return
const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
const id = ids.find((id) => list(id)?.some(include))
if (!id) return
return list(id)?.find(include)
}
if (!sessionID) return
export function sessionTreeIDs(session: SessionInfo[], sessionID?: string) {
if (!sessionID) return []
const map = session.reduce((acc, item) => {
if (!item.parentID) return acc
const list = acc.get(item.parentID)
@@ -35,7 +27,11 @@ export function sessionTreeIDs(session: SessionInfo[], sessionID?: string) {
ids.push(child)
}
}
return ids
const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
const id = ids.find((id) => list(id)?.some(include))
if (!id) return
return list(id)?.find(include)
}
export function sessionPermissionRequest(
@@ -47,15 +43,10 @@ export function sessionPermissionRequest(
return sessionTreeRequest(session, request, sessionID, include)
}
export function sessionFormRequest(
export function sessionQuestionForm(
session: SessionInfo[],
request: Record<string, FormInfo[] | undefined> | ((sessionID: string) => FormInfo[] | undefined),
sessionID?: string,
) {
return sessionTreeRequest(
session,
request,
sessionID,
(item) => item.metadata?.kind === "question" || item.metadata?.kind === "websearch.provider",
)
return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question")
}
@@ -1,58 +0,0 @@
[data-component="session-websearch-dock"] {
width: 100%;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 12px;
.websearch-body {
box-shadow: var(--v2-elevation-raised);
}
.websearch-setting {
padding: 16px;
}
.websearch-setting [data-component="settings-row"] {
column-gap: 24px;
padding-block: 0;
border: 0;
}
.websearch-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
padding: 24px 16px 12px;
border: 0;
}
.websearch-status {
display: flex;
align-items: center;
gap: 8px;
margin-inline-end: auto;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-compact);
}
.websearch-status:empty {
display: none;
}
.websearch-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
}
}
[data-component="menu-v2-content"][data-slot="select-v2-content"].websearch-provider-menu {
width: max-content;
min-width: 0;
[data-component="menu-v2-item"] {
gap: 24px;
}
}
@@ -1,86 +0,0 @@
import { createMemo, Show } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { DockShell, DockTray } from "@opencode-ai/ui/dock-surface"
import { Select } from "@opencode-ai/ui/select"
import { useLanguage } from "@/runtime/i18n/language"
import { SettingsRow } from "@/settings/row"
import type { WebSearchRequestModel } from "./websearch"
import "./session-websearch-dock.css"
export function SessionWebSearchDock(props: { model: WebSearchRequestModel; onSubmit: () => void }) {
const language = useLanguage()
const options = createMemo(() => [
...(props.model.specific() ? [] : [{ value: "random", label: language.t("session.websearch.any") }]),
...props.model.options(),
])
const current = createMemo(() => options().find((option) => option.value === props.model.selected()))
const busy = () => props.model.sending() || !props.model.connected()
const status = () => {
if (props.model.loading()) return language.t("common.loading")
if (props.model.failed()) return language.t("session.websearch.failed")
if (!props.model.options().length) return language.t("session.websearch.empty")
}
const unavailable = () => props.model.loading() || props.model.loadFailed() || !props.model.options().length
const submit = (selection: string | false) => {
if (busy()) return
props.onSubmit()
void props.model.submit(selection)
}
return (
<section
data-component="session-websearch-dock"
aria-label={language.t("session.websearch.title")}
aria-busy={props.model.sending()}
>
<DockShell class="websearch-body">
<div class="websearch-setting">
<SettingsRow
title={language.t("session.websearch.title")}
description={language.t("session.websearch.description")}
>
<Select
aria-label={language.t("session.websearch.provider")}
options={options()}
current={current()}
value={(option) => option.value}
label={(option) => option.label}
onSelect={(option) => option && props.model.select(option.value)}
disabled={busy() || unavailable()}
placeholder={language.t("session.websearch.provider")}
contentClass="websearch-provider-menu"
/>
</SettingsRow>
</div>
</DockShell>
<DockTray attach="top" class="websearch-footer">
<div class="websearch-status" aria-live="polite">
<Show when={props.model.loadFailed()} fallback={status()}>
<span>{language.t("session.websearch.loadFailed")}</span>
<Button variant="ghost" size="small" onClick={props.model.retry} disabled={busy()}>
{language.t("session.websearch.retry")}
</Button>
</Show>
</div>
<div class="websearch-actions">
<Show when={!props.model.specific()}>
<Button variant="ghost" size="small" onClick={() => submit(false)} disabled={busy()}>
{language.t("session.websearch.disable")}
</Button>
</Show>
<Button
variant="neutral"
size="small"
onClick={() => {
const selected = props.model.selected()
if (selected) submit(selected)
}}
disabled={busy() || unavailable() || !current()}
>
{language.t("session.websearch.enable")}
</Button>
</div>
</DockTray>
</section>
)
}
@@ -1,171 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { FormAnswer, FormCreated, FormReplyInput, OpenCodeEvent } from "@opencode-ai/client/promise"
import { replyWebSearch } from "./websearch"
const consent: FormCreated["data"]["form"] = {
id: "frm_consent",
sessionID: "ses_child",
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [{ key: "choice", type: "string", required: true, custom: false }],
}
const provider: FormCreated["data"]["form"] = {
...consent,
id: "frm_provider",
fields: [
{
key: "provider",
type: "string",
required: true,
custom: false,
options: [
{ value: "exa", label: "Exa" },
{ value: "parallel", label: "Parallel" },
],
},
],
}
function fixture() {
const listeners = new Set<(event: OpenCodeEvent) => void>()
const replies: FormReplyInput[] = []
const abort = new AbortController()
const emit = (event: OpenCodeEvent) => listeners.forEach((listener) => listener(event))
return {
form: consent,
signal: abort.signal,
abort,
listeners,
replies,
events: {
listen(listener: (event: OpenCodeEvent) => void) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
},
reply: async (input: FormReplyInput) => {
replies.push(input)
},
create: (form = provider) => emit({ id: "evt_create", created: 0, type: "form.created", data: { form } }),
cancel: (id: string) =>
emit({
id: "evt_cancel",
created: 0,
type: "form.cancelled",
data: { id, sessionID: consent.sessionID },
}),
answer: (id: string, answer: FormAnswer) =>
emit({
id: "evt_reply",
created: 0,
type: "form.replied",
data: { id, sessionID: consent.sessionID, answer },
}),
}
}
describe("web search desktop consent", () => {
test.each([
["random", "allow"],
[false, "disable"],
] as const)("submits %s without a second form", async (selection, choice) => {
const input = fixture()
await replyWebSearch({ ...input, selection })
expect(input.replies).toEqual([{ sessionID: consent.sessionID, formID: consent.id, answer: { choice } }])
expect(input.listeners.size).toBe(0)
})
test("subscribes before the first reply and answers the owning session's provider form", async () => {
const input = fixture()
await replyWebSearch({
...input,
selection: "parallel",
reply: async (answer) => {
input.replies.push(answer)
if (answer.answer.choice === "choose") input.create()
},
})
expect(input.replies).toEqual([
{ sessionID: consent.sessionID, formID: consent.id, answer: { choice: "choose" } },
{ sessionID: consent.sessionID, formID: provider.id, answer: { provider: "parallel" } },
])
expect(input.listeners.size).toBe(0)
})
test("ignores questions, other sessions, and repeated consent forms during handoff", async () => {
const input = fixture()
const pending = replyWebSearch({ ...input, selection: "exa" })
input.create({ ...provider, sessionID: "ses_other" })
input.create({ ...provider, metadata: { kind: "question" } })
input.create(consent)
input.create()
await pending
expect(input.replies).toHaveLength(2)
expect(input.replies[1]?.formID).toBe(provider.id)
})
test("leaves a changed provider list for explicit confirmation", async () => {
const input = fixture()
const pending = replyWebSearch({ ...input, selection: "removed" })
input.create()
await pending
expect(input.replies).toHaveLength(1)
expect(input.listeners.size).toBe(0)
})
test("supports direct entry into the provider form", async () => {
const input = fixture()
await replyWebSearch({ ...input, form: provider, selection: "exa" })
expect(input.replies).toEqual([{ sessionID: consent.sessionID, formID: provider.id, answer: { provider: "exa" } }])
expect(input.listeners.size).toBe(0)
})
test("does not misrepresent cancelling the provider form as disabling search", async () => {
const input = fixture()
await replyWebSearch({ ...input, form: provider, selection: false })
expect(input.replies).toEqual([])
})
test.each(["cancel", "other-client", "abort"])("ends a pending handoff on %s", async (action) => {
const input = fixture()
const pending = replyWebSearch({ ...input, selection: "exa" })
if (action === "cancel") input.cancel(consent.id)
if (action === "other-client") input.answer(consent.id, { choice: "disable" })
if (action === "abort") input.abort.abort()
await pending
input.create()
expect(input.replies).toHaveLength(1)
expect(input.listeners.size).toBe(0)
})
test("cleans up after a failed consent submission", async () => {
const input = fixture()
await expect(
replyWebSearch({
...input,
selection: "exa",
reply: async () => {
throw new Error("offline")
},
}),
).rejects.toThrow("offline")
expect(input.listeners.size).toBe(0)
})
test("propagates provider submission failures for retry", async () => {
const input = fixture()
await expect(
replyWebSearch({
...input,
selection: "exa",
reply: async (answer) => {
if (answer.formID === provider.id) throw new Error("offline")
input.create()
},
}),
).rejects.toThrow("offline")
expect(input.listeners.size).toBe(0)
})
})
@@ -1,154 +0,0 @@
import type { FormInfo, FormOption, FormReplyInput, FormStringField } from "@opencode-ai/client/promise"
import { createEffect, createMemo, createResource, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { OpenCodeEventStream } from "@/runtime/server/client"
export function webSearchProviderField(form: FormInfo) {
return form.fields.find(
(field): field is FormStringField => field.type === "string" && field.key === "provider" && !!field.options,
)
}
export function createWebSearchRequest(input: {
owner: () => string | undefined
connected: () => boolean
request: () => FormInfo | undefined
providers: (sessionID: string) => Promise<FormOption[]>
reply: (input: FormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
const [store, setStore] = createStore({
selected: "random",
sending: undefined as { form: FormInfo; abort: AbortController } | undefined,
error: false,
})
const [providers, resource] = createResource(input.request, async (form) => {
const field = webSearchProviderField(form)
if (field) return field.options ?? []
return input.providers(form.sessionID)
})
const request = createMemo(() => store.sending?.form ?? input.request())
const specific = createMemo(() => {
const form = request()
return !!form && !!webSearchProviderField(form)
})
const options = createMemo(() => (providers.error ? [] : (providers() ?? [])))
const selected = createMemo(() => {
if (!specific()) return store.selected
return options().some((option) => option.value === store.selected) ? store.selected : options()[0]?.value
})
createEffect(
on([input.owner, input.connected], () => {
store.sending?.abort.abort()
setStore({ sending: undefined, selected: "random", error: false })
}),
)
createEffect(
on(
() => input.request()?.id,
() => {
const form = input.request()
if (!form || store.sending || webSearchProviderField(form)) return
setStore({ selected: "random", error: false })
},
),
)
onCleanup(() => store.sending?.abort.abort())
const submit = async (selection: string | false) => {
const form = input.request()
if (!form || store.sending || !input.connected()) return
if (selection === false && webSearchProviderField(form)) return
const sending = { form, abort: new AbortController() }
setStore({ sending, error: false })
await replyWebSearch({ ...input, form, selection, signal: sending.abort.signal })
.catch(() => {
if (!sending.abort.signal.aborted) setStore("error", true)
})
.finally(() => {
if (store.sending?.abort === sending.abort) setStore("sending", undefined)
})
}
return {
request,
options,
selected,
specific,
loading: () => providers.loading,
loadFailed: () => !!providers.error,
failed: () => store.error,
sending: () => !!store.sending,
connected: input.connected,
select: (value: string) => setStore({ selected: value, error: false }),
retry: () => void resource.refetch(),
submit,
}
}
export type WebSearchRequestModel = ReturnType<typeof createWebSearchRequest>
export async function replyWebSearch(input: {
form: FormInfo
selection: string | false
signal: AbortSignal
reply: (input: FormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
if (input.signal.aborted) return
if (webSearchProviderField(input.form)) {
if (input.selection === false) return
return input.reply({
sessionID: input.form.sessionID,
formID: input.form.id,
answer: { provider: input.selection },
})
}
if (input.selection === false || input.selection === "random") {
return input.reply({
sessionID: input.form.sessionID,
formID: input.form.id,
answer: { choice: input.selection === false ? "disable" : "allow" },
})
}
const next = Promise.withResolvers<FormInfo | undefined>()
const stop = input.events.listen((event) => {
if (event.type === "form.created") {
const form = event.data.form
if (
form.sessionID !== input.form.sessionID ||
form.id === input.form.id ||
form.metadata?.kind !== "websearch.provider" ||
!webSearchProviderField(form)
)
return
next.resolve(form)
}
if (event.type === "form.cancelled" && event.data.id === input.form.id) next.resolve(undefined)
if (event.type === "form.replied" && event.data.id === input.form.id && event.data.answer.choice !== "choose")
next.resolve(undefined)
})
const cancel = () => next.resolve(undefined)
input.signal.addEventListener("abort", cancel, { once: true })
return Promise.all([
input.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: { choice: "choose" } }),
next.promise,
])
.then(([, form]) => {
if (!form || input.signal.aborted) return
const field = webSearchProviderField(form)
if (!field?.options?.some((option) => option.value === input.selection)) return
return input.reply({
sessionID: form.sessionID,
formID: form.id,
answer: { provider: input.selection },
})
})
.finally(() => {
stop()
input.signal.removeEventListener("abort", cancel)
})
}
@@ -2,54 +2,30 @@ import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createSessionResolution } from "./session-resolution"
function store() {
const syncs = { session: 0, message: 0, pending: 0 }
const sessions = {
get: () => undefined,
sync: () => {
syncs.session++
return Promise.resolve()
},
message: {
sync: () => {
syncs.message++
return Promise.resolve()
},
},
pending: {
sync: () => {
syncs.pending++
return Promise.resolve()
},
},
}
return { syncs, sessions }
}
describe("session resolution", () => {
test("waits for a route session ID", () => {
createRoot((dispose) => {
const input = store()
const syncs = { session: 0, message: 0 }
const sessions = {
get: () => undefined,
sync: () => {
syncs.session++
return Promise.resolve()
},
message: {
sync: () => {
syncs.message++
return Promise.resolve()
},
},
}
const session = createSessionResolution(
() => undefined,
() => input.sessions,
() => sessions,
)
expect(session()).toBeUndefined()
expect(input.syncs).toEqual({ session: 0, message: 0, pending: 0 })
dispose()
})
})
test("starts the transcript and queued input reads with metadata", () => {
createRoot((dispose) => {
const input = store()
createSessionResolution(
() => "ses_open",
() => input.sessions,
{ children: true },
)
expect(input.syncs).toEqual({ session: 1, message: 1, pending: 1 })
expect(syncs).toEqual({ session: 0, message: 0 })
dispose()
})
})
@@ -7,9 +7,6 @@ type SessionStore<T> = {
message: {
sync: (id: string) => Promise<unknown>
}
pending: {
sync: (id: string) => Promise<unknown>
}
}
type Resolution<T> = { id: string; store: SessionStore<T> } & (
@@ -55,11 +52,8 @@ export function createSessionResolution<T>(
onCleanup(() => {
stale = true
})
// The timeline owns message errors; metadata resolution stays independent. Queued inputs
// ride along so a reconnect refreshes them with the transcript instead of leaving the
// pre-disconnect queue on screen.
// The timeline owns message errors; metadata resolution stays independent.
void store.message.sync(id).catch(() => undefined)
void store.pending.sync(id).catch(() => undefined)
if (cached() && !options?.children && !options?.connected) {
setStatus({ id, store, state: "settled" })
return
@@ -138,26 +138,6 @@ export const QuestionRequest = {
),
}
export const WebSearchRequest = {
render: () => (
<SessionPreview
title="Search for current documentation"
description={description}
document={questionPendingDocument}
request={{
type: "websearch",
value: {
id: "frm_websearch_preview",
sessionID: "ses_websearch_preview",
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [{ key: "choice", type: "string", required: true, custom: false }],
},
}}
/>
),
}
export const RetryAndInterruption = {
render: () => (
<SessionPreview title="Recover the interrupted run" description={description} document={retryAfterInterruption} />
+1 -25
View File
@@ -23,7 +23,6 @@ import { useLanguage } from "@/runtime/i18n/language"
import { ReviewPanelView } from "@/session/review/panel"
import { createReviewPanelState } from "@/session/review/panel-state"
import { TerminalSurface } from "@/session/terminal/surface"
import type { WebSearchRequestModel } from "./requests/websearch"
const modelReady = Object.assign(() => true, { promise: undefined }) satisfies ModelSelection["ready"]
const storyComposerModel = {
@@ -83,7 +82,7 @@ export type SessionPreviewProps = {
description: string
document: SessionDocument
draft?: string
request?: { type: "permission"; value: PermissionRequest } | { type: "question" | "websearch"; value: FormInfo }
request?: { type: "permission"; value: PermissionRequest } | { type: "question"; value: FormInfo }
reviewOpened?: boolean
child?: { parentID: string }
terminal?: { title: string; lines: string[] }
@@ -174,12 +173,10 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
activity: string
reviewOpened: boolean
request: SessionPreviewProps["request"]
searchProvider: string
}>({
activity: "Ready",
reviewOpened: props.reviewOpened ?? false,
request: props.request,
searchProvider: "random",
})
const prompt = createPromptController({
initial: props.draft ?? "",
@@ -192,27 +189,6 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
const region = {
state: {
questionRequest: () => (state.request?.type === "question" ? state.request.value : undefined),
websearch: {
request: () => (state.request?.type === "websearch" ? state.request.value : undefined),
options: () => [
{ value: "exa", label: "Exa" },
{ value: "parallel", label: "Parallel" },
{ value: "tavily", label: "Tavily" },
],
selected: () => state.searchProvider,
specific: () => false,
loading: () => false,
loadFailed: () => false,
failed: () => false,
sending: () => false,
connected: () => true,
select: (value) => setState("searchProvider", value),
retry() {},
submit: async (value) => {
setState("request", undefined)
setState("activity", `Web search selection (local only): ${value}`)
},
} satisfies WebSearchRequestModel,
permissionRequest: () => (state.request?.type === "permission" ? state.request.value : undefined),
permissionResponding: () => false,
decide: (response) => {
@@ -58,6 +58,7 @@ export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => v
type="button"
variant="ghost-faint"
size="small"
icon="outline-arrow-to-corner-top-right"
class="max-w-full"
aria-label={language.t("session.background.moveInline", { keybind: keybind() })}
onClick={() => props.onMove?.()}
@@ -1,4 +1,4 @@
import { Component, Show } from "solid-js"
import { Component } from "solid-js"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/runtime/i18n/language"
@@ -61,22 +61,6 @@ 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,6 +348,20 @@ 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")}
@@ -1,6 +1,6 @@
import { createMemo, type Accessor } from "solid-js"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { sessionPermissionRequest, sessionFormRequest } from "@/session/requests/session-request-tree"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { ServerConnection } from "@/runtime/server/registry"
import { useSettings } from "@/settings/model"
@@ -29,12 +29,12 @@ export function useSessionTabAvatarState(
if (!ctx) return false
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId())
})
const hasForms = createMemo(() => {
const hasQuestions = createMemo(() => {
const data = serverCtx()?.data
if (!data) return false
return !!sessionFormRequest(sessions(), data.session.form.list, sessionId())
return !!sessionQuestionForm(sessions(), data.session.form.list, sessionId())
})
const needsAttention = createMemo(() => hasPermissions() || hasForms())
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
const unread = createMemo(
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
)
@@ -21,19 +21,6 @@ describe("serverStatusDotClass", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
})
test("pulses the neutral dot while the event stream is reconnecting", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false, connecting: true })).toBe(
"bg-border-weak-base animate-pulse",
)
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false, connecting: true })).toBe(
"bg-border-weak-base animate-pulse",
)
// A server that is known to be down stays critical rather than looking like a routine reconnect.
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false, connecting: true })).toBe(
"bg-icon-critical-base",
)
})
test("stays neutral before status is ready", () => {
expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
@@ -20,12 +20,8 @@ export function serverStatusDotClass(input: {
serverHealth: boolean | undefined
attention?: boolean
issue: boolean
connecting?: boolean
}) {
if (input.serverHealth === false) return "bg-icon-critical-base"
// The event stream is (re)connecting: keep the neutral dot but let it breathe so a stale
// session is visibly waiting on the server rather than silently frozen.
if (input.connecting) return "bg-border-weak-base animate-pulse"
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
if (input.attention) return "bg-v2-background-bg-accent"
if (input.issue) return "bg-icon-warning-base"
@@ -20,7 +20,6 @@ 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 })
@@ -42,10 +41,8 @@ export function StatusPopover() {
serverHealth: serverHealth(),
attention: attention(),
issue: issue(),
connecting: server.ctx.sdk.connection.status() !== "connected",
sidebar: sidebar(),
placement: sidebar() ? "top-start" : "bottom-end",
shift: sidebar() ? 0 : -168,
placement: desktop() && settings.appearance.tabLayout() === "vertical" ? "top-start" : "bottom-end",
shift: desktop() && settings.appearance.tabLayout() === "vertical" ? 0 : -168,
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
@@ -64,8 +61,6 @@ type StatusPopoverState = {
serverHealth: boolean | undefined
attention: boolean
issue: boolean
connecting: boolean
sidebar: boolean
placement: "top-start" | "bottom-end"
shift: number
label: string
@@ -98,37 +93,21 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
<Popover
open={props.state.shown}
onOpenChange={props.state.onOpenChange}
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,
}
}
triggerAs={IconButton}
triggerProps={{
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 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>
</>
<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>
}
{...popoverProps}
>
@@ -38,15 +38,9 @@ export function createTitlebarRightSlot(): TitlebarRightSlot {
}
}
export function TitlebarRightMount(props: { vertical?: boolean }) {
export function TitlebarRightMount() {
const slot = useTitlebarRightSlot()
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"}
/>
)
return <div ref={slot.setMount} id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
}
export function TitlebarRight(props: ParentProps) {
+14 -2
View File
@@ -680,8 +680,20 @@ export function Titlebar(props: {
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
</div>
<div data-slot="vertical-tabs-footer" class="mt-2 flex w-full shrink-0 flex-col">
<TitlebarRightMount vertical />
<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>
</Portal>
)}
@@ -17,11 +17,9 @@ function createFixture(initial: Record<string, Session> = {}) {
const deferred = new Map<string, PromiseWithResolvers<unknown>>()
const resolves: string[] = []
const messages = { syncs: [] as string[], ...Promise.withResolvers<unknown>() }
const pending = { syncs: [] as string[] }
return {
resolves,
messages,
pending,
sessions: {
get: (id: string) => cache()[id],
sync: (id: string) => {
@@ -36,12 +34,6 @@ function createFixture(initial: Record<string, Session> = {}) {
return messages.promise
},
},
pending: {
sync: (id: string) => {
pending.syncs.push(id)
return Promise.resolve()
},
},
},
settle(id: string, directory = `/dir/${id}`) {
setCache({ ...cache(), [id]: { id, directory } })
@@ -94,7 +86,6 @@ test("refreshes the current session on reconnect while keeping cached content vi
expect(fixture.resolves).toEqual(["ses_a", "ses_a"])
expect(current()).toEqual(sessionOf("ses_a"))
expect(fixture.messages.syncs).toEqual(["ses_a", "ses_a"])
expect(fixture.pending.syncs).toEqual(["ses_a", "ses_a"])
fixture.settle("ses_a", "/worktrees/moved")
await flush()
expect(current()?.directory).toBe("/worktrees/moved")
@@ -1,209 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { FormCreated, FormReplyInput, OpenCodeEvent } from "@opencode-ai/client/promise"
import { createEffect, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createWebSearchRequest } from "@/session/requests/websearch"
const consent: FormCreated["data"]["form"] = {
id: "frm_consent",
sessionID: "ses_child",
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [{ key: "choice", type: "string", required: true, custom: false }],
}
const options = [
{ value: "exa", label: "Exa" },
{ value: "parallel", label: "Parallel" },
]
const provider: FormCreated["data"]["form"] = {
...consent,
id: "frm_provider",
fields: [{ key: "provider", type: "string", options, required: true, custom: false }],
}
const cleanups: VoidFunction[] = []
afterEach(() => cleanups.splice(0).forEach((dispose) => dispose()))
function ready(condition: () => boolean) {
return new Promise<void>((resolve) => {
createRoot((dispose) => {
cleanups.push(dispose)
createEffect(() => {
if (!condition()) return
dispose()
resolve()
})
})
})
}
function fixture(form: FormCreated["data"]["form"] | null = consent) {
return createRoot((dispose) => {
cleanups.push(dispose)
const listeners = new Set<(event: OpenCodeEvent) => void>()
const replies: FormReplyInput[] = []
const loads: string[] = []
const [state, setState] = createStore({
request: form ?? undefined,
owner: "ses_root",
connected: true,
loadFails: false,
replyFails: false,
})
const model = createWebSearchRequest({
owner: () => state.owner,
connected: () => state.connected,
request: () => state.request,
providers: async (sessionID) => {
loads.push(sessionID)
if (state.loadFails) throw new Error("offline")
return options
},
reply: async (input) => {
replies.push(input)
if (state.replyFails) throw new Error("offline")
setState("request", undefined)
},
events: {
listen(listener) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
},
})
return {
model,
state,
setState,
replies,
loads,
dispose,
listeners,
create(form = provider) {
setState("request", form)
listeners.forEach((listener) =>
listener({
id: "evt_create",
created: 0,
type: "form.created",
data: { form },
}),
)
},
cancel(id: string) {
setState("request", undefined)
listeners.forEach((listener) =>
listener({
id: "evt_cancel",
created: 0,
type: "form.cancelled",
data: { id, sessionID: consent.sessionID },
}),
)
},
}
})
}
describe("web search request state", () => {
test("loads providers for the form owner, not the viewed parent, and waits for consent", async () => {
const input = fixture()
await ready(() => !input.model.loading())
expect(input.loads).toEqual(["ses_child"])
expect(input.model.selected()).toBe("random")
input.model.select("exa")
expect(input.replies).toEqual([])
await input.model.submit(false)
expect(input.replies[0]?.answer).toEqual({ choice: "disable" })
expect(input.model.request()).toBeUndefined()
expect(input.model.sending()).toBe(false)
})
test("does not load providers when there is no consent request", () => {
const input = fixture(null)
expect(input.model.request()).toBeUndefined()
expect(input.loads).toEqual([])
})
test("holds the card across both forms and prevents duplicate submissions", async () => {
const input = fixture()
await ready(() => !input.model.loading())
input.model.select("parallel")
const pending = input.model.submit("parallel")
await input.model.submit("parallel")
expect(input.state.request).toBeUndefined()
expect(input.model.request()?.id).toBe(consent.id)
expect(input.model.sending()).toBe(true)
input.create()
await pending
expect(input.replies.map((reply) => reply.answer)).toEqual([{ choice: "choose" }, { provider: "parallel" }])
expect(input.model.request()).toBeUndefined()
expect(input.model.sending()).toBe(false)
expect(input.listeners.size).toBe(0)
})
test("direct provider requests need confirmation and do not load a different provider list", async () => {
const input = fixture(provider)
await ready(() => !input.model.loading())
expect(input.loads).toEqual([])
expect(input.model.specific()).toBe(true)
expect(input.model.options()).toEqual(options)
expect(input.replies).toEqual([])
await input.model.submit("exa")
expect(input.replies.map((reply) => reply.answer)).toEqual([{ provider: "exa" }])
})
test("keeps the selected provider after failure and retries only the provider form", async () => {
const input = fixture()
await ready(() => !input.model.loading())
input.model.select("parallel")
const pending = input.model.submit("parallel")
input.setState("replyFails", true)
input.create()
await pending
expect(input.model.failed()).toBe(true)
expect(input.model.sending()).toBe(false)
expect(input.model.request()?.id).toBe(provider.id)
expect(input.model.selected()).toBe("parallel")
input.setState("replyFails", false)
await input.model.submit("parallel")
expect(input.replies.map((reply) => reply.answer)).toEqual([
{ choice: "choose" },
{ provider: "parallel" },
{ provider: "parallel" },
])
expect(input.model.request()).toBeUndefined()
})
test("can retry loading providers without submitting consent", async () => {
const input = fixture()
await ready(() => !input.model.loading())
input.setState("loadFails", true)
input.model.retry()
await ready(() => input.model.loadFailed())
expect(input.model.options()).toEqual([])
input.setState("loadFails", false)
input.model.retry()
await ready(() => !input.model.loading())
expect(input.model.options()).toEqual(options)
expect(input.model.loadFailed()).toBe(false)
expect(input.replies).toEqual([])
})
test.each(["navigate", "disconnect", "dispose", "cancel"])("stops automatic replies on %s", async (action) => {
const input = fixture()
await ready(() => !input.model.loading())
const pending = input.model.submit("exa")
if (action === "navigate") input.setState("owner", "ses_other")
if (action === "disconnect") input.setState("connected", false)
if (action === "dispose") input.dispose()
if (action === "cancel") input.cancel(consent.id)
await pending
expect(input.model.sending()).toBe(false)
expect(input.listeners.size).toBe(0)
input.create()
expect(input.replies.map((reply) => reply.answer)).toEqual([{ choice: "choose" }])
})
})
@@ -17,8 +17,8 @@ import {
} from "./shared"
const integrationPriority = new Map([
["opencode-go", 0],
["opencode", 1],
["opencode", 0],
["opencode-go", 1],
["openai", 2],
["github-copilot", 3],
["google", 4],
@@ -18,9 +18,7 @@ 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.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go")),
),
Effect.map((response) => response.data),
)
})
-30
View File
@@ -68,36 +68,6 @@ 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) => {
+1 -1
View File
@@ -9,7 +9,7 @@ export type { ClientOptions, RequestOptions } from "./generated/client.js"
export function make(options: ClientOptions) {
const raw = OpenCode.make(options)
const events = SharedEvents.make((signal, onActivity) => raw.event.subscribe({ signal, onActivity }))
const events = SharedEvents.make((signal) => raw.event.subscribe({ signal }))
return {
...raw,
rpc: Object.assign(makeRpc(raw, events), raw.rpc),
@@ -278,8 +278,6 @@ export interface ClientOptions {
export interface RequestOptions {
readonly signal?: AbortSignal
readonly headers?: RequestInit["headers"]
/** Reports every chunk a streaming response receives, including keepalive comments that yield no event. */
readonly onActivity?: () => void
}
interface RequestDescriptor {
@@ -371,7 +369,6 @@ export function make(options: ClientOptions) {
} catch (cause) {
throw new ClientError("Transport", { cause })
}
if (!next.done) requestOptions?.onActivity?.()
buffer += decoder.decode(next.value, { stream: !next.done })
if (buffer.length > maxSseEventBytes) throw new ClientError("SseEventTooLarge")
const trailingCarriageReturn = !next.done && buffer.endsWith("\r")
+3 -15
View File
@@ -1,19 +1,10 @@
export * as SharedEvents from "./shared-events.js"
export type SubscribeOptions = {
readonly signal?: AbortSignal
/** Reports transport activity on the shared stream, including keepalive frames that carry no event. */
readonly onActivity?: () => void
}
export function make<A extends { readonly type: string }>(
connect: (signal: AbortSignal, onActivity: () => void) => AsyncIterable<A>,
) {
export function make<A extends { readonly type: string }>(connect: (signal: AbortSignal) => AsyncIterable<A>) {
type Completion = { readonly error: unknown } | Record<string, never>
type Subscriber = {
push: (value: A) => void
finish: (completion: Completion) => void
activity?: () => void
}
type Connection = {
controller: AbortController
@@ -35,9 +26,7 @@ export function make<A extends { readonly type: string }>(
let completion: Completion = {}
try {
if (connection.controller.signal.aborted) return
iterator = connect(connection.controller.signal, () => {
connection.subscribers.forEach((subscriber) => subscriber.activity?.())
})[Symbol.asyncIterator]()
iterator = connect(connection.controller.signal)[Symbol.asyncIterator]()
while (!connection.controller.signal.aborted) {
const item = await iterator.next()
if (item.done || connection.controller.signal.aborted) break
@@ -58,7 +47,7 @@ export function make<A extends { readonly type: string }>(
}
return {
subscribe(options?: SubscribeOptions): AsyncIterable<A> {
subscribe(options?: { readonly signal?: AbortSignal }): AsyncIterable<A> {
return {
[Symbol.asyncIterator]() {
const pending: ReturnType<typeof Promise.withResolvers<IteratorResult<A>>>[] = []
@@ -83,7 +72,6 @@ export function make<A extends { readonly type: string }>(
}
const subscriber: Subscriber = {
activity: options?.onActivity,
finish(result) {
finish(result, false)
},
+17 -76
View File
@@ -1,4 +1,4 @@
import { batch, onCleanup } from "solid-js"
import { batch, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import type { OpenCodeClient, OpenCodeEvent } from "../promise"
@@ -18,11 +18,6 @@ export type ClientConnectionOptions = {
readonly onEvent: (event: OpenCodeEvent) => void
readonly flushInterval?: number
readonly pageLifecycle?: boolean
/**
* Abort and reconnect a stream that receives no bytes for this long. The server writes a keepalive
* comment every 15 seconds, so a quiet but healthy stream never trips this.
*/
readonly idleTimeout?: number
readonly log?: {
readonly debug?: (message: string, data?: Readonly<Record<string, unknown>>) => void
readonly info?: (message: string, data?: Readonly<Record<string, unknown>>) => void
@@ -32,15 +27,10 @@ export type ClientConnectionOptions = {
const connectTimeout = 2_000
const reconnectDelay = 1_000
const connectionHistoryLimit = 50
export const defaultIdleTimeout = 45_000
// Longer than one server keepalive interval: a stream that is silent this long when the page
// returns to the foreground is probably half-open after the device slept.
export const foregroundIdleThreshold = 20_000
export function createClientConnection(initialApi: OpenCodeClient, options: ClientConnectionOptions) {
const abort = new AbortController()
const history: ClientConnectionEvent[] = []
const idleTimeout = options.idleTimeout ?? defaultIdleTimeout
const [connection, setConnection] = createStore<{
status: ClientConnectionStatus
attempt: number
@@ -50,12 +40,9 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
let pending: OpenCodeEvent[] = []
let flushTimer: ReturnType<typeof setTimeout> | undefined
let stream: AbortController | undefined
let current: AbortController | undefined
let run: Promise<void> | undefined
let started = false
let generation = 0
let lastActivity = 0
let forced = false
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
@@ -76,25 +63,14 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
async function connect(signal: AbortSignal, attempt: number) {
let connectedAt: number | undefined
const request = new AbortController()
current = request
const cancel = () => request.abort(signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
signal.addEventListener("abort", cancel, { once: true })
// Any received bytes, including keepalive comments, push the stall deadline out. A timer whose
// deadline passed while the page was suspended fires as soon as the page resumes.
let watchdog: ReturnType<typeof setTimeout> | undefined
const touch = () => {
lastActivity = Date.now()
if (connectedAt === undefined) return
clearTimeout(watchdog)
watchdog = setTimeout(() => request.abort(new Error("Event stream stalled")), idleTimeout)
}
try {
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
options.log?.info?.("event stream connecting", { attempt })
const iterator = api.event.subscribe({ signal: request.signal, onActivity: touch })[Symbol.asyncIterator]()
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const first = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (first.done)
@@ -109,7 +85,6 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
clearTimeout(timeout)
record("connected", attempt)
connectedAt = Date.now()
touch()
options.log?.info?.("event stream connected")
publish(first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
@@ -117,13 +92,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
while (!signal.aborted) {
const event = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (event.done)
return {
error:
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
connectedAt,
}
touch()
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
if ("durable" in event.value && event.value.durable)
options.log?.debug?.("event", {
type: event.value.type,
@@ -137,9 +106,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
return { error, connectedAt }
} finally {
request.abort()
if (current === request) current = undefined
clearTimeout(timeout)
clearTimeout(watchdog)
signal.removeEventListener("abort", cancel)
}
}
@@ -173,11 +140,6 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
if (attempt === 1) continue
}
}
// A deliberate resync already knows the old socket is gone; reconnect without backing off.
if (forced) {
forced = false
continue
}
await wait(reconnectDelay, controller.signal)
}
}
@@ -185,7 +147,6 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
function start() {
if (started) return run
started = true
forced = false
const active = ++generation
const previous = run
const current = (async () => {
@@ -200,45 +161,26 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
}
function stop() {
if (!started) return
started = false
generation += 1
stream?.abort()
// Nothing is listening once stopped, so consumers must treat their data as stale until start() reconnects.
setConnection({ status: "connecting", attempt: 0, error: undefined })
}
// Drop the live request so the reconnect loop replaces it now instead of waiting for the idle watchdog.
function resync(reason: string) {
if (!started || connection.status !== "connected") return
options.log?.info?.("event stream resync", { reason, idle: Date.now() - lastActivity })
forced = true
current?.abort(new Error(reason))
}
if (options.pageLifecycle) {
const pagehide = () => stop()
const pageshow = () => void start()
// Locking a phone or switching apps hides the document without a pagehide; the socket usually
// dies while the page is suspended, and the browser may never report that on the hung read.
const visibility = () => {
if (document.visibilityState !== "visible") return
if (Date.now() - lastActivity < foregroundIdleThreshold) return
resync("Page returned to the foreground after the event stream went quiet")
onMount(() => {
if (options.pageLifecycle) {
const pagehide = () => stop()
const pageshow = (event: PageTransitionEvent) => {
if (event.persisted) void start()
}
window.addEventListener("pagehide", pagehide)
window.addEventListener("pageshow", pageshow)
onCleanup(() => {
window.removeEventListener("pagehide", pagehide)
window.removeEventListener("pageshow", pageshow)
})
}
const online = () => resync("Network connection restored")
window.addEventListener("pagehide", pagehide)
window.addEventListener("pageshow", pageshow)
window.addEventListener("online", online)
document.addEventListener("visibilitychange", visibility)
onCleanup(() => {
window.removeEventListener("pagehide", pagehide)
window.removeEventListener("pageshow", pageshow)
window.removeEventListener("online", online)
document.removeEventListener("visibilitychange", visibility)
})
}
void start()
void start()
})
onCleanup(() => {
stop()
@@ -253,7 +195,6 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
error: () => connection.error,
internal: {
history: () => history.slice(),
resync,
},
}
}
+17 -39
View File
@@ -73,8 +73,6 @@ 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
@@ -143,18 +141,12 @@ function formRequestOptions(sessionID: string, ref?: LocationRef) {
}
function createSync() {
// `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 }
type Pending = { promise: Promise<void>; invalidated: 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, started: !wait }
const entry: Pending = { promise: Promise.resolve(), invalidated: false }
state.set(key, entry)
const run = () => {
entry.started = true
return load()
}
entry.promise = (wait ? wait.catch(() => undefined).then(run) : run())
entry.promise = (wait ? wait.catch(() => undefined).then(load) : load())
.then(() => {
if (state.get(key) === entry && !entry.invalidated) state.set(key, true)
})
@@ -186,12 +178,12 @@ function createSync() {
if (key) {
const active = state.get(key)
if (active === true) state.delete(key)
if (active !== undefined && active !== true && active.started) active.invalidated = true
if (active !== undefined && active !== true) active.invalidated = true
return
}
state.forEach((active, current) => {
if (active === true) state.delete(current)
if (active !== true && active.started) active.invalidated = true
if (active !== true) active.invalidated = true
})
},
}
@@ -211,20 +203,6 @@ 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: {},
@@ -1054,16 +1032,17 @@ export function createData(config: CreateDataInput) {
if (store.session.info[event.data.sessionID]) {
setStore("session", "info", event.data.sessionID, "revert", undefined)
}
// 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),
)
// 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))
message.update(event.data.sessionID, (draft, index) => {
const position = draft.findIndex((item) => item.id >= event.data.to)
if (position === -1) return
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
}
for (const item of draft.splice(position)) index.delete(item.id)
})
return
@@ -1248,11 +1227,10 @@ 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. The server emits one event
// per MCP server as each settles, so a location booting nine servers emitted nine refetches.
// so the mcp list syncs here rather than off integration.updated.
case "mcp.status.changed":
result.location.mcp.server.invalidate(location)
settle(`mcp.status:${locationKey(location)}`, () => result.location.mcp.server.sync(location))
refresh(() => result.location.mcp.server.sync(location))
break
case "mcp.resources.changed":
result.location.mcp.resource.invalidate(location)
-25
View File
@@ -675,31 +675,6 @@ test("event.subscribe ignores server heartbeat comments", async () => {
expect(received).toEqual([event])
})
test("event.subscribe reports heartbeat comments as stream activity", async () => {
const event = { id: "evt_sentinel", created: 1, type: "server.connected", data: {} }
const encoder = new TextEncoder()
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(": heartbeat\n\n"))
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
controller.enqueue(encoder.encode(": heartbeat\n\n"))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
),
})
let activity = 0
const received = []
for await (const item of client.event.subscribe({ onActivity: () => activity++ })) received.push(item)
expect(received).toEqual([event])
expect(activity).toBe(3)
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event transport passes through ordinary health requests", async () => {
const requests: string[] = []
@@ -359,24 +359,3 @@ test("synchronous source creation failures reject subscribers without automatic
await expect(shared.subscribe()[Symbol.asyncIterator]().next()).rejects.toBe(failure)
expect(attempts).toHaveLength(2)
})
test("source activity fans out to every subscriber that asked for it", async () => {
const events = source()
let activity: (() => void) | undefined
const shared = SharedEvents.make<Event>((signal, onActivity) => {
activity = onActivity
return events.connect(signal)
})
const counts = { first: 0, second: 0 }
const first = shared.subscribe({ onActivity: () => counts.first++ })[Symbol.asyncIterator]()
const second = shared.subscribe()[Symbol.asyncIterator]()
const reads = Promise.all([first.next(), second.next()])
activity!()
activity!()
events.connections[0].push({ type: "server.connected" })
await reads
expect(counts).toEqual({ first: 2, second: 0 })
await first.return!()
await second.return!()
await events.connections[0].closed
})
@@ -1,153 +0,0 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createClientConnection } from "../src/solid"
import { OpenCode, type OpenCodeEvent } from "../src/promise"
const connected = { id: "evt_connected", created: 1, type: "server.connected", data: {} }
// One fake server whose event streams stay open until the test writes to them or the client aborts.
function server() {
const encoder = new TextEncoder()
const streams: {
write: (text: string) => void
close: () => void
aborted: boolean
}[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
let controller!: ReadableStreamDefaultController<Uint8Array>
const entry = {
write: (text: string) => controller.enqueue(encoder.encode(text)),
close: () => controller.close(),
aborted: false,
}
const body = new ReadableStream<Uint8Array>({
start(value) {
controller = value
},
cancel() {
entry.aborted = true
},
})
request.signal.addEventListener("abort", () => {
entry.aborted = true
controller.error(request.signal.reason)
})
streams.push(entry)
return new Response(body, { headers: { "content-type": "text/event-stream" } })
},
})
return { api, streams }
}
function setup(input: ReturnType<typeof server>, idleTimeout: number) {
const events: OpenCodeEvent[] = []
return createRoot((dispose) => ({
events,
dispose,
connection: createClientConnection(input.api, {
idleTimeout,
flushInterval: 0,
onEvent: (event) => events.push(event),
}),
}))
}
async function until(check: () => boolean, timeout = 2_000) {
const deadline = Date.now() + timeout
while (!check()) {
if (Date.now() > deadline) throw new Error("Timed out waiting for condition")
await new Promise((resolve) => setTimeout(resolve, 5))
}
}
test("a stream that goes silent past the idle timeout is replaced", async () => {
const fake = server()
const ctx = setup(fake, 60)
try {
await until(() => fake.streams.length === 1)
fake.streams[0].write(`data: ${JSON.stringify(connected)}\n\n`)
await until(() => ctx.connection.status() === "connected")
await until(() => fake.streams.length === 2)
expect(fake.streams[0].aborted).toBe(true)
expect(ctx.connection.internal.history().map((item) => item.data)).toContainEqual({
status: "disconnected",
attempt: 1,
error: "Event stream stalled",
})
fake.streams[1].write(`data: ${JSON.stringify({ ...connected, id: "evt_connected_2" })}\n\n`)
await until(() => ctx.connection.status() === "connected" && ctx.events.length === 2)
expect(ctx.connection.error()).toBeUndefined()
} finally {
ctx.dispose()
}
})
test("keepalive comments hold a quiet stream open", async () => {
const fake = server()
const ctx = setup(fake, 60)
try {
await until(() => fake.streams.length === 1)
fake.streams[0].write(`data: ${JSON.stringify(connected)}\n\n`)
await until(() => ctx.connection.status() === "connected")
const heartbeat = setInterval(() => fake.streams[0].write(": heartbeat\n\n"), 20)
await new Promise((resolve) => setTimeout(resolve, 250))
clearInterval(heartbeat)
expect(fake.streams).toHaveLength(1)
expect(fake.streams[0].aborted).toBe(false)
expect(ctx.connection.status()).toBe("connected")
expect(ctx.events).toHaveLength(1)
} finally {
ctx.dispose()
}
})
test("a forced resync replaces the stream immediately and only while connected", async () => {
const fake = server()
const ctx = setup(fake, 10_000)
try {
ctx.connection.internal.resync("too early")
await until(() => fake.streams.length === 1)
expect(fake.streams[0].aborted).toBe(false)
fake.streams[0].write(`data: ${JSON.stringify(connected)}\n\n`)
await until(() => ctx.connection.status() === "connected")
const started = Date.now()
ctx.connection.internal.resync("Network connection restored")
await until(() => fake.streams.length === 2)
expect(Date.now() - started).toBeLessThan(500)
expect(fake.streams[0].aborted).toBe(true)
expect(ctx.connection.internal.history().map((item) => item.data)).toContainEqual({
status: "disconnected",
attempt: 1,
error: "Network connection restored",
})
fake.streams[1].write(`data: ${JSON.stringify(connected)}\n\n`)
await until(() => ctx.connection.status() === "connected")
} finally {
ctx.dispose()
}
})
test("a stream the server closes reconnects and reports the disconnect", async () => {
const fake = server()
const ctx = setup(fake, 10_000)
try {
await until(() => fake.streams.length === 1)
fake.streams[0].write(`data: ${JSON.stringify(connected)}\n\n`)
await until(() => ctx.connection.status() === "connected")
fake.streams[0].close()
await until(() => ctx.connection.status() === "reconnecting")
expect(ctx.connection.error()).toBe("Event stream disconnected")
await until(() => fake.streams.length === 2)
} finally {
ctx.dispose()
}
})
+1 -84
View File
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, settleMs, type CreateDataInput } from "../src/solid"
import { createData, 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,86 +165,3 @@ 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()
}
})
+1 -1
View File
@@ -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.2
ARG BUN_VERSION=1.4.1
ENV BUN_INSTALL=/opt/bun
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+2 -1
View File
@@ -100,6 +100,7 @@
},
"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",
@@ -111,6 +112,7 @@
"@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",
@@ -120,7 +122,6 @@
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"@parcel/watcher": "2.5.1",
+147 -6
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
"id": "685a01ff-9b49-4789-a8a6-f1b5779a866c",
"prevIds": ["be60f352-8da1-40e1-8d70-dc41121cfbc5"],
"ddl": [
{
"name": "account_state",
@@ -72,6 +72,10 @@
"name": "session_v2",
"entityType": "tables"
},
{
"name": "timeline",
"entityType": "tables"
},
{
"name": "workspace",
"entityType": "tables"
@@ -940,6 +944,16 @@
"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,
@@ -1070,6 +1084,16 @@
"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,
@@ -1420,6 +1444,36 @@
"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,
@@ -1588,13 +1642,13 @@
"table": "session_inbox"
},
{
"columns": ["session_id"],
"tableTo": "session_v2",
"columns": ["timeline_id"],
"tableTo": "timeline",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_message_session_id_session_v2_id_fk",
"name": "fk_session_message_timeline_id_timeline_id_fk",
"entityType": "fks",
"table": "session_message"
},
@@ -1609,6 +1663,17 @@
"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",
@@ -1620,6 +1685,17 @@
"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",
@@ -1757,6 +1833,13 @@
"table": "session_v2",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "timeline_pk",
"table": "timeline",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
@@ -1877,13 +1960,71 @@
"isExpression": false
}
],
"isUnique": true,
"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",
"entityType": "indexes",
"table": "session_message"
},
{
"columns": [
{
-2
View File
@@ -195,9 +195,7 @@ 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 } : {}),
}
+2
View File
@@ -45,6 +45,7 @@ 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,
@@ -93,4 +94,5 @@ export const migrations = [
m43,
m44,
m45,
m46,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,126 @@
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
+21 -2
View File
@@ -158,12 +158,13 @@ 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_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
CONSTRAINT \`fk_session_message_timeline_id_timeline_id_fk\` FOREIGN KEY (\`timeline_id\`) REFERENCES \`timeline\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
@@ -181,6 +182,7 @@ 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,
@@ -216,9 +218,18 @@ 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,
@@ -249,9 +260,17 @@ 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_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
`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\`);`,
)
+13 -305
View File
@@ -10,10 +10,9 @@ 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
@@ -88,10 +87,6 @@ type RunResult = {
readonly status: "completed"
}
type Options = {
readonly nextDatabasePath?: string
}
type MigrationState = { readonly phase: "sessions"; readonly cursor?: string } | { readonly phase: "completed" }
type RuntimeState =
@@ -99,118 +94,6 @@ 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
@@ -513,7 +396,7 @@ function updateProgress(progress: Progress) {
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
}
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
export function run(): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
return lock.withPermit(
Effect.gen(function* () {
const db = (yield* Database.Service).db
@@ -546,7 +429,6 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
}),
)
.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 =
@@ -554,12 +436,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
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 })
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator: legacyTotal })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
@@ -589,15 +466,21 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
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, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
id, timeline_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, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
id, ${timelineID}, ${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
@@ -627,6 +510,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
.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,
@@ -657,7 +541,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
denominator: legacyTotal,
},
}
yield* Effect.yieldNow
@@ -681,182 +565,6 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
)
}
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: {
+1 -2
View File
@@ -245,8 +245,7 @@ export const connect = Effect.fnUntraced(function* (
return yield* open(url).pipe(
Effect.catch((error) => {
if (!addedCodemode || !(error instanceof StreamableHTTPError) || (error.code !== 400 && error.code !== 404))
return Effect.fail(error)
if (!addedCodemode || !(error instanceof StreamableHTTPError) || 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 -5
View File
@@ -79,7 +79,6 @@ import { WebSearchTool } from "../tool/plugin/websearch.js"
import { WellKnown } from "../wellknown.js"
import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "@opencode-ai/plugin-browser"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
@@ -88,7 +87,7 @@ import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { SkillPlugin } from "./skill.js"
import { VcsHgPlugin } from "./vcs/hg.js"
import { OptimizePlugin } from "./optimize.js"
import { SystemPromptPlugin } from "./system-prompt.js"
import { VariantPlugin } from "./variant.js"
import { VcsGitPlugin } from "./vcs/git.js"
import { WarmingPlugin } from "./warming.js"
@@ -193,7 +192,6 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
BrowserPlugin,
ConfigMcpPlugin.Plugin,
McpCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
@@ -203,12 +201,11 @@ const pre = [
CommandPlugin.Plugin,
SkillPlugin.Plugin,
VcsHgPlugin.Plugin,
...SystemPromptPlugin.Plugins,
ModelsDevPlugin,
...ProviderPlugins,
...WebSearchPlugins,
PatchTool.Plugin,
// Render model prompts after the patch plugin selects the available editing tools.
...OptimizePlugin.Plugins,
EditTool.Plugin,
GlobTool.Plugin,
GrepTool.Plugin,
+9 -57
View File
@@ -2,60 +2,14 @@ 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 { Deferred, Effect, FiberSet, PubSub, Schema, Stream } from "effect"
import { Effect, Schema } 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([
@@ -77,9 +31,8 @@ export class LoadError extends Schema.TaggedError<LoadError>()("PluginModule.Loa
cause: Schema.optional(Schema.Defect()),
}) {}
const load = Effect.fn("PluginModule.load")(function* (
export 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)
@@ -98,13 +51,12 @@ 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}` })
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(
// 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(
Effect.mapError(
(cause) =>
new LoadError({
@@ -120,7 +72,7 @@ const load = Effect.fn("PluginModule.load")(function* (
...(entrypoints.tui ? { tui: true as const } : {}),
...(entrypoints.rpc ? { rpc: true as const } : {}),
},
revision: JSON.stringify([operation, loaded.version]),
revision: JSON.stringify([operation, installed?.revision]),
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: fileURLToPath(entrypoint) }
: {
-74
View File
@@ -1,74 +0,0 @@
export * as OptimizePlugin from "./optimize.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import { Model } from "@opencode-ai/schema/model"
import { Effect } from "effect"
import { SessionSystemPrompt } from "../session/system-prompt.js"
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("opencode.prompt.openai", (model) => {
const id = model.id.toLowerCase()
if (!id.includes("gpt")) return undefined
return id.includes("gpt-6") ? PROMPT_ASTRA : PROMPT_GPT
})
export const OpenAIToolsPlugin = make("opencode.optimize.openai.tools", (model, tools) => {
const ids = [model.id, model.modelID, model.family].join(" ").toLowerCase()
if (!ids.includes("gpt")) return undefined
delete tools.grep
delete tools.glob
return undefined
})
export const AnthropicToolsPlugin = make("opencode.optimize.anthropic.tools", (model, tools) => {
const ids = [model.id, model.modelID, model.family].join(" ").toLowerCase()
if (!ids.includes("claude")) return undefined
delete tools.grep
delete tools.glob
return undefined
})
export const KimiPlugin = make("opencode.prompt.kimi", (model) =>
model.id.toLowerCase().includes("kimi") ? PROMPT_KIMI : undefined,
)
export const ArceePlugin = make("opencode.prompt.arcee", (model) =>
model.id.toLowerCase().includes("trinity") ? PROMPT_TRINITY : undefined,
)
export const MetaPlugin = make("opencode.prompt.meta", (model) => {
if (!model.id.toLowerCase().includes("muse")) return undefined
return PROMPT_META.replaceAll("{{MODEL_NAME}}", model.name)
})
export const Plugins = [OpenAIPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
function make(
id: string,
optimize: (model: Model.Info, tools: SessionHooks["context"]["tools"]) => string | undefined,
) {
return define({
id,
effect: Effect.fn(`OptimizePlugin.${id}`)(function* (ctx) {
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
const model =
(yield* ctx.catalog.model.list()).data.find(
(model) => model.providerID === event.model.providerID && model.id === event.model.id,
) ?? Model.Info.default(event.model.providerID, event.model.id)
// Curate tools before rendering their guidance, including for agents with a custom system prompt.
const template = optimize(model, event.tools)
if (!template) return
if ((yield* ctx.agent.get({ agentID: event.agent })).data.system) return
const system = event.system[0]
if (!system) return
event.system[0] = { ...system, text: SessionSystemPrompt.render(template, Object.keys(event.tools)) }
}).pipe(Effect.catch(() => Effect.void)),
)
}),
})
}
@@ -1,59 +1,123 @@
import { Effect } from "effect"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Provider } from "../../provider.js"
// 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",
]
type MantleSDK = {
chat: (modelID: string) => LanguageModelV3
responses: (modelID: string) => LanguageModelV3
}
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")
// 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)
}
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 (!isBedrock(item.provider)) continue
if (!Provider.isAISDK(item.provider.package)) continue
if (Provider.packageName(item.provider.package) !== "@ai-sdk/amazon-bedrock") continue
evt.provider.update(item.provider.id, (provider) => {
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 }
: {}),
}
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
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,7 +261,6 @@ 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 },
+3 -10
View File
@@ -13,10 +13,8 @@ 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[],
@@ -55,7 +53,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
continue
}
const plugin = yield* modules.load(operation, { install }).pipe(
const plugin = yield* PluginModule.load(operation, { install }).pipe(
Effect.catchCause((cause) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
const error = Cause.squash(cause)
@@ -119,7 +117,6 @@ 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()
@@ -152,7 +149,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(modules, pre, post, operations, false, running)
const immediate = yield* resolve(pre, post, operations, false, running)
const source = (source: Plugin.Source) =>
source.type === "package"
? {
@@ -167,9 +164,7 @@ export const layer = Layer.effectDiscard(
resolved.failures.map((failure) => ({ ...failure, source: source(failure.source) })),
)
yield* apply(immediate)
const resolved = immediate.pending.length
? yield* resolve(modules, pre, post, operations, true, running)
: immediate
const resolved = immediate.pending.length ? yield* resolve(pre, post, operations, true, running) : immediate
if (resolved !== immediate) yield* apply(resolved)
running = resolved.packages
const targets = new Set(
@@ -208,7 +203,6 @@ 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(
@@ -246,7 +240,6 @@ const nodeDeps = [
PluginUpdate.node,
Bus.node,
Npm.node,
Watcher.node,
PluginInternal.requirements,
] as const
+65
View File
@@ -0,0 +1,65 @@
export * as SystemPromptPlugin from "./system-prompt.js"
import { SystemPart } from "@opencode-ai/ai"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import PROMPT_ANTHROPIC from "./system-prompt/anthropic.txt"
import PROMPT_GPT from "./system-prompt/gpt-extension.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 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)
},
{ operation: "replace" },
)
export const Plugins = [OpenAIPlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
function make(
id: string,
getPrompt: (modelID: string) => string | undefined,
options: { operation: "replace" | "append" },
) {
return define({
id: `opencode.prompt.${id}`,
effect: Effect.fn(`SystemPromptPlugin.${id}`)(function* (ctx) {
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
if ((yield* ctx.agent.get({ agentID: event.agent })).data.system) return
const system = event.system[0]
if (!system) return
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
if (options.operation === "append") {
event.system.splice(1, 0, SystemPart.make(prompt))
return
}
event.system[0] = { ...system, text: prompt }
}).pipe(Effect.catch(() => Effect.void)),
)
}),
})
}
@@ -0,0 +1,53 @@
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>
@@ -1,45 +0,0 @@
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.
@@ -1,30 +1,10 @@
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
# Response channels
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 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.
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.
# Delegation
+7 -14
View File
@@ -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 { and, desc, eq } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { Project } from "./project.js"
import { Model } from "@opencode-ai/schema/model"
import { Location } from "./location.js"
@@ -63,6 +63,7 @@ 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
//
@@ -292,19 +293,11 @@ const layer = Layer.effect(
}),
fork: Effect.fn("Session.fork")(function* (input) {
const parent = yield* result.get(input.sessionID)
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)
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,
})
if (!boundary && input.boundary.type === "before")
return yield* new MessageNotFoundError({
sessionID: input.sessionID,
+26 -36
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
import { and, eq, gte, sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "../database/database.js"
import { MessageDecodeError } from "./error.js"
@@ -7,26 +7,25 @@ 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) {
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 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 decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
@@ -41,19 +40,12 @@ export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =
)
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
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)
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",
})
return yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
)
@@ -112,13 +104,11 @@ export const firstUserMessage = Effect.fn("SessionHistory.firstUserMessage")(fun
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
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)
const [row] = yield* Timeline.rows(db, yield* Timeline.forSession(db, sessionID), {
where: eq(SessionMessageTable.type, "user"),
order: "asc",
limit: 1,
})
if (!row) return undefined
const message = yield* decodeMessageRow(row).pipe(Effect.orElseSucceed(() => undefined))
return message?.type === "user" ? message : undefined
+48 -65
View File
@@ -1,6 +1,6 @@
export * as SessionProjector from "./projector.js"
import { and, asc, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql } from "drizzle-orm"
import { and, desc, eq, gt, gte, inArray, isNull, lt, lte, not, 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 } from "./sql.js"
import { SessionInboxTable, SessionMessageTable, SessionTable, unsettled } from "./sql.js"
import { InstructionEntry } from "./instruction-entry.js"
import { Slug } from "../util/slug.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -24,6 +24,7 @@ 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<
@@ -115,39 +116,30 @@ 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({ seq: SessionMessageTable.seq })
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.parentID),
eq(SessionMessageTable.id, event.data.boundary.messageID),
),
)
.where(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 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 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 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 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,
@@ -179,26 +171,19 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
if (event.data.instructionEntries)
yield* InstructionEntry.initialize(db, event.data.sessionID, event.data.instructionEntries, event.created)
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)
// 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,
})
if (rows.length === 0) break
yield* db
@@ -207,6 +192,7 @@ 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,
@@ -301,7 +287,10 @@ function run(db: DatabaseService, event: MessageEvent) {
.select()
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "assistant")),
and(
eq(SessionMessageTable.timeline_id, Timeline.current(event.data.sessionID)),
eq(SessionMessageTable.type, "assistant"),
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
@@ -359,7 +348,7 @@ function run(db: DatabaseService, event: MessageEvent) {
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.timeline_id, Timeline.current(event.data.sessionID)),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`,
),
@@ -390,6 +379,7 @@ 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),
@@ -436,10 +426,12 @@ 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,
@@ -539,7 +531,10 @@ const layer = Layer.effectDiscard(
}),
)
yield* bus.project(SessionEvent.Deleted, (event) =>
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
Effect.gen(function* () {
yield* db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie)
yield* Timeline.collect(db)
}),
)
yield* bus.project(SessionEvent.AgentSelected, (event) =>
Effect.gen(function* () {
@@ -720,20 +715,14 @@ const layer = Layer.effectDiscard(
)
yield* bus.project(SessionEvent.RevertEvent.Committed, (event) =>
Effect.gen(function* () {
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)
const boundary = yield* Timeline.find(db, event.data.sessionID, event.data.to)
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
.delete(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, event.data.sessionID), gte(SessionMessageTable.seq, boundary.seq)),
)
.update(SessionTable)
.set({ timeline_id: timelineID, revert: null, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
yield* db
@@ -746,12 +735,6 @@ 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)
}),
)
+7 -20
View File
@@ -1,6 +1,6 @@
export * as SessionRevert from "./revert.js"
import { and, asc, eq, gt } from "drizzle-orm"
import { and, eq, gt } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "../database/database.js"
import { Bus } from "../bus.js"
@@ -12,6 +12,7 @@ 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 }
@@ -84,26 +85,12 @@ 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* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID)))
.get()
.pipe(Effect.orDie)
const boundary = yield* Timeline.find(db, input.sessionID, input.messageID)
if (!boundary) return yield* new MessageNotFoundError(input)
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 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 decode = Schema.decodeUnknownEffect(SessionMessage.Info)
const files = new Map<RelativePath, Snapshot.ID>()
for (const row of rows) {
+2 -1
View File
@@ -17,6 +17,7 @@ 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"
@@ -293,7 +294,7 @@ const layer = Layer.effect(
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.timeline_id, Timeline.current(sessionID)),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`,
),
+1 -1
View File
@@ -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)
const stored = yield* store.message(messageID, sessionID)
return stored?.sessionID === sessionID ? stored.message : undefined
})
const updateMessage = Effect.fn("Session.updateMessage")(function* (
+39 -6
View File
@@ -1,5 +1,14 @@
import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core"
import { sql } from "drizzle-orm"
import {
sqliteTable,
text,
integer,
index,
primaryKey,
real,
uniqueIndex,
type AnySQLiteColumn,
} from "drizzle-orm/sqlite-core"
import { sql, type SQLWrapper } from "drizzle-orm"
import { directoryColumn, pathColumn } from "../database/path.js"
import { ProjectTable } from "../project/sql.js"
import type { SessionMessage } from "./message.js"
@@ -15,14 +24,27 @@ 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()
@@ -80,23 +102,34 @@ export const SessionMessageTable = sqliteTable(
"session_message",
{
id: text().$type<SessionMessage.ID>().primaryKey(),
session_id: text()
.$type<SessionSchema.ID>()
// 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>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
.references(() => TimelineTable.id, { onDelete: "cascade" }),
type: text().$type<SessionMessage.Type>().notNull(),
seq: integer().notNull(),
...Timestamps,
data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
},
(table) => [
uniqueIndex("session_message_session_seq_idx").on(table.session_id, table.seq),
// 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)),
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",
{
+18 -29
View File
@@ -13,6 +13,7 @@ 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),
@@ -56,6 +57,7 @@ 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
@@ -140,49 +142,36 @@ 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* 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
const anchor = input.cursor ? yield* Timeline.find(db, input.sessionID, input.cursor.id) : undefined
if (input.cursor && !anchor) return []
const boundary = anchor
? order === "asc"
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
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,
)
const rows = yield* Timeline.rows(db, yield* Timeline.forSession(db, input.sessionID), {
where: boundary,
order,
limit: input.limit,
})
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) {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, messageID))
.get()
.pipe(Effect.orDie)
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)
return row
? {
sessionID: Session.ID.make(row.session_id),
sessionID: sessionID ?? Session.ID.make(row.session_id),
message: yield* SessionHistory.decodeMessageRow(row).pipe(Effect.orDie),
}
: undefined
+1 -6
View File
@@ -3,15 +3,10 @@ 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")) {
@@ -24,5 +19,5 @@ export function render(prompt: string, 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"))
}
+123
View File
@@ -0,0 +1,123 @@
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)
})
+22 -2
View File
@@ -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 } from "drizzle-orm"
import { eq, inArray } from "drizzle-orm"
import { Clock, Context, DateTime, Effect, Layer, Schema } from "effect"
import { map } from "effect/Array"
import path from "path"
@@ -21,6 +21,7 @@ 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
@@ -74,12 +75,31 @@ 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: message.id,
id: existing.has(message.id) ? SessionMessage.ID.create() : message.id,
session_id: sessionID,
timeline_id: Timeline.current(sessionID),
type,
seq: index + 1,
time_created: DateTime.toEpochMillis(message.time.created),

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