mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-19 07:16:26 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2fc031d10 | ||
|
|
d81604714f | ||
|
|
d1e828c217 | ||
|
|
437cdc03a9 | ||
|
|
219ecada68 | ||
|
|
9e1fa80fb7 | ||
|
|
278db3023f | ||
|
|
c2067d59af | ||
|
|
f5d40d6f30 | ||
|
|
c8e654410d | ||
|
|
db80806651 | ||
|
|
47f66de8dd | ||
|
|
c076066c33 | ||
|
|
5c09d051e5 | ||
|
|
0a6111291e | ||
|
|
609044ef0a | ||
|
|
4f32bfca57 | ||
|
|
90112f52db |
@@ -184,6 +184,7 @@ const table = sqliteTable("session", {
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep native compaction mechanisms out of `SessionCompaction`. Plugins register `native` strategies through the `SessionCompaction` editor that turn a prepared request into a replacement window (the built-in `NativeCompactionPlugin` handles `@opencode/ai` compaction operations); later registrations win. Core owns the provider-mode decision, route provenance, the retry policy, overflow recovery, interruption, usage accounting, and checkpoint persistence.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep event replay ownership separate from clustered Session execution ownership.
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -177,7 +177,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -217,7 +217,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -253,7 +253,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -280,7 +280,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -297,7 +297,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -321,7 +321,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -341,7 +341,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -409,7 +409,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -456,12 +456,11 @@
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||
"msgpackr-extract": "3.0.4",
|
||||
},
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -498,7 +497,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -514,7 +513,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -533,7 +532,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -546,7 +545,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -560,7 +559,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -575,7 +574,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -614,7 +613,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -644,7 +643,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -659,7 +658,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -683,7 +682,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -704,7 +703,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -726,7 +725,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -761,7 +760,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -781,7 +780,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -815,7 +814,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -834,7 +833,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -880,7 +879,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -894,7 +893,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -929,7 +928,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -964,7 +963,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -997,7 +996,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1038,7 +1037,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-U9IuP/ev6w4urvogOwQyl3rdumY6W4YaY18NkFaOVHU=",
|
||||
"aarch64-linux": "sha256-Wc8OT2DRZpVo56KaoGE0Hsj1NDknakbWXO9w2qy6j+0=",
|
||||
"aarch64-darwin": "sha256-wAea8+jajnMDxZ6XJL+Hsrf0621hwtBtWyD1+dS45dE=",
|
||||
"x86_64-darwin": "sha256-g8PCNBSV6rO+VQjKU9AtYqj+r18o+fhLDXEQq+X2EZ4="
|
||||
"x86_64-linux": "sha256-TlCPtuIvTNF01fLI0yHCLzVkx53yF9xxvAObDMGeo8M=",
|
||||
"aarch64-linux": "sha256-aEjyPIXHeA5w1iZXbPKJAOIJQBfXg+LAx/y7C5uFz4g=",
|
||||
"aarch64-darwin": "sha256-lthMTio2qNy+H2tL9cAPmqC41O+wdSADI61cnW4dIb0=",
|
||||
"x86_64-darwin": "sha256-ONFbRQnwzOYHo0Pyh327QfJG2uzqzNEn/SS2vbYYMdM="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
export type ReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
||||
|
||||
const Options = Schema.Struct({
|
||||
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
|
||||
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
|
||||
enableThinking: Schema.optional(Schema.Boolean),
|
||||
thinkingBudget: Schema.optional(Schema.Int),
|
||||
preserveThinking: Schema.optional(Schema.Boolean),
|
||||
@@ -19,7 +19,7 @@ const Options = Schema.Struct({
|
||||
}),
|
||||
),
|
||||
toolStream: Schema.optional(Schema.Boolean),
|
||||
parallelToolCalls: OpenResponsesOptions.Options.fields.parallelToolCalls,
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
repetitionPenalty: Schema.optional(Schema.Number),
|
||||
responseFormat: Schema.optional(
|
||||
Schema.Struct({
|
||||
|
||||
@@ -6,9 +6,9 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
|
||||
const Options = Schema.Struct({
|
||||
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
|
||||
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
|
||||
enableThinking: Schema.optional(Schema.Boolean),
|
||||
store: OpenResponsesOptions.Options.fields.store,
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
previousResponseId: Schema.optional(Schema.String),
|
||||
conversation: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
@@ -21,11 +21,12 @@ import {
|
||||
type JsonSchema,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ProviderOptions,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolResultPart,
|
||||
} from "../schema/index.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, knownString, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
|
||||
import * as Cache from "./utils/cache.js"
|
||||
@@ -52,57 +53,10 @@ const SSE_EVENTS = new Set([
|
||||
])
|
||||
export const framing = Framing.sseEvents(SSE_EVENTS)
|
||||
|
||||
export type ThinkingBlockBinding = {
|
||||
readonly prefix_mismatch_behavior?: "error" | "drop_block" | (string & {})
|
||||
}
|
||||
|
||||
export type ThinkingInput =
|
||||
| {
|
||||
readonly type: "adaptive"
|
||||
readonly display?: "summarized" | "omitted"
|
||||
readonly block_binding?: ThinkingBlockBinding
|
||||
}
|
||||
| {
|
||||
readonly type: "disabled"
|
||||
}
|
||||
| ({
|
||||
readonly type: "enabled"
|
||||
readonly display?: "summarized" | "omitted"
|
||||
readonly block_binding?: ThinkingBlockBinding
|
||||
} & (
|
||||
| { readonly budgetTokens: number; readonly budget_tokens?: number }
|
||||
| { readonly budgetTokens?: number; readonly budget_tokens: number }
|
||||
))
|
||||
|
||||
export interface OptionsInput {
|
||||
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
|
||||
readonly contextManagement?: ContextManagement
|
||||
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 ThinkingBlockBinding = typeof AnthropicThinkingBlockBinding.Type
|
||||
export type ThinkingInput = typeof Thinking.Encoded
|
||||
/** Caller-facing provider options; unknown keys are accepted and ignored. `Options.Type` is the wire-ready form. */
|
||||
export type OptionsInput = ProviderOptions & typeof Options.Encoded
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
|
||||
export const ContextManagement = Schema.Struct({
|
||||
@@ -129,6 +83,7 @@ const AnthropicCacheControl = Schema.Struct({
|
||||
type: Schema.tag("ephemeral"),
|
||||
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
|
||||
})
|
||||
const AnthropicServiceTier = Schema.Literals(["auto", "standard_only"])
|
||||
|
||||
const AnthropicTextBlock = Schema.Struct({
|
||||
type: Schema.tag("text"),
|
||||
@@ -317,25 +272,21 @@ const AnthropicToolChoice = Schema.Union([
|
||||
])
|
||||
|
||||
const AnthropicThinkingBlockBinding = Schema.Struct({
|
||||
prefix_mismatch_behavior: Schema.optional(Schema.String),
|
||||
prefix_mismatch_behavior: Schema.optional(knownString<"error" | "drop_block">()),
|
||||
})
|
||||
|
||||
const AnthropicThinking = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("adaptive"),
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("disabled"),
|
||||
}),
|
||||
])
|
||||
const AnthropicThinkingFields = {
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}
|
||||
const AnthropicThinkingEnabled = Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
...AnthropicThinkingFields,
|
||||
})
|
||||
const AnthropicThinkingAdaptive = Schema.Struct({ type: Schema.tag("adaptive"), ...AnthropicThinkingFields })
|
||||
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.tag("disabled") })
|
||||
const AnthropicThinking = Schema.Union([AnthropicThinkingEnabled, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
|
||||
type AnthropicThinking = typeof AnthropicThinking.Type
|
||||
|
||||
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
|
||||
@@ -360,6 +311,53 @@ const AnthropicContainer = Schema.Union([
|
||||
}),
|
||||
])
|
||||
|
||||
// =============================================================================
|
||||
// Provider Options
|
||||
// =============================================================================
|
||||
// Callers spell the budget as `budgetTokens` or the wire `budget_tokens`; the
|
||||
// keys are disjoint per variant so the input type requires exactly one and the
|
||||
// transform can narrow on it. Decoding straight to the wire block keeps the
|
||||
// alias out of the rest of the file.
|
||||
const ThinkingEnabledInput = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("enabled"), budgetTokens: Schema.Number, ...AnthropicThinkingFields }),
|
||||
Schema.Struct({ type: Schema.tag("enabled"), budget_tokens: Schema.Number, ...AnthropicThinkingFields }),
|
||||
]).pipe(
|
||||
Schema.decodeTo(AnthropicThinkingEnabled, {
|
||||
decode: SchemaGetter.transform((input) => ({
|
||||
type: "enabled" as const,
|
||||
budget_tokens: "budgetTokens" in input ? input.budgetTokens : input.budget_tokens,
|
||||
display: input.display,
|
||||
block_binding: input.block_binding,
|
||||
})),
|
||||
encode: SchemaGetter.passthrough({ strict: false }),
|
||||
}),
|
||||
)
|
||||
const Thinking = Schema.Union([ThinkingEnabledInput, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
|
||||
|
||||
const OutputConfigInput = Schema.Struct({
|
||||
effort: optionalNull(Schema.String),
|
||||
format: optionalNull(AnthropicJsonOutputFormat),
|
||||
})
|
||||
|
||||
// Both key spellings are accepted; `fromRequest` prefers the snake_case one.
|
||||
const Options = Schema.Struct({
|
||||
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
|
||||
contextManagement: Schema.optional(ContextManagement),
|
||||
thinking: Schema.optional(Thinking),
|
||||
effort: Schema.optional(Schema.String),
|
||||
service_tier: Schema.optional(AnthropicServiceTier),
|
||||
serviceTier: Schema.optional(AnthropicServiceTier),
|
||||
metadata: Schema.optional(AnthropicMetadata),
|
||||
container: Schema.optional(AnthropicContainer),
|
||||
inference_geo: optionalNull(Schema.String),
|
||||
inferenceGeo: optionalNull(Schema.String),
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
cacheControl: Schema.optional(AnthropicCacheControl),
|
||||
output_config: Schema.optional(OutputConfigInput),
|
||||
outputConfig: Schema.optional(OutputConfigInput),
|
||||
})
|
||||
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
|
||||
|
||||
const AnthropicBodyFields = {
|
||||
context_management: Schema.optional(
|
||||
Schema.Struct({
|
||||
@@ -391,7 +389,7 @@ const AnthropicBodyFields = {
|
||||
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"])),
|
||||
service_tier: Schema.optional(AnthropicServiceTier),
|
||||
}
|
||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
@@ -1001,64 +999,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
return messages
|
||||
})
|
||||
|
||||
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 thinking = yield* resolveThinking(input?.thinking)
|
||||
return {
|
||||
thinking: applyThinkingBindingDefault(request.model, thinking),
|
||||
effort: outputConfigEffort,
|
||||
format: outputConfigFormat,
|
||||
service_tier,
|
||||
metadata,
|
||||
container,
|
||||
inference_geo,
|
||||
cache_control,
|
||||
}
|
||||
})
|
||||
|
||||
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
|
||||
const claudeVersion = (id: string) => {
|
||||
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
|
||||
@@ -1097,35 +1037,12 @@ const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: Anthr
|
||||
}
|
||||
}
|
||||
|
||||
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
|
||||
if (!ProviderShared.isRecord(input)) return undefined
|
||||
if (input.type === "disabled") return { type: "disabled" as const }
|
||||
if (input.type !== "adaptive" && input.type !== "enabled") return undefined
|
||||
const block_binding = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(AnthropicThinkingBlockBinding)),
|
||||
)(input.block_binding)
|
||||
const display =
|
||||
input.display === "summarized" || input.display === "omitted"
|
||||
? (input.display as "summarized" | "omitted")
|
||||
: undefined
|
||||
if (input.type === "adaptive") return { type: "adaptive" as const, display, block_binding }
|
||||
const budget =
|
||||
typeof input.budgetTokens === "number"
|
||||
? input.budgetTokens
|
||||
: typeof input.budget_tokens === "number"
|
||||
? input.budget_tokens
|
||||
: undefined
|
||||
if (budget === undefined)
|
||||
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
|
||||
return { type: "enabled" as const, budget_tokens: budget, display, block_binding }
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const options = yield* resolveOptions(request)
|
||||
const updates = resolveEffortUpdates(request, options.effort)
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const management = options.contextManagement
|
||||
const outputConfig = options.output_config ?? options.outputConfig
|
||||
const format = outputConfig?.format ?? undefined
|
||||
const updates = resolveEffortUpdates(request, options.effort ?? outputConfig?.effort ?? undefined)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
@@ -1161,12 +1078,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
)
|
||||
}
|
||||
const output_config =
|
||||
updates.effort === undefined && options.format === undefined
|
||||
? undefined
|
||||
: {
|
||||
...(updates.effort === undefined ? {} : { effort: updates.effort }),
|
||||
...(options.format === undefined ? {} : { format: options.format }),
|
||||
}
|
||||
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
|
||||
const body = {
|
||||
model: request.model.id,
|
||||
system,
|
||||
@@ -1179,14 +1091,14 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
top_p: generation?.topP,
|
||||
top_k: generation?.topK,
|
||||
stop_sequences: generation?.stop,
|
||||
thinking: options.thinking,
|
||||
thinking: applyThinkingBindingDefault(request.model, options.thinking),
|
||||
output_config,
|
||||
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
|
||||
cache_control: options.cache_control,
|
||||
cache_control: options.cache_control ?? options.cacheControl,
|
||||
container: options.container,
|
||||
inference_geo: options.inference_geo,
|
||||
inference_geo: options.inference_geo ?? options.inferenceGeo ?? undefined,
|
||||
metadata: options.metadata,
|
||||
service_tier: options.service_tier,
|
||||
service_tier: options.service_tier ?? options.serviceTier,
|
||||
}
|
||||
if (!management) return body
|
||||
return {
|
||||
|
||||
@@ -14,12 +14,13 @@ import {
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ProviderOptions,
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
} from "../schema/index.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, knownString, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
@@ -50,35 +51,8 @@ const omitsFunctionCallIds = (modelID: string) => {
|
||||
return match !== null && Number(match[1]) < 3
|
||||
}
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly cachedContent?: string
|
||||
readonly safetySettings?: ReadonlyArray<{
|
||||
readonly category:
|
||||
| "HARM_CATEGORY_UNSPECIFIED"
|
||||
| "HARM_CATEGORY_HATE_SPEECH"
|
||||
| "HARM_CATEGORY_DANGEROUS_CONTENT"
|
||||
| "HARM_CATEGORY_HARASSMENT"
|
||||
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
|
||||
| "HARM_CATEGORY_CIVIC_INTEGRITY"
|
||||
| (string & {})
|
||||
readonly threshold:
|
||||
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
|
||||
| "BLOCK_LOW_AND_ABOVE"
|
||||
| "BLOCK_MEDIUM_AND_ABOVE"
|
||||
| "BLOCK_ONLY_HIGH"
|
||||
| "BLOCK_NONE"
|
||||
| "OFF"
|
||||
| (string & {})
|
||||
}>
|
||||
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
|
||||
readonly thinkingConfig?: {
|
||||
readonly thinkingBudget?: number
|
||||
readonly includeThoughts?: boolean
|
||||
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller-facing provider options; unknown keys are accepted and ignored. */
|
||||
export type OptionsInput = ProviderOptions & typeof Options.Encoded
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
|
||||
// =============================================================================
|
||||
@@ -161,17 +135,50 @@ const GeminiToolConfig = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
const GeminiThinkingLevel = knownString<"minimal" | "low" | "medium" | "high">()
|
||||
const GeminiThinkingConfig = Schema.Struct({
|
||||
thinkingBudget: Schema.optional(Schema.Number),
|
||||
includeThoughts: Schema.optional(Schema.Boolean),
|
||||
thinkingLevel: Schema.optional(Schema.String),
|
||||
thinkingLevel: Schema.optional(GeminiThinkingLevel),
|
||||
})
|
||||
|
||||
const GeminiSafetySetting = Schema.Struct({
|
||||
category: Schema.String,
|
||||
threshold: Schema.String,
|
||||
category: knownString<
|
||||
| "HARM_CATEGORY_UNSPECIFIED"
|
||||
| "HARM_CATEGORY_HATE_SPEECH"
|
||||
| "HARM_CATEGORY_DANGEROUS_CONTENT"
|
||||
| "HARM_CATEGORY_HARASSMENT"
|
||||
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
|
||||
| "HARM_CATEGORY_CIVIC_INTEGRITY"
|
||||
>(),
|
||||
threshold: knownString<
|
||||
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
|
||||
| "BLOCK_LOW_AND_ABOVE"
|
||||
| "BLOCK_MEDIUM_AND_ABOVE"
|
||||
| "BLOCK_ONLY_HIGH"
|
||||
| "BLOCK_NONE"
|
||||
| "OFF"
|
||||
>(),
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Provider Options
|
||||
// =============================================================================
|
||||
// Malformed fields are dropped rather than failing the request; a `thinkingConfig`
|
||||
// object that omits `includeThoughts` asks for thoughts.
|
||||
const GeminiThinkingConfigInput = Schema.Struct({
|
||||
thinkingBudget: lenient(Schema.Number),
|
||||
includeThoughts: lenient(Schema.Boolean),
|
||||
thinkingLevel: lenient(GeminiThinkingLevel),
|
||||
})
|
||||
const Options = Schema.Struct({
|
||||
cachedContent: lenient(Schema.String),
|
||||
safetySettings: lenient(Schema.Array(GeminiSafetySetting)),
|
||||
serviceTier: lenient(knownString<"standard" | "flex" | "priority">()),
|
||||
thinkingConfig: lenient(GeminiThinkingConfigInput),
|
||||
})
|
||||
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
|
||||
|
||||
const GeminiGenerationConfig = Schema.Struct({
|
||||
maxOutputTokens: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
@@ -431,44 +438,11 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
return contents
|
||||
})
|
||||
|
||||
const resolveOptions = (request: LLMRequest) => {
|
||||
const input = request.providerOptions
|
||||
const value = input?.thinkingConfig
|
||||
const thinkingConfig = {
|
||||
thinkingBudget:
|
||||
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
|
||||
includeThoughts:
|
||||
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
|
||||
? value.includeThoughts
|
||||
: ProviderShared.isRecord(value)
|
||||
? true
|
||||
: undefined,
|
||||
thinkingLevel:
|
||||
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
|
||||
}
|
||||
return {
|
||||
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
|
||||
safetySettings: mapSafetySettings(input?.safetySettings),
|
||||
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
|
||||
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapSafetySettings(value: unknown) {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
const settings = value.flatMap((item) =>
|
||||
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
|
||||
? [{ category: item.category, threshold: item.threshold }]
|
||||
: [],
|
||||
)
|
||||
return settings
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
const hasTools = flattened.tools.length > 0
|
||||
const generation = request.generation
|
||||
const options = resolveOptions(request)
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const generationConfig = {
|
||||
maxOutputTokens: generation?.maxTokens,
|
||||
@@ -479,7 +453,10 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
presencePenalty: generation?.presencePenalty,
|
||||
seed: generation?.seed,
|
||||
stopSequences: generation?.stop,
|
||||
thinkingConfig: options.thinkingConfig,
|
||||
thinkingConfig:
|
||||
options.thinkingConfig === undefined
|
||||
? undefined
|
||||
: { ...options.thinkingConfig, includeThoughts: options.thinkingConfig.includeThoughts ?? true },
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import {
|
||||
@@ -29,6 +29,16 @@ const isJson = Schema.is(Schema.Json)
|
||||
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))
|
||||
/** Optional field whose malformed value decodes to `undefined` instead of failing the enclosing struct. */
|
||||
export const lenient = <const S extends Schema.Top>(schema: S) =>
|
||||
Schema.optionalKey(
|
||||
Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined)))),
|
||||
)
|
||||
/** Provider-defined string enum: known values for autocomplete, any string accepted at runtime. */
|
||||
export const knownString = <Known extends string>() =>
|
||||
Schema.declare<Known | (string & {})>((value): value is Known | (string & {}) => typeof value === "string", {
|
||||
expected: "string",
|
||||
})
|
||||
|
||||
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { Schema } from "effect"
|
||||
import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js"
|
||||
import { lenient } from "../shared.js"
|
||||
|
||||
export { ReasoningEffort, ReasoningEfforts }
|
||||
|
||||
@@ -49,21 +50,22 @@ export const StreamOptions = Schema.Struct({
|
||||
includeObfuscation: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
// Malformed options are dropped one at a time so a bad `topLogprobs` cannot discard `store` or `reasoningEffort`.
|
||||
export const Options = Schema.Struct({
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
safetyIdentifier: Schema.optional(Schema.String),
|
||||
streamOptions: Schema.optional(StreamOptions),
|
||||
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
|
||||
reasoningEffort: Schema.optional(ReasoningEffort),
|
||||
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
|
||||
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
|
||||
textVerbosity: Schema.optional(TextVerbositySchema),
|
||||
serviceTier: Schema.optional(ServiceTierSchema),
|
||||
truncation: Schema.optional(TruncationSchema),
|
||||
allowedTools: Schema.optional(AllowedTools),
|
||||
maxToolCalls: Schema.optional(Schema.Int),
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
store: lenient(Schema.Boolean),
|
||||
metadata: lenient(Schema.Record(Schema.String, Schema.String)),
|
||||
safetyIdentifier: lenient(Schema.String),
|
||||
streamOptions: lenient(StreamOptions),
|
||||
topLogprobs: lenient(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
|
||||
reasoningEffort: lenient(ReasoningEffort),
|
||||
reasoningSummary: lenient(Schema.Literals(["auto", "concise", "detailed"])),
|
||||
include: lenient(Schema.Array(ResponseIncludableSchema)),
|
||||
textVerbosity: lenient(TextVerbositySchema),
|
||||
serviceTier: lenient(ServiceTierSchema),
|
||||
truncation: lenient(TruncationSchema),
|
||||
allowedTools: lenient(AllowedTools),
|
||||
maxToolCalls: lenient(Schema.Int),
|
||||
parallelToolCalls: lenient(Schema.Boolean),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
@@ -71,11 +73,10 @@ export type Resolved = Omit<Options, "allowedTools"> & {
|
||||
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
|
||||
}
|
||||
|
||||
const decodeOptions = Schema.decodeUnknownOption(Options)
|
||||
const decodeOptions = Schema.decodeUnknownSync(Options)
|
||||
|
||||
export const resolve = (request: LLMRequest): Resolved => {
|
||||
const input = Option.getOrUndefined(decodeOptions(request.providerOptions))
|
||||
if (!input) return {}
|
||||
const input = decodeOptions(request.providerOptions ?? {})
|
||||
return {
|
||||
...input,
|
||||
include: input.include?.length ? input.include : undefined,
|
||||
|
||||
@@ -166,7 +166,71 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("Anthropic thinking provider option requires budgetTokens")
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("budgetTokens")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers passthrough provider options and accepts either key spelling", () =>
|
||||
Effect.gen(function* () {
|
||||
const snake = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {
|
||||
service_tier: "auto",
|
||||
metadata: { user_id: "user_1" },
|
||||
container: { id: "container_1" },
|
||||
inference_geo: "us",
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const camel = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {
|
||||
serviceTier: "standard_only",
|
||||
container: "container_2",
|
||||
inferenceGeo: "eu",
|
||||
cacheControl: { type: "ephemeral" },
|
||||
outputConfig: { effort: "low" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(snake.body).toMatchObject({
|
||||
service_tier: "auto",
|
||||
metadata: { user_id: "user_1" },
|
||||
container: { id: "container_1" },
|
||||
inference_geo: "us",
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
|
||||
})
|
||||
expect(camel.body).toMatchObject({
|
||||
service_tier: "standard_only",
|
||||
container: "container_2",
|
||||
inference_geo: "eu",
|
||||
cache_control: { type: "ephemeral" },
|
||||
output_config: { effort: "low" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores unknown provider options and rejects malformed known ones", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { unknownOption: true } }))
|
||||
const malformed = [
|
||||
{ service_tier: "fast" },
|
||||
{ metadata: { user_id: 42 } },
|
||||
{ cache_control: { type: "ephemeral", ttl: "2h" } },
|
||||
{ output_config: { format: { type: "text" } } },
|
||||
{ thinking: { type: "automatic" } },
|
||||
]
|
||||
const errors = yield* Effect.forEach(malformed, (providerOptions) =>
|
||||
compileRequest(LLMRequest.update(request, { providerOptions })).pipe(Effect.flip),
|
||||
)
|
||||
|
||||
expect(prepared.body).not.toHaveProperty("unknownOption")
|
||||
expect(errors.map((error) => error.reason._tag)).toEqual(malformed.map(() => "InvalidRequest"))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -248,6 +248,21 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps valid Chat options when a sibling option is malformed", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
|
||||
prompt: "think",
|
||||
providerOptions: { store: true, reasoningEffort: "max", topLogprobs: 25 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.store).toBe(true)
|
||||
expect(prepared.body.reasoning_effort).toBe("max")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps the request prompt cache key when the compatibility flag is set", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -1945,6 +1945,30 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops a malformed provider option without discarding its siblings", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "hi",
|
||||
providerOptions: {
|
||||
topLogprobs: 25,
|
||||
metadata: { tenant: 7 },
|
||||
reasoningEffort: "high",
|
||||
serviceTier: "priority",
|
||||
maxToolCalls: 4,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.top_logprobs).toBeUndefined()
|
||||
expect(prepared.body.metadata).toBeUndefined()
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "high" })
|
||||
expect(prepared.body.service_tier).toBe("priority")
|
||||
expect(prepared.body.max_tool_calls).toBe(4)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts the full ResponseIncludable union", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -52,14 +52,14 @@ export function createComposerAttachments(
|
||||
// Uploads this composer started; they finish (or fail) even if the composer unmounts.
|
||||
const [pending, setPending] = createStore<{ ids: string[] }>({ ids: [] })
|
||||
|
||||
// A file the model reads natively travels inline with the prompt, so its bytes live in the draft
|
||||
// store. Anything else reaches the model as a path on the server and never enters the store:
|
||||
// hashing and copying a large archive through it is what used to freeze the window.
|
||||
// Media the model reads natively travels inline with the prompt, so its bytes live in the draft
|
||||
// store. Everything else, including text, reaches the model as a path on the server that its
|
||||
// tools open; those bytes never enter the store, and never get base64-encoded into the request.
|
||||
const add = async (file: File, target = capture(), clipboard = false) => {
|
||||
if (!target) return false
|
||||
const mime = await attachmentMime(file)
|
||||
const destination = input.destination()
|
||||
if (native(mime, destination.input)) return addInline(file, mime, target, clipboard)
|
||||
if (native(mime, destination.input) && file.size <= MAX_INLINE_BYTES) return addInline(file, mime, target, clipboard)
|
||||
const sourcePath = input.getPathForFile?.(file) || undefined
|
||||
if (destination.local && sourcePath) return addPath(target, { filename: file.name, mime, path: sourcePath })
|
||||
void stage(file, mime, target, destination)
|
||||
@@ -213,9 +213,11 @@ export function createComposerAttachments(
|
||||
|
||||
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
||||
|
||||
// Mirrors the attachment kinds the server forwards to the model as message content.
|
||||
// The server rejects inline attachments above this size, so larger media takes the path route.
|
||||
const MAX_INLINE_BYTES = 20 * 1024 * 1024
|
||||
|
||||
// Mirrors the media the server forwards to the model as message content.
|
||||
function native(mime: string, input: AttachmentDestination["input"]) {
|
||||
if (mime === "text/plain") return true
|
||||
if (imageMimes.has(mime)) return input.image
|
||||
if (mime === "application/pdf") return input.pdf
|
||||
return false
|
||||
@@ -238,8 +240,8 @@ const textMimes = new Set([
|
||||
"application/yaml",
|
||||
])
|
||||
|
||||
// Text-like files normalize to text/plain so the server inlines their content; every other
|
||||
// file keeps a binary type and is delivered to the model by path or as native media.
|
||||
// Text-like files normalize to text/plain so the chip labels them as text; every other file keeps
|
||||
// a binary type. Delivery is decided separately: native media inline, everything else by path.
|
||||
async function attachmentMime(file: File) {
|
||||
const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
||||
if (imageMimes.has(type) || type === "application/pdf") return type
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { ProviderIcon } from "@opencode/ui/provider-icon"
|
||||
import { useI18n } from "@opencode/ui/context/i18n"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
@@ -545,17 +546,21 @@ export function ComposerAttachments(props: {
|
||||
</AttachmentCard>
|
||||
}
|
||||
>
|
||||
{(image) => (
|
||||
<>
|
||||
<img
|
||||
src={image().blob.url}
|
||||
alt={attachment.filename}
|
||||
class="w-[58px] h-[46px] rounded-[6px] object-cover"
|
||||
onClick={() => props.onAttachmentClick?.(attachment)}
|
||||
/>
|
||||
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
|
||||
</>
|
||||
)}
|
||||
{(image) => {
|
||||
// Restored drafts and history carry image ids only; bytes load when shown.
|
||||
const [url] = createResource(() => image().blob, resolveBlobUrl)
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={url() ?? ""}
|
||||
alt={attachment.filename}
|
||||
class="w-[58px] h-[46px] rounded-[6px] object-cover"
|
||||
onClick={() => props.onAttachmentClick?.(attachment)}
|
||||
/>
|
||||
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</Tooltip>
|
||||
<button
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { createSessionTabs } from "@/session/helpers"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
@@ -322,7 +323,9 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
},
|
||||
openAttachment: (attachment) => {
|
||||
if (attachment.type !== "image") return
|
||||
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename }))
|
||||
void resolveBlobUrl(attachment.blob).then((src) => {
|
||||
if (src) dialog.show(() => createComponent(ImagePreview, { src, alt: attachment.filename }))
|
||||
})
|
||||
},
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
|
||||
@@ -136,7 +136,7 @@ describe("composer persistence schemas", () => {
|
||||
).toEqual(value)
|
||||
})
|
||||
|
||||
test("migrates inline images but never encodes dataUrl or unresolved references", () => {
|
||||
test("migrates inline images, keeps store references without a URL, and never encodes dataUrl", () => {
|
||||
const value = Schema.decodeUnknownSync(
|
||||
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
|
||||
)({
|
||||
@@ -149,12 +149,16 @@ describe("composer persistence schemas", () => {
|
||||
{ ...image, blob: { id: "missing" }, dataUrl: "data:image/png;base64,YQ==" },
|
||||
],
|
||||
})
|
||||
expect(value.prompt).toHaveLength(3)
|
||||
expect(value.prompt).toHaveLength(6)
|
||||
expect(value.prompt[0]).toEqual({
|
||||
...image,
|
||||
sourcePath: "/image.png",
|
||||
blob: { id: "data:image/png;base64,YQ==", url: "data:image/png;base64,YQ==" },
|
||||
})
|
||||
// Bytes still in the draft store resolve on use; a non-blob URL is discarded in favour of the id.
|
||||
expect(value.prompt[3]).toEqual({ ...image, blob: { id: "missing", url: "" } })
|
||||
expect(value.prompt[4]).toEqual({ ...image, blob: { id: "bad", url: "" } })
|
||||
expect(value.prompt[5]).toEqual({ ...image, blob: { id: "missing", url: "" } })
|
||||
const encoded = Schema.encodeSync(ComposerStore)(value)
|
||||
expect(JSON.stringify(encoded)).not.toContain("dataUrl")
|
||||
expect(
|
||||
|
||||
@@ -61,11 +61,12 @@ const ImageFields = {
|
||||
}
|
||||
const Image = Persistence.struct({
|
||||
...ImageFields,
|
||||
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:)/)) }),
|
||||
// An empty URL is an image whose bytes are still in the draft store; see `resolveBlobUrl`.
|
||||
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:|$)/)) }),
|
||||
})
|
||||
|
||||
// Draft storage hydrates content-addressed blobs before this codec runs. Legacy
|
||||
// inline data remains usable, but unresolved references are not renderable.
|
||||
// Draft storage keeps content-addressed blobs in the store until an image is shown or sent; a
|
||||
// reference without a URL resolves through `resolveBlobUrl`. Legacy inline data remains usable.
|
||||
export const ImageAttachmentPart = Schema.Struct({
|
||||
...ImageFields,
|
||||
blob: Persistence.optional(
|
||||
|
||||
@@ -88,6 +88,15 @@ describe("prompt state initialization", () => {
|
||||
start: 5,
|
||||
end: 12,
|
||||
},
|
||||
// A reference without a usable URL keeps its id; the bytes resolve from the draft store on use.
|
||||
{
|
||||
type: "image",
|
||||
id: "missing-blob",
|
||||
filename: "missing.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "content-hash-without-a-url", url: "" },
|
||||
},
|
||||
{ type: "image", id: "invalid-url", filename: "invalid.png", mime: "image/png", blob: { id: "hash", url: "" } },
|
||||
{
|
||||
type: "image",
|
||||
id: "legacy",
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSess
|
||||
import { createComposerSubmission } from "./submission-state"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { setCursorPosition } from "./editor/dom"
|
||||
import { blobDataUrl } from "@/runtime/persistence/drafts"
|
||||
import { blobDataUrl, resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { isAttachment } from "./prompt-parts"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
|
||||
@@ -59,12 +59,22 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
selection: item.selection ? { ...item.selection } : undefined,
|
||||
})),
|
||||
})
|
||||
const value = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
|
||||
if (!value) {
|
||||
const read = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
|
||||
if (!read) {
|
||||
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
|
||||
return
|
||||
}
|
||||
if (submitting.has(input.adapter.state)) return
|
||||
// Images restored from a draft or history carry ids only; the optimistic message shows their URLs.
|
||||
const value = {
|
||||
...read,
|
||||
images: await Promise.all(
|
||||
read.images.map(async (image) => ({
|
||||
...image,
|
||||
blob: { ...image.blob, url: (await resolveBlobUrl(image.blob)) ?? image.blob.url },
|
||||
})),
|
||||
),
|
||||
}
|
||||
submitting.add(input.adapter.state)
|
||||
const comments = input.comments.capture()
|
||||
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createDraftStore, draftTextChunk, draftTextThreshold } from "./drafts"
|
||||
import { createDraftStore, draftTextChunk, draftTextThreshold, resolveBlobUrl } from "./drafts"
|
||||
|
||||
function memoryDriver() {
|
||||
const documents = new Map<string, string>()
|
||||
@@ -287,15 +287,34 @@ describe("draft store image retention", () => {
|
||||
expect(await released(store, 5, shared.url)).toBe(true)
|
||||
})
|
||||
|
||||
test("loading a document pins the images it references", async () => {
|
||||
test("loading a document pins the images it references without fetching their bytes", async () => {
|
||||
const { memory, store } = fresh()
|
||||
const reads: string[] = []
|
||||
const getBlob = memory.driver.getBlob
|
||||
memory.driver.getBlob = (id) => {
|
||||
reads.push(id)
|
||||
return getBlob(id)
|
||||
}
|
||||
const id = await memory.driver.putBlob(image(6))
|
||||
memory.documents.set("loaded", JSON.stringify({ prompt: [{ type: "image", blob: { id } }] }))
|
||||
const url = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob.url
|
||||
const loaded = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob
|
||||
expect(loaded).toEqual({ id })
|
||||
expect(reads).toEqual([])
|
||||
// The first consumer that shows or sends the image loads it; the pin from the load keeps it.
|
||||
const url = (await resolveBlobUrl(loaded))!
|
||||
expect(url.startsWith("blob:")).toBe(true)
|
||||
expect(reads).toEqual([id])
|
||||
expect(await resolveBlobUrl(loaded)).toBe(url)
|
||||
expect(reads).toEqual([id])
|
||||
await tick()
|
||||
expect(await released(store, 6, url)).toBe(false)
|
||||
await store.removeItem("loaded")
|
||||
await tick()
|
||||
expect(await released(store, 6, url)).toBe(true)
|
||||
})
|
||||
|
||||
test("a reference to bytes the store no longer holds resolves to nothing", async () => {
|
||||
fresh()
|
||||
expect(await resolveBlobUrl({ id: "gone" })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,6 +42,18 @@ const refs = new Map<string, Set<string>>()
|
||||
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
|
||||
// ones); live references still carry the original.
|
||||
const aliases = new Map<string, string>()
|
||||
// Fetches image bytes from the store created last. Documents load without their bytes; a consumer
|
||||
// that renders or sends an image resolves its URL through here, so a history full of large
|
||||
// attachments costs nothing at startup.
|
||||
let loader: ((id: string) => Promise<string | undefined>) | undefined
|
||||
|
||||
/** The object URL for an image reference, loading its bytes from the draft store on first use. */
|
||||
export function resolveBlobUrl(blob: { id: string; url?: string }) {
|
||||
if (blob.url) return Promise.resolve(blob.url)
|
||||
const existing = retained.get(aliases.get(blob.id) ?? blob.id)
|
||||
if (existing) return Promise.resolve(existing.url)
|
||||
return loader?.(blob.id) ?? Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
function blobUrl(id: string, blob: Blob, grace?: number) {
|
||||
const existing = retained.get(id)
|
||||
@@ -120,7 +132,7 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
const loading = new Map<string, Promise<string | undefined>>()
|
||||
const loadBlobUrl = (id: string) => {
|
||||
const existing = retained.get(id)
|
||||
if (existing) return existing.url
|
||||
if (existing) return Promise.resolve(existing.url)
|
||||
const pending = loading.get(id)
|
||||
if (pending) return pending
|
||||
const next = driver
|
||||
@@ -130,6 +142,7 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
loading.set(id, next)
|
||||
return next
|
||||
}
|
||||
loader = loadBlobUrl
|
||||
const putBlob = async (blob: Blob) => {
|
||||
const id = await driver.putBlob(blob)
|
||||
return { id, url: blobUrl(id, blob, grace) }
|
||||
@@ -221,9 +234,11 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
if (ref.kind === "text" && Array.isArray(ref.ids)) {
|
||||
return (await Promise.all(ref.ids.map((id) => loadChunk(String(id))))).join("")
|
||||
}
|
||||
// Bytes stay in the store until something renders or sends the image (see resolveBlobUrl);
|
||||
// only an image already pinned in this page gets its URL back immediately.
|
||||
if (typeof ref.id === "string") {
|
||||
const url = await loadBlobUrl(ref.id)
|
||||
if (url) return { ...item, blob: { id: ref.id, url } }
|
||||
const url = retained.get(aliases.get(ref.id) ?? ref.id)?.url
|
||||
return { ...item, blob: url ? { id: ref.id, url } : { id: ref.id } }
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
@@ -423,7 +438,9 @@ function referenced(json: string) {
|
||||
|
||||
export async function blobDataUrl(blob: BlobReference, mime: string) {
|
||||
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
|
||||
const data = kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
|
||||
const url = kept ? undefined : await resolveBlobUrl(blob)
|
||||
if (!kept && !url) throw new Error(`Attachment ${blob.id} has no stored bytes`)
|
||||
const data = kept ? kept.blob : await fetch(url!).then((response) => response.blob())
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener("error", () => reject(reader.error))
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createResource } from "solid-js"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
|
||||
export function createCameraAvailability() {
|
||||
const platform = usePlatform()
|
||||
const supported = platform.platform === "web" && window.isSecureContext && !!navigator.mediaDevices?.getUserMedia
|
||||
const [available, actions] = createResource(
|
||||
async () => {
|
||||
if (!supported || !navigator.mediaDevices.enumerateDevices) return false
|
||||
const denied = await navigator.permissions?.query({ name: "camera" }).then(
|
||||
(permission) => permission.state === "denied",
|
||||
() => false,
|
||||
)
|
||||
if (denied) return false
|
||||
return navigator.mediaDevices.enumerateDevices().then(
|
||||
(devices) => devices.some((device) => device.kind === "videoinput"),
|
||||
() => false,
|
||||
)
|
||||
},
|
||||
{ initialValue: false },
|
||||
)
|
||||
return { supported, available, refetch: actions.refetch }
|
||||
}
|
||||
@@ -4,7 +4,17 @@ import { Divider } from "@opencode/ui/divider"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { type Component, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import {
|
||||
type Component,
|
||||
Show,
|
||||
Suspense,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
lazy,
|
||||
onCleanup,
|
||||
onMount,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
createServerHealthPreview,
|
||||
@@ -18,8 +28,12 @@ import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useCheckServerHealth } from "@/runtime/server/health"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { isMixedContent } from "./browser"
|
||||
import { createCameraAvailability } from "./camera"
|
||||
import { decodePairingCode } from "./pairing"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
|
||||
|
||||
type FormMode = "list" | "add" | "edit"
|
||||
|
||||
export const DialogServer: Component<{
|
||||
@@ -29,6 +43,8 @@ export const DialogServer: Component<{
|
||||
}> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const camera = createCameraAvailability()
|
||||
const form = createFormController({
|
||||
onSelect: (server) => {
|
||||
props.onSave?.(server)
|
||||
@@ -75,64 +91,112 @@ export const DialogServer: Component<{
|
||||
</DialogHeader>
|
||||
<Divider />
|
||||
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
|
||||
<div class="flex w-full min-w-0 flex-col gap-6">
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.url")}</label>
|
||||
<TextInput
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.value()}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
invalid={!!form.state.error()}
|
||||
disabled={form.state.busy()}
|
||||
autofocus
|
||||
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
|
||||
onInput={(event) => form.change.value(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<Show when={form.state.error()}>
|
||||
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
|
||||
{form.state.error()}
|
||||
</span>
|
||||
<Show
|
||||
when={!form.state.scanning()}
|
||||
fallback={
|
||||
<Suspense fallback={<p role="status">{language.t("server.connect.camera.starting")}</p>}>
|
||||
<PairingScanner
|
||||
onCancel={() => {
|
||||
form.scan.stop()
|
||||
void camera.refetch()
|
||||
}}
|
||||
onScan={form.scan.complete}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
>
|
||||
<div class="flex w-full min-w-0 flex-col gap-6">
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.url")}</label>
|
||||
<TextInput
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.value()}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
invalid={!!form.state.error()}
|
||||
disabled={form.state.busy()}
|
||||
autofocus
|
||||
list="dialog-server-addresses"
|
||||
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
|
||||
onInput={(event) => form.change.value(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<datalist id="dialog-server-addresses">
|
||||
{form.state.urls().map((url) => (
|
||||
<option value={url} />
|
||||
))}
|
||||
</datalist>
|
||||
<Show when={form.state.error()}>
|
||||
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
|
||||
{form.state.error()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.name")}</label>
|
||||
<TextInput
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.name()}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.name(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.password")}</label>
|
||||
<TextInput
|
||||
type="password"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.password()}
|
||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.password(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
<Show when={props.mode === "add" && platform.platform === "web"}>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<Button
|
||||
variant="neutral"
|
||||
size="large"
|
||||
class="!w-full self-stretch"
|
||||
disabled={form.state.busy() || !camera.available.latest}
|
||||
aria-describedby={
|
||||
!camera.available.latest && !camera.available.loading
|
||||
? "dialog-server-camera-unavailable"
|
||||
: undefined
|
||||
}
|
||||
onClick={form.scan.start}
|
||||
>
|
||||
{language.t("server.connect.scan")}
|
||||
</Button>
|
||||
<Show when={!camera.available.latest && !camera.available.loading}>
|
||||
<span id="dialog-server-camera-unavailable" class="settings-server-dialog-hint">
|
||||
{language.t(
|
||||
window.isSecureContext ? "server.connect.camera.unavailable" : "server.connect.camera.insecure",
|
||||
)}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.name")}</label>
|
||||
<TextInput
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.name()}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.name(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.password")}</label>
|
||||
<TextInput
|
||||
type="password"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.password()}
|
||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.password(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
|
||||
{submitLabel()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<Show when={!form.state.scanning()}>
|
||||
<DialogFooter>
|
||||
<Button variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
|
||||
{submitLabel()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Show>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -149,6 +213,8 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
mode: "list" as FormMode,
|
||||
originalUrl: undefined as string | undefined,
|
||||
values: { url: "", name: "", password: "" },
|
||||
urls: [] as string[],
|
||||
scanning: false,
|
||||
error: "",
|
||||
status: undefined as boolean | undefined,
|
||||
})
|
||||
@@ -161,6 +227,8 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
mode: "list",
|
||||
originalUrl: undefined,
|
||||
values: { url: "", name: "", password: "" },
|
||||
urls: [],
|
||||
scanning: false,
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
@@ -265,6 +333,16 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
setStore("error", "")
|
||||
request.mutate()
|
||||
}
|
||||
const pair = (pairing: NonNullable<ReturnType<typeof decodePairingCode>>) => {
|
||||
healthPreview.cancel()
|
||||
setStore({
|
||||
values: { ...store.values, url: pairing.urls[0], password: pairing.password },
|
||||
urls: pairing.urls,
|
||||
scanning: false,
|
||||
error: "",
|
||||
})
|
||||
request.mutate()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (store.mode !== "edit") return
|
||||
@@ -281,6 +359,8 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
value: () => store.values.url,
|
||||
name: () => store.values.name,
|
||||
password: () => store.values.password,
|
||||
urls: () => store.urls,
|
||||
scanning: () => store.scanning,
|
||||
error: () => store.error,
|
||||
status: () => store.status,
|
||||
},
|
||||
@@ -289,6 +369,11 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
name: (value: string) => change("name", value),
|
||||
password: (value: string) => change("password", value),
|
||||
},
|
||||
scan: {
|
||||
start: () => setStore("scanning", true),
|
||||
stop: () => setStore("scanning", false),
|
||||
complete: pair,
|
||||
},
|
||||
start: { add: startAdd, edit: startEdit },
|
||||
reset,
|
||||
submit,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
.server-connect-scanner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-connect-error {
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
.server-connect-video {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: var(--v2-background-bg-deep);
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.server-connect-video [role="status"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { decodePairingCode } from "./pairing"
|
||||
import "./scanner.css"
|
||||
|
||||
export function PairingScanner(props: {
|
||||
onScan: (value: NonNullable<ReturnType<typeof decodePairingCode>>) => void
|
||||
|
||||
@@ -41,8 +41,7 @@
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
form,
|
||||
.server-connect-scanner {
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
@@ -95,25 +94,4 @@
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.server-connect-video {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: var(--v2-background-bg-deep);
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.server-connect-video [role="status"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createResource, lazy, Show, Suspense } from "solid-js"
|
||||
import { lazy, Show, Suspense } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
@@ -10,6 +10,7 @@ import { useCheckServerHealth } from "@/runtime/server/health"
|
||||
import { useServers } from "@/runtime/server/registry"
|
||||
import { serverAddress } from "./pairing"
|
||||
import { isMixedContent } from "./browser"
|
||||
import { createCameraAvailability } from "./camera"
|
||||
import "./screen.css"
|
||||
|
||||
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
|
||||
@@ -19,23 +20,7 @@ export function ConnectServerScreen() {
|
||||
const platform = usePlatform()
|
||||
const servers = useServers()
|
||||
const check = useCheckServerHealth()
|
||||
const cameraSupported =
|
||||
platform.platform === "web" && window.isSecureContext && !!navigator.mediaDevices?.getUserMedia
|
||||
const [camera, cameraActions] = createResource(
|
||||
async () => {
|
||||
if (!cameraSupported || !navigator.mediaDevices.enumerateDevices) return false
|
||||
const denied = await navigator.permissions?.query({ name: "camera" }).then(
|
||||
(permission) => permission.state === "denied",
|
||||
() => false,
|
||||
)
|
||||
if (denied) return false
|
||||
return navigator.mediaDevices.enumerateDevices().then(
|
||||
(devices) => devices.some((device) => device.kind === "videoinput"),
|
||||
() => false,
|
||||
)
|
||||
},
|
||||
{ initialValue: false },
|
||||
)
|
||||
const camera = createCameraAvailability()
|
||||
const [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
|
||||
const connectionError = () =>
|
||||
language.t(
|
||||
@@ -78,7 +63,7 @@ export function ConnectServerScreen() {
|
||||
<PairingScanner
|
||||
onCancel={() => {
|
||||
setState("scanning", false)
|
||||
void cameraActions.refetch()
|
||||
void camera.refetch()
|
||||
}}
|
||||
onScan={(pairing) => {
|
||||
setState({
|
||||
@@ -154,13 +139,15 @@ export function ConnectServerScreen() {
|
||||
<Button
|
||||
variant="neutral"
|
||||
size="large"
|
||||
disabled={request.isPending || !camera.latest}
|
||||
aria-describedby={!camera.latest && !camera.loading ? "server-connect-camera-unavailable" : undefined}
|
||||
disabled={request.isPending || !camera.available.latest}
|
||||
aria-describedby={
|
||||
!camera.available.latest && !camera.available.loading ? "server-connect-camera-unavailable" : undefined
|
||||
}
|
||||
onClick={() => setState("scanning", true)}
|
||||
>
|
||||
{language.t("server.connect.scan")}
|
||||
</Button>
|
||||
<Show when={!camera.latest && !camera.loading}>
|
||||
<Show when={!camera.available.latest && !camera.available.loading}>
|
||||
<p id="server-connect-camera-unavailable">
|
||||
{language.t(
|
||||
window.isSecureContext ? "server.connect.camera.unavailable" : "server.connect.camera.insecure",
|
||||
|
||||
@@ -1517,6 +1517,12 @@
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
.settings-server-dialog-hint {
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
|
||||
width: min(280px, 100%);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { resolveObjectURL } from "node:buffer"
|
||||
import { createDraftStore } from "@/runtime/persistence/drafts"
|
||||
import { createDraftStore, resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
|
||||
function fixture(id: string, getBlob: () => Promise<Blob | null>) {
|
||||
const documents = new Map([
|
||||
@@ -20,7 +20,19 @@ function fixture(id: string, getBlob: () => Promise<Blob | null>) {
|
||||
return { store, documents }
|
||||
}
|
||||
|
||||
test("deduplicates concurrent history and draft reads without invalidating either live reference", async () => {
|
||||
test("loading history and a draft reads no image bytes", async () => {
|
||||
let reads = 0
|
||||
const { store } = fixture("history-cache-lazy", async () => {
|
||||
reads++
|
||||
return new Blob(["shared screenshot"])
|
||||
})
|
||||
const [history, draft] = await Promise.all([store.getItem("history"), store.getItem("draft")])
|
||||
expect(JSON.parse(history!).entries[0].prompt[0].blob).toEqual({ id: "history-cache-lazy" })
|
||||
expect(JSON.parse(draft!).prompt[0].blob).toEqual({ id: "history-cache-lazy" })
|
||||
expect(reads).toBe(0)
|
||||
})
|
||||
|
||||
test("deduplicates concurrent resolves without invalidating either live reference", async () => {
|
||||
const pending = Promise.withResolvers<Blob | null>()
|
||||
const started = Promise.withResolvers<void>()
|
||||
let reads = 0
|
||||
@@ -29,55 +41,33 @@ test("deduplicates concurrent history and draft reads without invalidating eithe
|
||||
started.resolve()
|
||||
return pending.promise
|
||||
})
|
||||
const history = store.getItem("history")
|
||||
const draft = store.getItem("draft")
|
||||
await store.getItem("history")
|
||||
const first = resolveBlobUrl({ id: "history-cache-concurrent" })
|
||||
const second = resolveBlobUrl({ id: "history-cache-concurrent" })
|
||||
await started.promise
|
||||
pending.resolve(new Blob(["shared screenshot"]))
|
||||
const [saved, active] = await Promise.all([history, draft])
|
||||
const reference = JSON.parse(saved!).entries[0].prompt[0].blob
|
||||
expect(JSON.parse(active!).prompt[0].blob).toEqual(reference)
|
||||
const [a, b] = await Promise.all([first, second])
|
||||
expect(a).toBe(b!)
|
||||
expect(reads).toBe(1)
|
||||
await store.removeItem("history")
|
||||
expect(await resolveObjectURL(reference.url)?.text()).toBe("shared screenshot")
|
||||
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference)
|
||||
expect(await resolveObjectURL(a!)?.text()).toBe("shared screenshot")
|
||||
expect(await resolveBlobUrl({ id: "history-cache-concurrent" })).toBe(a!)
|
||||
expect(reads).toBe(1)
|
||||
})
|
||||
|
||||
test("hydrates repeated references once within one history document", async () => {
|
||||
let reads = 0
|
||||
const { store, documents } = fixture("history-cache-repeated", async () => {
|
||||
reads++
|
||||
return new Blob(["repeated screenshot"])
|
||||
})
|
||||
documents.set(
|
||||
"history",
|
||||
JSON.stringify({
|
||||
entries: Array.from({ length: 100 }, () => ({
|
||||
prompt: [{ type: "image", blob: { id: "history-cache-repeated" } }],
|
||||
})),
|
||||
}),
|
||||
)
|
||||
const value = JSON.parse((await store.getItem("history"))!)
|
||||
expect(value.entries).toHaveLength(100)
|
||||
expect(
|
||||
new Set(value.entries.map((entry: { prompt: { blob: { url: string } }[] }) => entry.prompt[0].blob.url)).size,
|
||||
).toBe(1)
|
||||
expect(reads).toBe(1)
|
||||
})
|
||||
|
||||
test("reuses a live URL on remount but reads the latest document", async () => {
|
||||
test("a document re-read while its image is live gets the URL back without a read", async () => {
|
||||
let reads = 0
|
||||
const { store, documents } = fixture("history-cache-remount", async () => {
|
||||
reads++
|
||||
return new Blob(["saved screenshot"])
|
||||
})
|
||||
const first = JSON.parse((await store.getItem("history"))!)
|
||||
const url = await resolveBlobUrl({ id: "history-cache-remount" })
|
||||
const changed = JSON.parse(documents.get("history")!)
|
||||
changed.entries[0].prompt.unshift({ type: "text", content: "new admission" })
|
||||
documents.set("history", JSON.stringify(changed))
|
||||
const second = JSON.parse((await store.getItem("history"))!)
|
||||
expect(second.entries[0].prompt[0].content).toBe("new admission")
|
||||
expect(second.entries[0].prompt[1].blob).toEqual(first.entries[0].prompt[0].blob)
|
||||
expect(second.entries[0].prompt[1].blob).toEqual({ id: "history-cache-remount", url })
|
||||
expect(reads).toBe(1)
|
||||
})
|
||||
|
||||
@@ -89,33 +79,34 @@ test("reuses a just-stored attachment without a round trip", async () => {
|
||||
})
|
||||
const reference = await store.putBlob(new Blob(["pending admission"]))
|
||||
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference)
|
||||
expect(await resolveBlobUrl({ id: reference.id })).toBe(reference.url)
|
||||
expect(reads).toBe(0)
|
||||
expect(await resolveObjectURL(reference.url)?.text()).toBe("pending admission")
|
||||
})
|
||||
|
||||
test("does not retain a missing blob result", async () => {
|
||||
let reads = 0
|
||||
const { store } = fixture("history-cache-missing", async () => (++reads === 1 ? null : new Blob(["arrived"])))
|
||||
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toBeUndefined()
|
||||
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toStartWith("blob:")
|
||||
fixture("history-cache-missing", async () => (++reads === 1 ? null : new Blob(["arrived"])))
|
||||
expect(await resolveBlobUrl({ id: "history-cache-missing" })).toBeUndefined()
|
||||
expect(await resolveBlobUrl({ id: "history-cache-missing" })).toStartWith("blob:")
|
||||
expect(reads).toBe(2)
|
||||
})
|
||||
|
||||
test("retries after a failed blob read", async () => {
|
||||
let reads = 0
|
||||
const { store } = fixture("history-cache-failure", async () => {
|
||||
fixture("history-cache-failure", async () => {
|
||||
if (++reads === 1) throw new Error("temporary storage failure")
|
||||
return new Blob(["recovered"])
|
||||
})
|
||||
await expect(store.getItem("history")).rejects.toThrow("temporary storage failure")
|
||||
expect(JSON.parse((await store.getItem("history"))!).entries[0].prompt[0].blob.url).toStartWith("blob:")
|
||||
await expect(resolveBlobUrl({ id: "history-cache-failure" })).rejects.toThrow("temporary storage failure")
|
||||
expect(await resolveBlobUrl({ id: "history-cache-failure" })).toStartWith("blob:")
|
||||
expect(reads).toBe(2)
|
||||
})
|
||||
|
||||
test("keeps different blob IDs independent", async () => {
|
||||
const reads: string[] = []
|
||||
const store = createDraftStore({
|
||||
get: async () => JSON.stringify(["history-cache-first", "history-cache-second"].map((id) => ({ blob: { id } }))),
|
||||
createDraftStore({
|
||||
get: async () => null,
|
||||
set: async () => [],
|
||||
remove: async () => {},
|
||||
putBlob: async () => "unused",
|
||||
@@ -124,10 +115,8 @@ test("keeps different blob IDs independent", async () => {
|
||||
return new Blob([id])
|
||||
},
|
||||
})
|
||||
const value = JSON.parse((await store.getItem("history"))!)
|
||||
expect(value[0].blob.url).not.toBe(value[1].blob.url)
|
||||
expect(
|
||||
await Promise.all(value.map((item: { blob: { url: string } }) => resolveObjectURL(item.blob.url)?.text())),
|
||||
).toEqual(reads)
|
||||
const urls = await Promise.all(["history-cache-first", "history-cache-second"].map((id) => resolveBlobUrl({ id })))
|
||||
expect(urls[0]).not.toBe(urls[1])
|
||||
expect(await Promise.all(urls.map((url) => resolveObjectURL(url!)?.text()))).toEqual(reads)
|
||||
expect(reads).toEqual(["history-cache-first", "history-cache-second"])
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Schema } from "effect"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
import { createComposerReady, createComposerState } from "@/composer/state"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { createDraftStore } from "@/runtime/persistence/drafts"
|
||||
import { createDraftStore, resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { flushPersisted } from "@/runtime/persistence/persist"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
@@ -103,15 +103,11 @@ describe("prompt persistence", () => {
|
||||
}),
|
||||
}))
|
||||
await root.session.ready.promise
|
||||
// Bytes stay in the store until the image is shown or sent.
|
||||
expect(root.session.current()).toEqual([
|
||||
{
|
||||
type: "image",
|
||||
id: "image",
|
||||
filename: "image.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "composer-image", url: expect.stringMatching(/^blob:/) },
|
||||
},
|
||||
{ type: "image", id: "image", filename: "image.png", mime: "image/png", blob: { id: "composer-image", url: "" } },
|
||||
])
|
||||
expect(await resolveBlobUrl(root.session.current()[0]!.blob)).toStartWith("blob:")
|
||||
root.session.set([{ type: "text", content: "hello", start: 0, end: 5 }, ...root.session.current()])
|
||||
flushPersisted()
|
||||
await Bun.sleep(0)
|
||||
@@ -237,7 +233,7 @@ describe("prompt persistence", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("moves image data URLs into blobs and hydrates object URLs", async () => {
|
||||
test("moves image data URLs into blobs and resolves object URLs on demand", async () => {
|
||||
const documents = new Map<string, string>()
|
||||
const blobs = new Map<string, Blob>()
|
||||
const store = createDraftStore({
|
||||
@@ -258,8 +254,8 @@ test("moves image data URLs into blobs and hydrates object URLs", async () => {
|
||||
await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] }))
|
||||
expect(documents.get("prompt")).not.toContain("dataUrl")
|
||||
const value = JSON.parse((await store.getItem("prompt"))!)
|
||||
expect(value.prompt[0].blob.id).toBe("1")
|
||||
expect(value.prompt[0].blob.url).toStartWith("blob:")
|
||||
expect(value.prompt[0].blob).toEqual({ id: "1" })
|
||||
expect(await resolveBlobUrl(value.prompt[0].blob)).toStartWith("blob:")
|
||||
})
|
||||
|
||||
test("does not let delayed blob migration overwrite a newer draft", async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Keymap } from "../../tui/src/context/keymap"
|
||||
export function ErrorOverlay(props: { component: string; error: unknown; onClose: () => void }) {
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("dialog")
|
||||
const focus = renderer.currentFocusedRenderable
|
||||
onCleanup(Keymap.use().mode.push("modal"))
|
||||
Keymap.createLayer(() => ({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -8,7 +8,9 @@ export type LocationPublicRef = { directory: string }
|
||||
|
||||
export type ModelRef = { id: string; providerID: string; variant?: string }
|
||||
|
||||
export type ProviderSettings = { [x: string]: any }
|
||||
export type ProviderCompaction = { type: "summary" } | { type: "native" }
|
||||
|
||||
export type ProviderTransport = "http" | "websocket"
|
||||
|
||||
export type AgentColor = string
|
||||
|
||||
@@ -218,19 +220,8 @@ export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning
|
||||
|
||||
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
|
||||
|
||||
export type ProviderCompaction = { mode: "local" } | { mode: "provider"; threshold?: number }
|
||||
|
||||
export type ProviderTransport = "http" | "websocket"
|
||||
|
||||
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
|
||||
|
||||
export type ModelVariant = {
|
||||
id: string
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type MoneyUSDPerMillionTokens = number
|
||||
|
||||
export type GenerateTextResponse = { data: { text: string } }
|
||||
@@ -465,11 +456,19 @@ export type V2EventServerConnected = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type ProviderRequest = {
|
||||
settings: ProviderSettings
|
||||
headers: { [x: string]: string }
|
||||
body: { [x: string]: any }
|
||||
}
|
||||
export type ProviderSettings = {
|
||||
timeout?: number | false
|
||||
chunkTimeout?: number
|
||||
compaction?: ProviderCompaction
|
||||
transport?: ProviderTransport
|
||||
} & { [x: string]: any }
|
||||
|
||||
export type ConfigProviderSettings = {
|
||||
timeout?: number | false
|
||||
chunkTimeout?: number
|
||||
compaction?: ProviderCompaction
|
||||
transport?: ProviderTransport
|
||||
} & { [x: string]: JsonValue | null }
|
||||
|
||||
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
|
||||
|
||||
@@ -1443,20 +1442,6 @@ export type ModelCompatibility = {
|
||||
supportsPromptCacheKey?: boolean
|
||||
}
|
||||
|
||||
export type ProviderInfo = {
|
||||
id: string
|
||||
canonical?: string
|
||||
integrationID?: string
|
||||
name: string
|
||||
activation: "auto" | "enabled" | "disabled"
|
||||
package: string
|
||||
compaction?: ProviderCompaction
|
||||
transport?: ProviderTransport
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ModelCost = {
|
||||
tier?: { type: "context"; size: number }
|
||||
input: MoneyUSDPerMillionTokens
|
||||
@@ -1671,6 +1656,31 @@ export type SessionInboxMove = {
|
||||
payload: SessionInboxMovePayload
|
||||
}
|
||||
|
||||
export type ProviderRequest = {
|
||||
settings: ProviderSettings
|
||||
headers: { [x: string]: string }
|
||||
body: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ModelVariant = {
|
||||
id: string
|
||||
settings?: ProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ProviderInfo = {
|
||||
id: string
|
||||
canonical?: string
|
||||
integrationID?: string
|
||||
name: string
|
||||
activation: "auto" | "enabled" | "disabled"
|
||||
package: string
|
||||
settings?: ProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type PermissionRuleset = Array<PermissionRule>
|
||||
|
||||
export type SessionRevertStaged = {
|
||||
@@ -1854,29 +1864,6 @@ export type FormField =
|
||||
|
||||
export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" }
|
||||
|
||||
export type ModelInfo = {
|
||||
id: string
|
||||
modelID: string
|
||||
providerID: string
|
||||
canonical?: string
|
||||
family?: string
|
||||
name: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
compaction?: ProviderCompaction
|
||||
transport?: ProviderTransport
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
capabilities: ModelCapabilities
|
||||
variants: Array<ModelVariant>
|
||||
time: { released: number }
|
||||
cost: Array<ModelCost>
|
||||
status: "alpha" | "beta" | "deprecated" | "active"
|
||||
enabled: boolean
|
||||
limit: { context: number; input?: number; output: number }
|
||||
}
|
||||
|
||||
export type FormField1 =
|
||||
| FormStringField1
|
||||
| FormNumberField1
|
||||
@@ -1902,6 +1889,27 @@ export type ReferenceInfo = {
|
||||
source: ReferenceSource
|
||||
}
|
||||
|
||||
export type ModelInfo = {
|
||||
id: string
|
||||
modelID: string
|
||||
providerID: string
|
||||
canonical?: string
|
||||
family?: string
|
||||
name: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
settings?: ProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
capabilities: ModelCapabilities
|
||||
variants: Array<ModelVariant>
|
||||
time: { released: number }
|
||||
cost: Array<ModelCost>
|
||||
status: "alpha" | "beta" | "deprecated" | "active"
|
||||
enabled: boolean
|
||||
limit: { context: number; input?: number; output: number }
|
||||
}
|
||||
|
||||
export type AgentInfo = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -2085,31 +2093,27 @@ export type ConfigEntry =
|
||||
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
|
||||
providers?: {
|
||||
[x: string]: {
|
||||
compaction?: ProviderCompaction
|
||||
transport?: ProviderTransport
|
||||
canonical?: string
|
||||
name?: string
|
||||
env?: Array<string>
|
||||
package?: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
settings?: ConfigProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
models?: {
|
||||
[x: string]: {
|
||||
compaction?: ProviderCompaction
|
||||
transport?: ProviderTransport
|
||||
modelID?: string
|
||||
family?: string
|
||||
name?: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
settings?: ConfigProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
capabilities?: ModelCapabilities
|
||||
variants?: Array<{
|
||||
id: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
settings?: ConfigProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -128,6 +128,8 @@ function prepareOptions(model: Info, pkg: string) {
|
||||
const customFetch = options.fetch
|
||||
const chunkTimeout = options.chunkTimeout
|
||||
delete options.chunkTimeout
|
||||
delete options.compaction
|
||||
delete options.transport
|
||||
options.fetch = async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
const opts = { ...(init ?? {}) }
|
||||
const signals = [
|
||||
@@ -388,7 +390,10 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
|
||||
if (settings === undefined) return undefined
|
||||
const result = Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
|
||||
([key]) =>
|
||||
!["apiKey", "authToken", "baseURL", "chunkTimeout", "compaction", "fetch", "timeout", "transport"].includes(
|
||||
key,
|
||||
),
|
||||
),
|
||||
)
|
||||
return Object.keys(result).length === 0 ? undefined : result
|
||||
|
||||
@@ -68,8 +68,6 @@ export const Plugin = define({
|
||||
if (item.canonical !== undefined) provider.canonical = item.canonical
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.package !== undefined) provider.package = item.package
|
||||
if (item.compaction !== undefined) provider.compaction = { ...item.compaction }
|
||||
if (item.transport !== undefined) provider.transport = item.transport
|
||||
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
|
||||
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
|
||||
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
|
||||
@@ -116,8 +114,6 @@ export const Plugin = define({
|
||||
if (config.compatibility !== undefined)
|
||||
model.compatibility = { ...model.compatibility, ...config.compatibility }
|
||||
if (config.package !== undefined) model.package = config.package
|
||||
if (config.compaction !== undefined) model.compaction = { ...config.compaction }
|
||||
if (config.transport !== undefined) model.transport = config.transport
|
||||
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
|
||||
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
|
||||
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
|
||||
|
||||
+61
-14
@@ -36,6 +36,7 @@ export type Status = Background["status"]
|
||||
|
||||
const decodeBackground = Schema.decodeUnknownResult(Background)
|
||||
const backgroundPrefix = "job.background/"
|
||||
const COMPLETED_LIMIT = 25
|
||||
|
||||
export type Info = {
|
||||
id: string
|
||||
@@ -57,6 +58,7 @@ type Active = {
|
||||
scope: Scope.Closeable
|
||||
blockingSessions: Map<SessionSchema.ID, number>
|
||||
isBackgrounded: boolean
|
||||
consumed: boolean
|
||||
recovery?: Recovery
|
||||
}
|
||||
|
||||
@@ -69,6 +71,7 @@ type FinishResult = {
|
||||
info?: Info
|
||||
done?: Deferred.Deferred<Info>
|
||||
scope?: Scope.Closeable
|
||||
generation?: Scope.Closeable
|
||||
}
|
||||
|
||||
type BackgroundResult = {
|
||||
@@ -81,11 +84,12 @@ type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable }
|
||||
type BlockWait = {
|
||||
done: Deferred.Deferred<Info>
|
||||
backgrounded: Deferred.Deferred<Info>
|
||||
generation: Scope.Closeable
|
||||
}
|
||||
|
||||
type BlockStart =
|
||||
| { type: "missing" }
|
||||
| { type: "finished"; info: Info }
|
||||
| { type: "finished"; info: Info; generation: Scope.Closeable }
|
||||
| { type: "backgrounded"; info: Info }
|
||||
| { type: "wait"; wait: BlockWait }
|
||||
|
||||
@@ -168,6 +172,9 @@ function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: Sessi
|
||||
/**
|
||||
* Makes one scoped, process-local registry. Explicitly recoverable background
|
||||
* work also owns a durable notification marker until its notification is admitted.
|
||||
* Unconsumed results survive the start-to-wait handoff. Foreground block/cancel
|
||||
* and non-recoverable wait results enter a 25-entry consumed history. Recoverable
|
||||
* wait results stay available for background registration and acknowledgment.
|
||||
*/
|
||||
export const make = Effect.gen(function* () {
|
||||
const kv = yield* KV.Service
|
||||
@@ -176,6 +183,19 @@ export const make = Effect.gen(function* () {
|
||||
scope: yield* Scope.Scope,
|
||||
}
|
||||
|
||||
const consume = (id: string, generation: Scope.Closeable) =>
|
||||
SynchronizedRef.update(state.jobs, (jobs) => {
|
||||
const job = jobs.get(id)
|
||||
if (!job || job.scope !== generation || job.info.status === "running" || job.consumed) return jobs
|
||||
const next = new Map(jobs)
|
||||
// Order history by first consumption, not by start time or subsequent reads.
|
||||
next.delete(id)
|
||||
next.set(id, { ...job, consumed: true })
|
||||
const completed = [...next].filter(([, job]) => job.consumed && !job.info.notificationID)
|
||||
for (const [id] of completed.slice(0, -COMPLETED_LIMIT)) next.delete(id)
|
||||
return next
|
||||
})
|
||||
|
||||
const persistBackground = Effect.fnUntraced(function* (job: Active) {
|
||||
if (!job.recovery || !job.info.notificationID) return
|
||||
yield* kv.set(`${backgroundPrefix}${job.info.notificationID}`, {
|
||||
@@ -260,6 +280,7 @@ export const make = Effect.gen(function* () {
|
||||
scope,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
isBackgrounded: false,
|
||||
consumed: false,
|
||||
recovery: input.recovery,
|
||||
}
|
||||
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
|
||||
@@ -280,12 +301,19 @@ export const make = Effect.gen(function* () {
|
||||
const wait: Interface["wait"] = Effect.fn("Job.wait")(function* (input) {
|
||||
const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id)
|
||||
if (!job) return { timedOut: false }
|
||||
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
|
||||
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
|
||||
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
|
||||
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
|
||||
if (info._tag === "Some") return { info: info.value, timedOut: false }
|
||||
return { info: snapshot(job), timedOut: true }
|
||||
return yield* Effect.gen(function* () {
|
||||
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
|
||||
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
|
||||
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
|
||||
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
|
||||
if (info._tag === "Some") return { info: info.value, timedOut: false }
|
||||
return { info: snapshot(job), timedOut: true }
|
||||
}).pipe(
|
||||
// Recoverable wait -> background is a supported handoff, even after failure.
|
||||
Effect.tap((result) =>
|
||||
result.info.status === "running" || job.recovery ? Effect.void : consume(input.id, job.scope),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const removeBlock = Effect.fnUntraced(function* (input: BlockInput) {
|
||||
@@ -303,10 +331,10 @@ export const make = Effect.gen(function* () {
|
||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
|
||||
const job = jobs.get(input.id)
|
||||
if (!job) return [{ type: "missing" }, jobs]
|
||||
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job) }, jobs]
|
||||
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job), generation: job.scope }, jobs]
|
||||
if (job.isBackgrounded) return [{ type: "backgrounded", info: snapshot(job) }, jobs]
|
||||
return [
|
||||
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded } },
|
||||
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded, generation: job.scope } },
|
||||
new Map(jobs).set(input.id, {
|
||||
...job,
|
||||
blockingSessions: incrementSession(job.blockingSessions, input.sessionID),
|
||||
@@ -314,12 +342,18 @@ export const make = Effect.gen(function* () {
|
||||
]
|
||||
})
|
||||
if (result.type === "missing") return undefined
|
||||
if (result.type === "finished") return { type: "finished", info: result.info }
|
||||
if (result.type === "finished") {
|
||||
yield* consume(input.id, result.generation)
|
||||
return { type: "finished", info: result.info }
|
||||
}
|
||||
if (result.type === "backgrounded") return { type: "backgrounded", info: result.info }
|
||||
return yield* Effect.raceFirst(
|
||||
Deferred.await(result.wait.done).pipe(Effect.map((info) => ({ type: "finished" as const, info }))),
|
||||
Deferred.await(result.wait.backgrounded).pipe(Effect.map((info) => ({ type: "backgrounded" as const, info }))),
|
||||
).pipe(Effect.ensuring(removeBlock(input)))
|
||||
).pipe(
|
||||
Effect.tap((outcome) => (outcome.type === "finished" ? consume(input.id, result.wait.generation) : Effect.void)),
|
||||
Effect.ensuring(removeBlock(input)),
|
||||
)
|
||||
})
|
||||
|
||||
const markBackground = Effect.fnUntraced(function* (job: Active) {
|
||||
@@ -383,7 +417,7 @@ export const make = Effect.gen(function* () {
|
||||
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job), generation: job.scope }, jobs]
|
||||
const next = {
|
||||
...job,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
@@ -394,11 +428,15 @@ export const make = Effect.gen(function* () {
|
||||
},
|
||||
}
|
||||
yield* persistBackground(next)
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
return [
|
||||
{ info: snapshot(next), done: job.done, scope: job.scope, generation: job.scope },
|
||||
new Map(jobs).set(id, next),
|
||||
]
|
||||
}),
|
||||
)
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
|
||||
if (result.scope) yield* Scope.close(result.scope, Exit.void)
|
||||
if (result.generation) yield* consume(id, result.generation)
|
||||
return result.info
|
||||
})
|
||||
|
||||
@@ -414,7 +452,16 @@ export const make = Effect.gen(function* () {
|
||||
}).pipe(Effect.withSpan("Job.pendingBackground"))
|
||||
|
||||
const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
|
||||
kv.remove(`${backgroundPrefix}${notificationID}`),
|
||||
SynchronizedRef.updateEffect(state.jobs, (jobs) =>
|
||||
Effect.gen(function* () {
|
||||
yield* kv.remove(`${backgroundPrefix}${notificationID}`)
|
||||
const entry = [...jobs].find(([, job]) => job.info.notificationID === notificationID)
|
||||
if (!entry || entry[1].info.status === "running") return jobs
|
||||
const next = new Map(jobs)
|
||||
next.delete(entry[0])
|
||||
return next
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
|
||||
@@ -118,9 +118,9 @@ export interface Resolved {
|
||||
/** Catalog token limits used by Core for context management. */
|
||||
readonly limit: Info["limit"]
|
||||
/** Model policy overrides the provider policy; omitted means local compaction. */
|
||||
readonly compaction?: Info["compaction"]
|
||||
readonly compaction?: Provider.Compaction
|
||||
/** Model transport overrides the provider transport; omitted means HTTP. */
|
||||
readonly transport?: Info["transport"]
|
||||
readonly transport?: Provider.Transport
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -178,7 +178,11 @@ export const fromCatalogModel = (
|
||||
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
|
||||
Effect.flatMap((resolved) => {
|
||||
// Reject provider compaction policies up front so the misconfiguration surfaces before any step runs.
|
||||
if (model.compaction?.mode !== "provider" || resolved.route.compact?.trigger || resolved.route.compact?.endpoint)
|
||||
if (
|
||||
model.settings?.compaction?.type !== "native" ||
|
||||
resolved.route.compact?.trigger ||
|
||||
resolved.route.compact?.endpoint
|
||||
)
|
||||
return Effect.succeed(resolved)
|
||||
return Effect.fail(
|
||||
new UnsupportedCompactionError({ providerID: model.providerID, modelID: model.id, route: resolved.route.id }),
|
||||
@@ -377,8 +381,8 @@ export const layer = Layer.effect(
|
||||
capabilities: selected.capabilities,
|
||||
cost: selected.cost,
|
||||
limit: selected.limit,
|
||||
compaction: selected.compaction,
|
||||
transport: selected.transport,
|
||||
compaction: runtimeInfo.settings?.compaction,
|
||||
transport: runtimeInfo.settings?.transport,
|
||||
}
|
||||
})
|
||||
return Service.of({
|
||||
|
||||
@@ -188,8 +188,6 @@ const layer = Layer.effect(
|
||||
...model,
|
||||
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
|
||||
package: model.package ?? provider?.package,
|
||||
compaction: model.compaction ?? provider?.compaction,
|
||||
transport: model.transport ?? provider?.transport,
|
||||
settings: Provider.mergeOverlay(provider?.settings, model.settings),
|
||||
headers: Provider.mergeHeaders(provider?.headers, model.headers),
|
||||
body: Provider.mergeOverlay(provider?.body, model.body),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export * as NativeCompactionPlugin from "./compaction.js"
|
||||
|
||||
import { LLMClient, Message } from "@opencode/ai"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { SessionCompaction } from "../session/compaction.js"
|
||||
import type { PluginInternal } from "./internal.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.compaction.native",
|
||||
effect: Effect.fn("NativeCompactionPlugin")(function* () {
|
||||
const llm = yield* LLMClient.Service
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
yield* compaction.transform((editor) => {
|
||||
editor.native((input) => {
|
||||
const request = input.request
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" }))
|
||||
return Effect.gen(function* () {
|
||||
const retained = yield* input.retained
|
||||
const result = yield* llm.compact(request, { ...input.options, mechanism: "trigger" })
|
||||
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
|
||||
})
|
||||
if (LLMClient.canCompact(request))
|
||||
return llm.compact(request, { mechanism: "endpoint", http: input.options.http })
|
||||
return undefined
|
||||
})
|
||||
})
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as PluginInternal from "./internal.js"
|
||||
|
||||
import { LLMClient } from "@opencode/ai"
|
||||
import type { Plugin } from "@opencode/plugin/effect/plugin"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { httpClient } from "@opencode/util/effect/app-node-platform"
|
||||
@@ -12,6 +13,7 @@ import { Provider } from "../provider.js"
|
||||
import { Command } from "../command.js"
|
||||
import { Config } from "../config.js"
|
||||
import { Credential } from "../credential.js"
|
||||
import { llmClient } from "../effect/app-node-platform.js"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command.js"
|
||||
import { ConfigCompactionPlugin } from "../config/plugin/compaction.js"
|
||||
@@ -84,6 +86,7 @@ import { WriteTool } from "../tool/plugin/write.js"
|
||||
import { AgentPlugin } from "./agent.js"
|
||||
import BrowserPlugin from "@opencode/plugin-browser"
|
||||
import { CommandPlugin } from "./command.js"
|
||||
import { NativeCompactionPlugin } from "./compaction.js"
|
||||
import { IdentityPlugin } from "./identity.js"
|
||||
import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
@@ -92,6 +95,7 @@ import { ProviderPlugins } from "./provider.js"
|
||||
import { WebSearchPlugins } from "./websearch/index.js"
|
||||
import { SkillPlugin } from "./skill.js"
|
||||
import { VcsHgPlugin } from "./vcs/hg.js"
|
||||
import { ToolInputRepairPlugin } from "./tool-input-repair.js"
|
||||
import { OptimizePlugin } from "./optimize.js"
|
||||
import { VcsGitPlugin } from "./vcs/git.js"
|
||||
import { WarmingPlugin } from "./warming.js"
|
||||
@@ -120,6 +124,7 @@ const services = [
|
||||
Integration.Service,
|
||||
Job.Service,
|
||||
KV.Service,
|
||||
LLMClient.Service,
|
||||
Location.Service,
|
||||
ModelsDev.Service,
|
||||
Mcp.Service,
|
||||
@@ -171,6 +176,7 @@ export const requirements = LayerNode.group([
|
||||
Integration.node,
|
||||
Job.node,
|
||||
KV.node,
|
||||
llmClient,
|
||||
Location.node,
|
||||
ModelsDev.node,
|
||||
Mcp.node,
|
||||
@@ -200,6 +206,7 @@ export const requirements = LayerNode.group([
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
ToolInputRepairPlugin.Plugin,
|
||||
ConfigWorktreePlugin.Plugin,
|
||||
BrowserPlugin,
|
||||
ConfigMcpPlugin.Plugin,
|
||||
@@ -212,6 +219,7 @@ const pre = [
|
||||
SkillPlugin.Plugin,
|
||||
VcsHgPlugin.Plugin,
|
||||
ModelsDevPlugin,
|
||||
NativeCompactionPlugin.Plugin,
|
||||
...ProviderPlugins,
|
||||
...WebSearchPlugins,
|
||||
PatchTool.Plugin,
|
||||
|
||||
@@ -168,7 +168,9 @@ export const AzurePlugin = define({
|
||||
resolveResourceName(draft.settings, resourceName) ?? resourceName,
|
||||
)
|
||||
if (responsesWebSocketCapable(item.provider, draft))
|
||||
draft.transport = item.provider.transport ?? "websocket"
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
transport: item.provider.settings?.transport ?? "websocket",
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +270,9 @@ export const OpenAIPlugin = define({
|
||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||
// subscription covers usage, so hide the rest and zero the cost.
|
||||
models.update(model.providerID, model.id, (draft) => {
|
||||
draft.transport = models.provider.get(model.providerID)?.provider.transport ?? "websocket"
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
transport: models.provider.get(model.providerID)?.provider.settings?.transport ?? "websocket",
|
||||
})
|
||||
if (!chatgpt) return
|
||||
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
|
||||
draft.enabled = false
|
||||
|
||||
@@ -98,7 +98,9 @@ export const XAIPlugin = define({
|
||||
yield* ctx.model.transform((models) => {
|
||||
for (const model of models.list(providerID)) {
|
||||
models.update(providerID, model.id, (draft) => {
|
||||
draft.transport = models.provider.get(providerID)?.provider.transport ?? "websocket"
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
transport: models.provider.get(providerID)?.provider.settings?.transport ?? "websocket",
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
export * as ToolInputRepairPlugin from "./tool-input-repair.js"
|
||||
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import type { ToolEditor } from "@opencode/plugin/effect/tool"
|
||||
import { Effect, JsonSchema, Option, Predicate, Schema } from "effect"
|
||||
import { definition } from "../tool/runtime.js"
|
||||
|
||||
// Repairs apply only when the input schema unambiguously supports them:
|
||||
// - Stringified root or nested object: '{"limit":"20"}' -> { limit: 20 }
|
||||
// - Closed object: { limit: "20", extra: true } -> { limit: 20 }
|
||||
// - Optional null or empty-object placeholder: { limit: null } -> {}
|
||||
// - Numeric or boolean string: { limit: "20", enabled: "false" } -> { limit: 20, enabled: false }
|
||||
// - Nullable field: { count: "2" } -> { count: 2 }
|
||||
// - Stringified array or compatible item: { tags: '["a"]', count: "2" } -> { tags: ["a"], count: [2] }
|
||||
// - Positional tuple: { pair: ["2", "false"] } -> { pair: [2, false] }
|
||||
// - Typed dictionary: { counts: { first: "2" } } -> { counts: { first: 2 } }
|
||||
// - Nested fields and local references: { items: [{ count: "2" }] } -> { items: [{ count: 2 }] }
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
const maxDepth = 6
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.tool.input.repair",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
let get: ToolEditor["get"] = () => undefined
|
||||
yield* ctx.tool.transform((draft) => {
|
||||
// The draft sees later tool transforms too; reload replaces this lookup.
|
||||
get = draft.get
|
||||
})
|
||||
yield* ctx.tool.hook("execute.before", (event) =>
|
||||
Effect.sync(() => {
|
||||
// The outer Code Mode tool is built per snapshot rather than registered, so it cannot be
|
||||
// looked up here. Its `{ code }` input is trivial; the tools it calls are repaired normally.
|
||||
if (event.tool === "execute") return
|
||||
const tool = get(event.tool)
|
||||
if (!tool) return
|
||||
const schema = definition(tool).inputSchema
|
||||
if (schema.type !== "object") return
|
||||
event.input = repair(event.input, schema, schema, 0)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function repair(value: unknown, schema: JsonSchema.JsonSchema, root: JsonSchema.JsonSchema, depth: number): unknown {
|
||||
if (depth > maxDepth) return value
|
||||
|
||||
if (typeof schema.$ref === "string") {
|
||||
const definitions = /^#\/\$defs\/[^/]+$/.test(schema.$ref)
|
||||
? root.$defs
|
||||
: /^#\/definitions\/[^/]+$/.test(schema.$ref)
|
||||
? root.definitions
|
||||
: undefined
|
||||
if (!Predicate.isObject(definitions)) return value
|
||||
const target = Object.fromEntries(
|
||||
Object.entries(definitions).filter((entry): entry is [string, JsonSchema.JsonSchema] =>
|
||||
Predicate.isObject(entry[1]),
|
||||
),
|
||||
)[
|
||||
schema.$ref
|
||||
.slice(schema.$ref.lastIndexOf("/") + 1)
|
||||
.replaceAll("~1", "/")
|
||||
.replaceAll("~0", "~")
|
||||
]
|
||||
return target ? repair(value, target, root, depth + 1) : value
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.type)) {
|
||||
if (value === null && schema.type.includes("null")) return value
|
||||
if (schema.type.includes(typeof value)) return value
|
||||
const types = schema.type.filter((type) => type !== "null")
|
||||
return types.length === 1 ? repair(value, { ...schema, type: types[0] }, root, depth + 1) : value
|
||||
}
|
||||
|
||||
if (schema.type === undefined) {
|
||||
if (Array.isArray(schema.anyOf) && Array.isArray(schema.oneOf)) return value
|
||||
const branches = Array.isArray(schema.anyOf) ? schema.anyOf : schema.oneOf
|
||||
if (!Array.isArray(branches) || value === null) return value
|
||||
if (branches.some((branch) => !Predicate.isObject(branch) || branch.type === typeof value)) return value
|
||||
const candidates = branches.filter((branch) => Predicate.isObject(branch) && branch.type !== "null")
|
||||
return candidates.length === 1 ? repair(value, candidates[0], root, depth + 1) : value
|
||||
}
|
||||
|
||||
if (schema.type === "number" || schema.type === "integer") {
|
||||
if (typeof value !== "string" || value.trim() === "") return value
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && (schema.type !== "integer" || Number.isSafeInteger(parsed)) ? parsed : value
|
||||
}
|
||||
if (schema.type === "boolean") return value === "true" ? true : value === "false" ? false : value
|
||||
if (schema.type === "object") return repairObject(value, schema, root, depth)
|
||||
if (schema.type === "array") return repairArray(value, schema, root, depth)
|
||||
return value
|
||||
}
|
||||
|
||||
function repairObject(
|
||||
value: unknown,
|
||||
schema: JsonSchema.JsonSchema,
|
||||
root: JsonSchema.JsonSchema,
|
||||
depth: number,
|
||||
): unknown {
|
||||
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
|
||||
if (!Predicate.isObject(parsed)) return value
|
||||
|
||||
const properties = Predicate.isObject(schema.properties) ? schema.properties : {}
|
||||
const required = Array.isArray(schema.required) ? schema.required : []
|
||||
const patterned = Predicate.isObject(schema.patternProperties)
|
||||
const composed = Array.isArray(schema.allOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf)
|
||||
|
||||
return Object.keys(parsed).reduce<Record<string, unknown>>((result, key) => {
|
||||
const current = result[key]
|
||||
const declared = Object.hasOwn(properties, key)
|
||||
const property = declared ? properties[key] : !patterned ? schema.additionalProperties : undefined
|
||||
|
||||
if (!declared && schema.additionalProperties === false && !patterned && !composed) {
|
||||
const next = { ...result }
|
||||
delete next[key]
|
||||
return next
|
||||
}
|
||||
if (!Predicate.isObject(property)) return result
|
||||
|
||||
// Only a bare single-type property provably rejects null and `{}`. Compositions, enums,
|
||||
// constants, references and nullable flags may accept them, so those are left alone.
|
||||
const plain =
|
||||
typeof property.type === "string" &&
|
||||
property.type !== "null" &&
|
||||
!composed &&
|
||||
["anyOf", "oneOf", "allOf", "enum", "const", "$ref", "nullable"].every((keyword) => !(keyword in property))
|
||||
const placeholder = Predicate.isObject(current) && Object.keys(current).length === 0 && property.type !== "object"
|
||||
if (declared && !required.includes(key) && plain && (current === null || placeholder)) {
|
||||
const next = { ...result }
|
||||
delete next[key]
|
||||
return next
|
||||
}
|
||||
|
||||
const repaired = repair(current, property, root, depth + 1)
|
||||
return repaired === current ? result : { ...result, [key]: repaired }
|
||||
}, parsed)
|
||||
}
|
||||
|
||||
function repairArray(
|
||||
value: unknown,
|
||||
schema: JsonSchema.JsonSchema,
|
||||
root: JsonSchema.JsonSchema,
|
||||
depth: number,
|
||||
): unknown {
|
||||
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
|
||||
const tuple = Array.isArray(schema.prefixItems)
|
||||
? schema.prefixItems
|
||||
: Array.isArray(schema.items)
|
||||
? schema.items
|
||||
: undefined
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
const repaired = parsed.map((item, index) => {
|
||||
const member = tuple
|
||||
? (tuple[index] ?? (Array.isArray(schema.prefixItems) ? schema.items : schema.additionalItems))
|
||||
: schema.items
|
||||
return Predicate.isObject(member) ? repair(item, member, root, depth + 1) : item
|
||||
})
|
||||
return repaired.every((item, index) => item === parsed[index]) ? parsed : repaired
|
||||
}
|
||||
|
||||
if (tuple || !Predicate.isObject(schema.items)) return value
|
||||
const repaired = repair(value, schema.items, root, depth + 1)
|
||||
const type = schema.items.type
|
||||
const compatible =
|
||||
type === "object"
|
||||
? Predicate.isObject(repaired)
|
||||
: type === "array"
|
||||
? Array.isArray(repaired)
|
||||
: type === "integer"
|
||||
? typeof repaired === "number" && Number.isSafeInteger(repaired)
|
||||
: type === "number"
|
||||
? typeof repaired === "number" && Number.isFinite(repaired)
|
||||
: (type === "string" || type === "boolean") && typeof repaired === type
|
||||
return compatible ? [repaired] : value
|
||||
}
|
||||
@@ -132,11 +132,11 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s
|
||||
return yield* importPackage(specifier, entrypoint)
|
||||
})
|
||||
|
||||
/** opencode transport settings consumed in aisdk.ts; native packages never receive them. */
|
||||
const TRANSPORT_KEYS = ["chunkTimeout", "fetch", "timeout"] as const
|
||||
/** opencode settings consumed in Core; native packages never receive them. */
|
||||
const CORE_KEYS = ["chunkTimeout", "compaction", "fetch", "timeout", "transport"] as const
|
||||
|
||||
export function nativeSettings(settings: Settings): Settings {
|
||||
return Struct.omit(settings, TRANSPORT_KEYS)
|
||||
return Struct.omit(settings, CORE_KEYS)
|
||||
}
|
||||
|
||||
export function mergeOverlay(
|
||||
@@ -177,6 +177,12 @@ export function mergeHeaders(
|
||||
export const Request = Provider.Request
|
||||
export type Request = Provider.Request
|
||||
|
||||
export const Compaction = Provider.Compaction
|
||||
export type Compaction = Provider.Compaction
|
||||
|
||||
export const Transport = Provider.Transport
|
||||
export type Transport = Provider.Transport
|
||||
|
||||
export const Settings = Provider.Settings
|
||||
export type Settings = Provider.Settings
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
LLMRequest,
|
||||
Message,
|
||||
type ContentPart,
|
||||
type Usage,
|
||||
} from "@opencode/ai"
|
||||
import type { StreamOptions } from "@opencode/ai/route"
|
||||
import type { SessionCompactionResult } from "@opencode/plugin/effect/session"
|
||||
import { SessionError } from "@opencode/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
@@ -91,8 +93,25 @@ export type Settings = {
|
||||
tokens: number
|
||||
}
|
||||
|
||||
export type NativeInput = {
|
||||
readonly request: LLMRequest
|
||||
readonly options: StreamOptions
|
||||
/** Whole, real user messages within the retained-token allowance, for checkpoint-only mechanisms. */
|
||||
readonly retained: Effect.Effect<ReadonlyArray<Message>>
|
||||
}
|
||||
|
||||
export type NativeResult = {
|
||||
readonly replacement: ReadonlyArray<Message>
|
||||
readonly usage?: Usage
|
||||
}
|
||||
|
||||
/** Returns the provider's replacement window, or `undefined` when this strategy has no mechanism for the route. */
|
||||
export type NativeStrategy = (input: NativeInput) => Effect.Effect<NativeResult, AIError> | undefined
|
||||
|
||||
export type Editor = {
|
||||
configure: (settings: Partial<Settings>) => void
|
||||
/** Later registrations take precedence. */
|
||||
native: (strategy: NativeStrategy) => void
|
||||
}
|
||||
|
||||
export type AutoInput = {
|
||||
@@ -380,15 +399,18 @@ export const layer = Layer.effect(
|
||||
const llm = yield* LLMClient.Service
|
||||
const db = (yield* Database.Service).db
|
||||
|
||||
const state = State.create<Settings, Editor>({
|
||||
const state = State.create<Settings & { readonly native: NativeStrategy[] }, Editor>({
|
||||
name: "session-compaction",
|
||||
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
|
||||
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS, native: [] }),
|
||||
editor: (editor) => ({
|
||||
configure: (settings) => {
|
||||
if (settings.auto !== undefined) editor.auto = settings.auto
|
||||
if (settings.buffer !== undefined) editor.buffer = settings.buffer
|
||||
if (settings.tokens !== undefined) editor.tokens = settings.tokens
|
||||
},
|
||||
native: (strategy) => {
|
||||
editor.native.push(strategy)
|
||||
},
|
||||
}),
|
||||
})
|
||||
const failed = Effect.fnUntraced(function* (input: SessionEvent.Compaction.Failed["data"]) {
|
||||
@@ -504,6 +526,23 @@ export const layer = Layer.effect(
|
||||
return yield* reject(
|
||||
"Provider compaction requires the endpoint in provider/model settings, not a model.request rewrite",
|
||||
)
|
||||
const native = state
|
||||
.get()
|
||||
.native.toReversed()
|
||||
.map((strategy) =>
|
||||
strategy({
|
||||
request,
|
||||
options: prepared.options,
|
||||
retained: original(context.session.id).pipe(
|
||||
Effect.map((messages) => retainUsers(messages, context.model, state.get().tokens)),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.find((effect) => effect !== undefined)
|
||||
if (!native)
|
||||
return yield* reject(
|
||||
`No plugin provides native compaction for ${request.model.provider}/${request.model.route.id}`,
|
||||
)
|
||||
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
|
||||
agent: context.agent.id,
|
||||
model: context.model.ref,
|
||||
@@ -514,25 +553,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
// Transient provider failures retry like any other request; only a known automatic overflow permits
|
||||
// local recovery, and nothing is installed until the provider returns a checkpoint.
|
||||
const result = yield* restore(
|
||||
Effect.gen(function* () {
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
|
||||
const retained = retainUsers(yield* original(context.session.id), context.model, state.get().tokens)
|
||||
const result = yield* llm
|
||||
.compact(request, { ...prepared.options, mechanism: "trigger" })
|
||||
.pipe(transient)
|
||||
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
|
||||
}
|
||||
if (LLMClient.canCompact(request))
|
||||
return yield* llm
|
||||
.compact(request, { mechanism: "endpoint", http: prepared.options.http })
|
||||
.pipe(transient)
|
||||
// Model resolution admits provider policies only for routes with a compaction operation.
|
||||
return yield* Effect.die(
|
||||
new Error(`${request.model.provider}/${request.model.route.id} has no compaction operation`),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const result = yield* restore(native.pipe(transient))
|
||||
const usage = result.usage ? SessionUsage.record(result.usage, context.model.cost) : undefined
|
||||
if (usage)
|
||||
yield* bus.publish(SessionEvent.UsageRecorded, {
|
||||
@@ -715,7 +736,7 @@ export const layer = Layer.effect(
|
||||
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput): Effect.fn.Return<Outcome> {
|
||||
const request = { ...input, reason: "auto" as const }
|
||||
if (input.overflow) return yield* recoverLocally(request)
|
||||
if (input.context.model.compaction?.mode !== "provider") return yield* execute(request)
|
||||
if (input.context.model.compaction?.type !== "native") return yield* execute(request)
|
||||
return yield* executeProvider(request)
|
||||
})
|
||||
const required = (input: RequiredInput) => {
|
||||
@@ -738,12 +759,7 @@ export const layer = Layer.effect(
|
||||
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
|
||||
context - Math.max(output, config.buffer),
|
||||
)
|
||||
const policy = input.resolved.compaction
|
||||
const threshold =
|
||||
policy?.mode === "provider" && policy.threshold !== undefined
|
||||
? Math.min(policy.threshold, promptCeiling)
|
||||
: promptCeiling
|
||||
return estimateTokens(input) >= threshold
|
||||
return estimateTokens(input) >= promptCeiling
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
if (findTailStart(input.messages, state.get().tokens) === undefined)
|
||||
@@ -771,7 +787,7 @@ export const layer = Layer.effect(
|
||||
inputID: input.inputID,
|
||||
started: input.started,
|
||||
}
|
||||
return context.model.compaction?.mode === "provider" ? executeProvider(request) : execute(request)
|
||||
return context.model.compaction?.type === "native" ? executeProvider(request) : execute(request)
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -93,9 +93,10 @@ export const prepare = Effect.fn("SessionPrompt.prepare")(function* (request: {
|
||||
const materializeAttachment = Effect.fn("SessionPrompt.materializeAttachment")(function* (
|
||||
input: PromptInput.FileAttachment,
|
||||
) {
|
||||
const label = attachmentLabel(input)
|
||||
const resolved = input.uri.startsWith("data:")
|
||||
? {
|
||||
bytes: yield* decodeDataURL(input.uri),
|
||||
bytes: yield* decodeDataURL(input.uri, label),
|
||||
source: { type: "inline" as const },
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
@@ -105,8 +106,8 @@ const materializeAttachment = Effect.fn("SessionPrompt.materializeAttachment")(f
|
||||
: yield* readFileAttachment(input.uri)
|
||||
if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES)
|
||||
return yield* new AttachmentError({
|
||||
uri: input.uri,
|
||||
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`,
|
||||
uri: label,
|
||||
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${label}`,
|
||||
})
|
||||
|
||||
const mime = resolved.mime ?? Mime.detect(resolved.bytes)
|
||||
@@ -138,7 +139,7 @@ const normalizeImageAttachment = Effect.fn("SessionPrompt.normalizeImageAttachme
|
||||
) {
|
||||
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
|
||||
const image = yield* Image.Service
|
||||
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
|
||||
const label = attachmentLabel(input)
|
||||
const content = { uri: label, content: data, encoding: "base64" as const, mime }
|
||||
const normalized = yield* image.normalize(label, content).pipe(
|
||||
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)),
|
||||
@@ -201,7 +202,12 @@ const readFileAttachment = Effect.fn("SessionPrompt.readFileAttachment")(functio
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
function decodeDataURL(uri: string) {
|
||||
// A data URL is the whole file; errors and logs must name the attachment, not echo its bytes.
|
||||
function attachmentLabel(input: PromptInput.FileAttachment) {
|
||||
return input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
|
||||
}
|
||||
|
||||
function decodeDataURL(uri: string, label: string) {
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
const comma = uri.indexOf(",")
|
||||
@@ -214,7 +220,7 @@ function decodeDataURL(uri: string) {
|
||||
if (bytes.toString("base64") !== payload) throw new Error("Non-canonical base64")
|
||||
return bytes
|
||||
},
|
||||
catch: () => new AttachmentError({ uri, message: "Invalid attachment data URL" }),
|
||||
catch: () => new AttachmentError({ uri: label, message: `Invalid attachment data URL: ${label}` }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -155,14 +155,18 @@ it.effect("projects request settings, headers, and body overlays", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
let body: unknown
|
||||
let options: Record<string, unknown> | undefined
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
body = event.options.body
|
||||
options = event.options
|
||||
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
|
||||
})
|
||||
|
||||
const input = model("@ai-sdk/google", {
|
||||
apiKey: "secret",
|
||||
thinkingConfig: { thinkingBudget: 1024 },
|
||||
compaction: { type: "native" },
|
||||
transport: "websocket",
|
||||
})
|
||||
const resolved = yield* aisdk.model({
|
||||
...input,
|
||||
@@ -185,6 +189,8 @@ it.effect("projects request settings, headers, and body overlays", () =>
|
||||
})
|
||||
expect(prepared.body.headers).toEqual({ "x-test": "header" })
|
||||
expect(body).toEqual({ safety_setting: "strict" })
|
||||
expect(options).not.toHaveProperty("compaction")
|
||||
expect(options).not.toHaveProperty("transport")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ function required<T>(value: T | undefined): T {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("inherits provider compaction policy with model overrides and rejects unsupported routes", () =>
|
||||
it.effect("inherits the provider compaction setting with model overrides and rejects unsupported routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const models = yield* Model.Service
|
||||
yield* addPlugin([
|
||||
@@ -44,12 +44,10 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
providers: {
|
||||
custom: {
|
||||
package: "@opencode/ai/providers/openai/responses",
|
||||
compaction: { mode: "provider", threshold: 120_000 },
|
||||
settings: { compaction: { type: "native" } },
|
||||
models: {
|
||||
native: {},
|
||||
reset: { compaction: { mode: "provider" } },
|
||||
threshold: { compaction: { mode: "provider", threshold: 90_000 } },
|
||||
local: { compaction: { mode: "local" }, package: "@opencode/ai/providers/openai/chat" },
|
||||
local: { settings: { compaction: { type: "summary" } }, package: "@opencode/ai/providers/openai/chat" },
|
||||
unsupported: { package: "@opencode/ai/providers/openai/chat" },
|
||||
},
|
||||
},
|
||||
@@ -62,16 +60,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
const local = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("local")))
|
||||
const unsupported = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("unsupported")))
|
||||
const defaultModel = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("chat")))
|
||||
expect(native.compaction).toEqual({ mode: "provider", threshold: 120_000 })
|
||||
expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("reset")))?.compaction).toEqual({
|
||||
mode: "provider",
|
||||
})
|
||||
expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("threshold")))?.compaction).toEqual({
|
||||
mode: "provider",
|
||||
threshold: 90_000,
|
||||
})
|
||||
expect(local.compaction).toEqual({ mode: "local" })
|
||||
expect(defaultModel.compaction).toBeUndefined()
|
||||
expect(native.settings?.compaction).toEqual({ type: "native" })
|
||||
expect(local.settings?.compaction).toEqual({ type: "summary" })
|
||||
expect(defaultModel.settings?.compaction).toBeUndefined()
|
||||
yield* ModelResolver.fromCatalogModel(native)
|
||||
yield* ModelResolver.fromCatalogModel(local)
|
||||
yield* ModelResolver.fromCatalogModel(defaultModel)
|
||||
@@ -92,8 +83,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
providers: {
|
||||
custom: {
|
||||
package: "@opencode/ai/providers/openai/responses",
|
||||
transport: "http",
|
||||
models: { inherited: {}, override: { transport: "websocket" } },
|
||||
settings: { transport: "http" },
|
||||
models: { inherited: {}, override: { settings: { transport: "websocket" } } },
|
||||
},
|
||||
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
|
||||
},
|
||||
@@ -103,9 +94,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
const inherited = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("inherited")))
|
||||
const override = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("override")))
|
||||
const untouched = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("untouched")))
|
||||
expect(inherited.transport).toBe("http")
|
||||
expect(override.transport).toBe("websocket")
|
||||
expect(untouched.transport).toBeUndefined()
|
||||
expect(inherited.settings?.transport).toBe("http")
|
||||
expect(override.settings?.transport).toBe("websocket")
|
||||
expect(untouched.settings?.transport).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -131,7 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
editor.models.update(providerID, modelID, () => {})
|
||||
})
|
||||
yield* builtin.plugin.effect(host)
|
||||
expect((yield* models.get(providerID, modelID))?.transport).toBe("websocket")
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("websocket")
|
||||
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
@@ -139,16 +130,16 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
info: decode({
|
||||
providers: {
|
||||
[builtin.id]: {
|
||||
transport: "http",
|
||||
models: { override: { modelID: builtin.model, transport: "websocket" } },
|
||||
settings: { transport: "http" },
|
||||
models: { override: { modelID: builtin.model, settings: { transport: "websocket" } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
expect((yield* models.get(providerID, modelID))?.transport).toBe("http")
|
||||
expect((yield* models.get(providerID, Model.ID.make("override")))?.transport).toBe("websocket")
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("http")
|
||||
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBe("websocket")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -473,10 +473,10 @@ describe("AzurePlugin", () => {
|
||||
yield* addPlugin()
|
||||
|
||||
const responses = required(yield* service.get(Provider.ID.azure, models.responses))
|
||||
expect(responses.transport).toBe("websocket")
|
||||
expect(responses.settings?.transport).toBe("websocket")
|
||||
for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
|
||||
const model = required(yield* service.get(Provider.ID.azure, modelID))
|
||||
expect(model.transport).toBeUndefined()
|
||||
expect(model.settings?.transport).toBeUndefined()
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -205,7 +205,7 @@ describe("OpenAIPlugin", () => {
|
||||
expect(model.package).toBe("@opencode/ai/providers/openai")
|
||||
expect(model.enabled).toBe(true)
|
||||
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
|
||||
expect(model.transport).toBe("websocket")
|
||||
expect(model.settings?.transport).toBe("websocket")
|
||||
expect(direct.headers).not.toHaveProperty("originator")
|
||||
expect(direct.baseURL).toBe("https://api.openai.com/v1")
|
||||
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
|
||||
@@ -236,7 +236,7 @@ describe("OpenAIPlugin", () => {
|
||||
id: "deployment-responses",
|
||||
provider: Provider.ID.azure,
|
||||
})
|
||||
const prepare = (preference?: Model.Info["transport"]) =>
|
||||
const prepare = (preference?: Provider.Transport) =>
|
||||
Effect.gen(function* () {
|
||||
const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("XAIPlugin", () => {
|
||||
yield* addPlugin()
|
||||
|
||||
const model = yield* models.get(providerID, Model.ID.make("grok-4.6"))
|
||||
expect(model?.transport).toBe("websocket")
|
||||
expect(model?.settings?.transport).toBe("websocket")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const identity = {
|
||||
sessionID: Session.ID.make("ses_repair"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_repair"),
|
||||
}
|
||||
|
||||
it.effect("repairs tool input before validating its original schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const registry = yield* Tool.Service
|
||||
const executed: unknown[] = []
|
||||
yield* plugins.activate([
|
||||
{ ...ToolInputRepairPlugin.Plugin, revision: "1" },
|
||||
{
|
||||
id: "repairable-tool",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.tool.transform((draft) =>
|
||||
draft.add({
|
||||
name: "repairable",
|
||||
options: { codemode: false },
|
||||
description: "Repairable",
|
||||
input: Schema.Struct({ count: Schema.Int, enabled: Schema.Boolean }),
|
||||
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
|
||||
}),
|
||||
),
|
||||
},
|
||||
])
|
||||
const snapshot = yield* registry.snapshot()
|
||||
yield* snapshot.execute({
|
||||
...identity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-repair",
|
||||
name: "repairable",
|
||||
input: '{"count":"2","enabled":"true","extra":true}',
|
||||
},
|
||||
})
|
||||
expect(executed).toEqual([{ count: 2, enabled: true }])
|
||||
|
||||
yield* registry.transform((draft) => {
|
||||
draft.update("repairable", (tool) => {
|
||||
tool.input = Schema.Struct({ count: Schema.Boolean, enabled: Schema.Boolean })
|
||||
})
|
||||
})
|
||||
const updated = yield* registry.snapshot()
|
||||
yield* updated.execute({
|
||||
...identity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-updated",
|
||||
name: "repairable",
|
||||
input: { count: "false", enabled: "true" },
|
||||
},
|
||||
})
|
||||
expect(executed).toEqual([
|
||||
{ count: 2, enabled: true },
|
||||
{ count: false, enabled: true },
|
||||
])
|
||||
|
||||
yield* registry.transform((draft) => draft.remove("repairable"))
|
||||
const removed = yield* registry.snapshot()
|
||||
expect(
|
||||
(yield* removed
|
||||
.execute({
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "call-removed", name: "repairable", input: {} },
|
||||
})
|
||||
.pipe(Effect.flip)).message,
|
||||
).toBe('No tool named "repairable" is currently available. Please use a tool from the available tool list.')
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs namespaced inner tool input called from Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const registry = yield* Tool.Service
|
||||
const executed: unknown[] = []
|
||||
yield* plugins.activate([{ ...ToolInputRepairPlugin.Plugin, revision: "1" }])
|
||||
yield* registry.transform((draft) =>
|
||||
draft.add({
|
||||
name: "count",
|
||||
options: { namespace: "example" },
|
||||
description: "Record a count",
|
||||
input: Schema.Struct({ count: Schema.Int }),
|
||||
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
|
||||
}),
|
||||
)
|
||||
|
||||
const snapshot = yield* registry.snapshot()
|
||||
yield* snapshot.execute({
|
||||
...identity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-codemode-repair",
|
||||
name: "execute",
|
||||
input: { code: 'return await tools.example.count({ count: "3" })' },
|
||||
},
|
||||
})
|
||||
expect(executed).toEqual([{ count: 3 }])
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,466 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import type { ToolHooks } from "@opencode/plugin/effect/tool"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Effect, type JsonSchema } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
import { host } from "./host"
|
||||
|
||||
function run(input: unknown, inputSchema: JsonSchema.JsonSchema) {
|
||||
const event: ToolHooks["execute.before"] = {
|
||||
tool: "test",
|
||||
input,
|
||||
sessionID: Session.ID.make("ses_repair"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_repair"),
|
||||
id: Tool.CallID.make("call_repair"),
|
||||
}
|
||||
const events: ToolHooks = {
|
||||
"execute.before": event,
|
||||
"execute.after": { ...event, status: "error", error: new Tool.Error({ message: "unused" }) },
|
||||
}
|
||||
const base = host()
|
||||
return ToolInputRepairPlugin.Plugin.effect(
|
||||
host({
|
||||
tool: {
|
||||
...base.tool,
|
||||
transform: (callback) =>
|
||||
Effect.sync(() => {
|
||||
const tool = {
|
||||
id: "test",
|
||||
name: "test",
|
||||
description: "Test repair",
|
||||
input: inputSchema,
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
callback({
|
||||
list: () => [tool],
|
||||
get: (id) => (id === tool.id ? tool : undefined),
|
||||
add: () => {},
|
||||
namespace: () => {},
|
||||
update: () => {},
|
||||
remove: () => {},
|
||||
})
|
||||
return { dispose: Effect.void }
|
||||
}),
|
||||
hook: (name, callback) => callback(events[name]).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
|
||||
},
|
||||
}),
|
||||
).pipe(Effect.as(event))
|
||||
}
|
||||
|
||||
const object = (properties: Record<string, unknown>, required?: string[]) => ({
|
||||
type: "object" as const,
|
||||
properties,
|
||||
...(required ? { required } : {}),
|
||||
})
|
||||
|
||||
describe("tool input repair plugin", () => {
|
||||
it.effect("preserves valid input identity, nested containers, and unknown properties", () =>
|
||||
Effect.gen(function* () {
|
||||
const nested = { enabled: true }
|
||||
const items = [2, 3]
|
||||
const input = { count: 2, nested, items, extra: "keep" }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
count: { type: "integer" },
|
||||
nested: object({ enabled: { type: "boolean" } }),
|
||||
items: { type: "array", items: { type: "number" } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toBe(input)
|
||||
expect((event.input as typeof input).nested).toBe(nested)
|
||||
expect((event.input as typeof input).items).toBe(items)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses root objects and repairs nested stringified containers", () =>
|
||||
Effect.gen(function* () {
|
||||
const schema = object({
|
||||
count: { type: "integer" },
|
||||
item: object({ enabled: { type: "boolean" } }),
|
||||
list: { type: "array", items: { type: "integer" } },
|
||||
})
|
||||
|
||||
expect(
|
||||
(yield* run('{"count":"2","item":"{\\"enabled\\":\\"false\\"}","list":"[\\"3\\"]"}', schema)).input,
|
||||
).toEqual({
|
||||
count: 2,
|
||||
item: { enabled: false },
|
||||
list: [3],
|
||||
})
|
||||
expect((yield* run("{broken", schema)).input).toBe("{broken")
|
||||
expect((yield* run("[]", schema)).input).toBe("[]")
|
||||
expect((yield* run(null, schema)).input).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes extras only from explicitly closed objects without mutating inputs", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
known: "2",
|
||||
extra: true,
|
||||
closed: { keep: "3", extra: true },
|
||||
open: { keep: "4", extra: true },
|
||||
items: [{ keep: "5", extra: true }],
|
||||
}
|
||||
const event = yield* run(input, {
|
||||
...object({
|
||||
known: { type: "integer" },
|
||||
closed: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
|
||||
open: object({ keep: { type: "integer" } }),
|
||||
items: {
|
||||
type: "array",
|
||||
items: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
|
||||
},
|
||||
}),
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
expect(event.input).toEqual({
|
||||
known: 2,
|
||||
closed: { keep: 3 },
|
||||
open: { keep: 4, extra: true },
|
||||
items: [{ keep: 5 }],
|
||||
})
|
||||
expect(input.extra).toBeTrue()
|
||||
expect(input.closed.extra).toBeTrue()
|
||||
expect(input.items[0]?.extra).toBeTrue()
|
||||
expect((yield* run({ extra: true }, { ...object({}), additionalProperties: false })).input).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves unknown keys when patterned ownership cannot be determined", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { known: 1, match: "2", extra: true }
|
||||
const event = yield* run(input, {
|
||||
...object({ known: { type: "integer" } }),
|
||||
additionalProperties: false,
|
||||
patternProperties: { "^match$": { type: "integer" } },
|
||||
})
|
||||
|
||||
expect(event.input).toBe(input)
|
||||
expect((event.input as typeof input).match).toBe("2")
|
||||
expect((event.input as typeof input).extra).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves properties that may belong to composed object schemas", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { name: "example", extra: true }
|
||||
|
||||
for (const keyword of ["allOf", "anyOf", "oneOf"]) {
|
||||
const event = yield* run(input, {
|
||||
type: "object",
|
||||
[keyword]: [object({ name: { type: "string" } })],
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
expect(event.input).toBe(input)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes only optional nonnullable nulls and non-object empty placeholders", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
optional: null,
|
||||
required: null,
|
||||
nullable: null,
|
||||
union: null,
|
||||
constant: null,
|
||||
permissive: null,
|
||||
referenced: null,
|
||||
placeholder: {},
|
||||
array: {},
|
||||
requiredPlaceholder: {},
|
||||
object: {},
|
||||
unknown: null,
|
||||
}
|
||||
const event = yield* run(
|
||||
input,
|
||||
object(
|
||||
{
|
||||
optional: { type: "string" },
|
||||
required: { type: "string" },
|
||||
nullable: { type: "string", nullable: true },
|
||||
union: { anyOf: [{ type: "integer" }, { type: "null" }] },
|
||||
constant: { anyOf: [{ type: "integer" }, { const: null }] },
|
||||
permissive: { anyOf: [{ type: "integer" }, true] },
|
||||
referenced: { anyOf: [{ type: "integer" }, { $ref: "#/$defs/nullable" }] },
|
||||
placeholder: { type: "integer" },
|
||||
array: { type: "array", items: { type: "string" } },
|
||||
requiredPlaceholder: { type: "boolean" },
|
||||
object: { type: "object" },
|
||||
unknown: {},
|
||||
},
|
||||
["required", "requiredPlaceholder"],
|
||||
),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({
|
||||
required: null,
|
||||
nullable: null,
|
||||
union: null,
|
||||
constant: null,
|
||||
permissive: null,
|
||||
referenced: null,
|
||||
requiredPlaceholder: {},
|
||||
object: {},
|
||||
unknown: null,
|
||||
})
|
||||
expect(input.optional).toBeNull()
|
||||
expect(input.placeholder).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves nulls whose validity is hidden inside compositions", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { wrapped: null, enumerated: null, permissive: null, constant: null }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
wrapped: { anyOf: [{ type: ["string", "null"] }] },
|
||||
enumerated: { anyOf: [{ enum: [null, "keep"] }] },
|
||||
permissive: { anyOf: [{}] },
|
||||
constant: { type: "string", const: "keep" },
|
||||
}),
|
||||
)
|
||||
expect(event.input).toBe(input)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves optional-looking nulls when the parent composes requirements", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { value: null }
|
||||
const event = yield* run(input, {
|
||||
...object({ value: { type: "string" } }),
|
||||
allOf: [{ required: ["value"] }],
|
||||
})
|
||||
expect(event.input).toBe(input)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("coerces numeric and boolean strings while preserving invalid and existing values", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
number: "1.5",
|
||||
integer: "42",
|
||||
enabled: "true",
|
||||
disabled: "false",
|
||||
valid: 3,
|
||||
empty: " ",
|
||||
infinite: "Infinity",
|
||||
fractional: "1.5",
|
||||
unsafe: "9007199254740992",
|
||||
uppercase: "TRUE",
|
||||
}
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
number: { type: "number" },
|
||||
integer: { type: "integer" },
|
||||
enabled: { type: "boolean" },
|
||||
disabled: { type: "boolean" },
|
||||
valid: { type: "integer" },
|
||||
empty: { type: "number" },
|
||||
infinite: { type: "number" },
|
||||
fractional: { type: "integer" },
|
||||
unsafe: { type: "integer" },
|
||||
uppercase: { type: "boolean" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({ ...input, number: 1.5, integer: 42, enabled: true, disabled: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("wraps compatible scalars after repairing array items", () =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* run(
|
||||
{
|
||||
text: "one",
|
||||
integer: "42",
|
||||
boolean: "false",
|
||||
item: '{"count":"2"}',
|
||||
incompatible: 2,
|
||||
fractional: 1.5,
|
||||
unconstrained: "4",
|
||||
},
|
||||
object({
|
||||
text: { type: "array", items: { type: "string" } },
|
||||
integer: { type: "array", items: { type: "integer" } },
|
||||
boolean: { type: "array", items: { type: "boolean" } },
|
||||
item: { type: "array", items: object({ count: { type: "integer" } }) },
|
||||
incompatible: { type: "array", items: { type: "string" } },
|
||||
fractional: { type: "array", items: { type: "integer" } },
|
||||
unconstrained: { type: "array" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({
|
||||
text: ["one"],
|
||||
integer: [42],
|
||||
boolean: [false],
|
||||
item: [{ count: 2 }],
|
||||
incompatible: 2,
|
||||
fractional: 1.5,
|
||||
unconstrained: "4",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs nested question-like inputs without mutating original containers", () =>
|
||||
Effect.gen(function* () {
|
||||
const question = { question: "Pick one", multiple: "false", options: { label: "First", description: null } }
|
||||
const input = { questions: [question] }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
questions: {
|
||||
type: "array",
|
||||
items: object({
|
||||
question: { type: "string" },
|
||||
multiple: { type: "boolean" },
|
||||
options: {
|
||||
type: "array",
|
||||
items: object({ label: { type: "string" }, description: { type: "string" } }, ["label"]),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({
|
||||
questions: [{ question: "Pick one", multiple: false, options: [{ label: "First" }] }],
|
||||
})
|
||||
expect(input).toEqual({ questions: [question] })
|
||||
expect(question.options.description).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs unique nullable alternatives while preserving accepted union values", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
number: "2",
|
||||
boolean: "false",
|
||||
nullable: null,
|
||||
typed: "3",
|
||||
typedBoolean: "true",
|
||||
accepted: "4",
|
||||
valid: 5,
|
||||
}
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
number: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
boolean: { oneOf: [{ type: "boolean" }, { type: "null" }] },
|
||||
nullable: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
typed: { type: ["integer", "null"] },
|
||||
typedBoolean: { type: ["boolean", "null"] },
|
||||
accepted: { anyOf: [{ type: "string" }, { type: "number" }] },
|
||||
valid: { type: ["number", "null"] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({ ...input, number: 2, boolean: false, typed: 3, typedBoolean: true })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs tuple positions and rest items while preserving valid array identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const valid = [2, false]
|
||||
const input = { prefix: ["2", "false", "3"], draft: '["4","true"]', valid, scalar: "5" }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
prefix: {
|
||||
type: "array",
|
||||
prefixItems: [{ type: "integer" }, { type: "boolean" }],
|
||||
items: { type: "number" },
|
||||
},
|
||||
draft: { type: "array", items: [{ type: "integer" }, { type: "boolean" }] },
|
||||
valid: { type: "array", prefixItems: [{ type: "integer" }, { type: "boolean" }] },
|
||||
scalar: { type: "array", prefixItems: [{ type: "integer" }] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({ prefix: [2, false, 3], draft: [4, true], valid, scalar: "5" })
|
||||
expect((event.input as typeof input).valid).toBe(valid)
|
||||
expect(input.prefix).toEqual(["2", "false", "3"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs typed dictionaries and straightforward local references", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
modern: "2",
|
||||
legacy: "false",
|
||||
nested: { count: "3" },
|
||||
dictionary: { first: "4" },
|
||||
missing: "5",
|
||||
pointer: "6",
|
||||
escaped: "7",
|
||||
}
|
||||
const event = yield* run(input, {
|
||||
...object({
|
||||
modern: { $ref: "#/$defs/integer" },
|
||||
legacy: { $ref: "#/definitions/boolean" },
|
||||
nested: { $ref: "#/$defs/nested" },
|
||||
dictionary: { type: "object", additionalProperties: { $ref: "#/$defs/integer" } },
|
||||
missing: { $ref: "#/$defs/missing" },
|
||||
pointer: { $ref: "#/$defs/nested/properties/count" },
|
||||
escaped: { $ref: "#/$defs/a~1b~0c" },
|
||||
}),
|
||||
$defs: {
|
||||
integer: { type: "integer" },
|
||||
"a/b~c": { type: "integer" },
|
||||
nested: object({ count: { $ref: "#/$defs/integer" } }),
|
||||
},
|
||||
definitions: { boolean: { type: "boolean" } },
|
||||
})
|
||||
|
||||
expect(event.input).toEqual({
|
||||
modern: 2,
|
||||
legacy: false,
|
||||
nested: { count: 3 },
|
||||
dictionary: { first: 4 },
|
||||
missing: "5",
|
||||
pointer: "6",
|
||||
escaped: 7,
|
||||
})
|
||||
expect(input.nested.count).toBe("3")
|
||||
expect(input.dictionary.first).toBe("4")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("leaves ambiguous unions, compositions, and unsupported roots unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { numeric: "2", objects: { value: "3" }, both: "4", composed: "5", unknown: "6" }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
numeric: { anyOf: [{ type: "number" }, { type: "integer" }] },
|
||||
objects: {
|
||||
oneOf: [
|
||||
object({ value: { type: "integer" } }, ["value"]),
|
||||
object({ value: { type: "number" } }, ["value"]),
|
||||
],
|
||||
},
|
||||
both: { anyOf: [{ type: "integer" }], oneOf: [{ type: "integer" }] },
|
||||
composed: { allOf: [{ type: "integer" }] },
|
||||
unknown: {},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toBe(input)
|
||||
expect((yield* run(input, { properties: { numeric: { type: "integer" } } })).input).toBe(input)
|
||||
expect((yield* run(input, { allOf: [object({ numeric: { type: "integer" } })] })).input).toBe(input)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -28,8 +28,16 @@ describe("Provider", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("passes flat settings to native packages without opencode transport keys", () => {
|
||||
expect(Provider.nativeSettings({ apiKey: "secret", reasoningEffort: "high", chunkTimeout: 1000 })).toEqual({
|
||||
test("passes flat settings to native packages without Core settings", () => {
|
||||
expect(
|
||||
Provider.nativeSettings({
|
||||
apiKey: "secret",
|
||||
reasoningEffort: "high",
|
||||
chunkTimeout: 1000,
|
||||
compaction: { type: "native" },
|
||||
transport: "websocket",
|
||||
}),
|
||||
).toEqual({
|
||||
apiKey: "secret",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
|
||||
@@ -207,20 +207,13 @@ it.effect("auto compaction estimates current content against the buffered prompt
|
||||
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
|
||||
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
|
||||
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
|
||||
const native = (
|
||||
tokens: number,
|
||||
limit: { context: number; input?: number; output: number } = inputLimited,
|
||||
threshold?: number,
|
||||
) => {
|
||||
const native = (tokens: number, limit: { context: number; input?: number; output: number } = inputLimited) => {
|
||||
const selected = input(tokens, limit)
|
||||
return { ...selected, resolved: { ...selected.resolved, compaction: { mode: "provider" as const, threshold } } }
|
||||
return { ...selected, resolved: { ...selected.resolved, compaction: { type: "native" as const } } }
|
||||
}
|
||||
expect(compaction.required(native(251_999))).toBe(false)
|
||||
expect(compaction.required(native(252_000))).toBe(true)
|
||||
expect(compaction.required(native(99_999, inputLimited, 100_000))).toBe(false)
|
||||
expect(compaction.required(native(100_000, inputLimited, 100_000))).toBe(true)
|
||||
expect(compaction.required(native(252_000, inputLimited, 500_000))).toBe(true)
|
||||
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }, 100_000))).toBe(false)
|
||||
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }))).toBe(false)
|
||||
|
||||
const contextLimited = { context: 100_000, output: 10_000 }
|
||||
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLMClient, LanguageModel, Message, ToolDefinition } from "@opencode/ai"
|
||||
import { LLMClient, LanguageModel, Message, ToolDefinition, Usage } from "@opencode/ai"
|
||||
import { OpenAI } from "@opencode/ai/providers"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
@@ -7,6 +7,7 @@ import { Database } from "@opencode/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { llmClient } from "@opencode/core/effect/app-node-platform"
|
||||
import { Instructions } from "@opencode/core/instructions/index"
|
||||
import { NativeCompactionPlugin } from "@opencode/core/plugin/compaction"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { Project } from "@opencode/core/project"
|
||||
import { ProjectTable } from "@opencode/core/project/sql"
|
||||
@@ -26,6 +27,7 @@ import { SessionStore } from "@opencode/core/session/store"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
@@ -44,7 +46,8 @@ const it = testEffect(
|
||||
),
|
||||
)
|
||||
|
||||
const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin?: boolean } = {}) {
|
||||
const endpoint = options.endpoint ?? false
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
const inbox = yield* SessionInbox.Service
|
||||
@@ -161,7 +164,7 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
compaction: { mode: "provider" },
|
||||
compaction: { type: "native" },
|
||||
},
|
||||
)
|
||||
const sessionID = SessionSchema.ID.create()
|
||||
@@ -185,6 +188,7 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" },
|
||||
})
|
||||
yield* InstructionState.prepare(db, bus, instructions, sessionID)
|
||||
if (options.plugin !== false) yield* NativeCompactionPlugin.Plugin.effect(host())
|
||||
yield* hooks.register("session", "model.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.headers["x-test-hook"] = event.kind
|
||||
@@ -261,6 +265,7 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
store,
|
||||
hooks,
|
||||
model,
|
||||
compaction,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -348,7 +353,7 @@ it.live(
|
||||
|
||||
it.live("manual and automatic endpoint compaction keep the provider replacement unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup(true)
|
||||
const fixture = yield* setup({ endpoint: true })
|
||||
yield* fixture.prompt("Original user")
|
||||
expect(yield* fixture.compact).toEqual({ status: "completed" })
|
||||
expect(yield* fixture.automatic).toEqual({ status: "completed" })
|
||||
@@ -445,6 +450,31 @@ it.live("rejects request-hook route rewrites before provider compaction", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("provider compaction fails without a native strategy and persists a registered strategy's window", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup({ plugin: false })
|
||||
yield* fixture.prompt("Original user")
|
||||
expect(yield* fixture.compact).toMatchObject({
|
||||
status: "failed",
|
||||
error: { type: "provider.unsupported-operation", message: expect.stringContaining("openai/openai-responses") },
|
||||
})
|
||||
yield* fixture.compaction.transform((editor) => {
|
||||
editor.native(() =>
|
||||
Effect.succeed({
|
||||
replacement: [Message.assistant("plugin window")],
|
||||
usage: new Usage({ nonCachedInputTokens: 20, outputTokens: 4 }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
expect(yield* fixture.compact).toEqual({ status: "completed" })
|
||||
expect(fixture.state.calls).toBe(0)
|
||||
const installed = yield* fixture.checkpoint
|
||||
expect(installed.provenance).toEqual(SessionProviderContext.provenance(fixture.model)!)
|
||||
expect(SessionProviderContext.decode(installed)).toEqual([Message.assistant("plugin window")])
|
||||
expect(yield* fixture.store.get(fixture.sessionID)).toMatchObject({ tokens: { input: 20, output: 4 } })
|
||||
}),
|
||||
)
|
||||
|
||||
test("retained user budget counts attachments and drops whole oldest messages", () => {
|
||||
const model = SessionRunnerModel.resolved(OpenAI.responses("gpt-5.4-mini"), {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
|
||||
@@ -501,8 +501,31 @@ describe("Session.prompt", () => {
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "Session.AttachmentError",
|
||||
uri,
|
||||
message: "Invalid attachment data URL",
|
||||
uri: "image.png",
|
||||
message: "Invalid attachment data URL: image.png",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects oversized inline attachments without echoing their bytes", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
const uri = `data:application/octet-stream;base64,${Buffer.alloc(20 * 1024 * 1024 + 1).toString("base64")}`
|
||||
|
||||
const error = yield* session
|
||||
.prompt({
|
||||
sessionID,
|
||||
text: "Inspect this",
|
||||
files: [{ uri }],
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "Session.AttachmentError",
|
||||
uri: "inline attachment",
|
||||
message: "Attachment exceeds the 20971520 byte limit: inline attachment",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -56,6 +56,7 @@ import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { OptimizePlugin } from "@opencode/core/plugin/optimize"
|
||||
import { IdentityPlugin } from "@opencode/core/plugin/identity"
|
||||
import { NativeCompactionPlugin } from "@opencode/core/plugin/compaction"
|
||||
import { QuestionTool } from "@opencode/core/tool/plugin/question"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Config } from "@opencode/core/config"
|
||||
@@ -470,6 +471,7 @@ const layer = Layer.unwrap(
|
||||
Config.node,
|
||||
Snapshot.node,
|
||||
SessionCompaction.node,
|
||||
LayerNodePlatform.llmClient,
|
||||
SessionRunnerLLM.node,
|
||||
SessionExecution.node,
|
||||
Session.node,
|
||||
@@ -523,6 +525,7 @@ const setup = Effect.gen(function* () {
|
||||
discard: true,
|
||||
})
|
||||
yield* IdentityPlugin.Plugin.effect(pluginHost)
|
||||
yield* NativeCompactionPlugin.Plugin.effect(pluginHost)
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(Agent.ID.make("build"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
@@ -2860,7 +2863,8 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
scenario("automatically persists native windows, retains earlier users, and waits for fresh usage", function* (s) {
|
||||
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
|
||||
s.compaction = { mode: "provider", threshold: 10_000 }
|
||||
modelLimits.set("native", { context: 42_000, output: 32_000 })
|
||||
s.compaction = { type: "native" }
|
||||
const agents = yield* Agent.Service
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(Agent.defaultID, (agent) => {
|
||||
@@ -2911,7 +2915,8 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
scenario("recovers an overflowing native window locally from original durable history", function* (s) {
|
||||
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
|
||||
s.compaction = { mode: "provider", threshold: 10_000 }
|
||||
modelLimits.set("native", { context: 42_000, output: 32_000 })
|
||||
s.compaction = { type: "native" }
|
||||
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 10_000))
|
||||
yield* s.runPrompt("Original durable request")
|
||||
yield* s.llm.push(
|
||||
|
||||
@@ -106,6 +106,9 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
[coreModel.Info, Model.Info],
|
||||
[coreProvider.ID, Provider.ID],
|
||||
[coreProvider.Request, Provider.Request],
|
||||
[coreProvider.Compaction, Provider.Compaction],
|
||||
[coreProvider.Transport, Provider.Transport],
|
||||
[coreProvider.Settings, Provider.Settings],
|
||||
[coreProvider.Info, Provider.Info],
|
||||
[corePermission.Effect, Permission.Effect],
|
||||
[corePermission.Rule, Permission.Rule],
|
||||
|
||||
@@ -48,7 +48,8 @@ test("does not package external copies of bundled dependencies", () => {
|
||||
expect(pkg.devDependencies.effect).toBe("catalog:")
|
||||
expect(pkg.devDependencies["@effect/platform-node"]).toBe("catalog:")
|
||||
expect(pkg.devDependencies["drizzle-orm"]).toBe("catalog:")
|
||||
expect(pkg.optionalDependencies["msgpackr-extract"]).toBe("3.0.4")
|
||||
// IPC crosses the port by structured clone; no MessagePack runtime or native accelerator ships.
|
||||
expect(Object.keys(pkg.optionalDependencies)).not.toContain("msgpackr-extract")
|
||||
})
|
||||
|
||||
test("keeps PTY binaries without stale native packaging", () => {
|
||||
@@ -101,6 +102,6 @@ test("bundles one Effect runtime and Drizzle while keeping native dependencies e
|
||||
expect(imports).toContain("node:sqlite")
|
||||
expect(chunks.some((chunk) => chunk.dynamicImports.includes("@zip.js/zip.js"))).toBe(true)
|
||||
expect(imports).toContain(`@lydell/node-pty-${process.platform}-${process.arch}`)
|
||||
expect(modules.some((id) => id.includes("/node_modules/msgpackr-extract/"))).toBe(false)
|
||||
expect(chunks.some((chunk) => chunk.code.includes("msgpackr-extract"))).toBe(true)
|
||||
expect(modules.some((id) => id.includes("/node_modules/msgpackr"))).toBe(false)
|
||||
expect(chunks.some((chunk) => chunk.code.includes("msgpackr"))).toBe(false)
|
||||
}, 30_000)
|
||||
|
||||
@@ -58,9 +58,9 @@ const require = __cjs_mod__.createRequire(import.meta.url);
|
||||
},
|
||||
},
|
||||
externalizeDeps: {
|
||||
// Bundle the Effect family together; native MessagePack acceleration stays optional and external.
|
||||
// Bundle the Effect family together.
|
||||
exclude: ["effect", "@effect/platform-node", "@effect/platform-node-shared", "drizzle-orm"],
|
||||
include: [nodePtyPkg, "msgpackr-extract"],
|
||||
include: [nodePtyPkg],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
@@ -68,7 +68,6 @@
|
||||
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||
"msgpackr-extract": "3.0.4"
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,22 @@ import { EventEmitter } from "node:events"
|
||||
import { MessageChannel } from "node:worker_threads"
|
||||
import type { MessagePortMain, WebContents } from "electron"
|
||||
import { Context, Effect, Layer, ManagedRuntime, Option, Queue, Schema, Stream } from "effect"
|
||||
import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
|
||||
import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcMessage, RpcServer } from "effect/unstable/rpc"
|
||||
import { Transferable } from "effect/unstable/workers"
|
||||
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
|
||||
|
||||
describe("desktop RPC transport", () => {
|
||||
test("keeps multiple renderer ports independent", async () => {
|
||||
let received: unknown
|
||||
const handlers = TestRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
return TestRpcs.of({
|
||||
"test.focused": (_request, context) => Effect.succeed(handoff.sender(context.client.id)?.id === 1),
|
||||
"test.blob.put": ({ data }) => Effect.succeed([...data].join(",")),
|
||||
"test.blob.put": ({ data }) => {
|
||||
received = data
|
||||
return Effect.succeed([...data].join(","))
|
||||
},
|
||||
"test.blob.get": () => Effect.succeed(new Uint8Array([3, 1, 4])),
|
||||
"test.events": () => Stream.make(new TestEvent({ value: "session.new" })),
|
||||
})
|
||||
@@ -34,6 +39,8 @@ describe("desktop RPC transport", () => {
|
||||
expect(focused).toBe(true)
|
||||
expect(unfocused).toBe(false)
|
||||
expect(await putBlob(firstClient, new Uint8Array([2, 7, 1]))).toBe("2,7,1")
|
||||
// Binary payloads arrive as bytes, not as base64 text.
|
||||
expect(received).toBeInstanceOf(Uint8Array)
|
||||
expect(await getBlob(firstClient)).toEqual(new Uint8Array([3, 1, 4]))
|
||||
expect(await firstEvent(firstClient)).toEqual(new TestEvent({ value: "session.new" }))
|
||||
|
||||
@@ -54,8 +61,8 @@ describe("desktop RPC transport", () => {
|
||||
class TestEvent extends Schema.TaggedClass<TestEvent>()("TestEvent", { value: Schema.String }) {}
|
||||
const TestRpcs = RpcGroup.make(
|
||||
Rpc.make("test.focused", { success: Schema.Boolean }),
|
||||
Rpc.make("test.blob.put", { payload: { data: Schema.Uint8Array }, success: Schema.String }),
|
||||
Rpc.make("test.blob.get", { success: Schema.Uint8Array }),
|
||||
Rpc.make("test.blob.put", { payload: { data: Transferable.Uint8Array }, success: Schema.String }),
|
||||
Rpc.make("test.blob.get", { success: Transferable.Uint8Array }),
|
||||
Rpc.make("test.events", { success: TestEvent, stream: true }),
|
||||
)
|
||||
type TestRpcClient = RpcClient.FromGroup<typeof TestRpcs, RpcClientError.RpcClientError>
|
||||
@@ -109,13 +116,9 @@ function clientProtocol(port: MessagePort) {
|
||||
RpcClient.Protocol,
|
||||
RpcClient.Protocol.make(
|
||||
Effect.fnUntraced(function* (writeResponse, clientIds) {
|
||||
const serialization = yield* RpcSerialization.RpcSerialization
|
||||
const parser = serialization.makeUnsafe()
|
||||
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
|
||||
const onMessage = (event: MessageEvent) =>
|
||||
parser
|
||||
.decode(event.data)
|
||||
.forEach((message) => Queue.offerUnsafe(inbound, message as RpcMessage.FromServerEncoded))
|
||||
Queue.offerUnsafe(inbound, event.data as RpcMessage.FromServerEncoded)
|
||||
port.addEventListener("message", onMessage)
|
||||
port.start()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
@@ -131,18 +134,15 @@ function clientProtocol(port: MessagePort) {
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return {
|
||||
codecFor: serialization.codecFor,
|
||||
codecFor: Schema.toCodecJson,
|
||||
send: (_clientId: number, request: RpcMessage.FromClientEncoded) =>
|
||||
Effect.sync(() => {
|
||||
const encoded = parser.encode(request)
|
||||
if (encoded !== undefined) port.postMessage(encoded)
|
||||
}),
|
||||
Effect.sync(() => port.postMessage(request)),
|
||||
supportsAck: true,
|
||||
supportsTransferables: false,
|
||||
}
|
||||
}),
|
||||
),
|
||||
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
|
||||
)
|
||||
}
|
||||
|
||||
function sender(id: number) {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { MessagePortMain, WebContents } from "electron"
|
||||
import { Context, Effect, Layer, Option, Queue, Stream } from "effect"
|
||||
import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
|
||||
import { Context, Effect, Layer, Option, Queue, Schema, Stream } from "effect"
|
||||
import { RpcMessage, RpcServer } from "effect/unstable/rpc"
|
||||
import { bindIpcEvents } from "./ipc-events"
|
||||
|
||||
type PortBinding = {
|
||||
readonly id: number
|
||||
readonly sender: WebContents
|
||||
readonly port: MessagePortMain
|
||||
readonly parser: RpcSerialization.Parser
|
||||
readonly onMessage: (event: Electron.MessageEvent) => void
|
||||
readonly onClose: () => void
|
||||
readonly unbindEvents: Effect.Effect<void>
|
||||
@@ -20,6 +19,9 @@ type Handoff = {
|
||||
|
||||
export class IpcPortHandoff extends Context.Service<IpcPortHandoff, Handoff>()("opencode/desktop/IpcPortHandoff") {}
|
||||
|
||||
// Messages cross the port by structured clone, like Effect's worker protocol: no serialization
|
||||
// layer, so binary payloads stay binary and nothing is packed into a shared buffer. Electron's
|
||||
// MessagePortMain can only transfer ports, so byte payloads are cloned in both directions.
|
||||
export const IpcServerProtocolLive = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const handoffs = yield* Queue.unbounded<readonly [WebContents, MessagePortMain]>()
|
||||
@@ -30,7 +32,6 @@ export const IpcServerProtocolLive = Layer.unwrap(
|
||||
RpcServer.Protocol,
|
||||
RpcServer.Protocol.make(
|
||||
Effect.fnUntraced(function* (writeRequest) {
|
||||
const serialization = yield* RpcSerialization.RpcSerialization
|
||||
const disconnects = yield* Queue.unbounded<number>()
|
||||
const inbound = yield* Queue.unbounded<readonly [number, RpcMessage.FromClientEncoded]>()
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
@@ -58,21 +59,12 @@ export const IpcServerProtocolLive = Layer.unwrap(
|
||||
}
|
||||
|
||||
const id = nextClientId++
|
||||
const parser = serialization.makeUnsafe()
|
||||
const onMessage = (event: Electron.MessageEvent) => {
|
||||
try {
|
||||
parser
|
||||
.decode(event.data)
|
||||
.forEach((message) =>
|
||||
Queue.offerUnsafe(inbound, [id, message as RpcMessage.FromClientEncoded] as const),
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
Queue.offerUnsafe(inbound, [id, event.data as RpcMessage.FromClientEncoded] as const)
|
||||
}
|
||||
const onClose = () => runFork(disconnect(id))
|
||||
const unbindEvents = yield* bindIpcEvents(sender.id)
|
||||
const binding = { id, sender, port, parser, onMessage, onClose, unbindEvents }
|
||||
const binding = { id, sender, port, onMessage, onClose, unbindEvents }
|
||||
bindings.set(id, binding)
|
||||
senderBindings.set(sender.id, id)
|
||||
port.on("message", onMessage)
|
||||
@@ -92,14 +84,11 @@ export const IpcServerProtocolLive = Layer.unwrap(
|
||||
yield* Effect.addFinalizer(() => Effect.forEach([...bindings.keys()], disconnect, { discard: true }))
|
||||
|
||||
return {
|
||||
codecFor: serialization.codecFor,
|
||||
codecFor: Schema.toCodecJson,
|
||||
disconnects,
|
||||
send: (clientId, response) =>
|
||||
Effect.sync(() => {
|
||||
const binding = bindings.get(clientId)
|
||||
if (!binding) return
|
||||
const encoded = binding.parser.encode(response)
|
||||
if (encoded !== undefined) binding.port.postMessage(encoded)
|
||||
bindings.get(clientId)?.port.postMessage(response)
|
||||
}),
|
||||
end: disconnect,
|
||||
clientIds: Effect.sync(() => new Set(bindings.keys())),
|
||||
@@ -123,4 +112,4 @@ export const IpcServerProtocolLive = Layer.unwrap(
|
||||
}),
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
|
||||
)
|
||||
|
||||
@@ -98,8 +98,10 @@ export function createDraftStore(
|
||||
orphans = true
|
||||
return id
|
||||
},
|
||||
getBlob(id: string): Uint8Array | null {
|
||||
return db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data ?? null
|
||||
getBlob(id: string): Uint8Array<ArrayBuffer> | null {
|
||||
const data = db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data
|
||||
// node:sqlite allocates a dedicated ArrayBuffer per BLOB column value.
|
||||
return data ? (data as Uint8Array<ArrayBuffer>) : null
|
||||
},
|
||||
flush: writer.flush,
|
||||
close: writer.close,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Context, Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"
|
||||
import { RpcClient, RpcMessage, RpcSerialization } from "effect/unstable/rpc"
|
||||
import { Context, Effect, Layer, ManagedRuntime, Queue, Schema, Stream } from "effect"
|
||||
import { RpcClient, RpcMessage } from "effect/unstable/rpc"
|
||||
import { DesktopRpcs, type DesktopRpcClient } from "../shared/ipc-rpc"
|
||||
import type { DesktopEvent } from "../shared/ipc-rpc/events"
|
||||
import { IpcTransportPort } from "../shared/ipc-transport"
|
||||
@@ -79,22 +79,17 @@ export function listen<Tag extends EventTag>(tag: Tag, listener: (value: EventVa
|
||||
}
|
||||
}
|
||||
|
||||
// Structured clone over the port, like Effect's worker protocol: no serialization layer, so binary
|
||||
// payloads stay binary. Buffers are cloned rather than transferred: Electron's MessagePortMain
|
||||
// drops transferred ArrayBuffers, so a request carrying one would never arrive.
|
||||
function clientProtocol(value: MessagePort) {
|
||||
return Layer.effect(
|
||||
RpcClient.Protocol,
|
||||
RpcClient.Protocol.make(
|
||||
Effect.fnUntraced(function* (writeResponse, clientIds) {
|
||||
const serialization = yield* RpcSerialization.RpcSerialization
|
||||
const parser = serialization.makeUnsafe()
|
||||
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
parser
|
||||
.decode(event.data)
|
||||
.forEach((message) => Queue.offerUnsafe(inbound, message as RpcMessage.FromServerEncoded))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
Queue.offerUnsafe(inbound, event.data as RpcMessage.FromServerEncoded)
|
||||
}
|
||||
value.addEventListener("message", onMessage)
|
||||
value.start()
|
||||
@@ -111,16 +106,15 @@ function clientProtocol(value: MessagePort) {
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return {
|
||||
codecFor: serialization.codecFor,
|
||||
codecFor: Schema.toCodecJson,
|
||||
send: (_clientId, request) =>
|
||||
Effect.sync(() => {
|
||||
const encoded = parser.encode(request)
|
||||
if (encoded !== undefined) value.postMessage(encoded)
|
||||
value.postMessage(request)
|
||||
}),
|
||||
supportsAck: true,
|
||||
supportsTransferables: false,
|
||||
}
|
||||
}),
|
||||
),
|
||||
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
import { Transferable } from "effect/unstable/workers"
|
||||
|
||||
const OptionalString = Schema.optional(Schema.String)
|
||||
const PickerOptions = Schema.Struct({
|
||||
@@ -18,7 +19,7 @@ const PickedFiles = Schema.Struct({
|
||||
token: Schema.String,
|
||||
files: Schema.Array(Schema.Struct({ path: Schema.String, name: Schema.String, size: Schema.Number })),
|
||||
})
|
||||
const ClipboardImage = Schema.Struct({ buffer: Schema.Uint8Array, width: Schema.Number, height: Schema.Number })
|
||||
const ClipboardImage = Schema.Struct({ buffer: Transferable.Uint8Array, width: Schema.Number, height: Schema.Number })
|
||||
|
||||
export const FilesOpenDirectoryPicker = Rpc.make("FilesOpenDirectoryPicker", {
|
||||
payload: { options: Schema.optional(PickerOptions) },
|
||||
@@ -30,7 +31,7 @@ export const FilesOpenFilePicker = Rpc.make("FilesOpenFilePicker", {
|
||||
})
|
||||
export const FilesReadPickedFile = Rpc.make("FilesReadPickedFile", {
|
||||
payload: { token: Schema.String, path: Schema.String },
|
||||
success: Schema.Uint8Array,
|
||||
success: Transferable.Uint8Array,
|
||||
})
|
||||
export const FilesReleasePickedFiles = Rpc.make("FilesReleasePickedFiles", {
|
||||
payload: { token: Schema.String },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
import { Transferable } from "effect/unstable/workers"
|
||||
|
||||
export const StorageItems = Rpc.make("StorageItems", {
|
||||
payload: { name: Schema.String },
|
||||
@@ -24,12 +25,12 @@ export const DraftsSet = Rpc.make("DraftsSet", {
|
||||
})
|
||||
export const DraftsDelete = Rpc.make("DraftsDelete", { payload: { key: Schema.String } })
|
||||
export const DraftsPutBlob = Rpc.make("DraftsPutBlob", {
|
||||
payload: { data: Schema.Uint8Array },
|
||||
payload: { data: Transferable.Uint8Array },
|
||||
success: Schema.String,
|
||||
})
|
||||
export const DraftsGetBlob = Rpc.make("DraftsGetBlob", {
|
||||
payload: { id: Schema.String },
|
||||
success: Schema.NullOr(Schema.Uint8Array),
|
||||
success: Schema.NullOr(Transferable.Uint8Array),
|
||||
})
|
||||
|
||||
export const StorageRpcs = RpcGroup.make(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -13161,7 +13161,7 @@
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["provider.use"]
|
||||
"enum": ["provider.use", "permission"]
|
||||
},
|
||||
"resource": {
|
||||
"type": "string"
|
||||
@@ -13254,12 +13254,6 @@
|
||||
"Config.ModelEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"transport": {
|
||||
"$ref": "#/components/schemas/Provider.Transport"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -13276,7 +13270,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
"$ref": "#/components/schemas/Config.Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -13299,7 +13293,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
"$ref": "#/components/schemas/Config.Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -13379,15 +13373,47 @@
|
||||
"required": ["package"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.ProviderEncoded": {
|
||||
"Config.Provider.Settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timeout": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"chunkTimeout": {
|
||||
"type": "number"
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"transport": {
|
||||
"$ref": "#/components/schemas/Provider.Transport"
|
||||
},
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Config.ProviderEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -13404,7 +13430,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
"$ref": "#/components/schemas/Config.Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -15481,14 +15507,8 @@
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"transport": {
|
||||
"$ref": "#/components/schemas/Provider.Transport"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
"$ref": "#/components/schemas/Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -15601,7 +15621,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
"$ref": "#/components/schemas/Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -16418,27 +16438,23 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["local"]
|
||||
"enum": ["summary"]
|
||||
}
|
||||
},
|
||||
"required": ["mode"],
|
||||
"required": ["type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["provider"]
|
||||
},
|
||||
"threshold": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0
|
||||
"enum": ["native"]
|
||||
}
|
||||
},
|
||||
"required": ["mode"],
|
||||
"required": ["type"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
@@ -16465,14 +16481,8 @@
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"transport": {
|
||||
"$ref": "#/components/schemas/Provider.Transport"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
"$ref": "#/components/schemas/Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -16507,7 +16517,35 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Provider.Settings": {
|
||||
"type": "object"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timeout": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"chunkTimeout": {
|
||||
"type": "number"
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"transport": {
|
||||
"$ref": "#/components/schemas/Provider.Transport"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Provider.Transport": {
|
||||
"type": "string",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -6,10 +6,21 @@ import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
|
||||
import { Provider } from "../provider.js"
|
||||
import { optional } from "../schema.js"
|
||||
|
||||
export const Settings = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
timeout: Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional),
|
||||
chunkTimeout: Schema.Finite.pipe(optional),
|
||||
compaction: Provider.Compaction.pipe(optional),
|
||||
transport: Provider.Transport.pipe(optional),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Json))],
|
||||
).annotate({ identifier: "Config.Provider.Settings" })
|
||||
export type Settings = typeof Settings.Type
|
||||
|
||||
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
||||
|
||||
export const Overlays = {
|
||||
settings: JsonRecord.pipe(optional),
|
||||
settings: Settings.pipe(optional),
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
body: JsonRecord.pipe(optional),
|
||||
}
|
||||
@@ -41,10 +52,6 @@ class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
|
||||
}) {}
|
||||
|
||||
class Model extends Schema.Class<Model>("Config.Model")({
|
||||
compaction: Provider.Compaction.pipe(optional),
|
||||
transport: Provider.Transport.pipe(optional).annotate({
|
||||
description: "Session transport for this model. Defaults to the provider transport.",
|
||||
}),
|
||||
modelID: ID.pipe(optional),
|
||||
family: Family.pipe(optional),
|
||||
name: Schema.String.pipe(optional),
|
||||
@@ -62,11 +69,6 @@ class Model extends Schema.Class<Model>("Config.Model")({
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Provider")({
|
||||
compaction: Provider.Compaction.pipe(optional),
|
||||
transport: Provider.Transport.pipe(optional).annotate({
|
||||
description:
|
||||
"Session transport for this provider's models. Defaults to the built-in policy; \"websocket\" on a route without a WebSocket channel warns and falls back to HTTP.",
|
||||
}),
|
||||
canonical: Provider.ID.pipe(optional),
|
||||
name: Schema.String.pipe(optional),
|
||||
env: Schema.String.pipe(Schema.Array, optional),
|
||||
|
||||
@@ -110,9 +110,6 @@ export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
compatibility: Compatibility.pipe(optional),
|
||||
package: Provider.Package.pipe(optional),
|
||||
compaction: Provider.Compaction.pipe(optional),
|
||||
/** Session transport; omitted inherits the provider transport, then defaults to HTTP. */
|
||||
transport: Provider.Transport.pipe(optional),
|
||||
...Provider.Overlays,
|
||||
capabilities: Capabilities,
|
||||
variants: Schema.Array(Variant),
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as Provider from "./provider.js"
|
||||
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Integration } from "./integration.js"
|
||||
import { optional, PositiveInt, statics } from "./schema.js"
|
||||
import { optional, statics } from "./schema.js"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
@@ -32,25 +32,35 @@ export type Package = typeof Package.Type
|
||||
export const Activation = Schema.Literals(["auto", "enabled", "disabled"])
|
||||
export type Activation = typeof Activation.Type
|
||||
|
||||
export type Compaction = typeof Compaction.Type
|
||||
export const Compaction = Schema.Union([
|
||||
Schema.Struct({ mode: Schema.Literal("local") }),
|
||||
Schema.Struct({ mode: Schema.Literal("provider"), threshold: PositiveInt.pipe(optional) }),
|
||||
]).annotate({ identifier: "Provider.Compaction" })
|
||||
Schema.Struct({ type: Schema.Literal("summary") }),
|
||||
Schema.Struct({ type: Schema.Literal("native") }),
|
||||
])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Provider.Compaction" })
|
||||
export type Compaction = typeof Compaction.Type
|
||||
|
||||
/** "websocket" on a route without a WebSocket channel warns and falls back to HTTP. */
|
||||
export const Transport = Schema.Literals(["http", "websocket"]).annotate({ identifier: "Provider.Transport" })
|
||||
export type Transport = typeof Transport.Type
|
||||
|
||||
export const Settings = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
timeout: Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional),
|
||||
chunkTimeout: Schema.Finite.pipe(optional),
|
||||
compaction: Compaction.pipe(optional),
|
||||
transport: Transport.pipe(optional),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
).annotate({ identifier: "Provider.Settings" })
|
||||
export type Settings = typeof Settings.Type
|
||||
|
||||
export const Overlays = {
|
||||
settings: Schema.Record(Schema.String, Schema.Any).pipe(optional),
|
||||
settings: Settings.pipe(optional),
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
body: Schema.Record(Schema.String, Schema.Any).pipe(optional),
|
||||
}
|
||||
|
||||
export const Settings = Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "Provider.Settings" })
|
||||
export type Settings = typeof Settings.Type
|
||||
|
||||
export interface Request extends Schema.Schema.Type<typeof Request> {}
|
||||
export const Request = Schema.Struct({
|
||||
settings: Settings.pipe(Schema.withConstructorDefault(Effect.succeed({}))),
|
||||
@@ -66,9 +76,6 @@ export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
activation: Activation,
|
||||
package: Package,
|
||||
compaction: Compaction.pipe(optional),
|
||||
/** Session transport for this provider's models; omitted means HTTP. */
|
||||
transport: Transport.pipe(optional),
|
||||
...Overlays,
|
||||
})
|
||||
.annotate({ identifier: "Provider.Info" })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { DateTime, Schema } from "effect"
|
||||
import { Agent } from "../src/agent.js"
|
||||
import { ConfigAgent } from "../src/config/agent.js"
|
||||
import { ConfigProvider } from "../src/config/provider.js"
|
||||
import { FileSystem } from "../src/filesystem.js"
|
||||
import { Form } from "../src/form.js"
|
||||
import { Mcp } from "../src/mcp.js"
|
||||
@@ -168,6 +169,7 @@ describe("contract hygiene", () => {
|
||||
test("reusable public identifiers are stable and unique", () => {
|
||||
const identifiers = [
|
||||
Agent.Color,
|
||||
ConfigProvider.Settings,
|
||||
FileSystem.Submatch,
|
||||
Form.Field,
|
||||
Form.Fields,
|
||||
@@ -226,7 +228,7 @@ describe("contract hygiene", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("current source limits Any to provider options and avoids mutable contract wrappers", async () => {
|
||||
test("current source limits Any to reviewed boundaries and avoids mutable contract wrappers", async () => {
|
||||
const files = [...new Bun.Glob("*.ts").scanSync(new URL("../src", import.meta.url).pathname)].filter(
|
||||
(file) => !file.endsWith("-v1.ts"),
|
||||
)
|
||||
@@ -237,11 +239,12 @@ describe("contract hygiene", () => {
|
||||
|
||||
expect(
|
||||
sources
|
||||
.filter((item) => item.file !== "provider.ts")
|
||||
.filter((item) => item.file !== "provider.ts" && item.file !== "integration.ts")
|
||||
.map((item) => item.source)
|
||||
.join("\n"),
|
||||
).not.toContain("Schema.Any")
|
||||
expect(sources.find((item) => item.file === "provider.ts")?.source.match(/Schema\.Any/g)).toHaveLength(4)
|
||||
expect(sources.find((item) => item.file === "provider.ts")?.source.match(/Schema\.Any/g)).toHaveLength(3)
|
||||
expect(sources.find((item) => item.file === "integration.ts")?.source.match(/Schema\.Any/g)).toHaveLength(2)
|
||||
expect(source).not.toContain("Schema.mutable")
|
||||
})
|
||||
|
||||
|
||||
@@ -56,23 +56,16 @@ describe("Model.Compatibility", () => {
|
||||
})
|
||||
|
||||
describe("Model.Info", () => {
|
||||
test("provider compaction policy is optional and uses the canonical closed schema", () => {
|
||||
test("provider compaction policy is a typed setting", () => {
|
||||
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
|
||||
expect(Schema.encodeSync(Model.Info)({ ...model, compaction: undefined })).not.toHaveProperty("compaction")
|
||||
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, compaction: { mode: "provider" } }).compaction).toEqual({
|
||||
mode: "provider",
|
||||
expect(Schema.encodeSync(Model.Info)({ ...model, settings: { compaction: undefined } }).settings).toEqual({})
|
||||
expect(
|
||||
Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { compaction: { type: "native" } } }).settings,
|
||||
).toEqual({
|
||||
compaction: { type: "native" },
|
||||
})
|
||||
expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "local" })).toEqual({ mode: "local" })
|
||||
expect(Schema.encodeSync(Provider.Compaction)({ mode: "provider", threshold: undefined })).toEqual({
|
||||
mode: "provider",
|
||||
})
|
||||
expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold: 120_000 })).toEqual({
|
||||
mode: "provider",
|
||||
threshold: 120_000,
|
||||
})
|
||||
for (const threshold of [0, -1, 1.5])
|
||||
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold })).toThrow()
|
||||
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "automatic" })).toThrow()
|
||||
expect(Schema.decodeUnknownSync(Provider.Compaction)({ type: "summary" })).toEqual({ type: "summary" })
|
||||
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ type: "automatic" })).toThrow()
|
||||
})
|
||||
|
||||
test("uses practical token limits for unknown models", () => {
|
||||
@@ -83,10 +76,12 @@ describe("Model.Info", () => {
|
||||
})
|
||||
|
||||
describe("Model.Capabilities", () => {
|
||||
test("decodes the optional transport preference", () => {
|
||||
test("decodes the optional transport setting", () => {
|
||||
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
|
||||
expect(Schema.encodeSync(Model.Info)({ ...model, transport: undefined })).not.toHaveProperty("transport")
|
||||
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, transport: "websocket" }).transport).toBe("websocket")
|
||||
expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, transport: "sse" })).toThrow()
|
||||
expect(Schema.encodeSync(Model.Info)({ ...model, settings: { transport: undefined } }).settings).toEqual({})
|
||||
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "websocket" } }).settings).toEqual({
|
||||
transport: "websocket",
|
||||
})
|
||||
expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "sse" } })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"name": "@opencode/sdk",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user