Compare commits

..
Author SHA1 Message Date
Kit Langton 57d70a3edf refactor(plugin): narrow cross-instance schema fix 2026-08-23 22:40:16 -04:00
Kit Langton a5c2458a5b fix(plugin): keep tool schema validation in the authoring effect instance
Plugins load their own copy of effect, so their live Effect schemas cannot be
interpreted by the host instance: parser sentinels and AST class identity are
per-instance, making checks false-fail on valid input (bogus minLength errors)
and branded IDs die as defects surfaced as bare 'Tool execution failed'.

Plugin.define now converts tool input/output schemas to detached Standard
Schema wrappers at the draft.add boundary, so validation and JSON Schema
generation run as closures bound to the instance that created the schema. The
core tool runtime detects still-foreign live schemas from older plugin
packages and skips validation with a warning instead of misvalidating, and
standard-schema validation errors now include the issue path.
2026-08-23 20:24:08 -04:00
168 changed files with 5344 additions and 17536 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/plugin": patch
---
Export the Effect runtime used by Effect plugins and safely adapt its tool schemas across host module instances.
-5
View File
@@ -84,11 +84,6 @@ jobs:
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Verify published codemode package
if: runner.os == 'Linux'
working-directory: packages/codemode
run: bun run script/publish.ts --dry-run
- name: Verify compiled service lifecycle
if: always()
timeout-minutes: 10
+6 -6
View File
@@ -71,7 +71,6 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/keyed": "1.5.3",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
@@ -1056,6 +1055,7 @@
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
@@ -1117,8 +1117,8 @@
"cross-spawn": "7.0.6",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-rc.111",
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
@@ -1684,7 +1684,7 @@
"@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.12.0", "", {}, "sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.111", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.111" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-bztAYpWoipn/a4tFjECMNvtWLkf2TS+Qn23PBI+f4q5DP9cBKfV00uhX8cwEVcfAtZd+lflSbGURHa4xlSewOA=="],
@@ -3830,9 +3830,9 @@
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
"drizzle-kit": ["drizzle-kit@1.0.0-rc.5-ab785fc", "", { "dependencies": { "@drizzle-team/brocli": "^0.12.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-TSg7sK1finOxdo48O9mDfyMY3lhi/aHCVIil5TFhthyq/H/89H/3nwX5V9Gdp7HYMUGFHyWJD5iowSn98l0elw=="],
"drizzle-kit": ["drizzle-kit@1.0.0-rc.2", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-TRxUmj1wDA2QCt3GvuhfamvIa66wJ7+MzSxBMKkpRtYScjHTumT9BE+x6daSzuEacSrPEuUH5/cW1uo5RkoPIg=="],
"drizzle-orm": ["drizzle-orm@1.0.0-rc.5-169397b", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@drizzle-team/minipg": ">=0.3.2", "@effect/sql-d1": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-libsql": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-mysql2": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-pg": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-pglite": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-sqlite-bun": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-sqlite-do": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-sqlite-node": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-sqlite-wasm": ">=4.0.0-beta.105 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=17", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.7.1", "@tursodatabase/database-common": ">=0.7.1", "@tursodatabase/database-wasm": ">=0.7.1", "@tursodatabase/serverless": ">=1.4.0", "@tursodatabase/sync": ">=0.7.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.105 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.2.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@drizzle-team/minipg", "@effect/sql-d1", "@effect/sql-libsql", "@effect/sql-mysql2", "@effect/sql-pg", "@effect/sql-pglite", "@effect/sql-sqlite-bun", "@effect/sql-sqlite-do", "@effect/sql-sqlite-node", "@effect/sql-sqlite-wasm", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@tursodatabase/serverless", "@tursodatabase/sync", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-xf5TudQhFplMMzjwgmMY5f64Sqbv4iwXxdTHBHqvLMt+SHyY8rAYhzNLQchEkDxiqMclTS/txPEOg5pdQy0Tcw=="],
"drizzle-orm": ["drizzle-orm@1.0.0-rc.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql-pg": ">=4.0.0-beta.58 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.58 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-UXYDkbplF5wX0hwxll+80QhEwUvAJLBu+tAK/d4fna18kLE6VuliAzufF/ieDEIJeSnLRYgtmsXD6x1Xuy1kIg=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-LvDHCOm8OAZfvb0I0L6AbdOevRoQmEJnnqrSgAiNHv8=",
"aarch64-linux": "sha256-O0L0iHjb4cwl9xWHIna8VFHyQzoAKzfY8oMpVNayMOg=",
"aarch64-darwin": "sha256-ETP8FE71NqufYDUbR7tBdsMEOVQ44wLmsZBeZiSRBRY=",
"x86_64-darwin": "sha256-WUcoLldDriT3QxcdlnBQhuPrxDNub0EDvvZXk/pDMpY="
"x86_64-linux": "sha256-mMg7Y8ZTL+SEcX6ciNBDL+H9UT9c85EM/pZmIcsoFtA=",
"aarch64-linux": "sha256-9JtD/rwpmF9hD9MPNLu/fMx8mQu1vpOMLYRN+nHq/eI=",
"aarch64-darwin": "sha256-wLtkY3d5o88bfPriChTCSmUtEnYR6PKj86qQxNqDVOE=",
"x86_64-darwin": "sha256-9bqDFh7YGNK94oRDo9xp0l5r0H17g1aUtnaVPTfFEOE="
}
}
+3 -2
View File
@@ -71,8 +71,8 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-rc.111",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
@@ -166,6 +166,7 @@
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
+35 -357
View File
@@ -1,4 +1,3 @@
import { Buffer } from "node:buffer"
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client.js"
@@ -55,7 +54,7 @@ export type ThinkingInput =
| {
readonly type: "disabled"
}
| ({ readonly type: "enabled"; readonly display?: "summarized" | "omitted" } & (
| ({ readonly type: "enabled" } & (
| { readonly budgetTokens: number; readonly budget_tokens?: number }
| { readonly budgetTokens?: number; readonly budget_tokens: number }
))
@@ -64,19 +63,6 @@ export interface OptionsInput {
readonly [key: string]: unknown
readonly thinking?: ThinkingInput
readonly effort?: string
readonly service_tier?: "auto" | "standard_only"
readonly serviceTier?: "auto" | "standard_only"
// SDK Metadata:2649 {user_id?: string | null}
readonly metadata?: { readonly user_id?: string | null }
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
readonly container?: string | { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
readonly inference_geo?: string | null
readonly inferenceGeo?: string | null
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
readonly output_config?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
readonly outputConfig?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
}
export type ProviderOptionsInput = OptionsInput
@@ -96,61 +82,25 @@ const AnthropicTextBlock = Schema.Struct({
})
type AnthropicTextBlock = Schema.Schema.Type<typeof AnthropicTextBlock>
// SDK: Base64ImageSource:201 {type:"base64", media_type:"image/jpeg"|... , data}, URLImageSource:3817 {type:"url", url}, FileImageSource:2350 {type:"file", file_id}
// SDK: ImageBlockParam:2356 {source: Base64|URL|File, cache_control, transformations:2381 {oversized_image?}}
const AnthropicBase64ImageSource = Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.String,
data: Schema.String,
})
const AnthropicURLImageSource = Schema.Struct({ type: Schema.tag("url"), url: Schema.String })
const AnthropicFileImageSource = Schema.Struct({ type: Schema.tag("file"), file_id: Schema.String })
const AnthropicImageSource = Schema.Union([
AnthropicBase64ImageSource,
AnthropicURLImageSource,
AnthropicFileImageSource,
])
const AnthropicImageTransformations = Schema.Struct({
oversized_image: Schema.optional(Schema.Literals(["downsize", "error"])),
})
const AnthropicImageBlock = Schema.Struct({
type: Schema.tag("image"),
source: AnthropicImageSource,
source: Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.String,
data: Schema.String,
}),
cache_control: Schema.optional(AnthropicCacheControl),
transformations: Schema.optional(AnthropicImageTransformations),
})
type AnthropicImageBlock = Schema.Schema.Type<typeof AnthropicImageBlock>
// SDK: Base64PDFSource:209 {type:"base64", media_type:"application/pdf", data}, PlainTextSource:2716 {type:"text", media_type:"text/plain", data},
// SDK: URLPDFSource:3823 {type:"url", url}, FileDocumentSource:2344 {type:"file", file_id}, ContentBlockSource:2266 {type:"content", content}
// SDK: DocumentBlockParam:2297 {source: 5-way union, cache_control, citations, context, title}
const AnthropicBase64PDFSource = Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.Literal("application/pdf"),
data: Schema.String,
})
const AnthropicPlainTextSource = Schema.Struct({
type: Schema.tag("text"),
media_type: Schema.Literal("text/plain"),
data: Schema.String,
})
const AnthropicURLPDFSource = Schema.Struct({ type: Schema.tag("url"), url: Schema.String })
const AnthropicFileDocumentSource = Schema.Struct({ type: Schema.tag("file"), file_id: Schema.String })
const AnthropicDocumentSource = Schema.Union([
AnthropicBase64PDFSource,
AnthropicPlainTextSource,
AnthropicURLPDFSource,
AnthropicFileDocumentSource,
])
const AnthropicDocumentBlock = Schema.Struct({
type: Schema.tag("document"),
source: AnthropicDocumentSource,
source: Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.Literal("application/pdf"),
data: Schema.String,
}),
cache_control: Schema.optional(AnthropicCacheControl),
title: Schema.optional(Schema.String),
context: Schema.optional(Schema.String),
citations: Schema.optional(Schema.Struct({ enabled: Schema.Boolean })),
})
type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
@@ -255,18 +205,14 @@ const AnthropicTool = Schema.Struct({
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
const AnthropicToolChoice = Schema.Union([
Schema.Struct({
type: Schema.Literals(["auto", "any", "none"]),
disable_parallel_tool_use: Schema.optional(Schema.Boolean),
}),
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String, disable_parallel_tool_use: Schema.optional(Schema.Boolean) }),
Schema.Struct({ type: Schema.Literals(["auto", "any", "none"]) }),
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
])
const AnthropicThinking = Schema.Union([
Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
}),
Schema.Struct({
type: Schema.tag("adaptive"),
@@ -277,28 +223,10 @@ const AnthropicThinking = Schema.Union([
}),
])
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
const AnthropicJsonOutputFormat = Schema.Struct({
type: Schema.Literal("json_schema"),
schema: JsonObject,
})
const AnthropicOutputConfig = Schema.Struct({
effort: Schema.optional(Schema.String),
format: Schema.optional(Schema.NullOr(AnthropicJsonOutputFormat)),
})
// SDK Metadata:2649 {user_id?: string|null}
const AnthropicMetadata = Schema.Struct({ user_id: optionalNull(Schema.String) })
// SDK MessageCreateParamsContainer:2596 ContainerParams|string; ContainerParams:2172 {id?, skills?}
const AnthropicContainer = Schema.Union([
Schema.String,
Schema.Struct({
id: optionalNull(Schema.String),
skills: optionalNull(Schema.Array(JsonObject)),
}),
])
const AnthropicBodyFields = {
model: Schema.String,
system: optionalArray(AnthropicTextBlock),
@@ -313,12 +241,6 @@ const AnthropicBodyFields = {
stop_sequences: optionalArray(Schema.String),
thinking: Schema.optional(AnthropicThinking),
output_config: Schema.optional(AnthropicOutputConfig),
// SDK top-level passthrough: cache_control:4638, container:4643, inference_geo:4649, metadata:4654, service_tier:4670
cache_control: Schema.optional(AnthropicCacheControl),
container: Schema.optional(Schema.NullOr(AnthropicContainer)),
inference_geo: Schema.optional(Schema.NullOr(Schema.String)),
metadata: Schema.optional(AnthropicMetadata),
service_tier: Schema.optional(Schema.Literals(["auto", "standard_only"])),
}
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
@@ -447,26 +369,10 @@ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSc
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, {
auto: () => ({
type: "auto" as const,
...(toolChoice.disableParallelToolUse === undefined
? {}
: { disable_parallel_tool_use: toolChoice.disableParallelToolUse }),
}),
auto: () => ({ type: "auto" as const }),
none: () => ({ type: "none" as const }),
required: () => ({
type: "any" as const,
...(toolChoice.disableParallelToolUse === undefined
? {}
: { disable_parallel_tool_use: toolChoice.disableParallelToolUse }),
}),
tool: (name) => ({
type: "tool" as const,
name,
...(toolChoice.disableParallelToolUse === undefined
? {}
: { disable_parallel_tool_use: toolChoice.disableParallelToolUse }),
}),
required: () => ({ type: "any" as const }),
tool: (name) => ({ type: "tool" as const, name }),
})
const scrubToolCallID = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
@@ -505,152 +411,7 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock
})
const fileIdFromMetadata = (metadata: MediaPart["metadata"]): string | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const anthropic = metadata.anthropic
if (ProviderShared.isRecord(anthropic)) {
if (typeof anthropic.file_id === "string") return anthropic.file_id
if (typeof anthropic.fileId === "string") return anthropic.fileId
}
if (typeof metadata.file_id === "string") return metadata.file_id
if (typeof metadata.fileId === "string") return metadata.fileId
return undefined
}
const transformationsFromMetadata = (
metadata: MediaPart["metadata"],
): AnthropicImageBlock["transformations"] | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const anthropic = ProviderShared.isRecord(metadata.anthropic) ? metadata.anthropic : undefined
const raw = anthropic?.transformations ?? metadata.transformations
if (ProviderShared.isRecord(raw)) {
const value = raw.oversized_image
if (value === "downsize" || value === "error") return { oversized_image: value }
}
if (anthropic && (anthropic.oversized_image === "downsize" || anthropic.oversized_image === "error"))
return { oversized_image: anthropic.oversized_image }
return undefined
}
const documentTitleFromPart = (part: MediaPart): string | undefined => {
if (ProviderShared.isRecord(part.metadata)) {
const anthropic = part.metadata.anthropic
if (ProviderShared.isRecord(anthropic) && typeof anthropic.title === "string") return anthropic.title
if (typeof part.metadata.title === "string") return part.metadata.title
}
if (typeof part.filename === "string" && part.filename.length > 0) return part.filename
return undefined
}
const documentContextFromMetadata = (metadata: MediaPart["metadata"]): string | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const anthropic = ProviderShared.isRecord(metadata.anthropic) ? metadata.anthropic : undefined
if (anthropic && typeof anthropic.context === "string") return anthropic.context
if (typeof metadata.context === "string") return metadata.context
return undefined
}
const citationsFromMetadata = (
metadata: MediaPart["metadata"],
): AnthropicDocumentBlock["citations"] | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const raw = ProviderShared.isRecord(metadata.anthropic)
? (metadata.anthropic.citations ?? metadata.citations)
: metadata.citations
if (ProviderShared.isRecord(raw) && typeof raw.enabled === "boolean") return { enabled: raw.enabled }
return undefined
}
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value.trim())
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (
part: MediaPart,
breakpoints?: Cache.Breakpoints,
) {
const mime = part.mediaType.toLowerCase()
const cacheControlValue = breakpoints ? cacheControl(breakpoints, part.cache) : undefined
const fileId = fileIdFromMetadata(part.metadata)
// SDK file sources: FileImageSource:2350 / FileDocumentSource:2344 {type:"file", file_id}
if (fileId) {
if (mime.startsWith("image/"))
return {
type: "image" as const,
source: { type: "file" as const, file_id: fileId },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(transformationsFromMetadata(part.metadata) === undefined
? {}
: { transformations: transformationsFromMetadata(part.metadata)! }),
} satisfies AnthropicImageBlock
return {
type: "document" as const,
source: { type: "file" as const, file_id: fileId },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
...(documentContextFromMetadata(part.metadata) === undefined
? {}
: { context: documentContextFromMetadata(part.metadata)! }),
...(citationsFromMetadata(part.metadata) === undefined
? {}
: { citations: citationsFromMetadata(part.metadata)! }),
} satisfies AnthropicDocumentBlock
}
const rawString = typeof part.data === "string" ? part.data.trim() : undefined
// SDK URL sources: URLImageSource:3817 / URLPDFSource:3823 {type:"url", url}
if (rawString && isHttpUrl(rawString) && !rawString.startsWith("data:")) {
if (mime.startsWith("image/"))
return {
type: "image" as const,
source: { type: "url" as const, url: rawString },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(transformationsFromMetadata(part.metadata) === undefined
? {}
: { transformations: transformationsFromMetadata(part.metadata)! }),
} satisfies AnthropicImageBlock
if (mime === "application/pdf")
return {
type: "document" as const,
source: { type: "url" as const, url: rawString },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
...(documentContextFromMetadata(part.metadata) === undefined
? {}
: { context: documentContextFromMetadata(part.metadata)! }),
...(citationsFromMetadata(part.metadata) === undefined
? {}
: { citations: citationsFromMetadata(part.metadata)! }),
} satisfies AnthropicDocumentBlock
}
// SDK PlainTextSource:2716 {type:"text", media_type:"text/plain", data}
if (mime === "text/plain") {
const textData =
typeof part.data !== "string"
? Buffer.from(part.data).toString("utf8")
: part.data.startsWith("data:")
? (() => {
const comma = part.data.indexOf(",")
const payload = comma >= 0 ? part.data.slice(comma + 1) : part.data
return part.data.includes(";base64")
? Buffer.from(payload, "base64").toString("utf8")
: decodeURIComponent(payload)
})()
: part.data
return {
type: "document" as const,
source: { type: "text" as const, media_type: "text/plain" as const, data: textData },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
...(documentContextFromMetadata(part.metadata) === undefined
? {}
: { context: documentContextFromMetadata(part.metadata)! }),
...(citationsFromMetadata(part.metadata) === undefined
? {}
: { citations: citationsFromMetadata(part.metadata)! }),
} satisfies AnthropicDocumentBlock
}
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
const media = ProviderShared.normalizeMedia(part)
if (media.mime === "application/pdf")
return {
@@ -660,14 +421,6 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (
media_type: "application/pdf" as const,
data: media.base64,
},
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
...(documentContextFromMetadata(part.metadata) === undefined
? {}
: { context: documentContextFromMetadata(part.metadata)! }),
...(citationsFromMetadata(part.metadata) === undefined
? {}
: { citations: citationsFromMetadata(part.metadata)! }),
} satisfies AnthropicDocumentBlock
if (!media.mime.startsWith("image/"))
return yield* invalid(`Anthropic Messages does not support media type ${part.mediaType}`)
@@ -678,10 +431,6 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (
media_type: media.mime,
data: media.base64,
},
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(transformationsFromMetadata(part.metadata) === undefined
? {}
: { transformations: transformationsFromMetadata(part.metadata)! }),
} satisfies AnthropicImageBlock
})
@@ -790,7 +539,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "media") {
content.push(yield* lowerMedia(part, breakpoints))
content.push(yield* lowerMedia(part))
continue
}
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
@@ -857,74 +606,24 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
})
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
const input = request.providerOptions as Record<string, unknown> | undefined
const rawServiceTier = (input as Record<string, unknown> | undefined)?.service_tier ?? (input as Record<string, unknown> | undefined)?.serviceTier
const service_tier =
rawServiceTier === "auto" || rawServiceTier === "standard_only"
? (rawServiceTier as "auto" | "standard_only")
: undefined
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
const metadata =
ProviderShared.isRecord(rawMetadata) &&
(typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
? { user_id: rawMetadata.user_id as string | null }
: undefined
const container =
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
? ((input as Record<string, unknown>).container as string | { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
: undefined
const rawInferenceGeo =
(input as Record<string, unknown> | undefined)?.inference_geo ??
(input as Record<string, unknown> | undefined)?.inferenceGeo
const inference_geo = typeof rawInferenceGeo === "string" ? rawInferenceGeo : undefined
const rawCacheControl =
(input as Record<string, unknown> | undefined)?.cache_control ??
(input as Record<string, unknown> | undefined)?.cacheControl
const cache_control =
ProviderShared.isRecord(rawCacheControl) && rawCacheControl.type === "ephemeral"
? (rawCacheControl as { type: "ephemeral"; ttl?: "5m" | "1h" })
: undefined
const rawOutputConfig =
(input as Record<string, unknown> | undefined)?.output_config ??
(input as Record<string, unknown> | undefined)?.outputConfig
const outputConfigEffort =
typeof (input as Record<string, unknown> | undefined)?.effort === "string"
? ((input as Record<string, unknown>).effort as string)
: ProviderShared.isRecord(rawOutputConfig) && typeof rawOutputConfig.effort === "string"
? (rawOutputConfig.effort as string)
: undefined
const outputConfigFormat =
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
: undefined
const output_config =
outputConfigEffort === undefined && outputConfigFormat === undefined
? undefined
: {
...(outputConfigEffort === undefined ? {} : { effort: outputConfigEffort }),
...(outputConfigFormat === undefined ? {} : { format: outputConfigFormat }),
}
const input = request.providerOptions
return {
thinking: yield* resolveThinking(input?.thinking),
effort: outputConfigEffort,
output_config,
service_tier,
metadata,
container,
inference_geo,
cache_control,
effort: typeof input?.effort === "string" ? input.effort : undefined,
}
})
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
if (!ProviderShared.isRecord(input)) return undefined
const display =
input.display === "summarized" || input.display === "omitted"
? (input.display as "summarized" | "omitted")
: undefined
if (input.type === "adaptive")
if (input.type === "adaptive") {
const display =
input.display === "summarized"
? ("summarized" as const)
: input.display === "omitted"
? ("omitted" as const)
: undefined
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
}
if (input.type === "disabled") return { type: "disabled" as const }
if (input.type !== "enabled") return undefined
const budget =
@@ -935,7 +634,7 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
: undefined
if (budget === undefined)
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
return { type: "enabled" as const, budget_tokens: budget, ...(display === undefined ? {} : { display }) }
return { type: "enabled" as const, budget_tokens: budget }
})
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
@@ -985,13 +684,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: options.thinking,
output_config: options.output_config,
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
cache_control: options.cache_control,
container: options.container,
inference_geo: options.inference_geo,
metadata: options.metadata,
service_tier: options.service_tier,
output_config: options.effort === undefined ? undefined : { effort: options.effort },
}
})
@@ -1102,8 +795,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
const block = event.content_block
if (!block) return [state, NO_EVENTS]
if (block.type === "tool_use" || block.type === "server_tool_use") {
if (event.index === undefined || !block.id) return [state, NO_EVENTS]
if ((block.type === "tool_use" || block.type === "server_tool_use") && event.index !== undefined) {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
@@ -1111,7 +803,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
...state,
lifecycle,
tools: ToolStream.start(state.tools, event.index, {
id: block.id,
id: block.id ?? String(event.index),
name: block.name ?? "",
input:
block.input !== undefined && (!ProviderShared.isRecord(block.input) || Object.keys(block.input).length > 0)
@@ -1123,7 +815,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
[
...events,
LLMEvent.toolInputStart({
id: block.id,
id: block.id ?? String(event.index),
name: block.name ?? "",
providerExecuted: block.type === "server_tool_use" ? true : undefined,
}),
@@ -1323,18 +1015,7 @@ const onError = (event: AnthropicEvent) =>
const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
if (event.type === "content_block_start") {
const block = event.content_block
if (block && (block.type === "tool_use" || block.type === "server_tool_use")) {
if (event.index === undefined)
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic ${block.type} missing index`))
if (!block.id)
return Effect.fail(
ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`),
)
}
return Effect.succeed(onContentBlockStart(state, event))
}
if (event.type === "content_block_start") return Effect.succeed(onContentBlockStart(state, event))
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
@@ -1373,10 +1054,7 @@ export const route = Route.make({
provider: "anthropic",
providerMetadataKey: "anthropic",
protocol,
endpoint: Endpoint.path(
(input) => (input.request.model.provider === "anthropic" ? `${PATH}?beta=true` : PATH),
{ baseURL: DEFAULT_BASE_URL },
),
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
auth: Auth.none,
framing,
headers: () => ({ "anthropic-version": "2023-06-01" }),
@@ -112,8 +112,6 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
responseID = created
return { type: "frame", frame }
}
// Keepalives carry no response state and may arrive before response.created.
if (event.type === "keepalive") return { type: "frame", frame }
if (!responseID)
return yield* ProviderShared.eventError(
options.id,
+44 -93
View File
@@ -313,9 +313,6 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
// Which lowered input item a persisted item id is about to be attached to.
export type ItemKind = "message" | "reasoning" | "function-call" | "reference"
export interface Extension {
readonly id: string
readonly name: string
@@ -324,10 +321,6 @@ export interface Extension {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
// Optional grammar check applied before a persisted item id is resent as
// part of replayed history. Returning false drops the id; every lowered
// item treats a dropped id the same as an absent one.
readonly acceptsItemID?: (kind: ItemKind, id: string) => boolean
}
const BASE: Extension = { id: ADAPTER, name: NAME }
@@ -353,10 +346,6 @@ interface ReasoningStreamItem {
// strings, but typing the map as `Record<number, ...>` documents intent
// and matches the wire field.
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
// Summary indexes that received at least one streamed delta. The `:0` block
// is started eagerly when the item opens, so block existence cannot tell
// whether a `.done` final would duplicate streamed text.
readonly deltaIndexes: ReadonlySet<number>
}
// =============================================================================
@@ -373,7 +362,7 @@ export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (
type: "function" as const,
name: tool.name,
description: tool.description,
parameters: inputSchema,
parameters: ToolSchemaProjection.responses(inputSchema),
// The common tool definition does not currently express Responses strict-schema policy.
strict: false,
}
@@ -387,46 +376,28 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
tool: (toolName) => ({ type: "function" as const, name: toolName }),
})
// Servers validate item ids on replayed history, and a malformed or oversized
// id can fail an otherwise valid request. Only server-issued tokens are worth
// resending; anything else is treated as absent so the item is resent without
// an id (or skipped, for items that cannot be expressed without one).
const ITEM_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
const metadata = providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) &&
typeof metadata.itemId === "string" &&
ITEM_ID_PATTERN.test(metadata.itemId)
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId
: undefined
}
const acceptsItemID = (extension: Extension, kind: ItemKind, id: string | undefined): id is string =>
id !== undefined && (extension.acceptsItemID?.(kind, id) ?? true)
const lowerToolCall = (
part: ToolCallPart,
providerMetadataKey: string,
extension: Extension,
): OpenResponsesInputItem => {
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const id = itemID(part.providerMetadata, providerMetadataKey)
return {
type: "function_call",
...(acceptsItemID(extension, "function-call", id) ? { id } : {}),
...(id ? { id } : {}),
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
}
}
const lowerReasoning = (
part: ReasoningPart,
providerMetadataKey: string,
extension: Extension,
): OpenResponsesReasoningInput | undefined => {
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey)
if (!ProviderShared.isRecord(metadata) || !acceptsItemID(extension, "reasoning", id)) return undefined
if (!ProviderShared.isRecord(metadata) || !id) return undefined
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
@@ -558,8 +529,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
>((groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const rawID = itemID(part.providerMetadata, providerMetadataKey)
const id = acceptsItemID(extension, "message", rawID) ? rawID : undefined
const id = itemID(part.providerMetadata, providerMetadataKey)
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
const group = groups.at(-1)
if (group && group.id === id && group.phase === phase) group.parts.push(part)
@@ -584,7 +554,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
if (part.type === "reasoning") {
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey, extension)
const reasoning = lowerReasoning(part, providerMetadataKey)
if (!reasoning) continue
if (store !== false) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
@@ -605,22 +575,15 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part, providerMetadataKey, extension))
input.push(lowerToolCall(part, providerMetadataKey))
continue
}
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const id = itemID(part.providerMetadata, providerMetadataKey)
const reference = acceptsItemID(extension, "reference", id) ? id : undefined
if (store !== false && reference && !hostedToolReferences.has(reference))
input.push({ type: "item_reference", id: reference })
if (store === false) {
// The server is not storing this exchange, so the tool outcome has to
// travel in the input. Non-content results degrade to their text form.
const content: ReadonlyArray<Content> =
part.result.type === "content"
? part.result.value
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
if (store !== false && id && !hostedToolReferences.has(id)) input.push({ type: "item_reference", id })
if (store === false && part.result.type === "content") {
const content: ReadonlyArray<Content> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
@@ -628,7 +591,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
),
})
}
if (reference) hostedToolReferences.add(reference)
if (id) hostedToolReferences.add(id)
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -665,7 +628,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const lowerOptions = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
@@ -675,7 +637,7 @@ const lowerOptions = (request: LLMRequest) => {
? { stream_options: { include_obfuscation: options.streamOptions.includeObfuscation } }
: {}),
...(options.topLogprobs !== undefined ? { top_logprobs: options.topLogprobs } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(options.include ? { include: options.include } : {}),
...(options.reasoningEffort || options.reasoningSummary
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
@@ -791,7 +753,7 @@ const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incompl
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
if (!event.delta || !state.messageItems.has(id)) return [state, NO_EVENTS]
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
@@ -809,33 +771,20 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const id =
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:${index}`, event.delta),
reasoningItems: {
...state.reasoningItems,
[itemID]: { ...item, deltaIndexes: new Set([...item.deltaIndexes, index]) },
},
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
},
events,
]
}
// Some compatible gateways emit a reasoning final without streaming any
// deltas, mirroring `response.output_text.done`. Reconcile the complete text
// as a single delta unless that summary index already streamed one.
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!item || typeof event.text !== "string") return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
export const onReasoningDone = (state: ParserState, _event: Event): StepResult => [state, NO_EVENTS]
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
@@ -873,11 +822,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
reasoningItems: {
...state.reasoningItems,
[item.id]: {
encryptedContent: item.encrypted_content,
summaryParts: { 0: "active" },
deltaIndexes: new Set(),
},
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
},
},
events,
@@ -907,9 +852,27 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
if (event.summary_index === 0) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} }
if (event.summary_index === 0) {
if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
state.lifecycle,
events,
`${event.item_id}:0`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
),
reasoningItems: {
...state.reasoningItems,
[event.item_id]: { ...item, summaryParts: { 0: "active" } },
},
},
events,
]
}
const events: LLMEvent[] = []
const closed = Object.entries(item.summaryParts)
@@ -988,7 +951,7 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
state: ParserState,
event: Event,
) {
if (!event.item_id || !event.delta || !state.tools[event.item_id]) return [state, NO_EVENTS] satisfies StepResult
if (!event.item_id || !event.delta) return [state, NO_EVENTS] satisfies StepResult
const result = ToolStream.appendExisting(
state.id,
state.tools,
@@ -1183,15 +1146,6 @@ export const step = (state: ParserState, event: Event) => {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
}
if (
event.type === "response.reasoning.done" ||
event.type === "response.reasoning_summary_text.done" ||
event.type === "response.reasoning_summary.done" ||
event.type === "response.reasoning_text.done"
) {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDone(state, event, event.item_id))
}
if (event.type === "response.reasoning_summary_part.added")
return event.item_id
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
@@ -1205,10 +1159,7 @@ export const step = (state: ParserState, event: Event) => {
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return Effect.succeed(onOutputItemAdded(state, event))
}
if (event.type === "response.function_call_arguments.delta")
return event.item_id
? onFunctionCallArgumentsDelta(state, event)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
+2 -3
View File
@@ -269,7 +269,7 @@ const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: Lower
function: {
name: tool.name,
description: tool.description,
parameters: inputSchema,
parameters: ToolSchemaProjection.openAI(inputSchema),
},
cache_control: options.cacheControl?.(tool.cache),
})
@@ -530,10 +530,9 @@ const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>)
const lowerOptions = (request: LLMRequest) => {
const options = OpenAIOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...(options.store !== undefined ? { store: options.store } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
}
}
+4 -19
View File
@@ -51,28 +51,9 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
// Replayed items are paired with stored server state by id, so a foreign or
// synthetic token can fail request validation even when `call_id` pairing is
// intact. Only resend ids in each item kind's own grammar; hosted tool
// references keep generic validation because every hosted tool mints its own
// prefix. The same allowlist approach codex uses before resending history
// (codex-rs core/src/client.rs, `prepare_response_items_for_request`).
const ITEM_ID_PREFIXES: Record<OpenResponses.ItemKind, ReadonlyArray<string>> = {
message: ["msg_"],
reasoning: ["rs_"],
"function-call": ["fc_"],
// Every hosted tool mints its own id prefix, so references keep generic
// validation only.
reference: [],
}
const extension = {
id: ADAPTER,
name: NAME,
acceptsItemID: (kind: OpenResponses.ItemKind, id: string) => {
const prefixes = ITEM_ID_PREFIXES[kind]
return prefixes.length === 0 || prefixes.some((prefix) => id.startsWith(prefix))
},
} satisfies OpenResponses.Extension
const nativeImageToolInput = (tool: ToolDefinition) => {
@@ -166,6 +147,10 @@ const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
-11
View File
@@ -24,17 +24,6 @@ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
// OpenAI limits `prompt_cache_key` to 64 chars; DeepSeek and Zai inherit the same
// limit via their OpenAI-compatible APIs. Clamp with unicode-aware slicing.
export const clampPromptCacheKey = (key: string | undefined): string | undefined => {
if (key === undefined) return undefined
const chars = Array.from(key)
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
}
/**
* Streaming tool-call accumulator. Adapters that build a tool call across
* multiple `tool-input-delta` chunks store the partial JSON input string here
+32 -1
View File
@@ -2,6 +2,20 @@ import type { JsonSchema, LanguageModelToolSchemaCompatibility } from "../../sch
import { isRecord } from "../../utils/record.js"
import { GeminiToolSchema } from "./gemini-tool-schema.js"
const removeNullSchemas = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(removeNullSchemas)
if (!isRecord(value)) return value
const fields = Object.fromEntries(
Object.entries(value)
.filter(([key]) => key !== "anyOf")
.map(([key, field]) => [key, removeNullSchemas(field)]),
)
if (!Array.isArray(value.anyOf)) return fields
const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas)
if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] }
return { ...fields, anyOf: variants }
}
const tupleItemsSchema = (items: ReadonlyArray<unknown>) => {
const projected = items.map(moonshotNode)
if (projected.length === 0) return {}
@@ -31,7 +45,24 @@ const moonshot = (schema: JsonSchema): JsonSchema => {
return isRecord(projected) ? projected : {}
}
const openAI = (schema: JsonSchema): JsonSchema => schema
const openAI = (schema: JsonSchema): JsonSchema => {
const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : []
const flattened =
variants.length === 0
? { ...schema, type: "object" }
: {
...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")),
type: "object",
properties: variants.reduce(
(properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }),
{},
),
additionalProperties: false,
}
const normalized = removeNullSchemas(flattened)
return isRecord(normalized) ? normalized : { type: "object" }
}
const responses = openAI
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
+10 -2
View File
@@ -1,5 +1,7 @@
import { Effect } from "effect"
import { Protocol } from "../route/protocol.js"
import { OpenResponses } from "./open-responses.js"
import { ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const ADAPTER = "xai-responses"
@@ -25,9 +27,15 @@ const HOSTED_TOOLS = {
},
} as const satisfies ResponsesHostedTools.Definitions
// Grok speaks the standard Responses reasoning dialect (`reasoning_summary_text.*`,
// handled by the baseline); only its hosted tool vocabulary differs.
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
+2 -3
View File
@@ -9,7 +9,7 @@ import type { ProviderPackage } from "../provider-package.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
import { isRecord, ProviderShared } from "../protocols/shared.js"
import { isRecord } from "../protocols/shared.js"
export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider)
@@ -115,12 +115,11 @@ export const protocol = Protocol.make({
reasoning_details: reasoningDetails,
}
})
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...body,
messages,
...bodyOptions(request.providerOptions),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
} as OpenRouterBody
}),
),
-2
View File
@@ -51,7 +51,6 @@ export const MediaPart = Schema.Struct({
mediaType: Schema.String,
data: Schema.Union([Schema.String, Schema.Uint8Array]),
filename: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
@@ -256,7 +255,6 @@ export namespace ToolDefinition {
export class ToolChoice extends Schema.Class<ToolChoice>("LLM.ToolChoice")({
type: Schema.Literals(["auto", "none", "required", "tool"]),
name: Schema.optional(Schema.String),
disableParallelToolUse: Schema.optional(Schema.Boolean),
}) {}
export namespace ToolChoice {
@@ -237,7 +237,6 @@ describe("Google Vertex providers", () => {
})
return input.respond(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello." },
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -132,78 +132,6 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("keeps foreign item id grammars but drops malformed ids", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
// The baseline does not enforce a provider id grammar, so a
// non-OpenAI but well-formed token is resent as-is.
{ type: "text", text: "Kept.", providerMetadata: { openresponses: { itemId: "history_1" } } },
// Shape violations are dropped even without a grammar policy.
{
type: "text",
text: "Dropped.",
providerMetadata: { openresponses: { itemId: `m${"a".repeat(64)}` } },
},
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
type: "message",
id: "history_1",
role: "assistant",
content: [{ type: "output_text", text: "Kept." }],
},
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Dropped." }],
},
])
}),
)
it.effect("reconciles raw reasoning finals without streamed deltas", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think it through." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_raw", encrypted_content: null },
},
// Raw reasoning finals carry no summary index; they reconcile
// into the item's first block.
{ type: "response.reasoning.done", item_id: "rs_raw", text: "Raw chain of thought." },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_raw", encrypted_content: "raw-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Raw chain of thought.")
}),
)
it.effect("preserves nullable phases in the forgiving Open Responses baseline", () =>
Effect.gen(function* () {
const model = configure({
@@ -196,7 +196,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("preserves function schemas", () =>
it.effect("flattens top-level object unions in function schemas", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, {
@@ -236,22 +236,13 @@ describe("OpenAI Responses route", () => {
strict: false,
parameters: {
type: "object",
anyOf: [
{
type: "object",
properties: {
path: { type: "string" },
reference: { anyOf: [{ type: "string" }, { type: "null" }] },
limit: { type: "integer", maximum: 2000 },
},
required: ["path"],
},
{
type: "object",
properties: { resource: { type: "string" }, limit: { type: "integer", maximum: 51200 } },
required: ["resource"],
},
],
properties: {
path: { type: "string" },
reference: { type: "string" },
limit: { type: "integer", maximum: 2000 },
resource: { type: "string" },
},
additionalProperties: false,
},
},
])
@@ -321,10 +312,6 @@ describe("OpenAI Responses route", () => {
}),
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_ws" } }),
ProviderShared.encodeJson({
type: "response.output_item.added",
item: { type: "message", id: "msg_1" },
}),
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
]),
@@ -405,38 +392,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("tolerates keepalive frames before response.created", () =>
Effect.gen(function* () {
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
ProviderShared.encodeJson({
type: "response.completed",
response: { id: "resp_alive", usage: { input_tokens: 1, output_tokens: 1 } },
}),
]),
close: Effect.void,
}),
})
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
"gpt-4.1-mini",
)
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "hi" }), { webSocket }).pipe(
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
)
expect(response.finishReason?.normalized).toBe("stop")
}),
)
it.effect("continues a tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
@@ -1669,7 +1624,6 @@ describe("OpenAI Responses route", () => {
it.effect("parses text and usage stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "!" },
{
@@ -1931,67 +1885,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("ignores deltas without a matching output item", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_missing", delta: "orphaned text" },
{ type: "response.refusal.delta", item_id: "refusal_missing", delta: "orphaned refusal" },
{
type: "response.reasoning_summary_text.delta",
item_id: "rs_missing",
summary_index: 0,
delta: "orphaned reasoning",
},
{
type: "response.reasoning_summary_part.added",
item_id: "rs_still_missing",
summary_index: 0,
},
{
type: "response.reasoning_summary_text.delta",
item_id: "rs_still_missing",
summary_index: 0,
delta: "still orphaned reasoning",
},
{
type: "response.function_call_arguments.delta",
item_id: "fc_missing",
delta: '{"orphaned":true}',
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("")
expect(response.message.content).toEqual([])
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
}),
)
it.effect("rejects function argument deltas without the spec-required item id", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.function_call_arguments.delta", delta: "{}" },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("response.function_call_arguments.delta is missing item_id")
}),
)
it.effect("rejects reasoning events without the spec-required item id", () =>
Effect.gen(function* () {
const events = [
@@ -2047,10 +1940,8 @@ describe("OpenAI Responses route", () => {
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_2" } },
{ type: "response.output_text.done", item_id: "msg_1" },
{ type: "response.output_text.delta", item_id: "msg_2", delta: "Second" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_2" } },
{ type: "response.completed", response: { id: "resp_1" } },
@@ -2061,10 +1952,10 @@ describe("OpenAI Responses route", () => {
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "First", providerMetadata: undefined },
{ type: "text-end", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "First" },
{ type: "text-end", id: "msg_1", providerMetadata: undefined },
{ type: "text-start", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{ type: "text-delta", id: "msg_2", text: "Second", providerMetadata: undefined },
{ type: "text-delta", id: "msg_2", text: "Second" },
{ type: "text-end", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
])
}),
@@ -2073,9 +1964,7 @@ describe("OpenAI Responses route", () => {
it.effect("parses reasoning summary stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1" },
{ type: "response.completed", response: { id: "resp_1" } },
@@ -2087,22 +1976,18 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "rs_1:0" },
{ type: "reasoning-delta", id: "rs_1:0", text: "thinking" },
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "reasoning-end", id: "rs_1:0" },
{ type: "reasoning-end", id: "rs_1" },
{ type: "text-end", id: "msg_1" },
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
])
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "thinking",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
])
}),
@@ -2114,7 +1999,6 @@ describe("OpenAI Responses route", () => {
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
{
type: "response.output_item.done",
@@ -2134,7 +2018,7 @@ describe("OpenAI Responses route", () => {
expect(response.events).toContainEqual(
expect.objectContaining({
type: "reasoning-end",
id: "rs_1:0",
id: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}),
)
@@ -2196,105 +2080,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles reasoning summaries that arrive only as finals", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
// No `.delta` events at all: the gateway sends the complete
// summary text in the `.done` final.
{
type: "response.reasoning_summary_text.done",
item_id: "rs_1",
summary_index: 0,
text: "Checked the diff.",
},
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the diff." }],
encrypted_content: "encrypted-state",
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Checked the diff.")
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "Checked the diff.", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [response.message],
providerOptions: { store: false, include: ["reasoning.encrypted_content"] },
}),
)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the diff." }],
encrypted_content: "encrypted-state",
},
])
}),
)
it.effect("does not duplicate reasoning finals after streamed deltas", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Streamed" },
// Repeats the complete text, as the spec allows.
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 0, text: "Streamed" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Streamed")
expect(response.events.filter((event) => event.type === "reasoning-delta")).toEqual([
{ type: "reasoning-delta", id: "rs_1:0", text: "Streamed", providerMetadata: undefined },
])
}),
)
it.effect("closes reasoning summary parts when storage is not disabled", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLMRequest.update(request, { providerOptions: { store: true } })).pipe(
@@ -2374,7 +2159,6 @@ describe("OpenAI Responses route", () => {
})
return input.respond(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -2486,134 +2270,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues stateless hosted tool results with their text form", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Search."),
Message.assistant([
ToolCallPart.make({
id: "ws_1",
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
}),
{
type: "tool-result",
id: "ws_1",
name: "web_search",
result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
},
]),
Message.user("Continue."),
],
providerOptions: { store: false },
}),
)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Search." }] },
{
role: "user",
content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_1","status":"completed"}' }],
},
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
])
}),
)
it.effect("drops replayed item ids outside the server's grammar", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
// Fails the message id prefix.
{
type: "text",
text: "Hello",
providerMetadata: { openai: { itemId: "history_1" } },
},
// Oversized for the Responses item id limit.
{
type: "text",
text: "World",
providerMetadata: { openai: { itemId: `m${"a".repeat(64)}` } },
},
// Fails the reasoning id prefix, so the whole item is unreplayable
// statelessly and is skipped rather than sent malformed.
{
type: "reasoning",
text: "Checked the diff.",
providerMetadata: { openai: { itemId: "thinking_1", reasoningEncryptedContent: "encrypted-state" } },
},
ToolCallPart.make({
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerMetadata: { openai: { itemId: "toolu_01A" } },
}),
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
type: "message",
role: "assistant",
content: [
{ type: "output_text", text: "Hello" },
{ type: "output_text", text: "World" },
],
},
{
type: "function_call",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
])
}),
)
it.effect("keeps well-formed hosted references and drops malformed ones under storage", () =>
Effect.gen(function* () {
const hostedResult = (itemId: string) => [
ToolCallPart.make({
id: itemId,
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId } },
}),
{
type: "tool-result" as const,
id: itemId,
name: "web_search",
result: { type: "json" as const, value: { status: "completed" } },
providerExecuted: true as const,
providerMetadata: { openai: { itemId } },
},
]
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.assistant(hostedResult("ws_1")), Message.assistant(hostedResult("bad ref"))],
providerOptions: { store: true },
}),
)
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "ws_1" }])
}),
)
it.effect("continues stateless hosted image generation with the generated image", () =>
Effect.gen(function* () {
const imageTool = OpenAI.imageGeneration({ action: "edit" })
@@ -2737,15 +2393,15 @@ describe("OpenAI Responses route", () => {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query"' },
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: ':"weather"}' },
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query"' },
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: ':"weather"}' },
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_item_1",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -2774,7 +2430,7 @@ describe("OpenAI Responses route", () => {
type: "tool-input-start",
id: "call_1",
name: "lookup",
providerMetadata: { openai: { itemId: "fc_item_1" } },
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-input-delta",
@@ -2792,7 +2448,7 @@ describe("OpenAI Responses route", () => {
type: "tool-input-end",
id: "call_1",
name: "lookup",
providerMetadata: { openai: { itemId: "fc_item_1" } },
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-call",
@@ -2800,7 +2456,7 @@ describe("OpenAI Responses route", () => {
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "fc_item_1" } },
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "step-finish",
@@ -2821,7 +2477,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
type: "function_call",
id: "fc_item_1",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -2835,7 +2491,7 @@ describe("OpenAI Responses route", () => {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
@@ -2847,7 +2503,7 @@ describe("OpenAI Responses route", () => {
type: "tool-input-end",
id: "call_1",
name: "lookup",
providerMetadata: { openai: { itemId: "fc_item_1" } },
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-call",
@@ -2855,7 +2511,7 @@ describe("OpenAI Responses route", () => {
name: "lookup",
input: {},
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "fc_item_1" } },
providerMetadata: { openai: { itemId: "item_1" } },
},
],
)
@@ -2868,14 +2524,14 @@ describe("OpenAI Responses route", () => {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query":"streamed"}' },
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"streamed"}' },
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_item_1",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"partial',
@@ -2907,7 +2563,7 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_item_1",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"partial',
@@ -2936,7 +2592,7 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_item_1",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -2950,7 +2606,7 @@ describe("OpenAI Responses route", () => {
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
providerMetadata: { openai: { itemId: "fc_item_1" } },
providerMetadata: { openai: { itemId: "item_1" } },
})
}),
)
@@ -24,23 +24,14 @@ describe("xAI Responses route", () => {
}),
)
it.effect("parses xAI reasoning summaries", () =>
it.effect("parses xAI reasoning text events", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "reasoning_1" },
},
// Grok streams reasoning with the standard summary event name.
{
type: "response.reasoning_summary_text.delta",
item_id: "reasoning_1",
summary_index: 0,
delta: "Considering.",
},
{ type: "response.reasoning_text.delta", item_id: "reasoning_1", delta: "Considering." },
{ type: "response.reasoning_text.done", item_id: "reasoning_1" },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
+31 -10
View File
@@ -50,7 +50,32 @@ describe("tool schema projections", () => {
})
})
it.effect("applies model compatibility without changing schema semantics", () =>
test("openai keeps one flat object top-level schema", () => {
expect(
ToolSchemaProjection.openAI({
anyOf: [
{
type: "object",
properties: {
path: { type: "string" },
maybe: { anyOf: [{ type: "string" }, { type: "null" }] },
},
},
{ type: "object", properties: { resource: { type: "string" } } },
],
}),
).toEqual({
type: "object",
properties: {
path: { type: "string" },
maybe: { type: "string" },
resource: { type: "string" },
},
additionalProperties: false,
})
})
it.effect("applies model compatibility before protocol projection", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
@@ -82,15 +107,11 @@ describe("tool schema projections", () => {
expect(prepared.body.tools?.[0]?.function.parameters).toEqual({
type: "object",
anyOf: [
{
type: "object",
properties: {
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
linked: { $ref: "#/$defs/Linked" },
},
},
],
properties: {
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
linked: { $ref: "#/$defs/Linked" },
},
additionalProperties: false,
})
}),
)
@@ -1,106 +0,0 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test("renames, exports, and deletes a home session from its context menu", async ({ page }) => {
const sessions = fixture.sessions.map((session) => ({ ...session }))
await mockOpenCodeServer(page, {
sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.route("**/api/session/*/rename", async (route) => {
const sessionID = new URL(route.request().url()).pathname.split("/").at(-2)
const session = sessions.find((item) => item.id === sessionID)
const payload: unknown = route.request().postDataJSON()
if (!payload || typeof payload !== "object" || !("title" in payload) || typeof payload.title !== "string")
throw new Error("Invalid rename payload")
if (session) session.title = payload.title
await route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle })
await expect(row).toBeVisible()
const container = page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`)
const titleBox = await container.locator('[data-component="home-session-title"]').boundingBox()
const avatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
await expect(container.getByRole("button", { name: "More options" })).toHaveCount(0)
await row.focus()
await row.press("Shift+F10")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
await page.keyboard.press("Escape")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeHidden()
await expect(row).toBeFocused()
const rowBox = await row.boundingBox()
await row.click({ button: "right", position: { x: 48, y: 12 } })
await expect(page).toHaveURL("/")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Export..." })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Delete..." })).toBeVisible()
const menuBox = await page.locator('[data-component="menu-v2-content"]').boundingBox()
expect(Math.abs((menuBox?.x ?? 0) - (rowBox?.x ?? 0) - 48)).toBeLessThan(4)
await page.getByRole("menuitem", { name: "Rename" }).click()
const title = page.locator('[data-component="home-session-rename"]')
await expect(title).toBeFocused()
await expect(title).toHaveValue(fixture.expected.targetTitle)
const editorBox = await title.boundingBox()
const editingAvatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
expect(editorBox?.x).toBe(titleBox?.x)
expect(editingAvatarBox).toEqual(avatarBox)
expect(
await title.evaluate((element) => ({
outline: getComputedStyle(element).outlineStyle,
shadow: getComputedStyle(element).boxShadow,
})),
).toEqual({ outline: "none", shadow: "none" })
expect(await container.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe("none")
await title.fill("Renamed from Home")
const renamed = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname.endsWith("/rename"),
)
await title.press("Enter")
expect((await renamed).postDataJSON()).toEqual({ title: "Renamed from Home" })
let renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
await expect(renamedRow).toBeVisible()
await renamedRow.click()
await expect(page).toHaveURL(new RegExp(`/session/${fixture.targetID}$`))
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from Home" })).toBeVisible()
await page.getByRole("button", { name: "Home" }).click()
await expect(page).toHaveURL("/")
renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
await expect(renamedRow).toBeVisible()
await renamedRow.click({ button: "right" })
const download = page.waitForEvent("download")
const exportItem = page.getByRole("menuitem", { name: "Export..." })
await exportItem.click()
expect((await download).suggestedFilename()).toBe("renamed-from-home.json")
await expect(exportItem).toBeHidden()
await renamedRow.click({ button: "right" })
await page.getByRole("menuitem", { name: "Delete..." }).click()
const dialog = page.getByRole("dialog")
await expect(dialog).toContainText('Delete session "Renamed from Home"?')
const removed = page.waitForRequest(
(request) => request.method() === "DELETE" && new URL(request.url()).pathname.endsWith(`/${fixture.targetID}`),
)
await dialog.getByRole("button", { name: "Delete session" }).click()
await removed
await expect(renamedRow).toBeHidden()
})
@@ -13,14 +13,9 @@ const sessionB = session("ses_server_b", directoryB, "Server B session")
test("session settings use the remote server context", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
// Server A has no tab and is never visited: a pending request there proves
// one toggle sweeps every connected server, not just the focused one.
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverA]: [pendingPermission("permission-pending-a", sessionA.id)] },
})
await mockServers(page, permissionRequests)
await configureServers(page)
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
@@ -43,17 +38,7 @@ test("session settings use the remote server context", async ({ page }) => {
}),
)
.toBe(true)
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-pending-a",
body: { reply: "once" },
},
])
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
await dialog.getByRole("tab", { name: "Models" }).click()
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
@@ -158,99 +143,6 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
])
})
test("auto-accept sweeps again after a reconnect", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
const pendingA: MockPermission[] = []
const listFailures: Record<string, number> = {}
const sessionGets: string[] = []
await installSseTransport(page, { server: serverB })
const transport = await installSseTransport(page, { server: serverA, retry: 20 })
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverA]: pendingA },
listFailures,
sessionGets,
})
await configureServers(page, [{ type: "session", server: serverA, sessionId: sessionA.id }])
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
const first = await transport.waitForConnection()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
.poll(() =>
permissionRequests.some((request) => {
const url = new URL(request)
return url.origin === serverA && url.searchParams.get("location[directory]") === directoryA
}),
)
.toBe(true)
await page.keyboard.press("Escape")
// This request is asked while the client is disconnected, so it is never
// delivered as an event and only a reconnect sweep can find it. The first
// listing after the reconnect fails, so only the bounded sweep retry can
// deliver the reply.
pendingA.push(pendingPermission("permission-offline-a", sessionA.id))
listFailures[serverA] = 1
const syncsBeforeReconnect = sessionGets.length
await transport.disconnect()
await transport.waitForConnection({ after: first.id })
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-offline-a",
body: { reply: "once" },
},
])
// The reconnect sweep must resync active sessions instead of trusting
// cached locations, since another client may have moved them meanwhile.
expect(sessionGets.slice(syncsBeforeReconnect)).toContain(sessionA.id)
})
test("auto-accept approves a request discovered by opening a session", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
// The request is only served from the per-session permission list, so it
// reaches the client through the store sync when the session view opens,
// never through a location sweep or an event.
await mockServers(page, permissionRequests, permissionResponses, {
sessionPending: { [sessionA.id]: [pendingPermission("permission-synced-a", sessionA.id)] },
})
await configureServers(page, [{ type: "session", server: serverA, sessionId: sessionA.id }])
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-synced-a",
body: { reply: "once" },
},
])
})
type PermissionResponse = {
origin: string
directory?: string
@@ -259,19 +151,6 @@ type PermissionResponse = {
body: unknown
}
type MockPermission = {
id: string
sessionID: string
action: string
resources: string[]
metadata: Record<string, unknown>
save: unknown[]
}
function pendingPermission(id: string, sessionID: string): MockPermission {
return { id, sessionID, action: "shell", resources: ["git status"], metadata: {}, save: [] }
}
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
await page.addInitScript(
({ serverB, tabs }) => {
@@ -282,23 +161,7 @@ async function configureServers(page: Page, tabs: { type: "session"; server: str
)
}
type MockServerOptions = {
// Pending requests served from /api/permission/request, keyed by origin.
pending?: Record<string, MockPermission[]>
// Pending requests served from /api/session/:id/permission, keyed by session ID.
sessionPending?: Record<string, MockPermission[]>
// Counts of /api/permission/request calls to fail with a 500, keyed by origin.
listFailures?: Record<string, number>
// Records /api/session/:id GETs so tests can assert session resyncs.
sessionGets?: string[]
}
async function mockServers(
page: Page,
permissionRequests: string[],
permissionResponses: PermissionResponse[] = [],
options: MockServerOptions = {},
) {
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
@@ -315,12 +178,8 @@ async function mockServers(
permissionID: response[2]!,
body: route.request().postDataJSON(),
})
// The generated client requires exactly 204 for a successful reply.
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
return json(route, true)
}
const sessionPermission = url.pathname.match(/^\/api\/session\/([^/]+)\/permission$/)
if (route.request().method() === "GET" && sessionPermission)
return json(route, { data: options.sessionPending?.[sessionPermission[1]!] ?? [] })
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/provider")
return json(route, {
@@ -338,12 +197,7 @@ async function mockServers(
if (url.pathname === "/api/agent") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/permission/request") {
permissionRequests.push(url.toString())
const failures = options.listFailures?.[url.origin] ?? 0
if (failures > 0) {
options.listFailures![url.origin] = failures - 1
return json(route, { name: "Internal" }, 500)
}
return json(route, { location: { directory }, data: options.pending?.[url.origin] ?? [] })
return json(route, { location: { directory }, data: [] })
}
if (["/api/command", "/api/reference", "/api/question/request"].includes(url.pathname))
return json(route, { location: { directory }, data: [] })
@@ -365,13 +219,9 @@ async function mockServers(
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory })
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active")
return json(route, { data: Object.fromEntries(sessions.map((session) => [session.id, { type: "running" }])) })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
if (currentSessionInfo) {
options.sessionGets?.push(currentSessionInfo.id)
return json(route, { data: currentSession(currentSessionInfo) })
}
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
+1 -2
View File
@@ -52,12 +52,12 @@
"vite-plugin-solid": "2.11.14"
},
"dependencies": {
"@ibm/plex": "6.4.1",
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
@@ -69,7 +69,6 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/keyed": "1.5.3",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
+13 -13
View File
@@ -105,16 +105,14 @@ export function AppInterface(props: {
// providers beneath it.
const Root = (rootProps: ParentProps) => (
<TabsProvider>
<GlobalProvider>
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</TabsProvider>
)
@@ -125,9 +123,11 @@ export function AppInterface(props: {
servers={props.servers}
>
<SettingsProvider>
<Dynamic component={props.router ?? Router} root={Root}>
<AppRoutes />
</Dynamic>
<GlobalProvider>
<Dynamic component={props.router ?? Router} root={Root}>
<AppRoutes />
</Dynamic>
</GlobalProvider>
</SettingsProvider>
</ServersProvider>
)
+6 -5
View File
@@ -1,6 +1,6 @@
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { ServerConnection } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo, startTransition } from "solid-js"
@@ -8,11 +8,12 @@ import { createEffect, createMemo, startTransition } from "solid-js"
export function createHomeController() {
const layout = useLayout()
const global = useGlobal()
const servers = useServers()
const tabs = useTabs()
const selection = layout.home.selection
const focusedServer = createMemo<ServerConnection.Any | undefined>(
() => servers.visible.find((conn) => ServerConnection.key(conn) === selection().server) ?? servers.visible[0],
() =>
global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ??
global.servers.list()[0],
)
const focusedServerCtx = useServerCtx(focusedServer)
const focusedSync = () => focusedServerCtx()?.sync
@@ -28,7 +29,7 @@ export function createHomeController() {
)
createEffect(() => {
const list = servers.visible
const list = global.servers.list()
if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
const conn = list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) })
@@ -53,7 +54,7 @@ export function createHomeController() {
void startTransition(() => setSelection({ server: ServerConnection.key(conn) })),
},
server: {
list: () => servers.visible,
list: global.servers.list,
health: (conn: ServerConnection.Any) => global.servers.health[ServerConnection.key(conn)],
context: (conn: ServerConnection.Any) => global.ensureServerCtx(conn),
focused: focusedServer,
@@ -61,8 +61,6 @@ export function createHomeProjectsController(home: HomeController) {
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
canHide: (conn: ServerConnection.Any) => serverManagement.connection.canHide(ServerConnection.key(conn)),
hide: (conn: ServerConnection.Any) => serverManagement.connection.setHidden(ServerConnection.key(conn), true),
edit: (conn: ServerConnection.Http) => {
void import("@/servers/connect/dialog").then(({ DialogServer }) => {
void dialog.show(() => <DialogServer mode="edit" server={conn} />)
@@ -26,8 +26,6 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
onSetDefaultServer={props.projects.server.setDefault}
canRemoveServer={props.projects.server.canRemove}
onRemoveServer={props.projects.server.remove}
canHideServer={props.projects.server.canHide}
onHideServer={props.projects.server.hide}
onMoveProject={props.projects.project.move}
onSelectProject={props.projects.project.select}
onAddProjects={props.projects.project.add}
+5 -11
View File
@@ -49,8 +49,6 @@ export type HomeProjectsViewProps = {
onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
canRemoveServer: (server: ServerConnection.Any) => boolean
onRemoveServer: (server: ServerConnection.Any) => void
canHideServer: (server: ServerConnection.Any) => boolean
onHideServer: (server: ServerConnection.Any) => void
onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void
onSelectProject: (server: ServerConnection.Any, directory: string) => void
onAddProjects: (server: ServerConnection.Any, directories: string[]) => void
@@ -190,8 +188,6 @@ function HomeServerRow(props: {
onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
canRemoveServer: HomeProjectsViewProps["canRemoveServer"]
onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
canHideServer: HomeProjectsViewProps["canHideServer"]
onHideServer: HomeProjectsViewProps["onHideServer"]
onSetContextMenuOpen: HomeProjectsContextMenuProps["onSetContextMenuOpen"]
onChooseProject: HomeProjectsViewProps["onChooseProject"]
server: ServerConnection.Any
@@ -216,11 +212,11 @@ function HomeServerRow(props: {
value={props.language.t("server.row.incompatible", { version: props.health?.version ?? "1" })}
>
<div class="group/server relative flex h-7 w-full min-w-0 items-center rounded-[6px]">
<HomeProjectNavButton
type="button"
class="pr-16"
classList={{ "opacity-60": !healthy() && !incompatible() }}
data-selected={props.selected ? "" : undefined}
<HomeProjectNavButton
type="button"
class="pr-16"
classList={{ "opacity-60": !healthy() && !incompatible() }}
data-selected={props.selected ? "" : undefined}
disabled={!healthy()}
onClick={() => props.onFocusServer(props.server)}
>
@@ -288,12 +284,10 @@ function HomeServerRow(props: {
canDefault={props.canDefaultServer}
isDefault={props.defaultServerKey === ServerConnection.key(props.server)}
canRemove={props.canRemoveServer(props.server)}
canHide={props.canHideServer(props.server)}
onEdit={props.onEditServer}
onSetDefault={() => props.onSetDefaultServer(props.server)}
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
onRemove={() => props.onRemoveServer(props.server)}
onHide={() => props.onHideServer(props.server)}
open={props.contextMenuOpen(contextMenuID())}
onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
/>
+4 -133
View File
@@ -1,12 +1,8 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Button } from "@opencode-ai/ui/button"
import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode-ai/ui/dialog"
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
import { skipToken, useQuery } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import { useCommand } from "@/shell/commands/command"
import { loadHomeSessionIndex, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index"
import type { LocalProject } from "@/shell/state/layout"
@@ -15,10 +11,7 @@ import { ServerConnection } from "@/runtime/server/registry"
import { sessionHasOpenTab, useTabs } from "@/shell/tabs/tabs"
import { errorMessage } from "@/shell/layout/helpers"
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
import { removedSessionIDs } from "@/session/session-domain"
import { pathKey } from "@/workspaces/path-key"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { sessionLabel, sessionTitle } from "@/session/title"
import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
import type { HomeController } from "../model"
@@ -42,8 +35,6 @@ export function createHomeSessionsController(home: HomeController) {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const queryClient = useQueryClient()
const [removed, setRemoved] = createStore({ keys: [] as string[] })
const projectDirectories = createMemo(() => {
const selected = home.selection.value().directory
if (!selected) return
@@ -68,13 +59,9 @@ export function createHomeSessionsController(home: HomeController) {
})
const indexedSessions = createMemo(() => {
const ctx = home.server.focusedContext()
const conn = home.server.focused()
if (!ctx || !conn) return []
const server = ServerConnection.key(conn)
if (!ctx) return []
return retainHomeSessions(
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()).filter(
(session) => !removed.keys.includes(`${server}\0${session.id}`),
),
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()),
HOME_SESSION_LIMIT,
Date.now(),
)
@@ -136,119 +123,6 @@ export function createHomeSessionsController(home: HomeController) {
},
])
const rename = async (server: ServerConnection.Key, session: SessionInfo, title: string) => {
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
const ctx = conn ? home.server.context(conn) : undefined
if (!conn || !ctx) return false
const next = title.trim()
if (!next || next === sessionLabel(session)) return true
return ctx.sdk.api.session
.rename({ sessionID: session.id, title: next })
.then(() => {
ctx.data.session.remember({ ...(ctx.data.session.get(session.id) ?? session), title: next })
// Rename advances time.updated server-side; re-sync the canonical
// record so date grouping and ordering do not go stale.
ctx.data.session.invalidate(session.id)
void ctx.data.session.sync(session.id).catch(() => {})
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
current?.map((item) => (item.id === session.id ? { ...item, title: next } : item)),
)
return true
})
.catch((cause) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
})
return false
})
}
const exportSession = async (server: ServerConnection.Key, session: SessionInfo) => {
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
const ctx = conn ? home.server.context(conn) : undefined
if (!ctx) return
try {
const data = await fetchSessionExport({ sessionID: session.id, api: ctx.sdk.api })
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("toast.session.export.success.title"),
description: language.t("toast.session.export.success.description", { filename }),
})
} catch (cause) {
showToast({
variant: "error",
title: language.t("toast.session.export.failed.title"),
description: cause instanceof Error ? cause.message : language.t("toast.session.export.failed.description"),
})
}
}
const remove = async (server: ServerConnection.Key, session: SessionInfo) => {
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
const ctx = conn ? home.server.context(conn) : undefined
if (!conn || !ctx) return false
const ids = [...removedSessionIDs(ctx.data.session.list(), session.id)]
await queryClient.cancelQueries({ queryKey: ["home-sessions", conn], exact: true })
return ctx.sdk.api.session
.remove({ sessionID: session.id })
.then(() => {
const removedIDs = new Set(ids)
setRemoved("keys", (current) => [...new Set([...current, ...ids.map((id) => `${server}\0${id}`)])])
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
current?.filter((item) => !removedIDs.has(item.id)),
)
notifySessionTabsRemoved({
server: ServerConnection.key(conn),
directory: session.location.directory,
sessionIDs: ids,
})
return true
})
.catch((cause) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(cause, language.t("session.delete.failed.title")),
})
return false
})
.finally(() => {
// Always refetch: the pre-mutation cancel may have aborted an
// in-flight index fetch, and a failed delete must not leave the
// index unloaded either.
void queryClient.invalidateQueries({ queryKey: ["home-sessions", conn], exact: true })
})
}
function DeleteDialog(props: { server: ServerConnection.Key; session: SessionInfo }) {
const name = () => sessionTitle(props.session.title) ?? language.t("command.session.new")
const confirm = async () => {
await remove(props.server, props.session)
dialog.close()
}
return (
<Dialog fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", { name: name() })}
/>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="danger" onClick={confirm}>
{language.t("session.delete.button")}
</Button>
</DialogFooter>
</Dialog>
)
}
return {
copy: {
language,
@@ -319,10 +193,6 @@ export function createHomeSessionsController(home: HomeController) {
}),
})
},
rename,
export: exportSession,
showDelete: (server: ServerConnection.Key, session: SessionInfo) =>
dialog.show(() => <DeleteDialog server={server} session={session} />),
},
tab: {
isOpen: (record: HomeSessionRecord) =>
@@ -373,6 +243,7 @@ export function HomeSessionStatusController(props: {
}) {
const avatar = useSessionTabAvatarState(
() => props.server,
() => props.record.session.location.directory,
() => props.record.session.id,
() => true,
)
@@ -27,9 +27,6 @@ export function HomeSessions(props: {
onCreateSession={props.sessions.session.create}
onOpenSession={props.sessions.session.open}
onArchiveSession={props.sessions.session.archive}
onRenameSession={props.sessions.session.rename}
onExportSession={props.sessions.session.export}
onDeleteSession={props.sessions.session.showDelete}
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
onSetContent={props.scroll.header.setContent}
+40 -282
View File
@@ -1,14 +1,10 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { Key } from "@solid-primitives/keyed"
import { createMemo, For, Index, onCleanup, Show, Suspense } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { createMemo, For, Show, Suspense } from "solid-js"
import { Spinner } from "@opencode-ai/ui/spinner"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
@@ -26,7 +22,6 @@ import {
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
const HOME_SESSION_LONG_PRESS_MS = 500
// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
// tab in the background without navigating, matching browser conventions.
@@ -59,9 +54,6 @@ export type HomeSessionsViewProps = {
onCreateSession: () => void
onOpenSession: (session: SessionInfo, options?: OpenSessionOptions) => void
onArchiveSession: (session: SessionInfo) => Promise<void>
onRenameSession: (server: ServerConnection.Key, session: SessionInfo, title: string) => Promise<boolean>
onExportSession: (server: ServerConnection.Key, session: SessionInfo) => Promise<void>
onDeleteSession: (server: ServerConnection.Key, session: SessionInfo) => void
onSetHoverTarget: (element: HTMLElement) => void
onSetThumbTrack: (element: HTMLDivElement) => void
onSetContent: (element: HTMLDivElement) => void
@@ -79,16 +71,7 @@ export type HomeSessionsViewProps = {
onSearchSelect: (record: HomeSessionRecord, options?: OpenSessionOptions) => void
}
// Session store updates recreate row components, so row-local state would
// close an open context menu or drop an in-progress rename. Keep both keyed
// by session ID at the view root, like the projects list does.
type HomeSessionRowUI = {
menu: { id: string; x: number; y: number } | undefined
editor: { id: string; draft: string; renaming: boolean } | undefined
}
export function HomeSessionsView(props: HomeSessionsViewProps) {
const [rowUI, setRowUI] = createStore<HomeSessionRowUI>({ menu: undefined, editor: undefined })
return (
<section
ref={props.onSetHoverTarget}
@@ -139,29 +122,23 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
}
>
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
{/* Index keeps group subtrees mounted when the group arrays are
rebuilt, so store updates cannot recreate rows mid-gesture. */}
<Index each={props.groups}>
<For each={props.groups}>
{(group, index) => (
<>
<HomeSessionGroupHeader
title={group().title}
titleOpacity={props.titleOpacity(group().id)}
onSetRef={(element) => props.onSetHeader(group().id, element)}
elevated={index === 0}
title={group.title}
titleOpacity={props.titleOpacity(group.id)}
onSetRef={(element) => props.onSetHeader(group.id, element)}
elevated={index() === 0}
/>
<div class={`flex min-w-0 flex-col gap-px pt-4 ${index === props.groups.length - 1 ? "" : "mb-6"}`}>
{/* Rows key by session ID: session.sync replaces the
stored session object wholesale, so reference-keyed
rows would be disposed mid-interaction whenever a
sync response lands. */}
<Key each={group().sessions} by={(record) => record.session.id}>
{(record) => <HomeSessionRow {...props} record={record()} rowUI={rowUI} setRowUI={setRowUI} />}
</Key>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups.length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
</div>
</>
)}
</Index>
</For>
</div>
</Show>
</Suspense>
@@ -435,263 +412,45 @@ function HomeSessionGroupHeader(props: {
)
}
function HomeSessionRow(
props: HomeSessionsViewProps & {
record: HomeSessionRecord
rowUI: HomeSessionRowUI
setRowUI: SetStoreFunction<HomeSessionRowUI>
},
) {
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
const title = createMemo(() => sessionLabel(props.record.session))
const showProjectName = () => props.showProjectName && props.record.projectName
const sessionID = () => props.record.session.id
const menu = () => (props.rowUI.menu?.id === sessionID() ? props.rowUI.menu : undefined)
const editor = () => (props.rowUI.editor?.id === sessionID() ? props.rowUI.editor : undefined)
let longPressTimer: ReturnType<typeof setTimeout> | undefined
let longPressStart: { x: number; y: number } | undefined
let suppressClick = false
let menuInteractedOutside = false
// Focus targets are looked up by session ID: session store updates recreate
// row components, so instance refs can point at detached nodes by the time
// deferred focus runs.
const rowSelector = () => `[data-component="home-session-row-container"][data-session-id="${sessionID()}"]`
const rowButton = () =>
document.querySelector<HTMLButtonElement>(`${rowSelector()} [data-component="home-session-row"]`)
const renameInput = () =>
document.querySelector<HTMLInputElement>(`${rowSelector()} [data-component="home-session-rename"]`)
const clearLongPress = () => {
if (longPressTimer !== undefined) clearTimeout(longPressTimer)
longPressTimer = undefined
longPressStart = undefined
}
onCleanup(clearLongPress)
const openMenu = (element: HTMLElement, clientX: number, clientY: number) => {
const bounds = element.getBoundingClientRect()
props.setRowUI("menu", { id: sessionID(), x: clientX - bounds.left, y: clientY - bounds.top })
}
const openEditor = () => {
props.setRowUI("editor", { id: sessionID(), draft: title(), renaming: false })
requestAnimationFrame(() => {
const input = renameInput()
input?.focus()
input?.select()
})
}
const closeEditor = () => {
if (editor()?.renaming) return
props.setRowUI("editor", (value) => (value?.id === sessionID() ? undefined : value))
}
const saveEditor = async () => {
const current = editor()
if (!current || current.renaming) return
props.setRowUI("editor", { ...current, renaming: true })
const saved = await props.onRenameSession(props.server, props.record.session, current.draft)
// Disabling the input during the request drops focus to the body; restore
// it unless the user focused another control while the rename was pending.
const restore = document.activeElement === document.body || document.activeElement === renameInput()
props.setRowUI("editor", (value) => {
if (value?.id !== sessionID()) return value
return saved ? undefined : { ...value, renaming: false }
})
if (!restore) return
requestAnimationFrame(() => {
if (saved) {
rowButton()?.focus()
return
}
renameInput()?.focus()
})
}
return (
<div
data-component="home-session-row-container"
data-session-id={props.record.session.id}
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px] outline-none focus:outline-none focus-visible:outline-none"
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
classList={{ group: !!showProjectName() }}
onContextMenu={(event) => {
// While renaming, keep the native menu so paste and spelling work.
if (editor()) return
event.preventDefault()
openMenu(event.currentTarget, event.clientX, event.clientY)
}}
>
<Show
when={!editor()}
fallback={
<div class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 pl-3 pr-10">
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={false}
/>
<InlineInput
data-component="home-session-rename"
aria-label={props.language.t("common.rename")}
dir="auto"
value={editor()?.draft ?? ""}
disabled={editor()?.renaming ?? false}
class={`
block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base
[font-weight:530] field-sizing-content outline-none focus:outline-none focus-visible:outline-none
${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}
`}
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => {
const draft = event.currentTarget.value
props.setRowUI("editor", (value) => (value?.id === sessionID() ? { ...value, draft } : value))
}}
onKeyDown={(event) => {
event.stopPropagation()
// Enter and Escape during IME composition commit or cancel
// the composition, not the rename. Safari can report the
// composition-confirming keydown with isComposing false but
// keyCode 229.
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
event.preventDefault()
void saveEditor()
return
}
if (event.key !== "Escape") return
event.preventDefault()
closeEditor()
requestAnimationFrame(() => rowButton()?.focus())
}}
onBlur={closeEditor}
/>
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</div>
}
>
<button
type="button"
data-component="home-session-row"
aria-haspopup="menu"
aria-expanded={!!menu()}
class={`
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
`}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onPointerDown={(event) => {
suppressClick = false
if (event.pointerType !== "touch") return
clearLongPress()
const element = event.currentTarget
const x = event.clientX
const y = event.clientY
longPressStart = { x, y }
longPressTimer = setTimeout(() => {
suppressClick = true
clearLongPress()
openMenu(element, x, y)
}, HOME_SESSION_LONG_PRESS_MS)
}}
onPointerMove={(event) => {
if (!longPressStart) return
if (Math.abs(event.clientX - longPressStart.x) <= 8 && Math.abs(event.clientY - longPressStart.y) <= 8)
return
clearLongPress()
}}
onPointerUp={clearLongPress}
onPointerCancel={() => {
clearLongPress()
suppressClick = false
}}
onKeyDown={(event) => {
if (event.key !== "ContextMenu" && (event.key !== "F10" || !event.shiftKey)) return
event.preventDefault()
const bounds = event.currentTarget.getBoundingClientRect()
openMenu(event.currentTarget, bounds.left + 12, bounds.bottom)
}}
onClick={(event) => {
// The flag stays set until the long-press compatibility click
// arrives, however delayed; keyboard activation (detail 0) is
// never that click and passes through.
if (suppressClick) {
suppressClick = false
if (event.detail !== 0) {
event.preventDefault()
return
}
}
props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })
}}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.onOpenSession(props.record.session, { background: true })
}}
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</button>
</Show>
<Menu
modal={false}
placement="bottom-start"
gutter={2}
open={!!menu()}
onOpenChange={(open) => {
if (open) return
props.setRowUI("menu", (value) => (value?.id === sessionID() ? undefined : value))
<button
type="button"
data-component="home-session-row"
class={`
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
`}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onClick={(event) => props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.onOpenSession(props.record.session, { background: true })
}}
>
<Menu.Trigger
as="span"
aria-hidden="true"
tabIndex={-1}
class="pointer-events-none absolute size-px"
style={{ left: `${menu()?.x ?? 0}px`, top: `${menu()?.y ?? 0}px` }}
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<Menu.Portal>
<Menu.Content
onInteractOutside={() => {
menuInteractedOutside = true
}}
onCloseAutoFocus={(event) => {
// The trigger is an invisible positioning span, so Kobalte's
// default close focus restore has no useful target. Skip the
// row focus when the rename editor owns focus or the user
// dismissed the menu by interacting elsewhere.
event.preventDefault()
const outside = menuInteractedOutside
menuInteractedOutside = false
if (outside || editor()) return
requestAnimationFrame(() => rowButton()?.focus())
}}
>
<Menu.Item onSelect={openEditor}>{props.language.t("common.rename")}</Menu.Item>
<Menu.Item onSelect={() => void props.onExportSession(props.server, props.record.session)}>
{props.language.t("common.export")}...
</Menu.Item>
<Menu.Separator />
<Menu.Item onSelect={() => props.onDeleteSession(props.server, props.record.session)}>
{props.language.t("common.delete")}...
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</button>
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
<div
class={`
@@ -722,7 +481,6 @@ function HomeSessionRow(
function HomeSessionTitle(props: { title: string; showProjectName: boolean; search?: boolean }) {
return (
<span
data-component="home-session-title"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530]"
classList={{
"text-[13px] leading-4 tracking-[-0.04px]": !!props.search,
@@ -8,6 +8,7 @@ import { createComposerControls, createComposerModelSelection } from "@/composer
import { createComposerProjectControls } from "./project/controller"
import { useLanguage } from "@/runtime/i18n/language"
import { useLocal } from "@/providers/models/selection"
import { usePermission } from "@/session/requests/permission"
import { useData, useServer } from "@/runtime/server/current"
import { type ServerSDK, useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
@@ -29,6 +30,7 @@ export function createNewSessionComposerAdapter(props: {
const data = useData()
const server = useServer()
const serverSDK = useServerSDK()
const permission = usePermission()
const tabs = useTabs()
const location = useWorkspaceLocation()
const language = useLanguage()
@@ -84,6 +86,9 @@ export function createNewSessionComposerAdapter(props: {
)
const cleanupReady = startTransition(() => {
tabs.updateDraft(props.draftID, { worktree: undefined })
if (permission.isAutoAcceptingDirectory(projectDirectory)) {
permission.enableAutoAccept(created.id, sessionDirectory)
}
local.session.promote(sessionDirectory, created.id, {
agent: selection.agent,
model: selection.model,
@@ -18,23 +18,13 @@ describe("new session workspace selection", () => {
).toBe("main")
})
test("uses the saved destination instead of the current worktree", () => {
test("derives an existing worktree from the current directory", () => {
expect(
resolveNewSessionWorktree({
enabled: true,
directory: "/project/feature",
projectWorktree: "/project",
fallback: "create",
}),
).toBe("create")
expect(
resolveNewSessionWorktree({
enabled: true,
directory: "/project/feature",
projectWorktree: "/project",
fallback: "main",
}),
).toBe("/project")
resolveNewSessionWorktree({ enabled: true, directory: "/project/feature", projectWorktree: "/project" }),
).toBe("/project/feature")
expect(resolveNewSessionWorktree({ enabled: true, directory: "/project", projectWorktree: "/project" })).toBe(
"main",
)
})
test("normalizes main to the project root outside the main worktree", () => {
@@ -43,9 +33,9 @@ describe("new session workspace selection", () => {
})
test("treats equivalent Windows roots as the main worktree", () => {
expect(
resolveNewSessionWorktree({ enabled: true, directory: "C:\\Repo\\", projectWorktree: "c:/repo" }),
).toBe("main")
expect(resolveNewSessionWorktree({ enabled: true, directory: "C:\\Repo\\", projectWorktree: "c:/repo" })).toBe(
"main",
)
expect(normalizeNewSessionWorktree("main", "C:\\Repo\\", "c:/repo")).toBe("main")
})
@@ -21,7 +21,8 @@ export function resolveNewSessionWorktree(input: {
}) {
if (!input.enabled) return "main"
if (input.selected) return input.selected
return normalizeNewSessionWorktree(input.fallback ?? "main", input.directory, input.projectWorktree)
if (input.projectWorktree && !sameDirectory(input.directory, input.projectWorktree)) return input.directory
return input.fallback ?? "main"
}
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
@@ -120,6 +121,7 @@ export function createNewSessionWorkspaceController(input: {
remember,
set: (worktree: string) => {
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
remember(worktree)
},
},
project: {
-2
View File
@@ -374,8 +374,6 @@ export const dict = {
"dialog.server.menu.default": "Set as default",
"dialog.server.menu.defaultRemove": "Remove default",
"dialog.server.menu.delete": "Delete",
"dialog.server.menu.hide": "Hide from project list",
"dialog.server.menu.show": "Show in project list",
"dialog.server.current": "Current Server",
"dialog.server.status.default": "Default",
"wsl.server.add": "Add WSL server",
@@ -114,12 +114,11 @@ export function createBrowserDraftStore(): DraftStore {
if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id)
return item
})
const store = transaction.objectStore("blobs")
const blobs = store.openKeyCursor()
const blobs = transaction.objectStore("blobs").openKeyCursor()
blobs.addEventListener("success", () => {
const cursor = blobs.result
if (!cursor) return
if (!used.has(String(cursor.key))) store.delete(cursor.key)
if (!used.has(String(cursor.key))) cursor.delete()
cursor.continue()
})
})
@@ -1,7 +1,7 @@
import { Platform, usePlatform } from "@/runtime/platform/platform"
import { makePersisted, messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { checksum } from "@opencode-ai/util/encode"
import { createResource, onCleanup, type Accessor } from "solid-js"
import { createResource, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
import { pathKey } from "@/workspaces/path-key"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
@@ -16,7 +16,6 @@ type PersistedWithReady<T> = [
type PersistTarget = {
draft?: boolean
sync?: boolean
storage?: string
scope?: "window"
workspaceStorageAliases?: string[]
@@ -638,17 +637,7 @@ export function persisted<T>(
return api
})()
const channel =
config.sync && typeof BroadcastChannel !== "undefined"
? new BroadcastChannel(`opencode.persist:${config.storage ?? "default"}:${config.key}`)
: undefined
if (channel) onCleanup(() => channel.close())
const [state, setState, init] = makePersisted(store, {
name: config.key,
storage,
sync: channel ? messageSync(channel) : undefined,
})
const [state, setState, init] = makePersisted(store, { name: config.key, storage })
const isAsync = init instanceof Promise
const [ready] = createResource(
@@ -104,9 +104,6 @@ type PlatformBase = {
/** Read image from clipboard (desktop only) */
readClipboardImage?(): Promise<File | null>
/** Write text to the native clipboard (desktop only) */
writeClipboardText?(text: string): Promise<void>
/** Export collected diagnostic logs (desktop only) */
exportDebugLogs?(): Promise<string>
@@ -264,13 +264,11 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
const [store, setStore, _] = persisted(
{
...Persist.global("server"),
sync: true,
previousKey: "server.v3",
migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
},
createStore({
list: [] as StoredServer[],
hidden: {} as Record<string, boolean>,
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
recentlyClosed: {} as Record<string, string[]>,
@@ -282,7 +280,6 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
const allServers = createMemo((): Array<ServerConnection.Any> => {
return resolveServerList({ stored: store.list, props: props.servers })
})
const visibleServers = createMemo(() => allServers().filter((conn) => !store.hidden[ServerConnection.key(conn)]))
function add(input: ServerConnection.Http) {
const url_ = normalizeServerUrl(input.http.url)
@@ -324,15 +321,6 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
get list() {
return allServers()
},
get visible() {
return visibleServers()
},
isHidden(key: ServerConnection.Key) {
return store.hidden[key] ?? false
},
setHidden(key: ServerConnection.Key, hidden: boolean) {
setStore("hidden", key, hidden)
},
add,
remove,
canRemove,
+3 -2
View File
@@ -8,7 +8,7 @@ import { createServerSdkContext } from "./client"
import { createServerSyncContext } from "./sync"
import { createData } from "@opencode-ai/client/solid"
import type { ServerScope } from "@/runtime/server/scope"
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
import { createServerPermissionState } from "@/session/requests/server-permission"
import { createServerNotificationState } from "@/shell/notifications/notification"
import { Persist, persisted } from "@/runtime/persistence/storage"
@@ -144,7 +144,7 @@ function createServerController(
directory: "",
})
const sync = createServerSyncContext(sdk, data)
createPermissionAutoApprover({ sdk, data })
const permission = createServerPermissionState({ sdk, sync, data })
const notification = createServerNotificationState({ sdk, data, key: connKey })
function enrich(project: { worktree: string; expanded: boolean }) {
@@ -187,6 +187,7 @@ function createServerController(
list: projectsList,
recentlyClosed: recentlyClosedList,
},
permission,
notification,
}
}
@@ -66,19 +66,7 @@ export function useServerActionsController() {
}
}
return {
defaults,
connection: {
canRemove: server.canRemove,
remove,
canHide: (key: ServerConnection.Key) => {
const conn = server.list.find((item) => ServerConnection.key(item) === key)
return server.visible.length > 1 && !!conn && ServerConnection.builtin(conn)
},
isHidden: server.isHidden,
setHidden: server.setHidden,
},
}
return { defaults, connection: { canRemove: server.canRemove, remove } }
}
export type ServerActionsController = ReturnType<typeof useServerActionsController>
@@ -22,14 +22,10 @@ export const ServerRowMenu: Component<{
canDefault={props.domain.defaults.available()}
isDefault={props.domain.defaults.key() === key}
canRemove={props.domain.connection.canRemove(key)}
canHide={props.domain.connection.canHide(key)}
hidden={props.domain.connection.isHidden(key)}
onEdit={props.onEdit}
onSetDefault={() => props.domain.defaults.set(key)}
onRemoveDefault={() => props.domain.defaults.set(null)}
onRemove={() => props.domain.connection.remove(key)}
onHide={() => props.domain.connection.setHidden(key, true)}
onShow={() => props.domain.connection.setHidden(key, false)}
open={props.open}
onOpenChange={props.onOpenChange}
/>
@@ -44,8 +40,6 @@ export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
default: language.t("dialog.server.menu.default"),
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
delete: language.t("dialog.server.menu.delete"),
hide: language.t("dialog.server.menu.hide"),
show: language.t("dialog.server.menu.show"),
}
}
@@ -55,14 +49,10 @@ export const ServerRowMenuView: Component<{
canDefault: boolean
isDefault: boolean
canRemove: boolean
canHide?: boolean
hidden?: boolean
onEdit: (server: ServerConnection.Http) => void
onSetDefault: () => void
onRemoveDefault: () => void
onRemove: () => void
onHide?: () => void
onShow?: () => void
open?: boolean
onOpenChange?: (open: boolean) => void
}> = (props) => {
@@ -96,12 +86,6 @@ export const ServerRowMenuView: Component<{
<Show when={props.canDefault && props.isDefault}>
<Menu.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</Menu.Item>
</Show>
<Show when={props.hidden}>
<Menu.Item onSelect={props.onShow}>{props.labels.show}</Menu.Item>
</Show>
<Show when={!props.hidden && props.canHide}>
<Menu.Item onSelect={props.onHide}>{props.labels.hide}</Menu.Item>
</Show>
<Show when={props.canRemove}>
<Menu.Separator />
<Menu.Item onSelect={props.onRemove}>{props.labels.delete}</Menu.Item>
@@ -4,13 +4,14 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b
import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { usePermission } from "@/session/requests/permission"
import { useComposerState } from "@/composer/persistence"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { useSettings } from "@/settings/model"
import { useTerminal } from "@/session/terminal/context"
import { showToast } from "@/shell/notifications/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { usePlatform } from "@/runtime/platform/platform"
import type { SessionModel } from "@/session/model"
import type { SessionRevert } from "@/session/revert"
@@ -46,11 +47,12 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const dialog = useDialog()
const file = useFile()
const language = useLanguage()
const permission = usePermission()
const prompt = useComposerState()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const settings = useSettings()
const terminal = useTerminal()
const platform = usePlatform()
const layout = useLayout()
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
const owner = actions.session.ownership.capture()
@@ -95,6 +97,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const mcpCommand = withCategory(language.t("command.category.mcp"))
const permissionsCommand = withCategory(language.t("command.category.permissions"))
const isAutoAcceptActive = () => {
const sessionID = actions.session.identity.params.id
if (sessionID) return permission.isAutoAccepting(sessionID, sdk().directory)
return permission.isAutoAcceptingDirectory(sdk().directory)
}
const exportSession = async () => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
@@ -124,7 +131,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
try {
await (platform.writeClipboardText?.(sessionID) ?? navigator.clipboard.writeText(sessionID))
await navigator.clipboard.writeText(sessionID)
showToast({
variant: "success",
icon: "circle-check",
@@ -144,7 +151,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const projectID = actions.session.data.info()?.projectID
if (!projectID) return
try {
await (platform.writeClipboardText?.(projectID) ?? navigator.clipboard.writeText(projectID))
await navigator.clipboard.writeText(projectID)
showToast({
variant: "success",
icon: "circle-check",
@@ -214,8 +221,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const toggleAutoAccept = () => {
const active = !settings.permissions.autoApprove()
settings.permissions.setAutoApprove(active)
const sessionID = actions.session.identity.params.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk().directory)
else permission.toggleAutoAcceptDirectory(sdk().directory)
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk().directory)
: permission.isAutoAcceptingDirectory(sdk().directory)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
@@ -440,7 +452,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const permissionsCmds = () => [
permissionsCommand({
id: "permissions.autoaccept",
title: settings.permissions.autoApprove()
title: isAutoAcceptActive()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
@@ -1,136 +0,0 @@
import { createEffect, onCleanup } from "solid-js"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import type { Data } from "@opencode-ai/client/solid"
import type { ServerSDK } from "@/runtime/server/client"
import { useSettings } from "@/settings/model"
const respondedLimit = 1000
const retryLimit = 2
const retryDelayMs = 1000
// Auto-approves permission requests on one server connection whenever the
// app-level auto-approve setting is on. The setting lives in the client-local
// settings store, so it applies to every session, tab, and server at once.
export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data }) {
const enabled = useSettings().permissions.autoApprove
const state = { disposed: false, generation: 0, responded: new Set<string>() }
const unsubscribe = input.sdk.event.on("permission.asked", (event) => {
if (enabled()) approve(event.data)
})
onCleanup(() => {
state.disposed = true
unsubscribe()
})
// The event stream does not replay requests asked while this client was
// disconnected, and requests may already be pending before the setting turns
// on, so sweep on every connect while the setting is on.
createEffect(() => {
if (!enabled() || input.sdk.connection.status() !== "connected") return
const generation = ++state.generation
void sweepWithRetry(generation, 0)
})
// Approves pending requests that reach the local store, which is how a
// previously unknown idle session's requests surface when its view opens
// and syncs them. Store changes cannot re-trigger the network sweep: it
// deliberately reads them after an await, outside Solid tracking.
createEffect(() => {
if (!enabled()) return
for (const session of input.data.session.list()) {
for (const request of input.data.session.permission.list(session.id) ?? []) approve(request)
}
})
// An incomplete sweep leaves pending requests hidden with no later trigger
// to recover them, so retry it a bounded number of times. A newer sweep
// supersedes scheduled retries.
async function sweepWithRetry(generation: number, attempt: number) {
const complete = await sweep()
if (complete || attempt >= retryLimit) return
setTimeout(() => {
if (state.disposed || !enabled() || generation !== state.generation) return
void sweepWithRetry(generation, attempt + 1)
}, retryDelayMs * (attempt + 1))
}
async function sweep() {
const inventory = await sweepLocations()
const listed = await Promise.all(
inventory.locations.map((location) =>
input.sdk.api.permission.request
.list({ location: { directory: location.directory, workspace: location.workspaceID } })
.then((pending) => {
if (!state.disposed) pending.data.forEach((request) => approve(request))
return true
})
.catch(() => false),
),
)
return inventory.complete && listed.every(Boolean)
}
// Active sessions are the primary inventory: session.active is server-wide,
// so it covers sessions no tab has loaded, and a request blocking a tool
// call always belongs to one (Permission.assert clears its entry when the
// awaiting fiber dies). Locally known sessions are swept too because the
// external session.permission.create API can park a request on an idle
// session. A detached request on a session this client never loaded is the
// one case that stays uncovered.
async function sweepLocations() {
const active = await input.sdk.api.session.active().catch(() => undefined)
const ids = Object.keys(active ?? {})
// Resync every active session rather than trusting cached info: another
// client may have moved one while this client was disconnected, and the
// cached location would list permissions from the old location. A failed
// resync falls back to the cached location and marks the sweep incomplete.
const synced = await Promise.all(
ids.map((id) => {
input.data.session.invalidate(id)
return input.data.session.sync(id).then(
() => true,
() => false,
)
}),
)
const locations = [
...ids.flatMap((id) => {
const location = input.data.session.get(id)?.location
return location ? [location] : []
}),
...input.data.session.list().map((session) => session.location),
]
return {
locations: [
...new Map(locations.map((item) => [`${item.directory}\u0000${item.workspaceID ?? ""}`, item])).values(),
],
complete: active !== undefined && synced.every(Boolean),
}
}
function approve(permission: PermissionRequest, attempt = 0) {
// enabled() guards the retry timer path: the user may disable the setting
// between a failed reply and its scheduled retry.
if (state.disposed || !enabled() || state.responded.has(permission.id)) return
remember(permission.id)
input.sdk.api.permission
.reply({ sessionID: permission.sessionID, requestID: permission.id, reply: "once" })
.catch(() => {
// A reply failure leaves the request pending but invisible (the UI
// hides prompts while auto-approve is on), so retry a bounded number
// of times. Later sweeps retry it after that.
state.responded.delete(permission.id)
if (state.disposed || attempt >= retryLimit) return
setTimeout(() => approve(permission, attempt + 1), retryDelayMs * (attempt + 1))
})
}
function remember(id: string) {
state.responded.add(id)
for (const oldest of state.responded) {
if (state.responded.size <= respondedLimit) break
state.responded.delete(oldest)
}
}
}
@@ -0,0 +1,129 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting, relocateAutoAccept, sessionAutoAccept } from "./auto-respond"
const session = (input: { id: string; parentID?: string }) =>
({
id: input.id,
parentID: input.parentID,
}) as SessionInfo
const permission = (sessionID: string) =>
({
sessionID,
}) as Pick<PermissionRequest, "sessionID">
describe("autoRespondsPermission", () => {
test("uses a parent session's directory-scoped auto-accept", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/root`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
})
test("defaults to requiring approval when no lineage override exists", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
const autoAccept = {
other: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(false)
})
test("inherits a parent session's false override", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/root`]: false,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
})
test("prefers a child override over parent override", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/root`]: false,
[`${base64Encode(directory)}/child`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
})
test("falls back to directory-level auto-accept", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
expect(sessionAutoAccept(autoAccept, sessions, permission("root"), directory)).toBeUndefined()
})
test("session-level override takes precedence over directory-level", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
[`${base64Encode(directory)}/root`]: false,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
})
test("parent false override takes precedence over directory-level auto-accept", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
[`${base64Encode(directory)}/root`]: false,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
})
test("parent true override takes precedence over disabled directory fallback", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: false,
[`${base64Encode(directory)}/root`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
})
})
describe("isDirectoryAutoAccepting", () => {
test("returns true when directory key is set", () => {
const directory = "/tmp/project"
const autoAccept = { [`${base64Encode(directory)}/*`]: true }
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true)
})
test("returns false when directory key is not set", () => {
expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false)
})
test("returns false when directory key is explicitly false", () => {
const directory = "/tmp/project"
const autoAccept = { [`${base64Encode(directory)}/*`]: false }
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
})
})
test("relocates bare session settings when the directory becomes known", () => {
const directory = "/tmp/project"
expect(relocateAutoAccept({ root: true }, [{ id: "root" }], directory)).toEqual({
[`${base64Encode(directory)}/root`]: true,
})
expect(
relocateAutoAccept({ root: true, [`${base64Encode(directory)}/root`]: false }, [{ id: "root" }], directory),
).toEqual({ [`${base64Encode(directory)}/root`]: false })
})
@@ -0,0 +1,79 @@
import { base64Encode } from "@opencode-ai/util/encode"
export function acceptKey(sessionID: string, directory?: string) {
if (!directory) return sessionID
return `${base64Encode(directory)}/${sessionID}`
}
export function directoryAcceptKey(directory: string) {
return `${base64Encode(directory)}/*`
}
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
return autoAccept[acceptKey(sessionID, directory)]
}
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
const key = directoryAcceptKey(directory)
return autoAccept[key] ?? false
}
export function relocateAutoAccept(
autoAccept: Record<string, boolean>,
sessions: readonly { id: string }[],
directory: string,
) {
const moves = sessions.flatMap((session) => {
const value = autoAccept[session.id]
if (value === undefined) return []
return [{ source: session.id, target: acceptKey(session.id, directory), value }]
})
if (moves.length === 0) return autoAccept
const next = { ...autoAccept }
for (const move of moves) {
if (next[move.target] === undefined) next[move.target] = move.value
delete next[move.source]
}
return next
}
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
const parent = session.reduce((acc, item) => {
if (item.parentID) acc.set(item.id, item.parentID)
return acc
}, new Map<string, string>())
const seen = new Set([sessionID])
const ids = [sessionID]
for (const id of ids) {
const parentID = parent.get(id)
if (!parentID || seen.has(parentID)) continue
seen.add(parentID)
ids.push(parentID)
}
return ids
}
export function autoRespondsPermission(
autoAccept: Record<string, boolean>,
session: { id: string; parentID?: string }[],
permission: { sessionID: string },
directory?: string,
) {
const value = sessionAutoAccept(autoAccept, session, permission, directory)
if (value !== undefined) return value
return directory ? isDirectoryAutoAccepting(autoAccept, directory) : false
}
export function sessionAutoAccept(
autoAccept: Record<string, boolean>,
session: { id: string; parentID?: string }[],
permission: { sessionID: string },
directory?: string,
) {
return sessionLineage(session, permission.sessionID)
.map((id) => accepted(autoAccept, id, directory))
.find((item): item is boolean => item !== undefined)
}
+5 -4
View File
@@ -5,7 +5,7 @@ import { useParams } from "@solidjs/router"
import { showToast } from "@/shell/notifications/toast"
import { useServerSDK } from "@/runtime/server/client"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { usePermission } from "@/session/requests/permission"
import { useWorkspaceLocation } from "@/workspaces/location"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { useData } from "@/runtime/server/current"
@@ -16,7 +16,7 @@ export function createSessionRequestModel() {
const serverSDK = useServerSDK()
const data = useData()
const language = useLanguage()
const settings = useSettings()
const permission = usePermission()
createEffect(() => {
const id = params.id
if (!id || serverSDK.connection.status() !== "connected") return
@@ -32,8 +32,9 @@ export function createSessionRequestModel() {
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
if (settings.permissions.autoApprove()) return undefined
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id)
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id, (item) => {
return !permission.autoResponds(item, sdk().directory)
})
})
const blocked = createMemo(() => {
@@ -0,0 +1,3 @@
import { useServer } from "@/runtime/server/current"
export const usePermission = () => useServer().ctx.permission
@@ -0,0 +1,348 @@
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { ServerSDK } from "@/runtime/server/client"
import type { ServerSync } from "@/runtime/server/sync"
import type { Data } from "@opencode-ai/client/solid"
import { useParams, useSearchParams } from "@solidjs/router"
import { decode64 } from "@/runtime/persistence/base64"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { type DraftTab, useTabs } from "@/shell/tabs/tabs"
import { useSettings } from "@/settings/model"
import { requireServerKey } from "@/shell/routes/session"
import { ServerScope } from "@/runtime/server/scope"
import {
acceptKey,
directoryAcceptKey,
isDirectoryAutoAccepting,
autoRespondsPermission,
relocateAutoAccept,
sessionAutoAccept,
} from "./auto-respond"
type PermissionRespondFn = (input: {
sessionID: string
permissionID: string
response: "once" | "always" | "reject"
directory?: string
}) => void
function isNonAllowRule(rule: unknown) {
if (!rule) return false
if (typeof rule === "string") return rule !== "allow"
if (typeof rule !== "object") return false
if (Array.isArray(rule)) return false
for (const action of Object.values(rule)) {
if (action !== "allow") return true
}
return false
}
function hasPermissionPromptRules(permission: unknown) {
if (!permission) return false
if (typeof permission === "string") return permission !== "allow"
if (typeof permission !== "object") return false
if (Array.isArray(permission)) return false
const config = permission as Record<string, unknown>
return Object.values(config).some(isNonAllowRule)
}
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync; data: Data }) {
const [store, setStore, _, ready] = persisted(
{
...Persist.serverGlobal(input.sdk.scope, "permission"),
...(input.sdk.scope === ServerScope.local ? { previousKey: "permission.v3" } : {}),
migrate(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value
const data = value as Record<string, unknown>
if (data.autoAccept) return value
return {
...data,
autoAccept:
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
? data.autoAcceptEdits
: {},
}
},
},
createStore({
autoAccept: {} as Record<string, boolean>,
}),
)
function enableConfiguredDirectory(directory: string) {
if (meta.disposed || !ready()) return
const [childStore] = input.sync.child(directory)
if (childStore.config.permission !== "allow") return
const key = directoryAcceptKey(directory)
if (store.autoAccept[key] !== undefined) return
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
}
const MAX_RESPONDED = 1000
const RESPONDED_TTL_MS = 60 * 60 * 1000
const responded = new Map<string, number>()
const enableVersion = new Map<string, number>()
const meta = { disposed: false }
function pruneResponded(now: number) {
for (const [id, ts] of responded) {
if (now - ts < RESPONDED_TTL_MS) break
responded.delete(id)
}
for (const id of responded.keys()) {
if (responded.size <= MAX_RESPONDED) break
responded.delete(id)
}
}
const respond: PermissionRespondFn = (request) => {
if (meta.disposed) return
input.sdk.api.permission
.reply({
sessionID: request.sessionID,
requestID: request.permissionID,
reply: request.response,
})
.catch(() => {
responded.delete(request.permissionID)
})
}
const list = async (directory: string) => {
return input.sdk.api.permission.request.list({ location: { directory } }).then((result) => result.data)
}
function respondOnce(permission: PermissionRequest, directory?: string) {
const now = Date.now()
const hit = responded.has(permission.id)
responded.delete(permission.id)
responded.set(permission.id, now)
pruneResponded(now)
if (hit) return
respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "once",
directory,
})
}
function sessions(_directory?: string) {
return input.data.session.list()
}
function autoAccept(directory?: string) {
if (!directory) return store.autoAccept
const next = relocateAutoAccept(store.autoAccept, sessions(directory), directory)
if (next !== store.autoAccept) setStore("autoAccept", reconcile(next))
return next
}
function isAutoAccepting(sessionID: string, directory?: string) {
return autoRespondsPermission(autoAccept(directory), sessions(directory), { sessionID }, directory)
}
function isAutoAcceptingDirectory(directory: string) {
return isDirectoryAutoAccepting(store.autoAccept, directory)
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
return autoRespondsPermission(autoAccept(directory), sessions(directory), permission, directory)
}
function isPending(permission: PermissionRequest) {
const pending = input.data.session.permission.list(permission.sessionID)
return pending === undefined || pending.some((item) => item.id === permission.id)
}
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
const override = sessionAutoAccept(autoAccept(directory), sessions(directory), permission, directory)
if (override !== undefined) return override
const loaded = new Set<string>()
while (!loaded.has(input.data.session.root(permission.sessionID))) {
const root = input.data.session.root(permission.sessionID)
loaded.add(root)
if (input.data.session.get(root)) break
await input.data.session.sync(root).catch(() => undefined)
}
if (meta.disposed || !input.data.session.get(permission.sessionID)) return false
return shouldAutoRespond(permission, directory)
}
async function respondPending(
permission: PermissionRequest,
directory?: string,
current: () => boolean = () => true,
) {
if (!current() || !isPending(permission)) return
if (!(await shouldAutoRespondResolved(permission, directory))) return
if (meta.disposed || !current() || !isPending(permission)) return
respondOnce(permission, directory)
}
function bumpEnableVersion(sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
const next = (enableVersion.get(key) ?? 0) + 1
enableVersion.set(key, next)
return next
}
const unsubscribe = input.sdk.event.on("permission.asked", (event) => {
if (ready()) {
void respondPending(event.data, event.location?.directory)
return
}
void ready.promise?.then(() => {
if (meta.disposed) return
void respondPending(event.data, event.location?.directory)
})
})
onCleanup(() => {
meta.disposed = true
unsubscribe()
})
function enableDirectory(directory: string) {
if (meta.disposed) return
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
list(directory)
.then((permissions) => {
if (meta.disposed) return
if (!isAutoAcceptingDirectory(directory)) return
for (const permission of permissions) {
void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory))
}
})
.catch(() => undefined)
}
function disableDirectory(directory: string) {
if (meta.disposed) return
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = false
}),
)
}
function enable(sessionID: string, directory: string) {
if (meta.disposed) return
const key = acceptKey(sessionID, directory)
const version = bumpEnableVersion(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
delete draft.autoAccept[sessionID]
}),
)
list(directory)
.then((permissions) => {
if (meta.disposed) return
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
for (const permission of permissions) {
void respondPending(
permission,
directory,
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
)
}
})
.catch(() => undefined)
}
function disable(sessionID: string, directory?: string) {
if (meta.disposed) return
bumpEnableVersion(sessionID, directory)
const key = directory ? acceptKey(sessionID, directory) : sessionID
setStore(
produce((draft) => {
draft.autoAccept[key] = false
if (!directory) return
delete draft.autoAccept[sessionID]
}),
)
}
const api = {
ready: () => !meta.disposed && ready(),
respond,
autoResponds(permission: PermissionRequest, directory?: string) {
if (meta.disposed) return false
return shouldAutoRespond(permission, directory)
},
isAutoAccepting(sessionID: string, directory?: string) {
if (meta.disposed) return false
return isAutoAccepting(sessionID, directory)
},
isAutoAcceptingDirectory(directory: string) {
if (meta.disposed) return false
return isAutoAcceptingDirectory(directory)
},
toggleAutoAccept(sessionID: string, directory: string) {
if (meta.disposed) return
if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory)
return
}
enable(sessionID, directory)
},
toggleAutoAcceptDirectory(directory: string) {
if (meta.disposed) return
if (isAutoAcceptingDirectory(directory)) {
disableDirectory(directory)
return
}
enableDirectory(directory)
},
enableAutoAccept(sessionID: string, directory: string) {
if (meta.disposed) return
if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory)
},
disableAutoAccept(sessionID: string, directory?: string) {
if (meta.disposed) return
disable(sessionID, directory)
},
isPermissionAllowAll(directory: string) {
if (meta.disposed) return false
const [childStore] = input.sync.child(directory)
return childStore.config.permission === "allow"
},
}
return {
...api,
api,
sync: input.sync,
enableConfiguredDirectory,
permissionsEnabled(directory: string) {
if (meta.disposed) return false
const [childStore] = input.sync.child(directory)
return hasPermissionPromptRules(childStore.config.permission)
},
}
}
@@ -1,15 +0,0 @@
import { describe, expect, test } from "bun:test"
import { removedSessionIDs } from "./session-domain"
describe("removedSessionIDs", () => {
test("includes all descendants without unrelated sessions", () => {
const sessions = [
{ id: "root" },
{ id: "child", parentID: "root" },
{ id: "grandchild", parentID: "child" },
{ id: "other" },
]
expect([...removedSessionIDs(sessions, "root")]).toEqual(["root", "child", "grandchild"])
})
})
@@ -17,19 +17,3 @@ export function selectVisibleSessionUserMessages(messages: SessionMessageUser[],
if (!revertMessageID) return messages
return messages.filter((message) => message.id < revertMessageID)
}
export function removedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) {
const removed = new Set([sessionID])
const byParent = Map.groupBy(
sessions.filter((session) => session.parentID),
(session) => session.parentID!,
)
const visit = (id: string) =>
byParent.get(id)?.forEach((child) => {
if (removed.has(child.id)) return
removed.add(child.id)
visit(child.id)
})
visit(sessionID)
return removed
}
@@ -31,3 +31,19 @@ export function timelineChildTitle(input: {
if (input.taskDescription) return input.taskDescription
return input.title?.replace(/\s+\(@[^)]+ subagent\)$/, "") || input.fallback
}
export function timelineRemovedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) {
const removed = new Set([sessionID])
const byParent = Map.groupBy(
sessions.filter((session) => session.parentID),
(session) => session.parentID!,
)
const visit = (id: string) =>
byParent.get(id)?.forEach((child) => {
if (removed.has(child.id)) return
removed.add(child.id)
visit(child.id)
})
visit(sessionID)
return removed
}
@@ -11,13 +11,17 @@ import { useSettings } from "@/settings/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useTabs } from "@/shell/tabs/tabs"
import type { SessionModel } from "@/session/model"
import { removedSessionIDs } from "@/session/session-domain"
import { useServerSDK } from "@/runtime/server/client"
import { sessionHref } from "@/shell/routes/session"
import { sessionTitle } from "@/session/title"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { showToast } from "@/shell/notifications/toast"
import { applyTimelineMessageHandoff, timelineChildTitle, visibleTimelineMessages } from "./controller-projection"
import {
applyTimelineMessageHandoff,
timelineChildTitle,
timelineRemovedSessionIDs,
visibleTimelineMessages,
} from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { useServer } from "@/runtime/server/current"
import { getSessionMessageHandoff } from "@/session/handoff"
@@ -68,14 +72,6 @@ export function createTimelineController(input: { session: TimelineSessionSource
input.session.data.info()?.revert?.messageID,
)
})
const pendingUserMessageIDs = createMemo(() => {
const id = input.session.identity.sessionID()
return new Set(
(id ? data.session.pending.list(id) : []).flatMap((item) =>
item.type === "user" && item.delivery === "steer" ? [item.id] : [],
),
)
})
const titleValue = createMemo(() => input.session.data.info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("command.session.new"))
const parentMessages = createMemo(() => {
@@ -105,7 +101,6 @@ export function createTimelineController(input: { session: TimelineSessionSource
sessionMessages: projectedMessages,
status: input.session.data.status,
showReasoningSummaries: settings.general.showReasoningSummaries,
pendingUserMessageIDs,
})
const [pending, setPending] = createStore({ rename: false })
@@ -176,7 +171,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
return false
})
if (!success) return false
const removed = removedSessionIDs(data.session.list(), id)
const removed = timelineRemovedSessionIDs(data.session.list(), id)
void navigateAfterRemoval(id, session.parentID, next?.id)
notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: [...removed] })
return true
@@ -8,7 +8,6 @@ export function createTimelineProjection(input: {
sessionMessages: Accessor<SessionMessageInfo[]>
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
pendingUserMessageIDs: Accessor<ReadonlySet<string>>
}) {
const sessionMessageByID = createMemo(
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
@@ -76,12 +75,7 @@ export function createTimelineProjection(input: {
return result
})
const projection = createMemo(() =>
Timeline.constructSessionMessageRows(
input.sessionMessages(),
input.showReasoningSummaries(),
input.status(),
input.pendingUserMessageIDs(),
),
Timeline.constructSessionMessageRows(input.sessionMessages(), input.showReasoningSummaries(), input.status()),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
+4 -1
View File
@@ -1,3 +1,4 @@
import { useParams } from "@solidjs/router"
import { onCleanup } from "solid-js"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
@@ -5,6 +6,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
export function useSettingsDialog(defaultValue?: string) {
const dialog = useDialog()
const params = useParams<{ id?: string }>()
let run = 0
let dead = false
@@ -14,9 +16,10 @@ export function useSettingsDialog(defaultValue?: string) {
return () => {
const current = ++run
const sessionID = params.id
void import("@/settings/shell").then((module) => {
if (dead || run !== current) return
void dialog.show(() => <module.DialogSettings defaultValue={defaultValue} />)
void dialog.show(() => <module.DialogSettings sessionID={sessionID} defaultValue={defaultValue} />)
})
}
}
@@ -1,6 +1,7 @@
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { usePermission } from "@/session/requests/permission"
import {
monoDefault,
monoFontFamily,
@@ -16,11 +17,43 @@ import {
import { playSoundById, SOUND_OPTIONS } from "@/shell/notifications/sound"
import { createSoundPreviewController, type ShellOption } from "./behavior"
import { ServerConnection } from "@/runtime/server/registry"
import { useServerCtx } from "@/runtime/server/runtime"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
export { createShellOptions, createSoundPreviewController } from "./behavior"
export type { ShellOption, ShellSelectOption } from "./behavior"
export function createPermissionScopeController(
server: Accessor<ServerConnection.Any | undefined>,
sessionID: Accessor<string | undefined>,
) {
const serverCtx = useServerCtx(server)
const permission = () => serverCtx()?.permission
const directory = createMemo(() => {
const s = server()
const id = sessionID()
if (!s || !id) return undefined
return serverCtx()?.data.session.get(id)?.location.directory
})
return {
accepting: createMemo(() => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return false
return permission()?.isAutoAccepting(id, dir)
}),
enabled: createMemo(() => !!directory()),
set: (checked: boolean) => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return
if (checked) return permission()?.enableAutoAccept(id, dir)
permission()?.disableAutoAccept(id, dir)
},
}
}
export function createShellSettingsController(server: Accessor<ServerConnection.Any | undefined>) {
const serverCtx = useServerCtx(server)
const [shells] = createResource(
@@ -139,6 +172,7 @@ export function createSoundSettingsController() {
}
}
export type PermissionScopeController = ReturnType<typeof createPermissionScopeController>
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
+12 -5
View File
@@ -13,9 +13,11 @@ import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import {
createAppearanceSettingsController,
createPermissionScopeController,
createShellOptions,
createShellSettingsController,
type AppearanceSettingsController,
type PermissionScopeController,
type ShellSettingsController,
} from "./controllers"
import "@/settings/settings.css"
@@ -45,9 +47,8 @@ const fontSettings = {
input: "setTerminal",
},
} as const
const AutoApprovePermissionsSetting: Component = () => {
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
const language = useLanguage()
const settings = useSettings()
return (
<SettingsRow
title={language.t("command.permissions.autoaccept.enable")}
@@ -55,8 +56,9 @@ const AutoApprovePermissionsSetting: Component = () => {
>
<div data-action="settings-auto-accept-permissions">
<Switch
checked={settings.permissions.autoApprove()}
onChange={(checked) => settings.permissions.setAutoApprove(checked)}
checked={props.controller.accepting()}
disabled={!props.controller.enabled()}
onChange={props.controller.set}
/>
</div>
</SettingsRow>
@@ -260,6 +262,7 @@ const LanguageSetting = () => {
}
export const SettingsGeneral: Component<{
sessionID?: string
server?: ServerConnection.Any
}> = (props) => {
const language = useLanguage()
@@ -267,6 +270,10 @@ export const SettingsGeneral: Component<{
const settings = useSettings()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
const permissionScope = createPermissionScopeController(
() => props.server,
() => props.sessionID,
)
const shell = createShellSettingsController(() => props.server)
const desktop = createMemo(() => platform.platform === "desktop")
@@ -290,7 +297,7 @@ export const SettingsGeneral: Component<{
<LanguageSetting />
<WorkspaceDestinationSetting />
<AutoApprovePermissionsSetting />
<PermissionScopeSetting controller={permissionScope} />
<ShellSetting controller={shell} />
<TerminalPlacementSetting />
-4
View File
@@ -724,8 +724,6 @@
display: flex;
flex-direction: column;
gap: 0;
max-height: 480px;
overflow-y: auto;
padding: 20px;
border-radius: 6px;
background-color: var(--v2-background-bg-base);
@@ -887,8 +885,6 @@
}
.settings-workspaces-inventory [data-component="settings-list"] {
max-height: none;
overflow-y: visible;
padding: 14px;
}
+3 -2
View File
@@ -23,6 +23,7 @@ import { ServerConnection, useServers } from "@/runtime/server/registry"
import "@/settings/settings.css"
export const DialogSettings: Component<{
sessionID?: string
defaultValue?: string
}> = (props) => {
const language = useLanguage()
@@ -67,7 +68,7 @@ export const DialogSettings: Component<{
})
const showProviders = () => {
void dialog.show(() => <DialogSettings defaultValue="providers" />)
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
}
return (
@@ -143,7 +144,7 @@ export const DialogSettings: Component<{
</Tabs.List>
<Tabs.Content value="general" class="settings-panel">
<SettingsGeneral server={server()} />
<SettingsGeneral server={server()} sessionID={props.sessionID} />
</Tabs.Content>
<Tabs.Content value="appearance" class="settings-panel">
<SettingsAppearance />
@@ -2,15 +2,14 @@ import { createMemo, type Accessor } from "solid-js"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { ServerConnection } from "@/runtime/server/registry"
import { useSettings } from "@/settings/model"
export function useSessionTabAvatarState(
server: Accessor<ServerConnection.Key>,
directory: Accessor<string>,
sessionId: Accessor<string>,
root?: Accessor<boolean>,
) {
const global = useGlobal()
const settings = useSettings()
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
const serverCtx = useServerCtx(connection)
const sessions = createMemo(() => {
@@ -24,10 +23,12 @@ export function useSessionTabAvatarState(
})
})
const hasPermissions = createMemo(() => {
if (settings.permissions.autoApprove()) return false
const ctx = serverCtx()
if (!ctx) return false
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId())
const permission = ctx.permission
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId(), (item) => {
return !permission.autoResponds(item, directory())
})
})
const hasQuestions = createMemo(() => {
const data = serverCtx()?.data
@@ -15,6 +15,7 @@ export function SessionTabAvatar(props: {
}) {
const state = useSessionTabAvatarState(
() => props.server,
() => props.directory,
() => props.sessionId,
)
return (
@@ -9,10 +9,10 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { decode64 } from "@/runtime/persistence/base64"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { playSoundByIdOnce } from "@/shell/notifications/sound"
import { playSoundById } from "@/shell/notifications/sound"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
import { type DraftTab, useTabs } from "@/shell/tabs/tabs"
import { requireServerKey, sessionHref } from "@/shell/routes/session"
import type { ServerScope } from "@/runtime/server/scope"
import { useServer } from "@/runtime/server/current"
@@ -112,7 +112,6 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
const tabs = useTabs()
const empty: Notification[] = []
const [store, setStore, _, ready] = persisted(
@@ -216,17 +215,14 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
dispatchEvent(new PopStateEvent("popstate"))
}
const handleSessionIdle = (sessionID: string, eventID: string, time: number) => {
const handleSessionIdle = (sessionID: string, time: number) => {
void lookup(sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
if (session.parentID) return
if (
sessionIDHasOpenTab(tabs.store, input.key, sessionID) &&
settings.sounds.agentEnabled()
) {
void playSoundByIdOnce(settings.sounds.agent(), `${input.key}\0${eventID}`)
if (settings.sounds.agentEnabled()) {
void playSoundById(settings.sounds.agent())
}
append({
@@ -249,18 +245,14 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const handleSessionError = (
sessionID: string,
error: ErrorNotification["error"],
eventID: string,
time: number,
) => {
void lookup(sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
if (
sessionIDHasOpenTab(tabs.store, input.key, sessionID) &&
settings.sounds.errorsEnabled()
) {
void playSoundByIdOnce(settings.sounds.errors(), `${input.key}\0${eventID}`)
if (settings.sounds.errorsEnabled()) {
void playSoundById(settings.sounds.errors())
}
append({
@@ -286,10 +278,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const time = Date.now()
if (event.type === "session.execution.failed") {
handleSessionError(event.data.sessionID, event.data.error, event.id, time)
handleSessionError(event.data.sessionID, event.data.error, time)
return
}
handleSessionIdle(event.data.sessionID, event.id, time)
handleSessionIdle(event.data.sessionID, time)
})
onCleanup(() => {
meta.disposed = true
@@ -74,9 +74,6 @@ function getLoads() {
}
const cache = new Map<SoundID, Promise<string | undefined>>()
const claimed = new Set<string>()
const CLAIMED_STORAGE_KEY = "opencode:notification-sounds"
const MAX_CLAIMED = 500
export function soundSrc(id: string | undefined) {
const loads = getLoads()
@@ -103,34 +100,3 @@ export function playSound(src: string | undefined) {
export function playSoundById(id: string | undefined) {
return soundSrc(id).then((src) => playSound(src))
}
export async function playSoundByIdOnce(id: string | undefined, eventID: string) {
const play = async () => {
if (!claim(eventID)) return
await playSoundById(id)
}
if (typeof navigator === "undefined" || !navigator.locks) return play()
await navigator.locks.request(`${CLAIMED_STORAGE_KEY}:${eventID}`, play)
}
function claim(eventID: string) {
if (claimed.has(eventID)) return false
if (typeof localStorage !== "undefined") {
try {
const value: unknown = JSON.parse(localStorage.getItem(CLAIMED_STORAGE_KEY) ?? "[]")
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
if (events.includes(eventID)) {
claimed.add(eventID)
return false
}
localStorage.setItem(CLAIMED_STORAGE_KEY, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
} catch {
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
}
}
claimed.add(eventID)
return true
}
+1 -10
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { migrateTabs } from "./migration"
import type { ServerConnection } from "@/runtime/server/registry"
@@ -47,15 +47,6 @@ test("session tab identity stays rooted while its href follows the child route",
expect(tabHref(child)).toContain("/session/child")
})
test("finds open root and routed session tabs", () => {
const tabs = [{ ...sessionTab("root"), routeSessionId: "child" }]
expect(sessionIDHasOpenTab(tabs, server, "root")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "child")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "closed")).toBe(false)
expect(sessionIDHasOpenTab(tabs, "other" as ServerConnection.Key, "root")).toBe(false)
})
describe("tab memory", () => {
test("keeps state until its tab is removed", () => {
createRoot((dispose) => {
+1 -5
View File
@@ -51,15 +51,11 @@ export const tabKey = (tab: Tab) =>
tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${sessionHref(tab.server, tab.sessionId)}`
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: SessionInfo) {
return sessionIDHasOpenTab(tabs, server, session.id)
}
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return tabs.some(
(tab) =>
tab.type === "session" &&
tab.server === server &&
(tab.sessionId === sessionID || tab.routeSessionId === sessionID),
(tab.sessionId === session.id || tab.routeSessionId === session.id),
)
}
@@ -1,14 +0,0 @@
import { describe, expect, test } from "bun:test"
import { windowsMenuAccelerator } from "./windows-menu"
describe("Windows app menu", () => {
test("resolves the new window accelerator", () => {
expect(windowsMenuAccelerator(new KeyboardEvent("keydown", { key: "N", ctrlKey: true, shiftKey: true }))).toBe(
"window.new",
)
})
test("ignores the accelerator without its modifiers", () => {
expect(windowsMenuAccelerator(new KeyboardEvent("keydown", { key: "N" }))).toBeUndefined()
})
})
@@ -1,10 +1,9 @@
import { makeEventListener } from "@solid-primitives/event-listener"
import { For, onMount, type JSX } from "solid-js"
import { For, type JSX } from "solid-js"
import { Menu } from "@opencode-ai/ui/menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { matchKeybind, parseKeybind, useCommand } from "@/shell/commands/command"
import { useCommand } from "@/shell/commands/command"
import {
DESKTOP_MENU,
desktopMenuVisible,
@@ -14,15 +13,6 @@ import {
import { usePlatform } from "@/runtime/platform/platform"
import { useLanguage } from "@/runtime/i18n/language"
const accelerators = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).flatMap((entry) => {
if (entry.type === "separator" || !entry.action || !entry.accelerator?.windows) return []
return [{ action: entry.action, keybind: parseKeybind(entry.accelerator.windows) }]
})
export function windowsMenuAccelerator(event: KeyboardEvent) {
return accelerators.find((entry) => matchKeybind(entry.keybind, event))?.action
}
export function WindowsAppMenu(props: {
command: ReturnType<typeof useCommand>
platform: ReturnType<typeof usePlatform>
@@ -60,22 +50,6 @@ export function WindowsAppMenu(props: {
if (entry.href) props.platform.openExternal(entry.href)
}
onMount(() => {
makeEventListener(
document,
"keydown",
(event) => {
if (event.defaultPrevented) return
const action = windowsMenuAccelerator(event)
if (!action) return
event.preventDefault()
event.stopPropagation()
runAction(action)
},
{ capture: true },
)
})
return (
<Menu appearance="standard" gutter={4} modal={false} placement="bottom-start">
<div
+1 -5
View File
@@ -185,11 +185,7 @@ async function compileExecutable(item: (typeof allTargets)[number]) {
headers: { Accept: "application/octet-stream", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
})
if (!response.ok) throw new Error(`Failed to download ${name} from Bun release ${release}: ${response.status}`)
// Stream to disk instead of `Bun.write(archive, response)`: passing the Response object
// hangs forever if it gets GC'd mid-download (https://github.com/oven-sh/bun/issues/40278).
const sink = Bun.file(archive).writer()
for await (const chunk of response.body!) await sink.write(chunk)
await sink.end()
await Bun.write(archive, response)
await $`unzip -oq ${archive} -d ${cache}`
await rm(archive)
return executable
-32
View File
@@ -1,5 +1,4 @@
import { Argument, Flag } from "effect/unstable/cli"
import { Schema } from "effect"
import { Spec } from "../framework/spec"
declare const OPENCODE_CLI_NAME: string | undefined
@@ -188,37 +187,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
description: "List all available models",
params: ServerParams,
}),
Spec.make("stats", {
description: "Show shareable usage statistics",
params: {
...ServerParams,
days: Flag.integer("days").pipe(
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
Flag.withDescription("Show the last N days; 0 means today"),
Flag.optional,
),
year: Flag.integer("year").pipe(
Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1970, maximum: 9_999 }))),
Flag.withDescription("Show a calendar year"),
Flag.optional,
),
all: Flag.boolean("all").pipe(Flag.withDescription("Show lifetime statistics"), Flag.withDefault(false)),
project: Flag.string("project").pipe(
Flag.withDescription('Filter by project ID, or use "." for the current project'),
Flag.optional,
),
models: Flag.boolean("models").pipe(Flag.withDescription("Show model usage"), Flag.withDefault(false)),
tools: Flag.boolean("tools").pipe(Flag.withDescription("Show tool reliability"), Flag.withDefault(false)),
cost: Flag.boolean("cost").pipe(Flag.withDescription("Show cost and token details"), Flag.withDefault(false)),
full: Flag.boolean("full").pipe(Flag.withDescription("Show every detailed section"), Flag.withDefault(false)),
limit: Flag.integer("limit").pipe(
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
Flag.withDescription("Number of rows in detailed sections"),
Flag.withDefault(5),
),
json: Flag.boolean("json").pipe(Flag.withDescription("Output statistics as JSON"), Flag.withDefault(false)),
},
}),
Spec.make("export", {
description: "Export session data as JSON",
params: {
-408
View File
@@ -1,408 +0,0 @@
import { ClientError, OpenCode, type SessionStatsInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, Option } from "effect"
import { EOL } from "node:os"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
import { errorMessage } from "../../util/error"
const handler = Effect.fn("cli.stats")(function* (input: Runtime.Input<typeof Commands.commands.stats>) {
const days = Option.getOrUndefined(input.days)
const year = Option.getOrUndefined(input.year)
const project = Option.getOrUndefined(input.project)
if ([days !== undefined, year !== undefined, input.all].filter(Boolean).length > 1)
yield* Effect.fail(new Error("--days, --year, and --all cannot be combined"))
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
const range = statsRange({ days, year, all: input.all })
const projectID =
project === "."
? yield* request(server.endpoint.url, (signal) =>
client.location
.get({ location: { directory: process.cwd() } }, { signal })
.then((location) => location.project.id),
)
: project
const details = input.models || input.tools || input.cost || input.full
const stats = yield* request(server.endpoint.url, (signal) =>
client.session.stats(
{
from: range.from,
to: range.to,
project: projectID,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
tools: input.json || input.tools || input.full ? "detail" : details ? "none" : "summary",
},
{ signal },
),
)
const output = input.json
? JSON.stringify(stats, null, 2)
: renderStats(stats, {
label: range.label,
scope: project === undefined ? "all projects" : project === "." ? "current project" : "selected project",
models: input.models || input.full,
tools: input.tools || input.full,
cost: input.cost || input.full,
limit: input.limit,
color: process.stdout.isTTY && process.env.NO_COLOR === undefined,
width: process.stdout.columns ?? 80,
})
process.stdout.write(output + EOL)
})
export default Runtime.handler(Commands.commands.stats, (input) =>
handler(input).pipe(
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(errorMessage(error) + EOL)
process.exitCode = 1
}),
),
),
)
export function request<A>(url: string, run: (signal: AbortSignal) => Promise<A>) {
return Effect.tryPromise({
try: () => run(AbortSignal.timeout(30_000)),
catch: (cause) =>
cause instanceof ClientError && cause.reason === "Transport"
? new Error(`Could not reach server at ${url}`, { cause })
: cause,
})
}
type RenderOptions = {
label: string
scope: string
models: boolean
tools: boolean
cost: boolean
limit: number
color: boolean
width: number
}
const colors = terminalPalette()
export function renderStats(stats: SessionStatsInfo, options: RenderOptions) {
const totalTokens = tokenTotal(stats.tokens)
const toolTotals = stats.tools.mode === "none" ? undefined : stats.tools.totals
const terminalTools = toolTotals ? toolTotals.succeeded + toolTotals.failed : 0
const toolRate = !toolTotals || terminalTools === 0 ? undefined : (toolTotals.succeeded / terminalTools) * 100
const primary = `1;${colors.primary}`
const sessionLine = [
metricCount(stats.sessions, "session", options.color),
stats.subagents > 0 ? metricCount(stats.subagents, "subagent", options.color) : undefined,
]
.filter((value) => value !== undefined)
.join(" · ")
const toolSummary = !toolTotals
? "tool stats unavailable"
: toolRate === undefined
? "no tool calls"
: `${style(formatPercent(toolRate), primary, options.color)} tool success`
const details = options.models || options.tools || options.cost
const empty = stats.sessions === 0 && stats.prompts === 0 && stats.steps === 0
const heading = `${style("opencode stats", primary, options.color)} ${style(`· ${options.label} · ${options.scope}`, "2", options.color)}`
const lines = details
? [style(`${options.label} · ${options.scope}`, "2", options.color)]
: empty
? [
heading,
"",
style("no activity in this range", "2", options.color),
"",
style("opencode.ai", "2", options.color),
]
: [
heading,
"",
...renderActivity(stats.activity, stats.range.from, stats.range.to, options.color, options.width),
"",
sessionLine,
`${metricCount(stats.prompts, "prompt", options.color)} · ${metricCount(stats.steps, "step", options.color)} · ${metricCount(totalTokens, "token", options.color)}`,
`${toolSummary} · ${metricCount(stats.activeDays, "active day", options.color)} · best streak ${style(stats.streak.toString(), primary, options.color)} day${stats.streak === 1 ? "" : "s"}`,
"",
style("opencode.ai", "2", options.color),
]
if (options.cost) lines.push(...(lines.length > 0 ? [""] : []), ...renderCost(stats))
if (options.models)
lines.push(...(lines.length > 0 ? [""] : []), ...renderModels(stats, options.limit, options.width))
if (options.tools) lines.push(...(lines.length > 0 ? [""] : []), ...renderTools(stats, options.limit, options.width))
return lines.join(EOL)
}
function statsRange(input: { days?: number; year?: number; all: boolean }) {
const now = new Date()
const to = now.getTime() + 1
if (input.all) return { from: undefined, to, label: "all time" }
if (input.days !== undefined) {
const from = new Date(now.getFullYear(), now.getMonth(), now.getDate())
from.setDate(from.getDate() - Math.max(0, input.days - 1))
return {
from: from.getTime(),
to,
label: input.days === 0 || input.days === 1 ? "today" : `last ${input.days} days`,
}
}
const year = input.year ?? now.getFullYear()
return {
from: new Date(year, 0, 1).getTime(),
to: year === now.getFullYear() ? to : new Date(year + 1, 0, 1).getTime(),
label: year === now.getFullYear() ? `${year} so far` : year.toString(),
}
}
function renderActivity(
activity: SessionStatsInfo["activity"],
from: number,
to: number,
color: boolean,
width: number,
) {
const values = new Map(activity.map((day) => [day.date, day.steps]))
const rangeStart = dateOrdinal(new Date(from))
const rangeEnd = dateOrdinal(new Date(to - 1))
const end = new Date(to - 1)
end.setHours(12, 0, 0, 0)
end.setDate(end.getDate() + (7 - mondayIndex(end) - 1))
const start = new Date(from)
start.setHours(12, 0, 0, 0)
start.setDate(start.getDate() - mondayIndex(start))
const maxWeeks = Math.max(1, Math.min(53, width - 4))
const totalWeeks = Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1
const latest = new Date(end)
latest.setDate(latest.getDate() - (maxWeeks - 1) * 7)
if (start < latest) start.setTime(latest.getTime())
const active = [...values.values()].filter((value) => value > 0)
const levels = [...new Set(active)].sort((a, b) => a - b)
const weekStarts = Array.from({ length: Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1 }, (_, week) => {
const date = new Date(start)
date.setDate(date.getDate() + week * 7)
return date
})
const weeks = weekStarts.map((week) =>
Array.from({ length: 7 }, (_, day) => {
const date = new Date(week)
date.setDate(date.getDate() + day)
const ordinal = dateOrdinal(date)
if (ordinal < rangeStart || ordinal > rangeEnd) return " "
return activityGlyph(values.get(dateKey(date)) ?? 0, levels, color)
}),
)
const weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
return [
style(totalWeeks > maxWeeks ? `activity · last ${maxWeeks} weeks` : "activity", `1;${colors.primary}`, color),
` ${style(monthLabels(weekStarts), "2", color)}`,
...weekdays.flatMap((label, day) => [
`${style(label, "2", color)} ${weeks.map((week) => week[day]).join("")}`,
...(day === weekdays.length - 1 ? [] : [""]),
]),
"",
` ${style("less", "2", color)} ${[0, 1, 2, 3, 4].map((level) => paintActivity(level, color)).join("")} ${style("more", "2", color)}`,
]
}
function renderCost(stats: SessionStatsInfo) {
const input = stats.tokens.input + stats.tokens.cache.read + stats.tokens.cache.write
const cached = input === 0 ? 0 : (stats.tokens.cache.read / input) * 100
return [
"COST & TOKENS",
row("cost", `$${stats.cost.toFixed(2)}`),
row("input", formatNumber(stats.tokens.input)),
row("output", formatNumber(stats.tokens.output)),
row("reasoning", formatNumber(stats.tokens.reasoning)),
row("cache read", formatNumber(stats.tokens.cache.read)),
row("cache write", formatNumber(stats.tokens.cache.write)),
row("cached input", formatPercent(cached)),
]
}
function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
if (stats.models.length === 0) return ["MODELS", " no model usage"]
const models = stats.models.slice(0, limit)
const more = stats.models.length - models.length
if (width < 68)
return [
"MODELS",
...models.flatMap((item) => [
truncate(
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
width,
),
` ${formatNumber(tokenTotal(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
]),
...(more > 0 ? ["", `+${more.toLocaleString("en-US")} more model${more === 1 ? "" : "s"}`] : []),
]
return [
"MODELS",
tableHeader("model", "tokens", "steps", "cost"),
...models.map((item) =>
tableRow(
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
formatNumber(tokenTotal(item.tokens)),
formatNumber(item.steps),
`$${item.cost.toFixed(2)}`,
),
),
...(more > 0 ? ["", `+${more.toLocaleString("en-US")} more model${more === 1 ? "" : "s"}`] : []),
]
}
function renderTools(stats: SessionStatsInfo, limit: number, width: number) {
if (stats.tools.mode !== "detail") return ["TOOL RELIABILITY", " tool details unavailable"]
if (stats.tools.usage.length === 0) return ["TOOL RELIABILITY", " no tool calls"]
const tools = stats.tools.usage.slice(0, limit)
const more = stats.tools.usage.length - tools.length
if (width < 68)
return [
"TOOL RELIABILITY",
...tools.flatMap((tool) => {
const terminal = tool.succeeded + tool.failed
return [
truncate(tool.name, width),
` ${formatNumber(tool.calls)} calls · ${terminal === 0 ? "-" : formatPercent((tool.failed / terminal) * 100)} error · ${tool.durationP50 === undefined ? "-" : formatDuration(tool.durationP50)} p50`,
]
}),
"",
`${formatNumber(stats.tools.totals.succeeded + stats.tools.totals.failed)} finished calls · ${formatNumber(stats.tools.totals.unfinished)} unfinished`,
...(more > 0 ? [`+${more.toLocaleString("en-US")} more tool${more === 1 ? "" : "s"}`] : []),
]
return [
"TOOL RELIABILITY",
tableHeader("tool", "calls", "error", "p50"),
...tools.map((tool) => {
const terminal = tool.succeeded + tool.failed
return tableRow(
tool.name,
formatNumber(tool.calls),
terminal === 0 ? "-" : formatPercent((tool.failed / terminal) * 100),
tool.durationP50 === undefined ? "-" : formatDuration(tool.durationP50),
)
}),
"",
`${formatNumber(stats.tools.totals.succeeded + stats.tools.totals.failed)} finished calls · ${formatNumber(stats.tools.totals.unfinished)} unfinished`,
...(more > 0 ? [`+${more.toLocaleString("en-US")} more tool${more === 1 ? "" : "s"}`] : []),
]
}
function row(label: string, value: string) {
return ` ${label.padEnd(20)}${value}`
}
function tableHeader(label: string, second: string, third: string, fourth: string) {
return tableRow(label, second, third, fourth)
}
function tableRow(label: string, second: string, third: string, fourth: string) {
return `${truncate(label, 34).padEnd(34)}${second.padStart(10)}${third.padStart(12)}${fourth.padStart(12)}`
}
function truncate(value: string, width: number) {
return value.length <= width ? value : value.slice(0, width - 1) + "…"
}
function tokenTotal(tokens: SessionStatsInfo["tokens"]) {
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
function formatNumber(value: number) {
if (value >= 1_000_000_000) return `${trimDecimal(value / 1_000_000_000)}b`
if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}m`
if (value >= 1_000) return `${trimDecimal(value / 1_000)}k`
return Math.round(value).toLocaleString("en-US")
}
function trimDecimal(value: number) {
return value.toFixed(1).replace(/\.0$/, "")
}
function formatPercent(value: number) {
return `${value.toFixed(value >= 10 ? 1 : 2)}%`
}
function formatDuration(value: number) {
if (value < 1_000) return `${Math.round(value)}ms`
return `${trimDecimal(value / 1_000)}s`
}
function metricCount(value: number, noun: string, color: boolean) {
return `${style(formatNumber(value), `1;${colors.primary}`, color)} ${noun}${value === 1 ? "" : "s"}`
}
function style(value: string, code: string, color: boolean) {
return color ? `\x1b[${code}m${value}\x1b[0m` : value
}
function activityGlyph(value: number, levels: number[], color: boolean) {
if (value === 0) return paintActivity(0, color)
const index = levels.indexOf(value)
const level = Math.max(1, Math.ceil(((index + 1) / levels.length) * 4))
return paintActivity(level, color)
}
function paintActivity(level: number, color: boolean) {
const glyph = ["·", "░", "▒", "▓", "█"][level]
if (!color) return glyph
if (level === 0) return `\x1b[2m${glyph}\x1b[22m`
return `\x1b[${colors.activity[level - 1]}m${glyph}\x1b[39m`
}
function terminalPalette() {
const background = Number(process.env.COLORFGBG?.split(";").at(-1))
if (Number.isFinite(background) && background >= 7)
return {
primary: "38;2;59;125;216",
activity: ["38;2;153;169;192", "38;2;122;155;200", "38;2;90;140;208", "38;2;59;125;216"],
}
if (Number.isFinite(background))
return {
primary: "38;2;250;178;131",
activity: ["38;2;117;99;87", "38;2;161;125;102", "38;2;206;152;116", "38;2;250;178;131"],
}
return { primary: "36", activity: ["2;36", "36", "1;36", "1;96"] }
}
function monthLabels(weeks: Date[]) {
const line: string[] = []
weeks.reduce((previous, week, index) => {
const middle = new Date(week)
middle.setDate(middle.getDate() + 3)
const month = middle.getMonth()
if (month === previous) return previous
Intl.DateTimeFormat("en-US", { month: "short" })
.format(middle)
.split("")
.forEach((character, offset) => {
line[index + offset] = character
})
return month
}, -1)
return Array.from({ length: Math.max(weeks.length, line.length) }, (_, index) => line[index] ?? " ")
.join("")
.trimEnd()
}
function mondayIndex(date: Date) {
return (date.getDay() + 6) % 7
}
function dateKey(date: Date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0"),
].join("-")
}
function dateOrdinal(date: Date) {
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000)
}
-1
View File
@@ -43,7 +43,6 @@ const Handlers = Runtime.handlers(Commands, {
remove: () => import("./commands/handlers/plugin/remove"),
},
models: () => import("./commands/handlers/models"),
stats: () => import("./commands/handlers/stats"),
export: () => import("./commands/handlers/export"),
import: () => import("./commands/handlers/import"),
mini: () => import("./commands/handlers/mini"),
@@ -3,16 +3,12 @@ import {
Command,
Connection,
Credential,
Effect,
Integration,
Mcp,
Model,
Plugin,
Provider,
Reference,
Schema,
Skill,
WebSearch,
} from "@opencode-ai/plugin/effect"
import { Tool } from "@opencode-ai/schema/tool"
@@ -22,15 +18,11 @@ const key = Symbol.for("opencode.plugin.v2.effect")
Command,
Connection,
Credential,
Effect,
Integration,
Mcp,
Model,
Plugin,
Provider,
Reference,
Schema,
Skill,
WebSearch,
Tool: { Error: Tool.Error },
}
@@ -4,13 +4,11 @@ import {
Connection,
Credential,
Integration,
Mcp,
Model,
Plugin,
Provider,
Reference,
Skill,
WebSearch,
} from "@opencode-ai/plugin"
const key = Symbol.for("opencode.plugin.v2.promise")
@@ -20,11 +18,9 @@ const key = Symbol.for("opencode.plugin.v2.promise")
Connection,
Credential,
Integration,
Mcp,
Model,
Plugin,
Provider,
Reference,
Skill,
WebSearch,
}
-157
View File
@@ -1,157 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ClientError, type SessionStatsInfo } from "@opencode-ai/client"
import { Effect } from "effect"
import { renderStats, request } from "../src/commands/handlers/stats"
const tools = {
mode: "detail",
totals: { calls: 10, succeeded: 8, failed: 2, unfinished: 0 },
usage: [{ name: "private_tool", calls: 10, succeeded: 8, failed: 2, unfinished: 0, durationP50: 250 }],
} satisfies SessionStatsInfo["tools"]
const stats: SessionStatsInfo = {
range: { from: Date.UTC(2026, 0, 1), to: Date.UTC(2026, 0, 8) },
sessions: 2,
subagents: 1,
prompts: 4,
steps: 6,
tokens: { input: 10_000, output: 2_000, reasoning: 1_000, cache: { read: 5_000, write: 500 } },
cost: 12.34,
tools,
activeDays: 2,
streak: 2,
activity: [
{ date: "2026-01-02", steps: 2 },
{ date: "2026-01-03", steps: 4 },
],
models: [
{
model: { providerID: "anthropic", id: "sonnet" },
steps: 6,
tokens: { input: 10_000, output: 2_000, reasoning: 1_000, cache: { read: 5_000, write: 500 } },
cost: 12.34,
},
],
}
describe("stats rendering", () => {
test("keeps the default card shareable", () => {
const output = renderStats(stats, options())
expect(output).toContain("opencode stats · 2026 so far · all projects")
expect(output).toContain("activity")
expect(output).toMatch(/Mo .*(?:\r?\n){2}Tu/)
expect(output).toMatch(/Su .*(?:\r?\n){2} less/)
expect(output).toContain("less ·░▒▓█ more")
expect(output).toContain("2 sessions · 1 subagent")
expect(output).toContain("80.0% tool success · 2 active days · best streak 2 days")
expect(output).not.toContain("private_tool")
expect(output).not.toContain("$12.34")
})
test("renders only requested detail tables", () => {
const output = renderStats(stats, options({ tools: true, cost: true }))
expect(output).toContain("COST & TOKENS")
expect(output).toContain("TOOL RELIABILITY")
expect(output).toContain("private_tool")
expect(output).toContain("tool")
expect(output).toContain("calls")
expect(output).toContain("cached input 32.3%")
expect(output).not.toContain("opencode stats")
expect(output).not.toContain("activity")
})
test("shows when detail tables omit rows", () => {
const output = renderStats(
{
...stats,
models: [
...stats.models,
{
model: { providerID: "anthropic", id: "haiku" },
steps: 2,
tokens: { input: 2_000, output: 500, reasoning: 0, cache: { read: 1_000, write: 0 } },
cost: 1.25,
},
],
tools: {
mode: "detail",
totals: tools.totals,
usage: [...tools.usage, { name: "grep", calls: 4, succeeded: 4, failed: 0, unfinished: 0, durationP50: 20 }],
},
},
options({ models: true, tools: true, limit: 1 }),
)
expect(output).toContain("+1 more model")
expect(output).toContain("+1 more tool")
})
test("uses the OpenCode palette in color mode", () => {
const output = renderStats(stats, options({ color: true }))
expect(output).toContain("\x1b[1;36m")
expect(output).not.toContain("38;5;45")
})
test("uses compact layouts in narrow terminals", () => {
const output = renderStats(stats, options({ models: true, width: 48 }))
expect(output).toContain("anthropic/sonnet")
expect(output).toContain("18.5k tokens · 6 steps · $12.34")
expect(output.split(/\r?\n/).every((line) => line.length <= 48)).toBe(true)
})
test("renders a concise empty state", () => {
const output = renderStats(
{ ...stats, sessions: 0, subagents: 0, prompts: 0, steps: 0, activeDays: 0, streak: 0, activity: [] },
options(),
)
expect(output).toContain("no activity in this range")
expect(output).not.toContain("less ·░▒▓█ more")
})
test("does not present uncollected tools as zero calls", () => {
const output = renderStats({ ...stats, tools: { mode: "none" } }, options())
expect(output).toContain("tool stats unavailable")
expect(output).not.toContain("no tool calls")
})
test("labels activity when terminal width truncates the requested range", () => {
const output = renderStats(
{ ...stats, range: { from: Date.UTC(2020, 0, 1), to: Date.UTC(2026, 0, 8) } },
options({ width: 20 }),
)
expect(output).toContain("activity · last 16 weeks")
})
})
describe("stats requests", () => {
test("maps transport failures to the server URL", async () => {
const cause = new ClientError("Transport")
const error = await Effect.runPromise(Effect.flip(request("http://localhost:4096", () => Promise.reject(cause))))
expect(error).toEqual(new Error("Could not reach server at http://localhost:4096", { cause }))
})
test("preserves declared API errors", async () => {
const cause = { _tag: "InvalidRequestError", message: "Stats range must end after it starts" } as const
const error = await Effect.runPromise(Effect.flip(request("http://localhost:4096", () => Promise.reject(cause))))
expect(error).toBe(cause)
})
test("preserves unexpected response failures", async () => {
const cause = new ClientError("UnexpectedStatus", { cause: { status: 500 } })
const error = await Effect.runPromise(Effect.flip(request("http://localhost:4096", () => Promise.reject(cause))))
expect(error).toBe(cause)
})
})
function options(input: Partial<Parameters<typeof renderStats>[1]> = {}): Parameters<typeof renderStats>[1] {
return {
label: "2026 so far",
scope: "all projects",
models: false,
tools: false,
cost: false,
limit: 5,
color: false,
width: 80,
...input,
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"lib": ["ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
},
"exclude": ["dist", "dist-node"]
+1 -4
View File
@@ -127,17 +127,14 @@ export const Command = sdk.Command
export const Connection = sdk.Connection
export const Credential = sdk.Credential
export const Integration = sdk.Integration
export const Mcp = sdk.Mcp
export const Model = sdk.Model
export const Plugin = sdk.Plugin
export const Provider = sdk.Provider
export const Reference = sdk.Reference
export const Skill = sdk.Skill
export const WebSearch = sdk.WebSearch`
export const Skill = sdk.Skill`
const effectModule = promiseModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
.concat("\nexport const Effect = sdk.Effect\nexport const Schema = sdk.Schema")
const promisePluginModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const define = sdk.Plugin.define`
-67
View File
@@ -10,7 +10,6 @@ import type { Project } from "@opencode-ai/schema/project"
import type { RelativePath } from "@opencode-ai/schema/schema"
import type { Brand } from "effect"
import type { Model } from "@opencode-ai/schema/model"
import type { DateTime } from "effect"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
@@ -112,71 +111,6 @@ export type SessionListOutput = {
}
export type SessionListOperation<E = never> = (input?: SessionListInput) => Effect.Effect<SessionListOutput, E>
export type SessionStatsInput = {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: Project.ID | undefined
readonly timezone?: string | undefined
readonly tools?: "none" | "summary" | "detail" | undefined
}
export type SessionStatsOutput = {
readonly range: { readonly from: DateTime.Utc; readonly to: DateTime.Utc }
readonly sessions: number
readonly subagents: number
readonly prompts: number
readonly steps: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly cost: number & Brand.Brand<"Money.USD">
readonly tools:
| { readonly mode: "none" }
| {
readonly mode: "summary"
readonly totals: {
readonly calls: number
readonly succeeded: number
readonly failed: number
readonly unfinished: number
}
}
| {
readonly mode: "detail"
readonly totals: {
readonly calls: number
readonly succeeded: number
readonly failed: number
readonly unfinished: number
}
readonly usage: ReadonlyArray<{
readonly name: string
readonly calls: number
readonly succeeded: number
readonly failed: number
readonly unfinished: number
readonly durationP50?: number | undefined
}>
}
readonly activeDays: number
readonly streak: number
readonly activity: ReadonlyArray<{ readonly date: string; readonly steps: number }>
readonly models: ReadonlyArray<{
readonly model: Model.Ref
readonly steps: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly cost: number & Brand.Brand<"Money.USD">
}>
}
export type SessionStatsOperation<E = never> = (input?: SessionStatsInput) => Effect.Effect<SessionStatsOutput, E>
export type SessionCreateInput = {
readonly id?: Session.ID | undefined
readonly title?: string | undefined
@@ -1032,7 +966,6 @@ export type SessionViewOperation<E = never> = (input: SessionViewInput) => Effec
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
readonly stats: SessionStatsOperation<E>
readonly create: SessionCreateOperation<E>
readonly import: SessionImportOperation<E>
readonly export: SessionExportOperation<E>
@@ -17,8 +17,6 @@ import type {
PluginListOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
SessionStatsOutput,
SessionCreateInput,
SessionCreateOutput,
SessionImportInput,
@@ -307,22 +305,6 @@ const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: Sessi
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointSessionStats = (raw: RawClient["server.session"]) => (input?: SessionStatsInput) =>
preserveEffect<SessionStatsOutput>()(
raw["session.stats"]({
query: {
from: input?.["from"],
to: input?.["to"],
project: input?.["project"],
timezone: input?.["timezone"],
tools: input?.["tools"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: SessionCreateInput) =>
preserveEffect<SessionCreateOutput>()(
raw["session.create"]({
@@ -650,7 +632,6 @@ const EndpointSessionView = (raw: RawClient["server.session"]) => (input: Sessio
const adaptGroupSession = (raw: RawClient["server.session"]) => ({
list: EndpointSessionList(raw),
stats: EndpointSessionStats(raw),
create: EndpointSessionCreate(raw),
import: EndpointSessionImport(raw),
export: EndpointSessionExport(raw),
@@ -11,8 +11,6 @@ import type {
PluginListOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
SessionStatsOutput,
SessionCreateInput,
SessionCreateOutput,
SessionImportInput,
@@ -456,24 +454,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
stats: (input?: SessionStatsInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionStatsOutput }>(
{
method: "GET",
path: `/api/session/stats`,
query: {
from: input?.["from"],
to: input?.["to"],
project: input?.["project"],
timezone: input?.["timezone"],
tools: input?.["tools"],
},
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input?: SessionCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionCreateOutput }>(
{
@@ -37,19 +37,6 @@ export type FileDiffInfo = {
status: "added" | "deleted" | "modified"
}
export type SessionStatsToolTotals = { calls: number; succeeded: number; failed: number; unfinished: number }
export type SessionStatsToolUsage = {
name: string
calls: number
succeeded: number
failed: number
unfinished: number
durationP50?: number
}
export type SessionStatsActivity = { date: string; steps: number }
export type SessionMessageAgentSelected = {
id: string
metadata?: { [x: string]: JsonValue }
@@ -435,13 +422,6 @@ export type V2EventServerConnected = {
export type SessionRevert = { messageID: string; partID?: string; snapshot?: string; files?: Array<FileDiffInfo> }
export type SessionStatsTools =
| { mode: "none" }
| { mode: "summary"; totals: SessionStatsToolTotals }
| { mode: "detail"; totals: SessionStatsToolTotals; usage: Array<SessionStatsToolUsage> }
export type SessionStatsModelUsage = { model: ModelRef; steps: number; tokens: TokenUsageInfo; cost: MoneyUSD }
export type SessionMessageModelSelected = {
id: string
metadata?: { [x: string]: JsonValue }
@@ -1558,21 +1538,6 @@ export type SessionRevertStaged = {
data: { sessionID: string; revert: SessionRevert }
}
export type SessionStatsInfo = {
range: { from: number; to: number }
sessions: number
subagents: number
prompts: number
steps: number
tokens: TokenUsageInfo
cost: MoneyUSD
tools: SessionStatsTools
activeDays: number
streak: number
activity: Array<SessionStatsActivity>
models: Array<SessionStatsModelUsage>
}
export type SessionMessageUser = {
id: string
metadata?: { [x: string]: JsonValue }
@@ -2481,46 +2446,6 @@ export type SessionListInput = {
export type SessionListOutput = SessionsResponse
export type SessionStatsInput = {
readonly from?: {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: string | undefined
readonly timezone?: string | undefined
readonly tools?: "none" | "summary" | "detail" | undefined
}["from"]
readonly to?: {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: string | undefined
readonly timezone?: string | undefined
readonly tools?: "none" | "summary" | "detail" | undefined
}["to"]
readonly project?: {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: string | undefined
readonly timezone?: string | undefined
readonly tools?: "none" | "summary" | "detail" | undefined
}["project"]
readonly timezone?: {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: string | undefined
readonly timezone?: string | undefined
readonly tools?: "none" | "summary" | "detail" | undefined
}["timezone"]
readonly tools?: {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: string | undefined
readonly timezone?: string | undefined
readonly tools?: "none" | "summary" | "detail" | undefined
}["tools"]
}
export type SessionStatsOutput = { data: SessionStatsInfo }["data"]
export type SessionCreateInput = {
readonly id?: {
readonly id?: string | null
+5 -33
View File
@@ -2,26 +2,20 @@
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const dryRun = Bun.argv.includes("--dry-run")
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
imports: Record<string, Record<string, string>>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
const output = (value: string, types = false) =>
value.replace("./src/", "./dist/").replace(/\.ts$/, types ? ".d.ts" : ".js")
if (!dryRun && (await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
@@ -35,38 +29,16 @@ try {
return [
key,
{
import: output(value),
types: output(value, true),
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
},
]
}),
)
pkg.imports = Object.fromEntries(
Object.entries(pkg.imports).map(([key, conditions]) => [
key,
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
]),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
const consumer = await mkdtemp(join(tmpdir(), "opencode-codemode-"))
try {
await Bun.write(
join(consumer, "package.json"),
JSON.stringify({
private: true,
type: "module",
dependencies: { [pkg.name]: `file:${join(process.cwd(), tarball)}` },
}),
)
await $`npm install --ignore-scripts --no-audit --no-fund`.cwd(consumer)
await $`node --input-type=module -e ${`await import(${JSON.stringify(pkg.name)})`}`.cwd(consumer)
await $`bun --conditions=workerd -e ${`await import(${JSON.stringify(pkg.name)})`}`.cwd(consumer)
} finally {
await rm(consumer, { recursive: true, force: true })
}
if (!dryRun) await $`npm publish ${tarball} --tag ${Script.channel} --access public`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
+1
View File
@@ -3,6 +3,7 @@ import { defineConfig } from "drizzle-kit"
export default defineConfig({
out: "./migrations/",
strict: true,
schema: ["./src/**/*.sql.ts"],
verbose: true,
dialect: "mysql",
+9 -2
View File
@@ -3,11 +3,19 @@ import { Resource } from "@opencode-ai/console-resource"
export * from "drizzle-orm"
import { Client } from "@planetscale/database"
import type { MySqlTransactionConfig } from "drizzle-orm/mysql-core"
import { MySqlTransaction, type MySqlTransactionConfig } from "drizzle-orm/mysql-core"
import type { PlanetScalePreparedQueryHKT, PlanetscaleQueryResultHKT } from "drizzle-orm/planetscale-serverless"
import { Context } from "../context"
import { memo } from "../util/memo"
export namespace Database {
export type Transaction = MySqlTransaction<
PlanetscaleQueryResultHKT,
PlanetScalePreparedQueryHKT,
Record<string, never>,
any
>
const client = memo(() => {
const result = new Client({
host: Resource.Database.host,
@@ -18,7 +26,6 @@ export namespace Database {
return db
})
export type Transaction = Parameters<Parameters<ReturnType<typeof client>["transaction"]>[0]>[0]
export type TxOrDb = Transaction | ReturnType<typeof client>
const TransactionContext = Context.create<{
@@ -6,7 +6,7 @@ import { EffectCache } from "drizzle-orm/cache/core/cache-effect"
import { EffectLogger } from "drizzle-orm/effect-core"
import { entityKind } from "drizzle-orm/entity"
import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations"
import { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import { SQLiteEffectDatabase } from "../sqlite-core/effect/db.js"
import type { DrizzleConfig } from "drizzle-orm/utils"
import { jitCompatCheck } from "../internal/drizzle-utils.js"
@@ -53,13 +53,12 @@ export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extend
const cache = yield* EffectCache
const logger = yield* EffectLogger
const useJitMappers = jitCompatCheck(config.jit)
const dialect = new SQLiteDialect({ useJitMappers })
const dialect = new SQLiteAsyncDialect()
const relations = config.relations ?? ({} as TRelations)
const session = new EffectSQLiteSession(client, dialect, relations, {
logger,
cache,
useJitMappers,
useJitMappers: jitCompatCheck(config.jit),
})
const db = new EffectSQLiteDatabase(dialect, session, relations) as EffectSQLiteDatabase<TRelations> & {
$client: SqlClient
@@ -14,7 +14,7 @@ import { entityKind } from "drizzle-orm/entity"
import type { AnyRelations } from "drizzle-orm/relations"
import type { RelationalQueryMapperConfig } from "drizzle-orm/relations"
import type { Query } from "drizzle-orm/sql/sql"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import {
SQLiteEffectPreparedQuery,
SQLiteEffectSession,
@@ -59,7 +59,7 @@ export class EffectSQLiteSession<TRelations extends AnyRelations> extends SQLite
constructor(
private client: SqlClient,
dialect: SQLiteDialect,
dialect: SQLiteAsyncDialect,
protected relations: TRelations,
private options: EffectSQLiteSessionOptions,
) {
@@ -41,34 +41,14 @@ export function orderSelectedFields<TColumn extends Column>(
): SelectedFieldsOrdered {
return Object.entries(fields).flatMap(([name, field]) => {
const path = pathPrefix ? [...pathPrefix, name] : [name]
if (is(field, Column)) return [{ path, field, fieldType: "Column", column: field }] as SelectedFieldsOrdered
if (is(field, SQL)) return [{ path, field, fieldType: "SQL" }] as SelectedFieldsOrdered
if (is(field, SQL.Aliased)) return [{ path, field, fieldType: "SQL.Aliased" }] as SelectedFieldsOrdered
if (is(field, Subquery)) return [{ path, field, fieldType: "Subquery" }] as SelectedFieldsOrdered
if (is(field, Column) || is(field, SQL) || is(field, SQL.Aliased) || is(field, Subquery)) {
return [{ path, field }] as SelectedFieldsOrdered
}
if (is(field, Table)) return orderSelectedFields(getTableColumnsRuntime(field as SQLiteTable), path)
return orderSelectedFields(field as Record<string, unknown>, path)
}) as SelectedFieldsOrdered
}
export function resolveNullableObjectPaths(
columns: SelectedFieldsOrdered,
joinsNotNullableMap: Record<string, boolean> | undefined,
) {
if (!joinsNotNullableMap) return undefined
const tableOf = columns
.filter((column) => column.path.length > 1 && column.fieldType === "Column")
.reduce<Record<string, string | false>>((result, column) => {
const key = column.path[0]!
const tableName = getTableName(getColumnTable(column.field as Column))
const current = result[key]
result[key] = current === undefined ? tableName : current === tableName ? current : false
return result
}, {})
return Object.entries(tableOf)
.filter(([, tableName]) => typeof tableName === "string" && !joinsNotNullableMap[tableName])
.map(([key]) => key)
}
export function mapUpdateSet<TTable extends SQLiteTable>(table: TTable, values: SQLiteUpdateSetSource<TTable>) {
const entries = Object.entries(values).filter(([, value]) => value !== undefined)
if (entries.length === 0) throw new Error("No values to set")
@@ -9,7 +9,7 @@ import type { TypedQueryBuilder } from "drizzle-orm/query-builders/query-builder
import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations"
import { SelectionProxyHandler } from "drizzle-orm/selection-proxy"
import { type ColumnsSelection, type SQL, sql, type SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import { QueryBuilder } from "drizzle-orm/sqlite-core/query-builders/query-builder"
import type { SelectedFields } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
@@ -45,7 +45,7 @@ export class SQLiteEffectDatabase<
constructor(
/** @internal */
readonly dialect: SQLiteDialect,
readonly dialect: SQLiteAsyncDialect,
/** @internal */
readonly session: SQLiteEffectSession<TEffectHKT, TRunResult, TRelations>,
relations: TRelations,
@@ -3,15 +3,14 @@ import type * as Effect from "effect/Effect"
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind, is } from "drizzle-orm/entity"
import type { SelectResultFields } from "drizzle-orm/query-builders/select.types"
import type { TypedQueryBuilder } from "drizzle-orm/query-builders/query-builder"
import type { RunnableQuery } from "drizzle-orm/runnable-query"
import type { Query, SQLWrapper } from "drizzle-orm/sql/sql"
import { SQL, sql } from "drizzle-orm/sql/sql"
import { Param, SQL, sql } from "drizzle-orm/sql/sql"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { IndexColumn } from "drizzle-orm/sqlite-core/indexes"
import type {
SQLiteInsertConfig,
SQLiteInsertSelection,
SQLiteInsertSelectQueryBuilder,
SQLiteInsertValue,
} from "drizzle-orm/sqlite-core/query-builders/insert"
import type { SelectedFieldsFlat } from "drizzle-orm/sqlite-core/query-builders/select.types"
@@ -153,24 +152,34 @@ export class SQLiteEffectInsertBuilder<
if (values.length === 0) {
throw new Error("values() must be called with at least one value")
}
return new SQLiteEffectInsertBase(this.table, values, this.session, this.dialect, this.withList)
const mappedValues = values.map((entry) => {
const result: Record<string, Param | SQL> = {}
const cols = getTableColumnsRuntime(this.table)
for (const colKey of Object.keys(entry)) {
const colValue = entry[colKey as keyof typeof entry]
result[colKey] = is(colValue, SQL) ? colValue : new Param(colValue, cols[colKey])
}
return result
})
return new SQLiteEffectInsertBase(this.table, mappedValues, this.session, this.dialect, this.withList)
}
select(
selectQuery: (qb: QueryBuilder) => TypedQueryBuilder<SQLiteInsertSelection<TTable>>,
selectQuery: (qb: QueryBuilder) => SQLiteInsertSelectQueryBuilder<TTable>,
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
select(
selectQuery: (qb: QueryBuilder) => SQL,
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
select(selectQuery: SQL): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
select(
selectQuery: TypedQueryBuilder<SQLiteInsertSelection<TTable>>,
selectQuery: SQLiteInsertSelectQueryBuilder<TTable>,
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
select(
selectQuery:
| SQL
| TypedQueryBuilder<SQLiteInsertSelection<TTable>>
| ((qb: QueryBuilder) => TypedQueryBuilder<SQLiteInsertSelection<TTable>> | SQL),
| SQLiteInsertSelectQueryBuilder<TTable>
| ((qb: QueryBuilder) => SQLiteInsertSelectQueryBuilder<TTable> | SQL),
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT> {
const select = typeof selectQuery === "function" ? selectQuery(new QueryBuilder()) : selectQuery
@@ -5,7 +5,7 @@ import { entityKind } from "drizzle-orm/entity"
import type { RunnableQuery } from "drizzle-orm/runnable-query"
import type { PreparedQuery } from "drizzle-orm/session"
import type { Query, SQL, SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
type SQLiteEffectRawAction = "all" | "get" | "values" | "run"
@@ -29,7 +29,7 @@ export class SQLiteEffectRaw<TResult, TEffectHKT extends QueryEffectHKTBase = Qu
/** @internal */
public getSQL: () => SQL,
private action: SQLiteEffectRawAction,
private dialect: SQLiteDialect,
private dialect: SQLiteAsyncDialect,
private mapBatchResult: (result: unknown) => unknown,
) {}
@@ -15,7 +15,7 @@ import { SQL } from "drizzle-orm/sql/sql"
import type { ColumnsSelection, SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteColumn } from "drizzle-orm/sqlite-core/columns"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import { SQLiteSelectBase } from "drizzle-orm/sqlite-core/query-builders/select"
import { SQLiteSelectQueryBuilderBase } from "drizzle-orm/sqlite-core/query-builders/select"
import type {
CreateSQLiteSelectFromBuilderMode,
SelectedFields,
@@ -97,8 +97,8 @@ export class SQLiteEffectSelectBuilder<
>
: CreateSQLiteSelectFromBuilderMode<
TBuilderMode,
SQLiteEffectSelectHKT<TEffectHKT>,
GetSelectTableName<TFrom>,
"async",
TRunResult,
TSelection extends undefined ? GetSelectTableSelection<TFrom> : TSelection,
TSelection extends undefined ? "single" : "partial"
@@ -164,9 +164,10 @@ export interface SQLiteEffectSelectBase<
out TResult extends any[] = SelectResult<TSelection, TSelectMode, TNullabilityMap>[],
out TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends SQLiteSelectBase<
> extends SQLiteSelectQueryBuilderBase<
SQLiteEffectSelectHKT<TEffectHKT>,
TTableName,
"async",
TRunResult,
TSelection,
TSelectMode,
@@ -192,9 +193,10 @@ export class SQLiteEffectSelectBase<
out TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
extends SQLiteSelectBase<
extends SQLiteSelectQueryBuilderBase<
SQLiteEffectSelectHKT<TEffectHKT>,
TTableName,
"async",
TRunResult,
TSelection,
TSelectMode,
@@ -213,7 +215,7 @@ export class SQLiteEffectSelectBase<
}
/** @internal */
override getSQL(): SQL {
getSQL(): SQL {
return this.dialect.buildSelectQuery(this.effectConfig)
}
@@ -238,7 +240,7 @@ export class SQLiteEffectSelectBase<
return query as ReturnType<this["prepare"]>
}
override $withCache(config?: { config?: CacheConfig; tag?: string; autoInvalidate?: boolean } | false) {
$withCache(config?: { config?: CacheConfig; tag?: string; autoInvalidate?: boolean } | false) {
this.cacheConfig =
config === undefined
? { config: {}, enabled: true, autoInvalidate: true }
@@ -21,12 +21,12 @@ import type {
import { makeJitRqbMapper } from "drizzle-orm/relations"
import type { PreparedQuery } from "drizzle-orm/session"
import { fillPlaceholders, type Query, type SQL, sql } from "drizzle-orm/sql/sql"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SelectedFieldsOrdered } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { PreparedQueryConfig, SQLiteExecuteMethod, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
import { upgradeIfNeeded } from "../../up-migrations/effect-sqlite.js"
import { assertUnreachable, makeJitQueryMapper, type RowsMapper } from "drizzle-orm/utils"
import { mapResultRow, resolveNullableObjectPaths } from "../../internal/drizzle-utils.js"
import { mapResultRow } from "../../internal/drizzle-utils.js"
import { SQLiteEffectDatabase } from "./db.js"
type MigrationConfigWithInit = MigrationConfig & { init?: boolean }
@@ -155,10 +155,7 @@ export class SQLiteEffectPreparedQuery<
return this.useJitMappers
? (this.jitMapper =
(this.jitMapper as RowsMapper<T["all"]>) ??
makeJitQueryMapper<T["all"]>(
this.fields!,
resolveNullableObjectPaths(this.fields!, this.joinsNotNullableMap),
))(rows as unknown[][])
makeJitQueryMapper<T["all"]>(this.fields!, this.joinsNotNullableMap))(rows as unknown[][])
: (rows as unknown[][]).map((row) => mapResultRow(this.fields!, row, this.joinsNotNullableMap))
}
@@ -190,10 +187,7 @@ export class SQLiteEffectPreparedQuery<
return this.useJitMappers
? (this.jitMapper =
(this.jitMapper as RowsMapper<T["get"][]>) ??
makeJitQueryMapper<T["get"][]>(
this.fields!,
resolveNullableObjectPaths(this.fields!, this.joinsNotNullableMap),
))([row as unknown[]])[0]
makeJitQueryMapper<T["get"][]>(this.fields!, this.joinsNotNullableMap))([row as unknown[]])[0]
: mapResultRow(this.fields!, row as unknown[], this.joinsNotNullableMap)
}
@@ -317,7 +311,7 @@ export abstract class SQLiteEffectSession<
> {
static readonly [entityKind]: string = "SQLiteEffectSession"
constructor(readonly dialect: SQLiteDialect) {}
constructor(readonly dialect: SQLiteAsyncDialect) {}
abstract prepareQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
query: Query,
@@ -418,7 +412,7 @@ export abstract class SQLiteEffectTransaction<
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
constructor(
dialect: SQLiteDialect,
dialect: SQLiteAsyncDialect,
session: SQLiteEffectSession<TEffectHKT, TRunResult, TRelations>,
protected relations: TRelations,
) {
+1 -7
View File
@@ -63,13 +63,7 @@ const baseLayer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const search = yield* FileSystemSearch.Service
// Workspace-placed directories exist only inside the workspace, so a host
// realpath probe at boot consults the wrong filesystem and would block
// construction on servers without a matching local directory. Treat the
// configured directory as canonical; local placements keep symlink
// canonicalization. This skip is boot-only: resolve/read/list below still
// access the host filesystem per operation (tracked in #44568).
const root = location.workspaceID ? location.directory : yield* fs.realPath(location.directory).pipe(Effect.orDie)
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
const absolute = path.resolve(location.directory, input ?? ".")
if (!FSUtil.contains(location.directory, absolute))
+1 -4
View File
@@ -184,12 +184,9 @@ export const fffLayer = Layer.effect(
export const layer = (options?: Options) =>
Layer.unwrap(
Effect.gen(function* () {
const location = yield* Location.Service
// Workspace-backed Locations resolve in a remote sandbox; fff would index the local server directory
// in-process and serve wrong results. Ripgrep routes through the Location environment spawner.
if (location.workspaceID) return ripgrepLayer
if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available())
return ripgrepLayer
const location = yield* Location.Service
// Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
return location.vcs && !Protected.isHome(location.directory) ? fffLayer : ripgrepLayer
}),
+6 -20
View File
@@ -8,27 +8,13 @@ import { DecodeError, ResizerUnavailableError, SizeError } from "../image.js"
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
export const make = Effect.gen(function* () {
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
const loadPhoton = yield* Effect.cached(
// A runtime without a photon wasm artifact (#photon-wasm resolves to the
// empty string on workerd) has no resizer, by declaration: fail typed
// before touching URLs or module loading. The path resolution and import
// for runtimes that DO have an artifact stay inside the guard too — a
// throw outside it (workerd's undefined import.meta.url was one) is a
// defect that escapes the ResizerUnavailableError handling and turns any
// image-bearing prompt into a 500 instead of degrading to passthrough.
photonWasm === ""
? Effect.fail(new ResizerUnavailableError())
: Effect.tryPromise({
try: async () => {
;(
globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }
).__OPENCODE_PHOTON_WASM_PATH = path.isAbsolute(photonWasm)
? photonWasm
: fileURLToPath(new URL(photonWasm, import.meta.url))
return await import("@silvia-odwyer/photon-node")
},
catch: () => new ResizerUnavailableError(),
}),
Effect.tryPromise({
try: () => import("@silvia-odwyer/photon-node"),
catch: () => new ResizerUnavailableError(),
}),
)
return Effect.fn("Image.Photon.normalize")(function* (
resource: string,
+1 -2
View File
@@ -2,7 +2,6 @@ export * as PluginSupervisor from "./supervisor.js"
export { Service, type Interface } from "./supervisor-service.js"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { instanceSafeContext } from "@opencode-ai/plugin/effect/tool-schema"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Latch, Layer, Schema, Stream } from "effect"
import path from "path"
@@ -127,7 +126,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
tui: plugin.tui,
version: JSON.stringify(operation),
source: pluginSource(operation.target),
effect: (host) => plugin.effect(instanceSafeContext({ ...host, options: operation.options })),
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Plugin.Versioned
})
-418
View File
@@ -1,418 +0,0 @@
export * as SessionStats from "./stats.js"
import { DateTime, Effect, Option, Schema } from "effect"
import { and, eq, gte, inArray, lt, sql } from "drizzle-orm"
import { Model } from "@opencode-ai/schema/model"
import { Money } from "@opencode-ai/schema/money"
import { Project } from "@opencode-ai/schema/project"
import { Provider } from "@opencode-ai/schema/provider"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { ToolMode } from "@opencode-ai/schema/session-stats"
import { Database } from "../database/database.js"
import { EventTable } from "../event/sql.js"
import { SessionMessageTable, SessionTable } from "./sql.js"
type Input = {
readonly from?: number
readonly to?: number
readonly projectID?: Project.ID
readonly timezone?: string
readonly tools?: ToolMode
}
type Tokens = {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
type MessageRow = {
sessionID: string
parentID: string | null
type: "user" | "assistant"
timeCreated: number
providerID: string | null
modelID: string | null
variant: string | null
input: number | null
output: number | null
reasoning: number | null
cacheRead: number | null
cacheWrite: number | null
cost: number | null
}
type ToolRow = {
name: string | null
status: string | null
duration: number | null
}
type ToolSummaryRow = { status: string | null; count: number }
type ModelAggregate = {
model: Model.Ref
steps: number
tokens: Tokens
cost: number
}
type ToolAggregate = {
name: string
calls: number
succeeded: number
failed: number
unfinished: number
durations: number[]
}
const decodeUsage = Schema.decodeUnknownOption(SessionEvent.UsageRecorded.data)
const Window = 31 * 24 * 60 * 60 * 1_000
export class InvalidRangeError extends Schema.TaggedError<InvalidRangeError>()("SessionStats.InvalidRangeError", {
from: Schema.Finite,
to: Schema.Finite,
}) {}
export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
const db = (yield* Database.Service).db
const to = input.to ?? Date.now()
if (input.from !== undefined && input.from >= to) return yield* new InvalidRangeError({ from: input.from, to })
const project = input.projectID === undefined ? sql`` : sql`AND session.project_id = ${input.projectID}`
const from =
input.from ??
(yield* db
.get<{ time: number | null }>(
sql`
SELECT min(message.time_created) AS time
FROM ${SessionMessageTable} AS message
JOIN ${SessionTable} AS session ON session.id = message.session_id
WHERE message.type IN ('user', 'assistant')
AND message.time_created < ${to}
AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created)
${project}
`,
)
.pipe(Effect.orDie))?.time ??
to
const ranges = windows(from, to)
const toolMode = input.tools ?? "summary"
const sessions = new Set<string>()
const subagents = new Set<string>()
const activity = new Map<string, number>()
const models = new Map<string, ModelAggregate>()
const tools = new Map<string, ToolAggregate>()
const totals = {
prompts: 0,
steps: 0,
tokens: emptyTokens(),
cost: 0,
}
const toolTotals = { calls: 0, succeeded: 0, failed: 0, unfinished: 0 }
const dateKey = makeDateKey(input.timezone)
yield* Effect.forEach(
ranges,
(range) =>
db
.all<MessageRow>(
sql`
SELECT
message.session_id AS sessionID,
session.parent_id AS parentID,
message.type AS type,
message.time_created AS timeCreated,
json_extract(message.data, '$.model.providerID') AS providerID,
json_extract(message.data, '$.model.id') AS modelID,
json_extract(message.data, '$.model.variant') AS variant,
json_extract(message.data, '$.tokens.input') AS input,
json_extract(message.data, '$.tokens.output') AS output,
json_extract(message.data, '$.tokens.reasoning') AS reasoning,
json_extract(message.data, '$.tokens.cache.read') AS cacheRead,
json_extract(message.data, '$.tokens.cache.write') AS cacheWrite,
json_extract(message.data, '$.cost') AS cost
FROM ${SessionMessageTable} AS message
JOIN ${SessionTable} AS session ON session.id = message.session_id
WHERE message.type IN ('user', 'assistant')
AND message.time_created >= ${range.from}
AND message.time_created < ${range.to}
AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created)
${project}
`,
)
.pipe(
Effect.orDie,
Effect.tap((rows) =>
Effect.sync(() => {
rows.forEach((row) => {
if (row.parentID === null) sessions.add(row.sessionID)
else subagents.add(row.sessionID)
if (row.type === "user") {
if (row.parentID === null) totals.prompts++
return
}
totals.steps++
const tokens = rowTokens(row)
addTokens(totals.tokens, tokens)
totals.cost += row.cost ?? 0
const day = dateKey(row.timeCreated)
activity.set(day, (activity.get(day) ?? 0) + 1)
if (!row.providerID || !row.modelID) return
const key = `${row.providerID}/${row.modelID}#${row.variant ?? ""}`
const model = models.get(key) ?? {
model: {
providerID: Provider.ID.make(row.providerID),
id: Model.ID.make(row.modelID),
variant: row.variant ? Model.VariantID.make(row.variant) : undefined,
},
steps: 0,
tokens: emptyTokens(),
cost: 0,
}
models.set(key, model)
model.steps++
model.cost += row.cost ?? 0
addTokens(model.tokens, tokens)
})
}),
),
),
{ concurrency: 1, discard: true },
)
if (toolMode !== "none")
yield* Effect.forEach(
ranges,
(range) => {
if (toolMode === "summary")
return db
.all<ToolSummaryRow>(
sql`
SELECT json_extract(content.value, '$.state.status') AS status, count(*) AS count
FROM ${SessionMessageTable} AS message
JOIN ${SessionTable} AS session ON session.id = message.session_id,
json_each(message.data, '$.content') AS content
WHERE message.type = 'assistant'
AND message.time_created >= ${range.from}
AND message.time_created < ${range.to}
AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created)
AND json_extract(content.value, '$.type') = 'tool'
${project}
GROUP BY status
`,
)
.pipe(
Effect.orDie,
Effect.tap((rows) =>
Effect.sync(() => rows.forEach((row) => addToolStatus(toolTotals, row.status, row.count))),
),
Effect.asVoid,
)
return db
.all<ToolRow>(
sql`
SELECT
json_extract(content.value, '$.name') AS name,
json_extract(content.value, '$.state.status') AS status,
CASE
WHEN json_extract(content.value, '$.time.completed') IS NULL THEN NULL
ELSE json_extract(content.value, '$.time.completed')
- coalesce(json_extract(content.value, '$.time.ran'), json_extract(content.value, '$.time.created'))
END AS duration
FROM ${SessionMessageTable} AS message
JOIN ${SessionTable} AS session ON session.id = message.session_id,
json_each(message.data, '$.content') AS content
WHERE message.type = 'assistant'
AND message.time_created >= ${range.from}
AND message.time_created < ${range.to}
AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created)
AND json_extract(content.value, '$.type') = 'tool'
${project}
`,
)
.pipe(
Effect.orDie,
Effect.tap((rows) =>
Effect.sync(() => {
rows.forEach((row) => {
addToolStatus(toolTotals, row.status, 1)
if (!row.name) return
const tool = tools.get(row.name) ?? {
name: row.name,
calls: 0,
succeeded: 0,
failed: 0,
unfinished: 0,
durations: [],
}
tools.set(row.name, tool)
addToolStatus(tool, row.status, 1)
if (row.duration !== null) tool.durations.push(row.duration)
})
}),
),
Effect.asVoid,
)
},
{ concurrency: 1, discard: true },
)
const ids = yield* db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(input.projectID === undefined ? undefined : eq(SessionTable.project_id, input.projectID))
.all()
.pipe(Effect.orDie)
const events = (yield* Effect.forEach(
batches(ids.map((row) => row.id)),
(batch) =>
db
.select({ created: EventTable.created, data: EventTable.data })
.from(EventTable)
.where(
and(
inArray(EventTable.aggregate_id, batch),
eq(EventTable.type, SessionEvent.UsageRecorded.type),
sql`json_extract(${EventTable.data}, '$.source') = 'compaction'`,
gte(EventTable.created, from),
lt(EventTable.created, to),
),
)
.all()
.pipe(Effect.orDie),
{ concurrency: 4 },
)).flat()
events.forEach((row) => {
const decoded = decodeUsage(row.data)
if (Option.isNone(decoded)) return
addTokens(totals.tokens, decoded.value.tokens)
totals.cost += decoded.value.cost
})
const days = [...activity.entries()].sort(([a], [b]) => a.localeCompare(b))
return {
range: { from: DateTime.makeUnsafe(from), to: DateTime.makeUnsafe(to) },
sessions: sessions.size,
subagents: subagents.size,
prompts: totals.prompts,
steps: totals.steps,
tokens: totals.tokens,
cost: Money.USD.make(totals.cost),
tools:
toolMode === "none"
? { mode: toolMode }
: toolMode === "summary"
? { mode: toolMode, totals: toolTotals }
: {
mode: toolMode,
totals: toolTotals,
usage: [...tools.values()]
.sort((a, b) => b.calls - a.calls)
.map((tool) => ({
name: tool.name,
calls: tool.calls,
succeeded: tool.succeeded,
failed: tool.failed,
unfinished: tool.unfinished,
durationP50: median(tool.durations),
})),
},
activeDays: days.length,
streak: longestStreak(days.map(([date]) => date)),
activity: days.map(([date, steps]) => ({ date, steps })),
models: [...models.values()]
.sort((a, b) => tokenTotal(b.tokens) - tokenTotal(a.tokens))
.map((model) => ({ ...model, cost: Money.USD.make(model.cost) })),
}
})
function windows(from: number, to: number) {
return Array.from({ length: Math.ceil((to - from) / Window) }, (_, index) => ({
from: from + index * Window,
to: Math.min(to, from + (index + 1) * Window),
}))
}
function batches(ids: string[]) {
return Array.from({ length: Math.ceil(ids.length / 500) }, (_, index) => ids.slice(index * 500, (index + 1) * 500))
}
function rowTokens(row: MessageRow): Tokens {
return {
input: row.input ?? 0,
output: row.output ?? 0,
reasoning: row.reasoning ?? 0,
cache: { read: row.cacheRead ?? 0, write: row.cacheWrite ?? 0 },
}
}
function emptyTokens(): Tokens {
return { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
}
function addTokens(target: Tokens, source: Tokens) {
target.input += source.input
target.output += source.output
target.reasoning += source.reasoning
target.cache.read += source.cache.read
target.cache.write += source.cache.write
}
function tokenTotal(tokens: Tokens) {
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
function addToolStatus(
target: { calls: number; succeeded: number; failed: number; unfinished: number },
status: string | null,
count: number,
) {
target.calls += count
if (status === "completed") {
target.succeeded += count
return
}
if (status === "error") {
target.failed += count
return
}
target.unfinished += count
}
function makeDateKey(timezone = "UTC") {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
})
return (time: number) => {
const parts = Object.fromEntries(formatter.formatToParts(time).map((part) => [part.type, part.value]))
return `${parts.year}-${parts.month}-${parts.day}`
}
}
function longestStreak(days: string[]) {
return days.reduce(
(result, day, index) => {
const previous = days[index - 1]
const current = previous && dayOrdinal(day) - dayOrdinal(previous) === 1 ? result.current + 1 : 1
return { current, longest: Math.max(result.longest, current) }
},
{ current: 0, longest: 0 },
).longest
}
function dayOrdinal(value: string) {
const [year, month, day] = value.split("-").map(Number)
return Math.floor(Date.UTC(year, month - 1, day) / 86_400_000)
}
function median(values: number[]) {
if (values.length === 0) return undefined
const sorted = values.toSorted((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}
+17 -56
View File
@@ -12,25 +12,8 @@ import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Location } from "@opencode-ai/core/location"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { location } from "../fixture/location"
const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void) =>
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
onFind(input)
if (input.onEntry)
yield* input.onEntry(FileSystem.Entry.make({ path: RelativePath.make(entry), type: "file" }))
return []
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
)
describe("FileSystemSearch", () => {
test("honors wildcard directory rules from .gitignore", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-fff-ignore-"))
@@ -67,44 +50,6 @@ describe("FileSystemSearch", () => {
}
})
test("selects the ripgrep layer for workspace-backed locations even when vcs would pick fff", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-search-workspace-"))
try {
// A local file that only an fff index of the server directory could surface.
// The fff-vs-ripgrep discrimination only bites where Fff.available() is
// true; elsewhere the layer choice already falls back to ripgrep.
await Bun.write(path.join(directory, "server-local.ts"), "server local")
let observed: Ripgrep.FindInput | undefined
const ref = Location.Ref.make({
directory: AbsolutePath.make(directory),
workspaceID: Workspace.ID.make("wrk_test"),
})
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(ref, { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } }),
),
),
],
[Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))],
])
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
const entries = yield* search.find({ query: "ts", type: "file" })
expect(observed?.cwd).toBe(directory)
expect(entries.map((entry) => entry.path)).toEqual([RelativePath.make("remote.ts")])
}).pipe(Effect.provide(layer), Effect.scoped),
)
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("bounds a home scan even when home is detected as a repository", async () => {
let observed: Ripgrep.FindInput | undefined
const home = AbsolutePath.make(os.homedir())
@@ -118,7 +63,23 @@ describe("FileSystemSearch", () => {
),
),
],
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
observed = input
if (input.onEntry)
yield* input.onEntry(FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" }))
return []
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
await Effect.runPromise(
+2 -46
View File
@@ -6,20 +6,16 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
const provide = (directory: string, workspaceID?: Workspace.ID) =>
const provide = (directory: string) =>
Effect.provide(
LayerNode.compile(FileSystem.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })),
),
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
]),
)
@@ -60,46 +56,6 @@ describe("FileSystem", () => {
),
)
it.live("skips host canonicalization for workspace locations at boot", () =>
withTmp((directory) =>
Effect.gen(function* () {
// The directory exists only inside the workspace, so boot must not
// require it to exist on the host. Operations still access the host
// filesystem per call (#44568); only boot canonicalization is skipped.
const missing = path.join(directory, "workspace-only")
const workspace = yield* FileSystem.Service.pipe(
provide(missing, Workspace.ID.make("wrk_filesystem")),
Effect.exit,
)
expect(Exit.isSuccess(workspace)).toBe(true)
// A local ref with the same missing directory keeps failing boot:
// host realpath canonicalization stays load-bearing for local placements.
const local = yield* FileSystem.Service.pipe(provide(missing), Effect.exit)
expect(Exit.isFailure(local)).toBe(true)
}),
),
)
it.live("canonicalizes local symlinked directories", () =>
withTmp((directory) =>
Effect.gen(function* () {
const real = path.join(directory, "real")
yield* Effect.promise(() => fs.mkdir(real))
yield* Effect.promise(() => fs.writeFile(path.join(real, "file.txt"), "linked"))
const link = path.join(directory, "link")
yield* Effect.promise(() => fs.symlink(real, link))
// Reads resolve through the symlink only because boot canonicalized
// the location root to the real directory.
const read = yield* FileSystem.Service.pipe(
Effect.flatMap((service) => service.read({ path: RelativePath.make("file.txt") })),
provide(link),
)
expect(new TextDecoder().decode(read.content)).toBe("linked")
}),
),
)
it.live("rejects lexical escapes", () =>
withTmp((directory) =>
Effect.gen(function* () {
+5 -5
View File
@@ -140,12 +140,12 @@ describe("LocationServiceMap", () => {
const workspaceRef = Location.Ref.make({ directory, workspaceID: Workspace.ID.make("wrk_liveness") })
const localRef = Location.Ref.make({ directory })
// The directory only exists inside the workspace, so neither liveness
// nor boot may consult the local filesystem: the full location graph
// must construct and the entry must survive release instead of being
// The directory only exists inside the workspace, so liveness must
// not consult the local filesystem. Boot outcome is not the subject
// here: parts of the location graph still read the host filesystem,
// so only assert that the entry survives release instead of being
// evicted by a zero idle time-to-live.
const location = yield* Location.Service.pipe(Effect.provide(locations.get(workspaceRef)), Effect.scoped)
expect(location.directory).toBe(directory)
yield* Location.Service.pipe(Effect.provide(locations.get(workspaceRef)), Effect.scoped, Effect.exit)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([workspaceRef])
// A local ref with the same missing directory keeps the existing
@@ -1,4 +1,5 @@
import { Effect, Plugin } from "@opencode-ai/plugin/effect"
import { Plugin } from "@opencode-ai/plugin/effect"
import { Effect } from "effect"
export default Plugin.define({
id: "config-effect-plugin",

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