mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-20 20:27:02 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71ff13133d |
@@ -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:",
|
||||
@@ -1188,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=="],
|
||||
|
||||
@@ -5866,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=="],
|
||||
@@ -5876,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=="],
|
||||
@@ -5922,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=="],
|
||||
|
||||
@@ -6384,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=="],
|
||||
@@ -7244,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
@@ -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-6VoxH/BowtAOYdhGqD7ZkFJv+h+/onFmuNZhvBxoGAI=",
|
||||
"aarch64-linux": "sha256-Va77URDCTAkvMQ0g7huFgp1iwb5C6+idSrlLOaFNVB0=",
|
||||
"aarch64-darwin": "sha256-IqCsKuiuuAmK4Z3oOfJ9kX4b4CpCmj6GeTKhXOewc7E=",
|
||||
"x86_64-darwin": "sha256-LXOxHzh6SS63WkfKvwBz+NylfvbcCi+rmXIRmpwu/oE="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }> = []
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const directory = "/tab-focus"
|
||||
const sessions = ["Alpha", "Beta"].map((title, index) => ({
|
||||
id: `ses_tab_focus_${index}`,
|
||||
slug: `tab-focus-${index}`,
|
||||
projectID: "proj_tab_focus",
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000 + index, updated: 1700000000000 + index },
|
||||
}))
|
||||
|
||||
for (const { layout, platform, modifier } of [
|
||||
{ layout: "horizontal", platform: "MacIntel", modifier: "Meta" },
|
||||
{ layout: "vertical", platform: "MacIntel", modifier: "Meta" },
|
||||
{ layout: "horizontal", platform: "Linux x86_64", modifier: "Control" },
|
||||
{ layout: "vertical", platform: "Linux x86_64", modifier: "Control" },
|
||||
]) {
|
||||
test(`${modifier}+number refocuses the destination composer with ${layout} tabs`, async ({ page }, testInfo) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_tab_focus",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [{ id: "opencode", name: "OpenCode", models: { test: { id: "test", name: "Test" } } }],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ server, sessions, layout, platform }) => {
|
||||
Object.defineProperty(navigator, "platform", { configurable: true, value: platform })
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: layout } }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((session) => ({ type: "session", server, sessionId: session.id }))),
|
||||
)
|
||||
},
|
||||
{ server, sessions, layout, platform },
|
||||
)
|
||||
const href = (index: number) => `/server/${base64Encode(server)}/session/${sessions[index].id}`
|
||||
await page.goto(href(0))
|
||||
await expect(page.locator("[data-titlebar-tab-title]")).toHaveText(["Alpha", "Beta"])
|
||||
const editor = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.fill("Alpha draft")
|
||||
|
||||
await page.keyboard.press("Home")
|
||||
for (const { index, text, expected } of [
|
||||
{ index: 1, text: "Beta draft", expected: "Beta draft" },
|
||||
{ index: 0, text: "Edited ", expected: "Edited Alpha draft" },
|
||||
{ index: 1, text: " continued", expected: "Beta draft continued" },
|
||||
]) {
|
||||
await page.keyboard.press(`${modifier}+${index + 1}`)
|
||||
await expect(page).toHaveURL(href(index))
|
||||
await expect(page.getByRole("heading", { name: sessions[index].title, exact: true })).toBeVisible()
|
||||
await expect(editor).toBeEditable()
|
||||
await expect(editor).toBeFocused()
|
||||
await page.keyboard.type(text)
|
||||
await expect(editor).toHaveText(expected)
|
||||
}
|
||||
await page.setViewportSize({ width: 800, height: 720 })
|
||||
await page.keyboard.press(`${modifier}+1`)
|
||||
await expect(page).toHaveURL(href(0))
|
||||
await expect(editor).toBeFocused()
|
||||
await expect(editor).toHaveText("Edited Alpha draft")
|
||||
const longDraft = "Keep this long draft while switching tabs. ".repeat(100)
|
||||
await editor.fill(longDraft)
|
||||
await page.keyboard.press(`${modifier}+2`)
|
||||
await expect(page).toHaveURL(href(1))
|
||||
await expect(editor).toBeFocused()
|
||||
await expect(editor).toHaveText("Beta draft continued")
|
||||
await page.keyboard.press(`${modifier}+1`)
|
||||
await expect(page).toHaveURL(href(0))
|
||||
await expect(editor).toBeFocused()
|
||||
await expect(editor).toHaveText(longDraft)
|
||||
await testInfo.attach("refocused-composer", { body: await page.screenshot(), contentType: "image/png" })
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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 />,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1045,8 +1045,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||
"settings.general.row.showStatus.title": "Server status",
|
||||
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
|
||||
"settings.general.row.showProjectIcon.title": "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,4 +1,5 @@
|
||||
import { createEffect, createMemo, on, type Accessor } from "solid-js"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import type { ComposerControls } from "@/composer/adapter"
|
||||
import { setCursorPosition } from "@/composer/editor/dom"
|
||||
import { createComposerModel } from "@/composer/model"
|
||||
@@ -12,6 +13,7 @@ export function createSessionComposerController(input: {
|
||||
controls: Accessor<ComposerControls>
|
||||
dock: Parameters<typeof createSessionComposerRegionController>[0]
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const settings = useSettings()
|
||||
const region = createSessionComposerRegionController(input.dock)
|
||||
let editor: HTMLDivElement | undefined
|
||||
@@ -40,6 +42,12 @@ export function createSessionComposerController(input: {
|
||||
})
|
||||
const composer = createComposerModel(adapter, { queue })
|
||||
const editable = createMemo(() => region.showComposer() && !region.child())
|
||||
createEffect(
|
||||
on(adapter.ready, (ready) => {
|
||||
if (!ready || !editable() || dialog.active) return
|
||||
composer.restoreFocus()
|
||||
}),
|
||||
)
|
||||
// Requests hide the view without disposing its draft or queue edit.
|
||||
createEffect(on(editable, () => composer.onDragLeave()))
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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,9 +41,8 @@ export function StatusPopover() {
|
||||
serverHealth: serverHealth(),
|
||||
attention: attention(),
|
||||
issue: issue(),
|
||||
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: () => (
|
||||
@@ -63,7 +61,6 @@ type StatusPopoverState = {
|
||||
serverHealth: boolean | undefined
|
||||
attention: boolean
|
||||
issue: boolean
|
||||
sidebar: boolean
|
||||
placement: "top-start" | "bottom-end"
|
||||
shift: number
|
||||
label: string
|
||||
@@ -96,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) {
|
||||
|
||||
@@ -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,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),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -2045,7 +2045,6 @@ export type ConfigEntry =
|
||||
experimental?: {
|
||||
portable_shell_scanner?: boolean
|
||||
subagent_depth?: number
|
||||
subagent_fork?: boolean
|
||||
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,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: {},
|
||||
@@ -1248,11 +1226,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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
|
||||
@@ -426,15 +426,6 @@ function normalizeExperimental(
|
||||
)
|
||||
if (value !== undefined) result.subagent_depth = value
|
||||
}
|
||||
if (own(experimental, "subagent_fork")) {
|
||||
const value = decodeEncoded(
|
||||
ConfigExperimental.Info.fields.subagent_fork,
|
||||
experimental.subagent_fork,
|
||||
["experimental", "subagent_fork"],
|
||||
diagnostics,
|
||||
)
|
||||
if (value !== undefined) result.subagent_fork = value
|
||||
}
|
||||
native.push(
|
||||
...decodeList(
|
||||
experimental.policies,
|
||||
|
||||
@@ -2,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) }
|
||||
: {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -93,7 +93,6 @@ type CompactInput = Parameters<Session.Handle["compact"]>[0] & { sessionID: Sess
|
||||
type ForkInput = {
|
||||
sessionID: SessionSchema.ID
|
||||
boundary: SessionSchema.ForkRequestBoundary
|
||||
parentID?: SessionSchema.ID
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -312,9 +311,7 @@ const layer = Layer.effect(
|
||||
messageID: input.boundary.messageID,
|
||||
})
|
||||
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
|
||||
const sessionID = input.parentID
|
||||
? (yield* result.create({ parentID: input.parentID })).id
|
||||
: SessionSchema.ID.create()
|
||||
const sessionID = SessionSchema.ID.create()
|
||||
const inherited = yield* db
|
||||
.transaction(() =>
|
||||
Effect.all({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionProjector from "./projector.js"
|
||||
|
||||
import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Database } from "../database/database.js"
|
||||
@@ -144,25 +144,22 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
.pipe(Effect.orDie)
|
||||
const copiedSeq = copied?.seq
|
||||
|
||||
const inherited = {
|
||||
fork_session_id: event.data.parentID,
|
||||
fork_boundary: event.data.boundary,
|
||||
project_id: parent.project_id,
|
||||
workspace_id: parent.workspace_id,
|
||||
directory: parent.directory,
|
||||
path: parent.path,
|
||||
title: forkTitle(parent.title ?? undefined),
|
||||
agent: parent.agent,
|
||||
model: parent.model,
|
||||
metadata: parent.metadata,
|
||||
}
|
||||
const stored = yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: event.data.sessionID,
|
||||
parent_id: null,
|
||||
...inherited,
|
||||
fork_session_id: event.data.parentID,
|
||||
fork_boundary: event.data.boundary,
|
||||
project_id: parent.project_id,
|
||||
workspace_id: parent.workspace_id,
|
||||
slug: Slug.create(),
|
||||
directory: parent.directory,
|
||||
path: parent.path,
|
||||
title: forkTitle(parent.title ?? undefined),
|
||||
agent: parent.agent,
|
||||
model: parent.model,
|
||||
metadata: parent.metadata,
|
||||
version: parent.version,
|
||||
cost: 0,
|
||||
tokens_input: 0,
|
||||
@@ -173,12 +170,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
time_created: event.created,
|
||||
time_updated: event.created,
|
||||
})
|
||||
// Created records optional ownership; Forked supplies the source's history and defaults.
|
||||
.onConflictDoUpdate({
|
||||
target: SessionTable.id,
|
||||
set: { ...inherited, time_updated: event.created },
|
||||
setWhere: and(isNotNull(SessionTable.parent_id), isNull(SessionTable.fork_session_id)),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ sessionID: SessionTable.id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { ConfigEntryObserver } from "../../config/plugin/entry-observer.js"
|
||||
import { Job } from "../../job.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { Session } from "../../session.js"
|
||||
@@ -39,13 +38,6 @@ export const Input = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
const ForkInput = Schema.Struct({
|
||||
...Input.fields,
|
||||
fork: Schema.optionalKey(Schema.Boolean).annotate({
|
||||
description: "Give the subagent your conversation history before this response.",
|
||||
}),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
sessionID: SessionSchema.ID,
|
||||
status: Schema.Literals(["completed", "running"]),
|
||||
@@ -69,26 +61,17 @@ export const Plugin = {
|
||||
const config = yield* Config.Service
|
||||
const permission = yield* Permission.Service
|
||||
const subagents = yield* SubagentJob.make
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.tool.reload())
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((editor) => {
|
||||
const fork = Config.latest(loaded.entries, "experimental")?.subagent_fork === true
|
||||
.transform((editor) =>
|
||||
editor.add({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description: fork
|
||||
? description.replace(
|
||||
"New child sessions start with fresh context, so include all relevant context and instructions when you don't pass a sessionID.",
|
||||
"New child sessions start with fresh context by default, so include the context needed for the task.",
|
||||
)
|
||||
: description,
|
||||
input: fork ? ForkInput : Input,
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input: typeof ForkInput.Type, context) =>
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
if (fork && input.fork !== undefined && input.sessionID !== undefined)
|
||||
return yield* new ToolFailure({ message: "Cannot use fork with sessionID. Omit one of them." })
|
||||
const parent = yield* sessions
|
||||
.get(context.sessionID)
|
||||
.pipe(
|
||||
@@ -167,40 +150,18 @@ export const Plugin = {
|
||||
const model = agent.model ?? parent.model
|
||||
const child =
|
||||
existing ??
|
||||
(yield* (
|
||||
fork && input.fork
|
||||
? sessions.fork({
|
||||
sessionID: context.sessionID,
|
||||
parentID: context.sessionID,
|
||||
boundary: { type: "before", messageID: context.messageID },
|
||||
})
|
||||
: sessions.create({
|
||||
parentID: context.sessionID,
|
||||
title: input.description,
|
||||
agent: Agent.ID.make(input.agent),
|
||||
model,
|
||||
})
|
||||
).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message:
|
||||
fork && input.fork
|
||||
? `Failed to create subagent: ${error.message}`
|
||||
: `Parent session not found: ${context.sessionID}`,
|
||||
error,
|
||||
}),
|
||||
),
|
||||
))
|
||||
|
||||
if (fork && input.fork)
|
||||
yield* sessions.rename({ sessionID: child.id, title: input.description }).pipe(
|
||||
Effect.andThen(sessions.switchAgent({ sessionID: child.id, agent: agent.id })),
|
||||
Effect.andThen(model ? sessions.switchModel({ sessionID: child.id, model }) : Effect.void),
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Failed to configure subagent: ${child.id}`, error }),
|
||||
),
|
||||
)
|
||||
(yield* sessions
|
||||
.create({
|
||||
parentID: context.sessionID,
|
||||
title: input.description,
|
||||
agent: Agent.ID.make(input.agent),
|
||||
model,
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
|
||||
),
|
||||
))
|
||||
|
||||
const background = input.background === true
|
||||
yield* context.progress({ sessionID: child.id, status: "running" })
|
||||
@@ -212,12 +173,7 @@ export const Plugin = {
|
||||
sessionID: child.id,
|
||||
text:
|
||||
existing === undefined
|
||||
? [
|
||||
fork && input.fork
|
||||
? "You are a forked subagent. Use the inherited history as context and perform only the task below."
|
||||
: "You are a subagent spawned by another session.",
|
||||
input.prompt,
|
||||
].join("\n")
|
||||
? ["You are a subagent spawned by another session.", input.prompt].join("\n")
|
||||
: input.prompt,
|
||||
...(background && existing === undefined ? { resume: false } : {}),
|
||||
})
|
||||
@@ -274,8 +230,8 @@ export const Plugin = {
|
||||
metadata: { sessionID: output.sessionID, status: output.status },
|
||||
})),
|
||||
),
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
|
||||
@@ -311,6 +311,7 @@ describe("AISDKNative", () => {
|
||||
region: "eu-west-1",
|
||||
},
|
||||
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/v1",
|
||||
profile: "ignored",
|
||||
credentialProvider: "ignored",
|
||||
fetch: "ignored",
|
||||
store: false,
|
||||
@@ -332,19 +333,6 @@ describe("AISDKNative", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("forwards Bedrock profile and auth mode for the default credential chain", () => {
|
||||
expect(
|
||||
map("@ai-sdk/amazon-bedrock", { profile: "work", auth: "sigv4", region: "eu-west-1" }, "anthropic.claude"),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/amazon-bedrock",
|
||||
settings: { profile: "work", auth: "sigv4", region: "eu-west-1" },
|
||||
})
|
||||
expect(map("@ai-sdk/amazon-bedrock", { auth: "bogus" }, "anthropic.claude")).toEqual({
|
||||
package: "@opencode-ai/ai/providers/amazon-bedrock",
|
||||
settings: {},
|
||||
})
|
||||
})
|
||||
|
||||
test("maps the legacy Bedrock endpoint override", () => {
|
||||
expect(
|
||||
map(
|
||||
|
||||
@@ -409,14 +409,12 @@ describe("ConfigNormalize", () => {
|
||||
experimental: {
|
||||
portable_shell_scanner: true,
|
||||
subagent_depth: 0,
|
||||
subagent_fork: true,
|
||||
policies: [{ action: "provider.use", resource: "custom", effect: "allow" }],
|
||||
},
|
||||
}).encoded.experimental,
|
||||
).toEqual({
|
||||
portable_shell_scanner: true,
|
||||
subagent_depth: 0,
|
||||
subagent_fork: true,
|
||||
policies: [
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "anthropic", effect: "allow" },
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginModule } from "@opencode-ai/core/plugin/module"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { fromPromise } from "@opencode-ai/plugin/promise/adapter"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -91,8 +90,7 @@ it.live("loads a local plugin with its configured options", () =>
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
yield* plugins.awaitActivation
|
||||
const modules = yield* PluginModule.make().pipe(Effect.provide(Watcher.testLayer))
|
||||
const definition = yield* modules.load({
|
||||
const definition = yield* PluginModule.load({
|
||||
type: "add",
|
||||
target: path.join(import.meta.dir, "plugin/fixtures/greeting.ts"),
|
||||
options: { description: "Configured greeting" },
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Schedule, Scope, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { PluginModule } from "@opencode-ai/core/plugin/module"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
AppNodeBuilder.build(Npm.node, [Global.node.replace(tempGlobalLayer)]),
|
||||
Watcher.layer().pipe(Layer.provide(Watcher.nativeLayer)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("watches creation of an external helper with missing parent directories", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const entry = path.join(directory.path, "plugin/index.ts")
|
||||
yield* Effect.promise(() => Bun.write(entry, 'export { default } from "../shared/new/nested/helper.ts"'))
|
||||
const modules = yield* PluginModule.make()
|
||||
const operation = { type: "add" as const, target: path.dirname(entry), options: {} }
|
||||
expect(Exit.isFailure(yield* modules.load(operation).pipe(Effect.exit))).toBe(true)
|
||||
const changed = yield* modules
|
||||
.changes()
|
||||
.pipe(Stream.runHead, Effect.timeout("5 seconds"), Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(directory.path, "shared/new/nested/helper.ts"),
|
||||
'export default { id: "appeared", async setup() {} }',
|
||||
),
|
||||
)
|
||||
yield* Fiber.join(changed)
|
||||
const loaded = yield* modules.load(operation)
|
||||
expect(loaded).toMatchObject({ id: "appeared" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("interrupts pending watcher setup when the loader scope closes during module evaluation", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const entry = path.join(directory.path, "index.ts")
|
||||
const entered = path.join(directory.path, "entered")
|
||||
const release = path.join(directory.path, "release")
|
||||
const started = yield* Deferred.make<void>()
|
||||
const stopped = yield* Deferred.make<void>()
|
||||
const gate = yield* Deferred.make<void>()
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => Bun.write(release, "release")).pipe(
|
||||
Effect.andThen(Deferred.succeed(gate, undefined)),
|
||||
Effect.andThen(Scope.close(scope, Exit.void)),
|
||||
),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
entry,
|
||||
`
|
||||
await Bun.write(${JSON.stringify(entered)}, "entered")
|
||||
while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(5)
|
||||
export default { id: "pending", async setup() {} }
|
||||
`,
|
||||
),
|
||||
)
|
||||
const modules = yield* PluginModule.make().pipe(
|
||||
Effect.provideService(Scope.Scope, scope),
|
||||
Effect.provideService(Watcher.Service, {
|
||||
subscribe: () =>
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(gate)),
|
||||
Effect.ensuring(Deferred.succeed(stopped, undefined)),
|
||||
Effect.as(Stream.never),
|
||||
),
|
||||
}),
|
||||
)
|
||||
yield* modules.load({ type: "add", target: directory.path, options: {} }).pipe(Effect.forkIn(scope))
|
||||
yield* Deferred.await(started)
|
||||
yield* Effect.promise(() => Bun.file(entered).exists()).pipe(
|
||||
Effect.repeat({ until: (exists) => exists, schedule: Schedule.spaced("5 millis") }),
|
||||
Effect.timeout("2 seconds"),
|
||||
)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* Deferred.isDone(stopped)).toBe(true)
|
||||
yield* Effect.promise(() => Bun.write(release, "release"))
|
||||
// Let the uncancellable native import finish; Bun's test runner detects unhandled rejections.
|
||||
yield* Effect.promise(() => Bun.sleep(50))
|
||||
}),
|
||||
)
|
||||
@@ -1,11 +1,13 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { fakeSelectorSdk } from "../fixture/selector"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -43,183 +45,529 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Ef
|
||||
)
|
||||
}
|
||||
|
||||
const noAmbientAWS = {
|
||||
AWS_PROFILE: undefined,
|
||||
AWS_ACCESS_KEY_ID: undefined,
|
||||
AWS_WEB_IDENTITY_TOKEN_FILE: undefined,
|
||||
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined,
|
||||
AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined,
|
||||
AWS_REGION: undefined,
|
||||
AWS_DEFAULT_REGION: undefined,
|
||||
function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
|
||||
const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID)
|
||||
return (language as { config: { baseUrl: () => string } }).config.baseUrl()
|
||||
}
|
||||
|
||||
const seedBedrock = Effect.fn(function* (settings?: Record<string, unknown>) {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(Provider.ID.amazonBedrock, (item) => {
|
||||
item.package = Provider.aisdk("@ai-sdk/amazon-bedrock")
|
||||
if (settings) item.settings = settings
|
||||
})
|
||||
function bedrockFetch(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
|
||||
const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID)
|
||||
return (
|
||||
language as { config: { fetch: (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response> } }
|
||||
).config.fetch
|
||||
}
|
||||
|
||||
function openAIUrl(language: unknown, path: string, modelId: string) {
|
||||
return (language as { config: { url: (input: { path: string; modelId: string }) => string } }).config.url({
|
||||
path,
|
||||
modelId,
|
||||
})
|
||||
return catalog
|
||||
})
|
||||
}
|
||||
|
||||
describe("AmazonBedrockPlugin", () => {
|
||||
it.effect("moves endpoint setting to baseURL", () =>
|
||||
withEnv(noAmbientAWS, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seedBedrock({ endpoint: "https://bedrock.example" })
|
||||
yield* addPlugin()
|
||||
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
|
||||
expect(result.package).toBe(Provider.aisdk("@ai-sdk/amazon-bedrock"))
|
||||
expect(result.settings).toEqual({ baseURL: "https://bedrock.example", region: "us-east-1" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("keeps an explicit baseURL over endpoint", () =>
|
||||
withEnv(noAmbientAWS, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seedBedrock({ baseURL: "https://base.example", endpoint: "https://endpoint.example" })
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.amazonBedrock)).settings).toEqual({
|
||||
baseURL: "https://base.example",
|
||||
region: "us-east-1",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("only treats the bearer token env var as a key credential", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const integrationID = Integration.ID.make(Provider.ID.amazonBedrock)
|
||||
yield* integrations.transform((editor) => {
|
||||
editor.method.update({ integrationID, method: { type: "key" } })
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: {
|
||||
type: "env",
|
||||
names: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION", "AWS_BEARER_TOKEN_BEDROCK"],
|
||||
},
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(Provider.ID.amazonBedrock, (item) => {
|
||||
item.package = Provider.aisdk("@ai-sdk/amazon-bedrock")
|
||||
item.settings = { endpoint: "https://bedrock.example" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect((yield* integrations.get(integrationID))?.methods).toEqual([
|
||||
{ type: "key" },
|
||||
{ type: "env", names: ["AWS_BEARER_TOKEN_BEDROCK"] },
|
||||
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
|
||||
expect(result.package).toBe(Provider.aisdk("@ai-sdk/amazon-bedrock"))
|
||||
expect(result.settings).toEqual({ baseURL: "https://bedrock.example" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prefers endpoint over baseURL for SDK base URL", () =>
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
bearerToken: "token",
|
||||
baseURL: "https://base.example",
|
||||
endpoint: "https://endpoint.example",
|
||||
region: "us-east-1",
|
||||
},
|
||||
})
|
||||
expect(bedrockBaseURL(result.sdk)).toBe("https://endpoint.example")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses baseURL as SDK base URL", () =>
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
bearerToken: "token",
|
||||
baseURL: "https://base.example",
|
||||
region: "us-east-1",
|
||||
},
|
||||
})
|
||||
expect(bedrockBaseURL(result.sdk)).toBe("https://base.example")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("creates SDK without explicit credential env so the default AWS chain can resolve credentials", () =>
|
||||
withEnv(
|
||||
{
|
||||
AWS_ACCESS_KEY_ID: undefined,
|
||||
AWS_BEARER_TOKEN_BEDROCK: undefined,
|
||||
AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined,
|
||||
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined,
|
||||
AWS_PROFILE: undefined,
|
||||
AWS_REGION: undefined,
|
||||
AWS_WEB_IDENTITY_TOKEN_FILE: undefined,
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: { name: "amazon-bedrock" },
|
||||
})
|
||||
expect(result.sdk).toBeDefined()
|
||||
expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses config region over AWS_REGION for SDK base URL", () =>
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: { name: "amazon-bedrock", region: "eu-west-1" },
|
||||
})
|
||||
expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses AWS_REGION for SDK base URL when config region is absent", () =>
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: { name: "amazon-bedrock" },
|
||||
})
|
||||
expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("defaults SDK region to us-east-1", () =>
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: { name: "amazon-bedrock" },
|
||||
})
|
||||
expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("loads bearer token option into env and uses bearer auth", () =>
|
||||
withEnv({ AWS_ACCESS_KEY_ID: undefined, AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const headers: Array<string | null> = []
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
bearerToken: "option-token",
|
||||
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
headers.push(new Headers(init?.headers).get("Authorization"))
|
||||
return new Response("{}")
|
||||
},
|
||||
},
|
||||
})
|
||||
yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" }))
|
||||
expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("option-token")
|
||||
expect(headers).toEqual(["Bearer option-token"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("prefers bearer token env over bearer token option", () =>
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "env-token" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const headers: Array<string | null> = []
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
bearerToken: "option-token",
|
||||
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
headers.push(new Headers(init?.headers).get("Authorization"))
|
||||
return new Response("{}")
|
||||
},
|
||||
},
|
||||
})
|
||||
yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" }))
|
||||
expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("env-token")
|
||||
expect(headers).toEqual(["Bearer env-token"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("creates Mantle SDK with GPT-5 OpenAI base path", () =>
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("openai.gpt-5.5")),
|
||||
modelID: Model.ID.make("openai.gpt-5.5"),
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock/mantle",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
bearerToken: "token",
|
||||
baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1",
|
||||
region: "us-east-2",
|
||||
},
|
||||
})
|
||||
const language = result.sdk.responses("openai.gpt-5.5")
|
||||
expect(openAIUrl(language, "/responses", "openai.gpt-5.5")).toBe(
|
||||
"https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("selects Mantle APIs without Bedrock cross-region prefixes", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("openai.gpt-5.5")),
|
||||
modelID: Model.ID.make("openai.gpt-5.5"),
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"),
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" },
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("openai.gpt-oss-safeguard-120b")),
|
||||
modelID: Model.ID.make("openai.gpt-oss-safeguard-120b"),
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"),
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { region: "us-east-1" },
|
||||
})
|
||||
expect(calls).toEqual(["responses:openai.gpt-5.5", "chat:openai.gpt-oss-safeguard-120b"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores other Bedrock provider subpaths", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock/anthropic"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock/anthropic",
|
||||
options: { name: "amazon-bedrock" },
|
||||
})
|
||||
expect(result.sdk).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses SigV4 credential env when bearer token is absent", () =>
|
||||
withEnv(
|
||||
{
|
||||
AWS_ACCESS_KEY_ID: "test-access-key",
|
||||
AWS_BEARER_TOKEN_BEDROCK: undefined,
|
||||
AWS_REGION: "us-east-1",
|
||||
AWS_SECRET_ACCESS_KEY: "test-secret-key",
|
||||
AWS_SESSION_TOKEN: "test-session-token",
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const headers: Array<string | null> = []
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
headers.push(new Headers(init?.headers).get("Authorization"))
|
||||
return new Response("{}")
|
||||
},
|
||||
},
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
bedrockFetch(result.sdk)("https://bedrock-runtime.us-east-1.amazonaws.com/model/test/invoke", {
|
||||
body: "{}",
|
||||
method: "POST",
|
||||
}),
|
||||
)
|
||||
expect(headers[0]?.startsWith("AWS4-HMAC-SHA256 ")).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies legacy cross-region inference prefixes", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "eu-west-1" },
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("global.anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("global.anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "eu-west-1" },
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "ap-northeast-1" },
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "ap-southeast-2" },
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
"languageModel:us.anthropic.claude-sonnet-4-5",
|
||||
"languageModel:eu.anthropic.claude-sonnet-4-5",
|
||||
"languageModel:global.anthropic.claude-sonnet-4-5",
|
||||
"languageModel:jp.anthropic.claude-sonnet-4-5",
|
||||
"languageModel:au.anthropic.claude-sonnet-4-5",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("leaves activation on auto without ambient AWS configuration", () =>
|
||||
withEnv(noAmbientAWS, () =>
|
||||
it.effect("uses AWS_REGION for language prefixes when region option is absent", () =>
|
||||
withEnv({ AWS_REGION: "eu-west-1" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seedBedrock()
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.amazonBedrock)).activation).toBe("auto")
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
})
|
||||
expect(calls).toEqual(["languageModel:eu.anthropic.claude-sonnet-4-5"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
for (const name of Object.keys(noAmbientAWS).filter((name) => !name.includes("REGION"))) {
|
||||
it.effect(`enables the provider when ${name} is set`, () =>
|
||||
withEnv({ ...noAmbientAWS, [name]: "value" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seedBedrock()
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.amazonBedrock)).activation).toBe("enabled")
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("enables the provider when a profile is configured", () =>
|
||||
withEnv(noAmbientAWS, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seedBedrock({ profile: "work" })
|
||||
yield* addPlugin()
|
||||
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
|
||||
expect(result.activation).toBe("enabled")
|
||||
expect(result.settings).toEqual({ profile: "work", region: "us-east-1" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not override a disabled provider", () =>
|
||||
withEnv({ ...noAmbientAWS, AWS_PROFILE: "work" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(Provider.ID.amazonBedrock, (item) => {
|
||||
item.package = Provider.aisdk("@ai-sdk/amazon-bedrock")
|
||||
item.activation = "disabled"
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.amazonBedrock)).activation).toBe("disabled")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("fills region from AWS_REGION then AWS_DEFAULT_REGION without overriding config", () =>
|
||||
withEnv({ ...noAmbientAWS, AWS_REGION: "eu-west-1", AWS_DEFAULT_REGION: "us-west-2" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seedBedrock()
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.amazonBedrock)).settings).toEqual({
|
||||
region: "eu-west-1",
|
||||
})
|
||||
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(Provider.ID.amazonBedrock, (item) => {
|
||||
item.settings = { region: "ap-southeast-2" }
|
||||
})
|
||||
})
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.amazonBedrock)).settings).toEqual({
|
||||
it.effect("applies the full legacy cross-region prefix matrix", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
const cases = [
|
||||
{ region: "us-east-1", modelID: "amazon.nova-micro-v1:0", expected: "us.amazon.nova-micro-v1:0" },
|
||||
{ region: "us-east-1", modelID: "amazon.nova-lite-v1:0", expected: "us.amazon.nova-lite-v1:0" },
|
||||
{ region: "us-east-1", modelID: "amazon.nova-pro-v1:0", expected: "us.amazon.nova-pro-v1:0" },
|
||||
{ region: "us-east-1", modelID: "amazon.nova-premier-v1:0", expected: "us.amazon.nova-premier-v1:0" },
|
||||
{ region: "us-east-1", modelID: "amazon.nova-2-lite-v1:0", expected: "us.amazon.nova-2-lite-v1:0" },
|
||||
{ region: "us-east-1", modelID: "anthropic.claude-sonnet-4-5", expected: "us.anthropic.claude-sonnet-4-5" },
|
||||
{ region: "us-east-1", modelID: "deepseek.r1-v1:0", expected: "us.deepseek.r1-v1:0" },
|
||||
{ region: "us-gov-west-1", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" },
|
||||
{ region: "us-east-1", modelID: "cohere.command-r-plus-v1:0", expected: "cohere.command-r-plus-v1:0" },
|
||||
{ region: "eu-west-1", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" },
|
||||
{ region: "eu-west-2", modelID: "amazon.nova-lite-v1:0", expected: "eu.amazon.nova-lite-v1:0" },
|
||||
{ region: "eu-west-3", modelID: "amazon.nova-micro-v1:0", expected: "eu.amazon.nova-micro-v1:0" },
|
||||
{
|
||||
region: "eu-north-1",
|
||||
modelID: "meta.llama3-70b-instruct-v1:0",
|
||||
expected: "eu.meta.llama3-70b-instruct-v1:0",
|
||||
},
|
||||
{ region: "eu-central-1", modelID: "mistral.pixtral-large-v1:0", expected: "eu.mistral.pixtral-large-v1:0" },
|
||||
{ region: "eu-south-1", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" },
|
||||
{ region: "eu-south-2", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" },
|
||||
{ region: "eu-central-2", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" },
|
||||
{ region: "eu-west-1", modelID: "cohere.command-r-plus-v1:0", expected: "cohere.command-r-plus-v1:0" },
|
||||
{
|
||||
region: "ap-southeast-2",
|
||||
modelID: "anthropic.claude-sonnet-4-5",
|
||||
expected: "au.anthropic.claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
region: "ap-southeast-4",
|
||||
modelID: "anthropic.claude-haiku-v1:0",
|
||||
expected: "au.anthropic.claude-haiku-v1:0",
|
||||
},
|
||||
{ region: "ap-southeast-2", modelID: "anthropic.claude-opus-4", expected: "apac.anthropic.claude-opus-4" },
|
||||
{
|
||||
region: "ap-northeast-1",
|
||||
modelID: "anthropic.claude-sonnet-4-5",
|
||||
expected: "jp.anthropic.claude-sonnet-4-5",
|
||||
},
|
||||
{ region: "ap-northeast-1", modelID: "amazon.nova-pro-v1:0", expected: "jp.amazon.nova-pro-v1:0" },
|
||||
{ region: "ap-south-1", modelID: "anthropic.claude-sonnet-4-5", expected: "apac.anthropic.claude-sonnet-4-5" },
|
||||
{ region: "ap-south-1", modelID: "amazon.nova-lite-v1:0", expected: "apac.amazon.nova-lite-v1:0" },
|
||||
{ region: "ca-central-1", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" },
|
||||
{
|
||||
region: "us-east-1",
|
||||
modelID: "global.anthropic.claude-sonnet-4-5",
|
||||
expected: "global.anthropic.claude-sonnet-4-5",
|
||||
},
|
||||
{ region: "us-east-1", modelID: "us.anthropic.claude-sonnet-4-5", expected: "us.anthropic.claude-sonnet-4-5" },
|
||||
{ region: "eu-west-1", modelID: "eu.anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" },
|
||||
{
|
||||
region: "ap-northeast-1",
|
||||
modelID: "jp.anthropic.claude-sonnet-4-5",
|
||||
expected: "jp.anthropic.claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
region: "ap-south-1",
|
||||
modelID: "apac.anthropic.claude-sonnet-4-5",
|
||||
expected: "apac.anthropic.claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
region: "ap-southeast-2",
|
||||
modelID: "au.anthropic.claude-sonnet-4-5",
|
||||
expected: "au.anthropic.claude-sonnet-4-5",
|
||||
},
|
||||
]
|
||||
yield* addPlugin()
|
||||
for (const item of cases) {
|
||||
yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make(item.modelID)),
|
||||
modelID: Model.ID.make(item.modelID),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: item.region },
|
||||
})
|
||||
}),
|
||||
),
|
||||
}
|
||||
expect(calls).toEqual(cases.map((item) => `languageModel:${item.expected}`))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to AWS_DEFAULT_REGION then us-east-1", () =>
|
||||
withEnv({ ...noAmbientAWS, AWS_DEFAULT_REGION: "us-west-2" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seedBedrock()
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.amazonBedrock)).settings).toEqual({
|
||||
region: "us-west-2",
|
||||
})
|
||||
const fallback = yield* Effect.gen(function* () {
|
||||
yield* catalog.reload()
|
||||
return required(yield* catalog.provider.get(Provider.ID.amazonBedrock)).settings
|
||||
}).pipe((fx) => withEnv({ AWS_DEFAULT_REGION: undefined }, () => fx))
|
||||
expect(fallback).toEqual({ region: "us-east-1" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies to Mantle and native Bedrock packages", () =>
|
||||
withEnv({ ...noAmbientAWS, AWS_PROFILE: "work" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(Provider.ID.make("mantle"), (item) => {
|
||||
item.package = Provider.aisdk("@ai-sdk/amazon-bedrock/mantle")
|
||||
})
|
||||
catalog.provider.update(Provider.ID.make("native"), (item) => {
|
||||
item.package = "@opencode-ai/ai/providers/amazon-bedrock"
|
||||
})
|
||||
catalog.provider.update(Provider.ID.make("other"), (item) => {
|
||||
item.package = Provider.aisdk("@ai-sdk/anthropic")
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.make("mantle"))).activation).toBe("enabled")
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.make("native"))).activation).toBe("enabled")
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.make("other"))).activation).toBe("auto")
|
||||
}),
|
||||
),
|
||||
it.effect("ignores non-Bedrock providers for language selection", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.openai, Model.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "eu-west-1" },
|
||||
})
|
||||
expect(calls).toEqual([])
|
||||
expect(result.language).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "primary")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent", "X-Interaction-Id": event.sessionID })
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -183,11 +183,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).child, "primary")
|
||||
expect(event.headers).toEqual({
|
||||
"X-Interaction-Type": "conversation-subagent",
|
||||
"X-Interaction-Id": event.sessionID,
|
||||
"x-initiator": "agent",
|
||||
})
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-subagent", "x-initiator": "agent" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -195,11 +191,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "title")
|
||||
expect(event.headers).toEqual({
|
||||
"X-Interaction-Type": "conversation-background",
|
||||
"X-Interaction-Id": event.sessionID,
|
||||
"x-initiator": "agent",
|
||||
})
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-background", "x-initiator": "agent" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -207,11 +199,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).child, "compaction", "build")
|
||||
expect(event.headers).toEqual({
|
||||
"X-Interaction-Type": "conversation-compaction",
|
||||
"X-Interaction-Id": event.sessionID,
|
||||
"x-initiator": "agent",
|
||||
})
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-compaction", "x-initiator": "agent" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -219,7 +207,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "primary", "compaction")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent", "X-Interaction-Id": event.sessionID })
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -264,7 +252,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "generate")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent", "X-Interaction-Id": event.sessionID })
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import { Instance } from "@opencode-ai/core/instance"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { Rpc } from "@opencode-ai/core/rpc"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
@@ -111,86 +110,6 @@ const failed = (plugins: Plugin.Interface) =>
|
||||
)
|
||||
|
||||
describe("PluginSupervisor reload", () => {
|
||||
;(
|
||||
[
|
||||
{ name: "on a helper-only save", helper: "nested/helper.ts", touchEntry: false },
|
||||
{ name: "when the entrypoint also changes", helper: "nested/helper.ts", touchEntry: true },
|
||||
{ name: "outside the configured plugin directory", helper: "../shared/helper.ts", touchEntry: false },
|
||||
] as const
|
||||
).forEach((scenario) => {
|
||||
it.live(`reloads helper-defined RPC methods ${scenario.name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const root = path.join(directory.path, "external/greeter")
|
||||
const file = path.join(root, "index.ts")
|
||||
const helper = path.join(root, scenario.helper)
|
||||
const entry = `export { default } from ${JSON.stringify("./" + scenario.helper)}`
|
||||
const source = (version: number) => `import { Schema } from ${JSON.stringify(import.meta.resolve("effect"))}
|
||||
export default {
|
||||
id: "greeter",
|
||||
async setup(ctx) {
|
||||
await ctx.rpc.register({ id: "greeter", methods: {
|
||||
status: { input: Schema.Unknown, output: Schema.Number },
|
||||
${version > 1 ? "info: { input: Schema.Unknown, output: Schema.Number }," : ""}
|
||||
}, events: {} }, {
|
||||
status: async () => ${version},
|
||||
${version > 1 ? `info: async () => ${version},` : ""}
|
||||
})
|
||||
await ctx.command.transform(editor => editor.add({ name: "greet-v${version}", execute: async () => {} }))
|
||||
}
|
||||
}`
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(file, entry)
|
||||
await Bun.write(helper, source(1))
|
||||
await Bun.write(path.join(directory.path, ".opencode/opencode.json"), JSON.stringify({ plugins: [root] }))
|
||||
})
|
||||
const watcher = yield* Watcher.Test
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
const commands = yield* Command.Service
|
||||
yield* plugins.awaitActivation
|
||||
expect(yield* rpc.call("greeter", "status", {})).toBe(1)
|
||||
expect(yield* rpc.call("greeter", "info", {}).pipe(Effect.flip)).toMatchObject({
|
||||
type: "rpc.method_not_found",
|
||||
})
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(helper, source(2))
|
||||
if (scenario.touchEntry) {
|
||||
await Bun.write(file, entry + "; // updated entry")
|
||||
await fs.utimes(file, new Date(), new Date(Date.now() + 1000))
|
||||
}
|
||||
})
|
||||
yield* watcher.emit({ path: scenario.touchEntry ? file : helper, type: "update" })
|
||||
yield* rpc
|
||||
.call("greeter", "info", {})
|
||||
.pipe(Effect.retry({ times: 80, schedule: Schedule.spaced("25 millis") }))
|
||||
expect(yield* rpc.call("greeter", "info", {})).toBe(2)
|
||||
expect(yield* commands.get("greet-v1")).toBeUndefined()
|
||||
expect(yield* commands.get("greet-v2")).toBeDefined()
|
||||
|
||||
// Failed helper evaluations retain the active registration and recover on the next save.
|
||||
yield* Effect.promise(() => Bun.write(helper, 'throw new Error("broken helper"); export default {}'))
|
||||
yield* watcher.emit({ path: helper, type: "update" })
|
||||
yield* failed(plugins)
|
||||
expect(yield* rpc.call("greeter", "info", {})).toBe(2)
|
||||
yield* Effect.promise(() => Bun.write(helper, source(3)))
|
||||
yield* watcher.emit({ path: helper, type: "update" })
|
||||
yield* commands.get("greet-v3").pipe(
|
||||
Effect.flatMap((command) => (command ? Effect.void : Effect.fail("activation pending"))),
|
||||
Effect.retry({ times: 80, schedule: Schedule.spaced("25 millis") }),
|
||||
)
|
||||
expect(yield* rpc.call("greeter", "info", {})).toBe(3)
|
||||
expect(yield* commands.get("greet-v2")).toBeUndefined()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
;(["discovered", "configured"] as const).forEach((mode) => {
|
||||
it.effect(`retains a ${mode} plugin change during initial activation`, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -578,73 +578,40 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
for (const ownership of ["none", "source", "other"] as const) {
|
||||
it.effect(`replays a fork with ${ownership} ownership and stable projected identities`, () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const source = yield* session.create({
|
||||
location,
|
||||
title: "Source",
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ id: Model.ID.make("source"), providerID: Provider.ID.make("test") }),
|
||||
metadata: { source: true },
|
||||
})
|
||||
const parentID =
|
||||
ownership === "none"
|
||||
? undefined
|
||||
: ownership === "source"
|
||||
? source.id
|
||||
: (yield* session.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/owner") }),
|
||||
title: "Owner",
|
||||
metadata: { owner: true },
|
||||
})).id
|
||||
yield* session.prompt({ sessionID: source.id, text: "First", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, source.id, "steer")
|
||||
yield* session.synthetic({ sessionID: source.id, text: "Second", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, source.id, "steer")
|
||||
const forked = yield* session.fork({ sessionID: source.id, boundary: { type: "through" }, parentID })
|
||||
expect(forked.parentID).toBe(parentID)
|
||||
expect(forked).toMatchObject({
|
||||
title: "Source (fork #1)",
|
||||
agent: source.agent,
|
||||
model: source.model,
|
||||
metadata: source.metadata,
|
||||
location: source.location,
|
||||
fork: { sessionID: source.id },
|
||||
})
|
||||
const original = (yield* session.context(forked.id)).map((message) => message.id)
|
||||
const recorded = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, forked.id))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(recorded.map((event) => event.type)).toEqual(
|
||||
parentID ? ["session.created.1", "session.forked.2"] : ["session.forked.2"],
|
||||
)
|
||||
it.effect("replays a fork with stable projected identities", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const parent = yield* session.create({ location, title: "Parent" })
|
||||
yield* session.prompt({ sessionID: parent.id, text: "First", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, parent.id, "steer")
|
||||
yield* session.synthetic({ sessionID: parent.id, text: "Second", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, parent.id, "steer")
|
||||
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
|
||||
const original = (yield* session.context(forked.id)).map((message) => message.id)
|
||||
const recorded = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, forked.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!recorded) return yield* Effect.die(new Error("Fork event not found"))
|
||||
|
||||
yield* bus.remove(forked.id)
|
||||
yield* db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run().pipe(Effect.orDie)
|
||||
yield* Effect.forEach(recorded, (event) =>
|
||||
bus.replay({
|
||||
id: event.id,
|
||||
created: event.created,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
}),
|
||||
)
|
||||
yield* bus.remove(forked.id)
|
||||
yield* db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run().pipe(Effect.orDie)
|
||||
yield* bus.replay({
|
||||
id: recorded.id,
|
||||
created: recorded.created,
|
||||
aggregateID: recorded.aggregate_id,
|
||||
seq: recorded.seq,
|
||||
type: recorded.type,
|
||||
data: recorded.data,
|
||||
})
|
||||
|
||||
expect((yield* session.context(forked.id)).map((message) => message.id)).toEqual(original)
|
||||
expect(yield* session.get(forked.id)).toEqual(forked)
|
||||
}),
|
||||
)
|
||||
}
|
||||
expect((yield* session.context(forked.id)).map((message) => message.id)).toEqual(original)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("inherits instruction entries when forking", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -30,12 +30,11 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -178,291 +177,6 @@ const withSubagent = (location: Location.Ref) =>
|
||||
})
|
||||
|
||||
describe("SubagentTool", () => {
|
||||
for (const enabled of [undefined, false, true]) {
|
||||
productionIt.live(`gates the fork parameter with experimental.subagent_fork=${enabled}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ experimental: { subagent_fork: enabled } })),
|
||||
)
|
||||
const sessions = yield* Session.Service
|
||||
const parent = yield* sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(dir.path) }),
|
||||
})
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const snapshot = yield* registry.snapshot()
|
||||
const definition = snapshot.definitions.find((tool) => tool.name === SubagentTool.name)!
|
||||
const context = yield* hooks.trigger("session", "context", {
|
||||
sessionID: parent.id,
|
||||
agent: toolIdentity.agent,
|
||||
model: parentModel,
|
||||
system: [],
|
||||
messages: [],
|
||||
tools: { subagent: { description: definition.description, input: { ...definition.inputSchema } } },
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
})
|
||||
expect(Object.keys(context.tools.subagent.input.properties ?? {})).toContain("sessionID")
|
||||
expect(Object.keys(context.tools.subagent.input.properties ?? {}).includes("fork")).toBe(enabled === true)
|
||||
expect(context.tools.subagent.input).toEqual(definition.inputSchema)
|
||||
expect(Object.keys(definition.inputSchema.properties ?? {}).includes("fork")).toBe(enabled === true)
|
||||
if (enabled === true) return
|
||||
expect(Object.keys(definition.inputSchema.properties ?? {})).toEqual([
|
||||
"agent",
|
||||
"description",
|
||||
"prompt",
|
||||
"sessionID",
|
||||
"background",
|
||||
])
|
||||
expect(definition.description).toBe(SubagentTool.description)
|
||||
expect(definition.description).toContain(
|
||||
"New child sessions start with fresh context, so include all relevant context and instructions when you don't pass a sessionID.",
|
||||
)
|
||||
expect(JSON.stringify(context.tools.subagent)).not.toMatch(/fork/i)
|
||||
// An unknown field keeps the original schema's behavior; it cannot enable forking or advertise it.
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-disabled-fork",
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "review", prompt: "review", fork: true },
|
||||
},
|
||||
})
|
||||
expect(result.status).toBe("completed")
|
||||
const childID = outputSessionID(result.metadata)
|
||||
expect((yield* sessions.get(childID)).fork).toBeUndefined()
|
||||
expect((yield* sessions.inbox(childID)).find((message) => message.type === "user")?.payload.text).toBe(
|
||||
"You are a subagent spawned by another session.\nreview",
|
||||
)
|
||||
const continued = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-disabled-fork-continuation",
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "review", prompt: "continue", fork: true, sessionID: childID },
|
||||
},
|
||||
})
|
||||
expect(continued.status).toBe("completed")
|
||||
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("rejects fork together with sessionID without changing the child", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ experimental: { subagent_fork: true } })),
|
||||
)
|
||||
const sessions = yield* Session.Service
|
||||
const parent = yield* sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(dir.path) }),
|
||||
})
|
||||
const child = yield* sessions.create({ parentID: parent.id })
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
for (const fork of [false, true]) {
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: `call-conflicting-fork-${fork}`,
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "review", prompt: "review", fork, sessionID: child.id },
|
||||
},
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
status: "error",
|
||||
error: { message: "Cannot use fork with sessionID. Omit one of them." },
|
||||
})
|
||||
}
|
||||
expect(yield* sessions.get(child.id)).toEqual(child)
|
||||
expect(yield* sessions.inbox(child.id)).toEqual([])
|
||||
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
completionIt.live("sends inherited history to a forked child and preserves it on continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ experimental: { subagent_fork: true } })),
|
||||
)
|
||||
const sessions = yield* Session.Service
|
||||
const parent = yield* sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(dir.path) }),
|
||||
agent: toolIdentity.agent,
|
||||
model: parentModel,
|
||||
metadata: { source: "fork-test" },
|
||||
})
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const requests: PluginHooks.Domains["session"]["context"][] = []
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(event)
|
||||
}),
|
||||
)
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* sessions.prompt({ sessionID: parent.id, text: "Remember the project context", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, parent.id, "steer")
|
||||
const previous = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: parent.id,
|
||||
assistantMessageID: previous,
|
||||
agent: toolIdentity.agent,
|
||||
model: parentModel,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: parent.id,
|
||||
assistantMessageID: previous,
|
||||
finish: "tool-calls",
|
||||
cost: Money.USD.zero,
|
||||
tokens,
|
||||
})
|
||||
yield* sessions.updateMessage({
|
||||
sessionID: parent.id,
|
||||
messageID: previous,
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-parent-read",
|
||||
name: "read",
|
||||
time: { created: parent.time.created },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "README.md" },
|
||||
content: [{ type: "text", text: "Inherited file contents" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const spawning = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: parent.id,
|
||||
assistantMessageID: spawning,
|
||||
agent: toolIdentity.agent,
|
||||
model: parentModel,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Text.Started, { sessionID: parent.id, assistantMessageID: spawning, ordinal: 0 })
|
||||
yield* bus.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: parent.id,
|
||||
assistantMessageID: spawning,
|
||||
ordinal: 0,
|
||||
text: "Spawning response must not be inherited",
|
||||
})
|
||||
yield* sessions.prompt({ sessionID: parent.id, text: "Later parent message", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, parent.id, "steer")
|
||||
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
messageID: spawning,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-fork",
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "fork review", prompt: "Review the file", fork: true },
|
||||
},
|
||||
})
|
||||
expect(result.status).toBe("completed")
|
||||
const child = yield* sessions.get(outputSessionID(result.metadata))
|
||||
expect(child).toMatchObject({
|
||||
parentID: parent.id,
|
||||
title: "fork review",
|
||||
agent: "reviewer",
|
||||
model: childModel,
|
||||
metadata: parent.metadata,
|
||||
fork: { sessionID: parent.id, boundary: { type: "before", messageID: spawning } },
|
||||
})
|
||||
const request = requests.find((request) => request.sessionID === child.id)!
|
||||
expect(request.agent).toBe(Agent.ID.make("reviewer"))
|
||||
expect(request.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "user", content: [{ type: "text", text: "Remember the project context" }] }),
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: [expect.objectContaining({ type: "tool-call", id: "call-parent-read" })],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: "tool",
|
||||
content: [
|
||||
expect.objectContaining({
|
||||
type: "tool-result",
|
||||
result: { type: "text", value: "Inherited file contents" },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "You are a forked subagent. Use the inherited history as context and perform only the task below.\nReview the file",
|
||||
},
|
||||
],
|
||||
}),
|
||||
]),
|
||||
)
|
||||
expect(JSON.stringify(request.messages)).not.toContain("Spawning response must not be inherited")
|
||||
expect(JSON.stringify(request.messages)).not.toContain("Later parent message")
|
||||
expect((yield* sessions.context(child.id)).map((message) => message.id)).not.toContain(previous)
|
||||
|
||||
const continued = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-fork-continue",
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "follow up", prompt: "Continue reviewing", sessionID: child.id },
|
||||
},
|
||||
})
|
||||
expect(outputSessionID(continued.metadata)).toBe(child.id)
|
||||
const latest = requests.findLast((request) => request.sessionID === child.id)!
|
||||
expect(JSON.stringify(latest.messages)).toContain("Inherited file contents")
|
||||
expect(JSON.stringify(latest.messages)).toContain("Continue reviewing")
|
||||
expect(JSON.stringify(latest.messages)).not.toContain("Later parent message")
|
||||
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1)
|
||||
for (const fork of [undefined, false]) {
|
||||
const fresh = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: `call-fresh-${fork}`,
|
||||
name: SubagentTool.name,
|
||||
input: {
|
||||
agent: "fallback",
|
||||
description: "fresh",
|
||||
prompt: "Start fresh",
|
||||
...(fork === undefined ? {} : { fork }),
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(fresh.status).toBe("completed")
|
||||
const freshChild = yield* sessions.get(outputSessionID(fresh.metadata))
|
||||
expect(freshChild.fork).toBeUndefined()
|
||||
expect(freshChild.model).toMatchObject(parentModel)
|
||||
const freshRequest = requests.find((request) => request.sessionID === freshChild.id)!
|
||||
expect(JSON.stringify(freshRequest.messages)).not.toContain("Inherited file contents")
|
||||
expect(JSON.stringify(freshRequest.messages)).toContain("Start fresh")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
completionIt.live("admits one durable completion across live delivery and restart replay", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { addRendererHeaders, documentPolicyHeader, jsCallStacksDocumentPolicy, upsertHeader } from "./headers"
|
||||
|
||||
describe("renderer response headers", () => {
|
||||
test("keeps the server's exact allow-headers list so Chromium can reuse the cached preflight", () => {
|
||||
const headers = {
|
||||
"access-control-allow-origin": ["oc://renderer"],
|
||||
"access-control-allow-headers": ["authorization"],
|
||||
"access-control-max-age": ["86400"],
|
||||
}
|
||||
addRendererHeaders(headers, { document: false })
|
||||
expect(headers).toEqual({
|
||||
"access-control-allow-origin": ["*"],
|
||||
"access-control-allow-headers": ["authorization"],
|
||||
"access-control-max-age": ["86400"],
|
||||
})
|
||||
})
|
||||
|
||||
test("fills CORS headers for servers that send none, naming authorization explicitly", () => {
|
||||
const headers: Record<string, string[]> = { "content-type": ["application/json"] }
|
||||
addRendererHeaders(headers, { document: false })
|
||||
expect(headers["Access-Control-Allow-Origin"]).toEqual(["*"])
|
||||
expect(headers["Access-Control-Allow-Headers"]).toEqual(["*, authorization"])
|
||||
expect(headers["Access-Control-Max-Age"]).toEqual(["7200"])
|
||||
})
|
||||
|
||||
test("adds the crash-report document policy only to renderer documents", () => {
|
||||
const document = {}
|
||||
addRendererHeaders(document, { document: true })
|
||||
expect(Reflect.get(document, documentPolicyHeader)).toEqual([jsCallStacksDocumentPolicy])
|
||||
const asset = {}
|
||||
addRendererHeaders(asset, { document: false })
|
||||
expect(Object.keys(asset)).not.toContain(documentPolicyHeader)
|
||||
})
|
||||
|
||||
test("upsert replaces a header regardless of key casing", () => {
|
||||
const headers = { "X-Test": ["a"] }
|
||||
upsertHeader(headers, "x-test", ["b"])
|
||||
expect(headers).toEqual({ "X-Test": ["b"] })
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
export const documentPolicyHeader = "Document-Policy"
|
||||
export const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports"
|
||||
|
||||
// The renderer lives at oc://renderer, so every server it talks to is cross-origin. Servers that
|
||||
// send no CORS headers get permissive ones here so the renderer can still reach them.
|
||||
//
|
||||
// A server's own `Access-Control-Allow-Headers` is kept as-is. Chromium reuses a cached preflight
|
||||
// only when that header names `authorization` explicitly (`*` never covers it), so overwriting the
|
||||
// server's exact list with `*` forced a fresh OPTIONS round trip in front of every API call.
|
||||
export function addRendererHeaders(headers: object, options: { document: boolean }) {
|
||||
upsertHeader(headers, "Access-Control-Allow-Origin", ["*"])
|
||||
if (!hasHeader(headers, "Access-Control-Allow-Headers")) {
|
||||
upsertHeader(headers, "Access-Control-Allow-Headers", ["*, authorization"])
|
||||
}
|
||||
if (!hasHeader(headers, "Access-Control-Max-Age")) {
|
||||
// Chromium caps preflight cache lifetime at two hours; without the header it caches for 5s.
|
||||
upsertHeader(headers, "Access-Control-Max-Age", ["7200"])
|
||||
}
|
||||
if (options.document) upsertHeader(headers, documentPolicyHeader, [jsCallStacksDocumentPolicy])
|
||||
}
|
||||
|
||||
export function hasHeader(headers: object, key: string) {
|
||||
return Object.keys(headers).some((header) => header.toLowerCase() === key.toLowerCase())
|
||||
}
|
||||
|
||||
export function upsertHeader(headers: object, key: string, value: string | string[]) {
|
||||
const current = Object.keys(headers).find((header) => header.toLowerCase() === key.toLowerCase())
|
||||
Reflect.set(headers, current ?? key, value)
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import { pathToFileURL } from "node:url"
|
||||
import { Effect, Path } from "effect"
|
||||
import { scoped } from "../native/logging"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
|
||||
|
||||
const rendererProtocol = "oc"
|
||||
const rendererHost = "renderer"
|
||||
const documentPolicyHeader = "Document-Policy"
|
||||
const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports"
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
@@ -84,6 +85,17 @@ export function isRendererUrl(value?: string, html = false) {
|
||||
return url.origin === new URL(devUrl).origin
|
||||
}
|
||||
|
||||
export function addRendererHeaders(value: string, headers: object) {
|
||||
upsertHeader(headers, "Access-Control-Allow-Origin", ["*"])
|
||||
upsertHeader(headers, "Access-Control-Allow-Headers", ["*"])
|
||||
if (isRendererUrl(value, true)) upsertHeader(headers, documentPolicyHeader, [jsCallStacksDocumentPolicy])
|
||||
}
|
||||
|
||||
export function upsertHeader(headers: object, key: string, value: string | string[]) {
|
||||
const current = Object.keys(headers).find((header) => header.toLowerCase() === key.toLowerCase())
|
||||
Reflect.set(headers, current ?? key, value)
|
||||
}
|
||||
|
||||
function addDocumentPolicy(response: Response, file: string) {
|
||||
if (!file.toLowerCase().endsWith(".html")) return response
|
||||
const headers = new Headers(response.headers)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { addRendererHeaders } from "./headers"
|
||||
import { isRendererUrl } from "./protocol"
|
||||
import { addRendererHeaders, isRendererUrl, upsertHeader } from "./protocol"
|
||||
|
||||
const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"])
|
||||
|
||||
@@ -31,9 +30,13 @@ export function wireNavigationPolicy(win: BrowserWindow, openExternalURL: (url:
|
||||
}
|
||||
|
||||
export function wireRendererHeaders(win: BrowserWindow) {
|
||||
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
upsertHeader(details.requestHeaders, "Access-Control-Allow-Origin", ["*"])
|
||||
callback({ requestHeaders: details.requestHeaders })
|
||||
})
|
||||
win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
||||
const responseHeaders = details.responseHeaders ?? {}
|
||||
addRendererHeaders(responseHeaders, { document: isRendererUrl(details.url, true) })
|
||||
addRendererHeaders(details.url, responseHeaders)
|
||||
callback({ responseHeaders })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,13 +16,6 @@
|
||||
"./tui": "./src/tui/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#plugin-source": {
|
||||
"bun": "./src/source.bun.ts",
|
||||
"node": "./src/source.node.ts",
|
||||
"default": "./src/source.node.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -16,7 +16,6 @@ const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string>
|
||||
imports: Record<string, Record<string, string>>
|
||||
}
|
||||
if (await published(pkg.name, pkg.version)) {
|
||||
console.log(`already published ${pkg.name}@${pkg.version}`)
|
||||
@@ -29,17 +28,6 @@ if (await published(pkg.name, pkg.version)) {
|
||||
types: file + ".d.ts",
|
||||
}
|
||||
}
|
||||
pkg.imports = Object.fromEntries(
|
||||
Object.entries(pkg.imports).map(([key, conditions]) => [
|
||||
key,
|
||||
Object.fromEntries(
|
||||
Object.entries(conditions).map(([condition, value]) => [
|
||||
condition,
|
||||
value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
)
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2))
|
||||
try {
|
||||
await $`bun pm pack`
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { readFileSync, readdirSync } from "node:fs"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import path from "node:path"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
|
||||
// Keep source fingerprints and import attempts together. Filesystem events
|
||||
// should reload changed local graphs, not repeat unchanged evaluations.
|
||||
export function createPluginSources(watch: (file: string) => Promise<void>) {
|
||||
const sources = new Map<string, Source>()
|
||||
const cleanups: Array<() => void> = []
|
||||
const watching = new Set<Promise<void>>()
|
||||
return {
|
||||
read: async (entrypoint: string) => {
|
||||
await Promise.all(watching)
|
||||
const previous = sources.get(entrypoint)
|
||||
if (previous && [...previous.files].every(([file, item]) => item.digest === digest(file, item.directory)))
|
||||
return previous.loaded
|
||||
|
||||
const files: Source["files"] = new Map()
|
||||
const track = (file: string, directory = false) => {
|
||||
if (files.has(file)) return
|
||||
files.set(file, { digest: digest(file, directory), directory })
|
||||
const pending = watch(file).finally(() => watching.delete(pending))
|
||||
watching.add(pending)
|
||||
// Watch setup can be interrupted while module evaluation is still pending.
|
||||
void pending.catch(() => {})
|
||||
}
|
||||
track(fileURLToPath(entrypoint))
|
||||
const { prepareSource } = await import("#plugin-source")
|
||||
const prepared: { version: string; load: () => Promise<unknown>; dispose?: () => void } = await prepareSource(
|
||||
entrypoint,
|
||||
track,
|
||||
)
|
||||
if (prepared.dispose) cleanups.push(prepared.dispose)
|
||||
// Cache the attempt before evaluating it: unchanged failing modules must
|
||||
// not repeat import-time effects on every filesystem notification.
|
||||
const loaded = prepared.load().then((module) => ({ version: prepared.version, module }))
|
||||
sources.set(entrypoint, { loaded, files })
|
||||
return loaded.finally(() => Promise.all(watching))
|
||||
},
|
||||
dispose: () => {
|
||||
for (const cleanup of cleanups.splice(0)) cleanup()
|
||||
sources.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Source = {
|
||||
loaded: Promise<{ version: string; module: unknown }>
|
||||
files: Map<string, { digest: string; directory: boolean }>
|
||||
}
|
||||
|
||||
function digest(file: string, directory: boolean) {
|
||||
try {
|
||||
return Hash.sha256(directory ? JSON.stringify(readdirSync(file).sort()) : readFileSync(file))
|
||||
} catch {
|
||||
return "missing"
|
||||
}
|
||||
}
|
||||
|
||||
export function localSource(spec: string, directory: string) {
|
||||
if (spec.startsWith("file://")) return new URL(spec)
|
||||
if (spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec))
|
||||
return pathToFileURL(path.resolve(directory, spec))
|
||||
return undefined
|
||||
}
|
||||
@@ -11,9 +11,6 @@ export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
|
||||
subagent_depth: NonNegativeInt.pipe(optional).annotate({
|
||||
description: "Maximum subagent nesting depth. Defaults to 1.",
|
||||
}),
|
||||
subagent_fork: Schema.Boolean.pipe(optional).annotate({
|
||||
description: "Enable the subagent fork parameter. Defaults to false.",
|
||||
}),
|
||||
policies: ConfigPolicy.Info.pipe(Schema.Array, optional).annotate({
|
||||
description: "Ordered policies controlling access to configured resources",
|
||||
}),
|
||||
|
||||
@@ -10,17 +10,6 @@ import { AbsolutePath } from "../src/schema.js"
|
||||
import { WebSearch } from "../src/websearch.js"
|
||||
|
||||
describe("Config.Entry", () => {
|
||||
test("keeps subagent forking opt-in", () => {
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const encode = Schema.encodeSync(Config.Info)
|
||||
expect(encode(decode({ experimental: {} }))).toEqual({ experimental: {} })
|
||||
for (const subagent_fork of [false, true]) {
|
||||
const input = { experimental: { subagent_fork } }
|
||||
expect(encode(decode(input))).toEqual(input)
|
||||
}
|
||||
expect(() => decode({ experimental: { subagent_fork: "true" } })).toThrow()
|
||||
})
|
||||
|
||||
test("accepts directory-only worktree config and omits it when absent", () => {
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const input = { worktree: { directory: "../worktrees" } }
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
"./component/register-spinner": "./src/component/register-spinner.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#plugin-source": {
|
||||
"bun": "./src/plugin/source.bun.ts",
|
||||
"node": "./src/plugin/source.node.ts",
|
||||
"default": "./src/plugin/source.node.ts"
|
||||
},
|
||||
"#attention-sounds": {
|
||||
"bun": "./src/attention-sounds.bun.ts",
|
||||
"node": "./src/attention-sounds.node.ts",
|
||||
|
||||
@@ -27,8 +27,8 @@ import { useToast } from "../ui/toast"
|
||||
import { formLabel, formToggleMultiselect, formValidateValue, type FormAnswerField } from "../util/form"
|
||||
|
||||
const INTEGRATION_PRIORITY: Record<string, number> = {
|
||||
"opencode-go": 0,
|
||||
opencode: 1,
|
||||
opencode: 0,
|
||||
"opencode-go": 1,
|
||||
openai: 2,
|
||||
"github-copilot": 3,
|
||||
anthropic: 4,
|
||||
|
||||
@@ -192,10 +192,7 @@ export function sortModelOptions<
|
||||
},
|
||||
>(options: T[], grouped = true) {
|
||||
return options.toSorted((a, b) => {
|
||||
const provider = grouped
|
||||
? Number(b.providerID === "opencode-go") - Number(a.providerID === "opencode-go") ||
|
||||
Number(b.providerID === "opencode") - Number(a.providerID === "opencode")
|
||||
: 0
|
||||
const provider = grouped ? Number(a.providerID !== "opencode") - Number(b.providerID !== "opencode") : 0
|
||||
if (provider !== 0) return provider
|
||||
|
||||
const name = grouped ? (a.providerName ?? "").localeCompare(b.providerName ?? "") : 0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readdir, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
export { localSource } from "@opencode-ai/plugin/source"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { isMissingPath, localProjectDirectory, projectConfigDirectories } from "../util/config-directories"
|
||||
|
||||
export async function localPluginDirectories(cwd: string, configDirectory: string) {
|
||||
@@ -49,3 +49,10 @@ export async function discoverPluginTargets(directories: string[]) {
|
||||
)
|
||||
).flat()
|
||||
}
|
||||
|
||||
export function localSource(spec: string, directory: string) {
|
||||
if (spec.startsWith("file://")) return new URL(spec)
|
||||
if (spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec))
|
||||
return pathToFileURL(path.resolve(directory, spec))
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -2,16 +2,15 @@ import { createRequire } from "node:module"
|
||||
import { readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { Host } from "./host.js"
|
||||
import { localSource } from "./source.js"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { localSource } from "./discovery"
|
||||
|
||||
let generation = Date.now()
|
||||
|
||||
export async function prepareSource(entrypoint: string, track: (file: string, directory?: boolean) => void) {
|
||||
const root = fileURLToPath(entrypoint)
|
||||
const files = new Set<string>()
|
||||
const visit = (file: string, search = "") => {
|
||||
if (file !== root && file.split(path.sep).includes("node_modules")) return
|
||||
if (file.split(path.sep).includes("node_modules")) return
|
||||
if (search) delete require.cache[file + search]
|
||||
if (files.has(file)) return
|
||||
files.add(file)
|
||||
@@ -55,6 +54,6 @@ export async function prepareSource(entrypoint: string, track: (file: string, di
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(root)
|
||||
visit(fileURLToPath(entrypoint))
|
||||
return { version: String(++generation), load: () => Host.load(entrypoint) }
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { registerHooks } from "node:module"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { localSource } from "./source.js"
|
||||
import { Host } from "./host.js"
|
||||
import { localSource } from "./discovery"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
|
||||
let generation = Date.now()
|
||||
|
||||
@@ -1 +1,56 @@
|
||||
export { createPluginSources } from "@opencode-ai/plugin/source"
|
||||
import { readFileSync, readdirSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
|
||||
// Keep source fingerprints and import attempts together. Filesystem events
|
||||
// should reload changed local graphs, not repeat unchanged evaluations.
|
||||
export function createPluginSources(watch: (file: string) => Promise<void>) {
|
||||
const sources = new Map<string, Source>()
|
||||
const cleanups: Array<() => void> = []
|
||||
const watching = new Set<Promise<void>>()
|
||||
return {
|
||||
read: async (entrypoint: string) => {
|
||||
await Promise.all(watching)
|
||||
const previous = sources.get(entrypoint)
|
||||
if (previous && [...previous.files].every(([file, item]) => item.digest === digest(file, item.directory)))
|
||||
return previous.loaded
|
||||
|
||||
const files: Source["files"] = new Map()
|
||||
const track = (file: string, directory = false) => {
|
||||
if (files.has(file)) return
|
||||
files.set(file, { digest: digest(file, directory), directory })
|
||||
const pending = watch(file).finally(() => watching.delete(pending))
|
||||
watching.add(pending)
|
||||
}
|
||||
track(fileURLToPath(entrypoint))
|
||||
const { prepareSource } = await import("#plugin-source")
|
||||
const prepared: { version: string; load: () => Promise<unknown>; dispose?: () => void } = await prepareSource(
|
||||
entrypoint,
|
||||
track,
|
||||
)
|
||||
if (prepared.dispose) cleanups.push(prepared.dispose)
|
||||
// Cache the attempt before evaluating it: unchanged failing modules must
|
||||
// not repeat import-time effects on every filesystem notification.
|
||||
const loaded = prepared.load().then((module) => ({ version: prepared.version, module }))
|
||||
sources.set(entrypoint, { loaded, files })
|
||||
return loaded.finally(() => Promise.all(watching))
|
||||
},
|
||||
dispose: () => {
|
||||
for (const cleanup of cleanups.splice(0)) cleanup()
|
||||
sources.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Source = {
|
||||
loaded: Promise<{ version: string; module: unknown }>
|
||||
files: Map<string, { digest: string; directory: boolean }>
|
||||
}
|
||||
|
||||
function digest(file: string, directory: boolean) {
|
||||
try {
|
||||
return Hash.sha256(directory ? JSON.stringify(readdirSync(file).sort()) : readFileSync(file))
|
||||
} catch {
|
||||
return "missing"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,8 @@ describe("integrationOptions", () => {
|
||||
integration({ id: "openai", name: "OpenAI" }),
|
||||
integration({ id: "custom-z", name: "Zebra" }),
|
||||
integration({ id: "anthropic", name: "Anthropic" }),
|
||||
integration({ id: "opencode", name: "OpenCode Zen" }),
|
||||
integration({ id: "opencode-go", name: "OpenCode Go" }),
|
||||
]).map((item) => item.id),
|
||||
).toEqual(["opencode-go", "opencode", "openai", "anthropic", "mistral", "custom-z"])
|
||||
).toEqual(["openai", "anthropic", "mistral", "custom-z"])
|
||||
})
|
||||
|
||||
test("keeps MCP integrations above popular integrations without relying on their IDs", () => {
|
||||
@@ -33,10 +31,8 @@ describe("integrationOptions", () => {
|
||||
integration({ id: "openai", name: "OpenAI" }),
|
||||
integration({ id: "linear", name: "Linear", metadata: { source: "mcp" } }),
|
||||
integration({ id: "github", name: "GitHub", metadata: { source: "mcp" } }),
|
||||
integration({ id: "opencode", name: "OpenCode Zen" }),
|
||||
integration({ id: "opencode-go", name: "OpenCode Go" }),
|
||||
]).map((item) => item.id),
|
||||
).toEqual(["github", "linear", "opencode-go", "opencode", "openai"])
|
||||
).toEqual(["github", "linear", "openai"])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -38,28 +38,14 @@ describe("sortModelOptions", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("orders OpenCode Go before Zen and other providers", () => {
|
||||
test("orders opencode models before other providers", () => {
|
||||
const sorted = sortModelOptions([
|
||||
{ providerID: "openai", providerName: "OpenAI", releaseDate: 3, title: "GPT 5" },
|
||||
{ providerID: "opencode", providerName: "OpenCode Zen", releaseDate: 1, title: "Claude Sonnet 4" },
|
||||
{ providerID: "opencode", providerName: "OpenCode", releaseDate: 1, title: "Claude Sonnet 4" },
|
||||
{ providerID: "anthropic", providerName: "Anthropic", releaseDate: 2, title: "Claude Opus 4" },
|
||||
{ providerID: "opencode-go", providerName: "OpenCode Go", releaseDate: 0, title: "Kimi K3" },
|
||||
])
|
||||
|
||||
expect(sorted.map((model) => model.title)).toEqual(["Kimi K3", "Claude Sonnet 4", "Claude Opus 4", "GPT 5"])
|
||||
})
|
||||
|
||||
test("keeps ungrouped results free-first regardless of OpenCode provider", () => {
|
||||
const sorted = sortModelOptions(
|
||||
[
|
||||
{ providerID: "opencode-go", releaseDate: 3, title: "Go model" },
|
||||
{ providerID: "opencode", releaseDate: 1, title: "Free Zen model", footer: "Free" },
|
||||
{ providerID: "anthropic", releaseDate: 4, title: "Anthropic model" },
|
||||
],
|
||||
false,
|
||||
)
|
||||
|
||||
expect(sorted.map((model) => model.title)).toEqual(["Free Zen model", "Anthropic model", "Go model"])
|
||||
expect(sorted.map((model) => model.title)).toEqual(["Claude Sonnet 4", "Claude Opus 4", "GPT 5"])
|
||||
})
|
||||
|
||||
test("orders provider groups by provider name and models by newest release", () => {
|
||||
|
||||
@@ -45,15 +45,6 @@ test("tracks transitive imports through the Solid runtime transform", async () =
|
||||
expect(after.module).toMatchObject({ default: "after" })
|
||||
})
|
||||
|
||||
test("an explicit local entrypoint inside node_modules still reloads", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("node_modules/local-plugin/index.ts", sources.url)
|
||||
await Bun.write(entry, 'export default "before"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "before" })
|
||||
await Bun.write(entry, 'export default "after"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
})
|
||||
|
||||
test("unchanged bytes are a no-op, reverted bytes get a fresh module", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
|
||||
@@ -236,7 +236,6 @@
|
||||
--v2-background-bg-layer-04: var(--v2-grey-400);
|
||||
--v2-background-bg-inverse: var(--v2-grey-1100);
|
||||
--v2-background-bg-contrast: var(--v2-grey-1000);
|
||||
--v2-background-bg-icon-button-contrast: var(--v2-background-bg-contrast);
|
||||
--v2-background-bg-button-neutral: var(--v2-grey-50);
|
||||
--v2-background-bg-accent: var(--v2-blue-600);
|
||||
|
||||
@@ -251,7 +250,6 @@
|
||||
|
||||
--v2-icon-icon-base: var(--v2-grey-800);
|
||||
--v2-icon-icon-muted: var(--v2-grey-600);
|
||||
--v2-icon-icon-faint: var(--v2-grey-500);
|
||||
--v2-icon-icon-inverse: var(--v2-grey-50);
|
||||
--v2-icon-icon-contrast: var(--v2-grey-100);
|
||||
--v2-icon-icon-accent: var(--v2-blue-600);
|
||||
|
||||
@@ -71,37 +71,23 @@
|
||||
|
||||
/* Contrast */
|
||||
[data-component="icon-button-v2"][data-variant="contrast"] {
|
||||
--icon-button-contrast-highlight: var(--v2-alpha-light-20);
|
||||
--icon-button-contrast-hover: var(--v2-overlay-simple-overlay-contrast-hover);
|
||||
|
||||
background-image:
|
||||
linear-gradient(180deg, var(--icon-button-contrast-highlight) 0%, var(--v2-alpha-light-0) 100%),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
var(--v2-background-bg-icon-button-contrast) 0%,
|
||||
var(--v2-background-bg-icon-button-contrast) 100%
|
||||
);
|
||||
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%);
|
||||
color: var(--v2-text-text-contrast);
|
||||
text-shadow: 0 0.5px 0.5px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--v2-elevation-button-contrast);
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="icon-button-v2"][data-variant="contrast"] {
|
||||
--icon-button-contrast-highlight: var(--v2-alpha-light-60);
|
||||
--icon-button-contrast-hover: var(--v2-alpha-dark-8);
|
||||
|
||||
color: var(--v2-icon-icon-inverse);
|
||||
}
|
||||
|
||||
[data-component="icon-button-v2"][data-variant="contrast"]:is(:hover, [data-state="hover"]):not(:disabled) {
|
||||
background-image:
|
||||
linear-gradient(90deg, var(--icon-button-contrast-hover) 0%, var(--icon-button-contrast-hover) 100%),
|
||||
linear-gradient(180deg, var(--icon-button-contrast-highlight) 0%, var(--v2-alpha-light-0) 100%),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
var(--v2-background-bg-icon-button-contrast) 0%,
|
||||
var(--v2-background-bg-icon-button-contrast) 100%
|
||||
);
|
||||
var(--v2-overlay-simple-overlay-contrast-hover) 0%,
|
||||
var(--v2-overlay-simple-overlay-contrast-hover) 100%
|
||||
),
|
||||
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%);
|
||||
}
|
||||
|
||||
[data-component="icon-button-v2"][data-variant="contrast"]:is(:active, [data-state="pressed"]):not(:disabled) {
|
||||
@@ -111,12 +97,8 @@
|
||||
var(--v2-overlay-simple-overlay-contrast-pressed) 0%,
|
||||
var(--v2-overlay-simple-overlay-contrast-pressed) 100%
|
||||
),
|
||||
linear-gradient(180deg, var(--icon-button-contrast-highlight) 0%, var(--v2-alpha-light-0) 100%),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
var(--v2-background-bg-icon-button-contrast) 0%,
|
||||
var(--v2-background-bg-icon-button-contrast) 100%
|
||||
);
|
||||
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%);
|
||||
}
|
||||
|
||||
[data-component="icon-button-v2"][data-variant="contrast"]:is(:disabled, [data-state="disabled"]) {
|
||||
|
||||
@@ -240,7 +240,6 @@
|
||||
--color-v2-background-bg-layer-04: var(--v2-background-bg-layer-04);
|
||||
--color-v2-background-bg-inverse: var(--v2-background-bg-inverse);
|
||||
--color-v2-background-bg-contrast: var(--v2-background-bg-contrast);
|
||||
--color-v2-background-bg-icon-button-contrast: var(--v2-background-bg-icon-button-contrast);
|
||||
--color-v2-background-bg-button-neutral: var(--v2-background-bg-button-neutral);
|
||||
--color-v2-background-bg-accent: var(--v2-background-bg-accent);
|
||||
--color-v2-text-text-base: var(--v2-text-text-base);
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
--v2-background-bg-layer-04: var(--v2-grey-400);
|
||||
--v2-background-bg-inverse: var(--v2-grey-1100);
|
||||
--v2-background-bg-contrast: var(--v2-grey-1000);
|
||||
--v2-background-bg-icon-button-contrast: var(--v2-background-bg-contrast);
|
||||
--v2-background-bg-button-neutral: var(--v2-grey-50);
|
||||
--v2-background-bg-accent: var(--v2-blue-600);
|
||||
--v2-background-bg-code-path: var(--v2-cyan-100);
|
||||
@@ -268,7 +267,6 @@
|
||||
--v2-background-bg-layer-04: var(--v2-grey-400);
|
||||
--v2-background-bg-inverse: var(--v2-grey-1100);
|
||||
--v2-background-bg-contrast: var(--v2-grey-1000);
|
||||
--v2-background-bg-icon-button-contrast: var(--v2-background-bg-contrast);
|
||||
--v2-background-bg-button-neutral: var(--v2-grey-50);
|
||||
--v2-background-bg-accent: var(--v2-blue-600);
|
||||
--v2-background-bg-code-path: var(--v2-cyan-100);
|
||||
@@ -394,7 +392,6 @@
|
||||
--v2-background-bg-layer-04: var(--v2-grey-700);
|
||||
--v2-background-bg-inverse: var(--v2-grey-50);
|
||||
--v2-background-bg-contrast: var(--v2-grey-700);
|
||||
--v2-background-bg-icon-button-contrast: var(--v2-grey-400);
|
||||
--v2-background-bg-button-neutral: var(--v2-alpha-light-6);
|
||||
--v2-background-bg-accent: var(--v2-blue-600);
|
||||
--v2-background-bg-code-path: var(--v2-cyan-1200);
|
||||
|
||||
@@ -164,7 +164,6 @@
|
||||
"v2-background-bg-layer-04": "var(--v2-grey-400)",
|
||||
"v2-background-bg-inverse": "var(--v2-grey-1100)",
|
||||
"v2-background-bg-contrast": "var(--v2-grey-1000)",
|
||||
"v2-background-bg-icon-button-contrast": "var(--v2-background-bg-contrast)",
|
||||
"v2-background-bg-button-neutral": "var(--v2-grey-50)",
|
||||
"v2-background-bg-accent": "var(--v2-blue-600)",
|
||||
"v2-background-bg-code-path": "var(--v2-cyan-100)",
|
||||
@@ -415,7 +414,6 @@
|
||||
"v2-background-bg-layer-04": "var(--v2-grey-700)",
|
||||
"v2-background-bg-inverse": "var(--v2-grey-50)",
|
||||
"v2-background-bg-contrast": "var(--v2-grey-700)",
|
||||
"v2-background-bg-icon-button-contrast": "var(--v2-grey-400)",
|
||||
"v2-background-bg-button-neutral": "var(--v2-alpha-light-6)",
|
||||
"v2-background-bg-accent": "var(--v2-blue-600)",
|
||||
"v2-background-bg-code-path": "var(--v2-cyan-1200)",
|
||||
|
||||
@@ -40,7 +40,6 @@ const light: Record<string, V2ColorValue> = {
|
||||
"v2-background-bg-layer-04": ref("v2-grey-600"),
|
||||
"v2-background-bg-inverse": ref("v2-grey-1000"),
|
||||
"v2-background-bg-contrast": ref("v2-grey-900"),
|
||||
"v2-background-bg-icon-button-contrast": ref("v2-background-bg-contrast"),
|
||||
"v2-background-bg-button-neutral": ref("v2-grey-100"),
|
||||
"v2-background-bg-accent": ref("v2-blue-600"),
|
||||
"v2-background-bg-code-path": ref("v2-cyan-100"),
|
||||
@@ -108,7 +107,6 @@ const dark: Record<string, V2ColorValue> = {
|
||||
"v2-background-bg-layer-04": ref("v2-grey-400"),
|
||||
"v2-background-bg-inverse": ref("v2-grey-100"),
|
||||
"v2-background-bg-contrast": ref("v2-grey-700"),
|
||||
"v2-background-bg-icon-button-contrast": ref("v2-grey-400"),
|
||||
"v2-background-bg-button-neutral": ref("v2-alpha-light-6"),
|
||||
"v2-background-bg-accent": ref("v2-blue-600"),
|
||||
"v2-background-bg-code-path": ref("v2-cyan-1200"),
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { DesktopTheme } from "../types"
|
||||
import { resolveThemeV2, resolveThemeVariantV2, themeV2ToCss } from "./resolve"
|
||||
|
||||
const theme: DesktopTheme = await Bun.file(new URL("../themes/oc-2.json", import.meta.url)).json()
|
||||
|
||||
describe("contrast icon-button tokens", () => {
|
||||
test("OC-2 dark mode uses a light background and an inverse icon matching the base surface", () => {
|
||||
const tokens = resolveThemeV2(theme).dark
|
||||
expect(tokens["v2-background-bg-icon-button-contrast"]).toBe("var(--v2-grey-400)")
|
||||
expect(tokens["v2-grey-400"]).toBe("#dbdbdbff")
|
||||
expect(tokens["v2-icon-icon-inverse"]).toBe(tokens["v2-background-bg-base"])
|
||||
expect(tokens["v2-grey-1100"]).toBe("#161616ff")
|
||||
expect(tokens["v2-background-bg-contrast"]).toBe("var(--v2-grey-700)")
|
||||
expect(tokens["v2-text-text-contrast"]).toBe("var(--v2-grey-50)")
|
||||
})
|
||||
|
||||
test("OC-2 light mode retains the existing contrast background and foreground", () => {
|
||||
const tokens = resolveThemeV2(theme).light
|
||||
expect(tokens["v2-background-bg-icon-button-contrast"]).toBe("var(--v2-background-bg-contrast)")
|
||||
expect(tokens["v2-background-bg-contrast"]).toBe("var(--v2-grey-1000)")
|
||||
expect(tokens["v2-text-text-contrast"]).toBe("var(--v2-grey-50)")
|
||||
})
|
||||
|
||||
test.each([false, true])("custom themes without the new token receive a fallback (dark: %s)", (dark) => {
|
||||
const tokens = resolveThemeVariantV2({ ...theme.dark, v2Overrides: undefined }, dark)
|
||||
expect(tokens["v2-background-bg-icon-button-contrast"]).toBe(
|
||||
dark ? "var(--v2-grey-400)" : "var(--v2-background-bg-contrast)",
|
||||
)
|
||||
expect(themeV2ToCss(tokens)).toContain(
|
||||
`--v2-background-bg-icon-button-contrast: ${tokens["v2-background-bg-icon-button-contrast"]};`,
|
||||
)
|
||||
})
|
||||
|
||||
test("custom themes can override the icon-button background independently", () => {
|
||||
const tokens = resolveThemeVariantV2(
|
||||
{
|
||||
...theme.dark,
|
||||
v2Overrides: { ...theme.dark.v2Overrides, "v2-background-bg-icon-button-contrast": "#dddddd" },
|
||||
},
|
||||
true,
|
||||
)
|
||||
expect(tokens["v2-background-bg-icon-button-contrast"]).toBe("#dddddd")
|
||||
expect(tokens["v2-background-bg-contrast"]).toBe("var(--v2-grey-700)")
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createUniqueId, type ComponentProps } from "solid-js"
|
||||
|
||||
export function Wordmark(props: Pick<ComponentProps<"svg">, "class"> & { fade?: boolean; muted?: boolean }) {
|
||||
export function Wordmark(props: Pick<ComponentProps<"svg">, "class">) {
|
||||
const mask = createUniqueId()
|
||||
const maskGradient = createUniqueId()
|
||||
|
||||
@@ -11,17 +11,49 @@ export function Wordmark(props: Pick<ComponentProps<"svg">, "class"> & { fade?:
|
||||
fill="none"
|
||||
classList={{ [props.class ?? ""]: !!props.class }}
|
||||
>
|
||||
<g opacity={props.muted === false ? 1 : 0.6} class="[[data-color-scheme=dark]_&]:opacity-100">
|
||||
<g mask={props.fade === false ? undefined : `url(#${mask})`}>
|
||||
<g opacity={props.muted === false ? 1 : 0.16 * 0.7} fill="currentColor">
|
||||
<path d="M55.3846 36.4286H18.4615V91.7143H55.3846V36.4286ZM73.8462 110.143H0V18H73.8462V110.143Z" />
|
||||
<path d="M110.462 91.7143H147.385V36.4286H110.462V91.7143ZM165.846 110.143H110.462V128.571H92V18H165.846V110.143Z" />
|
||||
<path d="M258.846 73.2857H203.462V91.7143H258.846V110.143H185V18H258.846V73.2857ZM203.462 54.8571H240.385V36.4286H203.462V54.8571Z" />
|
||||
<path d="M332.385 36.4286H295.462V110.143H277V18H332.385V36.4286ZM350.846 110.143H332.385V36.4286H350.846V110.143Z" />
|
||||
<path d="M442.846 36.4286H387.462V91.7143H442.846V110.143H369V18H442.846V36.4286Z" />
|
||||
<path d="M517.385 36.4286H480.462V91.7143H517.385V36.4286ZM535.846 110.143H462V18H535.846V110.143Z" />
|
||||
<path d="M609.385 36.8571H572.462V92.1429H609.385V36.8571ZM627.846 110.571H554V18.4286H609.385V0H627.846V110.571Z" />
|
||||
<path d="M664.462 36.4286V54.8571H701.385V36.4286H664.462ZM719.846 73.2857H664.462V91.7143H719.846V110.143H646V18H719.846V73.2857Z" />
|
||||
<g opacity="0.6" class="[[data-color-scheme=dark]_&]:opacity-100">
|
||||
<g mask={`url(#${mask})`}>
|
||||
<g opacity="0.16">
|
||||
<path
|
||||
opacity="0.7"
|
||||
d="M55.3846 36.4286H18.4615V91.7143H55.3846V36.4286ZM73.8462 110.143H0V18H73.8462V110.143Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
opacity="0.7"
|
||||
d="M110.462 91.7143H147.385V36.4286H110.462V91.7143ZM165.846 110.143H110.462V128.571H92V18H165.846V110.143Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
opacity="0.7"
|
||||
d="M258.846 73.2857H203.462V91.7143H258.846V110.143H185V18H258.846V73.2857ZM203.462 54.8571H240.385V36.4286H203.462V54.8571Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
opacity="0.7"
|
||||
d="M332.385 36.4286H295.462V110.143H277V18H332.385V36.4286ZM350.846 110.143H332.385V36.4286H350.846V110.143Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
opacity="0.7"
|
||||
d="M442.846 36.4286H387.462V91.7143H442.846V110.143H369V18H442.846V36.4286Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
opacity="0.7"
|
||||
d="M517.385 36.4286H480.462V91.7143H517.385V36.4286ZM535.846 110.143H462V18H535.846V110.143Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
opacity="0.7"
|
||||
d="M609.385 36.8571H572.462V92.1429H609.385V36.8571ZM627.846 110.571H554V18.4286H609.385V0H627.846V110.571Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
opacity="0.7"
|
||||
d="M664.462 36.4286V54.8571H701.385V36.4286H664.462ZM719.846 73.2857H664.462V91.7143H719.846V110.143H646V18H719.846V73.2857Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
Reference in New Issue
Block a user