Compare commits

..
12 Commits
94 changed files with 3201 additions and 1071 deletions
+3 -5
View File
@@ -432,6 +432,7 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"app-builder-lib": "26.15.7",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "42.10.1",
@@ -1036,12 +1037,9 @@
},
},
"trustedDependencies": [
"esbuild",
"tree-sitter-powershell",
"protobufjs",
"electron",
"web-tree-sitter",
"tree-sitter-bash",
"esbuild",
"protobufjs",
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-bWKV3fV8Yc+3ILaNRChIYajsRcvEG2OkydsD2KMUXCM=",
"aarch64-linux": "sha256-zIavB09LW+BU3GOq0PO7Lqfc3MVchkiOUf3trnAjhRM=",
"aarch64-darwin": "sha256-Jlvz7QsXsb8GS9Ty4TmUIfGOawiueZWWEYmyEPms3dc=",
"x86_64-darwin": "sha256-TzTDtcDtj1c3+eP2WZAHOfkszxIS06J6QUwlnscLaUM="
"x86_64-linux": "sha256-T1JUv8gUrXavDI1HdVGqNRal4y2Bh5QavpR4DV+bJdA=",
"aarch64-linux": "sha256-wlbWdEUVWAafgxaZis0Q+SAas8k6hu3s0C39CnMZ2gw=",
"aarch64-darwin": "sha256-74rRue8KQWKzgMMb540beqMzp1DUShlPte6qklYCVTs=",
"x86_64-darwin": "sha256-ihr3DoLd/4Lw6WbyXzSQXMP4hrVGk/UiRkO2aoQn8vg="
}
}
-4
View File
@@ -146,10 +146,6 @@
"esbuild",
"node-pty",
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron"
],
"overrides": {
@@ -415,10 +415,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
// System prompts share the cache-point convention: emit the text block, then
// optionally a positional `cachePoint` marker.
const lowerSystem = (
breakpoints: BedrockCache.Breakpoints,
system: ReadonlyArray<LLMRequest["system"][number]>,
) => {
const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArray<LLMRequest["system"][number]>) => {
const content = system
.filter((part) => part.text.length > 0)
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
@@ -431,7 +428,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
const generation = request.generation
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints()
const breakpoints = BedrockCache.breakpoints(request.model.id)
const toolConfig = (() => {
if (flattened.tools.length === 0) return undefined
return {
+8
View File
@@ -639,6 +639,14 @@ const step = (state: ParserState, event: GeminiEvent) => {
: state.usage,
}
const candidate = event.candidates?.[0]
if (candidate?.finishReason && mapFinishReason(candidate.finishReason, state.hasToolCalls) === "error")
return Effect.fail(
ProviderShared.eventError(
state.route,
`Gemini stopped with ${candidate.finishReason}`,
ProviderShared.encodeJson(event),
),
)
if (!candidate?.content)
return Effect.succeed([
{ ...nextState, finishReason: candidate?.finishReason ?? nextState.finishReason },
+12
View File
@@ -9,6 +9,8 @@ import {
AIError,
InvalidProviderOutputError,
LLMEvent,
ProviderInternalError,
UnknownProviderError,
Usage,
type FinishReasonDetails,
type LLMRequest,
@@ -700,6 +702,16 @@ const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event:
normalized: mapFinishReason(choice.finish_reason),
raw: choice.finish_reason,
}
if (finishReason.normalized === "error") {
const details = {
message: `Mistral Chat stopped with ${finishReason.raw}`,
body: ProviderShared.encodeJson(event),
}
return yield* new AIError({
reason:
finishReason.raw === "network_error" ? new ProviderInternalError(details) : new UnknownProviderError(details),
})
}
const incomplete = finishReason.normalized === "length" || finishReason.normalized === "content-filter"
if (!incomplete && Object.keys(withTools.pendingTools).length > 0)
return yield* ProviderShared.eventError(
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import type { CacheHint } from "../../schema/index.js"
import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache.js"
import { newBreakpoints, ttlBucket } from "./cache.js"
// Bedrock cache markers are positional: emit a `cachePoint` block immediately
// after the content the caller wants treated as a cacheable prefix. Bedrock
@@ -13,24 +13,46 @@ export const CachePointBlock = Schema.Struct({
})
export type CachePointBlock = Schema.Schema.Type<typeof CachePointBlock>
// Callers pass a shared counter through every `block()` call site so the
// four-breakpoint budget is respected across `system`, `messages`, and `tools`.
const LEGACY_CLAUDE = ["anthropic.claude-instant", "anthropic.claude-v1", "anthropic.claude-v2", "anthropic.claude-3-"]
// These legacy Claude releases support explicit caching, but only for five minutes.
const CLAUDE_5M = [
"anthropic.claude-3-5-sonnet-20241022-v2:0",
"anthropic.claude-3-5-haiku-20241022-v1:0",
"anthropic.claude-3-7-sonnet-20250219-v1:0",
"anthropic.claude-sonnet-4-20250514-v1:0",
"anthropic.claude-opus-4-20250514-v1:0",
"anthropic.claude-opus-4-1-20250805-v1:0",
]
// Callers share the four-breakpoint budget across system, messages, and tools.
export const BEDROCK_BREAKPOINT_CAP = 4
export type { Breakpoints } from "./cache.js"
export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP)
export const breakpoints = (modelID: string) => {
// Substring matching also handles regional prefixes and model-bearing ARNs.
const short = CLAUDE_5M.some((id) => modelID.includes(id))
return {
...newBreakpoints(BEDROCK_BREAKPOINT_CAP),
// Assume modern Claude releases retain caching support; older generations need an explicit exception.
// Other model families use implicit caching where available.
supported: modelID.includes("anthropic.claude-") && (short || !LEGACY_CLAUDE.some((id) => modelID.includes(id))),
ttl1h: !short,
}
}
export type Breakpoints = ReturnType<typeof breakpoints>
const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } }
const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } }
export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => {
if (!breakpoints.supported) return undefined
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
if (breakpoints.remaining <= 0) {
breakpoints.dropped += 1
return undefined
}
breakpoints.remaining -= 1
return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
return breakpoints.ttl1h && ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
}
export * as BedrockCache from "./bedrock-cache.js"
@@ -0,0 +1,119 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, Message, ToolCallPart } from "../../src/index.js"
import { AmazonBedrock } from "../../src/providers.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
const bedrock = AmazonBedrock.configure({ apiKey: "fixture" })
describe("Bedrock Converse cache policy", () => {
for (const id of [
"deepseek.r1-v1:0",
"meta.llama3-3-70b-instruct-v1:0",
"mistral.mistral-large-2402-v1:0",
"qwen.qwen3-coder-480b-a35b-v1:0",
"openai.gpt-oss-120b-1:0",
"cohere.command-r-v1:0",
"anthropic.claude-instant-v1",
"anthropic.claude-v1",
"anthropic.claude-v2",
"anthropic.claude-v2:1",
"anthropic.claude-3-haiku-20240307-v1:0",
"anthropic.claude-3-sonnet-20240229-v1:0",
"anthropic.claude-3-opus-20240229-v1:0",
"anthropic.claude-3-5-sonnet-20240620-v1:0",
"amazon.nova-lite-v1:0",
"global.amazon.nova-2-lite-v1:0",
"custom-model",
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123",
]) {
for (const policy of [undefined, "auto", "none", { tools: true, system: true, messages: { tail: 3 } }] as const) {
it.effect(`omits checkpoints for ${id} (${JSON.stringify(policy)})`, () =>
Effect.gen(function* () {
// Exercise both automatic placement and manual hints at every lowering site.
const cache = policy === "none" ? new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) : undefined
const prepared = yield* compileRequest(
LLM.request({
model: bedrock.model(id),
cache: policy,
system: [{ type: "text", text: "System prefix", cache }],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" }, cache }],
messages: [
Message.user([{ type: "text", text: "Question", cache }]),
Message.system([{ type: "text", text: "Update", cache }]),
Message.assistant([
{ type: "text", text: "Answer", cache },
{ type: "reasoning", text: "Unsigned reasoning", cache },
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
]),
Message.tool({ id: "call_1", name: "lookup", result: "Result", cache }),
],
}),
)
expect(JSON.stringify(prepared.body)).not.toContain("cachePoint")
expect(prepared.body).toMatchObject({
modelId: id,
system: [{ text: "System prefix" }],
toolConfig: { tools: [{ toolSpec: { name: "lookup" } }] },
messages: [
{ role: "user", content: [{ text: "Question" }, { text: "<system-update>\nUpdate\n</system-update>" }] },
{
role: "assistant",
content: [{ text: "Answer" }, { text: "Unsigned reasoning" }, { toolUse: { name: "lookup" } }],
},
{ role: "user", content: [{ toolResult: { content: [{ json: "Result" }] } }] },
],
})
}),
)
}
}
for (const [id, ttl] of [
["anthropic.claude-3-5-sonnet-20241022-v2:0", undefined],
["us.anthropic.claude-3-5-haiku-20241022-v1:0", undefined],
["eu.anthropic.claude-3-7-sonnet-20250219-v1:0", undefined],
["apac.anthropic.claude-sonnet-4-20250514-v1:0", undefined],
["anthropic.claude-opus-4-20250514-v1:0", undefined],
["anthropic.claude-opus-4-1-20250805-v1:0", undefined],
["anthropic.claude-sonnet-4-5-20250929-v1:0", "1h"],
["global.anthropic.claude-sonnet-99", "1h"],
["anthropic.claude-new-family-99", "1h"],
["arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6", "1h"],
["arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.anthropic.claude-sonnet-4-6", "1h"],
] as const) {
for (const ttlSeconds of [undefined, 3600]) {
it.effect(`preserves Claude checkpoints for ${id} (TTL: ${ttlSeconds ?? "default"})`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: bedrock.model(id),
system: [
{ type: "text", text: "Agent" },
{ type: "text", text: "Project" },
],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
prompt: "Question",
cache:
ttlSeconds === undefined ? undefined : { tools: true, system: true, messages: { tail: 1 }, ttlSeconds },
}),
)
const marker = {
cachePoint: ttlSeconds === undefined || ttl === undefined ? { type: "default" } : { type: "default", ttl },
}
expect(prepared.body).toMatchObject({
modelId: id,
toolConfig: { tools: [{ toolSpec: { name: "lookup" } }, marker] },
system: [{ text: "Agent" }, marker, { text: "Project" }, marker],
messages: [{ role: "user", content: [{ text: "Question" }, marker] }],
})
if (ttlSeconds === undefined || ttl === undefined)
expect(JSON.stringify(prepared.body)).not.toContain('"ttl"')
}),
)
}
}
})
@@ -136,7 +136,7 @@ const captureHeaders = (target: LanguageModel) =>
const model = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
apiKey: "test-bearer",
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
}).model("anthropic.claude-sonnet-4-5-20250929-v1:0")
const baseRequest = LLM.request({
id: "req_1",
@@ -155,7 +155,7 @@ describe("Bedrock Converse route", () => {
const prepared = yield* compileRequest(baseRequest)
expect(prepared.body).toEqual({
modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
modelId: "anthropic.claude-sonnet-4-5-20250929-v1:0",
system: [{ text: "You are concise." }],
messages: [{ role: "user", content: [{ text: "Say hello." }] }],
inferenceConfig: { maxTokens: 64, temperature: 0 },
+21 -6
View File
@@ -1643,19 +1643,14 @@ describe("Gemini route", () => {
}),
)
it.effect("maps current blocking and invalid-output finish reasons", () =>
it.effect("preserves blocking finishes and rejects invalid-output finish reasons", () =>
Effect.gen(function* () {
const reasons = [
["MODEL_ARMOR", "content-filter"],
["IMAGE_PROHIBITED_CONTENT", "content-filter"],
["IMAGE_RECITATION", "content-filter"],
["LANGUAGE", "content-filter"],
["UNEXPECTED_TOOL_CALL", "error"],
["NO_IMAGE", "error"],
["IMAGE_OTHER", "unknown"],
["TOO_MANY_TOOL_CALLS", "error"],
["MISSING_THOUGHT_SIGNATURE", "error"],
["MALFORMED_RESPONSE", "error"],
] as const
for (const [raw, normalized] of reasons) {
@@ -1666,6 +1661,26 @@ describe("Gemini route", () => {
)
expect(response.finishReason).toEqual({ normalized, raw })
}
for (const raw of [
"MALFORMED_FUNCTION_CALL",
"UNEXPECTED_TOOL_CALL",
"NO_IMAGE",
"TOO_MANY_TOOL_CALLS",
"MISSING_THOUGHT_SIGNATURE",
"MALFORMED_RESPONSE",
]) {
const event = { candidates: [{ finishReason: raw, finishMessage: "Provider detail" }], responseId: "failure" }
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event))),
Effect.flip,
)
expect(error).toMatchObject({
_tag: "AI.Error",
reason: { _tag: "InvalidProviderOutput", body: JSON.stringify(event), http: { status: 200 } },
message: `Gemini stopped with ${raw}`,
})
}
}),
)
+16 -1
View File
@@ -600,7 +600,6 @@ describe("Mistral Chat", () => {
["stop", "stop"],
["model_length", "length"],
["tool_calls", "tool-calls"],
["error", "error"],
["future_reason", "unknown"],
] as const) {
const response = yield* LLMClient.generate(request).pipe(
@@ -609,6 +608,22 @@ describe("Mistral Chat", () => {
expect(response.finishReason).toEqual({ normalized, raw })
}
for (const [raw, tag] of [
["error", "UnknownProvider"],
["network_error", "ProviderInternal"],
] as const) {
const event = { ...chunk({}, raw), diagnostics: { trace: "failure" } }
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event))),
Effect.flip,
)
expect(error).toMatchObject({
_tag: "AI.Error",
reason: { _tag: tag, body: JSON.stringify(event), http: { status: 200 } },
message: `Mistral Chat stopped with ${raw}`,
})
}
const truncated = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
@@ -1,7 +1,13 @@
import { describe, expect, test } from "bun:test"
import { createRequestQueue, isSlowRequest } from "./request-queue"
import { createRequestQueue, isSetupRequest, isSlowRequest } from "./request-queue"
function setup(input?: { limit?: number; slowLimit?: number; stallMs?: number; headersTimeoutMs?: number }) {
function setup(input?: {
limit?: number
slowLimit?: number
stallMs?: number
headersTimeoutMs?: number
setupHeadersTimeoutMs?: number
}) {
const pending: Array<{ url: string; signal: AbortSignal; resolve: () => void }> = []
const logs: Array<{ message: string; data: Record<string, unknown> }> = []
let clock = 0
@@ -10,12 +16,13 @@ function setup(input?: { limit?: number; slowLimit?: number; stallMs?: number; h
slowLimit: input?.slowLimit,
stallMs: input?.stallMs,
headersTimeoutMs: input?.headersTimeoutMs,
setupHeadersTimeoutMs: input?.setupHeadersTimeoutMs,
now: () => clock,
log: (message, data) => logs.push({ message, data }),
fetch: Object.assign(
(resource: RequestInfo | URL) =>
(resource: RequestInfo | URL, init?: RequestInit) =>
new Promise<Response>((resolve, reject) => {
const request = new Request(resource)
const request = new Request(resource, init)
request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true })
pending.push({ url: request.url, signal: request.signal, resolve: () => resolve(new Response("ok")) })
}),
@@ -43,7 +50,12 @@ describe("createRequestQueue", () => {
test("slow endpoints hold at most their share of slots so small reads go first", async () => {
const input = setup({ limit: 4, slowLimit: 2 })
const paths = ["/api/vcs?location[directory]=%2Fa", "/api/vcs/diff?location[directory]=%2Fa", "/api/worktree", "/api/session/ses_1"]
const paths = [
"/api/vcs?location[directory]=%2Fa",
"/api/vcs/diff?location[directory]=%2Fa",
"/api/worktree",
"/api/session/ses_1",
]
const responses = paths.map((path) => input.queue.fetch(`http://server${path}`))
await input.settle()
const started = () => input.pending.map((item) => new URL(item.url).pathname)
@@ -109,6 +121,26 @@ describe("createRequestQueue", () => {
expect(input.queue.inflight()).toBe(0)
})
test("worktree creation gets the setup deadline while worktree reads keep the normal one", async () => {
const input = setup({ limit: 4, headersTimeoutMs: 10, setupHeadersTimeoutMs: 200 })
const create = input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa", { method: "POST" })
const list = input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa")
const listError = await list.catch((cause: unknown) => cause)
expect((listError as DOMException).name).toBe("TimeoutError")
// Past the normal deadline, the create is still on the wire.
expect(input.pending[0]!.signal.aborted).toBe(false)
input.pending[0]!.resolve()
await expect(create).resolves.toBeInstanceOf(Response)
expect(input.queue.inflight()).toBe(0)
})
test("only worktree creation counts as a setup request", () => {
expect(isSetupRequest("POST", "/api/worktree")).toBe(true)
expect(isSetupRequest("GET", "/api/worktree")).toBe(false)
expect(isSetupRequest("POST", "/api/worktree/refresh")).toBe(false)
expect(isSetupRequest("DELETE", "/api/worktree")).toBe(false)
})
test("caller aborts still reach the underlying request", async () => {
const input = setup({ limit: 1 })
const controller = new AbortController()
@@ -19,16 +19,26 @@ export const requestStallMs = 2_000
// instead of wedging every later API call; the body may still stream for as long as it needs.
export const requestHeadersTimeoutMs = 60_000
// Creating a worktree runs the project's setup script (dependency installs, fetches) before the
// server answers, so it needs a budget measured in minutes rather than seconds. Cutting it off
// kills the script midway and leaves a registered but half-initialised worktree behind.
export const setupRequestHeadersTimeoutMs = 10 * 60_000
export function isSlowRequest(pathname: string) {
return slowRequestPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))
}
export function isSetupRequest(method: string, pathname: string) {
return method === "POST" && pathname === "/api/worktree"
}
export function createRequestQueue(input: {
fetch: typeof globalThis.fetch
limit?: number
slowLimit?: number
stallMs?: number
headersTimeoutMs?: number
setupHeadersTimeoutMs?: number
log?: (message: string, data: Record<string, unknown>) => void
now?: () => number
}) {
@@ -36,6 +46,7 @@ export function createRequestQueue(input: {
const slowLimit = input.slowLimit ?? requestQueueSlowLimit
const stallMs = input.stallMs ?? requestStallMs
const headersTimeoutMs = input.headersTimeoutMs ?? requestHeadersTimeoutMs
const setupHeadersTimeoutMs = input.setupHeadersTimeoutMs ?? setupRequestHeadersTimeoutMs
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
const base = input.fetch
const now = input.now ?? Date.now
@@ -101,7 +112,7 @@ export function createRequestQueue(input: {
request.signal.addEventListener("abort", () => controller.abort(request.signal.reason), { once: true })
const timer = setTimeout(
() => controller.abort(new DOMException("Timed out waiting for the server to respond", "TimeoutError")),
headersTimeoutMs,
isSetupRequest(request.method, pathname) ? setupHeadersTimeoutMs : headersTimeoutMs,
)
return base(new Request(request, { signal: controller.signal })).finally(() => {
clearTimeout(timer)
+1
View File
@@ -1429,6 +1429,7 @@ export type ProjectListOperation<E = never> = () => Effect.Effect<ProjectListOut
export type ProjectUpdateInput = {
readonly projectID: Project.ID
readonly canonical?: AbsolutePath | undefined
readonly name?: string | undefined
readonly icon?: Project.Icon | undefined
readonly commands?: Project.Commands | undefined
@@ -1018,7 +1018,7 @@ const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: Proj
preserveEffect<ProjectUpdateOutput>()(
raw["project.update"]({
params: { projectID: input["projectID"] },
payload: { name: input["name"], icon: input["icon"], commands: input["commands"] },
payload: { canonical: input["canonical"], name: input["name"], icon: input["icon"], commands: input["commands"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1377,7 +1377,12 @@ export function make(options: ClientOptions) {
{
method: "PATCH",
path: `/api/project/${encodeURIComponent(input.projectID)}`,
body: { name: input["name"], icon: input["icon"], commands: input["commands"] },
body: {
canonical: input["canonical"],
name: input["name"],
icon: input["icon"],
commands: input["commands"],
},
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
@@ -4672,17 +4672,26 @@ export type ProjectListOutput = Array<Project>
export type ProjectUpdateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly canonical?: {
readonly canonical?: string
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["canonical"]
readonly name?: {
readonly canonical?: string
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["name"]
readonly icon?: {
readonly canonical?: string
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["icon"]
readonly commands?: {
readonly canonical?: string
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
+2 -1
View File
@@ -52,7 +52,7 @@ export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fie
}),
)
return yield* Effect.gen(function* () {
const response = yield* HttpClient.filterStatusOk(http).execute(request)
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http)).execute(request)
const body = yield* collectBoundedResponseBody(
response,
MAX_RESPONSE_BYTES,
@@ -60,6 +60,7 @@ export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fie
)
return yield* parseResponse(body.toString("utf8"), schema.output)
}).pipe(
Effect.scoped,
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
+2 -1
View File
@@ -63,10 +63,11 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
max_results: 8,
}),
)
const response = yield* HttpClient.filterStatusOk(http)
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http))
.execute(request)
.pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(SearchResponse)),
Effect.scoped,
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error("Tavily web search request timed out")),
+1
View File
@@ -212,6 +212,7 @@ const layer = Layer.effect(
const row = yield* db
.update(ProjectTable)
.set({
worktree: input.canonical,
name: input.name === undefined ? undefined : input.name || null,
icon_url_override: input.icon?.override === undefined ? undefined : input.icon.override || null,
icon_color: input.icon?.color === undefined ? undefined : input.icon.color || null,
+21 -15
View File
@@ -92,7 +92,7 @@ export type Editor = {
export type AutoInput = {
readonly context: SessionContext.Loaded
readonly prepare: SessionModelRequest.Interface["compaction"]
readonly prepare: SessionModelRequest.Interface["prepare"]
}
type RequiredInput = {
@@ -113,7 +113,7 @@ export type ManualInput = {
SessionContext.Loaded & { readonly instructionUpdate: string },
SessionRunnerModel.Error | AgentNotFoundError | Instructions.InitializationBlocked
>
readonly prepare: SessionModelRequest.Interface["compaction"]
readonly prepare: SessionModelRequest.Interface["prepare"]
}
type ExecuteInput = AutoInput & {
@@ -396,20 +396,26 @@ export const layer = Layer.effect(
messages: history.messages,
})
const prepared = yield* input.prepare({
session: context.session,
agent: context.agent.id,
model: context.model,
tools: context.tools,
system: transcript.system,
messages: [
...transcript.messages,
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
Message.user(
buildPrompt(
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
kind: "compaction",
scope: {
session: context.session,
agentID: Agent.ID.make("compaction"),
contextAgentID: context.agent.id,
model: context.model,
tools: context.tools,
},
transcript: {
system: transcript.system,
messages: [
...transcript.messages,
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
Message.user(
buildPrompt(
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
),
),
),
],
],
},
})
const retry = yield* SessionRunnerRetry.policy(context.session.id)
// Both requests share the retry allowance; rejected output never enters the reminder request.
+3 -4
View File
@@ -64,8 +64,7 @@ export interface Interface {
}
| undefined
>
/** Outbound request preparation, one entry per Session flow. */
readonly request: SessionModelRequest.Interface
readonly prepare: SessionModelRequest.Interface["prepare"]
}
/** Location-scoped model-context loader for durable Session Steps. */
@@ -84,7 +83,7 @@ const layer = Layer.effect(
const mcpInstructions = yield* McpInstructions.Service
const mcpTools = yield* McpTool.Service
const models = yield* SessionRunnerModel.Service
const request = yield* SessionModelRequest.Service
const modelRequests = yield* SessionModelRequest.Service
const referenceInstructions = yield* ReferenceInstructions.Service
const skillInstructions = yield* SkillInstructions.Service
const store = yield* SessionStore.Service
@@ -168,7 +167,7 @@ const layer = Layer.effect(
}
})
return Service.of({ select, load, resolveModel, selectTitle, request })
return Service.of({ select, load, resolveModel, selectTitle, prepare: modelRequests.prepare })
}),
)
+11 -11
View File
@@ -37,17 +37,17 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
initial: history.initial,
messages: history.messages,
})
const prepared = yield* context.request.generate({
session: selection.session,
agent: selection.agent.id,
model,
tools: selection.tools,
system: transcript.system,
messages: [
...transcript.messages,
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
Message.user(input.prompt),
],
const prepared = yield* context.prepare({
kind: "generate",
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
transcript: {
system: transcript.system,
messages: [
...transcript.messages,
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
Message.user(input.prompt),
],
},
})
yield* Effect.logInfo("sending session generation request", {
sessionID: selection.session.id,
+209 -168
View File
@@ -1,18 +1,9 @@
export * as SessionModelRequest from "./model-request.js"
import {
GenerationOptions,
type GenerationOptionsFields,
HttpOptions,
LanguageModel,
LLM,
LLMRequest,
Message,
SystemPart,
} from "@opencode-ai/ai"
import { HttpOptions, LanguageModel, LLM, LLMRequest, Message, SystemPart } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { SessionRequest, SessionRequestKind } from "@opencode-ai/plugin/effect/session"
import { Agent } from "@opencode-ai/schema/agent"
import type { SessionRequestKind } from "@opencode-ai/plugin/effect/session"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Content } from "@opencode-ai/schema/tool"
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
@@ -34,30 +25,63 @@ const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
const IMAGE_REMOVED =
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
const GENERATION_KEYS = new Set(Object.keys(GenerationOptions.fields))
/** Tool errors, plus the user declining a permission or dismissing a question. */
const responsesWebSocketFlag = (providerID: string) =>
`OPENCODE_EXPERIMENTAL_${providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET`
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
// User declines dive under the leaves' blanket `mapError` as defects (the deliberate
// tunnel entered in Permission.assert and the question tool), so a user's "no" can
// never become model-facing tool output. They resurface as typed failures exactly once,
// here at the seam the runner executes through.
const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
const decline = cause.reasons.flatMap((reason) =>
Cause.isDieReason(reason) &&
(reason.defect instanceof Permission.DeclinedError || reason.defect instanceof QuestionTool.CancelledError)
? [reason.defect]
: [],
)[0]
return decline ? Result.succeed(decline) : Result.fail(cause)
}
export interface Prepared {
readonly request: LLMRequest
readonly options: StreamOptions
readonly retry: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void>
/** Runs a tool call against the tools this request advertised. */
/**
* One request-scoped execution operation. Unknown and hook-removed calls
* fail individually through the same seam.
*/
readonly executeTool: (
input: Parameters<Tool.Snapshot["execute"]>[0],
) => Effect.Effect<Tool.NormalizedResult, ExecuteError>
}
export interface Input {
readonly session: SessionSchema.Info
readonly agent: Agent.ID
readonly model: SessionRunnerModel.Resolved
readonly tools?: Tool.Snapshot
readonly system: Array<SystemPart>
readonly messages: Array<Message>
interface PrepareInput {
/** Which Session flow issues this request; request hooks receive it alongside the Session identity. */
readonly kind: SessionRequestKind
readonly scope: {
readonly session: SessionSchema.Info
readonly agentID: Agent.ID
/** Agent whose context an auxiliary request reuses, without changing its request-hook identity. */
readonly contextAgentID?: Agent.ID
readonly model: SessionRunnerModel.Resolved
/** Omitted for requests that carry no tool definitions, such as titles. */
readonly tools?: Tool.Snapshot
}
readonly transcript: {
readonly system: Array<SystemPart>
readonly messages: Array<Message>
}
readonly toolChoice?: LLM.RequestInput["toolChoice"]
/** Only the durable runner may use a stateful WebSocket. */
/**
* Session context hooks shape the agent conversation. Standalone requests
* such as titles opt out; compaction uses the selected Session context.
*/
readonly contextHooks?: false
/** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */
readonly webSocket?: "session"
}
@@ -171,18 +195,90 @@ export const boundImages = (messages: LLMRequest["messages"]) => {
)
}
type Definitions = PluginHooks.Domains["session"]["context"]["tools"]
/** Builds the model request for each session flow. Each entry runs its own plugin hook. */
export interface Interface {
readonly primary: (input: Input) => Effect.Effect<Prepared>
/** The context hook sees the session agent; request hooks see the `compaction` agent. */
readonly compaction: (input: Input) => Effect.Effect<Prepared>
readonly generate: (input: Input) => Effect.Effect<Prepared>
/** Runs `session.title` instead of `session.context`; no agent or tools. */
readonly title: (input: Input) => Effect.Effect<Prepared>
/** The identity a plugin hook sees for one outbound request. */
interface HookScope {
readonly sessionID: SessionSchema.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
}
const sessionHeaders = (session: Pick<SessionSchema.Info, "id" | "parentID" | "projectID">, app: App.Info) => ({
"x-session-affinity": session.id,
"X-Session-Id": session.id,
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
"User-Agent": App.useragent(app),
"x-opencode-project": session.projectID,
"x-opencode-session": session.id,
"x-opencode-client": app.name,
})
const promptCacheKey = (sessionID: SessionSchema.ID) =>
/^ses_[0-9a-f]{64}$/.test(sessionID) ? sessionID.slice(4) : sessionID
// Lets session.model.request hooks rewrite the base URL and headers before dispatch.
const applyModelHooks = (hooks: PluginHooks.Interface, scope: HookScope, request: LLMRequest) =>
Effect.gen(function* () {
const currentBaseURL = request.model.route.endpoint.baseURL
const event = yield* hooks.trigger("session", "model.request", {
...scope,
baseURL: typeof currentBaseURL === "string" ? currentBaseURL : undefined,
headers: { ...request.http?.headers },
})
const route =
event.baseURL !== undefined && event.baseURL !== currentBaseURL
? request.model.route.with({ endpoint: { baseURL: event.baseURL } })
: request.model.route
return LLMRequest.update(request, {
model: route === request.model.route ? request.model : LanguageModel.update(request.model, { route }),
http: new HttpOptions({
body: request.http?.body,
headers: Object.keys(event.headers).length === 0 ? undefined : event.headers,
query: request.http?.query,
}),
})
})
// Exposes each outbound HTTP exchange to session.http.request/response hooks
// through web-standard Request/Response values.
const httpMiddleware =
(hooks: PluginHooks.Interface, scope: HookScope): NonNullable<StreamOptions["http"]> =>
(request, handler) =>
Effect.gen(function* () {
const before = yield* hooks.trigger("session", "http.request", {
...scope,
request: yield* HttpClientRequest.toWeb(request),
})
let sent = HttpClientRequest.fromWeb(before.request)
if (before.request.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
before.request.headers.get("content-type") ?? undefined,
)
const response = yield* handler(sent)
const after = yield* hooks.trigger("session", "http.response", {
...scope,
request: before.request,
response: new Response(
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
{ status: response.status, headers: response.headers },
),
})
return HttpClientResponse.fromWeb(sent, after.response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
/**
* Builds an outbound model request and captures the tool-call capability that
* must remain paired with it. It does not execute the request or mutate
* Session state.
*/
export interface Interface {
/** Builds one outbound model request and its matching tool-call capability. */
readonly prepare: (input: PrepareInput) => Effect.Effect<Prepared>
}
/** Location-scoped outbound model-request preparation. */
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionModelRequest") {}
export const layer = Layer.effect(
@@ -191,157 +287,102 @@ export const layer = Layer.effect(
const hooks = yield* PluginHooks.Service
const transport = yield* SessionModelTransport.Service
const app = yield* App.Metadata
// `shape` runs the flow's plugin hook. Hooks mutate `tools` in place, so it is passed separately.
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (
kind: SessionRequestKind,
input: Input,
shape: (draft: SessionRequest, tools: Definitions) => Effect.Effect<SessionRequest & { tools?: Definitions }>,
) {
const session = input.session
const model = input.model
const scope = { sessionID: session.id, agent: input.agent, model: model.ref, kind }
const tools = input.tools ?? {
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
const session = input.scope.session
const resolved = input.scope.model
const model = resolved.model
const tools = input.scope.tools ?? {
definitions: [],
execute: () => new Tool.Error({ message: "Tools are not available for this request" }),
}
// Remember which tool each definition object came from. Hooks rename a tool by moving
// its definition to a new key, so after the hook we find the tool by object identity.
const registry = new Map(tools.definitions.map((tool) => [tool.name, tool]))
// The definition objects we hand to hooks, mapped back to their tools. Hooks rename a
// tool by moving its definition to a new key; recognizing the object recovers the tool.
const given = new Map(
tools.definitions.map((t) => [{ description: t.description, input: { ...t.inputSchema } }, t] as const),
tools.definitions.map(
(tool) => [{ description: tool.description, input: { ...tool.inputSchema } }, tool] as const,
),
)
const shaped = yield* shape(
{ sessionID: session.id, model: model.ref, system: input.system, messages: input.messages, options: {} },
Object.fromEntries(Array.from(given, ([d, t]) => [t.name, d])),
)
// Match by identity first, then by key. Entries matching neither were invented by a
// hook and are dropped. `t.name` stays the real name so execution can map renames back.
const byName = new Map(tools.definitions.map((t) => [t.name, t]))
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
const definitions = Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition]))
const context: PluginHooks.Domains["session"]["context"] = {
sessionID: session.id,
agent: input.scope.contextAgentID ?? input.scope.agentID,
model: resolved.ref,
system: input.transcript.system,
messages: input.transcript.messages,
tools: definitions,
generation: {},
providerOptions: {},
}
if (input.contextHooks !== false) yield* hooks.trigger("session", "context", context)
// Match each surviving entry back to its tool, by recognizing a moved definition or
// by key. Identity wins so a definition moved onto another tool's name still executes
// the tool it describes. Entries matching neither were invented by a hook and dropped.
// `tool.name` stays canonical so execution can translate renamed calls back.
const hooked = new Map(
Object.entries(shaped.tools ?? {}).flatMap(([name, d]) => {
const t = given.get(d) ?? byName.get(name)
return t ? [[name, { ...t, description: d.description, inputSchema: d.input }] as const] : []
Object.entries(context.tools).flatMap(([name, definition]) => {
const tool = given.get(definition) ?? registry.get(name)
if (!tool) return []
return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const]
}),
)
const entries = Object.entries(shaped.options)
const generation = Object.fromEntries(entries.filter(([k]) => GENERATION_KEYS.has(k))) as GenerationOptionsFields
const providerOptions = Object.fromEntries(entries.filter(([k]) => !GENERATION_KEYS.has(k)))
const root = session.fork?.sessionID ?? session.id
const base = LLM.request({
model: model.model,
http: {
headers: {
"x-session-affinity": session.id,
"X-Session-Id": session.id,
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
"User-Agent": App.useragent(app),
"x-opencode-project": session.projectID,
"x-opencode-session": session.id,
"x-opencode-client": app.name,
const request = yield* applyModelHooks(
hooks,
{ sessionID: session.id, agent: input.scope.agentID, model: resolved.ref, kind: input.kind },
LLM.request({
model,
http: {
headers: sessionHeaders(session, app),
},
},
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
promptCacheKey: /^ses_[0-9a-f]{64}$/.test(root) ? root.slice(4) : root,
system: shaped.system,
messages: boundImages(unsupportedParts(shaped.messages, model.capabilities)),
tools: Array.from(hooked, ([name, t]) => ({ ...t, name })),
toolChoice: input.toolChoice,
generation: Object.keys(generation).length === 0 ? undefined : generation,
providerOptions: Object.keys(providerOptions).length === 0 ? undefined : providerOptions,
})
const baseURL = base.model.route.endpoint.baseURL
const modelHook = yield* hooks.trigger("session", "model.request", {
...scope,
baseURL: typeof baseURL === "string" ? baseURL : undefined,
headers: { ...base.http?.headers },
})
const route =
modelHook.baseURL !== undefined && modelHook.baseURL !== baseURL
? base.model.route.with({ endpoint: { baseURL: modelHook.baseURL } })
: base.model.route
const request = LLMRequest.update(base, {
model: route === base.model.route ? base.model : LanguageModel.update(base.model, { route }),
http: new HttpOptions({
body: base.http?.body,
headers: Object.keys(modelHook.headers).length === 0 ? undefined : modelHook.headers,
query: base.http?.query,
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
promptCacheKey: promptCacheKey(session.fork?.sessionID ?? session.id),
system: context.system,
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
toolChoice: input.toolChoice,
generation: Object.keys(context.generation).length === 0 ? undefined : context.generation,
providerOptions: Object.keys(context.providerOptions).length === 0 ? undefined : context.providerOptions,
}),
})
// Hooks see each HTTP exchange as web Request/Response values. WebSockets bypass this,
// so registering an HTTP hook forces HTTP.
)
const hasHttpHooks =
(yield* hooks.has("session", "http.request", model.ref.providerID)) ||
(yield* hooks.has("session", "http.response", model.ref.providerID))
const http: StreamOptions["http"] = hasHttpHooks
? (req, handler) =>
Effect.gen(function* () {
const before = yield* hooks.trigger("session", "http.request", {
...scope,
request: yield* HttpClientRequest.toWeb(req),
})
let sent = HttpClientRequest.fromWeb(before.request)
if (before.request.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
before.request.headers.get("content-type") ?? undefined,
)
const res = yield* handler(sent)
const after = yield* hooks.trigger("session", "http.response", {
...scope,
request: before.request,
response: new Response(
[204, 205, 304].includes(res.status) ? null : yield* Stream.toReadableStreamEffect(res.stream),
{ status: res.status, headers: res.headers },
),
})
return HttpClientResponse.fromWeb(sent, after.response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
: undefined
(yield* hooks.has("session", "http.request", resolved.ref.providerID)) ||
(yield* hooks.has("session", "http.response", resolved.ref.providerID))
const webSocket =
input.webSocket === "session" &&
!hasHttpHooks &&
model.capabilities.responsesWebsockets === true &&
(yield* Config.boolean(
`OPENCODE_EXPERIMENTAL_${model.ref.providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET`,
).pipe(Config.withDefault(false), Effect.orDie))
resolved.capabilities.responsesWebsockets === true
? yield* Config.boolean(responsesWebSocketFlag(resolved.ref.providerID)).pipe(
Config.withDefault(false),
Effect.orDie,
)
: false
const http = hasHttpHooks
? httpMiddleware(hooks, {
sessionID: session.id,
agent: input.scope.agentID,
model: resolved.ref,
kind: input.kind,
})
: undefined
const options: StreamOptions = {
...(http ? { http } : {}),
...(input.webSocket === "session" && webSocket && !hasHttpHooks
? { webSocket: transport.bind(session.id) }
: {}),
}
const executeTool: Prepared["executeTool"] = (input) =>
tools
.execute({ ...input, definitions: hooked })
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
const retry: Prepared["retry"] = (event) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid)
return {
request,
options: { ...(http ? { http } : {}), ...(webSocket ? { webSocket: transport.bind(session.id) } : {}) },
retry: (event) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid),
// Permission.assert and the question tool throw declines as defects so tools cannot
// catch them and turn a "no" into model-visible output. Recover them here as failures.
executeTool: (call) =>
tools.execute({ ...call, definitions: hooked }).pipe(
Effect.catchCauseFilter(
(cause) => {
const decline = cause.reasons.flatMap((r) =>
Cause.isDieReason(r) &&
(r.defect instanceof Permission.DeclinedError || r.defect instanceof QuestionTool.CancelledError)
? [r.defect]
: [],
)[0]
return decline ? Result.succeed(decline) : Result.fail(cause)
},
(decline) => Effect.fail(decline),
),
),
} satisfies Prepared
options,
retry,
executeTool,
}
})
const context = (agent: Agent.ID) => (draft: SessionRequest, tools: Definitions) =>
hooks.trigger("session", "context", { ...draft, agent, tools })
return Service.of({
primary: (input) => prepare("primary", input, context(input.agent)),
generate: (input) => prepare("generate", input, context(input.agent)),
compaction: (input) =>
prepare("compaction", { ...input, agent: Agent.ID.make("compaction") }, context(input.agent)),
title: (input) => prepare("title", input, (draft) => hooks.trigger("session", "title", draft)),
})
return Service.of({ prepare })
}),
)
+11 -11
View File
@@ -124,7 +124,7 @@ const layer = Layer.effect(
instructionUpdate: history.instructionUpdate,
}
}),
prepare: context.request.compaction,
prepare: context.prepare,
messages: yield* store.context(sessionID),
inputID: pending.id,
started: true,
@@ -203,7 +203,7 @@ const layer = Layer.effect(
initial = undefined
const compactionInput = {
context: loaded,
prepare: context.request.compaction,
prepare: context.prepare,
}
if (compaction.required({ messages: loaded.messages, resolved: loaded.model, context: loaded })) {
const compacted = yield* compaction.compact(compactionInput)
@@ -219,15 +219,15 @@ const layer = Layer.effect(
initial: loaded.initial,
messages: loaded.messages,
})
const prepared = yield* context.request.primary({
session: loaded.session,
agent: loaded.agent.id,
model: loaded.model,
tools: loaded.tools,
system: transcript.system,
messages: stepLimitReached
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
: transcript.messages,
const prepared = yield* context.prepare({
kind: "primary",
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript: {
system: transcript.system,
messages: stepLimitReached
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
: transcript.messages,
},
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
+8 -6
View File
@@ -63,12 +63,14 @@ export const layer = Layer.effect(
})
: Effect.void,
)
const prepared = yield* context.request.title({
session: input.session,
agent: input.agent.id,
model: input.model,
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
messages: [Message.user(input.text)],
const prepared = yield* context.prepare({
kind: "title",
scope: { session: input.session, agentID: input.agent.id, model: input.model },
transcript: {
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
messages: [Message.user(input.text)],
},
contextHooks: false,
})
yield* llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) => {
+104 -120
View File
@@ -11,6 +11,10 @@ import { WebSearch } from "../../websearch.js"
export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query."
const providerSelectionLock = Semaphore.makeUnsafe(1)
const httpErrors = new Map([
[429, "Web search rate limited (HTTP 429)"],
[401, "Web search authentication failed (HTTP 401)"],
])
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
@@ -50,106 +54,100 @@ export const Plugin = {
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
websearch.default().pipe(
Effect.flatMap((provider) => {
if (!provider) return ctx.websearch.query(input)
return context
.progress({ provider: provider.id })
.pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID: provider.id })))
}),
Effect.catch((error) => {
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
return providerSelectionLock
.withPermit(
Effect.gen(function* () {
if (yield* websearch.default()) return
const providers = (yield* ctx.websearch.providers()).data
const defaultProvider = providers[0]
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
description: "Allow OpenCode to search the web for up-to-date information?",
type: "string",
required: true,
custom: false,
options: [
{
value: "allow",
label: `Allow search via ${providers.map((provider) => provider.name).join(", ")}`,
},
{
value: "choose",
label: "Choose another provider",
},
{ value: "disable", label: "Disable web search" },
],
},
],
})
if (response.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* websearch.select(false)
return yield* new WebSearch.DisabledError()
}
const selection =
response.answer.choice === "choose"
? yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "provider",
description: "Choose a provider for web search.",
type: "string",
required: true,
custom: false,
options: providers.map((provider) => ({
value: provider.id,
label: provider.name,
})),
},
],
})
: undefined
if (selection?.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
const providerID = selection?.answer.provider ?? "random"
if (
typeof providerID !== "string" ||
(providerID !== "random" && !providers.some((provider) => provider.id === providerID))
)
return yield* new WebSearch.ProviderRequiredError()
yield* websearch.select(providerID === "random" ? "random" : WebSearch.ID.make(providerID))
if (providerID !== "random") return WebSearch.ID.make(providerID)
return providers[Math.floor(Math.random() * providers.length)]?.id
}),
)
.pipe(
Effect.timeoutOrElse({
duration: "1 minute",
orElse: () => Effect.fail(new Error("Web search cancelled")),
}),
Effect.flatMap((providerID) => {
if (!providerID) return Effect.suspend(search)
return context
.progress({ provider: providerID })
.pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID })))
}),
)
}),
const search = (providerID?: WebSearch.ID) =>
websearch.query(
{ ...input, providerID },
{
sessionID: context.sessionID,
onProvider: (provider) => context.progress({ provider: provider.id }),
},
)
const result = yield* search()
const result = yield* search().pipe(
Effect.catchTag("WebSearch.ProviderRequired", () => {
return providerSelectionLock
.withPermit(
Effect.gen(function* () {
if (yield* websearch.default()) return
const providers = (yield* ctx.websearch.providers()).data
const defaultProvider = providers[0]
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
description: "Allow OpenCode to search the web for up-to-date information?",
type: "string",
required: true,
custom: false,
options: [
{
value: "allow",
label: `Allow search via ${providers.map((provider) => provider.name).join(", ")}`,
},
{
value: "choose",
label: "Choose another provider",
},
{ value: "disable", label: "Disable web search" },
],
},
],
})
if (response.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* websearch.select(false)
return yield* new WebSearch.DisabledError()
}
const selection =
response.answer.choice === "choose"
? yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "provider",
description: "Choose a provider for web search.",
type: "string",
required: true,
custom: false,
options: providers.map((provider) => ({
value: provider.id,
label: provider.name,
})),
},
],
})
: undefined
if (selection?.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
const providerID = selection?.answer.provider ?? "random"
if (providerID === "random") {
yield* websearch.select("random")
return
}
const provider = providers.find((provider) => provider.id === providerID)
if (!provider) return yield* new WebSearch.ProviderRequiredError()
yield* websearch.select(provider.id)
return provider.id
}),
)
.pipe(
Effect.timeoutOrElse({
duration: "1 minute",
orElse: () => Effect.fail(new Error("Web search cancelled")),
}),
Effect.flatMap(search),
)
}),
)
const output = {
provider: result.data.providerID,
results: result.data.results,
provider: result.providerID,
results: result.results,
}
const content = output.results.length
? output.results
@@ -168,28 +166,14 @@ export const Plugin = {
const fallback = `Unable to search the web for ${input.query}`
if (!Schema.is(WebSearch.RequestError)(error)) return new ToolFailure({ message: fallback, error })
const status = HttpClientError.isHttpClientError(error.cause) ? error.cause.response?.status : undefined
switch (status) {
case 429:
return new ToolFailure({
message: "Web search rate limited (HTTP 429)",
error,
metadata: { provider: error.providerID },
})
case 401:
return new ToolFailure({
message: "Web search authentication failed (HTTP 401)",
error,
metadata: { provider: error.providerID },
})
case undefined:
return new ToolFailure({ message: fallback, error, metadata: { provider: error.providerID } })
default:
return new ToolFailure({
message: `Web search request failed (HTTP ${status})`,
error,
metadata: { provider: error.providerID },
})
}
return new ToolFailure({
message:
status === undefined
? fallback
: (httpErrors.get(status) ?? `Web search request failed (HTTP ${status})`),
error,
metadata: { provider: error.providerID },
})
}),
),
}),
+97 -23
View File
@@ -1,7 +1,10 @@
export * as WebSearch from "./websearch.js"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Context, Effect, Layer, Option, Schema } from "effect"
import type { Session } from "@opencode-ai/schema/session"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { Clock, Context, Effect, Layer, Option, Schema, Stream } from "effect"
import { HttpClientError } from "effect/unstable/http"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus.js"
import { KV } from "./kv.js"
@@ -55,7 +58,13 @@ export interface Interface extends State.Transformable<Editor> {
readonly providers: () => Effect.Effect<readonly Provider[]>
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
readonly select: (selection: Selection) => Effect.Effect<void>
readonly query: (input: Input) => Effect.Effect<Response, Error>
readonly query: (
input: Input,
options?: {
readonly sessionID?: Session.ID
readonly onProvider?: (provider: Provider) => Effect.Effect<void>
},
) => Effect.Effect<Response, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/WebSearch") {}
@@ -79,6 +88,17 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const kv = yield* KV.Service
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
const cooldowns = new Map<ID, { until: number; error: RequestError }>()
const preferred = new Map<Session.ID | undefined, { provider?: ID }>()
yield* Effect.addFinalizer(() => Effect.sync(() => preferred.clear()))
yield* bus.subscribe([SessionEvent.Deleted, SessionEvent.Moved]).pipe(
Stream.runForEach((event) =>
Effect.sync(() => {
preferred.delete(event.data.sessionID)
}),
),
Effect.forkScoped({ startImmediately: true }),
)
const state = State.create<Data, Editor>({
initial: () => ({ providers: new Map() }),
editor: (editor) => ({
@@ -96,26 +116,46 @@ const layer = Layer.effect(
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
}
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
const selection = Effect.fn("WebSearch.selection")(function* () {
const data = state.get()
const stored = data.selection === undefined ? yield* kv.get(ProviderKey) : undefined
if (
data.selection === false ||
data.selection === "random" ||
(data.selection && data.providers.has(data.selection))
)
return data.selection
const stored = yield* kv.get(ProviderKey)
const decoded = Schema.decodeUnknownOption(Selection)(stored)
if (stored !== undefined && Option.isNone(decoded)) yield* kv.remove(ProviderKey)
const selection = data.selection ?? Option.getOrUndefined(decoded)
if (selection === false) return yield* new DisabledError()
if (selection === "random") {
const providers = Array.from(data.providers.values())
return providers[Math.floor(Math.random() * providers.length)]
}
return selection ? data.providers.get(selection) : undefined
return Option.getOrUndefined(decoded)
})
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
const randomProvider = (now: number, affinity: { provider?: ID }, attempted?: Set<ID>) => {
const providers = state.get().providers
if (input.providerID) return yield* requireProvider(providers, input.providerID)
const provider = yield* defaultProvider()
if (!provider) return yield* new ProviderRequiredError()
cooldowns.forEach((cooldown, id) => {
if (cooldown.until <= now || !providers.has(id)) cooldowns.delete(id)
})
const current = affinity.provider === undefined ? undefined : providers.get(affinity.provider)
if (current && !cooldowns.has(current.id) && !attempted?.has(current.id)) return current
const available = Array.from(providers.values()).filter(
(provider) => !cooldowns.has(provider.id) && !attempted?.has(provider.id),
)
const provider = available[Math.floor(Math.random() * available.length)]
if (provider) affinity.provider = provider.id
return provider
}
const defaultProvider = Effect.fn("WebSearch.default")(function* (choice: Selection | undefined) {
if (choice === false) return yield* new DisabledError()
if (choice === "random") {
// Inspection must not select a provider or reopen consent when every provider is cooling down.
const active = preferred.get(undefined)?.provider
return (
(active === undefined ? undefined : state.get().providers.get(active)) ??
state.get().providers.values().next().value
)
}
return choice ? state.get().providers.get(choice) : undefined
})
return Service.of({
@@ -128,24 +168,58 @@ const layer = Layer.effect(
})).toSorted((a, b) => a.name.localeCompare(b.name))
}),
default: Effect.fn("WebSearch.defaultInfo")(function* () {
const provider = yield* defaultProvider()
const provider = yield* defaultProvider(yield* selection())
return provider && { id: provider.id, name: provider.name }
}),
select: Effect.fn("WebSearch.select")(function* (selection) {
yield* kv.set(ProviderKey, selection)
}),
query: Effect.fn("WebSearch.query")(function* (input) {
const provider = yield* resolve(input)
const results = yield* provider.execute({ query: input.query }).pipe(
Effect.flatMap(decodeResults),
Effect.mapError((cause) => new RequestError({ providerID: provider.id, cause })),
)
return new Response({ providerID: provider.id, results })
query: Effect.fn("WebSearch.query")(function* (input, options) {
const choice = input.providerID ? undefined : yield* selection()
let provider = input.providerID
? yield* requireProvider(state.get().providers, input.providerID)
: yield* defaultProvider(choice)
if (!provider) return yield* new ProviderRequiredError()
// Keep the cell for this query so deletion/movement cannot reinsert an in-flight session's entry.
const affinity = preferred.get(options?.sessionID) ?? { provider: undefined }
if (choice === "random") {
preferred.set(options?.sessionID, affinity)
provider = randomProvider(yield* Clock.currentTimeMillis, affinity) ?? provider
}
const attempted = new Set<ID>()
while (true) {
if (options?.onProvider) yield* options.onProvider({ id: provider.id, name: provider.name })
let cooldown = choice === "random" ? cooldowns.get(provider.id) : undefined
if (!cooldown || cooldown.until <= (yield* Clock.currentTimeMillis)) {
attempted.add(provider.id)
const result = yield* provider
.execute({ query: input.query })
.pipe(Effect.flatMap(decodeResults), Effect.result)
if (result._tag === "Success") return new Response({ providerID: provider.id, results: result.success })
const cause = result.failure
const error = new RequestError({ providerID: provider.id, cause })
if (choice !== "random" || !HttpClientError.isHttpClientError(cause) || cause.response?.status !== 429)
return yield* error
const now = yield* Clock.currentTimeMillis
cooldown = { until: now + cooldownMillis(cause.response.headers["retry-after"], now), error }
cooldowns.set(provider.id, cooldown)
}
provider = randomProvider(yield* Clock.currentTimeMillis, affinity, attempted)
if (!provider) return yield* cooldown.error
}
}),
})
}),
)
function cooldownMillis(value: string | undefined, now: number) {
if (!value?.trim()) return 60_000
const seconds = Number(value)
if (Number.isFinite(seconds)) return seconds >= 0 ? seconds * 1000 : 60_000
const date = Date.parse(value)
return Number.isFinite(date) ? Math.max(0, date - now) : 60_000
}
export const node = makeLocationNode({
service: Service,
layer,
+1 -1
View File
@@ -95,7 +95,7 @@ describe("ConfigCompactionPlugin.Plugin", () => {
yield* compaction.compactManual({
session,
resolveContext: () => Effect.succeed({ ...nearInput.context, messages, instructionUpdate: "" }),
prepare: modelRequests.compaction,
prepare: modelRequests.prepare,
messages,
inputID: SessionMessage.ID.make("msg_compaction_manual"),
}),
+21 -2
View File
@@ -1,20 +1,36 @@
export * as TestWebSearch from "./websearch"
import { Context, Deferred, Effect, Layer } from "effect"
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { KV } from "@opencode-ai/core/kv"
import { WebSearch } from "@opencode-ai/core/websearch"
import type { Session } from "@opencode-ai/schema/session"
export interface Interface extends WebSearch.Interface {
readonly queries: readonly WebSearch.Input[]
readonly sessionIDs: readonly (Session.ID | undefined)[]
/** Waits for query arrivals, not provider execution or query completion. */
readonly wait: (count: number) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("test/WebSearch") {}
export function httpError(status = 429, retryAfter?: string, url = "https://search.example.com") {
const request = HttpClientRequest.post(url)
return new HttpClientError.HttpClientError({
reason: new HttpClientError.StatusCodeError({
request,
response: HttpClientResponse.fromWeb(
request,
new Response(null, { status, headers: retryAfter === undefined ? {} : { "Retry-After": retryAfter } }),
),
}),
})
}
// No providers are installed: tests register local executors through transform.
// The normal Bus and KV implementations use the default in-memory database.
export const layer = Layer.effectContext(
@@ -22,6 +38,7 @@ export const layer = Layer.effectContext(
const context = yield* Layer.build(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
const websearch = Context.get(context, WebSearch.Service)
const queries: WebSearch.Input[] = []
const sessionIDs: (Session.ID | undefined)[] = []
let started = yield* Deferred.make<void>()
const wait = (count: number): Effect.Effect<void> =>
Effect.suspend(() =>
@@ -30,13 +47,15 @@ export const layer = Layer.effectContext(
const test = Service.of({
...websearch,
queries,
sessionIDs,
wait,
query: Effect.fnUntraced(function* (input: WebSearch.Input) {
query: Effect.fnUntraced(function* (input, options) {
queries.push({ ...input })
sessionIDs.push(options?.sessionID)
const previous = started
started = yield* Deferred.make<void>()
yield* Deferred.succeed(previous, undefined)
return yield* websearch.query(input)
return yield* websearch.query(input, options)
}),
})
return Context.add(context, WebSearch.Service, test).pipe(Context.add(Service, test))
+2 -1
View File
@@ -37,7 +37,8 @@ const context = (id: string, system = fallback): SessionHooks["context"] => ({
{ description: name, input: { type: "object" } },
]),
),
options: {},
generation: {},
providerOptions: {},
})
describe("OptimizePlugin", () => {
+2 -1
View File
@@ -124,7 +124,8 @@ const request = (agent: Agent.ID, messages: Array<Message>): SessionContext => (
system: [],
messages,
tools: {},
options: {},
generation: {},
providerOptions: {},
})
type ToolErrorEvent = Extract<ToolHooks["execute.after"], { readonly status: "error" }>
@@ -240,20 +240,22 @@ describe("OpenAIPlugin", () => {
})
const program = Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service
return yield* requests.primary({
session: Session.Info.make({
id: sessionID,
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
}),
agent: agentID,
model,
tools: { definitions: [], execute: () => Effect.die("unused tool execution") },
system: [],
messages: [],
return yield* requests.prepare({
kind: "primary",
scope: {
session: Session.Info.make({
id: sessionID,
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
}),
agentID,
model,
tools: { definitions: [], execute: () => Effect.die("unused tool execution") },
},
transcript: { system: [], messages: [] },
webSocket: "session",
})
}).pipe(
@@ -17,24 +17,29 @@ interface WebSearchRequest {
}
export const requests: WebSearchRequest[] = []
export const signals: AbortSignal[] = []
let responseBody = ""
let responseStatus = 200
export function resetWebSearchFixture(body: string) {
export function resetWebSearchFixture(body: string, status = 200) {
requests.length = 0
signals.length = 0
responseBody = body
responseStatus = status
}
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
HttpClient.make((request, _url, signal) =>
Effect.sync(() => {
signals.push(signal)
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
requests.push({
url: request.url,
headers: request.headers,
body: JSON.parse(new TextDecoder().decode(request.body.body)),
})
return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: 200 }))
return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: responseStatus }))
}),
),
)
+20 -1
View File
@@ -7,7 +7,7 @@ import { WebSearchFirecrawl } from "@opencode-ai/core/plugin/websearch/firecrawl
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
import { WebSearchTavily } from "@opencode-ai/core/plugin/websearch/tavily"
import { host, integrationHost, webSearchHost } from "./host"
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
import { requests, signals, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
beforeEach(() => {
resetWebSearchFixture(
@@ -30,6 +30,25 @@ beforeEach(() => {
const it = webSearchIntegrationTest
describe("built-in web search providers", () => {
;[WebSearchExa.Plugin, WebSearchParallel.Plugin, WebSearchFirecrawl.Plugin, WebSearchTavily.Plugin].forEach(
(plugin) => {
it.effect(`releases rate-limited HTTP requests for ${plugin.id} before caching their errors`, () =>
Effect.gen(function* () {
resetWebSearchFixture("Rate limited", 429)
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
yield* websearch.select("random")
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
expect(signals).toHaveLength(1)
expect(signals[0]?.aborted).toBe(true)
}),
)
},
)
it.effect("registers a provider without an integration", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
@@ -362,7 +362,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
yield* compaction.compactManual({
session,
resolveContext: () => Effect.succeed(loaded(session, messages)),
prepare: modelRequests.compaction,
prepare: modelRequests.prepare,
messages,
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
@@ -430,7 +430,7 @@ it.effect("manual compaction records model resolution failures without calling t
modelID: Model.ID.make("missing"),
}),
),
prepare: modelRequests.compaction,
prepare: modelRequests.prepare,
messages: [
{
id: SessionMessage.ID.create(),
@@ -481,7 +481,7 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
yield* compaction.compactManual({
session,
resolveContext: () => Effect.succeed(loaded(session, messages)),
prepare: modelRequests.compaction,
prepare: modelRequests.prepare,
messages,
inputID: SessionMessage.ID.make("msg_fork_compaction"),
}),
@@ -57,12 +57,10 @@ describe("SessionModelRequest HTTP hooks", () => {
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
for (const kind of KINDS) {
const prepared = yield* requests[kind]({
session,
agent: Agent.ID.make("build"),
model,
system: [],
messages: [],
const prepared = yield* requests.prepare({
kind,
scope: { session, agentID: Agent.ID.make("build"), model },
transcript: { system: [], messages: [] },
})
const http = prepared.options.http
if (!http) throw new Error(`Expected HTTP middleware for ${kind}`)
@@ -72,13 +70,10 @@ describe("SessionModelRequest HTTP hooks", () => {
}
expect(seen).toEqual(
KINDS.flatMap((kind) => {
const agent = Agent.ID.make(kind === "compaction" ? "compaction" : "build")
return [
{ hook: "request", kind, agent },
{ hook: "response", kind, agent },
]
}),
KINDS.flatMap((kind) => [
{ hook: "request", kind, agent: Agent.ID.make("build") },
{ hook: "response", kind, agent: Agent.ID.make("build") },
]),
)
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
)
+1 -19
View File
@@ -1073,24 +1073,6 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("executes a tool renamed by a session context hook", function* (s) {
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.tools.renamed_echo = event.tools.echo!
delete event.tools.echo
}),
)
yield* s.admit("Use the renamed tool")
yield* s.llm.push(TestLLM.tool("call-renamed", "renamed_echo", { text: "renamed" }), [])
yield* s.resume
expect(s.requests[0]?.tools.map((tool) => tool.name)).toContain("renamed_echo")
expect(s.requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
expect(s.executions).toEqual(["renamed"])
})
scenario("executes the tool advertised before a registry reload", function* (s) {
const registry = yield* Tool.Service
const scope = yield* Scope.make()
@@ -2326,7 +2308,7 @@ describe("SessionRunnerLLM", () => {
expect(event.model.variant).toBe(variant)
event.system.push(SystemPart.make("Hook-provided instructions"))
event.tools.echo.description = "Hook-provided tool description"
event.options.maxTokens = 4_000
event.generation.maxTokens = 4_000
}),
)
yield* hooks.register("session", "model.request", (event) =>
+1 -43
View File
@@ -1,14 +1,5 @@
import { beforeEach, expect } from "bun:test"
import {
AIError,
LLMClient,
LLMEvent,
LanguageModel,
Message,
SystemPart,
TransportError,
type LLMRequest,
} from "@opencode-ai/ai"
import { AIError, LLMClient, LLMEvent, LanguageModel, TransportError, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
@@ -241,39 +232,6 @@ it.effect("generates a title from the sole user message and renames the session"
}),
)
it.effect("runs title hooks instead of context hooks", () =>
Effect.gen(function* () {
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_hook")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Redact this message")
const hooks = yield* PluginHooks.Service
let contexts = 0
yield* hooks.register("session", "context", () => Effect.sync(() => contexts++))
yield* hooks.register("session", "title", (event) =>
Effect.sync(() => {
expect(event.sessionID).toBe(sessionID)
expect(event.system.map((part) => part.text)).toEqual(["You are a title generator."])
event.system.push(SystemPart.make("Prefer short titles."))
event.messages = [Message.user("[redacted]")]
event.options.maxTokens = 32
event.options.reasoningEffort = "low"
}),
)
const title = yield* SessionTitle.Service
yield* title.generate(sessionID)
expect(contexts).toBe(0)
expect(requests).toHaveLength(1)
expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator.", "Prefer short titles."])
expect(JSON.stringify(requests[0]?.messages)).not.toContain("Redact this message")
expect(requests[0]?.generation).toEqual(expect.objectContaining({ maxTokens: 32 }))
expect(requests[0]?.providerOptions).toEqual({ reasoningEffort: "low" })
}),
)
it.effect("uses a small model from the primary provider", () =>
Effect.gen(function* () {
selectedSmall = small
+86 -12
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Context, Effect, Layer } from "effect"
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import type { HttpClientError } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Permission } from "@opencode-ai/core/permission"
@@ -131,10 +131,11 @@ describe("WebSearchTool registration", () => {
expect(fixture.websearch.queries).toEqual([
{
query: "effect typescript",
providerID: WebSearch.ID.make("exa"),
providerID: undefined,
},
])
expect(fixture.events).toEqual(["permission", "query"])
expect(fixture.websearch.sessionIDs).toEqual([sessionID])
}),
)
@@ -215,7 +216,7 @@ describe("WebSearchTool registration", () => {
})
expect(first.status).toBe("completed")
expect(["exa", "parallel"]).toContain(first.metadata?.provider)
expect(first.metadata?.provider).toBe(fixture.websearch.queries[1]?.providerID)
expect(fixture.websearch.sessionIDs).toEqual([sessionID, sessionID])
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
expect(fixture.websearch.queries).toHaveLength(2)
expect(fixture.formRequests).toEqual([
@@ -253,12 +254,31 @@ describe("WebSearchTool registration", () => {
})
expect(second.status).toBe("completed")
expect(["exa", "parallel"]).toContain(second.metadata?.provider)
expect(second.metadata?.provider).toBe(fixture.websearch.queries[2]?.providerID)
expect(second.metadata?.provider).toBe(first.metadata?.provider)
expect(fixture.formRequests).toHaveLength(1)
expect(fixture.websearch.queries).toHaveLength(3)
}),
)
it.effect("honors automatic consent when the configured provider is unavailable", () =>
Effect.gen(function* () {
const fixture = yield* setup
yield* fixture.websearch.transform((editor) => editor.default.set(WebSearch.ID.make("missing")))
fixture.formResponse = { status: "answered", answer: { choice: "allow" } }
const result = yield* executeTool(fixture.registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-missing", name: "websearch", input: { query: "effect" } },
})
expect(result.status).toBe("completed")
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
expect(result.metadata?.provider).toBe(
(yield* fixture.websearch.query({ query: "next" }, { sessionID })).providerID,
)
expect(fixture.formRequests).toHaveLength(1)
}),
)
it.effect("asks a second form when choosing another provider", () =>
Effect.gen(function* () {
const fixture = yield* setup
@@ -347,6 +367,67 @@ describe("WebSearchTool registration", () => {
}),
)
it.effect("keeps provider progress, output, and metadata accurate across automatic failover", () =>
Effect.gen(function* () {
const fixture = yield* setup
yield* fixture.websearch.select("random")
const first = (yield* fixture.websearch.query({ query: "seed" }, { sessionID })).providerID
yield* fixture.websearch.transform((editor) =>
editor.add({
id: first,
name: first,
execute: () => Effect.fail(TestWebSearch.httpError()),
}),
)
const progress: Tool.Metadata[] = []
const tools = yield* fixture.registry.snapshot()
const result = yield* tools.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-failover", name: "websearch", input: { query: "effect" } },
progress: (metadata) =>
Effect.sync(() => {
progress.push(metadata)
}),
})
const replacement = WebSearch.ID.make(first === "exa" ? "parallel" : "exa")
expect(progress).toEqual([{ provider: first }, { provider: replacement }])
expect(result).toMatchObject({
output: { provider: replacement, results: fixture.results },
metadata: { provider: replacement },
})
expect(fixture.formRequests).toEqual([])
expect((yield* fixture.websearch.query({ query: "next" }, { sessionID })).providerID).toBe(replacement)
}),
)
it.effect("does not reopen consent when all automatic providers are cooling down", () =>
Effect.gen(function* () {
const fixture = yield* setup
yield* fixture.websearch.select("random")
fixture.error = TestWebSearch.httpError()
const tools = yield* fixture.registry.snapshot()
yield* Effect.forEach(["first", "cooling"], (query) =>
Effect.gen(function* () {
const error = yield* tools
.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: `call-${query}`, name: "websearch", input: { query } },
})
.pipe(Effect.flip)
expect(toSessionError(error)).toEqual({
type: "tool.execution",
message: "Web search rate limited (HTTP 429)",
})
expect(error.metadata).toMatchObject({ provider: expect.stringMatching(/^(exa|parallel)$/) })
}),
)
expect(fixture.events.filter((event) => event === "query")).toHaveLength(2)
expect(fixture.formRequests).toEqual([])
}),
)
it.effect("reports safe HTTP failures with the attempted provider", () =>
Effect.gen(function* () {
const fixture = yield* setup
@@ -362,14 +443,7 @@ describe("WebSearchTool registration", () => {
],
({ status, message }, index) =>
Effect.gen(function* () {
const request = HttpClientRequest.post("https://mcp.exa.ai/mcp?exaApiKey=secret")
fixture.error = new HttpClientError.HttpClientError({
reason: new HttpClientError.StatusCodeError({
request,
response: HttpClientResponse.fromWeb(request, new Response(null, { status })),
description: "non 2xx status code",
}),
})
fixture.error = TestWebSearch.httpError(status, undefined, "https://mcp.exa.ai/mcp?exaApiKey=secret")
const progress: Tool.Metadata[] = []
const error = yield* tools
.execute({
+444 -6
View File
@@ -1,24 +1,34 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Scope } from "effect"
import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
import { TestClock } from "effect/testing"
import { KV } from "@opencode-ai/core/kv"
import { Bus } from "@opencode-ai/core/bus"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Session } from "@opencode-ai/schema/session"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { testEffect } from "./lib/effect"
import { TestWebSearch } from "./lib/websearch"
const it = testEffect(TestWebSearch.layer)
const firstSession = Session.ID.make("ses_search_first")
const secondSession = Session.ID.make("ses_search_second")
const register = (id: string) =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const providerID = WebSearch.ID.make(id)
const calls: WebSearch.ProviderInput[] = []
yield* websearch.transform((editor) => {
const failure: { cause?: unknown } = {}
const registration = yield* websearch.transform((editor) => {
editor.add({
id: providerID,
name: id.toUpperCase(),
execute: (input) =>
Effect.sync(() => {
Effect.gen(function* () {
calls.push(input)
if (failure.cause !== undefined) return yield* Effect.fail(failure.cause)
return [
{
url: `https://${id}.example.com`,
@@ -30,7 +40,7 @@ const register = (id: string) =>
}),
})
})
return { providerID, calls }
return { providerID, calls, failure, dispose: registration.dispose }
})
describe("WebSearch", () => {
@@ -137,17 +147,445 @@ describe("WebSearch", () => {
}),
)
it.effect("chooses a registered provider for random selection", () =>
it.effect("keeps the random provider across queries, default lookups, and reloads", () =>
Effect.gen(function* () {
yield* register("exa")
yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.transform((editor) => editor.default.set("random"))
expect(["exa", "parallel"]).toContain((yield* websearch.query({ query: "random" })).providerID)
const first = yield* websearch.query({ query: "first" })
expect(["exa", "parallel"]).toContain(first.providerID)
expect((yield* websearch.default())?.id).toBe(first.providerID)
yield* websearch.reload()
const results = yield* Effect.all(
Array.from({ length: 10 }, () => websearch.query({ query: "next" })),
{ concurrency: "unbounded" },
)
expect(results.every((result) => result.providerID === first.providerID)).toBe(true)
}),
)
it.effect("preserves persisted random selection and keeps its provider", () =>
Effect.gen(function* () {
yield* register("exa")
yield* register("parallel")
const websearch = yield* WebSearch.Service
const kv = yield* KV.Service
yield* kv.set(WebSearch.ProviderKey, "random")
const first = yield* websearch.query({ query: "legacy" })
expect((yield* websearch.query({ query: "sticky" })).providerID).toBe(first.providerID)
yield* websearch.select("random")
expect(yield* kv.get(WebSearch.ProviderKey)).toBe("random")
expect((yield* websearch.query({ query: "canonical" })).providerID).toBe(first.providerID)
}),
)
it.effect("fails over on rate limits with random and keeps the replacement after cooldown", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.transform((editor) => editor.default.set("random"))
const first = yield* websearch.query({ query: "first" })
const limited = first.providerID === exa.providerID ? exa : parallel
const replacement = first.providerID === exa.providerID ? parallel : exa
limited.failure.cause = TestWebSearch.httpError()
const progress: WebSearch.ID[] = []
expect(
(yield* websearch.query(
{ query: "retry" },
{
onProvider: (provider) =>
Effect.sync(() => {
progress.push(provider.id)
}),
},
)).providerID,
).toBe(replacement.providerID)
expect(progress).toEqual([limited.providerID, replacement.providerID])
expect(limited.calls.at(-1)).toEqual({ query: "retry" })
expect(replacement.calls).toEqual([{ query: "retry" }])
limited.failure.cause = undefined
yield* TestClock.adjust("59 seconds")
expect((yield* websearch.query({ query: "cooling" })).providerID).toBe(replacement.providerID)
expect(limited.calls).toHaveLength(2)
yield* TestClock.adjust("1 second")
expect((yield* websearch.query({ query: "still sticky" })).providerID).toBe(replacement.providerID)
replacement.failure.cause = TestWebSearch.httpError()
expect((yield* websearch.query({ query: "recovered" })).providerID).toBe(limited.providerID)
}),
)
it.effect("reselects when a concurrent query cools down the provider while progress is pending", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
yield* websearch.query({ query: "seed" })
const first = yield* websearch.default()
if (!first) return yield* Effect.die("Expected an automatic provider")
const limited = first.id === exa.providerID ? exa : parallel
const replacement = first.id === exa.providerID ? parallel : exa
const paused = yield* Deferred.make<void>()
const resume = yield* Deferred.make<void>()
const progress: WebSearch.ID[] = []
const pending = yield* websearch
.query(
{ query: "pending" },
{
onProvider: (provider) =>
Effect.gen(function* () {
progress.push(provider.id)
if (provider.id !== first.id) return
yield* Deferred.succeed(paused, undefined)
yield* Deferred.await(resume)
}),
},
)
.pipe(Effect.forkChild)
yield* Deferred.await(paused)
limited.failure.cause = TestWebSearch.httpError()
expect((yield* websearch.query({ query: "trigger" })).providerID).toBe(replacement.providerID)
yield* Deferred.succeed(resume, undefined)
expect((yield* Fiber.join(pending)).providerID).toBe(replacement.providerID)
expect(progress).toEqual([limited.providerID, replacement.providerID])
expect(limited.calls).toEqual([{ query: "seed" }, { query: "trigger" }])
expect(replacement.calls).toEqual([{ query: "trigger" }, { query: "pending" }])
}),
)
it.effect("fails promptly when all providers are cooling down without asking for a provider", () =>
Effect.gen(function* () {
const providers = [yield* register("exa"), yield* register("parallel"), yield* register("tavily")]
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
providers.forEach((provider) => {
provider.failure.cause = TestWebSearch.httpError()
})
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
expect(providers.map((provider) => provider.calls.length)).toEqual([1, 1, 1])
expect(yield* websearch.default()).toBeDefined()
expect(yield* websearch.query({ query: "still limited" }).pipe(Effect.flip)).toBeInstanceOf(
WebSearch.RequestError,
)
expect(providers.map((provider) => provider.calls.length)).toEqual([1, 1, 1])
}),
)
it.effect("tries each provider only once per query even with a zero cooldown", () =>
Effect.gen(function* () {
const providers = [yield* register("exa"), yield* register("parallel")]
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
providers.forEach((provider) => {
provider.failure.cause = TestWebSearch.httpError(429, "0")
})
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
expect(providers.map((provider) => provider.calls.length)).toEqual([1, 1])
}),
)
;[
{ header: "120", millis: 120_000 },
{ header: "Thu, 01 Jan 1970 00:02:00 GMT", millis: 120_000 },
{ header: undefined, millis: 60_000 },
{ header: "invalid", millis: 60_000 },
{ header: "", millis: 60_000 },
{ header: "-1", millis: 60_000 },
].forEach(({ header, millis }) => {
it.effect(`respects Retry-After ${JSON.stringify(header)} and recovers after cooldown`, () =>
Effect.gen(function* () {
const provider = yield* register("exa")
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
provider.failure.cause = TestWebSearch.httpError(429, header)
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
provider.failure.cause = undefined
yield* TestClock.adjust(millis - 1)
expect(yield* websearch.query({ query: "early" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
expect(provider.calls).toHaveLength(1)
yield* TestClock.adjust(1)
expect((yield* websearch.query({ query: "recovered" })).providerID).toBe(provider.providerID)
expect(provider.calls).toHaveLength(2)
}),
)
})
it.effect("does not rotate or cool down providers for other failures", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
const first = yield* websearch.query({ query: "first" })
const provider = first.providerID === exa.providerID ? exa : parallel
yield* Effect.forEach(
[TestWebSearch.httpError(401), TestWebSearch.httpError(500), new Error("timeout")],
(cause) =>
Effect.gen(function* () {
provider.failure.cause = cause
expect(yield* websearch.query({ query: "failure" }).pipe(Effect.flip)).toMatchObject({
providerID: first.providerID,
cause,
})
expect((yield* websearch.default())?.id).toBe(first.providerID)
}),
)
provider.failure.cause = undefined
expect((yield* websearch.query({ query: "recovered" })).providerID).toBe(first.providerID)
expect((first.providerID === exa.providerID ? parallel : exa).calls).toEqual([])
}),
)
it.effect("does not fail over fixed or explicitly requested providers", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
exa.failure.cause = TestWebSearch.httpError()
yield* websearch.select(exa.providerID)
expect(yield* websearch.query({ query: "fixed" }).pipe(Effect.flip)).toMatchObject({ providerID: exa.providerID })
yield* websearch.select("random")
expect(yield* websearch.query({ query: "explicit", providerID: exa.providerID }).pipe(Effect.flip)).toMatchObject(
{
providerID: exa.providerID,
},
)
expect(exa.calls).toHaveLength(2)
expect(parallel.calls).toEqual([])
}),
)
it.effect("reselects when the active provider is removed", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
expect((yield* websearch.query({ query: "first" })).providerID).toBe(exa.providerID)
const parallel = yield* register("parallel")
expect((yield* websearch.query({ query: "still sticky" })).providerID).toBe(exa.providerID)
yield* exa.dispose
expect((yield* websearch.query({ query: "removed" })).providerID).toBe(parallel.providerID)
}),
)
it.effect("uses updated registrations for the sticky provider", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
expect((yield* websearch.query({ query: "original" })).results).toHaveLength(1)
const updated = yield* websearch.transform((editor) =>
editor.add({
id: exa.providerID,
name: "Updated Exa",
execute: () => Effect.succeed([]),
}),
)
expect(yield* websearch.default()).toEqual({ id: exa.providerID, name: "Updated Exa" })
expect((yield* websearch.query({ query: "updated" })).results).toEqual([])
yield* updated.dispose
expect((yield* websearch.query({ query: "restored" })).results).toHaveLength(1)
expect(exa.calls).toEqual([{ query: "original" }, { query: "restored" }])
}),
)
it.effect("keeps independent session affinities across parallel initial and subsequent searches", () =>
Effect.gen(function* () {
yield* register("exa")
yield* register("parallel")
yield* register("tavily")
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
const results = yield* Effect.forEach(
[firstSession, secondSession],
(sessionID) =>
Effect.all(
Array.from({ length: 8 }, () => websearch.query({ query: "parallel" }, { sessionID })),
{
concurrency: "unbounded",
},
),
{ concurrency: "unbounded" },
)
expect(results.map((group) => new Set(group.map((result) => result.providerID)).size)).toEqual([1, 1])
expect((yield* websearch.query({ query: "later" }, { sessionID: firstSession })).providerID).toBe(
results[0]?.[0]?.providerID,
)
expect((yield* websearch.query({ query: "later" }, { sessionID: secondSession })).providerID).toBe(
results[1]?.[0]?.providerID,
)
}),
)
it.effect("does not overwrite peer or Location affinity when a session switches providers", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
yield* websearch.query({ query: "location" })
yield* websearch.query({ query: "first" }, { sessionID: firstSession })
yield* websearch.query({ query: "second" }, { sessionID: secondSession })
const parallel = yield* register("parallel")
exa.failure.cause = TestWebSearch.httpError()
expect((yield* websearch.query({ query: "switch" }, { sessionID: firstSession })).providerID).toBe(
parallel.providerID,
)
// Even while Exa is cooling down, inspection must not reroute any caller.
expect((yield* websearch.default())?.id).toBe(exa.providerID)
exa.failure.cause = undefined
yield* TestClock.adjust("1 minute")
expect((yield* websearch.query({ query: "peer" }, { sessionID: secondSession })).providerID).toBe(exa.providerID)
expect((yield* websearch.query({ query: "location" })).providerID).toBe(exa.providerID)
expect((yield* websearch.query({ query: "sticky replacement" }, { sessionID: firstSession })).providerID).toBe(
parallel.providerID,
)
}),
)
it.effect("shares cooldowns without sending a peer back to the rate-limited provider", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
yield* websearch.query({ query: "first" }, { sessionID: firstSession })
yield* websearch.query({ query: "second" }, { sessionID: secondSession })
const parallel = yield* register("parallel")
exa.failure.cause = TestWebSearch.httpError(429, "120")
yield* websearch.query({ query: "switch" }, { sessionID: firstSession })
expect((yield* websearch.query({ query: "peer" }, { sessionID: secondSession })).providerID).toBe(
parallel.providerID,
)
expect(exa.calls).toHaveLength(3)
exa.failure.cause = undefined
yield* TestClock.adjust("2 minutes")
expect((yield* websearch.query({ query: "sticky peer" }, { sessionID: secondSession })).providerID).toBe(
parallel.providerID,
)
}),
)
it.effect("converges overlapping session failures and ignores a late success on the old provider", () =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const arrived = yield* Deferred.make<void>()
const failures = yield* Deferred.make<void>()
const lateStarted = yield* Deferred.make<void>()
const lateRelease = yield* Deferred.make<void>()
const calls: string[] = []
yield* websearch.transform((editor) =>
editor.add({
id: WebSearch.ID.make("exa"),
name: "Exa",
execute: (input) =>
Effect.gen(function* () {
if (input.query === "seed") return []
if (input.query === "late") {
yield* Deferred.succeed(lateStarted, undefined)
yield* Deferred.await(lateRelease)
return []
}
calls.push(input.query)
if (calls.length === 3) yield* Deferred.succeed(arrived, undefined)
yield* Deferred.await(failures)
return yield* TestWebSearch.httpError()
}),
}),
)
yield* websearch.select("random")
yield* websearch.query({ query: "seed" }, { sessionID: firstSession })
const late = yield* websearch.query({ query: "late" }, { sessionID: firstSession }).pipe(Effect.forkChild)
yield* Deferred.await(lateStarted)
yield* register("parallel")
yield* register("tavily")
const pending = yield* Effect.all(
Array.from({ length: 3 }, (_, index) =>
websearch.query({ query: `fail-${index}` }, { sessionID: firstSession }),
),
{ concurrency: "unbounded" },
).pipe(Effect.forkChild)
yield* Deferred.await(arrived)
yield* Deferred.succeed(failures, undefined)
const results = yield* Fiber.join(pending)
expect(new Set(results.map((result) => result.providerID)).size).toBe(1)
expect(results[0]?.providerID).not.toBe(WebSearch.ID.make("exa"))
yield* Deferred.succeed(lateRelease, undefined)
expect((yield* Fiber.join(late)).providerID).toBe(WebSearch.ID.make("exa"))
expect((yield* websearch.query({ query: "later" }, { sessionID: firstSession })).providerID).toBe(
results[0]?.providerID,
)
}),
)
it.effect("keeps fixed and explicit providers pinned with session context", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
exa.failure.cause = TestWebSearch.httpError()
yield* websearch.select(exa.providerID)
expect(yield* websearch.query({ query: "fixed" }, { sessionID: firstSession }).pipe(Effect.flip)).toMatchObject({
providerID: exa.providerID,
})
yield* websearch.select("random")
expect(
yield* websearch
.query({ query: "explicit", providerID: exa.providerID }, { sessionID: firstSession })
.pipe(Effect.flip),
).toMatchObject({ providerID: exa.providerID })
expect(parallel.calls).toEqual([])
}),
)
;["delete", "move"].forEach((operation) => {
it.effect(`forgets affinity on session ${operation} without retaining it through an in-flight query`, () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const websearch = yield* WebSearch.Service
const bus = yield* Bus.Service
yield* websearch.select("random")
yield* websearch.query({ query: "seed" }, { sessionID: firstSession })
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const pending = yield* websearch
.query(
{ query: "pending" },
{
sessionID: firstSession,
onProvider: (provider) =>
provider.id === exa.providerID
? Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)))
: Effect.void,
},
)
.pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* operation === "delete"
? bus.publish(SessionEvent.Deleted, { sessionID: firstSession })
: bus.publish(SessionEvent.Moved, {
sessionID: firstSession,
location: { directory: AbsolutePath.make("/moved") },
projectID: Project.ID.global,
})
yield* Effect.yieldNow
yield* exa.dispose
const parallel = yield* register("parallel")
expect((yield* websearch.query({ query: "new affinity" }, { sessionID: firstSession })).providerID).toBe(
parallel.providerID,
)
yield* parallel.dispose
const tavily = yield* register("tavily")
exa.failure.cause = TestWebSearch.httpError()
yield* Deferred.succeed(release, undefined)
expect((yield* Fiber.join(pending)).providerID).toBe(tavily.providerID)
yield* register("parallel")
expect((yield* websearch.query({ query: "still new affinity" }, { sessionID: firstSession })).providerID).toBe(
parallel.providerID,
)
}),
)
})
it.effect("fails when web search is explicitly disabled", () =>
Effect.gen(function* () {
yield* register("exa")
+1
View File
@@ -3,6 +3,7 @@
- Follow Solid best practices, leave a comment when violating this: https://www.brenelz.com/posts/solid-js-best-practices/
- Renderer process should only call `window.api` from `src/preload`.
- Main process should register IPC handlers in `src/main/ipc.ts`.
- Avoid FS operations where possible. For any desktop persistence prefer sqlite in most cases, as performance and EPERM and many other things, especially on windows can be quite painful. Using anything other than sqlite should come with strong reasons.
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for native menus, picker titles, dialogs, buttons, accessible labels, and displayed errors.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "drizzle-kit"
export default defineConfig({
dialect: "sqlite",
schema: "./src/main/storage/schema.ts",
out: "./src/main/storage/migration",
})
+4 -1
View File
@@ -78,7 +78,10 @@ const require = __cjs_mod__.createRequire(import.meta.url);
input: { index: "src/preload/index.ts" },
output: {
format: "cjs",
entryFileNames: "[name].js",
// The package is "type": "module". Under --no-sandbox Electron loads the preload
// through Node's module loader, which treats a .js file as ESM and fails on
// require("electron"). The sandboxed path ignores the extension.
entryFileNames: "[name].cjs",
},
},
},
+2
View File
@@ -13,6 +13,7 @@
"typecheck": "tsgo -b",
"test": "bun test --timeout 30000",
"dev": "bun ./scripts/dev.ts",
"migration": "bun ./scripts/migration.ts",
"prebuild": "bun ./scripts/prebuild.ts",
"build": "electron-vite build",
"preview": "electron-vite preview",
@@ -46,6 +47,7 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"app-builder-lib": "26.15.7",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "42.10.1",
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bun
// Renders the drizzle-kit output under src/main/storage/migration into migration.gen.ts so the
// main bundle and Bun tests import plain TypeScript instead of reading SQL files at runtime.
//
// bun run migration --name <change> generate a migration from schema.ts, then render
// bun run migration render only
// bun run migration --check fail when the rendered registry is stale
import { $ } from "bun"
import path from "node:path"
import { parseArgs } from "node:util"
const root = path.resolve(import.meta.dirname, "..")
const directory = path.join(root, "src/main/storage/migration")
const output = path.join(root, "src/main/storage/migration.gen.ts")
const args = parseArgs({
args: process.argv.slice(2),
options: {
check: { type: "boolean" },
name: { type: "string" },
},
})
if (args.values.name) await $`bun drizzle-kit generate --name ${args.values.name}`.cwd(root)
const rendered = await render()
if (!args.values.check) {
await Bun.write(output, rendered)
process.exit(0)
}
if ((await Bun.file(output).text()) !== rendered) {
throw new Error("Desktop storage migrations are stale. Run `bun run migration` from packages/desktop.")
}
async function render() {
const ids = (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: directory })))
.map((file) => path.dirname(file))
.sort()
const migrations = await Promise.all(
ids.map(async (id) => ({
id,
// Normalize so a CRLF checkout renders the same registry as an LF one.
statements: (await Bun.file(path.join(directory, id, "migration.sql")).text())
.replaceAll("\r\n", "\n")
.split("--> statement-breakpoint")
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0),
})),
)
const source = `// Generated by scripts/migration.ts from src/main/storage/migration. Do not edit.
export const migrations = ${JSON.stringify(migrations, null, 2)}
`
const prettier = await import("prettier")
return prettier.format(source, { parser: "typescript", semi: false, printWidth: 120 })
}
@@ -6,24 +6,17 @@ export const storageHandlers = StorageRpcs.toLayer(
Effect.gen(function* () {
const storage = yield* DesktopStorage.Service
return StorageRpcs.of({
StorageGet: ({ name, key }) => Effect.sync(() => storage.get(name, key)),
StorageSet: ({ name, key, value }) => Effect.sync(() => storage.set(name, key, value)),
StorageDelete: ({ name, key }) => storage.deleteValue(name, key).pipe(Effect.orDie),
StorageClear: ({ name }) => storage.clear(name).pipe(Effect.orDie),
StorageKeys: ({ name }) => Effect.sync(() => storage.keys(name)),
StorageLength: ({ name }) => Effect.sync(() => storage.length(name)),
StorageGet: ({ name, key }) => Effect.sync(() => storage.state.get(name, key)),
StorageSet: ({ name, key, value }) => Effect.sync(() => storage.state.set(name, key, value)),
StorageDelete: ({ name, key }) => Effect.sync(() => storage.state.delete(name, key)),
StorageClear: ({ name }) => Effect.sync(() => storage.state.clear(name)),
StorageKeys: ({ name }) => Effect.sync(() => storage.state.keys(name)),
StorageLength: ({ name }) => Effect.sync(() => storage.state.length(name)),
DraftsGet: ({ key }) => Effect.sync(() => storage.drafts.get(key)),
DraftsSet: ({ key, value }) => Effect.sync(() => storage.drafts.set(key, value)),
DraftsDelete: ({ key }) => Effect.sync(() => storage.drafts.set(key, null)),
DraftsPutBlob: ({ data }) =>
Effect.sync(() =>
storage.drafts.putBlob(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer),
),
DraftsGetBlob: ({ id }) =>
Effect.sync(() => {
const data = storage.drafts.getBlob(id)
return data ? new Uint8Array(data) : null
}),
DraftsPutBlob: ({ data }) => Effect.sync(() => storage.drafts.putBlob(data)),
DraftsGetBlob: ({ id }) => Effect.sync(() => storage.drafts.getBlob(id)),
})
}),
)
+1 -2
View File
@@ -19,12 +19,11 @@ import { ApplicationLifecycle } from "./lifecycle"
import { showCliInstaller } from "./native/install-cli"
import { createMenu, sendMenuCommand } from "./native/menu"
import { DesktopCli } from "./service/desktop-cli"
import { DesktopStorage } from "./storage"
import { Updater } from "./updater"
import { getLastFocusedWindow } from "./windows"
import { Wsl } from "./wsl/start"
const services = Layer.mergeAll(DesktopFiles.layer, DesktopStorage.layer, Wsl.layer)
const services = Layer.mergeAll(DesktopFiles.layer, Wsl.layer)
const handlers = Layer.mergeAll(
appHandlers,
storageHandlers,
@@ -10,7 +10,6 @@ import {
prepareApplicationEnvironment,
prepareDesktop,
} from "./environment"
import { initializeFirstLaunchOnboarding } from "./onboarding"
export interface Interface {
readonly version: string
@@ -23,7 +22,6 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const logging = yield* DesktopLogging.Service
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
yield* prepareApplicationEnvironment
yield* preferApplicationEnvironment
yield* loadProxyEnvironment
@@ -8,7 +8,6 @@ import { Effect, FileSystem, Path } from "effect"
import { CHANNEL } from "../constants"
import { DesktopPaths } from "../paths"
import { getUserShell, loadShellEnv } from "../service/shell-env"
import { cleanupStoreFiles } from "../storage/cleanup"
import { registerRendererProtocol, setDockIcon } from "../windows"
const appNames: Record<string, string> = {
@@ -74,14 +73,6 @@ export const preferApplicationEnvironment = Effect.gen(function* () {
export const prepareDesktop = Effect.gen(function* () {
const path = yield* Path.Path
const paths = yield* DesktopPaths.resolve
yield* cleanupStoreFiles(app.getPath("userData")).pipe(
Effect.tap((result) =>
result.deleted.length === 0
? Effect.void
: Effect.logInfo("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned }),
),
Effect.catch((error) => Effect.logWarning("failed to clean scoped store files", { error })),
)
if (app.isPackaged || process.env.OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION !== "1")
app.setAsDefaultProtocolClient("opencode")
yield* registerRendererProtocol()
+12 -1
View File
@@ -6,9 +6,11 @@ import { Context, Effect, Layer } from "effect"
import { DeepLinksOpened } from "../../shared/ipc-rpc/events"
import { emitIpcEvent } from "../ipc-events"
import { DesktopLogging, scoped } from "../native/logging"
import { DesktopStorage } from "../storage"
import { safeWebContentsURL } from "../windows/state"
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
import { acquireApplicationLock, configureApplication } from "./environment"
import { initializeFirstLaunchOnboarding } from "./onboarding"
import { Shutdown } from "./shutdown"
export interface Interface {
@@ -143,13 +145,22 @@ const runtime = Layer.effect(
}),
)
const platform = Layer.merge(DesktopLogging.layer, Shutdown.layer)
// Storage opens after configureApplication has set userData and before windows exist, so window
// teardown can clear a window's persisted state and every renderer request finds it ready.
const platform = Layer.mergeAll(
DesktopLogging.layer,
Shutdown.layer,
DesktopStorage.layer.pipe(Layer.provide(DesktopLogging.layer)),
)
export const layer = Layer.unwrap(
Effect.gen(function* () {
// Electron scopes the single-instance lock to userData.
yield* configureApplication()
if (!acquireApplicationLock()) return yield* Effect.interrupt
// Decide first-launch state before the storage layer creates drafts.sqlite, which would
// otherwise read as evidence of an earlier launch on a fresh install.
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
return runtime.pipe(Layer.provideMerge(platform))
}),
)
+18 -7
View File
@@ -2,7 +2,7 @@ export * as DesktopLogging from "./logging"
import log from "electron-log/main.js"
import { app, crashReporter, netLog, shell } from "electron"
import { Context, Effect, FileSystem, Layer, Logger, Option, Path, References } from "effect"
import { Context, Effect, FileSystem, Layer, Logger, Option, Path, References, Stream } from "effect"
import { homedir } from "node:os"
import { VERSION } from "../constants"
@@ -134,11 +134,15 @@ function exportDebugLogsEffect(fs: FileSystem.FileSystem, path: Path.Path) {
const output = path.join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`)
return yield* Effect.gen(function* () {
yield* Effect.logInfo("exporting debug logs", { output })
yield* writeZip(fs, output, [
{ name: "manifest.json", data: Buffer.from(JSON.stringify(manifest(path), null, 2)) },
const files = [
...(yield* collect(fs, path, root, "desktop")),
...(yield* Effect.forEach(serverLogRoots(path), (dir, i) => collect(fs, path, dir, `server-${i + 1}`))).flat(),
...(yield* collect(fs, path, app.getPath("crashDumps"), "crashpad")),
]
const truncated = files.filter((file) => file.offset > 0).map((file) => file.name)
yield* writeZip(fs, output, [
{ name: "manifest.json", data: Buffer.from(JSON.stringify({ ...manifest(path), truncated }, null, 2)) },
...files,
])
yield* Effect.sync(() => shell.showItemInFolder(output))
return output
@@ -229,7 +233,7 @@ function serverLogRoots(path: Path.Path) {
]
}
type Entry = { name: string; path: string } | { name: string; data: Uint8Array }
type Entry = { name: string; path: string; offset: number } | { name: string; data: Uint8Array }
function collect(fs: FileSystem.FileSystem, path: Path.Path, dir: string, prefix: string) {
return Effect.gen(function* () {
@@ -242,9 +246,11 @@ function collect(fs: FileSystem.FileSystem, path: Path.Path, dir: string, prefix
const info = yield* fs.stat(file)
if (info.type === "Directory") return null
if (Option.getOrElse(info.mtime, () => new Date(0)).getTime() < cutoff) return null
if (info.size > FileSystem.Size(MAX_EXPORT_FILE_SIZE)) return null
if (file.endsWith(".heapsnapshot")) return null
return { name: path.join(prefix, entry).replace(/\\/g, "/"), path: file }
// Server logs append forever without rotation, so the active log is often the largest
// file. Export its tail rather than dropping the most relevant file from the bundle.
const offset = Math.max(0, Number(info.size) - MAX_EXPORT_FILE_SIZE)
return { name: path.join(prefix, entry).replace(/\\/g, "/"), path: file, offset }
}),
)).filter((entry) => entry !== null)
})
@@ -258,7 +264,12 @@ function writeZip(fs: FileSystem.FileSystem, output: string, entries: Entry[]) {
entries,
(entry) =>
Effect.gen(function* () {
const data = "data" in entry ? entry.data : yield* fs.readFile(entry.path)
const data =
"data" in entry
? entry.data
: entry.offset === 0
? yield* fs.readFile(entry.path)
: Buffer.concat(yield* Stream.runCollect(fs.stream(entry.path, { offset: entry.offset })))
yield* Effect.tryPromise(() => writer.add(entry.name, new BlobReader(new Blob([new Uint8Array(data)]))))
}),
{ concurrency: 1, discard: true },
+1 -1
View File
@@ -13,7 +13,7 @@ export const resolve = Effect.gen(function* () {
const root = path.dirname(yield* path.fromFileUrl(new URL(import.meta.url)))
return {
developmentResourcesRoot: path.join(root, "../../resources"),
preloadPath: path.join(root, "../preload/index.js"),
preloadPath: path.join(root, "../preload/index.cjs"),
rendererRoot: path.join(root, "../renderer"),
} satisfies Resolved
}).pipe(Effect.orDie)
@@ -1,141 +0,0 @@
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
import * as NodePath from "@effect/platform-node/NodePath"
import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "node:os"
import { Effect, FileSystem, Layer, Path } from "effect"
import { cleanupStoreFiles, deleteStoreFileIfEmpty } from "./cleanup"
const roots: string[] = []
const platform = Layer.merge(NodeFileSystem.layer, NodePath.layer)
const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) =>
Effect.runPromise(effect.pipe(Effect.provide(platform)))
const tempRoot = Effect.fn("StorageTest.tempRoot")(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* fs.makeTempDirectory({ directory: tmpdir(), prefix: "opencode-store-cleanup-" })
roots.push(root)
return root
})
const writeStore = Effect.fn("StorageTest.writeStore")(function* (
root: string,
name: string,
value: string,
modified: Date,
) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
yield* fs.writeFileString(path.join(root, name), value)
yield* fs.utimes(path.join(root, name), modified, modified)
})
afterEach(() =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* Effect.forEach(roots.splice(0), (root) => fs.remove(root, { recursive: true, force: true }), {
concurrency: "unbounded",
discard: true,
})
}),
),
)
describe("store cleanup", () => {
test("removes empty scoped stores and leaves global stores alone", () =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
yield* writeStore(root, "opencode.draft.empty.dat", "{}", now)
yield* writeStore(root, "opencode.workspace.empty.dat", "{\n}", now)
yield* writeStore(root, "opencode.global.dat", "{}", now)
yield* writeStore(root, "opencode.workspace.empty.dat.json", "{}", now)
const result = yield* cleanupStoreFiles(root, now.getTime())
expect(result.deleted.sort()).toEqual(["opencode.draft.empty.dat", "opencode.workspace.empty.dat"])
expect((yield* fs.readDirectory(root)).sort()).toEqual([
"opencode.global.dat",
"opencode.workspace.empty.dat.json",
])
}),
),
)
test("removes stale drafts by age without removing non-empty workspace stores", () =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
yield* writeStore(
root,
"opencode.draft.old.dat",
'{"draft:prompt":"hello"}',
new Date("2026-05-01T00:00:00.000Z"),
)
yield* writeStore(root, "opencode.draft.recent.dat", '{"draft:prompt":"hello"}', now)
yield* writeStore(
root,
"opencode.workspace.old.dat",
'{"workspace:layout":"wide"}',
new Date("2025-01-01T00:00:00.000Z"),
)
yield* writeStore(root, "opencode.workspace.recent.dat", '{"workspace:layout":"wide"}', now)
const result = yield* cleanupStoreFiles(root, now.getTime())
expect(result.deleted).toEqual(["opencode.draft.old.dat"])
expect((yield* fs.readDirectory(root)).sort()).toEqual([
"opencode.draft.recent.dat",
"opencode.workspace.old.dat",
"opencode.workspace.recent.dat",
])
}),
),
)
test("caps scoped stores by recency", () =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
yield* Effect.forEach(
Array.from({ length: 102 }, (_, index) => index),
(index) =>
writeStore(
root,
`opencode.draft.${index}.dat`,
'{"draft:prompt":"hello"}',
new Date(now.getTime() - index * 1000),
),
{ concurrency: "unbounded", discard: true },
)
const result = yield* cleanupStoreFiles(root, now.getTime())
const remaining = yield* fs.readDirectory(root)
expect(result.deleted.sort()).toEqual(["opencode.draft.100.dat", "opencode.draft.101.dat"])
expect(remaining).toHaveLength(100)
}),
),
)
test("removes a scoped store immediately when it becomes empty", () =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* tempRoot()
yield* writeStore(root, "opencode.draft.empty.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
yield* writeStore(root, "opencode.global.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
expect(yield* deleteStoreFileIfEmpty(root, "opencode.draft.empty.dat")).toBe(true)
expect(yield* deleteStoreFileIfEmpty(root, "opencode.global.dat")).toBe(false)
expect(yield* fs.readDirectory(root)).toEqual(["opencode.global.dat"])
}),
),
)
})
@@ -1,104 +0,0 @@
import { Effect, FileSystem, Option, Path } from "effect"
const EMPTY_STORE_MAX_BYTES = 128
const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
const DRAFT_KEEP_RECENT = 100
type StoreKind = "draft" | "workspace"
type StoreCandidate = {
name: string
path: string
kind: StoreKind
modified: number
empty: boolean
}
export const cleanupStoreFiles = Effect.fn("Storage.cleanupStoreFiles")(function* (
userDataPath: string,
now = Date.now(),
) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const entries = yield* fs.readDirectory(userDataPath).pipe(Effect.orElseSucceed(() => []))
const candidates = (yield* Effect.forEach(
entries,
Effect.fnUntraced(function* (entry) {
const kind = storeKind(entry)
if (!kind) return
const file = path.join(userDataPath, entry)
const stats = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
if (stats?.type !== "File") return
return {
name: entry,
path: file,
kind,
modified: Option.getOrElse(stats.mtime, () => new Date(0)).getTime(),
empty: yield* isEmptyStore(file, stats.size),
}
}),
{ concurrency: 5 },
)).filter((candidate) => !!candidate)
const stale = new Set<StoreCandidate>()
for (const candidate of candidates) {
if (candidate.empty) stale.add(candidate)
if (candidate.kind === "draft" && now - candidate.modified > DRAFT_RETENTION_MS) stale.add(candidate)
}
candidates
.filter((candidate) => candidate.kind === "draft" && !candidate.empty)
.sort((a, b) => b.modified - a.modified)
.slice(DRAFT_KEEP_RECENT)
.forEach((candidate) => stale.add(candidate))
const deleted = yield* Effect.forEach(
stale,
Effect.fnUntraced(function* (candidate) {
yield* fs.remove(candidate.path, { force: true })
return candidate.name
}),
{ concurrency: "unbounded" },
)
return { scanned: candidates.length, deleted }
})
export const deleteStoreFileIfEmpty = Effect.fn("Storage.deleteStoreFileIfEmpty")(function* (
userDataPath: string,
name: string,
) {
if (!storeKind(name)) return false
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const file = path.join(userDataPath, name)
const stats = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
if (stats?.type !== "File") return false
if (!(yield* isEmptyStore(file, stats.size))) return false
yield* fs.remove(file, { force: true })
return true
})
function storeKind(name: string): StoreKind | undefined {
if (/^opencode\.draft\..+\.dat$/.test(name)) return "draft"
if (/^opencode\.workspace\..+\.dat$/.test(name)) return "workspace"
}
const isEmptyStore = Effect.fn("Storage.isEmptyStore")(function* (file: string, size: FileSystem.Size) {
if (size > FileSystem.Size(EMPTY_STORE_MAX_BYTES)) return false
const fs = yield* FileSystem.FileSystem
const raw = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
if (raw === undefined) return false
if (raw.trim() === "") return true
try {
const parsed = JSON.parse(raw) as unknown
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0
} catch {
return false
}
})
@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test"
import { DatabaseSync } from "node:sqlite"
import path from "node:path"
import { sql } from "drizzle-orm"
import { drizzle } from "drizzle-orm/node-sqlite"
import { migrate, openDatabase } from "./database"
import { migrations } from "./migration.gen"
const tables = (db: ReturnType<typeof drizzle>) =>
db
.all<{ name: string }>(sql`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
.map((row) => row.name)
describe("database", () => {
test("bootstraps every table on a fresh database and is idempotent", () => {
const database = openDatabase(":memory:")
expect(tables(database.db)).toEqual(["blob", "document", "migration", "state"])
expect(migrate(database.db)).toEqual([])
database.close()
})
test("adopts a drafts.sqlite created before the journal existed", () => {
const native = new DatabaseSync(":memory:")
native.exec(
"CREATE TABLE document (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE blob (id TEXT PRIMARY KEY, data BLOB NOT NULL); INSERT INTO document VALUES ('k', 'v')",
)
const db = drizzle({ client: native })
expect(migrate(db)).toEqual(migrations.map((migration) => migration.id))
expect(tables(db)).toEqual(["blob", "document", "migration", "state"])
expect(db.all<{ value: string }>(sql`SELECT value FROM document`)).toEqual([{ value: "v" }])
expect(migrate(db)).toEqual([])
})
test("rendered registry matches the drizzle-kit output on disk", async () => {
const directory = path.join(import.meta.dirname, "migration")
const ids = (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: directory })))
.map((file) => path.dirname(file))
.sort()
expect(migrations.map((migration) => migration.id)).toEqual(ids)
for (const migration of migrations) {
const source = (await Bun.file(path.join(directory, migration.id, "migration.sql")).text()).replaceAll(
"\r\n",
"\n",
)
for (const statement of migration.statements) expect(source).toContain(statement)
}
})
})
@@ -0,0 +1,48 @@
import { DatabaseSync } from "node:sqlite"
import { sql } from "drizzle-orm"
import { drizzle } from "drizzle-orm/node-sqlite"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { migrations } from "./migration.gen"
export type Database = ReturnType<typeof drizzle>
// Owned by the runner rather than schema.ts so drizzle-kit never tries to migrate the journal itself.
const journal = sqliteTable("migration", {
id: text().primaryKey(),
time_completed: integer().notNull(),
})
export function openDatabase(filename: string) {
const native = new DatabaseSync(filename)
// WAL keeps readers off the writer. NORMAL fsyncs at checkpoints only, which survives an app
// crash but not power loss; the right trade for UI state and far cheaper on Windows.
native.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA temp_store=MEMORY")
const db = drizzle({ client: native })
migrate(db)
return { db, close: () => native.close() }
}
export function migrate(db: Database) {
db.run(sql`CREATE TABLE IF NOT EXISTS ${journal} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
const applied = new Set(
db
.select({ id: journal.id })
.from(journal)
.all()
.map((row) => row.id),
)
// drafts.sqlite predates the journal: its tables were created by hand, so the migration that
// would create them is recorded as applied instead of run.
const legacy =
applied.size === 0 &&
db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'document'`) !== undefined
const pending = migrations.filter((migration) => !applied.has(migration.id))
if (pending.length === 0) return []
db.transaction((tx) => {
pending.forEach((migration, index) => {
if (!(legacy && index === 0)) migration.statements.forEach((statement) => tx.run(sql.raw(statement)))
tx.insert(journal).values({ id: migration.id, time_completed: Date.now() }).run()
})
})
return pending.map((migration) => migration.id)
}
@@ -1,29 +1,32 @@
import { expect, test } from "bun:test"
import { createDesktopDraftStore } from "./drafts"
import { describe, expect, test } from "bun:test"
import { sql } from "drizzle-orm"
import { openDatabase } from "./database"
import { createDraftStore } from "./drafts"
test("flushes the latest buffered draft and stores blobs", () => {
const store = createDesktopDraftStore(":memory:")
store.set("prompt", "first")
store.set("prompt", "latest")
expect(store.get("prompt")).toBe("latest")
store.flush()
expect(store.get("prompt")).toBe("latest")
describe("draft store", () => {
test("queues documents and reads them back before and after flush", () => {
const database = openDatabase(":memory:")
const drafts = createDraftStore(database.db, { delay: 1_000 })
drafts.set("a:draft:prompt", "{}")
expect(drafts.get("a:draft:prompt")).toBe("{}")
drafts.flush()
expect(drafts.get("a:draft:prompt")).toBe("{}")
drafts.set("a:draft:prompt", null)
expect(drafts.get("a:draft:prompt")).toBeNull()
drafts.flush()
expect(database.db.all(sql`SELECT key FROM document`)).toEqual([])
})
const bytes = new TextEncoder().encode("image")
const id = store.putBlob(bytes)
expect(store.getBlob(id)).toEqual(bytes)
store.close()
})
test("allows repeated flushes until closing", () => {
const store = createDesktopDraftStore(":memory:")
store.set("prompt", "first")
store.flush()
store.set("prompt", "draft")
store.flush()
expect(store.get("prompt")).toBe("draft")
store.close()
expect(() => store.flush()).not.toThrow()
expect(() => store.close()).not.toThrow()
test("stores blobs by content hash and collects unreferenced ones on open", () => {
const database = openDatabase(":memory:")
const first = createDraftStore(database.db, { delay: 1_000 })
const used = first.putBlob(new Uint8Array([1, 2, 3]))
const unused = first.putBlob(new Uint8Array([4, 5, 6]))
expect(first.putBlob(new Uint8Array([1, 2, 3]))).toBe(used)
first.set("doc", JSON.stringify({ parts: [{ blob: { id: used } }] }))
first.flush()
const second = createDraftStore(database.db, { delay: 1_000 })
expect(second.getBlob(used)).toEqual(new Uint8Array([1, 2, 3]))
expect(second.getBlob(unused)).toBeNull()
})
})
+46 -70
View File
@@ -1,72 +1,39 @@
import { createHash } from "node:crypto"
import { DatabaseSync } from "node:sqlite"
import { eq } from "drizzle-orm"
import { drizzle } from "drizzle-orm/node-sqlite"
import { blob, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { eq, sql } from "drizzle-orm"
import type { Database } from "./database"
import { blobs, document } from "./schema"
import { createWriteBehind } from "./write-behind"
const documents = sqliteTable("document", {
key: text().primaryKey(),
value: text().notNull(),
})
const blobs = sqliteTable("blob", {
id: text().primaryKey(),
data: blob({ mode: "buffer" }).notNull(),
})
export type DraftStore = ReturnType<typeof createDraftStore>
export function createDesktopDraftStore(filename: string) {
const native = new DatabaseSync(filename)
native.exec(
"PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS document (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS blob (id TEXT PRIMARY KEY, data BLOB NOT NULL);",
)
const db = drizzle({ client: native })
const used = new Set<string>()
db.select({ value: documents.value })
.from(documents)
.all()
.forEach(({ value }) =>
JSON.parse(value, (_key, item) => {
if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id)
return item
export function createDraftStore(db: Database, input: { delay?: number; onError?: (error: unknown) => void } = {}) {
collectBlobs(db)
const byKey = eq(document.key, sql.placeholder("key"))
const read = db.select({ value: document.value }).from(document).where(byKey).prepare()
const remove = db.delete(document).where(byKey).prepare()
const upsert = db
.insert(document)
.values({ key: sql.placeholder("key"), value: sql.placeholder("value") })
.onConflictDoUpdate({ target: document.key, set: { value: sql.placeholder("value") } })
.prepare()
const writer = createWriteBehind<string | null>({
delay: input.delay ?? 500,
onError: input.onError,
write: (batch) =>
db.transaction(() => {
for (const [key, value] of batch) {
if (value === null) remove.run({ key })
else upsert.run({ key, value })
}
}),
)
db.select({ id: blobs.id })
.from(blobs)
.all()
.filter(({ id }) => !used.has(id))
.forEach(({ id }) => db.delete(blobs).where(eq(blobs.id, id)).run())
const pending = new Map<string, string | null>()
let timer: ReturnType<typeof setTimeout> | undefined
let closed = false
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
if (closed) return
const writes = [...pending]
pending.clear()
if (!writes.length) return
db.transaction((tx) => {
writes.forEach(([key, value]) => {
if (value === null) tx.delete(documents).where(eq(documents.key, key)).run()
else
tx.insert(documents)
.values({ key, value })
.onConflictDoUpdate({ target: documents.key, set: { value } })
.run()
})
})
}
const schedule = () => {
if (!timer) timer = setTimeout(flush, 500)
}
})
return {
get: (key: string) =>
pending.has(key)
? (pending.get(key) ?? null)
: (db.select({ value: documents.value }).from(documents).where(eq(documents.key, key)).get()?.value ?? null),
set(key: string, value: string | null) {
pending.set(key, value)
schedule()
get(key: string) {
if (writer.has(key)) return writer.get(key) ?? null
return read.get({ key })?.value ?? null
},
set: (key: string, value: string | null) => writer.set(key, value),
putBlob(data: Uint8Array) {
const id = createHash("sha256").update(data).digest("hex")
db.insert(blobs)
@@ -75,13 +42,22 @@ export function createDesktopDraftStore(filename: string) {
.run()
return id
},
getBlob: (id: string) => db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data ?? null,
flush,
close() {
if (closed) return
flush()
closed = true
native.close()
getBlob(id: string): Uint8Array | null {
return db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data ?? null
},
flush: writer.flush,
close: writer.close,
}
}
// Blobs are content-addressed and shared; drop the ones no document references anymore. SQLite
// walks the JSON itself, so startup does not parse every draft and history entry in JavaScript.
function collectBlobs(db: Database) {
db.run(sql`
DELETE FROM ${blobs} WHERE ${blobs.id} NOT IN (
SELECT json_extract(node.value, '$.id')
FROM ${document}, json_tree(${document.value}) AS node
WHERE json_valid(${document.value}) AND node.key = 'blob' AND node.type = 'object'
)
`)
}
+37 -43
View File
@@ -2,8 +2,10 @@ export * as DesktopStorage from "./index"
import { app, BrowserWindow } from "electron"
import { Context, Effect, Layer, Path } from "effect"
import { createDesktopDraftStore } from "./drafts"
import { getStore, removeStoreFileIfEmpty } from "./store"
import { openDatabase } from "./database"
import { createDraftStore } from "./drafts"
import { importLegacyStores } from "./legacy"
import { createStateStore } from "./state"
export type Interface = ReturnType<typeof make>
@@ -13,60 +15,52 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const path = yield* Path.Path
const storage = make(path.join(app.getPath("userData"), "drafts.sqlite"))
const flush = () => storage.drafts.flush()
const wire = (_event: Electron.Event, win: BrowserWindow) => win.on("session-end", flush)
app.on("before-quit", flush)
const runFork = Effect.runForkWith(yield* Effect.context())
const userData = app.getPath("userData")
const storage = make(path.join(userData, "drafts.sqlite"), (error) =>
runFork(Effect.logError("storage flush failed", { error })),
)
yield* importLegacyStores(storage.db, userData).pipe(
Effect.tap((result) =>
result.removed.length === 0
? Effect.void
: Effect.logInfo("imported legacy store files", { imported: result.imported, files: result.removed }),
),
Effect.catch((error) => Effect.logWarning("failed to import legacy store files", { error })),
)
const wire = (_event: Electron.Event, win: BrowserWindow) => win.on("session-end", storage.flush)
app.on("before-quit", storage.flush)
app.on("browser-window-created", wire)
BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win))
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
app.off("before-quit", flush)
app.off("before-quit", storage.flush)
app.off("browser-window-created", wire)
BrowserWindow.getAllWindows().forEach((win) => win.off("session-end", flush))
storage.drafts.close()
BrowserWindow.getAllWindows().forEach((win) => win.off("session-end", storage.flush))
storage.close()
}),
)
return Service.of(storage)
}),
)
function make(draftFile: string) {
const drafts = createDesktopDraftStore(draftFile)
const deleteValue = Effect.fn("DesktopStorage.delete")(function* (name: string, key: string) {
getStore(name).delete(key)
yield* removeStoreFileIfEmpty(name).pipe(Effect.ignore)
})
const clear = Effect.fn("DesktopStorage.clear")(function* (name: string) {
getStore(name).clear()
yield* removeStoreFileIfEmpty(name).pipe(Effect.ignore)
})
// The file keeps its historical name; renaming it would mean moving the drafts it already holds.
export function make(filename: string, onError?: (error: unknown) => void) {
const database = openDatabase(filename)
const state = createStateStore(database.db, { onError })
const drafts = createDraftStore(database.db, { onError })
return {
get(name: string, key: string) {
try {
const value = getStore(name).get(key)
if (value === undefined || value === null) return null
return typeof value === "string" ? value : JSON.stringify(value)
} catch {
return null
}
db: database.db,
state,
drafts,
flush() {
state.flush()
drafts.flush()
},
set: (name: string, key: string, value: string) => getStore(name).set(key, value),
deleteValue,
clear,
keys: (name: string) => Object.keys(getStore(name).store),
length: (name: string) => Object.keys(getStore(name).store).length,
drafts: {
get: (key: string) => drafts.get(key),
set: (key: string, value: string | null) => drafts.set(key, value),
putBlob: (data: ArrayBuffer) => drafts.putBlob(new Uint8Array(data)),
getBlob(id: string) {
const data = drafts.getBlob(id)
return data ? new Uint8Array(data).buffer : null
},
flush: drafts.flush,
close: drafts.close,
close() {
state.close()
drafts.close()
database.close()
},
}
}
@@ -13,6 +13,7 @@ describe("hasExistingAppState", () => {
test("recognizes state written by an earlier OpenCode launch", () => {
expect(hasExistingAppState([file("opencode.settings")])).toBe(true)
expect(hasExistingAppState([file("opencode.global.dat")])).toBe(true)
expect(hasExistingAppState([file("drafts.sqlite")])).toBe(true)
expect(hasExistingAppState([file("window-state-abc.json")])).toBe(true)
expect(hasExistingAppState([directory("opencode")])).toBe(true)
})
@@ -2,6 +2,7 @@ export function hasExistingAppState(entries: Array<{ name: string; directory: bo
return entries.some((entry) => {
if (entry.name === "opencode.settings") return true
if (entry.name.endsWith(".dat")) return true
if (entry.name === "drafts.sqlite") return true
if (/^window-state-.+\.json$/.test(entry.name)) return true
return entry.directory && entry.name === "opencode"
})
@@ -0,0 +1,148 @@
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
import * as NodePath from "@effect/platform-node/NodePath"
import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "node:os"
import { sql } from "drizzle-orm"
import { Effect, FileSystem, Layer, Path } from "effect"
import { openDatabase } from "./database"
import { importLegacyStores } from "./legacy"
const roots: string[] = []
const platform = Layer.merge(NodeFileSystem.layer, NodePath.layer)
const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) =>
Effect.runPromise(effect.pipe(Effect.provide(platform)))
const now = new Date("2026-07-01T00:00:00.000Z")
const day = 24 * 60 * 60 * 1000
const tempRoot = Effect.fn("LegacyTest.tempRoot")(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* fs.makeTempDirectory({ directory: tmpdir(), prefix: "opencode-legacy-store-" })
roots.push(root)
return root
})
const writeStore = Effect.fn("LegacyTest.writeStore")(function* (
root: string,
name: string,
value: string,
modified = now,
) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
yield* fs.writeFileString(path.join(root, name), value)
yield* fs.utimes(path.join(root, name), modified, modified)
})
const listing = Effect.fn("LegacyTest.listing")(function* (root: string) {
const fs = yield* FileSystem.FileSystem
return (yield* fs.readDirectory(root)).sort()
})
const rows = (db: ReturnType<typeof openDatabase>["db"]) =>
db.all<{ name: string; key: string; value: string }>(sql`SELECT name, key, value FROM state ORDER BY name, key`)
afterEach(() =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* Effect.forEach(roots.splice(0), (root) => fs.remove(root, { recursive: true, force: true }), {
concurrency: "unbounded",
discard: true,
})
}),
),
)
describe("legacy store import", () => {
test("copies every namespace into state and removes the files", () =>
run(
Effect.gen(function* () {
const root = yield* tempRoot()
const database = openDatabase(":memory:")
yield* writeStore(root, "opencode.global.dat", JSON.stringify({ model: "m", layout: { sidebar: 1 } }))
yield* writeStore(root, "opencode.window.w1.dat", JSON.stringify({ tabs: "[]" }))
yield* writeStore(root, "default.dat", JSON.stringify({ "settings.v3": "{}" }))
yield* writeStore(root, "opencode.settings", JSON.stringify({ keep: true }))
yield* writeStore(root, "unrelated.txt", "x")
const result = yield* importLegacyStores(database.db, root, now.getTime())
expect(result.imported).toBe(4)
expect(rows(database.db)).toEqual([
{ name: "default.dat", key: "settings.v3", value: "{}" },
{ name: "opencode.global.dat", key: "layout", value: '{"sidebar":1}' },
{ name: "opencode.global.dat", key: "model", value: "m" },
{ name: "opencode.window.w1.dat", key: "tabs", value: "[]" },
])
expect(yield* listing(root)).toEqual(["opencode.settings", "unrelated.txt"])
}),
))
test("does not overwrite state that already exists", () =>
run(
Effect.gen(function* () {
const root = yield* tempRoot()
const database = openDatabase(":memory:")
database.db.run(sql`INSERT INTO state VALUES ('opencode.global.dat', 'model', 'new', 0)`)
yield* writeStore(root, "opencode.global.dat", JSON.stringify({ model: "old" }))
yield* importLegacyStores(database.db, root, now.getTime())
expect(rows(database.db)).toEqual([{ name: "opencode.global.dat", key: "model", value: "new" }])
expect(yield* listing(root)).toEqual([])
}),
))
test("leaves unreadable files in place", () =>
run(
Effect.gen(function* () {
const root = yield* tempRoot()
const database = openDatabase(":memory:")
yield* writeStore(root, "opencode.global.dat", "{not json")
yield* writeStore(root, "opencode.workspace.x.dat", JSON.stringify({ terminal: "{}" }))
const result = yield* importLegacyStores(database.db, root, now.getTime())
expect(result.imported).toBe(1)
expect(yield* listing(root)).toEqual(["opencode.global.dat"])
}),
))
test("applies draft retention: skips empty, stale, and excess draft files", () =>
run(
Effect.gen(function* () {
const root = yield* tempRoot()
const database = openDatabase(":memory:")
yield* writeStore(root, "opencode.draft.empty.dat", "{}")
yield* writeStore(
root,
"opencode.draft.stale.dat",
JSON.stringify({ "draft:prompt": "old" }),
new Date(now.getTime() - 31 * day),
)
yield* Effect.forEach(
Array.from({ length: 102 }, (_, index) => index),
(index) =>
writeStore(
root,
`opencode.draft.${index}.dat`,
JSON.stringify({ "draft:prompt": `${index}` }),
new Date(now.getTime() - index * 1_000),
),
{ concurrency: 5 },
)
const result = yield* importLegacyStores(database.db, root, now.getTime())
expect(result.imported).toBe(100)
const names = rows(database.db).map((row) => row.name)
expect(names).toContain("opencode.draft.0.dat")
expect(names).toContain("opencode.draft.99.dat")
expect(names).not.toContain("opencode.draft.100.dat")
expect(names).not.toContain("opencode.draft.101.dat")
expect(names).not.toContain("opencode.draft.stale.dat")
expect(names).not.toContain("opencode.draft.empty.dat")
expect(yield* listing(root)).toEqual([])
}),
))
})
@@ -0,0 +1,84 @@
import { Effect, FileSystem, Option, Path, Schema } from "effect"
import type { Database } from "./database"
import { state } from "./schema"
const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
const DRAFT_KEEP_RECENT = 100
const Entries = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
const decode = Schema.decodeUnknownOption(Entries)
type Candidate = { name: string; path: string; modified: number; entries: Record<string, unknown> }
// Before the state table existed, every namespace the renderer persisted was an electron-store
// JSON file in userData. Copy them into SQLite once and remove them; this is the only place the
// storage layer touches the filesystem, and it runs before the first renderer request.
export const importLegacyStores = Effect.fn("DesktopStorage.importLegacyStores")(function* (
db: Database,
userData: string,
now = Date.now(),
) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const names = (yield* fs.readDirectory(userData).pipe(Effect.orElseSucceed(() => []))).filter(isStoreFile)
const files = yield* Effect.forEach(
names,
Effect.fnUntraced(function* (name) {
const file = path.join(userData, name)
const stats = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
if (stats?.type !== "File") return
const raw = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
if (raw === undefined) return
const entries = decode(raw)
if (Option.isNone(entries)) {
yield* Effect.logWarning("legacy store is not readable, leaving it in place", { name })
return
}
return {
name,
path: file,
modified: Option.getOrElse(stats.mtime, () => new Date(0)).getTime(),
entries: entries.value,
} satisfies Candidate
}),
{ concurrency: 5 },
)
const candidates = files.filter((file) => !!file)
const kept = new Set(
candidates
.filter((file) => isDraft(file.name) && Object.keys(file.entries).length > 0)
.filter((file) => now - file.modified <= DRAFT_RETENTION_MS)
.sort((a, b) => b.modified - a.modified)
.slice(0, DRAFT_KEEP_RECENT)
.map((file) => file.name),
)
const rows = candidates
.filter((file) => !isDraft(file.name) || kept.has(file.name))
.flatMap((file) =>
Object.entries(file.entries).map(([key, value]) => ({
name: file.name,
key,
value: typeof value === "string" ? value : JSON.stringify(value),
updated_at: file.modified,
})),
)
// Existing rows win: a file left behind by an interrupted import must not overwrite newer state.
if (rows.length > 0) {
db.transaction((tx) => {
rows.forEach((row) => tx.insert(state).values(row).onConflictDoNothing().run())
})
}
yield* Effect.forEach(candidates, (file) => fs.remove(file.path, { force: true }), {
concurrency: "unbounded",
discard: true,
})
return { imported: rows.length, removed: candidates.map((file) => file.name) }
})
function isStoreFile(name: string) {
return name === "default.dat" || /^opencode\..+\.dat$/.test(name)
}
function isDraft(name: string) {
return name.startsWith("opencode.draft.")
}
@@ -0,0 +1,17 @@
// Generated by scripts/migration.ts from src/main/storage/migration. Do not edit.
export const migrations = [
{
id: "20260906234153_drafts",
statements: [
"CREATE TABLE `blob` (\n\t`id` text PRIMARY KEY,\n\t`data` blob NOT NULL\n);",
"CREATE TABLE `document` (\n\t`key` text PRIMARY KEY,\n\t`value` text NOT NULL\n);",
],
},
{
id: "20260906234220_state",
statements: [
"CREATE TABLE `state` (\n\t`name` text NOT NULL,\n\t`key` text NOT NULL,\n\t`value` text NOT NULL,\n\t`updated_at` integer NOT NULL,\n\tCONSTRAINT `state_pk` PRIMARY KEY(`name`, `key`)\n);",
],
},
]
@@ -0,0 +1,9 @@
CREATE TABLE `blob` (
`id` text PRIMARY KEY,
`data` blob NOT NULL
);
--> statement-breakpoint
CREATE TABLE `document` (
`key` text PRIMARY KEY,
`value` text NOT NULL
);
@@ -0,0 +1,77 @@
{
"version": "7",
"dialect": "sqlite",
"id": "463be56f-5f29-4831-9d00-1b9c6bd957e3",
"prevIds": [
"00000000-0000-0000-0000-000000000000"
],
"ddl": [
{
"name": "blob",
"entityType": "tables"
},
{
"name": "document",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "blob"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "blob"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "document"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "document"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "blob_pk",
"table": "blob",
"entityType": "pks"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "document_pk",
"table": "document",
"entityType": "pks"
}
],
"renames": []
}
@@ -0,0 +1,7 @@
CREATE TABLE `state` (
`name` text NOT NULL,
`key` text NOT NULL,
`value` text NOT NULL,
`updated_at` integer NOT NULL,
CONSTRAINT `state_pk` PRIMARY KEY(`name`, `key`)
);
@@ -0,0 +1,131 @@
{
"version": "7",
"dialect": "sqlite",
"id": "5a2f8b7e-2765-4a0b-8877-a74ee2f29b20",
"prevIds": [
"463be56f-5f29-4831-9d00-1b9c6bd957e3"
],
"ddl": [
{
"name": "blob",
"entityType": "tables"
},
{
"name": "document",
"entityType": "tables"
},
{
"name": "state",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "blob"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "blob"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "document"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "document"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "state"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "state"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "state"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "state"
},
{
"columns": [
"name",
"key"
],
"nameExplicit": false,
"name": "state_pk",
"entityType": "pks",
"table": "state"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "blob_pk",
"table": "blob",
"entityType": "pks"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "document_pk",
"table": "document",
"entityType": "pks"
}
],
"renames": []
}
@@ -0,0 +1,27 @@
import { blob, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"
// Prompt drafts and history. `key` is `${storage}:${key}` as the renderer draft store writes it.
export const document = sqliteTable("document", {
key: text().primaryKey(),
value: text().notNull(),
})
// Images referenced from documents by content hash.
export const blobs = sqliteTable("blob", {
id: text().primaryKey(),
data: blob({ mode: "buffer" }).notNull(),
})
// Everything the renderer persists through `platform.storage(name)`. `name` is the storage
// namespace the app chooses (still spelled like the `.dat` file it used to be) and `key` the
// entry within it, so the app's relocation and alias logic keeps working unchanged.
export const state = sqliteTable(
"state",
{
name: text().notNull(),
key: text().notNull(),
value: text().notNull(),
updated_at: integer().notNull(),
},
(table) => [primaryKey({ columns: [table.name, table.key] })],
)
@@ -0,0 +1,111 @@
import { afterEach, describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { sql } from "drizzle-orm"
import { openDatabase } from "./database"
import { createStateStore } from "./state"
const roots: string[] = []
// Bun's node:sqlite shim keeps prepared statements alive after close(), which pins the WAL files
// on Windows. Node (and so Electron) finalizes them; tolerate the leftover here only.
afterEach(() =>
Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }).catch(() => undefined))),
)
const open = () => {
const database = openDatabase(":memory:")
return { db: database.db, store: createStateStore(database.db, { delay: 1_000 }) }
}
const rows = (db: ReturnType<typeof openDatabase>["db"]) =>
db.all<{ name: string; key: string; value: string }>(sql`SELECT name, key, value FROM state ORDER BY name, key`)
describe("state store", () => {
test("reads its own queued writes before they reach the database", () => {
const { db, store } = open()
store.set("global", "model", "a")
expect(store.get("global", "model")).toBe("a")
expect(rows(db)).toEqual([])
store.flush()
expect(rows(db)).toEqual([{ name: "global", key: "model", value: "a" }])
expect(store.get("global", "model")).toBe("a")
})
test("delete is visible immediately and removes the row on flush", () => {
const { db, store } = open()
store.set("global", "model", "a")
store.flush()
store.delete("global", "model")
expect(store.get("global", "model")).toBeNull()
expect(store.keys("global")).toEqual([])
store.flush()
expect(rows(db)).toEqual([])
})
test("keys and length merge stored rows with queued changes", () => {
const { store } = open()
store.set("w", "tabs", "[]")
store.set("w", "recent", "{}")
store.flush()
store.set("w", "info", "{}")
store.delete("w", "recent")
expect(store.keys("w").sort()).toEqual(["info", "tabs"])
expect(store.length("w")).toBe(2)
expect(store.keys("other")).toEqual([])
})
test("clear drops a namespace including queued writes and leaves others alone", () => {
const { db, store } = open()
store.set("w1", "tabs", "[]")
store.set("w2", "tabs", "[]")
store.flush()
store.set("w1", "recent", "{}")
store.clear("w1")
expect(store.get("w1", "recent")).toBeNull()
store.flush()
expect(rows(db)).toEqual([{ name: "w2", key: "tabs", value: "[]" }])
})
test("a failed flush keeps every acknowledged write until a later flush succeeds", () => {
const database = openDatabase(":memory:")
const errors: unknown[] = []
const store = createStateStore(database.db, { delay: 1_000, onError: (error) => errors.push(error) })
store.set("w", "tabs", "[1]")
store.set("w", "recent", "{}")
database.db.run(sql`DROP TABLE state`)
store.flush()
expect(errors).toHaveLength(1)
expect(store.get("w", "tabs")).toBe("[1]")
database.db.run(
sql`CREATE TABLE state (name TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (name, key))`,
)
store.set("w", "info", "{}")
store.flush()
expect(rows(database.db)).toEqual([
{ name: "w", key: "info", value: "{}" },
{ name: "w", key: "recent", value: "{}" },
{ name: "w", key: "tabs", value: "[1]" },
])
})
test("survives close and reopen on disk", async () => {
const root = await mkdtemp(path.join(tmpdir(), "opencode-state-"))
roots.push(root)
const file = path.join(root, "drafts.sqlite")
const first = openDatabase(file)
const store = createStateStore(first.db, { delay: 1_000 })
store.set("global", "model", "a")
store.close()
first.close()
const second = openDatabase(file)
expect(createStateStore(second.db).get("global", "model")).toBe("a")
second.close()
})
test("a burst of writes to one key lands as a single upsert", () => {
const { db, store } = open()
for (let index = 0; index < 100; index++) store.set("global", "layout", `${index}`)
store.flush()
expect(rows(db)).toEqual([{ name: "global", key: "layout", value: "99" }])
})
})
@@ -0,0 +1,78 @@
import { and, eq, sql } from "drizzle-orm"
import type { Database } from "./database"
import { state } from "./schema"
import { createWriteBehind } from "./write-behind"
export type StateStore = ReturnType<typeof createStateStore>
type Row = { name: string; key: string; value: string | null }
// Reads hit SQLite directly: they happen at mount time and a point lookup on the primary key
// costs microseconds, so a second in-memory copy would only duplicate the renderer's cache.
export function createStateStore(db: Database, input: { delay?: number; onError?: (error: unknown) => void } = {}) {
// Prepared once; the flush loop then only binds values instead of rebuilding SQL per row.
const byKey = and(eq(state.name, sql.placeholder("name")), eq(state.key, sql.placeholder("key")))
const read = db.select({ value: state.value }).from(state).where(byKey).prepare()
const remove = db.delete(state).where(byKey).prepare()
const upsert = db
.insert(state)
.values({
name: sql.placeholder("name"),
key: sql.placeholder("key"),
value: sql.placeholder("value"),
updated_at: sql.placeholder("updated_at"),
})
.onConflictDoUpdate({
target: [state.name, state.key],
set: { value: sql.placeholder("value"), updated_at: sql.placeholder("updated_at") },
})
.prepare()
const writer = createWriteBehind<Row>({
delay: input.delay ?? 250,
onError: input.onError,
write: (batch) =>
db.transaction(() => {
const updated_at = Date.now()
for (const row of batch.values()) {
if (row.value === null) remove.run({ name: row.name, key: row.key })
else upsert.run({ name: row.name, key: row.key, value: row.value, updated_at })
}
}),
})
const id = (name: string, key: string) => `${name}\0${key}`
const keys = (name: string) => {
const result = new Set(
db
.select({ key: state.key })
.from(state)
.where(eq(state.name, name))
.all()
.map((row) => row.key),
)
for (const row of writer.entries()) {
if (row.name !== name) continue
if (row.value === null) result.delete(row.key)
else result.add(row.key)
}
return [...result]
}
return {
get(name: string, key: string) {
const queued = writer.get(id(name, key))
if (queued) return queued.value
return read.get({ name, key })?.value ?? null
},
set: (name: string, key: string, value: string) => writer.set(id(name, key), { name, key, value }),
delete: (name: string, key: string) => writer.set(id(name, key), { name, key, value: null }),
keys,
length: (name: string) => keys(name).length,
// Rare (window closed for good, explicit clear) so it goes straight to the database.
clear(name: string) {
writer.drop((row) => row.name === name)
db.delete(state).where(eq(state.name, name)).run()
},
flush: writer.flush,
close: writer.close,
}
}
+5 -10
View File
@@ -1,12 +1,15 @@
import Store from "electron-store"
import electron from "electron"
import { Effect } from "effect"
import { deleteStoreFileIfEmpty } from "./cleanup"
import { SETTINGS_STORE } from "./keys"
const cache = new Map<string, Store>()
// Main-process settings only (onboarding, default server, window list, appearance, updater).
// These are read synchronously before the storage layer exists and written on user action, so
// electron-store's synchronous file write is acceptable here. Renderer state goes through
// DesktopStorage instead.
//
// We cannot instantiate the electron-store at module load time because
// module import hoisting causes this to run before app.setPath("userData", ...)
// in index.ts has executed, which would result in files being written to the default directory
@@ -23,11 +26,3 @@ export function getStore(name = SETTINGS_STORE) {
cache.set(name, next)
return next
}
export const removeStoreFileIfEmpty = Effect.fn("DesktopStorage.removeStoreFileIfEmpty")(function* (name: string) {
if (yield* deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name)
})
export function forgetStore(name: string) {
cache.delete(name)
}
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import { createWriteBehind } from "./write-behind"
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
describe("write-behind", () => {
test("coalesces a burst into one batch with the latest value per key", async () => {
const batches: Map<string, number>[] = []
const writer = createWriteBehind<number>({ delay: 10, write: (batch) => batches.push(batch) })
writer.set("a", 1)
writer.set("b", 1)
writer.set("a", 2)
expect(writer.get("a")).toBe(2)
expect(batches).toHaveLength(0)
await wait(30)
expect(batches).toHaveLength(1)
expect([...batches[0]!]).toEqual([
["a", 2],
["b", 1],
])
expect(writer.has("a")).toBe(false)
})
test("flush writes immediately and close stops accepting writes", () => {
const batches: Map<string, number>[] = []
const writer = createWriteBehind<number>({ delay: 1_000, write: (batch) => batches.push(batch) })
writer.set("a", 1)
writer.flush()
expect(batches).toHaveLength(1)
writer.set("b", 2)
writer.close()
expect(batches).toHaveLength(2)
writer.set("c", 3)
writer.flush()
expect(batches).toHaveLength(2)
})
test("drop removes queued entries matching a predicate", () => {
const batches: Map<string, number>[] = []
const writer = createWriteBehind<number>({ delay: 1_000, write: (batch) => batches.push(batch) })
writer.set("a", 1)
writer.set("b", 2)
writer.drop((value) => value === 1)
writer.flush()
expect([...batches[0]!]).toEqual([["b", 2]])
})
test("keeps a failed batch queued and retries it on the next flush", () => {
const errors: unknown[] = []
let fail = true
const batches: Map<string, number>[] = []
const writer = createWriteBehind<number>({
delay: 1_000,
onError: (error) => errors.push(error),
write: (batch) => {
if (fail) throw new Error("disk full")
batches.push(batch)
},
})
writer.set("a", 1)
writer.set("b", 1)
writer.flush()
expect(errors).toHaveLength(1)
expect(writer.get("a")).toBe(1)
fail = false
writer.set("a", 2)
writer.flush()
expect([...batches[0]!].sort()).toEqual([
["a", 2],
["b", 1],
])
})
})
@@ -0,0 +1,47 @@
// Coalesces a burst of writes into one batch. The latest value per key wins, so a store that
// re-serializes on every mutation costs one row write per flush instead of one per change.
export function createWriteBehind<T>(input: {
delay: number
write: (batch: Map<string, T>) => void
onError?: (error: unknown) => void
}) {
const pending = new Map<string, T>()
let timer: ReturnType<typeof setTimeout> | undefined
let closed = false
const flush = () => {
clearTimeout(timer)
timer = undefined
if (closed || pending.size === 0) return
const batch = new Map(pending)
pending.clear()
try {
input.write(batch)
} catch (error) {
// The renderer already saw these writes succeed. Keep them queued so the next flush retries
// them; anything written for the same key since then takes precedence.
for (const [key, value] of batch) if (!pending.has(key)) pending.set(key, value)
if (!input.onError) throw error
input.onError(error)
}
}
return {
get: (key: string) => pending.get(key),
has: (key: string) => pending.has(key),
entries: () => pending.values(),
set(key: string, value: T) {
if (closed) return
pending.set(key, value)
timer ??= setTimeout(flush, input.delay)
},
drop(predicate: (value: T) => boolean) {
for (const [key, value] of pending) if (predicate(value)) pending.delete(key)
},
flush,
close() {
flush()
closed = true
},
}
}
+7 -6
View File
@@ -5,7 +5,8 @@ import { Effect, FileSystem, Path } from "effect"
import { openExternalURL } from "../files"
import { scoped } from "../native/logging"
import { DesktopPaths } from "../paths"
import { forgetStore, getStore } from "../storage/store"
import { DesktopStorage } from "../storage"
import { getStore } from "../storage/store"
import { WINDOW_IDS_KEY } from "../storage/keys"
import { windowIDArgument } from "../../shared/window-bootstrap"
import {
@@ -77,6 +78,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const paths = yield* DesktopPaths.resolve
const storage = yield* DesktopStorage.Service
const runFork = Effect.runForkWith(yield* Effect.context())
const wireWindowRecovery = yield* makeWindowRecovery
@@ -141,14 +143,12 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
win.on("session-end", () => registry.setQuitting())
win.on("closed", () => {
if (!registry.closed(id)) return
const data = windowDataFile(id)
runFork(
Effect.gen(function* () {
yield* Effect.try(() => storage.state.clear(windowDataFile(id)))
yield* fs.remove(path.join(app.getPath("userData"), windowStateFile(id)), { force: true })
yield* fs.remove(path.join(app.getPath("userData"), data), { force: true })
}).pipe(
Effect.tap(() => Effect.sync(() => forgetStore(data))),
Effect.catch((error) => scoped("window", Effect.logError("failed to clean window files", { id, error }))),
Effect.catch((error) => scoped("window", Effect.logError("failed to clean window state", { id, error }))),
),
)
})
@@ -161,7 +161,8 @@ function windowStateFile(id: string) {
return `window-state-${safeWindowID(id)}.json`
}
// Mirrors windowStorage() in packages/app/src/utils/persist.ts.
// Mirrors windowStorage() in packages/app/src/runtime/persistence/storage.ts; it is the state
// namespace the renderer persists this window's tabs under.
function windowDataFile(id: string) {
return `opencode.window.${safeWindowID(id)}.dat`
}
+5 -13
View File
@@ -18,25 +18,18 @@ export interface SessionPrompt {
delivery: SessionInbox.Delivery
}
/** Request overrides. Typed keys are generation settings; any other key is a provider option. */
export type SessionRequestOptions = Types.DeepMutable<GenerationOptionsFields> & Record<string, unknown>
export interface SessionRequest {
export interface SessionContext {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
system: Array<SystemPart>
messages: Array<Message>
options: SessionRequestOptions
}
export interface SessionContext extends SessionRequest {
readonly agent: Agent.ID
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
/** Request overrides; unset fields retain route and model defaults. */
generation: Types.DeepMutable<GenerationOptionsFields>
providerOptions: Record<string, unknown>
}
/** Title generation is not an agent conversation and exposes no agent or tools. */
export interface SessionTitle extends SessionRequest {}
/**
* Why a Session request is being made. Auxiliary requests share the Session's
* hook identity but need to be told apart from the agent loop.
@@ -83,7 +76,6 @@ export interface SessionRetry {
export interface SessionHooks {
readonly prompt: SessionPrompt
readonly context: SessionContext
readonly title: SessionTitle
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
+5 -13
View File
@@ -18,25 +18,18 @@ export interface SessionPrompt {
delivery: SessionInbox.Delivery
}
/** Request overrides. Typed keys are generation settings; any other key is a provider option. */
export type SessionRequestOptions = Types.DeepMutable<GenerationOptionsFields> & Record<string, unknown>
export interface SessionRequest {
export interface SessionContext {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
system: Array<SystemPart>
messages: Array<Message>
options: SessionRequestOptions
}
export interface SessionContext extends SessionRequest {
readonly agent: Agent.ID
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
/** Request overrides; unset fields retain route and model defaults. */
generation: Types.DeepMutable<GenerationOptionsFields>
providerOptions: Record<string, unknown>
}
/** Title generation is not an agent conversation and exposes no agent or tools. */
export interface SessionTitle extends SessionRequest {}
/**
* Why a Session request is being made. Auxiliary requests share the Session's
* hook identity but need to be told apart from the agent loop.
@@ -83,7 +76,6 @@ export interface SessionRetry {
export interface SessionHooks {
readonly prompt: SessionPrompt
readonly context: SessionContext
readonly title: SessionTitle
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
+6 -2
View File
@@ -7407,7 +7407,7 @@
}
}
},
"description": "Update project display metadata and workspace commands.",
"description": "Update the project canonical directory, display metadata, and workspace commands.",
"summary": "Update project",
"requestBody": {
"content": {
@@ -7415,6 +7415,9 @@
"schema": {
"type": "object",
"properties": {
"canonical": {
"type": "string"
},
"name": {
"type": "string"
},
@@ -14589,7 +14592,8 @@
"anyOf": [
{
"type": "string",
"enum": ["random"]
"enum": ["random"],
"description": "Reuse a randomly selected provider until it is rate limited, then switch to another available provider."
},
{
"type": "string"
+1 -1
View File
@@ -29,7 +29,7 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
OpenApi.annotations({
identifier: "v2.project.update",
summary: "Update project",
description: "Update project display metadata and workspace commands.",
description: "Update the project canonical directory, display metadata, and workspace commands.",
}),
),
)
+7 -1
View File
@@ -4,7 +4,13 @@ import { Schema } from "effect"
import { WebSearch } from "../websearch.js"
export class Info extends Schema.Class<Info>("ConfigWebSearch.Info")({
provider: Schema.Union([Schema.Literal("random"), WebSearch.ID]),
provider: Schema.Union([
Schema.Literal("random").annotate({
description:
"Reuse a randomly selected provider until it is rate limited, then switch to another available provider.",
}),
WebSearch.ID,
]),
}) {}
export const Selection = Schema.Union([Schema.Literal(false), Info])
+1
View File
@@ -50,6 +50,7 @@ export interface Info extends Schema.Schema.Type<typeof Info> {}
export const UpdateInput = Schema.Struct({
projectID: ID,
canonical: optional(AbsolutePath),
name: optional(Schema.String),
icon: optional(Icon),
commands: optional(Commands),
+1 -1
View File
@@ -69,7 +69,7 @@ it.live(
)
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
event.options.temperature = 0.25
event.generation.temperature = 0.25
}),
)
yield* ctx.tool.transform((editor) =>
@@ -94,7 +94,7 @@ it.live(
)
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
event.options.temperature = config.temperature
event.generation.temperature = config.temperature
}),
)
yield* ctx.permission.hook("evaluate", (event) =>
@@ -0,0 +1,212 @@
import {
CliRenderer,
RGBA,
RootTextNodeRenderable,
TextNodeRenderable,
type Renderable,
type RenderContext,
type TextNodeOptions,
} from "@opentui/core"
import { extend } from "@opentui/solid"
import { createEffect, onCleanup } from "solid-js"
import { smootherstep } from "./tab-pulse"
import { stringWidth } from "../util/string-width"
import { webSearchProviderName } from "../util/tool-display"
type Value = { id: string; provider: string; running: boolean }
type Options = TextNodeOptions & { value?: Value; enabled?: boolean }
const FADE = 80
const RESIZE = 60
const DURATION = FADE * 2 + RESIZE
/** An inline span: keep native wrapping, selection, and the surrounding tool's styles. */
export class RetryProviderRenderable extends TextNodeRenderable {
private readonly ctx: CliRenderer
private current?: Value
private enabledValue: boolean
private painted = false
private disposed = false
private elapsed: number | undefined
private fresh = false
private source = ""
private target = ""
private displayed = ""
private opacity = 1
private fromOpacity = 1
constructor(ctx: RenderContext, options: Options) {
super(options)
if (!(ctx instanceof CliRenderer)) throw new Error("RetryProvider requires a renderer frame clock")
this.ctx = ctx
this.enabledValue = options.enabled ?? true
if (options.value) this.value = options.value
}
private markPainted = () => {
if (!this.onScreen()) return
this.painted = true
this.ctx.off("frame", this.markPainted)
}
private onScreen() {
let node: TextNodeRenderable | null = this
while (node && !(node instanceof RootTextNodeRenderable)) {
if (!node.visible) return false
node = node.parent
}
if (!node) return false
const text = node.textParent
if (
text.width <= 0 ||
text.height <= 0 ||
text.screenX < 0 ||
text.screenY < 0 ||
text.screenX + text.width > this.ctx.width ||
text.screenY + text.height > this.ctx.height
)
return false
// Be conservative with clipped rows: do not start a transition as history enters the viewport.
for (let parent: Renderable | null = text; parent; parent = parent.parent) {
if (!parent.visible) return false
if (parent.overflow === "visible") continue
if (
text.screenX < parent.screenX ||
text.screenY < parent.screenY ||
text.screenX + text.width > parent.screenX + parent.width ||
text.screenY + text.height > parent.screenY + parent.height
)
return false
}
return true
}
set value(value: Value) {
if (this.disposed) return
const previous = this.current
this.current = value
if (!value.running) this.ctx.off("frame", this.markPainted)
if (previous?.id === value.id && previous.provider === value.provider) return
this.target = webSearchProviderName(value.provider)
const retry =
this.enabledValue &&
this.painted &&
previous?.id === value.id &&
previous.running &&
value.running &&
this.onScreen()
if (!retry) {
this.finish()
this.painted = false
this.ctx.off("frame", this.markPainted)
if (value.running) this.ctx.on("frame", this.markPainted)
return
}
// During fade-out, replace the destination without restarting or queuing transitions.
if (this.elapsed !== undefined && this.elapsed < FADE) return
this.source = this.displayed
this.fromOpacity = this.opacity
const start = this.elapsed === undefined
this.elapsed = 0
this.fresh = true
if (start) {
this.ctx.setFrameCallback(this.tick)
this.ctx.requestLive()
}
}
set enabled(value: boolean) {
if (value === this.enabledValue) return
this.enabledValue = value
if (!value) this.finish()
}
private tick = async (delta: number) => {
if (this.elapsed === undefined) return
if (!this.onScreen()) {
this.finish()
this.painted = false
if (this.current?.running) this.ctx.on("frame", this.markPainted)
return
}
// Ignore time the renderer spent idle before this transition started.
this.elapsed += this.fresh ? 0 : delta
this.fresh = false
if (this.elapsed >= DURATION) return this.finish()
if (this.elapsed < FADE) {
return this.show(this.source, this.fromOpacity * (1 - smootherstep(this.elapsed / FADE)))
}
if (this.elapsed < FADE + RESIZE) {
// Move the query only while the name is invisible; never reveal partial provider glyphs.
const from = stringWidth(this.source)
const to = stringWidth(this.target)
return this.show(" ".repeat(Math.round(from + (to - from) * smootherstep((this.elapsed - FADE) / RESIZE))), 0)
}
this.show(this.target, smootherstep((this.elapsed - FADE - RESIZE) / FADE))
}
private show(text: string, opacity: number) {
if (text === this.displayed && opacity === this.opacity) return
this.displayed = text
this.opacity = opacity
this.children = [text]
}
private stop() {
if (this.elapsed !== undefined) {
this.ctx.removeFrameCallback(this.tick)
this.ctx.dropLive()
}
this.elapsed = undefined
}
private finish() {
this.stop()
this.show(this.target, 1)
}
override gatherWithInheritedStyle(style?: Parameters<TextNodeRenderable["gatherWithInheritedStyle"]>[0]) {
const chunks = super.gatherWithInheritedStyle(style)
if (this.opacity === 1) return chunks
return chunks.map((chunk) => {
const fg = RGBA.clone(chunk.fg ?? RGBA.defaultForeground())
fg.a *= this.opacity
return { ...chunk, fg }
})
}
override destroy() {
if (this.disposed) return
this.disposed = true
this.ctx.off("frame", this.markPainted)
this.stop()
super.destroy()
}
override destroyRecursively() {
this.destroy()
}
}
extend({ retry_provider: RetryProviderRenderable })
export function RetryProvider(props: { value: Value; enabled: boolean }) {
// Solid's text-node reconciler only applies inline styles; control the custom span through its ref.
return (
<retry_provider
ref={(node) => {
onCleanup(() => node.destroy())
createEffect(() => {
node.enabled = props.enabled
node.value = props.value
})
}}
/>
)
}
declare module "@opentui/solid" {
interface OpenTUIComponents {
retry_provider: typeof RetryProviderRenderable
}
}
+19 -2
View File
@@ -47,8 +47,8 @@ import {
primitiveInputSummary,
toolDisplayContent,
toolDisplayMetadata,
webSearchProviderLabel,
} from "../../util/tool-display"
import { RetryProvider } from "../../component/retry-provider"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useClient } from "../../context/client"
import { useEditorContext } from "../../context/editor"
@@ -3374,9 +3374,26 @@ function WebFetch(props: ToolProps) {
}
function WebSearch(props: ToolProps) {
const ctx = use()
const provider = createMemo(() => stringValue(props.metadata.provider))
return (
<InlineTool icon="◈" pending="Searching web…" complete={stringValue(props.input.query)} part={props.part}>
{webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"
<Show when={provider()} fallback="Web Search">
{(value) => (
<>
Web Search via{" "}
<RetryProvider
value={{
id: `${ctx.sessionID}:${props.part.time.created}:${props.part.id}`,
provider: value(),
running: props.part.state.status === "running",
}}
enabled={ctx.config.animations ?? true}
/>
</>
)}
</Show>{" "}
"{stringValue(props.input.query)}"
</InlineTool>
)
}
+7 -2
View File
@@ -19,9 +19,14 @@ export function primitiveInputSummary(input: Record<string, unknown>, omit: read
return `[${entries.map(([key, value]) => `${key}=${String(value)}`).join(", ")}]`
}
export function webSearchProviderName(provider: unknown) {
if (typeof provider !== "string" || !provider) return ""
return `${provider[0].toUpperCase()}${provider.slice(1)}`
}
export function webSearchProviderLabel(provider: unknown) {
if (typeof provider !== "string" || !provider) return "Web Search"
return `Web Search via ${provider[0].toUpperCase()}${provider.slice(1)}`
const name = webSearchProviderName(provider)
return name ? `Web Search via ${name}` : "Web Search"
}
export function toolDisplayMetadata(state: unknown): Record<string, unknown> {
@@ -0,0 +1,280 @@
import { expect, test } from "bun:test"
import { BoxRenderable, RGBA, TextAttributes, TextRenderable } from "@opentui/core"
import { createTestRenderer, ManualClock } from "@opentui/core/testing"
import { testRender } from "@opentui/solid"
import { createSignal, Show } from "solid-js"
import { RetryProvider, RetryProviderRenderable } from "../../src/component/retry-provider"
async function fixture() {
const clock = new ManualClock()
const app = await createTestRenderer({ width: 60, height: 2, useThread: false, clock })
app.renderer.pause()
const text = new TextRenderable(app.renderer, { fg: "#eeeeee", bg: "#111111", attributes: TextAttributes.BOLD })
const provider = new RetryProviderRenderable(app.renderer, {
value: { id: "call-1", provider: "exa", running: true },
})
text.add("Web Search via ")
text.add(provider)
text.add(' "query"')
app.renderer.root.add(text)
const renderOnce = async () => {
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
await app.renderOnce()
}
return {
...app,
clock,
text,
provider,
renderOnce,
step: async (millis: number) => {
clock.setTime(clock.now() + millis)
await renderOnce()
},
[Symbol.dispose]: () => {
provider.destroy()
app.renderer.destroy()
},
}
}
test("only an already-painted running call starts a provider transition", async () => {
using app = await fixture()
// Updates before the first paint are not a visible retry.
app.provider.value = { id: "call-1", provider: "parallel", running: true }
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe('Web Search via Parallel "query"')
expect(app.renderer.liveRequestCount).toBe(0)
app.provider.value = { id: "call-1", provider: "parallel", running: true }
app.provider.value = { id: "call-1", provider: "parallel", running: false }
app.provider.value = { id: "call-1", provider: "tavily", running: false }
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe('Web Search via Tavily "query"')
expect(app.renderer.liveRequestCount).toBe(0)
// A new invocation/history view must not animate from the previous call's label.
app.provider.value = { id: "call-2", provider: "exa", running: true }
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe('Web Search via Exa "query"')
expect(app.renderer.liveRequestCount).toBe(0)
})
test("fades only the provider, changes width while invisible, and settles without a timer", async () => {
using app = await fixture()
await app.renderOnce()
const initial = app.captureSpans()
app.clock.setTime(2000)
app.provider.value = { id: "call-1", provider: "parallel", running: true }
await app.renderOnce()
expect(app.captureSpans()).toEqual(initial)
expect(app.renderer.liveRequestCount).toBe(1)
await app.step(40)
const middle = app.captureSpans().lines[0].spans
expect(middle.find((span) => span.text.includes("Exa"))?.fg.toInts()).not.toEqual(
initial.lines[0].spans[0].fg.toInts(),
)
expect(middle.find((span) => span.text.includes("Web Search"))?.fg.toInts()).toEqual(
initial.lines[0].spans[0].fg.toInts(),
)
expect(middle.find((span) => span.text.includes("query"))?.fg.toInts()).toEqual(initial.lines[0].spans[0].fg.toInts())
expect(
middle.filter((span) => span.text.trim()).every((span) => Boolean(span.attributes & TextAttributes.BOLD)),
).toBe(true)
await app.step(40)
const before = app.captureCharFrame().indexOf('"query"')
await app.step(30)
const moving = app.captureCharFrame().indexOf('"query"')
expect(moving).toBeGreaterThan(before)
expect(moving).toBeLessThan("Web Search via Parallel ".length)
await app.step(30)
await app.step(80)
expect(app.captureCharFrame().trim()).toBe('Web Search via Parallel "query"')
expect(app.captureSpans().lines[0].spans[0].fg.toInts()).toEqual(initial.lines[0].spans[0].fg.toInts())
expect(app.renderer.liveRequestCount).toBe(0)
})
test("coalesces rapid fallbacks and does not restart on completion", async () => {
using app = await fixture()
await app.renderOnce()
app.provider.value = { id: "call-1", provider: "parallel", running: true }
await app.renderOnce()
await app.step(40)
app.provider.value = { id: "call-1", provider: "firecrawl", running: true }
app.provider.value = { id: "call-1", provider: "firecrawl", running: false }
await app.step(180)
expect(app.captureCharFrame().trim()).toBe('Web Search via Firecrawl "query"')
expect(app.renderer.liveRequestCount).toBe(0)
})
test("retargets a fading-in provider without flashing back to full brightness", async () => {
using app = await fixture()
await app.renderOnce()
app.provider.value = { id: "call-1", provider: "parallel", running: true }
await app.renderOnce()
await app.step(180)
const middle = app.captureSpans()
app.provider.value = { id: "call-1", provider: "tavily", running: true }
await app.renderOnce()
expect(app.captureSpans()).toEqual(middle)
await app.step(220)
expect(app.captureCharFrame().trim()).toBe('Web Search via Tavily "query"')
expect(app.renderer.liveRequestCount).toBe(0)
})
test("disabling animations settles immediately and recursive destruction releases active work", async () => {
using app = await fixture()
await app.renderOnce()
app.provider.value = { id: "call-1", provider: "parallel", running: true }
await app.renderOnce()
app.provider.enabled = false
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe('Web Search via Parallel "query"')
expect(app.renderer.liveRequestCount).toBe(0)
app.provider.value = { id: "call-1", provider: "exa", running: true }
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe('Web Search via Exa "query"')
expect(app.renderer.liveRequestCount).toBe(0)
app.provider.enabled = true
app.provider.value = { id: "call-1", provider: "tavily", running: true }
expect(app.renderer.liveRequestCount).toBe(1)
app.text.remove(app.provider)
app.provider.destroyRecursively()
expect(app.renderer.liveRequestCount).toBe(0)
})
test("a hidden first paint does not arm the transition", async () => {
using app = await fixture()
app.text.visible = false
await app.renderOnce()
app.provider.value = { id: "call-1", provider: "parallel", running: true }
await app.renderOnce()
expect(app.renderer.liveRequestCount).toBe(0)
app.text.visible = true
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe('Web Search via Parallel "query"')
expect(app.renderer.liveRequestCount).toBe(0)
app.provider.value = { id: "call-1", provider: "tavily", running: true }
expect(app.renderer.liveRequestCount).toBe(1)
})
test("offscreen and ancestor-clipped rows show the latest provider on entry, without animation", async () => {
using app = await fixture()
app.text.top = 4
await app.renderOnce()
app.provider.value = { id: "call-1", provider: "parallel", running: true }
await app.renderOnce()
expect(app.renderer.liveRequestCount).toBe(0)
app.text.top = 0
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe('Web Search via Parallel "query"')
const clip = new BoxRenderable(app.renderer, { width: 50, height: 1, overflow: "hidden" })
app.renderer.root.remove(app.text)
clip.add(app.text)
app.renderer.root.add(clip)
app.text.top = 1
await app.renderOnce()
app.provider.value = { id: "call-1", provider: "tavily", running: true }
await app.renderOnce()
expect(app.renderer.liveRequestCount).toBe(0)
app.text.top = 0
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe('Web Search via Tavily "query"')
expect(app.renderer.liveRequestCount).toBe(0)
})
test("a transition settles when hidden and waits for a new paint before animating again", async () => {
using app = await fixture()
await app.renderOnce()
app.provider.value = { id: "call-1", provider: "parallel", running: true }
await app.renderOnce()
await app.step(40)
app.text.visible = false
await app.step(40)
expect(app.renderer.liveRequestCount).toBe(0)
expect(app.captureCharFrame().trim()).toBe("")
app.text.visible = true
app.provider.value = { id: "call-1", provider: "tavily", running: true }
expect(app.renderer.liveRequestCount).toBe(0)
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe('Web Search via Tavily "query"')
app.provider.value = { id: "call-1", provider: "exa", running: true }
expect(app.renderer.liveRequestCount).toBe(1)
app.text.top = 4
await app.step(40)
expect(app.renderer.liveRequestCount).toBe(0)
app.text.top = 0
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe('Web Search via Exa "query"')
expect(app.renderer.liveRequestCount).toBe(0)
})
test("Solid unmount releases a running transition and its frame listener", async () => {
const clock = new ManualClock()
const [visible, setVisible] = createSignal(true)
const [provider, setProvider] = createSignal("exa")
const app = await testRender(
() => (
<Show when={visible()}>
<text fg="#eeeeee">
Web Search via <RetryProvider value={{ id: "call", provider: provider(), running: true }} enabled={true} />
</text>
</Show>
),
{ width: 60, height: 2, useThread: false, clock },
)
try {
app.renderer.pause()
await app.renderOnce()
setProvider("parallel")
await app.waitFor(() => app.renderer.liveRequestCount === 1)
setVisible(false)
await app.waitFor(() => app.renderer.liveRequestCount === 0)
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe("")
expect(app.renderer.listenerCount("frame")).toBe(0)
} finally {
app.renderer.destroy()
}
})
test("the Solid inline span inherits colors and wraps like ordinary text after a Unicode retry", async () => {
const clock = new ManualClock()
const [provider, setProvider] = createSignal("firecrawl")
const [running, setRunning] = createSignal(true)
const app = await testRender(
() => (
<box width={24}>
<text fg="#222222" bg="#eeeeee">
Web Search via{" "}
<RetryProvider value={{ id: "call", provider: provider(), running: running() }} enabled={true} /> "a longer
query"
</text>
<text fg="#222222" bg="#eeeeee">
Web Search via "a longer query"
</text>
</box>
),
{ width: 24, height: 6, useThread: false, clock },
)
try {
app.renderer.pause()
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Firecrawl")
setProvider("日本語")
await app.renderOnce()
clock.setTime(220)
await app.renderOnce()
setRunning(false)
await app.renderOnce()
const lines = app
.captureCharFrame()
.trimEnd()
.split("\n")
.map((line) => line.trimEnd())
expect(lines.slice(0, 2)).toEqual(lines.slice(2, 4))
expect(app.captureCharFrame()).not.toContain("Firecrawl")
expect(app.captureSpans().lines[0].spans[0].fg.equals(RGBA.fromHex("#222222"))).toBe(true)
expect(app.renderer.liveRequestCount).toBe(0)
} finally {
app.renderer.destroy()
}
})
+98 -2
View File
@@ -1,8 +1,18 @@
import { Effect, FileSystem, Formatter, Logger, type LogLevel } from "effect"
import { Effect, FileSystem, Formatter, Logger, Option, Schedule, Stream, type LogLevel } from "effect"
import path from "path"
import { Global } from "../global.js"
import { runID } from "./shared.js"
// One log file is shared by every opencode process on the machine and only ever appended to, so it
// is bounded by compacting in place instead of rotating: once it passes LOG_MAX_BYTES the head is
// dropped so roughly LOG_KEEP_BYTES remain, rounded forward to the next line boundary.
export const LOG_MAX_BYTES = 50 * 1024 * 1024
export const LOG_KEEP_BYTES = 25 * 1024 * 1024
export const LOG_TRIM_INTERVAL = "1 hour"
// A trim of a 1 GB log takes well under a second, so a lock older than this belongs to a dead process.
export const LOG_TRIM_LOCK_STALE_MS = 5 * 60 * 1000
const LOG_TRIM_CHUNK = 64 * 1024
function formatter(id: string = runID()) {
return Logger.map(Logger.formatStructured, (output) => {
const messages = Array.isArray(output.message) ? output.message : [output.message]
@@ -56,7 +66,93 @@ export function fileLogger(target = file(), id: string = runID()) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(path.dirname(target), { recursive: true })
return yield* Logger.toFile(formatter(id), target, { flag: "a" })
const logger = yield* Logger.toFile(formatter(id), target, { flag: "a" })
yield* trim(target).pipe(
Effect.ignore,
Effect.repeat(Schedule.spaced(LOG_TRIM_INTERVAL)),
Effect.forkScoped({ startImmediately: true }),
)
return logger
})
}
// Compacts in place rather than writing a temp file and renaming over the log. Other processes hold
// the same file open with O_APPEND, so they keep appending to the compacted file, whereas a rename
// would strand them on the unlinked inode and lose their output.
//
// Appenders are not coordinated with the final truncate, so a batch flushed by another process
// between the last read and the truncate is lost. That window is a few milliseconds once per trim,
// which is an accepted trade for not wrapping every log write in a cross-process lock.
export const trim = Effect.fn("Logging.trim")(function* (
target: string,
options: { max?: number; keep?: number } = {},
) {
const max = options.max ?? LOG_MAX_BYTES
const keep = options.keep ?? LOG_KEEP_BYTES
const fs = yield* FileSystem.FileSystem
// Every opencode process on the machine runs this against the same file. Two trimmers racing
// would have one compute its cut from a size the other already shrank, so only one may proceed
// and the rest skip until the next interval. mkdir is the atomic primitive on every platform.
// acquireRelease keeps the mkdir uninterruptible, so a scope closing mid-call cannot leave a lock
// on disk with no finalizer registered to remove it.
const lock = `${target}.trim`
const acquired = yield* Effect.acquireRelease(
fs.makeDirectory(lock).pipe(
Effect.as(true),
Effect.catchIf(
(error) => error.reason._tag === "AlreadyExists",
() => breakStaleLock(fs, lock),
),
),
(acquired) => (acquired ? fs.remove(lock, { recursive: true }).pipe(Effect.ignore) : Effect.void),
)
if (!acquired) return
const size = Number((yield* fs.stat(target)).size)
if (size <= max) return
const handle = yield* fs.open(target, { flag: "r+" })
const start = yield* lineStart(handle, size - keep)
yield* handle.seek(0, "start")
// Reads run to the current EOF so lines appended since the stat survive; the write cursor always
// trails the read cursor so the forward copy never overwrites unread bytes.
const written = yield* fs.stream(target, { offset: start, chunkSize: LOG_TRIM_CHUNK }).pipe(
Stream.runFoldEffect(
() => 0,
(total, chunk) => handle.writeAll(chunk).pipe(Effect.as(total + chunk.length)),
),
)
yield* handle.truncate(written)
}, Effect.scoped)
// Removes a lock left by a process that died mid-trim. Still yields this round: the next interval
// acquires cleanly, and a process that is legitimately trimming right now keeps its lock.
//
// Two processes that both observe the same stale lock can race here: one removes it, a third
// acquires fresh, and the other removes that fresh lock. That needs a crash inside a sub-second trim
// followed by three processes ticking within the same few milliseconds, and the consequence is the
// same bounded tail loss documented on `trim`. Not worth a breaker protocol; see EffectFlock if it is.
function breakStaleLock(fs: FileSystem.FileSystem, lock: string) {
return Effect.gen(function* () {
const info = yield* fs.stat(lock).pipe(Effect.option)
const modified = Option.flatMap(info, (value) => value.mtime)
if (Option.isNone(modified)) return false
if (Date.now() - modified.value.getTime() < LOG_TRIM_LOCK_STALE_MS) return false
yield* fs.remove(lock, { recursive: true }).pipe(Effect.ignore)
return false
})
}
// First byte after the first newline at or beyond `from`, or EOF when the tail has no newline.
function lineStart(handle: FileSystem.File, from: number) {
return Effect.gen(function* () {
let cursor = from
while (true) {
yield* handle.seek(cursor, "start")
const chunk = yield* handle.readAlloc(LOG_TRIM_CHUNK)
if (Option.isNone(chunk)) return cursor
const newline = chunk.value.indexOf(10)
if (newline !== -1) return cursor + newline + 1
cursor += chunk.value.length
}
})
}
+6 -2
View File
@@ -7407,7 +7407,7 @@
}
}
},
"description": "Update project display metadata and workspace commands.",
"description": "Update the project canonical directory, display metadata, and workspace commands.",
"summary": "Update project",
"requestBody": {
"content": {
@@ -7415,6 +7415,9 @@
"schema": {
"type": "object",
"properties": {
"canonical": {
"type": "string"
},
"name": {
"type": "string"
},
@@ -14589,7 +14592,8 @@
"anyOf": [
{
"type": "string",
"enum": ["random"]
"enum": ["random"],
"description": "Reuse a randomly selected provider until it is rate limited, then switch to another available provider."
},
{
"type": "string"
+6 -2
View File
@@ -7407,7 +7407,7 @@
}
}
},
"description": "Update project display metadata and workspace commands.",
"description": "Update the project canonical directory, display metadata, and workspace commands.",
"summary": "Update project",
"requestBody": {
"content": {
@@ -7415,6 +7415,9 @@
"schema": {
"type": "object",
"properties": {
"canonical": {
"type": "string"
},
"name": {
"type": "string"
},
@@ -14589,7 +14592,8 @@
"anyOf": [
{
"type": "string",
"enum": ["random"]
"enum": ["random"],
"description": "Reuse a randomly selected provider until it is rate limited, then switch to another available provider."
},
{
"type": "string"
@@ -1089,7 +1089,7 @@ effect: (ctx) =>
### Sessions
Modify assembled system instructions, messages, tools, or request options immediately before model dispatch.
Modify assembled system instructions, messages, or tools immediately before model dispatch.
```ts
effect: (ctx) =>
@@ -1099,14 +1099,11 @@ effect: (ctx) =>
Effect.sync(() => {
event.system.push({ text: "Keep the review focused on correctness." })
delete event.tools.write
event.options.maxTokens = 8_000
}),
)
}),
```
Title generation runs `title` instead, with the same shape minus `agent` and `tools`.
Modify model request settings and optionally scope the hook to one provider. The event carries the same `kind` as
the HTTP hooks below.
@@ -1181,7 +1178,6 @@ Context-overflow recovery remains separate because it compacts the conversation
```ts
interface SessionHooks {
readonly context: SessionContext
readonly title: SessionTitle
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
@@ -1199,32 +1195,6 @@ interface SessionRetry {
decision: RetryDecision
}
type SessionRequestOptions = {
maxTokens?: number
temperature?: number
topP?: number
topK?: number
frequencyPenalty?: number
presencePenalty?: number
seed?: number
stop?: string[]
} & Record<string, unknown>
interface SessionRequest {
readonly sessionID: string
readonly model: { providerID: string; id: string; variant?: string }
system: SystemPart[]
messages: Message[]
options: SessionRequestOptions
}
interface SessionContext extends SessionRequest {
readonly agent: string
tools: Record<string, { description: string; input: JsonSchema }>
}
interface SessionTitle extends SessionRequest {}
interface SessionHookDomain {
readonly hook: ModelHooks<SessionHooks>
}
@@ -1195,29 +1195,28 @@ Keep prompt hooks retry-safe. They are not an exactly-once side-effect boundary:
#### Model context
Modify assembled system instructions, messages, tools, or request options immediately before model dispatch.
Modify assembled system instructions, messages, tools, generation settings, or provider options immediately before model
dispatch.
```ts
await ctx.session.hook("context", (event) => {
event.system.push({ text: "Keep the review focused on correctness." })
delete event.tools.write
event.options.temperature = 0.2
event.options.maxTokens = 8_000
event.generation.temperature = 0.2
event.generation.maxTokens = 8_000
})
```
Context changes affect only the outgoing model call, not persisted history or
configuration. The hook runs again for subsequent calls such as tool-driven
continuations, transient session generation, and compaction. Title generation
has its own hook.
continuations, transient session generation, and compaction, but not for title requests.
Compaction context hooks receive the selected session agent. Its model-request
and HTTP hooks retain the `compaction` agent identity for provider-specific handling.
Request options follow these rules:
Request overrides follow these rules:
- `options` starts empty for each model call; it does not contain resolved model settings.
- Typed keys are generation settings; any other key is passed to the selected protocol as a provider option.
- `generation` and `providerOptions` start empty for each model call; they do not contain resolved model settings.
- Hooks run in registration order and see overrides made by earlier hooks.
- Request overrides take precedence over model defaults, which take precedence over route defaults.
- Provider option objects merge recursively; arrays and scalar values replace earlier values.
@@ -1233,7 +1232,7 @@ settings to the matching provider. For example, OpenAI Responses uses `reasoning
await ctx.session.hook(
"context",
(event) => {
event.options.reasoningEffort = "high"
event.providerOptions.reasoningEffort = "high"
},
{ providerID: "openai" },
)
@@ -1244,17 +1243,6 @@ Generation options depend on the selected protocol and model:
- `maxTokens` is the semantic output-token limit.
- Gemini supports `topK`; OpenAI Responses does not expose it.
#### Title generation
Title generation is not an agent conversation, so its hook carries no `agent` or `tools`.
```ts
await ctx.session.hook("title", (event) => {
event.system.push({ text: "Titles are at most five words." })
event.options.maxTokens = 32
})
```
#### Model request
Modify model request settings and optionally scope the hook to one provider. The event carries the same `kind`
@@ -1332,7 +1320,6 @@ import type { SessionPrompt } from "@opencode-ai/plugin/promise/session"
interface SessionHooks {
prompt: SessionPrompt
context: SessionContextHook
title: SessionTitleHook
"model.request": SessionModelRequestHook
"http.request": SessionHttpRequestHook
"http.response": SessionHttpResponseHook
@@ -1350,32 +1337,26 @@ interface SessionRetryHook {
decision: RetryDecision
}
type SessionRequestOptions = {
maxTokens?: number
temperature?: number
topP?: number
topK?: number
frequencyPenalty?: number
presencePenalty?: number
seed?: number
stop?: string[]
} & Record<string, unknown>
interface SessionRequestHook {
interface SessionContextHook {
readonly sessionID: string
readonly agent: string
readonly model: { providerID: string; id: string; variant?: string }
system: SystemPart[]
messages: Message[]
options: SessionRequestOptions
}
interface SessionContextHook extends SessionRequestHook {
readonly agent: string
tools: Record<string, { description: string; input: JsonSchema }>
generation: {
maxTokens?: number
temperature?: number
topP?: number
topK?: number
frequencyPenalty?: number
presencePenalty?: number
seed?: number
stop?: string[]
}
providerOptions: Record<string, unknown>
}
interface SessionTitleHook extends SessionRequestHook {}
interface SessionHookContext {
hook<Name extends keyof SessionHooks>(
name: Name,
+20
View File
@@ -298,6 +298,26 @@ Set the maximum number of lines and bytes retained from a tool result.
}
```
### Web search
Use `"random"` to randomly choose a search provider for each session and keep using it until it
returns HTTP 429. OpenCode then retries the query with another available provider.
```jsonc
{
"websearch": {
"provider": "random",
},
}
```
- Rate-limited providers cool down for `Retry-After`, or 60 seconds if it is missing or invalid.
- When every provider is cooling down, the search fails without waiting.
- Each session remembers its preferred provider; cooldowns are shared within a Location.
- State is kept in memory. Moving a session or restarting its Location services resets its preference.
- API and plugin queries without session context share a Location-level preference.
- Set `provider` to a provider ID to disable automatic switching, or set `websearch` to `false` to disable search.
### MCP
Configure local and remote Model Context Protocol servers. Global timeouts can