mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-06 08:56:22 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
353fb69c9b | ||
|
|
cd504dc66a | ||
|
|
c4f2926ffb | ||
|
|
aa48433fa0 | ||
|
|
8da7526616 | ||
|
|
7a4ad68af6 | ||
|
|
2960c61f9c | ||
|
|
7ad705225e | ||
|
|
5045b3ce18 | ||
|
|
4306c07b34 | ||
|
|
7de1e86b5d | ||
|
|
a6fd1f8b22 |
@@ -91,7 +91,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
bun-version: 1.4.1
|
||||
bun-version: 1.4.2
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
id: build
|
||||
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.1
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.2
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"name": "@opencode-ai/ai",
|
||||
"version": "1.17.20",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -1067,7 +1068,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.1",
|
||||
"bun-types": "1.4.2",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
@@ -3487,7 +3488,7 @@
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="],
|
||||
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-IDORw4Nup1Yj+RZSLGo0pDnwrkveYA8GvYQVNTJUnMM=",
|
||||
"aarch64-linux": "sha256-jxjyAI2imF7csdZNUqvs9mFj8SnYqGg1CapEEk15rxA=",
|
||||
"aarch64-darwin": "sha256-Hbh+cw2DPOJpYCDjasK6m0SLqznyAw1ODKSb7w87GG8=",
|
||||
"x86_64-darwin": "sha256-Aj0/MtMPoeccrN36mu2wUM6gyRKaTF2AbjZ0/wZtKnA="
|
||||
"x86_64-linux": "sha256-is89RjF5ijUsAaJC+d9BaJaV27pGyEPyUNg0DJcv9eg=",
|
||||
"aarch64-linux": "sha256-gyExE7mLVzAZ39kApwDELrFRZjtdwy3kWdYaeS/MY0Y=",
|
||||
"aarch64-darwin": "sha256-uMrja7eBy07eUEaF12Ip8g/Jgj9PPNZoiGJGMFUi9vA=",
|
||||
"x86_64-darwin": "sha256-eZXjKLsLmMeBaK5aTUL2sS0ffQeEgB6/oalZAtxBwp0="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.1",
|
||||
"packageManager": "bun@1.4.2",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev": "bun run --cwd packages/cli src/index.ts",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
@@ -159,7 +159,7 @@
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.1",
|
||||
"bun-types": "1.4.2",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
|
||||
@@ -2,13 +2,12 @@ import { AwsV4Signer } from "aws4fetch"
|
||||
import { Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Auth, type AuthInput } from "../../route/auth.js"
|
||||
import { AIError, AuthenticationError } from "../../schema/index.js"
|
||||
import { ProviderShared } from "../shared.js"
|
||||
|
||||
/**
|
||||
* AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth,
|
||||
* which provider facades configure as route auth instead of SigV4. STS-vended
|
||||
* credentials should be refreshed by the consumer (rebuild the model) before
|
||||
* they expire; the route does not refresh.
|
||||
* which provider facades configure as route auth instead of SigV4.
|
||||
*/
|
||||
export interface Credentials {
|
||||
readonly region: string
|
||||
@@ -17,6 +16,44 @@ export interface Credentials {
|
||||
readonly sessionToken?: string
|
||||
}
|
||||
|
||||
/** Static credentials or an effect resolved before every request. */
|
||||
export type CredentialSource = Credentials | Effect.Effect<Credentials, AIError>
|
||||
|
||||
export interface DefaultChainOptions {
|
||||
readonly region: string
|
||||
/** Shared config profile passed to the AWS default chain. */
|
||||
readonly profile?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve credentials through the AWS default provider chain: environment
|
||||
* variables, shared config and SSO caches, web identity tokens, process
|
||||
* credentials, and container or instance metadata. A fresh chain runs on every
|
||||
* request so credentials rotated on disk without an expiration (for example
|
||||
* shared-config keys rewritten by a corporate SSO tool) are always re-read;
|
||||
* the SDK's own memoization would otherwise pin them for the process lifetime.
|
||||
*/
|
||||
export const defaultChain = (options: DefaultChainOptions): Effect.Effect<Credentials, AIError> =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const { fromNodeProviderChain } = await import("@aws-sdk/credential-providers")
|
||||
const identity = await fromNodeProviderChain(options.profile === undefined ? {} : { profile: options.profile })()
|
||||
return {
|
||||
region: options.region,
|
||||
accessKeyId: identity.accessKeyId,
|
||||
secretAccessKey: identity.secretAccessKey,
|
||||
...(identity.sessionToken === undefined ? {} : { sessionToken: identity.sessionToken }),
|
||||
}
|
||||
},
|
||||
catch: (error) =>
|
||||
new AIError({
|
||||
reason: new AuthenticationError({
|
||||
message: `AWS default credential chain failed: ${ProviderShared.errorText(error)}`,
|
||||
cause: error,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const signRequest = (input: {
|
||||
readonly url: string
|
||||
readonly body: string
|
||||
@@ -48,16 +85,17 @@ const signRequest = (input: {
|
||||
|
||||
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
|
||||
export const sigV4 = (
|
||||
credentials: Credentials | undefined,
|
||||
source: CredentialSource | undefined,
|
||||
options: { readonly service?: string; readonly name?: string } = {},
|
||||
) =>
|
||||
Auth.custom((input: AuthInput) => {
|
||||
return Effect.gen(function* () {
|
||||
if (!credentials) {
|
||||
if (!source) {
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
`${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
|
||||
)
|
||||
}
|
||||
const credentials = Effect.isEffect(source) ? yield* source : source
|
||||
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
|
||||
const signed = yield* signRequest({
|
||||
url: input.url,
|
||||
@@ -74,4 +112,35 @@ export const sigV4 = (
|
||||
/** Bedrock route auth defaults to SigV4 and expects credentials from route configuration. */
|
||||
export const auth = sigV4(undefined)
|
||||
|
||||
export const resolveRegion = (input: {
|
||||
readonly region?: string
|
||||
readonly credentials?: { readonly region: string }
|
||||
}) =>
|
||||
input.region ?? input.credentials?.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1"
|
||||
|
||||
export interface ResolveAuthInput {
|
||||
readonly apiKey?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly profile?: string
|
||||
}
|
||||
|
||||
export interface ResolveAuthOptions {
|
||||
readonly service?: string
|
||||
readonly name?: string
|
||||
/** `sigv4` ignores an ambient `AWS_BEARER_TOKEN_BEDROCK`; `bearer` is validated by the caller. */
|
||||
readonly mode?: "bearer" | "sigv4"
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer wins over SigV4 and explicit static credentials win over the default
|
||||
* chain, matching the AWS SDK's own precedence for `AWS_BEARER_TOKEN_BEDROCK`.
|
||||
* The region is applied to the SigV4 scope so it always matches the endpoint host.
|
||||
*/
|
||||
export const resolveAuth = (input: ResolveAuthInput, region: string, options: ResolveAuthOptions = {}) => {
|
||||
const apiKey = options.mode === "sigv4" ? undefined : (input.apiKey ?? process.env.AWS_BEARER_TOKEN_BEDROCK)
|
||||
if (apiKey !== undefined) return Auth.bearer(apiKey)
|
||||
if (input.credentials !== undefined) return sigV4({ ...input.credentials, region }, options)
|
||||
return sigV4(defaultChain({ region, profile: input.profile }), options)
|
||||
}
|
||||
|
||||
export * as BedrockAuth from "./bedrock-auth.js"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
@@ -10,9 +9,15 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
|
||||
export type Config = RouteDefaultsInput & {
|
||||
/** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */
|
||||
readonly apiKey?: string
|
||||
/** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
/** Static SigV4 credentials. When omitted the AWS default credential chain resolves them per request. */
|
||||
readonly credentials?: Credentials
|
||||
/** Shared config profile for the default credential chain. */
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
@@ -22,6 +27,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
@@ -47,23 +53,35 @@ const chatRoute = OpenAIChat.route.with({
|
||||
export const routes = [responsesRoute, chatRoute]
|
||||
|
||||
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
|
||||
const region = input.region ?? input.credentials?.region ?? "us-east-1"
|
||||
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
|
||||
const region = BedrockAuth.resolveRegion(input)
|
||||
return route.with({
|
||||
endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` },
|
||||
auth:
|
||||
input.apiKey === undefined
|
||||
? BedrockAuth.sigV4(credentials, { service: "bedrock-mantle", name: "Bedrock Mantle" })
|
||||
: Auth.bearer(input.apiKey),
|
||||
auth: BedrockAuth.resolveAuth(input, region, {
|
||||
service: "bedrock-mantle",
|
||||
name: "Bedrock Mantle",
|
||||
mode: input.auth,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const defaults = (input: Config) => {
|
||||
const { apiKey: _, baseURL: _baseURL, credentials: _credentials, region: _region, ...rest } = input
|
||||
const {
|
||||
apiKey: _,
|
||||
auth: _auth,
|
||||
baseURL: _baseURL,
|
||||
credentials: _credentials,
|
||||
profile: _profile,
|
||||
region: _region,
|
||||
...rest
|
||||
} = input
|
||||
return rest
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
if (input.auth === "sigv4" && input.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
||||
const configuredChatRoute = configuredRoute(chatRoute, input)
|
||||
const modelDefaults = defaults(input)
|
||||
@@ -87,29 +105,26 @@ export const configure = (input: Config = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
const config = (settings: Settings): Config => {
|
||||
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
return {
|
||||
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||
const fromSettings = (settings: Settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
auth: settings.auth,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
profile: settings.profile,
|
||||
providerOptions: settings.providerOptions,
|
||||
region: settings.region,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).chat(modelID)
|
||||
) => fromSettings(settings).chat(modelID)
|
||||
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).responses(modelID)
|
||||
) => fromSettings(settings).responses(modelID)
|
||||
export const model = responsesModel
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as BedrockConverse from "../protocols/bedrock-converse.js"
|
||||
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
|
||||
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
|
||||
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
|
||||
export type Config = RouteDefaultsInput & {
|
||||
/** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */
|
||||
readonly apiKey?: string
|
||||
/** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly headers?: Record<string, string>
|
||||
/** Static SigV4 credentials. When omitted the AWS default credential chain resolves them per request. */
|
||||
readonly credentials?: BedrockCredentials
|
||||
/** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */
|
||||
/** Shared config profile for the default credential chain. */
|
||||
readonly profile?: string
|
||||
/** AWS region. Falls back to `credentials.region`, `AWS_REGION`, `AWS_DEFAULT_REGION`, then `us-east-1`. */
|
||||
readonly region?: string
|
||||
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
|
||||
readonly baseURL?: string
|
||||
@@ -22,6 +28,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: BedrockCredentials
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
}
|
||||
@@ -30,14 +37,17 @@ export const routes = [BedrockConverse.route]
|
||||
const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
const { apiKey, credentials, region, baseURL, ...rest } = input
|
||||
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
|
||||
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
|
||||
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
const resolvedRegion = BedrockAuth.resolveRegion(input)
|
||||
return BedrockConverse.route.with({
|
||||
...rest,
|
||||
provider: id,
|
||||
providerMetadataKey: "bedrock",
|
||||
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
|
||||
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
|
||||
auth: BedrockAuth.resolveAuth({ apiKey, credentials, profile }, resolvedRegion, { mode: auth }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,18 +61,15 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
return configure({
|
||||
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
auth: settings.auth,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
profile: settings.profile,
|
||||
region: settings.region,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Effect } from "effect"
|
||||
|
||||
/**
|
||||
* Run an effect with `process.env` overrides, restoring the previous values
|
||||
* afterwards. Use for code that reads `process.env` directly, such as AWS SDK
|
||||
* credential providers, where Effect `ConfigProvider` layers do not apply.
|
||||
* `undefined` removes a variable for the duration of the effect.
|
||||
*/
|
||||
export const withProcessEnv =
|
||||
(env: Record<string, string | undefined>) =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(env).map((name) => [name, process.env[name]]))
|
||||
apply(env)
|
||||
return previous
|
||||
}),
|
||||
() => effect,
|
||||
(previous) => Effect.sync(() => apply(previous)),
|
||||
)
|
||||
|
||||
const apply = (env: Record<string, string | undefined>) => {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name]
|
||||
continue
|
||||
}
|
||||
process.env[name] = value
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Encoding, Ref, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
CacheHint,
|
||||
GenerationOptions,
|
||||
type LanguageModel,
|
||||
LLM,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
@@ -18,7 +20,8 @@ import { compileRequest } from "../../src/route/client.js"
|
||||
import { AmazonBedrock } from "../../src/providers.js"
|
||||
import * as BedrockConverse from "../../src/protocols/bedrock-converse.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { withProcessEnv } from "../lib/env.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import {
|
||||
eventSummary,
|
||||
expectWeatherToolLoop,
|
||||
@@ -95,6 +98,41 @@ const fixedByteChunks = (...chunks: ReadonlyArray<Uint8Array>) =>
|
||||
{ headers: { "content-type": "application/vnd.amazon.eventstream" } },
|
||||
)
|
||||
|
||||
// Clears every input the AWS default chain reads so tests do not pick up the
|
||||
// developer's profiles, SSO cache, or instance metadata.
|
||||
const noAmbientAWS = {
|
||||
AWS_ACCESS_KEY_ID: undefined,
|
||||
AWS_SECRET_ACCESS_KEY: undefined,
|
||||
AWS_SESSION_TOKEN: undefined,
|
||||
AWS_BEARER_TOKEN_BEDROCK: undefined,
|
||||
AWS_PROFILE: undefined,
|
||||
AWS_REGION: undefined,
|
||||
AWS_DEFAULT_REGION: undefined,
|
||||
AWS_WEB_IDENTITY_TOKEN_FILE: undefined,
|
||||
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined,
|
||||
AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined,
|
||||
AWS_EC2_METADATA_DISABLED: "true",
|
||||
}
|
||||
|
||||
const captureHeaders = (target: LanguageModel) =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<Headers> = []
|
||||
yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: target })).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request)
|
||||
seen.push(request.headers)
|
||||
return input.respond(eventStreamBody(["messageStop", { stopReason: "end_turn" }]), {
|
||||
headers: { "content-type": "application/vnd.amazon.eventstream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return seen[0]!
|
||||
})
|
||||
|
||||
const model = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
apiKey: "test-bearer",
|
||||
@@ -1311,35 +1349,137 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects requests with no auth path", () =>
|
||||
it.effect("fails with an authentication error when the default chain cannot resolve credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const unsignedModel = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
profile: "opencode-test-missing-profile",
|
||||
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
const error = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: unsignedModel })).pipe(
|
||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.message).toContain("Bedrock Converse requires either route bearer auth or AWS credentials")
|
||||
}),
|
||||
expect(error.reason._tag).toBe("Authentication")
|
||||
expect(error.message).toContain("AWS default credential chain failed")
|
||||
}).pipe(withProcessEnv(noAmbientAWS)),
|
||||
)
|
||||
|
||||
it.effect("signs requests with SigV4 when AWS credentials are provided (deterministic plumbing check)", () =>
|
||||
it.effect("signs with static credentials using the configured region for the SigV4 scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const signed = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
region: "eu-west-1",
|
||||
credentials: {
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
},
|
||||
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
const prepared = yield* compileRequest(LLMRequest.update(baseRequest, { model: signed }))
|
||||
const headers = yield* captureHeaders(signed)
|
||||
|
||||
expect(prepared.route).toBe("bedrock-converse")
|
||||
expect(prepared.model).toBe(signed)
|
||||
}),
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIAIOSFODNN7EXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/eu-west-1/bedrock/aws4_request")
|
||||
expect(headers.get("x-amz-security-token")).toBeNull()
|
||||
}).pipe(withProcessEnv(noAmbientAWS)),
|
||||
)
|
||||
|
||||
it.effect("resolves SigV4 credentials through the AWS default chain on every request", () =>
|
||||
Effect.gen(function* () {
|
||||
const chained = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", region: "us-west-2" }).model(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
)
|
||||
const headers = yield* captureHeaders(chained)
|
||||
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/us-west-2/bedrock/aws4_request")
|
||||
expect(headers.get("x-amz-security-token")).toBe("chain-session-token")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
AWS_SESSION_TOKEN: "chain-session-token",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("re-resolves rotated chain credentials on the next request without rebuilding the model", () =>
|
||||
Effect.gen(function* () {
|
||||
const chained = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", region: "us-west-2" }).model(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
)
|
||||
const first = yield* captureHeaders(chained)
|
||||
const second = yield* captureHeaders(chained).pipe(
|
||||
withProcessEnv({ AWS_ACCESS_KEY_ID: "AKIAROTATEDEXAMPLE", AWS_SECRET_ACCESS_KEY: "rotated-secret" }),
|
||||
)
|
||||
|
||||
expect(first.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(second.get("authorization")).toContain("Credential=AKIAROTATEDEXAMPLE/")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("prefers AWS_BEARER_TOKEN_BEDROCK over the credential chain", () =>
|
||||
Effect.gen(function* () {
|
||||
const bearer = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test" }).model(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
)
|
||||
const headers = yield* captureHeaders(bearer)
|
||||
|
||||
expect(headers.get("authorization")).toBe("Bearer env-bearer-token")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
AWS_BEARER_TOKEN_BEDROCK: "env-bearer-token",
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("auth: sigv4 ignores an ambient bearer token from configure and settings", () =>
|
||||
Effect.gen(function* () {
|
||||
const configured = AmazonBedrock.configure({ auth: "sigv4", baseURL: "https://bedrock-runtime.test" }).model(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
)
|
||||
const fromSettings = AmazonBedrock.model("anthropic.claude-3-5-sonnet-20240620-v1:0", {
|
||||
auth: "sigv4",
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
})
|
||||
|
||||
for (const target of [configured, fromSettings]) {
|
||||
const headers = yield* captureHeaders(target)
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
|
||||
}
|
||||
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
AWS_BEARER_TOKEN_BEDROCK: "env-bearer-token",
|
||||
AWS_REGION: "ap-southeast-2",
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("derives the endpoint region from AWS_REGION then AWS_DEFAULT_REGION", () =>
|
||||
Effect.gen(function* () {
|
||||
const fromRegion = AmazonBedrock.configure({ apiKey: "k" }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
expect(fromRegion.route.endpoint.baseURL).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com")
|
||||
|
||||
const fromDefault = yield* Effect.sync(() =>
|
||||
AmazonBedrock.configure({ apiKey: "k" }).model("anthropic.claude-3-5-sonnet-20240620-v1:0"),
|
||||
).pipe(withProcessEnv({ AWS_REGION: undefined }))
|
||||
expect(fromDefault.route.endpoint.baseURL).toBe("https://bedrock-runtime.us-gov-west-1.amazonaws.com")
|
||||
}).pipe(withProcessEnv({ ...noAmbientAWS, AWS_REGION: "eu-central-1", AWS_DEFAULT_REGION: "us-gov-west-1" })),
|
||||
)
|
||||
|
||||
it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { model } from "../../src/providers/amazon-bedrock/mantle.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { compileRequest, LLMClient } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { withProcessEnv } from "../lib/env.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
@@ -82,6 +83,36 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("signs with the Mantle service using default-chain credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<string | undefined> = []
|
||||
const model = AmazonBedrockMantle.configure({ region: "us-west-1" }).responses("openai.gpt-oss-120b")
|
||||
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request)
|
||||
seen.push(request.headers.get("authorization") ?? undefined)
|
||||
return input.respond("", { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(seen[0]).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(seen[0]).toContain("/us-west-1/bedrock-mantle/aws4_request")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
AWS_BEARER_TOKEN_BEDROCK: undefined,
|
||||
AWS_PROFILE: undefined,
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
AWS_SESSION_TOKEN: undefined,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("supports bearer authentication and custom base URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test.use({
|
||||
deviceScaleFactor: 2,
|
||||
// Emulating DPR alone does not exercise Chromium's native hairline border rounding.
|
||||
launchOptions: { args: ["--force-device-scale-factor=2"] },
|
||||
})
|
||||
|
||||
for (const width of [1400, 390]) {
|
||||
test(`patch file borders retain a full CSS pixel at ${width}px on high-density displays`, async ({ page }) => {
|
||||
const file = `src/${"long-directory/".repeat(12)}patch-border.ts`
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
"prt_border_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update file" },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: createTwoFilesPatch(file, file, "const value = 1\n", "const value = 2\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
reducedMotion: true,
|
||||
viewport: { width, height: 900 },
|
||||
})
|
||||
await page.getByRole("button", { name: "Used 1 Patch", exact: true }).click()
|
||||
const patch = page.locator('[data-component="apply-patch-tool"]')
|
||||
const trigger = patch.getByRole("button", { name: /patch-border.ts/ })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(trigger).toHaveCSS("height", "32px")
|
||||
for (const side of ["top", "right", "bottom", "left"]) {
|
||||
await expect(trigger).toHaveCSS(`border-${side}-width`, "1px")
|
||||
}
|
||||
|
||||
const box = await trigger.boundingBox()
|
||||
await trigger.hover()
|
||||
expect(await trigger.boundingBox()).toEqual(box)
|
||||
await page.mouse.move(0, 0)
|
||||
expect(await trigger.boundingBox()).toEqual(box)
|
||||
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
const content = patch.locator('[data-slot="accordion-content"]')
|
||||
await expect(content).toBeVisible()
|
||||
for (const side of ["left", "right", "bottom"]) {
|
||||
await expect(content).toHaveCSS(`border-${side}-width`, "1px")
|
||||
}
|
||||
await expect(content).toHaveCSS("border-top-width", "0px")
|
||||
await trigger.press("Enter")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
|
||||
SHELL ["/bin/bash", "-lc"]
|
||||
|
||||
ARG NODE_VERSION=24.4.0
|
||||
ARG BUN_VERSION=1.4.1
|
||||
ARG BUN_VERSION=1.4.2
|
||||
|
||||
ENV BUN_INSTALL=/opt/bun
|
||||
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
@@ -245,7 +245,8 @@ export const connect = Effect.fnUntraced(function* (
|
||||
|
||||
return yield* open(url).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!addedCodemode || !(error instanceof StreamableHTTPError) || error.code !== 404) return Effect.fail(error)
|
||||
if (!addedCodemode || !(error instanceof StreamableHTTPError) || (error.code !== 400 && error.code !== 404))
|
||||
return Effect.fail(error)
|
||||
// Some servers reject unknown query params. Retry once with the user's original URL.
|
||||
return open(new URL(config.url))
|
||||
}),
|
||||
|
||||
@@ -2,14 +2,60 @@ export * as PluginModule from "./module.js"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { createPluginSources } from "@opencode-ai/plugin/source"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Deferred, Effect, FiberSet, PubSub, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { stat } from "node:fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import type { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import type { Generation } from "../plugin.js"
|
||||
import { PluginPromise } from "./promise.js"
|
||||
import { Watcher } from "../filesystem/watcher.js"
|
||||
|
||||
export const make = Effect.fn("PluginModule.make")(function* () {
|
||||
const watcher = yield* Watcher.Service
|
||||
const scope = yield* Effect.scope
|
||||
const runPromise = yield* FiberSet.makeRuntimePromise()
|
||||
const changes = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
const watch = Effect.fn("PluginModule.watch")(function* (file: string) {
|
||||
if (watched.has(file)) return
|
||||
watched.add(file)
|
||||
const ready = yield* Deferred.make<void>()
|
||||
const target = yield* Effect.promise(() => watchTarget(file))
|
||||
const updates = yield* watcher.subscribe(target, Deferred.succeed(ready, undefined))
|
||||
yield* updates.pipe(
|
||||
Stream.runForEach(() => PubSub.publish(changes, undefined)),
|
||||
Effect.ensuring(Deferred.succeed(ready, undefined)),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
yield* Deferred.await(ready)
|
||||
})
|
||||
const sources = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => createPluginSources((file) => runPromise(watch(file)))),
|
||||
(sources) => Effect.sync(() => sources.dispose()),
|
||||
)
|
||||
return {
|
||||
load: (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
options?: { readonly install?: boolean },
|
||||
) => load(operation, sources, options),
|
||||
changes: () => Stream.fromPubSub(changes),
|
||||
}
|
||||
})
|
||||
|
||||
// A missing dependency may have missing parents too. Watch the nearest existing
|
||||
// ancestor recursively so creating the rest of the path can trigger recovery.
|
||||
function watchTarget(file: string): Promise<Watcher.WatchInput> {
|
||||
return stat(file).then(
|
||||
(info) => ({ path: file, type: info.isDirectory() ? "directory" : "file" }),
|
||||
(cause) => {
|
||||
if (path.dirname(file) === file) throw cause
|
||||
return watchTarget(path.dirname(file))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const Module = Schema.Struct({
|
||||
default: Schema.Union([
|
||||
@@ -31,8 +77,9 @@ export class LoadError extends Schema.TaggedError<LoadError>()("PluginModule.Loa
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export const load = Effect.fn("PluginModule.load")(function* (
|
||||
const load = Effect.fn("PluginModule.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
sources: ReturnType<typeof createPluginSources>,
|
||||
options?: { readonly install?: boolean },
|
||||
) {
|
||||
const local = path.isAbsolute(operation.target)
|
||||
@@ -51,12 +98,13 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
const entrypoint = entrypoints.server
|
||||
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
|
||||
if (!entrypoint) return yield* new LoadError({ message: `Plugin entrypoint not found: ${operation.target}` })
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const target = typeof Bun !== "undefined" ? fileURLToPath(entrypoint).replaceAll("\\", "/") : entrypoint
|
||||
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
|
||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
|
||||
const mod = yield* Effect.promise(() => Host.load(source))
|
||||
const value = (yield* Schema.decodeUnknownEffect(Module)(mod).pipe(
|
||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint })
|
||||
const loaded = yield* Effect.promise(() =>
|
||||
local
|
||||
? sources.read(entrypoint)
|
||||
: Host.load(entrypoint).then((module) => ({ module, version: installed?.revision })),
|
||||
)
|
||||
const value = (yield* Schema.decodeUnknownEffect(Module)(loaded.module).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new LoadError({
|
||||
@@ -72,7 +120,7 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
...(entrypoints.tui ? { tui: true as const } : {}),
|
||||
...(entrypoints.rpc ? { rpc: true as const } : {}),
|
||||
},
|
||||
revision: JSON.stringify([operation, installed?.revision]),
|
||||
revision: JSON.stringify([operation, loaded.version]),
|
||||
source: path.isAbsolute(operation.target)
|
||||
? { type: "local" as const, path: fileURLToPath(entrypoint) }
|
||||
: {
|
||||
|
||||
@@ -261,6 +261,7 @@ export const GithubCopilotPlugin = define({
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
const interaction = interactionType(evt.kind, session?.parentID !== undefined)
|
||||
evt.headers["X-Interaction-Type"] = interaction
|
||||
evt.headers["X-Interaction-Id"] = evt.sessionID
|
||||
if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent"
|
||||
}),
|
||||
{ providerID: Provider.ID.githubCopilot },
|
||||
|
||||
@@ -13,8 +13,10 @@ import { PluginInternal } from "./internal.js"
|
||||
import { PluginModule } from "./module.js"
|
||||
import { SdkPlugins } from "./sdk.js"
|
||||
import { PluginUpdate } from "./update.js"
|
||||
import { Watcher } from "../filesystem/watcher.js"
|
||||
|
||||
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
modules: Effect.Success<ReturnType<typeof PluginModule.make>>,
|
||||
pre: readonly Plugin.Generation[],
|
||||
post: readonly Plugin.Generation[],
|
||||
operations: readonly ConfigPluginSource.Operation[],
|
||||
@@ -53,7 +55,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
continue
|
||||
}
|
||||
|
||||
const plugin = yield* PluginModule.load(operation, { install }).pipe(
|
||||
const plugin = yield* modules.load(operation, { install }).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
const error = Cause.squash(cause)
|
||||
@@ -117,6 +119,7 @@ export const layer = Layer.effectDiscard(
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const instance = yield* InstancePlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const modules = yield* PluginModule.make()
|
||||
const bus = yield* Bus.Service
|
||||
const updates = yield* PluginUpdate.Service
|
||||
const internal = yield* PluginInternal.list()
|
||||
@@ -149,7 +152,7 @@ export const layer = Layer.effectDiscard(
|
||||
}))
|
||||
const operations = yield* sources.operations()
|
||||
// Activate everything available locally before waiting on missing package installs.
|
||||
const immediate = yield* resolve(pre, post, operations, false, running)
|
||||
const immediate = yield* resolve(modules, pre, post, operations, false, running)
|
||||
const source = (source: Plugin.Source) =>
|
||||
source.type === "package"
|
||||
? {
|
||||
@@ -164,7 +167,9 @@ export const layer = Layer.effectDiscard(
|
||||
resolved.failures.map((failure) => ({ ...failure, source: source(failure.source) })),
|
||||
)
|
||||
yield* apply(immediate)
|
||||
const resolved = immediate.pending.length ? yield* resolve(pre, post, operations, true, running) : immediate
|
||||
const resolved = immediate.pending.length
|
||||
? yield* resolve(modules, pre, post, operations, true, running)
|
||||
: immediate
|
||||
if (resolved !== immediate) yield* apply(resolved)
|
||||
running = resolved.packages
|
||||
const targets = new Set(
|
||||
@@ -203,6 +208,7 @@ export const layer = Layer.effectDiscard(
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* watch(sources.changes())
|
||||
yield* watch(modules.changes())
|
||||
yield* watch(Stream.fromEffectRepeat(Effect.sleep("24 hours")))
|
||||
yield* watch(bus.subscribe([Event.Updated, SdkPlugins.Updated]))
|
||||
yield* watch(
|
||||
@@ -240,6 +246,7 @@ const nodeDeps = [
|
||||
PluginUpdate.node,
|
||||
Bus.node,
|
||||
Npm.node,
|
||||
Watcher.node,
|
||||
PluginInternal.requirements,
|
||||
] as const
|
||||
|
||||
|
||||
@@ -2,42 +2,50 @@ export * as SystemPromptPlugin from "./system-prompt.js"
|
||||
|
||||
import { SystemPart } from "@opencode-ai/ai"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Effect } from "effect"
|
||||
import { SessionSystemPrompt } from "../session/system-prompt.js"
|
||||
|
||||
import PROMPT_ANTHROPIC from "./system-prompt/anthropic.txt"
|
||||
import PROMPT_GPT from "./system-prompt/gpt-extension.txt"
|
||||
import PROMPT_GPT from "./system-prompt/gpt.txt"
|
||||
import PROMPT_ASTRA from "./system-prompt/gpt-astra.txt"
|
||||
import PROMPT_KIMI from "./system-prompt/kimi.txt"
|
||||
import PROMPT_META from "./system-prompt/meta.txt"
|
||||
import PROMPT_TRINITY from "./system-prompt/trinity.txt"
|
||||
|
||||
export const OpenAIPlugin = make("openai", (id) => (id.includes("gpt") ? PROMPT_GPT : undefined), {
|
||||
operation: "append",
|
||||
})
|
||||
export const OpenAIPlugin = make(
|
||||
"openai",
|
||||
(model) => {
|
||||
if (!model.id.toLowerCase().includes("gpt")) return
|
||||
|
||||
export const AnthropicPlugin = make("anthropic", (id) => (id.includes("claude") ? PROMPT_ANTHROPIC : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const KimiPlugin = make("kimi", (id) => (id.includes("kimi") ? PROMPT_KIMI : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const ArceePlugin = make("arcee", (id) => (id.includes("trinity") ? PROMPT_TRINITY : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const MetaPlugin = make(
|
||||
"meta",
|
||||
(id) => {
|
||||
if (!id.includes("muse")) return
|
||||
const name = id.includes("muse-glimmer") ? "Muse Glimmer" : "Muse Spark"
|
||||
return PROMPT_META.replaceAll("{{MODEL_NAME}}", name)
|
||||
if (model.id.toLowerCase().includes("gpt-6")) return PROMPT_ASTRA
|
||||
|
||||
return PROMPT_GPT
|
||||
},
|
||||
{ operation: "replace" },
|
||||
)
|
||||
|
||||
export const Plugins = [OpenAIPlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
|
||||
export const KimiPlugin = make("kimi", (model) => (model.id.toLowerCase().includes("kimi") ? PROMPT_KIMI : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const ArceePlugin = make(
|
||||
"arcee",
|
||||
(model) => (model.id.toLowerCase().includes("trinity") ? PROMPT_TRINITY : undefined),
|
||||
{ operation: "replace" },
|
||||
)
|
||||
export const MetaPlugin = make(
|
||||
"meta",
|
||||
(model) => {
|
||||
if (!model.id.toLowerCase().includes("muse")) return
|
||||
return PROMPT_META.replaceAll("{{MODEL_NAME}}", model.name)
|
||||
},
|
||||
{ operation: "replace" },
|
||||
)
|
||||
|
||||
export const Plugins = [OpenAIPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
|
||||
|
||||
function make(
|
||||
id: string,
|
||||
getPrompt: (modelID: string) => string | undefined,
|
||||
getPrompt: (model: Model.Info) => string | undefined,
|
||||
options: { operation: "replace" | "append" },
|
||||
) {
|
||||
return define({
|
||||
@@ -51,8 +59,9 @@ function make(
|
||||
const model = (yield* ctx.catalog.model.list()).data.find(
|
||||
(model) => model.providerID === event.model.providerID && model.id === event.model.id,
|
||||
)
|
||||
const prompt = getPrompt(`${model?.modelID ?? event.model.id} ${model?.family ?? ""}`.toLowerCase())
|
||||
if (!prompt) return
|
||||
const template = getPrompt(model ?? Model.Info.default(event.model.providerID, event.model.id))
|
||||
if (!template) return
|
||||
const prompt = SessionSystemPrompt.render(template, Object.keys(event.tools))
|
||||
if (options.operation === "append") {
|
||||
event.system.splice(1, 0, SystemPart.make(prompt))
|
||||
return
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
You are OpenCode, the best coding agent on the planet.
|
||||
|
||||
You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
||||
|
||||
If the user asks for help or wants to give feedback inform them of the following:
|
||||
- ctrl+p to list available actions
|
||||
- To give feedback, users should report the issue at
|
||||
https://github.com/anomalyco/opencode
|
||||
|
||||
When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the webfetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/v2/docs/
|
||||
|
||||
# Tone and style
|
||||
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||||
- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
||||
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like the shell tool or code comments as means to communicate with the user during the session.
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
|
||||
|
||||
# Professional objectivity
|
||||
Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs.
|
||||
|
||||
# Doing tasks
|
||||
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
|
||||
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.
|
||||
|
||||
|
||||
# Tool usage policy
|
||||
- When doing file search, prefer to use the subagent tool in order to reduce context usage.
|
||||
- You should proactively use the subagent tool with specialized agents when the task at hand matches the agent's description.
|
||||
|
||||
- When webfetch returns a message about a redirect to a different host, you should immediately make a new webfetch request with the redirect URL provided in the response.
|
||||
- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls.
|
||||
- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple subagent tool calls.
|
||||
- Use specialized tools instead of shell commands when possible, as this provides a better user experience. For file operations, use dedicated tools: read for reading files instead of cat/head/tail, edit for editing instead of sed/awk, and write for creating files instead of cat with heredoc or echo redirection. Reserve the shell tool exclusively for actual system commands and terminal operations that require shell execution. NEVER use shell echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
|
||||
- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the subagent tool instead of running search commands directly.
|
||||
<example>
|
||||
user: Where are errors from the client handled?
|
||||
assistant: [Uses the subagent tool to find the files that handle client errors instead of using glob or grep directly]
|
||||
</example>
|
||||
<example>
|
||||
user: What is the codebase structure?
|
||||
assistant: [Uses the subagent tool]
|
||||
</example>
|
||||
|
||||
# Code References
|
||||
|
||||
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
|
||||
|
||||
<example>
|
||||
user: Where are errors from the client handled?
|
||||
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
|
||||
</example>
|
||||
@@ -0,0 +1,45 @@
|
||||
You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.
|
||||
|
||||
# Harness
|
||||
- Responses are rendered as GitHub-flavored Markdown.
|
||||
- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.
|
||||
- Prefer parallelizing independent tool calls.
|
||||
- Do not use a skill based solely on keywords, superficial relevance, or its availability. Avoid re-reading skills already available in the conversation unless needed.
|
||||
${OPENCODE_TOOL_GUIDANCE}
|
||||
|
||||
# Communication
|
||||
|
||||
State the main point clearly and early. Keep responses clear and concise, and avoid unnecessary technical jargon. Use only as much structure as needed, and include technical detail only when it helps the conversation. Use clear file paths when referring to files.
|
||||
|
||||
When describing your work, avoid adding what you won't do, what will remain unchanged, or how you'll separate or categorize results. Do not introduce unprompted alternatives through framing such as "X, not Y" or "This isn't about X. It's about Y."
|
||||
|
||||
## Autonomy
|
||||
|
||||
Infer the user's intent and your task scope from their instructions and the prior conversation context. You should bias towards action and carry out the user's intended task until it is completed. If the intent is unclear, progress towards the goal using the available information and ask for clarification while continuing independent work when possible.
|
||||
|
||||
When the user's prompt indicates a request for action, such as "can you...", "I want to...", "help me..." and similar expressions, treat these as instructions to take action. Do not stop at acknowledging capability (e.g. "Yes…"), proposing a plan, or offering to continue. Do not settle for a partial or "helpful enough" solution to save time, effort, or tokens. Continue until the user's intended goal is fulfilled, even when it requires sustained work.
|
||||
|
||||
## Intermediate Commentary
|
||||
|
||||
As you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.
|
||||
|
||||
By default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.
|
||||
|
||||
Do not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.
|
||||
|
||||
## Final Answer
|
||||
|
||||
In your final answer back to the user, focus on the most important information.
|
||||
|
||||
# Working in codebases
|
||||
|
||||
- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.
|
||||
- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.
|
||||
- Do not introduce unsolicited warnings, disclaimers, approval flows, or safety/compliance checklists due to hypothetical risk.
|
||||
- Do not write tests for reversible, low-impact changes or that mirror the implementation. If you do choose to verify your work with tests, make sure that the tests are meaningful and necessary to verify implementation.
|
||||
- Run tests appropriate to the change and complete required checks. Once those pass, broaden or repeat testing only when new changes, failures, or unresolved concerns justify it; otherwise, continue toward completing the task.
|
||||
|
||||
|
||||
# Delegation
|
||||
|
||||
Do not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.
|
||||
+22
-2
@@ -1,10 +1,30 @@
|
||||
# Response channels
|
||||
You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.
|
||||
|
||||
# Harness
|
||||
- Responses are rendered as GitHub-flavored Markdown.
|
||||
- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.
|
||||
- Prefer parallelizing independent tool calls.
|
||||
${OPENCODE_TOOL_GUIDANCE}
|
||||
|
||||
# Communication
|
||||
|
||||
Use clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.
|
||||
|
||||
## Intermediate Commentary
|
||||
|
||||
As you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.
|
||||
|
||||
By default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.
|
||||
|
||||
Do not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.
|
||||
|
||||
In the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and skip explanation the user did not ask for. Include technical detail only where it helps.
|
||||
## Final Answer
|
||||
|
||||
In the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.
|
||||
|
||||
# Working in codebases
|
||||
- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.
|
||||
- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.
|
||||
|
||||
# Delegation
|
||||
|
||||
@@ -3,10 +3,15 @@ export * as SessionSystemPrompt from "./system-prompt.js"
|
||||
import PROMPT from "./runner/prompt/system.txt"
|
||||
|
||||
export function make(tools: string[]) {
|
||||
return render(PROMPT, tools)
|
||||
}
|
||||
|
||||
export function render(prompt: string, tools: string[]) {
|
||||
const instructions: string[] = []
|
||||
if (tools.includes("shell")) {
|
||||
instructions.push(
|
||||
"- Prefer dedicated tools over shell commands; fall back to the shell when a tool cannot do what you need.",
|
||||
"- Do not chain shell commands with separators like `echo \"====\";` or `printf '---'`; the output becomes noisy in a way that makes the user's side of the conversation worse.",
|
||||
)
|
||||
}
|
||||
if (tools.includes("write")) {
|
||||
@@ -19,5 +24,5 @@ export function make(tools: string[]) {
|
||||
"- Use the edit tool for targeted changes to existing text files. It replaces the exact text in `oldString` with `newString`, and the values must differ. By default, `oldString` must occur exactly once. If it occurs multiple times, include more surrounding context to make it unique or set `replaceAll` to true to replace every occurrence.",
|
||||
)
|
||||
}
|
||||
return PROMPT.replace("${OPENCODE_TOOL_GUIDANCE}", instructions.join("\n"))
|
||||
return prompt.replace("${OPENCODE_TOOL_GUIDANCE}", instructions.join("\n"))
|
||||
}
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n- Use clear file paths when referring to files.\\n- Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Response channels\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and skip explanation the user did not ask for. Include technical detail only where it helps.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -906,14 +906,16 @@ for (const entry of [
|
||||
)
|
||||
}
|
||||
|
||||
for (const query of ["", "?source=hello%20world&tag=a&tag=b"]) {
|
||||
testEffect(Layer.empty).live(`retries an MCP initialization 404 with the original URL: ${query || "no query"}`, () =>
|
||||
for (const { status, query } of [400, 404].flatMap((status) =>
|
||||
["", "?source=hello%20world&tag=a&tag=b"].map((query) => ({ status, query })),
|
||||
)) {
|
||||
testEffect(Layer.empty).live(`retries MCP initialization ${status} at the original URL: ${query || "no query"}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const headers: Array<string | null> = []
|
||||
const server = yield* resourceServer({
|
||||
respond: (request) => {
|
||||
headers.push(request.headers.get("x-mcp-test"))
|
||||
return new URL(request.url).searchParams.has("codemode") ? new Response(null, { status: 404 }) : undefined
|
||||
return new URL(request.url).searchParams.has("codemode") ? new Response(null, { status }) : undefined
|
||||
},
|
||||
})
|
||||
const config = new ConfigMCP.Remote({
|
||||
@@ -939,7 +941,7 @@ for (const query of ["", "?source=hello%20world&tag=a&tag=b"]) {
|
||||
|
||||
for (const entry of [
|
||||
{ name: "second 404", status: 404, query: "", codemode: undefined, attempts: 2 },
|
||||
{ name: "400", status: 400, query: "", codemode: undefined, attempts: 1 },
|
||||
{ name: "second 400", status: 400, query: "", codemode: undefined, attempts: 2 },
|
||||
{ name: "401", status: 401, query: "", codemode: undefined, attempts: 1 },
|
||||
{ name: "403", status: 403, query: "", codemode: undefined, attempts: 1 },
|
||||
{ name: "500", status: 500, query: "", codemode: undefined, attempts: 1 },
|
||||
@@ -947,6 +949,10 @@ for (const entry of [
|
||||
{ name: "user codemode=false", status: 404, query: "?codemode=false", codemode: undefined, attempts: 1 },
|
||||
{ name: "empty user codemode", status: 404, query: "?codemode=", codemode: undefined, attempts: 1 },
|
||||
{ name: "direct tools", status: 404, query: "", codemode: false, attempts: 1 },
|
||||
{ name: "400 with user codemode=true", status: 400, query: "?codemode=true", codemode: undefined, attempts: 1 },
|
||||
{ name: "400 with user codemode=false", status: 400, query: "?codemode=false", codemode: undefined, attempts: 1 },
|
||||
{ name: "400 with empty user codemode", status: 400, query: "?codemode=", codemode: undefined, attempts: 1 },
|
||||
{ name: "400 with direct tools", status: 400, query: "", codemode: false, attempts: 1 },
|
||||
]) {
|
||||
testEffect(Layer.empty).live(`does not retry MCP beyond the query fallback: ${entry.name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -970,23 +976,25 @@ for (const entry of [
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(Layer.empty).live("does not strip codemode for an MCP 404 after initialization", () =>
|
||||
Effect.gen(function* () {
|
||||
let expired = false
|
||||
const server = yield* resourceServer({
|
||||
respond: (request) => (expired && request.method === "POST" ? new Response(null, { status: 404 }) : undefined),
|
||||
})
|
||||
const config = new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false })
|
||||
const connection = yield* connect("resources", config, import.meta.dir)
|
||||
expired = true
|
||||
expect(yield* connection.tools().pipe(Effect.flip)).toBeInstanceOf(Error)
|
||||
for (const status of [400, 404]) {
|
||||
testEffect(Layer.empty).live(`does not strip codemode for an MCP ${status} after initialization`, () =>
|
||||
Effect.gen(function* () {
|
||||
let expired = false
|
||||
const server = yield* resourceServer({
|
||||
respond: (request) => (expired && request.method === "POST" ? new Response(null, { status }) : undefined),
|
||||
})
|
||||
const config = new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false })
|
||||
const connection = yield* connect("resources", config, import.meta.dir)
|
||||
expired = true
|
||||
expect(yield* connection.tools().pipe(Effect.flip)).toBeInstanceOf(Error)
|
||||
|
||||
// The SDK tries to recover the expired session, but must keep the same URL.
|
||||
expect(server.state.initializations).toBe(2)
|
||||
expect(new Set(server.state.urls)).toEqual(new Set([server.url + "?codemode=false"]))
|
||||
expect(server.state.toolLists).toBe(0)
|
||||
}),
|
||||
)
|
||||
// The SDK tries to recover an expired session on 404, but must keep the same URL.
|
||||
expect(server.state.initializations).toBe(status === 404 ? 2 : 1)
|
||||
expect(new Set(server.state.urls)).toEqual(new Set([server.url + "?codemode=false"]))
|
||||
expect(server.state.toolLists).toBe(0)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
test("lists, reads, and reports MCP resource changes", async () => {
|
||||
await Effect.runPromise(
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginModule } from "@opencode-ai/core/plugin/module"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { fromPromise } from "@opencode-ai/plugin/promise/adapter"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -90,7 +91,8 @@ it.live("loads a local plugin with its configured options", () =>
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
yield* plugins.awaitActivation
|
||||
const definition = yield* PluginModule.load({
|
||||
const modules = yield* PluginModule.make().pipe(Effect.provide(Watcher.testLayer))
|
||||
const definition = yield* modules.load({
|
||||
type: "add",
|
||||
target: path.join(import.meta.dir, "plugin/fixtures/greeting.ts"),
|
||||
options: { description: "Configured greeting" },
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Schedule, Scope, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { PluginModule } from "@opencode-ai/core/plugin/module"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
AppNodeBuilder.build(Npm.node, [Global.node.replace(tempGlobalLayer)]),
|
||||
Watcher.layer().pipe(Layer.provide(Watcher.nativeLayer)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("watches creation of an external helper with missing parent directories", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const entry = path.join(directory.path, "plugin/index.ts")
|
||||
yield* Effect.promise(() => Bun.write(entry, 'export { default } from "../shared/new/nested/helper.ts"'))
|
||||
const modules = yield* PluginModule.make()
|
||||
const operation = { type: "add" as const, target: path.dirname(entry), options: {} }
|
||||
expect(Exit.isFailure(yield* modules.load(operation).pipe(Effect.exit))).toBe(true)
|
||||
const changed = yield* modules
|
||||
.changes()
|
||||
.pipe(Stream.runHead, Effect.timeout("5 seconds"), Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(directory.path, "shared/new/nested/helper.ts"),
|
||||
'export default { id: "appeared", async setup() {} }',
|
||||
),
|
||||
)
|
||||
yield* Fiber.join(changed)
|
||||
const loaded = yield* modules.load(operation)
|
||||
expect(loaded).toMatchObject({ id: "appeared" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("interrupts pending watcher setup when the loader scope closes during module evaluation", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const entry = path.join(directory.path, "index.ts")
|
||||
const entered = path.join(directory.path, "entered")
|
||||
const release = path.join(directory.path, "release")
|
||||
const started = yield* Deferred.make<void>()
|
||||
const stopped = yield* Deferred.make<void>()
|
||||
const gate = yield* Deferred.make<void>()
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => Bun.write(release, "release")).pipe(
|
||||
Effect.andThen(Deferred.succeed(gate, undefined)),
|
||||
Effect.andThen(Scope.close(scope, Exit.void)),
|
||||
),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
entry,
|
||||
`
|
||||
await Bun.write(${JSON.stringify(entered)}, "entered")
|
||||
while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(5)
|
||||
export default { id: "pending", async setup() {} }
|
||||
`,
|
||||
),
|
||||
)
|
||||
const modules = yield* PluginModule.make().pipe(
|
||||
Effect.provideService(Scope.Scope, scope),
|
||||
Effect.provideService(Watcher.Service, {
|
||||
subscribe: () =>
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(gate)),
|
||||
Effect.ensuring(Deferred.succeed(stopped, undefined)),
|
||||
Effect.as(Stream.never),
|
||||
),
|
||||
}),
|
||||
)
|
||||
yield* modules.load({ type: "add", target: directory.path, options: {} }).pipe(Effect.forkIn(scope))
|
||||
yield* Deferred.await(started)
|
||||
yield* Effect.promise(() => Bun.file(entered).exists()).pipe(
|
||||
Effect.repeat({ until: (exists) => exists, schedule: Schedule.spaced("5 millis") }),
|
||||
Effect.timeout("2 seconds"),
|
||||
)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* Deferred.isDone(stopped)).toBe(true)
|
||||
yield* Effect.promise(() => Bun.write(release, "release"))
|
||||
// Let the uncancellable native import finish; Bun's test runner detects unhandled rejections.
|
||||
yield* Effect.promise(() => Bun.sleep(50))
|
||||
}),
|
||||
)
|
||||
@@ -175,7 +175,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "primary")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" })
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent", "X-Interaction-Id": event.sessionID })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -183,7 +183,11 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).child, "primary")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-subagent", "x-initiator": "agent" })
|
||||
expect(event.headers).toEqual({
|
||||
"X-Interaction-Type": "conversation-subagent",
|
||||
"X-Interaction-Id": event.sessionID,
|
||||
"x-initiator": "agent",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -191,7 +195,11 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "title")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-background", "x-initiator": "agent" })
|
||||
expect(event.headers).toEqual({
|
||||
"X-Interaction-Type": "conversation-background",
|
||||
"X-Interaction-Id": event.sessionID,
|
||||
"x-initiator": "agent",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -199,7 +207,11 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).child, "compaction", "build")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-compaction", "x-initiator": "agent" })
|
||||
expect(event.headers).toEqual({
|
||||
"X-Interaction-Type": "conversation-compaction",
|
||||
"X-Interaction-Id": event.sessionID,
|
||||
"x-initiator": "agent",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -207,7 +219,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "primary", "compaction")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" })
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent", "X-Interaction-Id": event.sessionID })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -252,7 +264,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "generate")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" })
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent", "X-Interaction-Id": event.sessionID })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Instance } from "@opencode-ai/core/instance"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { Rpc } from "@opencode-ai/core/rpc"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
@@ -110,6 +111,86 @@ const failed = (plugins: Plugin.Interface) =>
|
||||
)
|
||||
|
||||
describe("PluginSupervisor reload", () => {
|
||||
;(
|
||||
[
|
||||
{ name: "on a helper-only save", helper: "nested/helper.ts", touchEntry: false },
|
||||
{ name: "when the entrypoint also changes", helper: "nested/helper.ts", touchEntry: true },
|
||||
{ name: "outside the configured plugin directory", helper: "../shared/helper.ts", touchEntry: false },
|
||||
] as const
|
||||
).forEach((scenario) => {
|
||||
it.live(`reloads helper-defined RPC methods ${scenario.name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const root = path.join(directory.path, "external/greeter")
|
||||
const file = path.join(root, "index.ts")
|
||||
const helper = path.join(root, scenario.helper)
|
||||
const entry = `export { default } from ${JSON.stringify("./" + scenario.helper)}`
|
||||
const source = (version: number) => `import { Schema } from ${JSON.stringify(import.meta.resolve("effect"))}
|
||||
export default {
|
||||
id: "greeter",
|
||||
async setup(ctx) {
|
||||
await ctx.rpc.register({ id: "greeter", methods: {
|
||||
status: { input: Schema.Unknown, output: Schema.Number },
|
||||
${version > 1 ? "info: { input: Schema.Unknown, output: Schema.Number }," : ""}
|
||||
}, events: {} }, {
|
||||
status: async () => ${version},
|
||||
${version > 1 ? `info: async () => ${version},` : ""}
|
||||
})
|
||||
await ctx.command.transform(editor => editor.add({ name: "greet-v${version}", execute: async () => {} }))
|
||||
}
|
||||
}`
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(file, entry)
|
||||
await Bun.write(helper, source(1))
|
||||
await Bun.write(path.join(directory.path, ".opencode/opencode.json"), JSON.stringify({ plugins: [root] }))
|
||||
})
|
||||
const watcher = yield* Watcher.Test
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
const commands = yield* Command.Service
|
||||
yield* plugins.awaitActivation
|
||||
expect(yield* rpc.call("greeter", "status", {})).toBe(1)
|
||||
expect(yield* rpc.call("greeter", "info", {}).pipe(Effect.flip)).toMatchObject({
|
||||
type: "rpc.method_not_found",
|
||||
})
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(helper, source(2))
|
||||
if (scenario.touchEntry) {
|
||||
await Bun.write(file, entry + "; // updated entry")
|
||||
await fs.utimes(file, new Date(), new Date(Date.now() + 1000))
|
||||
}
|
||||
})
|
||||
yield* watcher.emit({ path: scenario.touchEntry ? file : helper, type: "update" })
|
||||
yield* rpc
|
||||
.call("greeter", "info", {})
|
||||
.pipe(Effect.retry({ times: 80, schedule: Schedule.spaced("25 millis") }))
|
||||
expect(yield* rpc.call("greeter", "info", {})).toBe(2)
|
||||
expect(yield* commands.get("greet-v1")).toBeUndefined()
|
||||
expect(yield* commands.get("greet-v2")).toBeDefined()
|
||||
|
||||
// Failed helper evaluations retain the active registration and recover on the next save.
|
||||
yield* Effect.promise(() => Bun.write(helper, 'throw new Error("broken helper"); export default {}'))
|
||||
yield* watcher.emit({ path: helper, type: "update" })
|
||||
yield* failed(plugins)
|
||||
expect(yield* rpc.call("greeter", "info", {})).toBe(2)
|
||||
yield* Effect.promise(() => Bun.write(helper, source(3)))
|
||||
yield* watcher.emit({ path: helper, type: "update" })
|
||||
yield* commands.get("greet-v3").pipe(
|
||||
Effect.flatMap((command) => (command ? Effect.void : Effect.fail("activation pending"))),
|
||||
Effect.retry({ times: 80, schedule: Schedule.spaced("25 millis") }),
|
||||
)
|
||||
expect(yield* rpc.call("greeter", "info", {})).toBe(3)
|
||||
expect(yield* commands.get("greet-v2")).toBeUndefined()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
;(["discovered", "configured"] as const).forEach((mode) => {
|
||||
it.effect(`retains a ${mode} plugin change during initial activation`, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -54,7 +54,6 @@ describe("SystemPromptPlugin", () => {
|
||||
test("uses granular IDs with a common prefix", () => {
|
||||
expect(SystemPromptPlugin.Plugins.map((plugin) => plugin.id)).toEqual([
|
||||
"opencode.prompt.openai",
|
||||
"opencode.prompt.anthropic",
|
||||
"opencode.prompt.kimi",
|
||||
"opencode.prompt.arcee",
|
||||
"opencode.prompt.meta",
|
||||
@@ -67,19 +66,23 @@ describe("SystemPromptPlugin", () => {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
yield* catalog.transform((editor) => {
|
||||
for (const id of ["gpt-5", "gpt-4.1", "gpt-5-codex"])
|
||||
for (const id of ["gpt-5", "gpt-4.1", "gpt-5-codex", "gpt-6-astra"])
|
||||
editor.model.update(Provider.ID.make("test"), Model.ID.make(id), () => {})
|
||||
editor.model.update(Provider.ID.make("test"), Model.ID.make("meta/muse-spark-1.1"), (model) => {
|
||||
model.name = "Muse Spark"
|
||||
})
|
||||
})
|
||||
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
|
||||
discard: true,
|
||||
})
|
||||
const cases = [
|
||||
["gpt-5", "# Response channels"],
|
||||
["gpt-4.1", "# Response channels"],
|
||||
["gpt-5", "# Delegation"],
|
||||
["gpt-4.1", "# Delegation"],
|
||||
["o3", fallback],
|
||||
["gpt-5-codex", "# Response channels"],
|
||||
["gpt-5-codex", "# Delegation"],
|
||||
["gpt-6-astra", "Do not settle for a partial"],
|
||||
["gemini-2.5-pro", fallback],
|
||||
["claude-sonnet-4", "# Professional objectivity"],
|
||||
["claude-sonnet-4", fallback],
|
||||
["kimi-k2", "# Prompt and Tool Use"],
|
||||
["trinity", "what command should I run to list files"],
|
||||
["meta/muse-spark-1.1", "powered by Muse Spark"],
|
||||
@@ -103,7 +106,7 @@ describe("SystemPromptPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("appends the OpenAI extension after the baseline", () =>
|
||||
it.effect("renders the OpenAI prompt and preserves project instructions", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
@@ -113,26 +116,42 @@ describe("SystemPromptPlugin", () => {
|
||||
)
|
||||
yield* SystemPromptPlugin.OpenAIPlugin.effect(pluginHost)
|
||||
const event = context("gpt-5")
|
||||
event.system.push(SystemPart.make("Project instructions"))
|
||||
event.tools.shell = { description: "Run a command", input: { type: "object" } }
|
||||
|
||||
yield* hooks.trigger("session", "context", event)
|
||||
|
||||
expect(event.system.map((part) => part.text)).toEqual([fallback, expect.stringContaining("# Delegation")])
|
||||
expect(event.system.map((part) => part.text)).toEqual([
|
||||
expect.stringContaining("# Delegation"),
|
||||
"Project instructions",
|
||||
])
|
||||
expect(event.system[0]?.text).toStartWith("You are an AI agent powered by OpenCode")
|
||||
expect(event.system[0]?.text).toContain("Prefer dedicated tools over shell commands")
|
||||
expect(event.system[0]?.text).not.toContain("${OPENCODE_TOOL_GUIDANCE}")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("selects the Meta prompt for Muse family model IDs", () =>
|
||||
it.effect("uses catalog names in Meta prompts for Muse model IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
const cases = [
|
||||
["meta/muse-spark-preview", "Muse Spark Preview"],
|
||||
["muse-spark-1.2", "Muse Spark 1.2"],
|
||||
["meta/muse-glimmer-30b", "Muse Glimmer 30B"],
|
||||
["muse-glimmer-30b", "Muse Glimmer"],
|
||||
] as const
|
||||
yield* catalog.transform((editor) => {
|
||||
for (const [id, name] of cases)
|
||||
editor.model.update(Provider.ID.make("test"), Model.ID.make(id), (model) => {
|
||||
model.name = name
|
||||
})
|
||||
})
|
||||
yield* SystemPromptPlugin.MetaPlugin.effect(pluginHost)
|
||||
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
["meta/muse-spark-preview", "Muse Spark"],
|
||||
["muse-spark-1.2", "Muse Spark"],
|
||||
["meta/muse-glimmer-30b", "Muse Glimmer"],
|
||||
["muse-glimmer-30b", "Muse Glimmer"],
|
||||
] as const,
|
||||
cases,
|
||||
([id, name]) => {
|
||||
const event = context(id)
|
||||
return hooks.trigger("session", "context", event).pipe(
|
||||
@@ -190,19 +209,19 @@ describe("SystemPromptPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
yield* SystemPromptPlugin.AnthropicPlugin.effect(pluginHost)
|
||||
yield* SystemPromptPlugin.KimiPlugin.effect(pluginHost)
|
||||
const gemini = context("gemini-2.5-pro")
|
||||
const claude = context("claude-sonnet-4")
|
||||
const kimi = context("kimi-k2")
|
||||
|
||||
yield* hooks.trigger("session", "context", gemini)
|
||||
yield* hooks.trigger("session", "context", claude)
|
||||
yield* hooks.trigger("session", "context", kimi)
|
||||
|
||||
expect(gemini.system[0]?.text).toBe(fallback)
|
||||
expect(claude.system[0]?.text).toContain("# Professional objectivity")
|
||||
expect(kimi.system[0]?.text).toContain("# Prompt and Tool Use")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("selects against the catalog model ID instead of its alias", () =>
|
||||
it.effect("selects against the catalog ID rather than the physical model ID or family", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
@@ -228,12 +247,9 @@ describe("SystemPromptPlugin", () => {
|
||||
yield* hooks.trigger("session", "context", physicalCustom)
|
||||
yield* hooks.trigger("session", "context", familyOpenAI)
|
||||
|
||||
expect(physicalOpenAI.system.map((part) => part.text)).toEqual([
|
||||
fallback,
|
||||
expect.stringContaining("# Delegation"),
|
||||
])
|
||||
expect(physicalCustom.system.map((part) => part.text)).toEqual([fallback])
|
||||
expect(familyOpenAI.system.map((part) => part.text)).toEqual([fallback, expect.stringContaining("# Delegation")])
|
||||
expect(physicalOpenAI.system.map((part) => part.text)).toEqual([fallback])
|
||||
expect(physicalCustom.system.map((part) => part.text)).toEqual([expect.stringContaining("# Delegation")])
|
||||
expect(familyOpenAI.system.map((part) => part.text)).toEqual([fallback])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1590,7 +1590,6 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
||||
defaultSystem,
|
||||
expect.stringContaining("# Delegation"),
|
||||
"Initial context",
|
||||
])
|
||||
@@ -1611,7 +1610,6 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
||||
defaultSystem,
|
||||
expect.stringContaining("# Delegation"),
|
||||
"Initial context",
|
||||
])
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
"./tui": "./src/tui/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#plugin-source": {
|
||||
"bun": "./src/source.bun.ts",
|
||||
"node": "./src/source.node.ts",
|
||||
"default": "./src/source.node.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -16,6 +16,7 @@ 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}`)
|
||||
@@ -28,6 +29,17 @@ 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`
|
||||
|
||||
@@ -2,15 +2,16 @@ import { createRequire } from "node:module"
|
||||
import { readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { localSource } from "./discovery"
|
||||
import { Host } from "./host.js"
|
||||
import { localSource } from "./source.js"
|
||||
|
||||
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.split(path.sep).includes("node_modules")) return
|
||||
if (file !== root && file.split(path.sep).includes("node_modules")) return
|
||||
if (search) delete require.cache[file + search]
|
||||
if (files.has(file)) return
|
||||
files.add(file)
|
||||
@@ -54,6 +55,6 @@ export async function prepareSource(entrypoint: string, track: (file: string, di
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(fileURLToPath(entrypoint))
|
||||
visit(root)
|
||||
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 "./discovery"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { localSource } from "./source.js"
|
||||
import { Host } from "./host.js"
|
||||
|
||||
let generation = Date.now()
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM oven/bun:1.4.1-alpine AS base
|
||||
FROM oven/bun:1.4.2-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -54,11 +54,6 @@
|
||||
"./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",
|
||||
|
||||
@@ -232,7 +232,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
Effect.gen(function* () {
|
||||
const options = {
|
||||
externalOutputMode: "passthrough",
|
||||
targetFps: 60,
|
||||
targetFps: config.targetFps ?? 60,
|
||||
maxFps: config.maxFps ?? 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: {},
|
||||
@@ -598,6 +599,8 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
|
||||
createEffect(() => {
|
||||
renderer.useMouse = config.data.mouse
|
||||
renderer.targetFps = config.data.targetFps ?? 60
|
||||
renderer.maxFps = config.data.maxFps ?? 60
|
||||
})
|
||||
|
||||
let active: { id: string; title?: string } | undefined
|
||||
|
||||
@@ -46,6 +46,7 @@ export function TerminalPane(props: {
|
||||
let restored = false
|
||||
let wantsControl = false
|
||||
let disposed = false
|
||||
let exited = false
|
||||
let size: TerminalSize | undefined
|
||||
let canonicalSize: TerminalSize | undefined
|
||||
let terminalSize: TerminalSize | undefined
|
||||
@@ -203,6 +204,10 @@ export function TerminalPane(props: {
|
||||
if (typeof event.data !== "string") return
|
||||
const message: unknown = JSON.parse(event.data)
|
||||
if (!message || typeof message !== "object" || !("type" in message)) return
|
||||
if (message.type === "exited") {
|
||||
exited = true
|
||||
return
|
||||
}
|
||||
if (
|
||||
message.type === "resized" &&
|
||||
"cols" in message &&
|
||||
@@ -266,7 +271,8 @@ export function TerminalPane(props: {
|
||||
if (disposed) return
|
||||
const focused = terminal?.focused
|
||||
terminal = undefined
|
||||
setFailure("Terminal disconnected")
|
||||
// The removal event arrives separately; keep the terminal visible until then.
|
||||
if (!exited) setFailure("Terminal disconnected")
|
||||
if (focused) props.onDisconnect?.()
|
||||
})
|
||||
socket = next
|
||||
|
||||
@@ -230,6 +230,12 @@ export const Info = Schema.Struct({
|
||||
description: "Experimental features that may change or be removed at any time",
|
||||
}),
|
||||
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
|
||||
targetFps: Schema.optional(Schema.Finite.check(Schema.isGreaterThan(0))).annotate({
|
||||
description: "Target animation frame rate (default: 60 in the TUI, 30 in Mini)",
|
||||
}),
|
||||
maxFps: Schema.optional(Schema.Finite.check(Schema.isGreaterThan(0))).annotate({
|
||||
description: "Maximum frame rate for requested redraws (default: 60)",
|
||||
}),
|
||||
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
|
||||
cursor: Schema.optional(Cursor),
|
||||
})
|
||||
|
||||
@@ -197,7 +197,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
if (state().tabs.some((tab) => tab.sessionID === sessionID)) return
|
||||
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
|
||||
const replaced = permanent ? undefined : previewID()
|
||||
if (replaced) scrollAnchors.delete(replaced)
|
||||
if (replaced) family(replaced).forEach((id) => scrollAnchors.delete(id))
|
||||
if (!permanent) setPreview(sessionID)
|
||||
update((draft) => {
|
||||
if (cancelledTabs.has(sessionID)) return
|
||||
@@ -341,7 +341,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
function remove(sessionID: string, navigate: boolean) {
|
||||
const target = root(sessionID)
|
||||
cancelledTabs.add(target)
|
||||
scrollAnchors.delete(target)
|
||||
family(target).forEach((id) => scrollAnchors.delete(id))
|
||||
if (previewID() === target) setPreview(undefined)
|
||||
const closed = closeSessionTab(state().tabs, target)
|
||||
const selected = navigate && current() === target
|
||||
@@ -379,17 +379,17 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
scrollAnchor(sessionID: string) {
|
||||
const target = root(sessionID)
|
||||
if (!state().tabs.some((tab) => tab.sessionID === target)) return
|
||||
return scrollAnchors.get(target)
|
||||
return scrollAnchors.get(sessionID)
|
||||
},
|
||||
setScrollAnchor(sessionID: string, anchor: ScrollAnchor | undefined) {
|
||||
const target = root(sessionID)
|
||||
if (anchor === undefined || !state().tabs.some((tab) => tab.sessionID === target)) {
|
||||
scrollAnchors.delete(target)
|
||||
scrollAnchors.delete(sessionID)
|
||||
return
|
||||
}
|
||||
const current = scrollAnchors.get(target)
|
||||
const current = scrollAnchors.get(sessionID)
|
||||
if (current?.messageID === anchor.messageID && current.screenY === anchor.screenY) return
|
||||
scrollAnchors.set(target, anchor)
|
||||
scrollAnchors.set(sessionID, anchor)
|
||||
},
|
||||
select(sessionID: string) {
|
||||
if (!enabled()) return
|
||||
|
||||
@@ -156,8 +156,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
const mono = miniSettings.mono
|
||||
const renderer = await createCliRenderer({
|
||||
stdin: input.host.terminal.stdin,
|
||||
targetFps: 30,
|
||||
maxFps: 60,
|
||||
targetFps: tuiConfig.targetFps ?? 30,
|
||||
maxFps: tuiConfig.maxFps ?? 60,
|
||||
useMouse: false,
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
|
||||
@@ -397,7 +397,7 @@ export type FormCancel = {
|
||||
|
||||
export type RunTuiConfig = Pick<
|
||||
Config.Resolved,
|
||||
"keybinds" | "leader" | "theme" | "mini" | "prompt" | "session" | "cursor" | "animations"
|
||||
"keybinds" | "leader" | "theme" | "mini" | "prompt" | "session" | "cursor" | "animations" | "targetFps" | "maxFps"
|
||||
>
|
||||
|
||||
export type MiniSettings = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readdir, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
export { localSource } from "@opencode-ai/plugin/source"
|
||||
import { isMissingPath, localProjectDirectory, projectConfigDirectories } from "../util/config-directories"
|
||||
|
||||
export async function localPluginDirectories(cwd: string, configDirectory: string) {
|
||||
@@ -49,10 +49,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,56 +1 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
export { createPluginSources } from "@opencode-ai/plugin/source"
|
||||
|
||||
@@ -500,6 +500,8 @@ export function Session(props: {
|
||||
})
|
||||
}
|
||||
function saveScrollAnchor() {
|
||||
// Initial layout must not overwrite the saved position before synchronization restores it.
|
||||
if (!restored) return
|
||||
if (!isAwayFromBottom()) {
|
||||
sessionTabs.setScrollAnchor(sessionID, undefined)
|
||||
return
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||
|
||||
type AnimatableValue = number | readonly number[]
|
||||
@@ -27,11 +28,6 @@ type ValueState = {
|
||||
from: number[]
|
||||
}
|
||||
|
||||
type AnimationTask = (now: number) => boolean
|
||||
|
||||
const tasks = new Set<AnimationTask>()
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const smoothstep = (progress: number) => progress * progress * (3 - 2 * progress)
|
||||
|
||||
export function spring(options: { visualDuration: number; restDelta?: number; restSpeed?: number }): Transition {
|
||||
@@ -58,30 +54,25 @@ export function createAnimatable<T extends AnimatableTarget>(
|
||||
enabled?: Accessor<boolean>
|
||||
},
|
||||
) {
|
||||
const renderer = useRenderer()
|
||||
const enabled = options.enabled ?? (() => true)
|
||||
const state = new Map<string, ValueState>()
|
||||
const [value, setValue] = createSignal(clone(initial), { equals: false })
|
||||
let target = clone(initial)
|
||||
let started = performance.now()
|
||||
let previous = started
|
||||
let elapsed = 0
|
||||
let running = false
|
||||
|
||||
rebuild(initial)
|
||||
|
||||
const step: AnimationTask = (now) => {
|
||||
if (!enabled()) {
|
||||
jump(target)
|
||||
return false
|
||||
}
|
||||
const step = async (deltaTime: number) => {
|
||||
if (!running) return
|
||||
if (!enabled()) return jump(target)
|
||||
|
||||
const delta = Math.min(0.05, (now - previous) / 1_000)
|
||||
previous = now
|
||||
const moving = options.transition.type === "spring" ? advanceSpring(delta) : advanceTween(now)
|
||||
if (!moving) {
|
||||
jump(target)
|
||||
return false
|
||||
}
|
||||
elapsed += deltaTime
|
||||
const moving =
|
||||
options.transition.type === "spring" ? advanceSpring(Math.min(0.05, deltaTime / 1_000)) : advanceTween()
|
||||
if (!moving) return jump(target)
|
||||
setValue(() => read())
|
||||
return true
|
||||
}
|
||||
|
||||
function animate(next: T) {
|
||||
@@ -89,25 +80,30 @@ export function createAnimatable<T extends AnimatableTarget>(
|
||||
target = clone(next)
|
||||
if (!enabled() || !sameShape(next)) return jump(next)
|
||||
|
||||
started = performance.now()
|
||||
previous = started
|
||||
elapsed = 0
|
||||
for (const [key, current] of state) {
|
||||
current.from = [...current.value]
|
||||
current.target = values(next[key]!)
|
||||
}
|
||||
if (settled()) return jump(next)
|
||||
schedule(step)
|
||||
if (running) return
|
||||
running = true
|
||||
renderer.setFrameCallback(step)
|
||||
renderer.requestLive()
|
||||
}
|
||||
|
||||
function jump(next: T) {
|
||||
target = clone(next)
|
||||
unschedule(step)
|
||||
stop()
|
||||
rebuild(next)
|
||||
setValue(() => clone(next))
|
||||
}
|
||||
|
||||
function stop() {
|
||||
unschedule(step)
|
||||
if (!running) return
|
||||
running = false
|
||||
renderer.removeFrameCallback(step)
|
||||
renderer.dropLive()
|
||||
}
|
||||
|
||||
function rebuild(next: T) {
|
||||
@@ -122,8 +118,7 @@ export function createAnimatable<T extends AnimatableTarget>(
|
||||
from: [...value],
|
||||
})
|
||||
}
|
||||
started = performance.now()
|
||||
previous = started
|
||||
elapsed = 0
|
||||
}
|
||||
|
||||
function sameShape(next: T) {
|
||||
@@ -174,10 +169,10 @@ export function createAnimatable<T extends AnimatableTarget>(
|
||||
return moving
|
||||
}
|
||||
|
||||
function advanceTween(now: number) {
|
||||
function advanceTween() {
|
||||
const transition = options.transition
|
||||
if (transition.type !== "tween") return false
|
||||
const progress = Math.min(1, (now - started) / 1_000 / Math.max(0.001, transition.duration))
|
||||
const progress = Math.min(1, elapsed / 1_000 / Math.max(0.001, transition.duration))
|
||||
const eased = transition.ease(progress)
|
||||
for (const current of state.values())
|
||||
current.value.forEach((_, index) => {
|
||||
@@ -212,24 +207,3 @@ function clone<T extends AnimatableTarget>(target: T) {
|
||||
Object.entries(target).map(([key, value]) => [key, typeof value === "number" ? value : [...value]]),
|
||||
) as T
|
||||
}
|
||||
|
||||
function schedule(task: AnimationTask) {
|
||||
tasks.add(task)
|
||||
if (timer) return
|
||||
timer = setInterval(tick, 16)
|
||||
}
|
||||
|
||||
function unschedule(task: AnimationTask) {
|
||||
tasks.delete(task)
|
||||
if (tasks.size > 0 || !timer) return
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const now = performance.now()
|
||||
for (const task of tasks) if (!task(now)) tasks.delete(task)
|
||||
if (tasks.size > 0 || !timer) return
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
|
||||
@@ -10,6 +10,48 @@ import { tmpdir } from "./fixture/fixture"
|
||||
import { createAppFixture } from "./fixture/app"
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
|
||||
test("applies frame rates to the handed-off renderer and reloads edits and removal", async () => {
|
||||
await using state = await tmpdir()
|
||||
const file = Bun.file(path.join(state.path, "cli.json"))
|
||||
await file.write(JSON.stringify({ targetFps: 24, maxFps: 90, animations: false }))
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false, targetFps: 15, maxFps: 30 })
|
||||
setup.renderer.start()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const calls = createFetch(undefined, createEventStream())
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { path: file.name, get: () => file.json(), update: () => file.json() },
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
args: {},
|
||||
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
try {
|
||||
await ready.promise
|
||||
await setup.waitForFrame(() => setup.renderer.targetFps === 24 && setup.renderer.maxFps === 90)
|
||||
expect(setup.renderer.targetFps).toBe(24)
|
||||
expect(setup.renderer.maxFps).toBe(90)
|
||||
|
||||
await file.write(JSON.stringify({ targetFps: 120, maxFps: 45, animations: false }))
|
||||
await setup.waitFor(() => setup.renderer.targetFps === 120 && setup.renderer.maxFps === 45)
|
||||
expect(setup.renderer.targetFps).toBe(120)
|
||||
expect(setup.renderer.maxFps).toBe(45)
|
||||
|
||||
await file.write(JSON.stringify({ animations: false }))
|
||||
await setup.waitFor(() => setup.renderer.targetFps === 60 && setup.renderer.maxFps === 60)
|
||||
expect(setup.renderer.targetFps).toBe(60)
|
||||
expect(setup.renderer.maxFps).toBe(60)
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await task
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
|
||||
await using state = await tmpdir()
|
||||
const requested = Promise.withResolvers<void>()
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { afterEach, describe, expect, jest, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { ManualClock } from "@opentui/core/testing"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { createMarquee, createTabMarquee } from "../../src/component/session-tabs"
|
||||
|
||||
afterEach(() => jest.useRealTimers())
|
||||
|
||||
async function setup<T>(create: () => T) {
|
||||
let marquee!: T
|
||||
const app = await testRender(
|
||||
() => {
|
||||
marquee = create()
|
||||
return null
|
||||
},
|
||||
{ clock: new ManualClock() },
|
||||
)
|
||||
return { marquee, dispose: () => app.renderer.destroy() }
|
||||
}
|
||||
|
||||
describe("session tab marquee", () => {
|
||||
test("starts for the hovered width and resets when the next tab fits", () => {
|
||||
test("starts for the hovered width and resets when the next tab fits", async () => {
|
||||
const scope = await setup(() => createMarquee(() => false))
|
||||
jest.useFakeTimers()
|
||||
const scope = createRoot((dispose) => ({ marquee: createMarquee(() => false), dispose }))
|
||||
|
||||
scope.marquee.enter("first", "opencode", 6)
|
||||
expect(scope.marquee.active()).toBe("first")
|
||||
@@ -23,9 +36,9 @@ describe("session tab marquee", () => {
|
||||
scope.dispose()
|
||||
})
|
||||
|
||||
test("stops after one cycle", () => {
|
||||
test("stops after one cycle", async () => {
|
||||
const scope = await setup(() => createMarquee(() => false))
|
||||
jest.useFakeTimers()
|
||||
const scope = createRoot((dispose) => ({ marquee: createMarquee(() => false), dispose }))
|
||||
|
||||
scope.marquee.enter("first", "opencode", 6)
|
||||
jest.advanceTimersByTime(1_400)
|
||||
@@ -36,9 +49,9 @@ describe("session tab marquee", () => {
|
||||
scope.dispose()
|
||||
})
|
||||
|
||||
test("resets immediately after leaving", () => {
|
||||
test("resets immediately after leaving", async () => {
|
||||
const scope = await setup(() => createMarquee(() => false))
|
||||
jest.useFakeTimers()
|
||||
const scope = createRoot((dispose) => ({ marquee: createMarquee(() => false), dispose }))
|
||||
|
||||
scope.marquee.enter("first", "opencode", 6)
|
||||
jest.advanceTimersByTime(700)
|
||||
@@ -50,9 +63,9 @@ describe("session tab marquee", () => {
|
||||
scope.dispose()
|
||||
})
|
||||
|
||||
test("resets when the pointer leaves the tab rail", () => {
|
||||
test("resets when the pointer leaves the tab rail", async () => {
|
||||
const scope = await setup(() => createTabMarquee(() => false))
|
||||
jest.useFakeTimers()
|
||||
const scope = createRoot((dispose) => ({ marquee: createTabMarquee(() => false), dispose }))
|
||||
|
||||
scope.marquee.enter("first", "opencode", 6)
|
||||
jest.advanceTimersByTime(700)
|
||||
|
||||
@@ -9,6 +9,26 @@ import { CommandMap, Definitions } from "../src/config/v1/keybind"
|
||||
|
||||
const decodeInfo = Schema.decodeUnknownSync(Info)
|
||||
|
||||
test.each(["targetFps", "maxFps"])("validates optional positive finite %s", (key) => {
|
||||
expect(decodeInfo({})).toEqual({})
|
||||
for (const value of [0.5, 60, 1000]) {
|
||||
expect(decodeInfo({ [key]: value })).toEqual({ [key]: value })
|
||||
}
|
||||
for (const value of [0, -1, NaN, Infinity, -Infinity, "60", null]) {
|
||||
expect(() => decodeInfo({ [key]: value })).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
test("resolves independent frame rate settings without imposing defaults", () => {
|
||||
expect(resolve(decodeInfo({ targetFps: 120, maxFps: 30 }), { terminalSuspend: true })).toMatchObject({
|
||||
targetFps: 120,
|
||||
maxFps: 30,
|
||||
})
|
||||
const defaults = resolve({}, { terminalSuspend: true })
|
||||
expect(defaults.targetFps).toBeUndefined()
|
||||
expect(defaults.maxFps).toBeUndefined()
|
||||
})
|
||||
|
||||
test("validates the three explicit diff source defaults", () => {
|
||||
for (const source of ["branch", "committed", "working"] as const) {
|
||||
expect(decodeInfo({ diffs: { source } })).toEqual({ diffs: { source } })
|
||||
|
||||
@@ -587,6 +587,41 @@ test("keeps scroll anchors for open session tabs", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps parent and subagent scroll anchors independent", async () => {
|
||||
const setup = await renderSessionTabs("root", {
|
||||
persisted: ["root"],
|
||||
sessionParents: { child: "root" },
|
||||
})
|
||||
|
||||
try {
|
||||
await wait(() => setup.data.session.get("child") !== undefined)
|
||||
const parent = { messageID: "msg_parent", screenY: -3 }
|
||||
const child = { messageID: "msg_child", screenY: -5 }
|
||||
setup.tabs.setScrollAnchor("root", parent)
|
||||
|
||||
// A short subagent transcript is at the bottom, so it saves no anchor.
|
||||
setup.tabs.setScrollAnchor("child", undefined)
|
||||
expect(setup.tabs.scrollAnchor("root")).toEqual(parent)
|
||||
expect(setup.tabs.scrollAnchor("child")).toBeUndefined()
|
||||
|
||||
setup.tabs.setScrollAnchor("child", child)
|
||||
expect(setup.tabs.scrollAnchor("root")).toEqual(parent)
|
||||
expect(setup.tabs.scrollAnchor("child")).toEqual(child)
|
||||
|
||||
setup.tabs.setScrollAnchor("root", undefined)
|
||||
expect(setup.tabs.scrollAnchor("child")).toEqual(child)
|
||||
|
||||
setup.tabs.close("root")
|
||||
await wait(() => setup.tabs.tabs().length === 0)
|
||||
setup.route.navigate({ type: "session", sessionID: "root" })
|
||||
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "root"))
|
||||
expect(setup.tabs.scrollAnchor("root")).toBeUndefined()
|
||||
expect(setup.tabs.scrollAnchor("child")).toBeUndefined()
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("derives unread state from server session times", async () => {
|
||||
const setup = await renderSessionTabs("first", {
|
||||
home: true,
|
||||
|
||||
@@ -31,12 +31,16 @@ describe("run runtime boot", () => {
|
||||
theme: { mode: "light" },
|
||||
leader: { timeout: 450 },
|
||||
cursor: { style: "underline", blinking: false },
|
||||
targetFps: 24,
|
||||
maxFps: 120,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.theme).toEqual({ mode: "light" })
|
||||
expect(result.leader.timeout).toBe(450)
|
||||
expect(result.cursor).toEqual({ style: "underline", blinking: false })
|
||||
expect(result.targetFps).toBe(24)
|
||||
expect(result.maxFps).toBe(120)
|
||||
expect(resolveMiniSettings(result)).toEqual({
|
||||
thinking: "hide",
|
||||
shell_output: "hide",
|
||||
|
||||
@@ -45,6 +45,15 @@ 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)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { ManualClock } from "@opentui/core/testing"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { createAnimatable, spring, tween } from "../../src/ui/animation"
|
||||
|
||||
test("tweens advance together on renderer frames and release the renderer when settled", async () => {
|
||||
const clock = new ManualClock()
|
||||
const motions: ReturnType<typeof createAnimatable<{ value: number }>>[] = []
|
||||
const app = await testRender(
|
||||
() => {
|
||||
const first = createAnimatable({ value: 0 }, { transition: tween({ duration: 0.1 }) })
|
||||
const second = createAnimatable({ value: 0 }, { transition: tween({ duration: 0.1 }) })
|
||||
motions.push(first, second)
|
||||
return (
|
||||
<text>
|
||||
{first.value().value} {second.value().value}
|
||||
</text>
|
||||
)
|
||||
},
|
||||
{ clock, targetFps: 20, maxFps: 20, width: 20, height: 1 },
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
motions.forEach((motion) => motion.animate({ value: 1 }))
|
||||
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
|
||||
expect(app.renderer.isRunning).toBe(true)
|
||||
|
||||
clock.advance(49)
|
||||
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
|
||||
expect(motions.map((motion) => motion.value().value)).toEqual([0, 0])
|
||||
clock.advance(1)
|
||||
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
|
||||
expect(motions.map((motion) => motion.value().value)).toEqual([0.5, 0.5])
|
||||
expect(app.captureCharFrame()).toContain("0.5 0.5")
|
||||
|
||||
clock.advance(50)
|
||||
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
|
||||
expect(motions.map((motion) => motion.value().value)).toEqual([1, 1])
|
||||
expect(app.renderer.isRunning).toBe(false)
|
||||
|
||||
motions[0]!.animate({ value: 0 })
|
||||
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
|
||||
expect(app.renderer.isRunning).toBe(true)
|
||||
motions[0]!.jump({ value: 0 })
|
||||
expect(app.renderer.isRunning).toBe(false)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("springs settle on renderer frames and disabled or disposed animations stop", async () => {
|
||||
const clock = new ManualClock()
|
||||
const [enabled, setEnabled] = createSignal(true)
|
||||
const [mounted, setMounted] = createSignal(true)
|
||||
let motion!: ReturnType<typeof createAnimatable<{ value: number }>>
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<Show when={mounted()}>
|
||||
{(() => {
|
||||
motion = createAnimatable({ value: 0 }, { enabled, transition: spring({ visualDuration: 0.2 }) })
|
||||
return <text>{motion.value().value}</text>
|
||||
})()}
|
||||
</Show>
|
||||
),
|
||||
{ clock, targetFps: 50, maxFps: 50, width: 20, height: 1 },
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
motion.animate({ value: 1 })
|
||||
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
|
||||
for (let frame = 0; frame < 60; frame++) {
|
||||
clock.advance(20)
|
||||
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
|
||||
}
|
||||
expect(motion.value().value).toBe(1)
|
||||
expect(app.renderer.isRunning).toBe(false)
|
||||
|
||||
motion.animate({ value: 2 })
|
||||
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
|
||||
setEnabled(false)
|
||||
expect(motion.value().value).toBe(2)
|
||||
expect(app.renderer.isRunning).toBe(false)
|
||||
|
||||
setEnabled(true)
|
||||
motion.animate({ value: 3 })
|
||||
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
|
||||
expect(app.renderer.isRunning).toBe(true)
|
||||
setMounted(false)
|
||||
expect(app.renderer.isRunning).toBe(false)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -36,7 +36,7 @@
|
||||
user-select: none;
|
||||
|
||||
background-color: var(--v2-background-bg-base);
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border: 1px solid var(--v2-border-border-base);
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
overflow: clip;
|
||||
@@ -80,7 +80,7 @@
|
||||
|
||||
&[data-expanded] {
|
||||
[data-slot="accordion-content"] {
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border: 1px solid var(--v2-border-border-base);
|
||||
border-top: 0;
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,22 @@ Set scroll speed and acceleration:
|
||||
| `speed` | number, at least `0.001` | Sets the distance per input tick. |
|
||||
| `acceleration` | boolean | Speeds up repeated scrolling. |
|
||||
|
||||
## Frame rate
|
||||
|
||||
Set the renderer frame rates for the TUI and Mini:
|
||||
|
||||
```json title="cli.json"
|
||||
{
|
||||
"targetFps": 30,
|
||||
"maxFps": 60
|
||||
}
|
||||
```
|
||||
|
||||
- `targetFps` controls continuous rendering and animations. Defaults to `60` in the TUI and `30` in Mini.
|
||||
- `maxFps` limits requested redraws, such as those triggered by input. Defaults to `60` in both.
|
||||
|
||||
Both accept positive finite numbers. The TUI applies edits to these settings live; removing a setting restores its default.
|
||||
|
||||
## Attention
|
||||
|
||||
Configure notifications and sounds:
|
||||
|
||||
Reference in New Issue
Block a user